Merge pull request 'fix: audit follow-up мелочи — индекс domrf_kn_flats + max_height_m coercion + hide empty reg-date (#1953)' (#2031) from fix/audit-followups-1953 into main
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 30s
Deploy / build-worker (push) Successful in 31s
Deploy / build-frontend (push) Successful in 3m11s
Deploy / deploy (push) Successful in 1m12s

This commit is contained in:
bot-backend 2026-06-27 22:37:14 +00:00
commit 91edef1524
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.
* 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) {

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
* 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,

View file

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

View file

@ -9,11 +9,12 @@ export interface NspdZoning {
raw_props?: Record<string, unknown>;
// 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; // мин. площадь участка, м²