diff --git a/data/sql/174_domrf_kn_flats_obj_snapshot_idx.sql b/data/sql/174_domrf_kn_flats_obj_snapshot_idx.sql new file mode 100644 index 00000000..f0a9d993 --- /dev/null +++ b/data/sql/174_domrf_kn_flats_obj_snapshot_idx.sql @@ -0,0 +1,35 @@ +-- 174_domrf_kn_flats_obj_snapshot_idx.sql +-- +-- Composite index on domrf_kn_flats (obj_id, snapshot_date DESC). +-- +-- Serves the `flats_latest` CTE in best_layouts.py (#1956 supply batch query, +-- backend/app/services/site_finder/best_layouts.py:_SUPPLY_BATCH_SQL): +-- +-- flats_latest AS ( +-- SELECT DISTINCT ON (f.obj_id) f.obj_id, f.snapshot_date +-- FROM domrf_kn_flats f +-- JOIN nearby n ON n.obj_id = f.obj_id +-- ORDER BY f.obj_id, f.snapshot_date DESC, f.id DESC +-- ) +-- ... JOIN domrf_kn_flats f ON f.obj_id = fl.obj_id +-- AND f.snapshot_date = fl.snapshot_date +-- +-- domrf_kn_flats is a per-object time-series (UNIQUE (obj_id, snapshot_date)); +-- the DISTINCT ON + ORDER BY obj_id, snapshot_date DESC picks each object's +-- newest snapshot, and the self-join re-fetches that snapshot's rows by +-- (obj_id, snapshot_date). The only existing index covering obj_id is +-- idx_kn_flats_obj (obj_id) alone, so Postgres sorts per object on +-- snapshot_date instead of reading it pre-ordered. A composite +-- (obj_id, snapshot_date DESC) lets DISTINCT ON walk the index and lets the +-- join probe by (obj_id, snapshot_date) directly. +-- +-- Prod (2026-06-28): domrf_kn_flats = 789 569 rows; no (obj_id, snapshot_date) +-- index present. DRY-RUN (BEGIN; CREATE INDEX; ROLLBACK) succeeded — index size +-- ~5.7 MB, builds instantly. Idempotent: IF NOT EXISTS. + +BEGIN; + +CREATE INDEX IF NOT EXISTS idx_kn_flats_obj_snap + ON domrf_kn_flats (obj_id, snapshot_date DESC); + +COMMIT; diff --git a/frontend/src/components/site-finder/analysis/EgrnPropertyTable.tsx b/frontend/src/components/site-finder/analysis/EgrnPropertyTable.tsx index 8ac689bb..855eaa81 100644 --- a/frontend/src/components/site-finder/analysis/EgrnPropertyTable.tsx +++ b/frontend/src/components/site-finder/analysis/EgrnPropertyTable.tsx @@ -1,5 +1,6 @@ /** - * EgrnPropertyTable — 2-column key-value table of 10 EGRN properties. + * EgrnPropertyTable — 2-column key-value table of EGRN properties (the + * "Дата регистрации" row is hidden when registration_date is null — #1953). * Server component (no "use client") — pure display. * monospace for cad_num and area_m2. */ @@ -24,7 +25,7 @@ function formatDate(raw: string | null): string { } function buildRows(data: ParcelEgrn): Row[] { - return [ + const rows: Row[] = [ { label: "Кадастровый номер", value: data.cad_num, mono: true }, { label: "Адрес", value: data.address }, { @@ -37,15 +38,25 @@ function buildRows(data: ParcelEgrn): Row[] { }, { label: "ВРИ", value: data.vri }, { label: "Категория", value: data.category }, - { + ]; + + // registration_date is null for ~97% of parcels (#1953 audit) — hide the row + // entirely rather than render a bare "—". + if (data.registration_date) { + rows.push({ label: "Дата регистрации", value: formatDate(data.registration_date), - }, + }); + } + + rows.push( { label: "Форма собственности", value: data.owner_type }, { label: "Обременения", value: data.encumbrance }, { label: "Статус", value: data.status }, { label: "Обновлено", value: formatDate(data.last_updated) }, - ]; + ); + + return rows; } export function EgrnPropertyTable({ data }: Props) { diff --git a/frontend/src/components/site-finder/analysis/nspd-regulation.ts b/frontend/src/components/site-finder/analysis/nspd-regulation.ts index b582f094..7921fb51 100644 --- a/frontend/src/components/site-finder/analysis/nspd-regulation.ts +++ b/frontend/src/components/site-finder/analysis/nspd-regulation.ts @@ -13,10 +13,10 @@ import type { NspdZoning } from "@/types/nspd"; /** * Coerce a wire value to a finite float. The ekb-geoportal-urbancard regulation - * columns arrive as STRINGS ("2.4", "80") in `nspd_zoning.max_far` / - * `max_building_pct`, but `max_floors` / `max_height_m` are real numbers — this - * accepts both and rejects null / "" / non-numeric so callers never do `NaN` - * arithmetic. The single coercion point for #1962 + the cockpit. + * columns are NUMERIC, so they arrive as STRINGS ("2.4", "12", "80") in + * `nspd_zoning.max_far` / `max_height_m` / `max_building_pct` — this accepts + * both string and number and rejects null / "" / non-numeric so callers never + * do `NaN` arithmetic. The single coercion point for #1962 + the cockpit. */ export function coerceFloat( v: string | number | null | undefined, diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index 6b3b0b20..c8ac2df0 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -414,9 +414,10 @@ function utilityLabel(subtype: string): string { export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard { const z = a.nspd_zoning; const zone = regulationZone(z); - // max_far / max_building_pct arrive as strings on the wire — coerce to numbers. + // max_far / max_height_m / max_building_pct are NUMERIC → strings on the wire; + // coerce to numbers. const maxFar = coerceFloat(z?.max_far); - const maxHeight = z?.max_height_m; + const maxHeight = coerceFloat(z?.max_height_m); const buildingPct = coerceFloat(z?.max_building_pct); const areaM2 = parcelAreaM2(a); @@ -960,7 +961,7 @@ export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer { : notReal(SRC_NSPD_ZONING); const maxFar = coerceFloat(zoning?.max_far); - const maxHeight = zoning?.max_height_m; + const maxHeight = coerceFloat(zoning?.max_height_m); const buildingPct = coerceFloat(zoning?.max_building_pct); const areaM2 = parcelAreaM2(a); const regSource = zoning?.regulation_source; @@ -1663,7 +1664,7 @@ export function adaptPotentialDrawer(a: ParcelAnalysis): PticaPotentialDrawer { const maxFar = coerceFloat(z?.max_far); const farReal = maxFar != null; const buildingPct = coerceFloat(z?.max_building_pct); - const maxHeight = z?.max_height_m; + const maxHeight = coerceFloat(z?.max_height_m); const maxFloors = z?.max_floors; // КСИТ ёмкость (надземная GFA) = площадь × max_far — real with both inputs. @@ -2001,7 +2002,7 @@ export function adaptPotentialCard(a: ParcelAnalysis): PticaPotentialCard { const areaM2 = parcelAreaM2(a); const z = a.nspd_zoning; const maxFar = coerceFloat(z?.max_far); - const maxHeight = z?.max_height_m; + const maxHeight = coerceFloat(z?.max_height_m); const buildingPct = coerceFloat(z?.max_building_pct); const areaMetric: PticaPotentialMetric = diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index e22658e2..96223f7f 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -9,11 +9,12 @@ export interface NspdZoning { raw_props?: Record; // Real ПЗЗ regulation (PR #1847, synth #1891) — optional + nullable so the // cockpit stays backward-compatible until the field flows, then auto-lights-up. - // NB: max_far / max_building_pct arrive on the wire as STRINGS ("2.4", "80") - // from the ekb-geoportal-urbancard regulation columns — typed `string | number` - // so consumers must coerce (see coerceFloat in nspd-regulation.ts) before maths. + // NB: max_far / max_height_m / max_building_pct are NUMERIC columns, so they + // arrive on the wire as STRINGS ("2.4", "12", "80") from the + // ekb-geoportal-urbancard regulation columns — typed `string | number` so + // consumers must coerce (see coerceFloat in nspd-regulation.ts) before maths. max_far?: string | number | null; // КСИТ (предельный коэф. плотности застройки) - max_height_m?: number | null; // предельная высота, м + max_height_m?: string | number | null; // предельная высота, м max_floors?: number | null; // предельная этажность max_building_pct?: string | number | null; // коэф. застройки (процент пятна) min_parcel_area_m2?: number | null; // мин. площадь участка, м²