From 03e5d07907d5ad25adaebdde908d7f7fc543ae16 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 10:23:49 +0500 Subject: [PATCH] =?UTF-8?q?fix(site-finder):=20adapt=20backend=20egrn=5Fbl?= =?UTF-8?q?ock=20=E2=86=92=20frontend=20ParcelEgrn=20(#1217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд /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 --- .../site-finder/analysis/ExportButtons.tsx | 23 +++- .../analysis/Section1ParcelInfo.tsx | 13 +- frontend/src/lib/__tests__/adaptEgrn.test.ts | 126 ++++++++++++++++++ frontend/src/lib/mocks/parcel-analyze.json | 19 +-- frontend/src/lib/site-finder-api.ts | 57 +++++++- 5 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/__tests__/adaptEgrn.test.ts diff --git a/frontend/src/components/site-finder/analysis/ExportButtons.tsx b/frontend/src/components/site-finder/analysis/ExportButtons.tsx index f522dfb6..f8f09d08 100644 --- a/frontend/src/components/site-finder/analysis/ExportButtons.tsx +++ b/frontend/src/components/site-finder/analysis/ExportButtons.tsx @@ -17,12 +17,22 @@ import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api"; interface Props { cad: string; analyzeData?: ParcelAnalyzeResponse | null; + /** + * Adapted EGRN (frontend ParcelEgrn shape). Passed in by the parent + * (Section1ParcelInfo) which runs `adaptEgrn` on `analyzeData.egrn` to + * translate backend snake_case columns (permitted_use_text / land_category / + * etc.) → frontend labels (vri / category / etc.). Without this, the CSV's + * 9 ЕГРН columns rendered empty in prod (#1217). + */ + egrn?: ParcelEgrn | null; } // ── CSV generation ──────────────────────────────────────────────────────────── -function buildCsvRows(data: ParcelAnalyzeResponse): string[][] { - const egrn = data.egrn as ParcelEgrn | undefined | null; +function buildCsvRows( + data: ParcelAnalyzeResponse, + egrn: ParcelEgrn | null | undefined, +): string[][] { const rows: string[][] = [ ["Поле", "Значение"], ["Кадастровый номер", data.cad_num], @@ -41,7 +51,10 @@ function buildCsvRows(data: ParcelAnalyzeResponse): string[][] { if (egrn) { rows.push( ["Адрес", egrn.address], - ["Площадь м²", String(egrn.area_m2)], + [ + "Площадь м²", + Number.isFinite(egrn.area_m2) ? String(egrn.area_m2) : "", + ], ["ВРИ", egrn.vri], ["Категория", egrn.category], ["Форма собственности", egrn.owner_type], @@ -79,7 +92,7 @@ function generateCsvBlob(rows: string[][]): Blob { // ── Component ───────────────────────────────────────────────────────────────── -export function ExportButtons({ cad, analyzeData }: Props) { +export function ExportButtons({ cad, analyzeData, egrn }: Props) { const [pdfLoading, setPdfLoading] = useState(false); const [csvLoading, setCsvLoading] = useState(false); const [error, setError] = useState(null); @@ -112,7 +125,7 @@ export function ExportButtons({ cad, analyzeData }: Props) { setError("Данные ещё не загружены"); return; } - const rows = buildCsvRows(analyzeData); + const rows = buildCsvRows(analyzeData, egrn); const blob = generateCsvBlob(rows); triggerDownload(blob, `участок-${cad}.csv`); } catch (err) { diff --git a/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx b/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx index 82b482d2..9218e3a7 100644 --- a/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx +++ b/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx @@ -20,6 +20,7 @@ import { EgrnPropertyTable } from "./EgrnPropertyTable"; import { PoiList2Gis } from "./PoiList2Gis"; import { ExportButtons } from "./ExportButtons"; import { + adaptEgrn, useParcelAnalyzeQuery, useParcelPoiScoreQuery, } from "@/lib/site-finder-api"; @@ -183,9 +184,13 @@ export function Section1ParcelInfo({ cad }: Props) { const data = analyzeQuery.data; const poiData = poiQuery.data; - // EGRN: use response field if B5-extended is deployed, else fallback stub - const egrn: ParcelEgrn = - data.egrn != null ? (data.egrn as ParcelEgrn) : buildFallbackEgrn(cad); + // EGRN: adapt backend `egrn_block` (snake_case ЕГРН-cad_parcels columns) into + // the frontend ParcelEgrn shape. Backend ships `permitted_use_text` / + // `land_category` / `ownership_type` / `parcel_status` / `last_egrn_update_date`; + // frontend table & CSV expect `vri` / `category` / `owner_type` / `status` / + // `last_updated`. Without the adapter 6/10 rows of the ЕГРН table render + // empty in prod (#1217). Empty `{}` from backend → null → fallback stub. + const egrn: ParcelEgrn = adaptEgrn(data.egrn, cad) ?? buildFallbackEgrn(cad); // KPI values const areaHa = @@ -303,7 +308,7 @@ export function Section1ParcelInfo({ cad }: Props) { borderTop: "1px solid var(--border-soft)", }} > - + ); diff --git a/frontend/src/lib/__tests__/adaptEgrn.test.ts b/frontend/src/lib/__tests__/adaptEgrn.test.ts new file mode 100644 index 00000000..173c5279 --- /dev/null +++ b/frontend/src/lib/__tests__/adaptEgrn.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for `adaptEgrn` — issue #1217 (P2 prod-bug). + * + * Bug: frontend cast `data.egrn as ParcelEgrn` without mapping; backend + * `egrn_block` ships ЕГРН column names (`permitted_use_text`, `land_category`, + * `ownership_type`, `parcel_status`, `last_egrn_update_date`) but the frontend + * type expects the labelled form (`vri`, `category`, `owner_type`, `status`, + * `last_updated`). 6/10 rows of the ЕГРН table and the matching CSV cells + * rendered empty in prod. The dev mock fixture hid the bug by storing the old + * frontend shape; it's been rewritten alongside this adapter to the real shape. + * + * Fix: `adaptEgrn(raw, cad)` maps backend → frontend; an empty `{}` returns + * `null` so the caller falls back to `buildFallbackEgrn(cad)`. + */ +import { describe, expect, it } from "vitest"; + +import { adaptEgrn } from "../site-finder-api"; + +const CAD = "66:41:0701045:42"; + +describe("adaptEgrn — backend egrn_block → frontend ParcelEgrn (#1217)", () => { + it("maps the real backend egrn_block shape into ParcelEgrn", () => { + // Mirror of backend/app/api/v1/parcels.py:2004-2024 (egrn_block). + const backend = { + cadastral_value_rub: 12_500_000, + cadastral_value_per_m2: 1_517.0, + land_category: "Земли населённых пунктов", + permitted_use_text: "Многоэтажная жилая застройка (МКД)", + last_egrn_update_date: "2025-10-01", + area_m2: 8240, + ownership_type: "Государственная", + right_type: "Постоянное (бессрочное) пользование", + parcel_status: "Учтённый", + address: "Свердловская обл., г. Екатеринбург, ул. Ленина, 42", + registration_date: "2003-07-15", + }; + + const adapted = adaptEgrn(backend, CAD); + + expect(adapted).not.toBeNull(); + expect(adapted).toEqual({ + cad_num: CAD, + address: "Свердловская обл., г. Екатеринбург, ул. Ленина, 42", + area_m2: 8240, + vri: "Многоэтажная жилая застройка (МКД)", + category: "Земли населённых пунктов", + registration_date: "2003-07-15", + owner_type: "Государственная", + // encumbrance lives in a sibling encumbrance_block, not egrn_block. + encumbrance: "—", + status: "Учтённый", + last_updated: "2025-10-01", + }); + }); + + it("returns null for an empty object (backend writes {} on missing row)", () => { + expect(adaptEgrn({}, CAD)).toBeNull(); + }); + + it("returns null for null / undefined / non-object input", () => { + expect(adaptEgrn(null, CAD)).toBeNull(); + expect(adaptEgrn(undefined, CAD)).toBeNull(); + expect(adaptEgrn("not-an-object", CAD)).toBeNull(); + expect(adaptEgrn(42, CAD)).toBeNull(); + }); + + it("defaults missing string fields to em-dash, missing area to NaN", () => { + // Partial backend response (some columns NULL in cad_parcels). + const partial = { + address: null, + area_m2: null, + permitted_use_text: null, + land_category: null, + ownership_type: null, + parcel_status: null, + last_egrn_update_date: null, + registration_date: null, + // Presence guarantees the object isn't empty so we adapt rather than + // returning null (caller's fallback is for a totally missing block). + }; + + const adapted = adaptEgrn(partial, CAD); + + expect(adapted).not.toBeNull(); + expect(adapted?.cad_num).toBe(CAD); + expect(adapted?.address).toBe("—"); + expect(adapted?.vri).toBe("—"); + expect(adapted?.category).toBe("—"); + expect(adapted?.owner_type).toBe("—"); + expect(adapted?.status).toBe("—"); + expect(adapted?.encumbrance).toBe("—"); + expect(adapted?.registration_date).toBeNull(); + expect(adapted?.last_updated).toBeNull(); + expect(Number.isNaN(adapted?.area_m2)).toBe(true); + }); + + it("threads `cad` through (egrn_block has no cad_num column)", () => { + const adapted = adaptEgrn( + { address: "ул. Тестовая, 1", permitted_use_text: "test" }, + "66:00:1234567:89", + ); + expect(adapted?.cad_num).toBe("66:00:1234567:89"); + }); + + it("ignores empty strings the same as missing values", () => { + const adapted = adaptEgrn( + { + address: "", + permitted_use_text: "", + land_category: "Земли населённых пунктов", + }, + CAD, + ); + expect(adapted?.address).toBe("—"); + expect(adapted?.vri).toBe("—"); + expect(adapted?.category).toBe("Земли населённых пунктов"); + }); + + it("rejects non-finite area_m2 (Infinity / NaN from backend)", () => { + const adapted = adaptEgrn( + { area_m2: Number.POSITIVE_INFINITY, address: "x" }, + CAD, + ); + expect(Number.isNaN(adapted?.area_m2)).toBe(true); + }); +}); diff --git a/frontend/src/lib/mocks/parcel-analyze.json b/frontend/src/lib/mocks/parcel-analyze.json index e27c794f..9b3565db 100644 --- a/frontend/src/lib/mocks/parcel-analyze.json +++ b/frontend/src/lib/mocks/parcel-analyze.json @@ -102,16 +102,17 @@ ] }, "egrn": { - "cad_num": "66:41:0701045:42", - "address": "Свердловская обл., г. Екатеринбург, ул. Ленина, 42", + "cadastral_value_rub": null, + "cadastral_value_per_m2": null, + "land_category": "Земли населённых пунктов", + "permitted_use_text": "Многоэтажная жилая застройка (МКД)", + "last_egrn_update_date": "2025-10-01", "area_m2": 8240, - "vri": "Многоэтажная жилая застройка (МКД)", - "category": "Земли населённых пунктов", - "registration_date": "2003-07-15", - "owner_type": "Государственная", - "encumbrance": "Нет", - "status": "Учтённый", - "last_updated": "2025-10-01" + "ownership_type": "Государственная", + "right_type": null, + "parcel_status": "Учтённый", + "address": "Свердловская обл., г. Екатеринбург, ул. Ленина, 42", + "registration_date": "2003-07-15" }, "utilities": { "summary": [ diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index e5dd5cd5..81216859 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -265,6 +265,52 @@ export interface ParcelEgrn { 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 = @@ -311,8 +357,15 @@ export interface ParcelAnalyzeResponse { }> >; poi_count: number; - /** EGRN data — may be null if B5 extended not yet deployed */ - egrn?: ParcelEgrn | null; + /** + * 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: