/** * 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; area_ha: number; 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; area_ha: number; 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) => { if (filters.min_area != null && p.area_ha < filters.min_area) return false; if (filters.max_area != 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 => { 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, area_ha: p.area_m2 != null ? p.area_m2 / 10000 : 0, status: p.status ?? "free", district: "—", vri: "other", address: "", })); // Apply filters client-side until backend supports them (B1 follow-up) return applyFilters(adapted, filters); }, enabled: MOCK_PARCELS_BBOX || bbox != null, staleTime: 30_000, }); } // ── Hook: useRecentParcels ──────────────────────────────────────────────────── interface RecentParcelsResponse { parcels: RecentParcel[]; } export function useRecentParcels() { return useQuery({ queryKey: ["recent-parcels"], queryFn: async (): Promise => { const localItems = getLocalRecentParcels(); if (MOCK_RECENT_PARCELS) { return localItems; } try { const serverData = await apiFetch( "/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; // 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 => { 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( `/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 => { 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( `/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 { return apiFetch("/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 => { 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( `/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, }); }