fix(site-finder): adapt backend egrn_block → frontend ParcelEgrn (#1217) #1253
5 changed files with 218 additions and 20 deletions
|
|
@ -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<string | null>(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) {
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
}}
|
||||
>
|
||||
<ExportButtons cad={cad} analyzeData={data} />
|
||||
<ExportButtons cad={cad} analyzeData={data} egrn={egrn} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
126
frontend/src/lib/__tests__/adaptEgrn.test.ts
Normal file
126
frontend/src/lib/__tests__/adaptEgrn.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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<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 =
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue