fix(site-finder): adapt backend egrn_block → frontend ParcelEgrn (#1217)
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

Бэкенд /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
This commit is contained in:
Light1YT 2026-06-13 10:23:49 +05:00
parent 4648e1aee7
commit e2258c39d5
5 changed files with 218 additions and 20 deletions

View file

@ -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) {

View file

@ -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>
);

View 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);
});
});

View file

@ -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": [

View file

@ -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: