Merge pull request 'fix(report): вывести ПЗЗ-градрегламент НСПД в Раздел 1 + coerceFloat КСИТ (#1962)' (#1987) from fix/report-nspd-zoning-1962 into main
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m59s
Deploy / deploy (push) Successful in 1m10s

This commit is contained in:
bot-backend 2026-06-27 12:22:51 +00:00
commit 16f375782b
9 changed files with 479 additions and 22 deletions

View file

@ -0,0 +1,176 @@
/**
* NspdRegulationCard «Градрегламент (НСПД)» block for Раздел 1 (#1962).
*
* Surfaces the REAL ПЗЗ-градрегламент resolved into `nspd_zoning` (synth #1891):
* тер.зона, КСИТ (max_far), пред. высота, этажность, коэф. застройки, ёмкость,
* источник + the permitted-use (ВРИ) list of the zone. Previously the light
* report only rendered the dead `zoning.data_available=false` (closed-PKK6 path)
* and never showed the real regulation the user asked about («мы выгружали НСПД
* где они?»).
*
* Mirrors EgrnPropertyTable's card pattern (uppercase header + striped 2-col
* table). Pure display view-model built by `adaptNspdRegulation`. Graceful
* empty-state when the resolver produced nothing (gap-зоны / не-ЕКБ).
*/
import { Landmark } from "lucide-react";
import type { NspdZoning } from "@/types/nspd";
import { adaptNspdRegulation } from "./nspd-regulation";
interface Props {
zoning: NspdZoning | null | undefined;
/** Parcel area in m² (ЕГРН) — drives the КСИТ-ёмкость / пятно rows. */
areaM2: number | null;
}
const HEADER_STYLE: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 16px",
borderBottom: "1px solid var(--border-soft)",
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
};
export function NspdRegulationCard({ zoning, areaM2 }: Props) {
const vm = adaptNspdRegulation(zoning, areaM2);
return (
<div
style={{
border: "1px solid var(--border-card)",
borderRadius: 12,
overflow: "hidden",
background: "var(--bg-card)",
}}
>
<div style={HEADER_STYLE}>
<Landmark size={16} strokeWidth={1.5} aria-hidden="true" />
Градрегламент (НСПД)
</div>
{vm.present ? (
<>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<tbody>
{vm.rows.map((row, i) => (
<tr
key={row.label}
style={{
background:
i % 2 === 0 ? "var(--bg-card)" : "var(--bg-card-alt)",
}}
>
<td
style={{
padding: "8px 16px",
color: "var(--fg-secondary)",
fontWeight: 400,
width: "45%",
verticalAlign: "top",
borderBottom:
i < vm.rows.length - 1
? "1px solid var(--border-soft)"
: "none",
}}
>
{row.label}
</td>
<td
style={{
padding: "8px 16px",
color: "var(--fg-primary)",
fontWeight: 500,
fontVariantNumeric: "tabular-nums",
verticalAlign: "top",
wordBreak: "break-word",
borderBottom:
i < vm.rows.length - 1
? "1px solid var(--border-soft)"
: "none",
}}
>
{row.value}
</td>
</tr>
))}
</tbody>
</table>
{/* КСИТ microcopy — расшифровка жаргона на первой странице (ui-microcopy). */}
<p
style={{
margin: 0,
padding: "8px 16px",
fontSize: 12,
lineHeight: "16px",
color: "var(--fg-tertiary)",
borderTop: "1px solid var(--border-soft)",
}}
>
КСИТ коэффициент строительного использования: предельная надземная
площадь застройки на участке (площадь участка × КСИТ).
</p>
{vm.permittedUses.length > 0 && (
<div
style={{
padding: "10px 16px",
borderTop: "1px solid var(--border-soft)",
}}
>
<div
style={{
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
marginBottom: 8,
}}
>
Разрешённые ВРИ зоны
</div>
<ul
style={{
margin: 0,
paddingLeft: 16,
fontSize: 13,
lineHeight: "20px",
color: "var(--fg-secondary)",
}}
>
{vm.permittedUses.map((use, i) => (
<li key={`${i}-${use}`}>{use}</li>
))}
</ul>
</div>
)}
</>
) : (
<p
style={{
margin: 0,
padding: "12px 16px",
fontSize: 13,
lineHeight: "20px",
color: "var(--fg-secondary)",
}}
>
Градрегламент не определён: территориальная зона ПЗЗ для участка не
разрешена (вне ЕКБ или участок в разрыве зон).
</p>
)}
</div>
);
}

View file

@ -17,6 +17,7 @@ import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { KpiCard } from "@/components/site-finder/KpiCard"; import { KpiCard } from "@/components/site-finder/KpiCard";
import { MiniMap } from "./MiniMap"; import { MiniMap } from "./MiniMap";
import { EgrnPropertyTable } from "./EgrnPropertyTable"; import { EgrnPropertyTable } from "./EgrnPropertyTable";
import { NspdRegulationCard } from "./NspdRegulationCard";
import { NspdVerifyLink } from "./NspdVerifyLink"; import { NspdVerifyLink } from "./NspdVerifyLink";
import { PoiList2Gis } from "./PoiList2Gis"; import { PoiList2Gis } from "./PoiList2Gis";
import { ExportButtons } from "./ExportButtons"; import { ExportButtons } from "./ExportButtons";
@ -307,10 +308,23 @@ export function Section1ParcelInfo({ cad }: Props) {
{/* Left: mini-map */} {/* Left: mini-map */}
<MiniMap data={data} /> <MiniMap data={data} />
{/* Right: EGRN table + POI list */} {/* Right: EGRN table + НСПД-градрегламент + POI list */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<EgrnPropertyTable data={egrn} /> <EgrnPropertyTable data={egrn} />
{/* #1962 реальный ПЗЗ-градрегламент из nspd_zoning (synth #1891).
Раньше отчёт показывал только мёртвый zoning.data_available=false
(закрытый PKK6-путь) и НЕ выводил резолвленный регламент. КСИТ-ёмкость
считается от площади ЕГРН (egrn.area_m2). */}
<NspdRegulationCard
zoning={data.nspd_zoning}
areaM2={
Number.isFinite(egrn.area_m2) && egrn.area_m2 > 0
? egrn.area_m2
: null
}
/>
{poiData ? ( {poiData ? (
<PoiList2Gis <PoiList2Gis
items={poiData.items} items={poiData.items}

View file

@ -0,0 +1,106 @@
/**
* nspd-regulation mapper tests audit-issue #1962 «Градрегламент НСПД не выведен
* в Раздел 1».
*
* Pin the field mapping the light report depends on:
* 1. Real nspd_zoning (live shape: max_far/max_building_pct as STRINGS)
* coerced rows + ВРИ list + КСИТ-ёмкость from area.
* 2. null nspd_zoning present:false каллер показывает empty-state.
* 3. coerceFloat narrows "2.4"/"80"/numbers, rejects null/""/non-numeric.
*/
import { describe, expect, it } from "vitest";
import { adaptNspdRegulation, coerceFloat } from "../nspd-regulation";
import type { NspdZoning } from "@/types/nspd";
// Live shape for 66:41:0205010:287 (POST /analyze) — max_far / max_building_pct
// arrive as strings; regulation_zone_index is the human index "ПК-1".
const LIVE_ZONING: NspdZoning = {
zone_code: "66:00-7.155",
zone_name: "66:00-7.155",
source: "nspd-quarter-dump",
max_far: "2.4",
max_height_m: null,
max_floors: 2,
max_building_pct: "80",
min_parcel_area_m2: null,
regulation_zone_index: "ПК-1",
regulation_source: "ekb-geoportal-urbancard",
main_vri: [
"3.1 Коммунальное обслуживание",
"2.7.1\n Хранение автотранспорта",
" ", // whitespace-only → trimmed away
],
};
const AREA_M2 = 106378;
describe("coerceFloat (#1962)", () => {
it("parses wire strings, passes numbers, rejects junk", () => {
expect(coerceFloat("2.4")).toBe(2.4);
expect(coerceFloat("80")).toBe(80);
expect(coerceFloat(2)).toBe(2);
expect(coerceFloat(null)).toBeNull();
expect(coerceFloat(undefined)).toBeNull();
expect(coerceFloat("")).toBeNull();
expect(coerceFloat("n/a")).toBeNull();
});
});
describe("adaptNspdRegulation (#1962)", () => {
it("maps the live nspd_zoning shape into regulation rows", () => {
const vm = adaptNspdRegulation(LIVE_ZONING, AREA_M2);
expect(vm.present).toBe(true);
expect(vm.zone).toBe("ПК-1");
expect(vm.source).toBe("ekb-geoportal-urbancard");
const byLabel = Object.fromEntries(vm.rows.map((r) => [r.label, r.value]));
expect(byLabel["Тер. зона"]).toBe("ПК-1");
expect(byLabel["КСИТ (max FAR)"]).toBe("2,4");
expect(byLabel["Этажность"]).toBe("2 эт.");
expect(byLabel["Коэф. застройки"]).toBe("80 %");
// max_height_m null → row omitted, not a "—" with NaN.
expect(byLabel["Пред. высота"]).toBeUndefined();
// КСИТ-ёмкость = area × max_far = 106378 × 2.4 = 255307,2 → 255 307 (digits
// grouped by a NBSP from ru-RU toLocaleString, hence the regex over \D).
expect(byLabel["КСИТ-ёмкость"].replace(/\D/g, "")).toBe("255307");
// Пятно застройки = area × 80% = 85 102.
expect(byLabel["Пятно застройки"].replace(/\D/g, "")).toBe("85102");
expect(byLabel["Источник"]).toBe("ekb-geoportal-urbancard");
});
it("trims and filters the permitted-use (ВРИ) list", () => {
const vm = adaptNspdRegulation(LIVE_ZONING, AREA_M2);
expect(vm.permittedUses).toEqual([
"3.1 Коммунальное обслуживание",
"2.7.1 Хранение автотранспорта",
]);
});
it("omits area-derived rows when geometry is unknown", () => {
const vm = adaptNspdRegulation(LIVE_ZONING, null);
const labels = vm.rows.map((r) => r.label);
expect(labels).not.toContain("КСИТ-ёмкость");
expect(labels).not.toContain("Пятно застройки");
// зона / КСИТ / этажность / коэф. застройки / источник still present.
expect(labels).toContain("КСИТ (max FAR)");
});
it("reports not-present for null nspd_zoning (empty-state)", () => {
const vm = adaptNspdRegulation(null, AREA_M2);
expect(vm.present).toBe(false);
expect(vm.zone).toBeNull();
expect(vm.rows).toHaveLength(0);
expect(vm.permittedUses).toHaveLength(0);
});
it("falls back to zone_code when regulation_zone_index is absent", () => {
const vm = adaptNspdRegulation(
{ zone_code: "Ж-2", zone_name: null },
AREA_M2,
);
expect(vm.present).toBe(true);
expect(vm.zone).toBe("Ж-2");
});
});

View file

@ -0,0 +1,138 @@
/**
* nspd-regulation PURE typed mapper: NspdZoning градрегламент view-model.
*
* Single source of truth for turning the resolved ПЗЗ-регламент (synth #1891,
* `nspd_zoning`) into the rows the light report (Section1ParcelInfo, #1962) and
* the /ptica cockpit drawer render. Reused by `ptica-adapt` so the two stay
* consistent (same field paths, same КСИТ-capacity maths, same coercion).
*
* No React, no side effects narrowing + formatting only.
*/
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.
*/
export function coerceFloat(
v: string | number | null | undefined,
): number | null {
if (v == null) return null;
if (typeof v === "number") return Number.isFinite(v) ? v : null;
const n = Number.parseFloat(v);
return Number.isFinite(n) ? n : null;
}
/** Human regulation zone — prefer the readable index ("ПК-1") over reg-number. */
export function regulationZoneLabel(
z: NspdZoning | null | undefined,
): string | null {
return z?.regulation_zone_index ?? z?.zone_code ?? null;
}
/** КСИТ (max_far) as a plain RU number — e.g. "2,4". */
function formatFar(far: number): string {
return far.toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
}
function formatInt(n: number): string {
return Math.round(n).toLocaleString("ru-RU");
}
/** A single value row of the градрегламент block. */
export interface NspdRegulationRow {
label: string;
value: string;
}
/** View-model consumed by the Раздел 1 «Градрегламент (НСПД)» card. */
export interface NspdRegulationVM {
/** True when the resolver produced at least a zone — otherwise hide / empty. */
present: boolean;
/** тер.зона ("ПК-1") or null. */
zone: string | null;
/** источник регламента, e.g. "ekb-geoportal-urbancard". */
source: string | null;
/** Param rows (зона / КСИТ / высота / этажность / коэф. застройки / ёмкость). */
rows: NspdRegulationRow[];
/** Разрешённые ВРИ зоны (#1891 main_vri), trimmed; empty when absent. */
permittedUses: string[];
}
/**
* Map `nspd_zoning` (+ optional parcel area in m² for the КСИТ-capacity row) into
* the градрегламент view-model. `areaM2` lets us derive КСИТ-ёмкость
* (площадь × max_far) and пятно застройки (площадь × коэф. застройки) the same
* computation `adaptUrbanDrawer` uses; pass null when geometry is unknown and
* those derived rows are simply omitted.
*/
export function adaptNspdRegulation(
z: NspdZoning | null | undefined,
areaM2: number | null,
): NspdRegulationVM {
const zone = regulationZoneLabel(z);
const maxFar = coerceFloat(z?.max_far);
const maxHeight = coerceFloat(z?.max_height_m);
const maxFloors = coerceFloat(z?.max_floors);
const buildingPct = coerceFloat(z?.max_building_pct);
const source = z?.regulation_source ?? null;
const permittedUses = (z?.main_vri ?? [])
.map((v) => v.replace(/\s+/g, " ").trim())
.filter((v) => v.length > 0);
const rows: NspdRegulationRow[] = [];
if (zone != null) rows.push({ label: "Тер. зона", value: zone });
if (maxFar != null) {
rows.push({ label: "КСИТ (max FAR)", value: formatFar(maxFar) });
}
if (maxHeight != null) {
rows.push({ label: "Пред. высота", value: `${formatInt(maxHeight)} м` });
}
if (maxFloors != null) {
rows.push({ label: "Этажность", value: `${formatInt(maxFloors)} эт.` });
}
if (buildingPct != null) {
rows.push({
label: "Коэф. застройки",
value: `${formatInt(buildingPct)} %`,
});
}
// КСИТ-ёмкость = площадь × max_far (надземная GFA) — real only with both inputs.
if (maxFar != null && areaM2 != null) {
rows.push({
label: "КСИТ-ёмкость",
value: `${formatInt(areaM2 * maxFar)} м²`,
});
}
// Пятно застройки = площадь × коэф. застройки.
if (buildingPct != null && areaM2 != null) {
rows.push({
label: "Пятно застройки",
value: `${formatInt(areaM2 * (buildingPct / 100))} м²`,
});
}
if (source != null) rows.push({ label: "Источник", value: source });
// "present" = the resolver produced a zone (synth #1891 always sets one when it
// hits). A null nspd_zoning or an empty resolve → not present → caller hides /
// shows «регламент не определён».
const present = zone != null || rows.length > 0;
return { present, zone, source, rows, permittedUses };
}

View file

@ -10,6 +10,7 @@
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
import { coerceFloat } from "@/components/site-finder/analysis/nspd-regulation";
import { parcelAreaM2 } from "@/components/site-finder/ptica/ptica-adapt"; import { parcelAreaM2 } from "@/components/site-finder/ptica/ptica-adapt";
import { MassingViewer } from "@/components/site-finder/ptica/massing/MassingViewer"; import { MassingViewer } from "@/components/site-finder/ptica/massing/MassingViewer";
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys"; import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
@ -21,7 +22,8 @@ interface Props {
export function InsolationCard({ analysis, onOpenDrawer }: Props) { export function InsolationCard({ analysis, onOpenDrawer }: Props) {
const areaM2 = parcelAreaM2(analysis) ?? undefined; const areaM2 = parcelAreaM2(analysis) ?? undefined;
const maxFar = analysis.nspd_zoning?.max_far ?? undefined; // max_far arrives as a string on the wire — coerce before passing to the 3D viewer.
const maxFar = coerceFloat(analysis.nspd_zoning?.max_far) ?? undefined;
const farIsReal = maxFar != null; const farIsReal = maxFar != null;
return ( return (

View file

@ -13,6 +13,7 @@
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
import { coerceFloat } from "@/components/site-finder/analysis/nspd-regulation";
import type { ForecastReport } from "@/types/forecast"; import type { ForecastReport } from "@/types/forecast";
import type { import type {
DrawerKvRow, DrawerKvRow,
@ -67,8 +68,9 @@ function massingAreaM2(analysis: ParcelAnalysis): number {
/** Real КСИТ from НСПД-зонирование (PR #1847), else the regulation default. */ /** Real КСИТ from НСПД-зонирование (PR #1847), else the regulation default. */
function massingMaxFar(analysis: ParcelAnalysis): number { function massingMaxFar(analysis: ParcelAnalysis): number {
const maxFar = analysis.nspd_zoning?.max_far; // max_far arrives as a string on the wire — coerce before comparing.
if (typeof maxFar === "number" && Number.isFinite(maxFar) && maxFar > 0) { const maxFar = coerceFloat(analysis.nspd_zoning?.max_far);
if (maxFar != null && maxFar > 0) {
return maxFar; return maxFar;
} }
return MASSING_MAX_FAR; return MASSING_MAX_FAR;
@ -76,8 +78,8 @@ function massingMaxFar(analysis: ParcelAnalysis): number {
/** True when the КСИТ used by the massing sandbox is the real НСПД value. */ /** True when the КСИТ used by the massing sandbox is the real НСПД value. */
function massingFarIsReal(analysis: ParcelAnalysis): boolean { function massingFarIsReal(analysis: ParcelAnalysis): boolean {
const maxFar = analysis.nspd_zoning?.max_far; const maxFar = coerceFloat(analysis.nspd_zoning?.max_far);
return typeof maxFar === "number" && Number.isFinite(maxFar) && maxFar > 0; return maxFar != null && maxFar > 0;
} }
const SRC_UPDATED = "источник: /analyze"; const SRC_UPDATED = "источник: /analyze";

View file

@ -16,6 +16,7 @@ import type {
ParcelFinancialEstimate, ParcelFinancialEstimate,
} from "@/types/site-finder"; } from "@/types/site-finder";
import type { NspdZoning } from "@/types/nspd"; import type { NspdZoning } from "@/types/nspd";
import { coerceFloat } from "@/components/site-finder/analysis/nspd-regulation";
import type { ForecastReport, ProductMixEntry } from "@/types/forecast"; import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
// ── View-model ────────────────────────────────────────────────────────────── // ── View-model ──────────────────────────────────────────────────────────────
@ -413,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);
const maxFar = z?.max_far; // max_far / max_building_pct arrive as strings on the wire — coerce to numbers.
const maxFar = coerceFloat(z?.max_far);
const maxHeight = z?.max_height_m; const maxHeight = z?.max_height_m;
const buildingPct = z?.max_building_pct; const buildingPct = coerceFloat(z?.max_building_pct);
const areaM2 = parcelAreaM2(a); const areaM2 = parcelAreaM2(a);
// Плотность (КСИТ) ёмкость = площадь × КСИТ (м²), real only with both inputs. // Плотность (КСИТ) ёмкость = площадь × КСИТ (м²), real only with both inputs.
@ -858,9 +860,9 @@ export function adaptBuildabilityDrawer(
// МОП/parking, so it stays an estimate placeholder. // МОП/parking, so it stays an estimate placeholder.
const z = a.nspd_zoning; const z = a.nspd_zoning;
const areaM2 = parcelAreaM2(a); const areaM2 = parcelAreaM2(a);
const maxFar = z?.max_far; const maxFar = coerceFloat(z?.max_far);
const farReal = maxFar != null; const farReal = maxFar != null;
const buildingPct = z?.max_building_pct; const buildingPct = coerceFloat(z?.max_building_pct);
const spotRow: DrawerKvRow = const spotRow: DrawerKvRow =
areaM2 != null && buildingPct != null areaM2 != null && buildingPct != null
@ -957,9 +959,9 @@ export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
? real(zoning.zone_name) ? real(zoning.zone_name)
: notReal(SRC_NSPD_ZONING); : notReal(SRC_NSPD_ZONING);
const maxFar = zoning?.max_far; const maxFar = coerceFloat(zoning?.max_far);
const maxHeight = zoning?.max_height_m; const maxHeight = zoning?.max_height_m;
const buildingPct = 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;
@ -1658,9 +1660,9 @@ export function adaptPotentialDrawer(a: ParcelAnalysis): PticaPotentialDrawer {
const z = a.nspd_zoning; const z = a.nspd_zoning;
const areaM2 = parcelAreaM2(a); const areaM2 = parcelAreaM2(a);
const maxFar = z?.max_far; const maxFar = coerceFloat(z?.max_far);
const farReal = maxFar != null; const farReal = maxFar != null;
const buildingPct = z?.max_building_pct; const buildingPct = coerceFloat(z?.max_building_pct);
const maxHeight = z?.max_height_m; const maxHeight = z?.max_height_m;
const maxFloors = z?.max_floors; const maxFloors = z?.max_floors;
@ -1998,9 +2000,9 @@ export function adaptPotentialCard(a: ParcelAnalysis): PticaPotentialCard {
const areaHa = a.geometry_suitability?.area_ha; const areaHa = a.geometry_suitability?.area_ha;
const areaM2 = parcelAreaM2(a); const areaM2 = parcelAreaM2(a);
const z = a.nspd_zoning; const z = a.nspd_zoning;
const maxFar = z?.max_far; const maxFar = coerceFloat(z?.max_far);
const maxHeight = z?.max_height_m; const maxHeight = z?.max_height_m;
const buildingPct = z?.max_building_pct; const buildingPct = coerceFloat(z?.max_building_pct);
const areaMetric: PticaPotentialMetric = const areaMetric: PticaPotentialMetric =
areaM2 != null areaM2 != null

View file

@ -31,6 +31,7 @@ import type {
Pipeline24mo, Pipeline24mo,
} from "@/types/site-finder"; } from "@/types/site-finder";
import type { import type {
NspdZoning,
NspdZouitOverlap, NspdZouitOverlap,
OpportunityParcel, OpportunityParcel,
RedLine, RedLine,
@ -465,6 +466,16 @@ export interface ParcelAnalyzeResponse {
* если backend ещё не добавил поле). * если backend ещё не добавил поле).
*/ */
nspd_zouit_overlaps?: NspdZouitOverlap[] | null; nspd_zouit_overlaps?: NspdZouitOverlap[] | null;
/**
* #1962 резолвленный ПЗЗ-градрегламент зоны (synth #1891). Бэкенд
* (`parcels.py`, top-level `nspd_zoning`) отдаёт тер.зону (`regulation_zone_index`
* напр. "ПК-1"), КСИТ (`max_far`), предельную высоту/этажность, коэф. застройки
* (`max_building_pct`), источник регламента (`regulation_source`) и список
* разрешённых ВРИ зоны (`main_vri`). Light-отчёт (Section1ParcelInfo) выводит его
* в «Градрегламент (НСПД)»-блоке через `adaptNspdRegulation`. Optional + nullable:
* для не-ЕКБ участков / при miss резолвера поле может быть null (gap-зоны #1891).
*/
nspd_zoning?: NspdZoning | null;
} }
// ── Types: B6 poi-score response ───────────────────────────────────────────── // ── Types: B6 poi-score response ─────────────────────────────────────────────

View file

@ -7,15 +7,21 @@ export interface NspdZoning {
zone_name: string | null; zone_name: string | null;
source?: string; source?: string;
raw_props?: Record<string, unknown>; raw_props?: Record<string, unknown>;
// Real ПЗЗ regulation (PR #1847) — optional + nullable so the cockpit stays // Real ПЗЗ regulation (PR #1847, synth #1891) — optional + nullable so the
// backward-compatible until #1847 deploys, then auto-lights-up. // cockpit stays backward-compatible until the field flows, then auto-lights-up.
max_far?: number | null; // КСИТ (предельный коэф. плотности застройки) // 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.
max_far?: string | number | null; // КСИТ (предельный коэф. плотности застройки)
max_height_m?: number | null; // предельная высота, м max_height_m?: number | null; // предельная высота, м
max_floors?: number | null; // предельная этажность max_floors?: number | null; // предельная этажность
max_building_pct?: number | null; // коэф. застройки (процент пятна) max_building_pct?: string | number | null; // коэф. застройки (процент пятна)
min_parcel_area_m2?: number | null; // мин. площадь участка, м² min_parcel_area_m2?: number | null; // мин. площадь участка, м²
regulation_zone_index?: string | null; // human zone index, e.g. "Ж-2" regulation_zone_index?: string | null; // human zone index, e.g. "ПК-1"
regulation_source?: string | null; // источник регламента regulation_source?: string | null; // источник регламента (ekb-geoportal-urbancard)
// #1891 — authoritative permitted-use (ВРИ) list of the zone, used by the gate
// and surfaced read-only in the Раздел 1 регламент-блок (#1962).
main_vri?: string[] | null;
} }
export interface NspdZouitOverlap { export interface NspdZouitOverlap {