fix(audit): index for flats_latest, coerce max_height_m, hide empty reg-date (#1953)
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:
parent
2fb34112b5
commit
8aec625134
5 changed files with 66 additions and 18 deletions
35
data/sql/174_domrf_kn_flats_obj_snapshot_idx.sql
Normal file
35
data/sql/174_domrf_kn_flats_obj_snapshot_idx.sql
Normal 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;
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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; // мин. площадь участка, м²
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue