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 {
|
interface Props {
|
||||||
cad: string;
|
cad: string;
|
||||||
analyzeData?: ParcelAnalyzeResponse | null;
|
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 ────────────────────────────────────────────────────────────
|
// ── CSV generation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildCsvRows(data: ParcelAnalyzeResponse): string[][] {
|
function buildCsvRows(
|
||||||
const egrn = data.egrn as ParcelEgrn | undefined | null;
|
data: ParcelAnalyzeResponse,
|
||||||
|
egrn: ParcelEgrn | null | undefined,
|
||||||
|
): string[][] {
|
||||||
const rows: string[][] = [
|
const rows: string[][] = [
|
||||||
["Поле", "Значение"],
|
["Поле", "Значение"],
|
||||||
["Кадастровый номер", data.cad_num],
|
["Кадастровый номер", data.cad_num],
|
||||||
|
|
@ -41,7 +51,10 @@ function buildCsvRows(data: ParcelAnalyzeResponse): string[][] {
|
||||||
if (egrn) {
|
if (egrn) {
|
||||||
rows.push(
|
rows.push(
|
||||||
["Адрес", egrn.address],
|
["Адрес", egrn.address],
|
||||||
["Площадь м²", String(egrn.area_m2)],
|
[
|
||||||
|
"Площадь м²",
|
||||||
|
Number.isFinite(egrn.area_m2) ? String(egrn.area_m2) : "",
|
||||||
|
],
|
||||||
["ВРИ", egrn.vri],
|
["ВРИ", egrn.vri],
|
||||||
["Категория", egrn.category],
|
["Категория", egrn.category],
|
||||||
["Форма собственности", egrn.owner_type],
|
["Форма собственности", egrn.owner_type],
|
||||||
|
|
@ -79,7 +92,7 @@ function generateCsvBlob(rows: string[][]): Blob {
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ExportButtons({ cad, analyzeData }: Props) {
|
export function ExportButtons({ cad, analyzeData, egrn }: Props) {
|
||||||
const [pdfLoading, setPdfLoading] = useState(false);
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
const [csvLoading, setCsvLoading] = useState(false);
|
const [csvLoading, setCsvLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -112,7 +125,7 @@ export function ExportButtons({ cad, analyzeData }: Props) {
|
||||||
setError("Данные ещё не загружены");
|
setError("Данные ещё не загружены");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rows = buildCsvRows(analyzeData);
|
const rows = buildCsvRows(analyzeData, egrn);
|
||||||
const blob = generateCsvBlob(rows);
|
const blob = generateCsvBlob(rows);
|
||||||
triggerDownload(blob, `участок-${cad}.csv`);
|
triggerDownload(blob, `участок-${cad}.csv`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { EgrnPropertyTable } from "./EgrnPropertyTable";
|
||||||
import { PoiList2Gis } from "./PoiList2Gis";
|
import { PoiList2Gis } from "./PoiList2Gis";
|
||||||
import { ExportButtons } from "./ExportButtons";
|
import { ExportButtons } from "./ExportButtons";
|
||||||
import {
|
import {
|
||||||
|
adaptEgrn,
|
||||||
useParcelAnalyzeQuery,
|
useParcelAnalyzeQuery,
|
||||||
useParcelPoiScoreQuery,
|
useParcelPoiScoreQuery,
|
||||||
} from "@/lib/site-finder-api";
|
} from "@/lib/site-finder-api";
|
||||||
|
|
@ -183,9 +184,13 @@ export function Section1ParcelInfo({ cad }: Props) {
|
||||||
const data = analyzeQuery.data;
|
const data = analyzeQuery.data;
|
||||||
const poiData = poiQuery.data;
|
const poiData = poiQuery.data;
|
||||||
|
|
||||||
// EGRN: use response field if B5-extended is deployed, else fallback stub
|
// EGRN: adapt backend `egrn_block` (snake_case ЕГРН-cad_parcels columns) into
|
||||||
const egrn: ParcelEgrn =
|
// the frontend ParcelEgrn shape. Backend ships `permitted_use_text` /
|
||||||
data.egrn != null ? (data.egrn as ParcelEgrn) : buildFallbackEgrn(cad);
|
// `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
|
// KPI values
|
||||||
const areaHa =
|
const areaHa =
|
||||||
|
|
@ -303,7 +308,7 @@ export function Section1ParcelInfo({ cad }: Props) {
|
||||||
borderTop: "1px solid var(--border-soft)",
|
borderTop: "1px solid var(--border-soft)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ExportButtons cad={cad} analyzeData={data} />
|
<ExportButtons cad={cad} analyzeData={data} egrn={egrn} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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": {
|
"egrn": {
|
||||||
"cad_num": "66:41:0701045:42",
|
"cadastral_value_rub": null,
|
||||||
"address": "Свердловская обл., г. Екатеринбург, ул. Ленина, 42",
|
"cadastral_value_per_m2": null,
|
||||||
|
"land_category": "Земли населённых пунктов",
|
||||||
|
"permitted_use_text": "Многоэтажная жилая застройка (МКД)",
|
||||||
|
"last_egrn_update_date": "2025-10-01",
|
||||||
"area_m2": 8240,
|
"area_m2": 8240,
|
||||||
"vri": "Многоэтажная жилая застройка (МКД)",
|
"ownership_type": "Государственная",
|
||||||
"category": "Земли населённых пунктов",
|
"right_type": null,
|
||||||
"registration_date": "2003-07-15",
|
"parcel_status": "Учтённый",
|
||||||
"owner_type": "Государственная",
|
"address": "Свердловская обл., г. Екатеринбург, ул. Ленина, 42",
|
||||||
"encumbrance": "Нет",
|
"registration_date": "2003-07-15"
|
||||||
"status": "Учтённый",
|
|
||||||
"last_updated": "2025-10-01"
|
|
||||||
},
|
},
|
||||||
"utilities": {
|
"utilities": {
|
||||||
"summary": [
|
"summary": [
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,52 @@ export interface ParcelEgrn {
|
||||||
last_updated: string | null;
|
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) ────────────────────────────────────
|
// ── Types: utilities (engineering nearby) ────────────────────────────────────
|
||||||
|
|
||||||
export type UtilitySubtype =
|
export type UtilitySubtype =
|
||||||
|
|
@ -311,8 +357,15 @@ export interface ParcelAnalyzeResponse {
|
||||||
}>
|
}>
|
||||||
>;
|
>;
|
||||||
poi_count: number;
|
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 */
|
/** Engineering / utilities nearby — present when NSPD data available */
|
||||||
utilities?: UtilitiesData | null;
|
utilities?: UtilitiesData | null;
|
||||||
// #999 (958-B4) — рыночные слои для карты Site Finder. Все optional:
|
// #999 (958-B4) — рыночные слои для карты Site Finder. Все optional:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue