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
This commit is contained in:
commit
91edef1524
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.
|
||||
* 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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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; // мин. площадь участка, м²
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue