fix(audit): index for flats_latest, coerce max_height_m, hide empty reg-date (#1953)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m2s
CI / openapi-codegen-check (pull_request) Successful in 1m56s
CI / backend-tests (pull_request) Successful in 14m7s

Three #1953 audit follow-ups:

- perf: add migration 174 — composite index idx_kn_flats_obj_snap on
  domrf_kn_flats (obj_id, snapshot_date DESC). Serves the #1956 flats_latest
  CTE in best_layouts.py (DISTINCT ON (obj_id) ORDER BY obj_id, snapshot_date
  DESC + self-join on (obj_id, snapshot_date)); previously only (obj_id)
  existed so Postgres sorted per object. Prod: 789 569 rows, idx ~5.7 MB,
  dry-run instant. Idempotent, self-wrapped BEGIN/COMMIT.

- frontend: route every max_height_m read through coerceFloat (same string-bug
  class as max_far #1962). max_height_m is NUMERIC → arrives as a string on the
  wire; ptica-adapt.ts read it raw at 4 sites and relied on formatInt/Math.round
  coercion. Widen the type in nspd.ts and fix the stale "real number" comment in
  nspd-regulation.ts.

- frontend: hide the «Дата регистрации» EGRN row entirely when
  registration_date is null (~97% of parcels) instead of rendering a bare «—».
This commit is contained in:
Light1YT 2026-06-28 03:19:18 +05:00
parent 2fb34112b5
commit 8aec625134
5 changed files with 66 additions and 18 deletions

View file

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

View file

@ -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. * Server component (no "use client") pure display.
* monospace for cad_num and area_m2. * monospace for cad_num and area_m2.
*/ */
@ -24,7 +25,7 @@ function formatDate(raw: string | null): string {
} }
function buildRows(data: ParcelEgrn): Row[] { function buildRows(data: ParcelEgrn): Row[] {
return [ const rows: Row[] = [
{ label: "Кадастровый номер", value: data.cad_num, mono: true }, { label: "Кадастровый номер", value: data.cad_num, mono: true },
{ label: "Адрес", value: data.address }, { label: "Адрес", value: data.address },
{ {
@ -37,15 +38,25 @@ function buildRows(data: ParcelEgrn): Row[] {
}, },
{ label: "ВРИ", value: data.vri }, { label: "ВРИ", value: data.vri },
{ label: "Категория", value: data.category }, { 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: "Дата регистрации", label: "Дата регистрации",
value: formatDate(data.registration_date), value: formatDate(data.registration_date),
}, });
}
rows.push(
{ label: "Форма собственности", value: data.owner_type }, { label: "Форма собственности", value: data.owner_type },
{ label: "Обременения", value: data.encumbrance }, { label: "Обременения", value: data.encumbrance },
{ label: "Статус", value: data.status }, { label: "Статус", value: data.status },
{ label: "Обновлено", value: formatDate(data.last_updated) }, { label: "Обновлено", value: formatDate(data.last_updated) },
]; );
return rows;
} }
export function EgrnPropertyTable({ data }: Props) { export function EgrnPropertyTable({ data }: Props) {

View file

@ -13,10 +13,10 @@ import type { NspdZoning } from "@/types/nspd";
/** /**
* Coerce a wire value to a finite float. The ekb-geoportal-urbancard regulation * 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` / * columns are NUMERIC, so they arrive as STRINGS ("2.4", "12", "80") in
* `max_building_pct`, but `max_floors` / `max_height_m` are real numbers this * `nspd_zoning.max_far` / `max_height_m` / `max_building_pct` this accepts
* accepts both and rejects null / "" / non-numeric so callers never do `NaN` * both string and number and rejects null / "" / non-numeric so callers never
* arithmetic. The single coercion point for #1962 + the cockpit. * do `NaN` arithmetic. The single coercion point for #1962 + the cockpit.
*/ */
export function coerceFloat( export function coerceFloat(
v: string | number | null | undefined, v: string | number | null | undefined,

View file

@ -414,9 +414,10 @@ function utilityLabel(subtype: string): string {
export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard { export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard {
const z = a.nspd_zoning; const z = a.nspd_zoning;
const zone = regulationZone(z); 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 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 buildingPct = coerceFloat(z?.max_building_pct);
const areaM2 = parcelAreaM2(a); const areaM2 = parcelAreaM2(a);
@ -960,7 +961,7 @@ export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
: notReal(SRC_NSPD_ZONING); : notReal(SRC_NSPD_ZONING);
const maxFar = coerceFloat(zoning?.max_far); 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 buildingPct = coerceFloat(zoning?.max_building_pct);
const areaM2 = parcelAreaM2(a); const areaM2 = parcelAreaM2(a);
const regSource = zoning?.regulation_source; const regSource = zoning?.regulation_source;
@ -1663,7 +1664,7 @@ export function adaptPotentialDrawer(a: ParcelAnalysis): PticaPotentialDrawer {
const maxFar = coerceFloat(z?.max_far); const maxFar = coerceFloat(z?.max_far);
const farReal = maxFar != null; const farReal = maxFar != null;
const buildingPct = coerceFloat(z?.max_building_pct); 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; const maxFloors = z?.max_floors;
// КСИТ ёмкость (надземная GFA) = площадь × max_far — real with both inputs. // КСИТ ёмкость (надземная GFA) = площадь × max_far — real with both inputs.
@ -2001,7 +2002,7 @@ export function adaptPotentialCard(a: ParcelAnalysis): PticaPotentialCard {
const areaM2 = parcelAreaM2(a); const areaM2 = parcelAreaM2(a);
const z = a.nspd_zoning; const z = a.nspd_zoning;
const maxFar = coerceFloat(z?.max_far); 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 buildingPct = coerceFloat(z?.max_building_pct);
const areaMetric: PticaPotentialMetric = const areaMetric: PticaPotentialMetric =

View file

@ -9,11 +9,12 @@ export interface NspdZoning {
raw_props?: Record<string, unknown>; raw_props?: Record<string, unknown>;
// Real ПЗЗ regulation (PR #1847, synth #1891) — optional + nullable so the // Real ПЗЗ regulation (PR #1847, synth #1891) — optional + nullable so the
// cockpit stays backward-compatible until the field flows, then auto-lights-up. // 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") // NB: max_far / max_height_m / max_building_pct are NUMERIC columns, so they
// from the ekb-geoportal-urbancard regulation columns — typed `string | number` // arrive on the wire as STRINGS ("2.4", "12", "80") from the
// so consumers must coerce (see coerceFloat in nspd-regulation.ts) before maths. // 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_far?: string | number | null; // КСИТ (предельный коэф. плотности застройки)
max_height_m?: number | null; // предельная высота, м max_height_m?: string | number | null; // предельная высота, м
max_floors?: number | null; // предельная этажность max_floors?: number | null; // предельная этажность
max_building_pct?: string | number | null; // коэф. застройки (процент пятна) max_building_pct?: string | number | null; // коэф. застройки (процент пятна)
min_parcel_area_m2?: number | null; // мин. площадь участка, м² min_parcel_area_m2?: number | null; // мин. площадь участка, м²