gendesign/frontend/src/lib/site-finder-api.ts
Light1YT e2258c39d5
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (push) Successful in 49s
CI / frontend-tests (pull_request) Successful in 52s
fix(site-finder): adapt backend egrn_block → frontend ParcelEgrn (#1217)
Бэкенд /analyze отдаёт egrn_block c snake_case-ключами
permitted_use_text/land_category/ownership_type/parcel_status/
last_egrn_update_date (parcels.py:2151-2171), фронт ждёт ParcelEgrn с
vri/category/owner_type/status/last_updated (site-finder-api.ts:255).
Section1ParcelInfo делал `data.egrn as ParcelEgrn` — каст глушит TS;
6 из 10 строк таблицы ЕГРН в prod пустые. ExportButtons тем же кастом
писал пустые ячейки в CSV. egrn={} (truthy) глушил buildFallbackEgrn.
Mock-фикстура была в старой фронт-форме — маскировала баг в dev.

Patch:
- `adaptEgrn(raw, cad)` в site-finder-api.ts: маппит backend-ключи →
  frontend, пустой `{}` → null. Тестируется в изоляции.
- AnalyzeResponse.egrn: ParcelEgrn|null → unknown (wire-shape).
- Section1ParcelInfo: `adaptEgrn(data.egrn, cad) ?? buildFallbackEgrn(cad)`.
- ExportButtons: пропс `egrn` (адаптированный) вместо bad-cast'а.
- mock parcel-analyze.json — на реальную backend-форму.
- 7 vitest-кейсов: backend→frontend mapping, {}→null, null/scalar→null,
  partial nullable→em-dash, threading cad, пустые строки=missing.

80/80 frontend тестов зелёные. tsc + lint clean.

Closes #1217
2026-06-13 10:23:49 +05:00

647 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 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 { 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<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,
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<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;
}
// ── 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],
queryFn: async (): 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" });
// 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++) {
await new Promise((r) => setTimeout(r, ANALYZE_POLL_INTERVAL_MS));
const status = await apiFetch<FetchStatusResponse>(
`/api/v1/parcels/${encodeURIComponent(cad)}/fetch-status`,
);
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" });
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,
});
}