- #1338 concepts.py: geometry.generate вынесен в run_in_threadpool (не блокирует event loop) - #1363 house_imv_backfill.py + scrape_pipeline.py: периодический heartbeat в долгих IMV-фазах (reap_zombies не помечает живой sweep zombie) - #1419 site-finder-api.ts: placeholderData keepPreviousData для bbox-запроса (KPI не мигает «0/0» при пане/зуме) - #1421 site-finder-api.ts: area_ha nullable + адаптер не подставляет 0; null-гейт потребителей EntryMap.tsx, ParcelDrawer.tsx - #1424/#1480 EstimateResult.tsx: блок «Параметры объекта» скрыт при восстановлении по shared-ссылке (нет мусора «0 м²/Студия/Этаж 0 из 0») Верификация: tsc --noEmit 0 ошибок; py_compile OK.
703 lines
26 KiB
TypeScript
703 lines
26 KiB
TypeScript
/**
|
||
* Site Finder API hooks — TanStack Query wrappers.
|
||
*
|
||
* Mock fallback strategy (from mock-toggle.ts):
|
||
* MOCK_PARCELS_BBOX — uses parcels-bbox.json fixture (B1 not yet in prod)
|
||
* MOCK_RECENT_PARCELS — uses localStorage only (B2 stub not yet in prod)
|
||
* MOCK_ANALYZE — uses parcel-analyze.json fixture (B5)
|
||
* MOCK_POI_SCORE — uses poi-score.json fixture (B6)
|
||
*/
|
||
|
||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||
import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api";
|
||
import { abortableSleep } from "@/lib/abortableSleep";
|
||
import type {
|
||
AnalyzeAcceptedResponse,
|
||
FetchStatusResponse,
|
||
} from "@/hooks/useSiteAnalysis";
|
||
import {
|
||
MOCK_PARCELS_BBOX,
|
||
MOCK_RECENT_PARCELS,
|
||
MOCK_ANALYZE,
|
||
MOCK_POI_SCORE,
|
||
} from "@/lib/mock-toggle";
|
||
import fixtureParcels from "@/lib/mocks/parcels-bbox.json";
|
||
import fixtureAnalyze from "@/lib/mocks/parcel-analyze.json";
|
||
import fixturePoiScore from "@/lib/mocks/poi-score.json";
|
||
import fixtureForecast from "@/lib/mocks/parcel-forecast.json";
|
||
import type { ForecastEnvelope } from "@/types/forecast";
|
||
import type {
|
||
ParcelAnalysisCompetitor,
|
||
Pipeline24mo,
|
||
} from "@/types/site-finder";
|
||
import type {
|
||
NspdZouitOverlap,
|
||
OpportunityParcel,
|
||
RedLine,
|
||
RiskZone,
|
||
} from "@/types/nspd";
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
|
||
export type ParcelStatus = "free" | "in_progress" | "favorite";
|
||
|
||
export type ParcelVri =
|
||
| "multistory"
|
||
| "mixed"
|
||
| "individual"
|
||
| "office"
|
||
| "other";
|
||
|
||
export interface ParcelBboxItem {
|
||
cad_num: string;
|
||
address: string;
|
||
// #1421: nullable — the B1 by-bbox payload may omit area_m2; null означает
|
||
// «н/д», в отличие от реального 0 га. Потребители обязаны гейтить null.
|
||
area_ha: number | null;
|
||
status: ParcelStatus;
|
||
district: string;
|
||
vri: ParcelVri;
|
||
lat: number;
|
||
lon: number;
|
||
}
|
||
|
||
export interface ParcelBboxFilters {
|
||
min_area?: number;
|
||
max_area?: number;
|
||
status?: ParcelStatus;
|
||
district?: string;
|
||
vri?: ParcelVri;
|
||
}
|
||
|
||
export interface BboxCoords {
|
||
minLat: number;
|
||
minLon: number;
|
||
maxLat: number;
|
||
maxLon: number;
|
||
}
|
||
|
||
export interface RecentParcel {
|
||
cad_num: string;
|
||
address: string;
|
||
// #1421: nullable — площадь может быть неизвестна; null → «н/д», не «0 га».
|
||
area_ha: number | null;
|
||
district: string;
|
||
visited_at: string; // ISO string
|
||
}
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||
|
||
const LOCAL_STORAGE_KEY = "gd_recent_parcels";
|
||
const MAX_RECENT = 10;
|
||
|
||
// ── localStorage helpers ──────────────────────────────────────────────────────
|
||
|
||
export function getLocalRecentParcels(): RecentParcel[] {
|
||
if (typeof window === "undefined") return [];
|
||
try {
|
||
const raw = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||
if (!raw) return [];
|
||
const parsed: unknown = JSON.parse(raw);
|
||
if (!Array.isArray(parsed)) return [];
|
||
return parsed as RecentParcel[];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export function addLocalRecentParcel(parcel: RecentParcel): void {
|
||
if (typeof window === "undefined") return;
|
||
try {
|
||
const existing = getLocalRecentParcels();
|
||
const filtered = existing.filter((p) => p.cad_num !== parcel.cad_num);
|
||
const updated = [parcel, ...filtered].slice(0, MAX_RECENT);
|
||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updated));
|
||
} catch {
|
||
// ignore localStorage errors
|
||
}
|
||
}
|
||
|
||
// ── Hook: useParcelsBboxQuery ─────────────────────────────────────────────────
|
||
|
||
function applyFilters(
|
||
parcels: ParcelBboxItem[],
|
||
filters: ParcelBboxFilters,
|
||
): ParcelBboxItem[] {
|
||
return parcels.filter((p) => {
|
||
// #1421: area_ha теперь nullable («н/д»). Площадной фильтр применяем только
|
||
// к участкам с известной площадью; null проходит мимо min/max гейтов.
|
||
if (
|
||
filters.min_area != null &&
|
||
p.area_ha != null &&
|
||
p.area_ha < filters.min_area
|
||
)
|
||
return false;
|
||
if (
|
||
filters.max_area != null &&
|
||
p.area_ha != null &&
|
||
p.area_ha > filters.max_area
|
||
)
|
||
return false;
|
||
if (filters.status != null && p.status !== filters.status) return false;
|
||
if (
|
||
filters.district != null &&
|
||
filters.district !== "" &&
|
||
p.district !== filters.district
|
||
)
|
||
return false;
|
||
if (filters.vri != null && filters.vri !== "other" && p.vri !== filters.vri)
|
||
return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function filterByBbox(
|
||
parcels: ParcelBboxItem[],
|
||
bbox: BboxCoords,
|
||
): ParcelBboxItem[] {
|
||
return parcels.filter(
|
||
(p) =>
|
||
p.lat >= bbox.minLat &&
|
||
p.lat <= bbox.maxLat &&
|
||
p.lon >= bbox.minLon &&
|
||
p.lon <= bbox.maxLon,
|
||
);
|
||
}
|
||
|
||
export function useParcelsBboxQuery(
|
||
bbox: BboxCoords | null,
|
||
filters: ParcelBboxFilters = {},
|
||
) {
|
||
return useQuery({
|
||
queryKey: [
|
||
"parcels-bbox",
|
||
bbox?.minLat,
|
||
bbox?.minLon,
|
||
bbox?.maxLat,
|
||
bbox?.maxLon,
|
||
filters.min_area,
|
||
filters.max_area,
|
||
filters.status,
|
||
filters.district,
|
||
filters.vri,
|
||
],
|
||
queryFn: async (): Promise<ParcelBboxItem[]> => {
|
||
if (MOCK_PARCELS_BBOX) {
|
||
// Fixture: filter by bbox + filters client-side
|
||
const typed = fixtureParcels as ParcelBboxItem[];
|
||
const inBbox = bbox ? filterByBbox(typed, bbox) : typed;
|
||
return applyFilters(inBbox, filters);
|
||
}
|
||
|
||
if (!bbox) return [];
|
||
|
||
const params = new URLSearchParams({
|
||
min_lat: String(bbox.minLat),
|
||
min_lon: String(bbox.minLon),
|
||
max_lat: String(bbox.maxLat),
|
||
max_lon: String(bbox.maxLon),
|
||
});
|
||
if (filters.min_area != null)
|
||
params.set("min_area", String(filters.min_area));
|
||
if (filters.max_area != null)
|
||
params.set("max_area", String(filters.max_area));
|
||
if (filters.status) params.set("status", filters.status);
|
||
if (filters.district) params.set("district", filters.district);
|
||
if (filters.vri) params.set("vri", filters.vri);
|
||
|
||
// Backend returns { parcels, count, limit, bbox_area_km2 } with field
|
||
// names centroid_lat/centroid_lon/area_m2 and nullable status. Adapt to
|
||
// the flat ParcelBboxItem[] shape the map components consume. district /
|
||
// vri / address are not in B1 yet — defaulted until enrichment lands.
|
||
const raw = await apiFetch<{
|
||
parcels: Array<{
|
||
cad_num: string;
|
||
centroid_lat: number;
|
||
centroid_lon: number;
|
||
area_m2: number | null;
|
||
land_category: string | null;
|
||
status: ParcelStatus | null;
|
||
}>;
|
||
}>(`/api/v1/parcels/by-bbox?${params.toString()}`);
|
||
|
||
const adapted: ParcelBboxItem[] = raw.parcels.map((p) => ({
|
||
cad_num: p.cad_num,
|
||
lat: p.centroid_lat,
|
||
lon: p.centroid_lon,
|
||
// #1421: keep null when area_m2 is missing — не приземляем в 0, иначе
|
||
// карточка показывает «0 га» вместо «н/д».
|
||
area_ha: p.area_m2 != null ? p.area_m2 / 10000 : null,
|
||
status: p.status ?? "free",
|
||
district: "—",
|
||
vri: "other",
|
||
address: "",
|
||
}));
|
||
// Apply filters client-side until backend supports them (B1 follow-up).
|
||
// district/vri are NOT in the B1 by-bbox payload — every adapted parcel
|
||
// carries the placeholders district:"—" / vri:"other". Filtering on those
|
||
// client-side would wipe the whole map the moment the user picks any real
|
||
// district or VRI (none of the UI values equal "—"/"other"), so omit those
|
||
// two filter keys here. Площадь/статус — реальные данные, фильтруем их.
|
||
const areaStatusFilters: ParcelBboxFilters = {
|
||
min_area: filters.min_area,
|
||
max_area: filters.max_area,
|
||
status: filters.status,
|
||
};
|
||
return applyFilters(adapted, areaStatusFilters);
|
||
},
|
||
enabled: MOCK_PARCELS_BBOX || bbox != null,
|
||
staleTime: 30_000,
|
||
// #1419: bbox is part of the queryKey, so every pan/zoom is a fresh cache
|
||
// entry and `data` would blank to `undefined` mid-refetch → KPI flickers to
|
||
// «0 / 0». Hold the previous parcels on screen while the new bbox refetches.
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
}
|
||
|
||
// ── Hook: useRecentParcels ────────────────────────────────────────────────────
|
||
|
||
interface RecentParcelsResponse {
|
||
parcels: RecentParcel[];
|
||
}
|
||
|
||
export function useRecentParcels() {
|
||
return useQuery({
|
||
queryKey: ["recent-parcels"],
|
||
queryFn: async (): Promise<RecentParcel[]> => {
|
||
const localItems = getLocalRecentParcels();
|
||
|
||
if (MOCK_RECENT_PARCELS) {
|
||
return localItems;
|
||
}
|
||
|
||
try {
|
||
const serverData = await apiFetch<RecentParcelsResponse>(
|
||
"/api/v1/users/me/recent-parcels",
|
||
);
|
||
// Merge: server list + local items not already in server list
|
||
const serverCads = new Set(serverData.parcels.map((p) => p.cad_num));
|
||
const localOnly = localItems.filter((p) => !serverCads.has(p.cad_num));
|
||
return [...serverData.parcels, ...localOnly].slice(0, MAX_RECENT);
|
||
} catch {
|
||
// B2 stub may not be deployed yet — fall back to localStorage
|
||
return localItems;
|
||
}
|
||
},
|
||
staleTime: 60_000,
|
||
});
|
||
}
|
||
|
||
// ── Types: B5 extended analyze response ──────────────────────────────────────
|
||
|
||
export interface ParcelEgrn {
|
||
cad_num: string;
|
||
address: string;
|
||
area_m2: number;
|
||
vri: string;
|
||
category: string;
|
||
registration_date: string | null;
|
||
owner_type: string;
|
||
encumbrance: string;
|
||
status: string;
|
||
last_updated: string | null;
|
||
}
|
||
|
||
// ── Adapter: backend egrn_block → frontend ParcelEgrn (issue #1217) ──────────
|
||
|
||
/**
|
||
* Adapt the backend `egrn` block (see `backend/app/api/v1/parcels.py:2004-2024`)
|
||
* into the frontend's `ParcelEgrn` shape. Backend column names trace to ЕГРН's
|
||
* cad_parcels table (`permitted_use_established_by_document`, `ownership_type`,
|
||
* `status`, `cost_registration_date`, `land_record_category_type`), so the
|
||
* frontend type — designed around the spec'd table labels — drifted. Without
|
||
* this mapping 6/10 rows in the ЕГРН table and the matching CSV cells render
|
||
* empty in prod (#1217, P2).
|
||
*
|
||
* Encumbrance and cad_num are NOT in `egrn_block`:
|
||
* - encumbrance is its own `encumbrance_block` (ЗОУИТ derived) → defaulted "—".
|
||
* - cad_num comes from the route/caller, threaded in via the `cad` argument.
|
||
*
|
||
* Returns `null` for an empty object `{}` or non-object input — the call-site
|
||
* substitutes `buildFallbackEgrn(cad)`.
|
||
*/
|
||
export function adaptEgrn(raw: unknown, cad: string): ParcelEgrn | null {
|
||
if (raw == null || typeof raw !== "object") return null;
|
||
const src = raw as Record<string, unknown>;
|
||
// Empty object → "no data" (backend writes `{}` when cad_parcels row is missing).
|
||
if (Object.keys(src).length === 0) return null;
|
||
|
||
const str = (v: unknown): string | null =>
|
||
typeof v === "string" && v.length > 0 ? v : null;
|
||
const num = (v: unknown): number | null =>
|
||
typeof v === "number" && Number.isFinite(v) ? v : null;
|
||
|
||
return {
|
||
cad_num: cad,
|
||
address: str(src.address) ?? "—",
|
||
area_m2: num(src.area_m2) ?? NaN,
|
||
vri: str(src.permitted_use_text) ?? "—",
|
||
category: str(src.land_category) ?? "—",
|
||
registration_date: str(src.registration_date),
|
||
owner_type: str(src.ownership_type) ?? "—",
|
||
// encumbrance lives in a sibling `encumbrance_block`, not in `egrn_block`.
|
||
// Section 1 renders ЗОУИТ-derived encumbrance separately; keep "—" here so
|
||
// the ЕГРН table doesn't claim "Нет" when we simply don't know.
|
||
encumbrance: "—",
|
||
status: str(src.parcel_status) ?? "—",
|
||
last_updated: str(src.last_egrn_update_date),
|
||
};
|
||
}
|
||
|
||
// ── Types: utilities (engineering nearby) ────────────────────────────────────
|
||
|
||
export type UtilitySubtype =
|
||
| "substation"
|
||
| "pipeline"
|
||
| "power_line"
|
||
| "water_intake"
|
||
| "pumping_station"
|
||
| string; // open for future subtypes
|
||
|
||
export interface UtilitySummaryItem {
|
||
subtype: UtilitySubtype;
|
||
nearest_m: number;
|
||
name: string | null;
|
||
count_within_2km: number;
|
||
}
|
||
|
||
export interface UtilitiesData {
|
||
summary: UtilitySummaryItem[];
|
||
power_line_охранная_зона_flag: boolean;
|
||
note: string | null;
|
||
}
|
||
|
||
export interface ParcelAnalyzeResponse {
|
||
cad_num: string;
|
||
source: string;
|
||
geom_geojson: unknown;
|
||
district: {
|
||
district_name: string;
|
||
median_price_per_m2: number;
|
||
dist_to_center: number;
|
||
} | null;
|
||
score: number;
|
||
score_label?: string;
|
||
score_explanation?: string;
|
||
score_breakdown: Record<
|
||
string,
|
||
Array<{
|
||
name: string | null;
|
||
distance_m: number;
|
||
last_edit: string | null;
|
||
lat: number;
|
||
lon: number;
|
||
}>
|
||
>;
|
||
poi_count: number;
|
||
/**
|
||
* EGRN data — raw backend `egrn_block` (snake_case ЕГРН-cad_parcels columns,
|
||
* see `backend/app/api/v1/parcels.py:2004-2024`). Use `adaptEgrn()` to narrow
|
||
* into the frontend `ParcelEgrn` shape before rendering. Typed as `unknown`
|
||
* because the wire shape (`permitted_use_text` / `land_category` / …) doesn't
|
||
* match `ParcelEgrn` (`vri` / `category` / …) — the un-narrowed cast was the
|
||
* root cause of #1217. May be `null`/missing if B5-extended hasn't shipped.
|
||
*/
|
||
egrn?: unknown;
|
||
/** Engineering / utilities nearby — present when NSPD data available */
|
||
utilities?: UtilitiesData | null;
|
||
// #999 (958-B4) — рыночные слои для карты Site Finder. Все optional:
|
||
// отражают graceful 202-стаб (ещё нет данных) и тонкие ответы (нет
|
||
// конкурентов / пустой pipeline / нет risk-зон).
|
||
/** Конкуренты в радиусе 3 км (DOM.РФ + objective_lots), несут lat/lon. */
|
||
competitors?: ParcelAnalysisCompetitor[];
|
||
/** Будущие проекты 24 мес (D4 #36); top_objects несут lat/lon. */
|
||
pipeline_24mo?: Pipeline24mo | null;
|
||
/** Риск-зоны НСПД (TIER 3 #94) — geom_wkt; часто пустой массив. */
|
||
nspd_risk_zones?: RiskZone[] | null;
|
||
// §12.1-13 (#958) — карта-слои граддок/перспективного предложения. Все
|
||
// optional: backend сериализует их в /analyze (parcels.py), но дамп квартала
|
||
// может быть не загружен → пустой массив (graceful), либо тонкий 202-стаб.
|
||
/** TIER 4 opportunity-ЗУ (#94): auction/scheme/free/future/oopt — geom_wkt. */
|
||
nspd_opportunity_parcels?: OpportunityParcel[] | null;
|
||
/** TIER 4 красные линии застройки (#94, граддок) — geom_wkt (LINESTRING). */
|
||
nspd_red_lines?: RedLine[] | null;
|
||
/**
|
||
* #255 — ЗОУИТ overlaps (зоны с особыми условиями использования). Бэкенд уже
|
||
* отдаёт их в /analyze (Section4Estimate читает через cast в ParcelAnalysis);
|
||
* объявляем здесь, чтобы MiniMap-адаптер пробрасывал их в ЗОУИТ-слой карты без
|
||
* cast. geom_geojson на каждом overlap — для отрисовки полигонов (graceful,
|
||
* если backend ещё не добавил поле).
|
||
*/
|
||
nspd_zouit_overlaps?: NspdZouitOverlap[] | null;
|
||
}
|
||
|
||
// ── Types: B6 poi-score response ─────────────────────────────────────────────
|
||
|
||
export interface PoiScoreItem {
|
||
category: string;
|
||
name: string;
|
||
distance_m: number;
|
||
weight: number;
|
||
score_contribution: number;
|
||
}
|
||
|
||
export interface PoiScoreResponse {
|
||
cad_num: string;
|
||
poi_weighted_score: number;
|
||
items: PoiScoreItem[];
|
||
}
|
||
|
||
// ── Hook: useParcelAnalyzeQuery (B5) ─────────────────────────────────────────
|
||
|
||
// #93 on-demand NSPD fetch: POST /analyze returns 202 for any parcel whose
|
||
// geometry isn't cached yet. We must poll /fetch-status and re-POST once ready,
|
||
// mirroring useSiteAnalysis — otherwise the 202 stub (no score/competitors)
|
||
// reaches the render tree and crashes Section 3/4 (#1001, 958-B5).
|
||
const ANALYZE_POLL_INTERVAL_MS = 2000;
|
||
const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap
|
||
|
||
export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
||
return useQuery({
|
||
queryKey: ["parcel-analyze", cad, horizon],
|
||
// TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts
|
||
// on unmount and whenever the queryKey changes (смена cad/horizon). Thread
|
||
// it through the POST/GET fetches and check it before each poll iteration so
|
||
// the loop dies immediately instead of zombie-polling /fetch-status + re-
|
||
// POSTing /analyze up to the 2-min hard cap (#1248).
|
||
queryFn: async ({ signal }): Promise<ParcelAnalyzeResponse> => {
|
||
if (MOCK_ANALYZE) {
|
||
// Return fixture data regardless of cad/horizon — for dev only
|
||
return fixtureAnalyze as ParcelAnalyzeResponse;
|
||
}
|
||
|
||
// Backend accepts ?horizon= ∈ {6,12,18} (default 12; 422 otherwise, #995).
|
||
const analyzeUrl = `/api/v1/parcels/${encodeURIComponent(
|
||
cad,
|
||
)}/analyze?horizon=${horizon}`;
|
||
|
||
// First request — POST /analyze. apiFetchWithStatus surfaces the 202
|
||
// Accepted code instead of treating it as a successful payload.
|
||
const first = await apiFetchWithStatus<
|
||
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
||
>(analyzeUrl, { method: "POST", signal });
|
||
|
||
// 200 → geometry was cached, full analysis is ready.
|
||
if (first.status === 200) {
|
||
return first.body as ParcelAnalyzeResponse;
|
||
}
|
||
|
||
// 202 Accepted → geometry is being fetched from НСПД. Poll /fetch-status
|
||
// every 2s; once ready re-POST /analyze (now 200). The query stays
|
||
// pending throughout, so the page's "Анализируем участок…" screen shows.
|
||
if (first.status === 202) {
|
||
for (let i = 0; i < ANALYZE_POLL_MAX_ITERATIONS; i++) {
|
||
// abortableSleep wakes immediately on abort; then bail before issuing
|
||
// any further requests (signal also aborts the in-flight fetches).
|
||
await abortableSleep(ANALYZE_POLL_INTERVAL_MS, signal);
|
||
if (signal.aborted) {
|
||
throw new DOMException("Анализ отменён", "AbortError");
|
||
}
|
||
const status = await apiFetch<FetchStatusResponse>(
|
||
`/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`,
|
||
{ signal },
|
||
);
|
||
if (status.status === "ready") {
|
||
// Re-POST should now be 200. If it races back to 202, keep polling
|
||
// rather than returning the stub (symmetry with the first request).
|
||
const second = await apiFetchWithStatus<
|
||
ParcelAnalyzeResponse | AnalyzeAcceptedResponse
|
||
>(analyzeUrl, { method: "POST", signal });
|
||
if (second.status === 200) {
|
||
return second.body as ParcelAnalyzeResponse;
|
||
}
|
||
// 202 race → fall through and poll /fetch-status again.
|
||
}
|
||
if (status.status === "not_in_nspd") {
|
||
throw new HTTPError(
|
||
404,
|
||
status,
|
||
status.error_msg ?? "Кадастровый номер не найден в НСПД",
|
||
);
|
||
}
|
||
if (status.status === "failed") {
|
||
throw new HTTPError(
|
||
503,
|
||
status,
|
||
status.error_msg ?? "НСПД временно недоступен",
|
||
);
|
||
}
|
||
if (status.status === "invalid_format") {
|
||
throw new HTTPError(
|
||
400,
|
||
status,
|
||
status.error_msg ?? "Неверный формат кадастрового номера",
|
||
);
|
||
}
|
||
// status === "fetching" → continue polling
|
||
}
|
||
throw new Error(
|
||
"Загрузка длится слишком долго (>2 мин). Попробуйте позже.",
|
||
);
|
||
}
|
||
|
||
throw new Error(`Неожиданный статус ответа: ${first.status}`);
|
||
},
|
||
staleTime: 5 * 60_000, // 5 min — analyze is expensive
|
||
// The queryFn polls internally; a thrown terminal error (404/503/400/
|
||
// timeout) is final and must NOT restart the whole poll loop via retry.
|
||
retry: false,
|
||
// Horizon is in the queryKey, so switching it is a fresh cache entry. Keep
|
||
// the previous analysis on screen while the new horizon re-fetches instead
|
||
// of blanking the whole page to the loading state on every selector change.
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
}
|
||
|
||
// ── Hook: useParcelPoiScoreQuery (B6) ────────────────────────────────────────
|
||
|
||
/**
|
||
* Raw shape from backend /api/v1/parcels/{cad}/poi-score.
|
||
* Backend returns top_poi array with address field (not score_contribution).
|
||
* We adapt to PoiScoreResponse so downstream components are stable.
|
||
*/
|
||
interface PoiScoreRaw {
|
||
cad_num: string;
|
||
radius_m: number;
|
||
top_poi: Array<{
|
||
name: string;
|
||
category: string;
|
||
distance_m: number;
|
||
weight: number;
|
||
address?: string | null;
|
||
}>;
|
||
}
|
||
|
||
export function useParcelPoiScoreQuery(cad: string) {
|
||
return useQuery({
|
||
queryKey: ["parcel-poi-score", cad],
|
||
queryFn: async (): Promise<PoiScoreResponse> => {
|
||
if (MOCK_POI_SCORE) {
|
||
return fixturePoiScore as PoiScoreResponse;
|
||
}
|
||
// Backend returns {cad_num, radius_m, top_poi: [{name,category,distance_m,weight,address}]}
|
||
// Adapt to PoiScoreResponse {cad_num, poi_weighted_score, items} for stable consumer API.
|
||
const raw = await apiFetch<PoiScoreRaw>(
|
||
`/api/v1/parcels/${encodeURIComponent(cad)}/poi-score`,
|
||
);
|
||
const items: PoiScoreItem[] = (raw.top_poi ?? []).map((p) => ({
|
||
category: p.category,
|
||
name: p.name,
|
||
distance_m: p.distance_m,
|
||
weight: p.weight,
|
||
// score_contribution not in backend response — derive from weight (0..1) × 100
|
||
score_contribution: Math.round(p.weight * 100),
|
||
}));
|
||
const poi_weighted_score = items.reduce(
|
||
(s, p) => s + p.score_contribution,
|
||
0,
|
||
);
|
||
return { cad_num: raw.cad_num, poi_weighted_score, items };
|
||
},
|
||
staleTime: 5 * 60_000,
|
||
retry: 1,
|
||
});
|
||
}
|
||
|
||
// ── Chat: POST /api/v1/chat/ask (#958) ───────────────────────────────────────
|
||
|
||
/**
|
||
* Parcel-chat contract (backend shipped, deployed). The endpoint is behind
|
||
* Caddy auth + RBAC (any authenticated user). Types are defined inline rather
|
||
* than via `npm run codegen` because codegen targets a live OpenAPI on
|
||
* localhost:8000 (`src/lib/api-types.ts`) which is unreachable in this env;
|
||
* these mirror the documented response exactly.
|
||
*/
|
||
export type ChatIntent =
|
||
| "summary"
|
||
| "what_to_build"
|
||
| "why_forecast"
|
||
| "risks"
|
||
| "scenarios"
|
||
| "unknown";
|
||
|
||
export interface ChatMessage {
|
||
role: "user" | "assistant";
|
||
content: string;
|
||
}
|
||
|
||
export interface ChatAskRequest {
|
||
cad_num: string;
|
||
message: string;
|
||
intent?: ChatIntent;
|
||
/** Recent turns for context — caller caps the history length. */
|
||
history?: ChatMessage[];
|
||
}
|
||
|
||
export interface ChatGroundedIn {
|
||
run_id: number;
|
||
schema_version: string;
|
||
sections: string[];
|
||
}
|
||
|
||
/** "ready" → forecast exists; "pending" → answer is a "run analysis first" msg. */
|
||
export type ChatReportStatus = "ready" | "pending";
|
||
|
||
export interface ChatAskResponse {
|
||
answer: string;
|
||
grounded_in: ChatGroundedIn | null;
|
||
llm_used: boolean;
|
||
fallback_reason: string | null;
|
||
advisory: boolean;
|
||
report_status: ChatReportStatus;
|
||
}
|
||
|
||
/**
|
||
* POST /api/v1/chat/ask — ask a grounded question about a parcel's analysis.
|
||
* Action (not a query) → drive from a TanStack `useMutation` in the component.
|
||
* Uses the shared apiFetch (base URL + session header + Content-Type) per the
|
||
* "don't duplicate fetch config" rule.
|
||
*/
|
||
export function postChatAsk(req: ChatAskRequest): Promise<ChatAskResponse> {
|
||
return apiFetch<ChatAskResponse>("/api/v1/chat/ask", {
|
||
method: "POST",
|
||
body: JSON.stringify(req),
|
||
});
|
||
}
|
||
|
||
// ── Hook: useParcelForecastQuery (958-B3 / §22) ──────────────────────────────
|
||
|
||
/**
|
||
* Polls GET /api/v1/parcels/{cad}/forecast until the async §22 forecast is
|
||
* ready. The forecast is enqueued fire-and-forget by useParcelAnalyzeQuery on
|
||
* page mount (POST /analyze) — this hook does NOT trigger it, only reads.
|
||
*
|
||
* Endpoint contract:
|
||
* 200 → { status: "ready", run_id, created_at, report }
|
||
* 202 → { status: "pending" } (also returned gracefully on any error — never 404/500)
|
||
*
|
||
* Polls every 4s while pending; stops once status === "ready". Mock mode
|
||
* (MOCK_ANALYZE) returns the committed prod fixture immediately.
|
||
*/
|
||
export function useParcelForecastQuery(cad: string) {
|
||
return useQuery({
|
||
queryKey: ["parcel-forecast", cad],
|
||
queryFn: async (): Promise<ForecastEnvelope> => {
|
||
if (MOCK_ANALYZE) {
|
||
// Fixture is raw JSON (loose shape: factors carries a stray
|
||
// `advisory_capped` boolean) — cast via unknown, runtime guards in the
|
||
// confidence block narrow factor entries.
|
||
return fixtureForecast as unknown as ForecastEnvelope;
|
||
}
|
||
// apiFetchWithStatus tolerates the 202 path (Accepted) without throwing.
|
||
const { body } = await apiFetchWithStatus<ForecastEnvelope>(
|
||
`/api/v1/parcels/${encodeURIComponent(cad)}/forecast`,
|
||
);
|
||
return body;
|
||
},
|
||
enabled: !!cad,
|
||
// Stop polling once ready; otherwise re-poll every 4s while pending.
|
||
refetchInterval: (query) =>
|
||
query.state.data?.status === "ready" ? false : 4000,
|
||
retry: 1,
|
||
});
|
||
}
|