gendesign/frontend/src/components/site-finder/ptica/ptica-adapt.ts
Light1YT 8aec625134
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
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 «—».
2026-06-28 03:19:18 +05:00

2493 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ptica-adapt — PURE typed mappers ParcelAnalysis → cockpit view-models.
*
* INCREMENT 1 contract: render REAL data where the backend provides it, and an
* honest PLACEHOLDER ("—" + caption) where a field is absent or not yet wired.
* Every value the cockpit renders flows through here as a {value, isReal,
* caption?} view-model so the UI never presents a fake hard number as live.
*
* No React, no side effects — just narrowing + formatting. The single source of
* truth for the real-vs-placeholder decision.
*/
import type {
ParcelAnalysis,
ParcelAnalysisCompetitor,
ParcelFinancialEstimate,
} from "@/types/site-finder";
import type { NspdZoning } from "@/types/nspd";
import { coerceFloat } from "@/components/site-finder/analysis/nspd-regulation";
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
// ── View-model ──────────────────────────────────────────────────────────────
/** A single field as shown in the cockpit. `caption` annotates placeholders. */
export interface PticaField {
value: string;
isReal: boolean;
caption?: string;
}
/** Gauge view-model — value 0..100 (null = no data → render muted track). */
export interface PticaGauge {
/** 0..100 progress, or null when the metric is unknown. */
value: number | null;
/** Center caption under the number (e.g. "Можно строить"). */
label: string;
/** Severity bucket drives the stroke color via CSS tokens. */
tone: "good" | "warn" | "bad" | "neutral";
isReal: boolean;
/** Footnote (e.g. "предварительная оценка"). */
footnote?: string;
}
/** A titled scan card: metric rows + optional footnote. */
export interface PticaScanCard {
title: string;
/** Subtitle shown next to the title (e.g. "предв."). */
badge?: string;
rows: PticaScanRow[];
/** Empty-state message when there is nothing to render. */
emptyState?: string;
}
/** Tone for a status-row dot + state label (prototype .dot / .state). */
export type PticaStatusTone = "ok" | "warn" | "bad";
export interface PticaScanRow {
key: string;
field: PticaField;
/**
* When set, the row renders as a prototype status-row (dot + label + state)
* instead of the default kvrow. `field.value` is the state text. Used by the
* Инженерия card (Экология stays honest kvrow placeholders, no status dots).
*/
status?: PticaStatusTone;
}
const PLACEHOLDER = "—";
function real(value: string): PticaField {
return { value, isReal: true };
}
function placeholder(caption: string): PticaField {
return { value: PLACEHOLDER, isReal: false, caption };
}
// ── Formatting helpers (ru typography) ────────────────────────────────────────
function formatInt(n: number): string {
return Math.round(n).toLocaleString("ru-RU");
}
/** Fixed-decimal RU number (used for the invest-score on a /10 scale). */
function fmtNum(value: number, digits = 1): string {
return value.toLocaleString("ru-RU", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
/** м² from area_ha (×10000). */
function areaM2FromHa(areaHa: number): string {
return `${formatInt(areaHa * 10000)} м²`;
}
function formatHa(areaHa: number): string {
return `${areaHa.toFixed(2)} га`;
}
function formatRubPerM2(n: number): string {
return `${formatInt(n)} ₽/м²`;
}
/** Сумма в млрд ₽ (rub / 1e9, 2 знака) — для finance-bignums (#1881). */
function formatBln(rub: number): string {
return (rub / 1e9).toLocaleString("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
/** Доля → проценты с одним знаком (0.183 → "18,3"). */
function formatPctFromFraction(fraction: number, digits = 1): string {
return (fraction * 100).toLocaleString("ru-RU", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
/**
* Знаковый процент из доли — для метрик, которые честно могут быть
* отрицательными (levered/equity IRR при негативном леверидже). Отрицательное
* число печатается с Unicode-минусом «−» (U+2212), не ASCII «-», как требует
* ui-microcopy: «11,6 %». Форматируем модуль и сами ставим «−», чтобы знак не
* зависел от ICU-сборки рантайма (Node даёт ASCII-hyphen).
*/
function formatSignedPctFromFraction(fraction: number, digits = 1): string {
const body = formatPctFromFraction(Math.abs(fraction), digits);
return fraction < 0 ? `${body}` : body;
}
// #1881 PR-4: backend отдаёт inferred-токены латиницей (housing_class /
// development_type) — локализуем для финмодель-drawer, чтобы финдиректор не
// видел "high_rise" в русском UI. Неизвестный токен → как есть (fallback).
const _HOUSING_CLASS_RU: Record<string, string> = {
econom: "эконом",
comfort: "комфорт",
business: "бизнес",
};
const _DEVELOPMENT_TYPE_RU: Record<string, string> = {
spot: "точечная",
mid_rise: "среднеэтажная",
high_rise: "высотная",
};
// Honest RU caption per backend price_source token. Backend отдаёт латиницей;
// в UI финдиректор должен видеть, откуда взята цена, человеческим языком — не
// сырой токен «objective_geo_radius». Неизвестный/пустой → "" (suffix опускаем,
// сырой токен не печатаем). См. .claude/rules/ui-microcopy.md.
const _PRICE_SOURCE_RU: Record<string, string> = {
objective_district_median: "медиана сделок квартала",
objective_geo_radius: "медиана рынка в радиусе 3 км",
district_reference: "ориентир по району",
class_norm: "норматив класса",
};
/** Источник цены → честная RU-подпись в «ёлочках»; неизвестный/пустой → "". */
function priceSourceCaption(source: string): string {
const ru = _PRICE_SOURCE_RU[source];
return ru ? `«${ru}»` : "";
}
// ── НСПД-регламент (ПЗЗ) helpers ──────────────────────────────────────────────
/** КСИТ (max_far) as a plain RU number — e.g. "3,5". */
function formatFar(far: number): string {
return fmtNum(far, 1);
}
/**
* Real parcel area in m² when geometry is known (area_ha × 10000); null
* otherwise. The single point at which we turn area_ha into m² for ёмкость.
*/
export function parcelAreaM2(a: ParcelAnalysis): number | null {
const areaHa = a.geometry_suitability?.area_ha;
return areaHa != null ? areaHa * 10000 : null;
}
/** Human regulation zone — prefer the readable index ("Ж-2") over reg-number. */
function regulationZone(z: NspdZoning | null | undefined): string | null {
return z?.regulation_zone_index ?? z?.zone_code ?? null;
}
const SRC_NSPD_ZONING = "источник НСПД-зонирование";
// ── Passport ──────────────────────────────────────────────────────────────────
export interface PticaPassport {
cadNum: string;
district: PticaField;
area: PticaField;
address: PticaField;
landCategory: PticaField;
vri: PticaField;
status: PticaField;
encumbrances: PticaField;
}
export function adaptPassport(a: ParcelAnalysis): PticaPassport {
const areaHa = a.geometry_suitability?.area_ha;
const area =
areaHa != null
? real(`${formatHa(areaHa)} · ${areaM2FromHa(areaHa)}`)
: placeholder("нет геометрии");
const districtName = a.district?.district_name;
const address = a.egrn?.address;
const landCategory = a.egrn?.land_category;
const vri = a.egrn?.permitted_use_text;
const status = a.egrn?.parcel_status;
return {
cadNum: a.cad_num,
district: districtName ? real(districtName) : placeholder("нет данных"),
area,
address: address ? real(address) : placeholder("нет данных ЕГРН"),
landCategory: landCategory
? real(landCategory)
: placeholder("нет данных ЕГРН"),
vri: vri ? real(vri) : placeholder("нет данных ЕГРН"),
status: status ? real(status) : placeholder("нет данных ЕГРН"),
// encumbrances never arrive from the backend yet — always a stub.
encumbrances: notReal("источник ЕГРН"),
};
}
// ── Buildability gauge (soft proxy — PLACEHOLDER, isReal:false) ────────────────
/**
* Derive a soft buildability proxy from the gate verdict + geometry suitability.
* This is NOT a backend score — it's a preliminary heuristic, flagged with a
* "предварительная оценка" footnote so it never reads as a live index.
*/
export function adaptBuildabilityGauge(a: ParcelAnalysis): PticaGauge {
const gate = a.gate_verdict;
const geom = a.geometry_suitability;
// Gate contributes the bulk; geometry suitability nudges within the band.
let base: number | null = null;
let tone: PticaGauge["tone"] = "neutral";
let label = "нет данных";
if (gate) {
if (gate.can_build_mkd === true) {
base = 78;
tone = "good";
label = "Можно строить";
} else if (gate.can_build_mkd === false) {
base = 24;
tone = "bad";
label = "Нельзя";
} else {
base = 50;
tone = "warn";
label = "Нужна проверка";
}
// Each blocker/warning trims the proxy a little.
base -= gate.blockers.length * 8 + gate.warnings.length * 3;
}
// Geometry suitability (0..1) shifts the proxy ±10.
if (base != null && geom?.suitability_score != null) {
base += (geom.suitability_score - 0.5) * 20;
}
const value =
base == null ? null : Math.max(0, Math.min(100, Math.round(base)));
return {
value,
label,
tone,
isReal: false,
footnote: "предварительная оценка",
};
}
/**
* Risk gauge — inverse soft proxy from the same signals. Lower is better.
* PLACEHOLDER ("предв.") until the real risk model is wired.
*/
export function adaptRiskGauge(a: ParcelAnalysis): PticaGauge {
const build = adaptBuildabilityGauge(a);
const value = build.value == null ? null : 100 - build.value;
let tone: PticaGauge["tone"] = "neutral";
let label = "нет данных";
if (value != null) {
if (value <= 33) {
tone = "good";
label = "Низкий";
} else if (value <= 66) {
tone = "warn";
label = "Средний";
} else {
tone = "bad";
label = "Высокий";
}
}
return { value, label, tone, isReal: false, footnote: "предв." };
}
// ── Invest score block (PLACEHOLDER — после прогноза) ─────────────────────────
/** Semantic value tone for a hero stat-line (prototype .v.good / .v.warn). */
export type PticaValueTone = "good" | "warn" | "none";
export interface PticaInvestScore {
score: PticaField;
potential: PticaField;
risk: PticaField;
buySignal: PticaField;
/** Hero stat-line value color for «Потенциал». */
potentialTone: PticaValueTone;
/** Hero stat-line value color for «Риск». */
riskTone: PticaValueTone;
}
/**
* Buy-signal from the §22 forecast exec-summary key numbers. Derived from the
* (signed) deficit_index — the long `verdict` sentence is a paragraph, not a
* badge, so we map the structured KPI instead. >0.05 недонасыщенность →
* «приобретать», <0.05 затоварка → «пропустить», иначе «наблюдать». Flagged
* advisory (the forecast itself is advisory). Independent of the overall score,
* so it surfaces as soon as the report is ready even if scoring is thin.
*/
export function buySignalFromForecast(report: ForecastReport): PticaField {
const kn = report.exec_summary.key_numbers;
const di = kn.deficit_index;
if (di == null) {
// No forecast number → honest stub, distinct from a real «наблюдать».
return notReal("прогноз недоступен (Сценарии §22)");
}
const word =
di > 0.05 ? "приобретать" : di < -0.05 ? "пропустить" : "наблюдать";
return { value: word, isReal: true, caption: "advisory · §22" };
}
/**
* Invest-score view-model. INCREMENT 1: PLACEHOLDER until the §22 forecast is
* ready; PR#2: when `report` is present, the score is overall_score×10 (∈ 0..10)
* and the buy-signal is derived from the exec-summary KPIs. While the forecast is
* still pending (`report` undefined) the «после прогноза» placeholders stand.
*/
export function adaptInvestScore(
a: ParcelAnalysis,
report?: ForecastReport,
): PticaInvestScore {
const risk = adaptRiskGauge(a);
const riskField: PticaField =
risk.value != null
? { value: risk.label, isReal: false, caption: "предв." }
: placeholder("предв.");
// Hero «Риск» value color (prototype .v.warn): green when low, yellow otherwise.
const riskTone: PticaValueTone =
risk.value == null ? "none" : risk.tone === "good" ? "good" : "warn";
const overall =
report?.scoring?.overall ?? report?.exec_summary.key_numbers.overall_score;
// Buy-signal is derived from the deficit_index alone, so it surfaces whenever
// the report is ready — independent of whether the overall score is present.
const buySignal = report
? buySignalFromForecast(report)
: placeholder("после прогноза (Scenarios)");
if (report && overall != null) {
// overall_score ∈ 0..1 → /10 scale (one decimal), advisory.
const scoreOn10 = real(fmtNum(overall * 10, 1));
scoreOn10.caption = "advisory · §22";
return {
score: scoreOn10,
potential: placeholder("после финмодели"),
risk: riskField,
buySignal,
// Потенциал stays muted until the financial model surfaces a real value.
potentialTone: "none",
riskTone,
};
}
return {
score: placeholder("после прогноза (Scenarios)"),
potential: placeholder("после прогноза (Scenarios)"),
risk: riskField,
buySignal,
potentialTone: "none",
riskTone,
};
}
// ── Development Scan cards ─────────────────────────────────────────────────────
// subtype → ru label for the engineering card.
const UTILITY_LABELS: Record<string, string> = {
substation: "Подстанция",
pipeline: "Трубопровод",
power_line: "ЛЭП",
water_intake: "Водозабор",
pumping_station: "Насосная станция",
};
function utilityLabel(subtype: string): string {
return UTILITY_LABELS[subtype] ?? subtype;
}
/**
* Градостроительство — REAL ПЗЗ-регламент when nspd_zoning carries it
* (PR #1847), honest placeholder otherwise. Зона prefers the human index
* ("Ж-2"); КСИТ / высота / плотность light up once the backend field flows.
*/
export function adaptUrbanCard(a: ParcelAnalysis): PticaScanCard {
const z = a.nspd_zoning;
const zone = regulationZone(z);
// 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 = coerceFloat(z?.max_height_m);
const buildingPct = coerceFloat(z?.max_building_pct);
const areaM2 = parcelAreaM2(a);
// Плотность (КСИТ) ёмкость = площадь × КСИТ (м²), real only with both inputs.
const densityField =
maxFar != null
? areaM2 != null
? {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: `КСИТ ${formatFar(maxFar)} · НСПД`,
}
: { value: formatFar(maxFar), isReal: true, caption: "КСИТ · НСПД" }
: placeholder(SRC_NSPD_ZONING);
// Пятно застройки = площадь участка × коэф. застройки (max_building_pct).
// Reuses parcelAreaM2 (area_ha × 10000) — the same area КСИТ uses, and the same
// computation as the DEVELOPMENT POTENTIAL card (adaptPotentialDrawer).
const spotField: PticaField =
areaM2 != null && buildingPct != null
? {
value: `${formatInt(areaM2 * (buildingPct / 100))} м²`,
isReal: true,
caption: `${formatInt(buildingPct)} % площади · НСПД`,
}
: placeholder(SRC_NSPD_ZONING);
return {
title: "Градостроительство",
rows: [
{
// ВРИ (вид разрешённого использования) — real from ЕГРН, matches the
// prototype's first ГРАДОСТРОИТЕЛЬСТВО row.
key: "ВРИ",
field: a.egrn?.permitted_use_text
? { value: a.egrn.permitted_use_text, isReal: true, caption: "ЕГРН" }
: placeholder("нет данных ЕГРН"),
},
{
key: "Зона",
field: zone
? { value: zone, isReal: true, caption: "НСПД-зонирование" }
: placeholder(SRC_NSPD_ZONING),
},
{
key: "Пред. высота",
field:
maxHeight != null
? real(`${formatInt(maxHeight)} м`)
: placeholder(SRC_NSPD_ZONING),
},
{
key: "Плотность (КСИТ)",
field: densityField,
},
{
key: "Пятно застройки",
field: spotField,
},
],
};
}
/** Ограничения · ЗОУИТ — ЗОУИТ count is real; the rest placeholder. */
export function adaptRestrictionsCard(a: ParcelAnalysis): PticaScanCard {
const zouit = a.nspd_zouit_overlaps;
return {
title: "Ограничения · ЗОУИТ",
rows: [
{
key: "ЗОУИТ",
field:
zouit != null
? real(formatInt(zouit.length))
: placeholder("нет данных НСПД"),
},
{ key: "Красные линии", field: placeholder("источник НСПД-граддок") },
{ key: "ОКН", field: placeholder("источник НСПД") },
{ key: "Сервитуты", field: placeholder("источник ЕГРН") },
],
};
}
/** Инженерия — utilities.summary rows (subtype → label + nearest_m). REAL. */
export function adaptEngineeringCard(a: ParcelAnalysis): PticaScanCard {
const summary = a.utilities?.summary ?? [];
if (summary.length === 0) {
return {
title: "Инженерия",
rows: [],
emptyState: "Нет данных по инженерным сетям",
};
}
return {
title: "Инженерия",
// Prototype Инженерия renders status-rows (dot + label + state). The state
// text is the nearest-node distance; tone is «ok» when the узел is within
// ~1 km, «warn» when further (ограниченная доступность).
rows: summary.map((u) => ({
key: utilityLabel(u.subtype),
field: real(`${formatInt(u.nearest_m)} м`),
status: (u.nearest_m <= 1000 ? "ok" : "warn") as PticaStatusTone,
})),
};
}
/**
* Рынок — REAL data from the market layer of /analyze. Median (district),
* absorption (market_pulse.avg_velocity_m2), competitor count
* (market_pulse.competitors_total), pipeline объектов (pipeline_24mo) and the
* demand-trend label (market_trend.label). Honest «—» where a field is absent.
* Prototype row order: Медиана цены / Поглощение / Конкурентов / Новых проектов /
* Уровень спроса.
*/
export function adaptMarketCard(a: ParcelAnalysis): PticaScanCard {
const median = a.district?.median_price_per_m2;
const pulse = a.market_pulse;
const velocity = pulse?.avg_velocity_m2;
const competitors = pulse?.competitors_total;
const newProjects = a.pipeline_24mo?.objects_count;
const demandLabel = a.market_trend?.label;
return {
title: "Рынок",
rows: [
{
key: "Медиана цены",
field:
median != null
? real(formatRubPerM2(median))
: placeholder("нет данных района"),
},
{
key: "Поглощение",
field:
velocity != null
? {
value: `${fmtNum(velocity, 1)} м²/мес`,
isReal: true,
caption: "средний темп поглощения · рынок",
}
: placeholder("нет данных рынка"),
},
{
key: "Конкурентов",
field:
competitors != null
? real(formatInt(competitors))
: placeholder("нет данных рынка"),
},
{
key: "Новых проектов",
field:
newProjects != null
? real(formatInt(newProjects))
: placeholder("нет пайплайна"),
},
{
key: "Уровень спроса",
field: demandLabel
? { value: demandLabel, isReal: true, caption: "тренд цен · рынок" }
: placeholder("нет тренда рынка"),
},
],
};
}
/**
* Экономика — REAL когда backend отдаёт `financial_estimate` (#1881), иначе
* честный PLACEHOLDER (предв.) до финмодели. Выручка/маржа/ROI/IRR в реальных
* числах; caption у выручки говорит источник цены (рынок vs норматив класса),
* IRR помечается «(proxy)» когда `irr_is_proxy`.
*/
export function adaptEconomyCard(
financial?: ParcelFinancialEstimate | null,
): PticaScanCard {
if (!financial) {
return {
title: "Экономика",
badge: "предв.",
rows: [
{ key: "Потенц. выручки", field: placeholder("после финмодели") },
{ key: "Маржа", field: placeholder("после финмодели") },
{ key: "ROI", field: placeholder("после финмодели") },
{ key: "IRR", field: placeholder("после финмодели") },
],
};
}
const irrLabel = financial.irr_is_proxy
? `${formatPctFromFraction(financial.irr)} % (proxy)`
: `${formatPctFromFraction(financial.irr)} %`;
return {
title: "Экономика",
badge: "ориент.",
rows: [
{
key: "Потенц. выручки",
field: {
value: `${formatBln(financial.revenue_rub)} млрд ₽`,
isReal: true,
caption: financial.price_is_calibrated ? "рынок" : "норматив класса",
},
},
{
key: "Маржа",
field: real(`${formatPctFromFraction(financial.margin_pct)} %`),
},
{ key: "ROI", field: real(`${formatPctFromFraction(financial.roi)} %`) },
{ key: "IRR", field: real(irrLabel) },
],
};
}
/**
* Среда · экология — REAL atmospheric layer from /analyze. Шум (noise.estimated_db
* + level), Воздух (air_quality.pm2_5 — honest PM2.5 reading, NOT a fabricated AQI
* index), Ветер (wind dominant direction + max speed), Водоёмы (hydrology nearest
* water distance). Honest «—» where a field is absent.
*/
export function adaptEnvironmentCard(a: ParcelAnalysis): PticaScanCard {
const noise = a.noise;
const aq = a.air_quality;
const wind = a.wind;
const hydro = a.hydrology;
const nearestWater = hydro?.nearest?.[0]?.distance_m;
const noiseField: PticaField = noise
? {
value: `${fmtNum(noise.estimated_db, 1)} дБ`,
isReal: true,
caption: `уровень: ${noise.level}`,
}
: placeholder("нет данных шума");
// Воздух — честное прямое чтение PM2.5 (не выдуманный AQI-индекс).
const airField: PticaField = aq
? {
value: `PM2.5 ${fmtNum(aq.pm2_5, 1)}`,
isReal: true,
caption: "мкг/м³ · атмосферный слой",
}
: placeholder("нет данных AQI");
const windField: PticaField = wind
? wind.max_speed_m_s != null
? real(
`${wind.dominant_direction_label} · ${fmtNum(wind.max_speed_m_s, 1)} м/с`,
)
: real(wind.dominant_direction_label)
: placeholder("нет данных ветра");
return {
title: "Среда",
badge: "экология",
rows: [
{ key: "Шум", field: noiseField },
{ key: "Воздух (PM2.5)", field: airField },
{ key: "Ветер", field: windField },
{
key: "Водоёмы",
field:
nearestWater != null
? real(`${formatInt(nearestWater)} м`)
: placeholder("нет ближайшего водоёма"),
},
],
};
}
// (ОКС + Инсоляция moved to the lower-grid — see cockpit/OksCard + InsolationCard.)
// ══════════════════════════════════════════════════════════════════════════════
// DRAWER-LEVEL ADAPTERS (PR#4) — typed view-models, real-vs-placeholder centralized
// ══════════════════════════════════════════════════════════════════════════════
//
// Each drawer consumes a typed view-model below rather than reaching into
// ParcelAnalysis / ForecastReport in the React tree. REAL data is surfaced where
// the backend provides it; absent fields become honest `{value:"—", isReal:false,
// caption}` placeholders — NO fabricated numbers. `notReal()` rows are styled
// muted by the drawer content so they never read as live.
const DASH = "—";
function notReal(caption: string): PticaField {
return { value: DASH, isReal: false, caption };
}
/** A single "Параметр → Значение" row with real/placeholder provenance. */
export interface DrawerKvRow {
k: string;
field: PticaField;
}
function metersField(
m: number | null | undefined,
caption: string,
): PticaField {
return m != null ? real(`${formatInt(m)} м`) : notReal(caption);
}
// ── passport — full ЕГРН / НСПД (REAL) ────────────────────────────────────────
export interface PticaPassportDrawer {
summary: string;
registry: DrawerKvRow[];
zouitCount: number | null;
zouitOverlaps: Array<{ layer: string; name: string; sub: string }>;
}
export function adaptPassportDrawer(a: ParcelAnalysis): PticaPassportDrawer {
const p = adaptPassport(a);
const areaHa = a.geometry_suitability?.area_ha;
const zouit = a.nspd_zouit_overlaps ?? null;
const districtPart = a.district?.district_name
? `, ${a.district.district_name} р`
: "";
const summary =
`Участок ${a.cad_num}${districtPart}. ` +
(a.egrn?.land_category ? `Категория — ${a.egrn.land_category}. ` : "") +
(a.egrn?.permitted_use_text ? `ВРИ — ${a.egrn.permitted_use_text}. ` : "") +
(a.egrn?.parcel_status ? `Статус ЕГРН — ${a.egrn.parcel_status}.` : "");
const registry: DrawerKvRow[] = [
{ k: "Кадастровый номер", field: real(a.cad_num) },
{ k: "Адрес", field: p.address },
{ k: "Район", field: p.district },
{ k: "Площадь", field: p.area },
{
k: "Площадь, га",
field: areaHa != null ? real(formatHa(areaHa)) : notReal("нет геометрии"),
},
{ k: "Категория земель", field: p.landCategory },
{ k: "ВРИ", field: p.vri },
{ k: "Статус ЕГРН", field: p.status },
{
k: "Кадастровая стоимость",
field:
a.egrn?.cadastral_value_rub != null
? real(`${formatInt(a.egrn.cadastral_value_rub)}`)
: notReal("нет данных ЕГРН"),
},
{
k: "Стоимость, ₽/м²",
field:
a.egrn?.cost_per_m2_rub != null
? real(formatRubPerM2(a.egrn.cost_per_m2_rub))
: notReal("нет данных ЕГРН"),
},
{
k: "Дата обновления ЕГРН",
field: a.egrn?.last_egrn_update_date
? real(a.egrn.last_egrn_update_date)
: notReal("нет данных ЕГРН"),
},
{ k: "Обременения", field: notReal("источник ЕГРН") },
];
const zouitOverlaps = (zouit ?? []).map((z) => ({
layer: z.layer,
name: z.name ?? z.type_zone ?? z.category_name ?? "ЗОУИТ",
sub: z.subcategory != null ? String(z.subcategory) : DASH,
}));
return {
summary,
registry,
zouitCount: zouit != null ? zouit.length : null,
zouitOverlaps,
};
}
// ── buildability — factor breakdown (DERIVED / предв.) ─────────────────────────
export interface PticaFactorBar {
name: string;
pct: number;
value: string;
tone: "good" | "bad" | "neutral";
reason: string;
}
export interface PticaBuildabilityDrawer {
gauge: PticaGauge;
summary: string;
factors: PticaFactorBar[];
geometry: DrawerKvRow[];
penalties: Array<{ label: string; impact: string }>;
isReal: boolean;
}
export function adaptBuildabilityDrawer(
a: ParcelAnalysis,
): PticaBuildabilityDrawer {
const gauge = adaptBuildabilityGauge(a);
const gate = a.gate_verdict;
const geom = a.geometry_suitability;
const utilCount = a.utilities?.summary?.length ?? null;
// Soft, derived factor contributions — labelled «предв.» everywhere.
const factors: PticaFactorBar[] = [
{
name: "Гейт-вердикт",
pct:
gate?.can_build_mkd === true
? 90
: gate?.can_build_mkd === false
? 20
: 55,
value: gate?.verdict_label ?? DASH,
tone:
gate?.can_build_mkd === true
? "good"
: gate?.can_build_mkd === false
? "bad"
: "neutral",
reason: gate ? `источник: ${gate.source}` : "нет gate_verdict",
},
{
name: "Геометрия",
pct: geom?.suitability_score != null ? geom.suitability_score * 100 : 0,
value:
geom?.suitability_score != null
? `${fmtNum(geom.suitability_score * 10, 1)} / 10`
: DASH,
tone: "neutral",
reason: geom?.label ? `форма: ${geom.label}` : "нет геометрии",
},
{
name: "Инженерия",
pct: utilCount != null ? Math.min(100, utilCount * 20) : 0,
value: utilCount != null ? `${utilCount} сетей` : DASH,
tone: utilCount != null && utilCount >= 3 ? "good" : "neutral",
reason: "близость инж. узлов (utilities)",
},
];
// ── Пятно / продаваемая площадь — REAL when ПЗЗ max_far flows (PR #1847) ──
// КСИТ ёмкость (надземная GFA) = площадь × max_far. Footprint (пятно) needs
// the building-coverage % (max_building_pct); sellable still depends on
// МОП/parking, so it stays an estimate placeholder.
const z = a.nspd_zoning;
const areaM2 = parcelAreaM2(a);
const maxFar = coerceFloat(z?.max_far);
const farReal = maxFar != null;
const buildingPct = coerceFloat(z?.max_building_pct);
const spotRow: DrawerKvRow =
areaM2 != null && buildingPct != null
? {
k: "Пятно застройки",
field: {
value: `${formatInt(areaM2 * (buildingPct / 100))} м²`,
isReal: true,
caption: `${formatInt(buildingPct)} % площади · НСПД`,
},
}
: { k: "Пятно застройки", field: notReal(SRC_NSPD_ZONING) };
const sellableRow: DrawerKvRow =
farReal && areaM2 != null
? {
k: "Продаваемая площадь",
field: {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: "оценка по КСИТ-ёмкости · без вычета МОП/паркинга",
},
}
: { k: "Продаваемая площадь", field: notReal("нужен max_far (НСПД)") };
const geometry: DrawerKvRow[] = [
{
k: "Площадь",
field:
geom?.area_ha != null
? real(`${formatHa(geom.area_ha)} · ${areaM2FromHa(geom.area_ha)}`)
: notReal("нет геометрии"),
},
{
k: "Периметр",
field: metersField(geom?.perimeter_m, "нет геометрии"),
},
{
k: "Соотношение сторон",
field:
geom?.aspect_ratio != null
? real(fmtNum(geom.aspect_ratio, 2))
: notReal("нет геометрии"),
},
{
k: "Suitability score",
field:
geom?.suitability_score != null
? real(`${fmtNum(geom.suitability_score * 10, 1)} / 10`)
: notReal("нет геометрии"),
},
spotRow,
sellableRow,
];
const penalties = (geom?.penalties ?? []).map((p) => ({
label: p,
impact: "штраф",
}));
const summary = farReal
? "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
"участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
"значение появится после финмодели. КСИТ-ёмкость рассчитана по регламенту НСПД."
: "Индекс застраиваемости — предварительная оценка по гейт-вердикту, геометрии " +
"участка и близости инженерии. Это эвристика, а не итоговый индекс: точное " +
"значение появится после расчёта потенциала (max_far НСПД) и финмодели.";
return {
gauge,
summary,
factors,
geometry,
penalties,
isReal: false,
};
}
// ── urban — Градостроительство (mostly PLACEHOLDER) ───────────────────────────
export interface PticaUrbanDrawer {
summary: string;
regulation: DrawerKvRow[];
zoneCode: PticaField;
zoneName: PticaField;
}
export function adaptUrbanDrawer(a: ParcelAnalysis): PticaUrbanDrawer {
const zoning = a.nspd_zoning;
// Территориальная зона: prefer human index ("Ж-2"), else reg-number code.
const zone = regulationZone(zoning);
const zoneCode = zone ? real(zone) : notReal(SRC_NSPD_ZONING);
const zoneName = zoning?.zone_name
? real(zoning.zone_name)
: notReal(SRC_NSPD_ZONING);
const maxFar = coerceFloat(zoning?.max_far);
const maxHeight = coerceFloat(zoning?.max_height_m);
const buildingPct = coerceFloat(zoning?.max_building_pct);
const areaM2 = parcelAreaM2(a);
const regSource = zoning?.regulation_source;
// КСИТ ёмкость (надземная GFA) = площадь × max_far.
const densityField: PticaField =
maxFar != null && areaM2 != null
? {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: regSource
? `КСИТ ${formatFar(maxFar)} · ${regSource}`
: `КСИТ ${formatFar(maxFar)} · НСПД`,
}
: maxFar != null
? notReal("нужна геометрия участка")
: notReal("после расчёта потенциала");
const farReal = maxFar != null;
const regulation: DrawerKvRow[] = [
{ k: "Территориальная зона", field: zoneCode },
{ k: "Наименование зоны", field: zoneName },
{
k: "ВРИ (основной)",
field: a.egrn?.permitted_use_text
? real(a.egrn.permitted_use_text)
: notReal("нет данных ЕГРН"),
},
{
k: "КСИТ (max FAR)",
field: farReal
? real(formatFar(maxFar))
: notReal("нужен max_far (НСПД)"),
},
{
k: "Предельная высота",
field:
maxHeight != null
? real(`${formatInt(maxHeight)} м`)
: notReal(SRC_NSPD_ZONING),
},
{
k: "Этажность (предельная)",
field:
zoning?.max_floors != null
? real(`${formatInt(zoning.max_floors)} эт.`)
: notReal(SRC_NSPD_ZONING),
},
{
k: "Коэфф. застройки (пятно)",
field:
buildingPct != null
? real(`${formatInt(buildingPct)} %`)
: notReal(SRC_NSPD_ZONING),
},
{ k: "Плотность (КСИТ)", field: densityField },
];
const summary = farReal
? "Градостроительный регламент участка из НСПД-зонирования (ПЗЗ). " +
"Территориальная зона, КСИТ (max_far), предельная высота и коэффициент " +
"застройки — реальные регламентные значения; плотность рассчитана от " +
"площади участка."
: "Градостроительный регламент участка. Территориальная зона и ВРИ берутся из " +
"НСПД-зонирования / ЕГРН; КСИТ (max_far), предельная высота и плотность пока " +
"недоступны в выгрузке — отмечены «—» с источником.";
return { summary, regulation, zoneCode, zoneName };
}
// ── restrictions / legal — ЗОУИТ (REAL list) ──────────────────────────────────
export interface PticaRestrictionsDrawer {
summary: string;
zouitCount: number | null;
zouitRows: Array<{ layer: string; sub: string; reg: string }>;
statusRows: Array<{ label: string; state: string; tone: "ok" | "warn" }>;
}
export function adaptRestrictionsDrawer(
a: ParcelAnalysis,
): PticaRestrictionsDrawer {
const zouit = a.nspd_zouit_overlaps ?? null;
const count = zouit != null ? zouit.length : null;
const zouitRows = (zouit ?? []).map((z) => ({
layer: z.name ?? z.type_zone ?? z.layer,
sub:
z.category_name ?? (z.subcategory != null ? String(z.subcategory) : DASH),
reg: z.group_key,
}));
const statusRows: Array<{
label: string;
state: string;
tone: "ok" | "warn";
}> = [
{
label: "ЗОУИТ (зоны с особыми условиями)",
state: count != null ? `${count}` : "нет данных НСПД",
tone: count != null && count > 0 ? "warn" : "ok",
},
{
label: "Красные линии",
state:
a.nspd_red_lines != null
? `${a.nspd_red_lines.length}`
: "источник НСПД",
tone: (a.nspd_red_lines?.length ?? 0) > 0 ? "warn" : "ok",
},
{
label: "ОКН (объекты культ. наследия)",
state: "источник НСПД",
tone: "ok",
},
{ label: "Сервитуты", state: "источник ЕГРН", tone: "ok" },
];
const summary =
count != null
? `Зон с особыми условиями использования территории (ЗОУИТ): ${count}. ` +
"Красные линии, ОКН и сервитуты требуют отдельной выгрузки НСПД / ЕГРН."
: "Данные НСПД по ограничениям недоступны — ЗОУИТ, красные линии, ОКН и " +
"сервитуты отмечены источником.";
return { summary, zouitCount: count, zouitRows, statusRows };
}
export interface PticaLegalDrawer {
summary: string;
gate: DrawerKvRow[];
restrictions: Array<{ label: string; state: string; tone: "ok" | "warn" }>;
registry: DrawerKvRow[];
}
export function adaptLegalDrawer(a: ParcelAnalysis): PticaLegalDrawer {
const gate = a.gate_verdict;
const restr = adaptRestrictionsDrawer(a);
const gateRows: DrawerKvRow[] = [
{
k: "Можно строить МКД",
field: gate ? real(gate.verdict_label) : notReal("нет gate_verdict"),
},
{
k: "Статус участка",
field: a.egrn?.parcel_status
? real(a.egrn.parcel_status)
: notReal("нет данных ЕГРН"),
},
{
k: "Категория земель",
field: a.egrn?.land_category
? real(a.egrn.land_category)
: notReal("нет данных ЕГРН"),
},
{
k: "ВРИ",
field: a.egrn?.permitted_use_text
? real(a.egrn.permitted_use_text)
: notReal("нет данных ЕГРН"),
},
{
k: "Зона ПЗЗ",
field: a.nspd_zoning?.zone_code
? real(a.nspd_zoning.zone_code)
: notReal("источник НСПД"),
},
];
const registry: DrawerKvRow[] = [
{
k: "ЗОУИТ",
field:
restr.zouitCount != null
? real(formatInt(restr.zouitCount))
: notReal("источник НСПД"),
},
{ k: "Обременения", field: notReal("источник ЕГРН") },
{
k: "Блокеры (gate)",
field: gate
? real(formatInt(gate.blockers.length))
: notReal("нет gate_verdict"),
},
{
k: "Предупреждения (gate)",
field: gate
? real(formatInt(gate.warnings.length))
: notReal("нет gate_verdict"),
},
];
const summary =
"Правовой статус: гейт-вердикт о допустимости МКД (НСПД-дамп) + реестр " +
"ограничений. Обременения в текущей выгрузке не раскрываются («—»).";
return { summary, gate: gateRows, restrictions: restr.statusRows, registry };
}
// ── engineering — utilities.summary (REAL) ────────────────────────────────────
export interface PticaEngineeringDrawer {
summary: string;
rows: Array<{ label: string; nearest: string; name: string; count: string }>;
bars: PticaFactorBar[];
hasData: boolean;
}
export function adaptEngineeringDrawer(
a: ParcelAnalysis,
): PticaEngineeringDrawer {
const summaryArr = a.utilities?.summary ?? [];
const hasData = summaryArr.length > 0;
const rows = summaryArr.map((u) => ({
label: utilityLabel(u.subtype),
nearest: `${formatInt(u.nearest_m)} м`,
name: u.name ?? DASH,
count: u.count_within_2km != null ? formatInt(u.count_within_2km) : DASH,
}));
// bar = inverse distance (closer → fuller). Cap reference 2000 m.
const bars: PticaFactorBar[] = summaryArr.map((u) => ({
name: utilityLabel(u.subtype),
pct: Math.max(4, 100 - Math.min(100, (u.nearest_m / 2000) * 100)),
value: `${formatInt(u.nearest_m)} м`,
tone: u.nearest_m <= 700 ? "good" : "neutral",
reason: u.name ?? "",
}));
const summary = hasData
? `Инженерная обеспеченность: ${summaryArr.length} типов сетей в радиусе ` +
"анализа. Расстояния — до ближайшего узла каждого ресурса (НСПД)."
: "Данные по инженерным сетям недоступны для участка.";
return { summary, rows, bars, hasData };
}
// ── market — 4 sub-tabs (comp REAL · plan/speed/trend mixed) ───────────────────
export interface PticaMarketCompetitor {
name: string;
dev: string;
cls: string;
flats: string;
distance: string;
status: string;
}
export interface PticaMarketDrawer {
summary: string;
competitors: PticaMarketCompetitor[];
medianField: PticaField;
trend: {
field: PticaField;
recent: string;
prior: string;
count: string;
available: boolean;
};
speed: {
field: PticaField;
velocityScore: string;
monthly: string;
median: string;
confidence: string;
available: boolean;
};
plan: {
available: boolean;
note: string;
pipelineByClass: Array<{ cls: string; count: number }>;
};
}
function competitorRow(c: ParcelAnalysisCompetitor): PticaMarketCompetitor {
return {
name: c.comm_name ?? `#${c.obj_id}`,
dev: c.dev_name ?? DASH,
cls: c.obj_class ?? DASH,
flats: c.flat_count != null ? formatInt(c.flat_count) : DASH,
distance: `${formatInt(c.distance_m)} м`,
status: c.site_status ?? DASH,
};
}
export function adaptMarketDrawer(a: ParcelAnalysis): PticaMarketDrawer {
const competitors = (a.competitors ?? []).slice(0, 12).map(competitorRow);
const median = a.district?.median_price_per_m2;
const medianField =
median != null
? real(formatRubPerM2(median))
: notReal("нет данных района");
const mt = a.market_trend;
// P2 (#1871): тренд «настоящий» только при status "ok" (или старом ответе без
// status, где числовые поля присутствуют). source_stale/no_deals → честный
// placeholder с причиной вместо фейкового процента.
const mtStatus = mt?.status;
const mtComputed =
mt != null &&
(mtStatus == null || mtStatus === "ok") &&
mt.delta_6m_pct != null &&
mt.recent_avg_price_per_m2 != null &&
mt.prior_avg_price_per_m2 != null;
const trendCaption =
mtStatus === "source_stale"
? "источник сделок устарел"
: mtStatus === "no_deals"
? "нет сделок за период"
: "после прогноза";
const trend = {
field: mtComputed
? real(
`${mt!.delta_6m_pct! >= 0 ? "+" : ""}${fmtNum(mt!.delta_6m_pct!, 1)}%`,
)
: notReal(trendCaption),
recent: mtComputed ? formatRubPerM2(mt!.recent_avg_price_per_m2!) : DASH,
prior: mtComputed ? formatRubPerM2(mt!.prior_avg_price_per_m2!) : DASH,
count: mt != null ? `${formatInt(mt.recent_deals_count)} сделок` : DASH,
available: mtComputed,
};
const v = a.velocity ?? null;
const speed = {
field:
v != null
? real(`${fmtNum(v.velocity_score * 10, 1)} / 10`)
: notReal("после прогноза"),
velocityScore: v != null ? fmtNum(v.velocity_score * 10, 1) : DASH,
monthly: v != null ? `${formatInt(v.monthly_velocity_sqm)} м²/мес` : DASH,
median: v != null ? `${formatInt(v.ekb_median_sqm)} м²/мес` : DASH,
confidence: v != null ? v.confidence : DASH,
available: v != null,
};
const pipeline = a.pipeline_24mo ?? null;
const pipelineByClass = pipeline
? Object.entries(pipeline.by_class).map(([cls, count]) => ({ cls, count }))
: [];
const plan = {
available: pipeline != null,
note:
pipeline != null
? `Пайплайн 24 мес: ${pipeline.objects_count} проектов, ` +
`${formatInt(pipeline.flats_total)} квартир.`
: "Планировки и квартирография — после прогноза (§22).",
pipelineByClass,
};
const summary =
`Конкурентов в радиусе анализа: ${competitors.length}` +
(median != null ? `, медиана ${formatRubPerM2(median)}` : "") +
(pipeline != null
? `, ${pipeline.objects_count} проектов в пайплайне`
: "") +
". Тренд, скорость и планировки уточняются прогнозом §22.";
return { summary, competitors, medianField, trend, speed, plan };
}
// ── environment (Среда) — noise / AQI / wind / weather (REAL) ──────────────────
export interface PticaEnvironmentDrawer {
summary: string;
noise: {
rows: DrawerKvRow[];
sources: Array<{ name: string; db: string; dist: string }>;
available: boolean;
};
air: { rows: DrawerKvRow[]; available: boolean };
wind: { rows: DrawerKvRow[]; available: boolean };
weather: { rows: DrawerKvRow[]; available: boolean };
}
const WIND_DIR_NOTE = "источник: прогноз ветра";
export function adaptEnvironmentDrawer(
a: ParcelAnalysis,
): PticaEnvironmentDrawer {
const n = a.noise;
const aq = a.air_quality;
const w = a.wind;
const wt = a.weather;
const noiseRows: DrawerKvRow[] = n
? [
{ k: "Уровень", field: real(n.level) },
{ k: "Оценка шума", field: real(`${formatInt(n.estimated_db)} дБ`) },
{ k: "Скор", field: real(fmtNum(n.score, 2)) },
{ k: "Источников шума", field: real(formatInt(n.sources.length)) },
]
: [{ k: "Шум", field: notReal("нет данных шума") }];
const airRows: DrawerKvRow[] = aq
? [
{ k: "PM2.5", field: real(`${fmtNum(aq.pm2_5, 1)} мкг/м³`) },
{ k: "PM10", field: real(`${fmtNum(aq.pm10, 1)} мкг/м³`) },
{ k: "NO₂", field: real(`${fmtNum(aq.no2, 1)} мкг/м³`) },
{ k: "Источник", field: real(aq.source) },
]
: [{ k: "Воздух", field: notReal("нет данных AQI") }];
const windRows: DrawerKvRow[] = w
? [
{
k: "Направление",
field: real(
`${w.dominant_direction_label} (${formatInt(w.dominant_direction_deg)}°)`,
),
},
{
k: "Макс. скорость",
field:
w.max_speed_m_s != null
? real(`${fmtNum(w.max_speed_m_s, 1)} м/с`)
: notReal(WIND_DIR_NOTE),
},
{
k: "Горизонт прогноза",
field: real(`${formatInt(w.forecast_days)} дн.`),
},
]
: [{ k: "Ветер", field: notReal("нет данных ветра") }];
const weatherRows: DrawerKvRow[] = wt
? [
{
k: "Ср. макс. °C",
field:
wt.temperature.avg_max_c != null
? real(`${fmtNum(wt.temperature.avg_max_c, 1)} °C`)
: notReal("нет данных погоды"),
},
{
k: "Ср. мин. °C",
field:
wt.temperature.avg_min_c != null
? real(`${fmtNum(wt.temperature.avg_min_c, 1)} °C`)
: notReal("нет данных погоды"),
},
{
k: "Осадки (сумма)",
field: real(`${formatInt(wt.precipitation_total_mm)} мм`),
},
{ k: "Дней с осадками", field: real(formatInt(wt.precipitation_days)) },
]
: [{ k: "Погода", field: notReal("нет данных погоды") }];
const sources = (n?.sources ?? []).map((s) => ({
name: s.name ?? s.source_type,
db: `${formatInt(s.estimated_db)} дБ`,
dist: `${formatInt(s.distance_m)} м`,
}));
const summary =
"Средовые факторы участка: шум, качество воздуха, преобладающий ветер и " +
"погодный фон. Данные из атмосферного слоя /analyze.";
return {
summary,
noise: { rows: noiseRows, sources, available: n != null },
air: { rows: airRows, available: aq != null },
wind: { rows: windRows, available: w != null },
weather: { rows: weatherRows, available: wt != null },
};
}
// ── georisks — geology / hydrology / geotech (REAL where present) ──────────────
export interface PticaGeorisksDrawer {
summary: string;
geology: {
rows: DrawerKvRow[];
links: Array<{ label: string; href: string }>;
available: boolean;
};
hydrology: { rows: DrawerKvRow[]; available: boolean };
geotech: { rows: DrawerKvRow[]; available: boolean };
}
export function adaptGeorisksDrawer(a: ParcelAnalysis): PticaGeorisksDrawer {
const g = a.geology;
const h = a.hydrology;
const gt = a.geotech_risk;
const geologyRows: DrawerKvRow[] = g
? [
{
k: "Данные геологии",
field: g.data_available
? real("доступны")
: notReal("нет ИГИ — нужен полевой расчёт"),
},
{ k: "Примечание", field: g.note ? real(g.note) : notReal("—") },
]
: [{ k: "Геология", field: notReal("нет данных геологии") }];
const geologyLinks = g
? [
{ label: "Карпинский WebGIS", href: g.links.karpinsky_webgis },
{ label: "ЕФГИ (фед. реестр)", href: g.links.efgi_federal_registry },
]
: [];
const hydrologyRows: DrawerKvRow[] = h
? [
{
k: "Риск подтопления",
field: real(h.flood_risk_flag ? "есть" : "нет"),
},
{
k: "Ближайший водоём",
field:
h.nearest.length > 0
? real(`${formatInt(h.nearest[0].distance_m)} м`)
: notReal("нет данных"),
},
{ k: "Примечание", field: h.note ? real(h.note) : notReal("—") },
]
: [{ k: "Гидрология", field: notReal("нет данных гидрологии") }];
const geotechRows: DrawerKvRow[] = gt
? [
{ k: "Сейсмика", field: real(gt.seismic_label) },
{
k: "Баллы (сейсм.)",
field:
gt.seismic_intensity_balls != null
? real(formatInt(gt.seismic_intensity_balls))
: notReal("нет данных"),
},
{
k: "Многолетняя мерзлота",
field: real(gt.permafrost ? "есть" : "нет"),
},
{
k: "Пром. объектов <500 м",
field: real(formatInt(gt.industrial_within_500m)),
},
]
: [{ k: "Геотехника", field: notReal("нет данных геотехники") }];
const summary =
"Инженерно-геологический фон участка: геология, гидрология и геотехнические " +
"риски. Поля без полевых данных отмечены «—» (нужны ИГИ).";
return {
summary,
geology: { rows: geologyRows, links: geologyLinks, available: g != null },
hydrology: { rows: hydrologyRows, available: h != null },
geotech: { rows: geotechRows, available: gt != null },
};
}
// ── risks — composite breakdown (DERIVED / предв.) ─────────────────────────────
export interface PticaRisksDrawer {
gauge: PticaGauge;
summary: string;
breakdown: PticaFactorBar[];
blockers: Array<{ code: string; detail: string }>;
warnings: Array<{ code: string; detail: string }>;
isReal: boolean;
}
export function adaptRisksDrawer(a: ParcelAnalysis): PticaRisksDrawer {
const gauge = adaptRiskGauge(a);
const gate = a.gate_verdict;
const geom = a.geometry_suitability;
const n = a.noise;
const breakdown: PticaFactorBar[] = [
{
name: "Правовые / гейт",
pct: gate
? Math.min(100, gate.blockers.length * 30 + gate.warnings.length * 12)
: 0,
value: gate
? `${gate.blockers.length} блок. · ${gate.warnings.length} пред.`
: DASH,
tone: (gate?.blockers.length ?? 0) > 0 ? "bad" : "good",
reason: gate ? "из gate_verdict" : "нет gate_verdict",
},
{
name: "Геометрия",
pct:
geom?.suitability_score != null
? (1 - geom.suitability_score) * 100
: 0,
value:
geom?.suitability_score != null
? `${fmtNum((1 - geom.suitability_score) * 10, 1)} / 10`
: DASH,
tone: "neutral",
reason: geom?.label ? geom.label : "нет геометрии",
},
{
name: "Шум / среда",
pct: n != null ? Math.min(100, (n.estimated_db / 80) * 100) : 0,
value: n != null ? `${formatInt(n.estimated_db)} дБ · ${n.level}` : DASH,
tone: n != null && n.estimated_db >= 60 ? "bad" : "neutral",
reason: n != null ? "из шумового слоя" : "нет данных шума",
},
];
const summary =
"Сводный риск — предварительная композиция из гейт-вердикта, геометрии и " +
"средовых факторов. Это эвристика («предв.»), а не итоговая риск-модель.";
return {
gauge,
summary,
breakdown,
blockers: gate?.blockers ?? [],
warnings: gate?.warnings ?? [],
isReal: false,
};
}
// ── product — §22 product_tz («после прогноза» while absent) ───────────────────
export interface PticaProductDrawer {
available: boolean;
summary: string;
objClass: PticaField;
mix: Array<{ bucket: string; cls: string; signal: string; pct: number }>;
usp: Array<{ text: string; cls: string }>;
reasons: string[];
}
function mixBarPct(entry: ProductMixEntry, total: number): number {
if (entry.pct != null) return entry.pct;
// No explicit share → even split as a visual hint only.
return total > 0 ? Math.round(100 / total) : 0;
}
export function adaptProductDrawer(
report?: ForecastReport,
): PticaProductDrawer {
const tz = report?.product_tz;
if (!tz) {
return {
available: false,
summary:
"Продуктовая стратегия (класс, квартирография, USP) формируется в " +
"асинхронном прогнозе §22. Откройте вкладку «Сценарии», чтобы запустить расчёт.",
objClass: notReal("после прогноза (Scenarios)"),
mix: [],
usp: [],
reasons: [],
};
}
const mix = tz.mix.map((m) => ({
bucket: m.bucket ?? DASH,
cls: m.obj_class ?? DASH,
signal: m.signal ?? DASH,
pct: mixBarPct(m, tz.mix.length),
}));
const usp = tz.usp.map((u) => ({
text: u.usp_text ?? DASH,
cls: u.obj_class ?? DASH,
}));
const reasons = tz.reasons
.map((r) => r.why)
.filter((w): w is string => Boolean(w));
return {
available: true,
summary: tz.summary ?? "Рекомендация продукта из прогноза §22 (advisory).",
objClass: tz.obj_class
? { value: tz.obj_class, isReal: true, caption: "advisory · §22" }
: notReal("нет рекомендации класса"),
mix,
usp,
reasons,
};
}
// ── potential — ёмкость (PLACEHOLDER — нужен max_far) ──────────────────────────
export interface PticaPotentialDrawer {
summary: string;
rows: DrawerKvRow[];
}
export function adaptPotentialDrawer(a: ParcelAnalysis): PticaPotentialDrawer {
const areaHa = a.geometry_suitability?.area_ha;
const areaField =
areaHa != null
? real(`${areaM2FromHa(areaHa)} (${formatHa(areaHa)})`)
: notReal("нет геометрии");
const z = a.nspd_zoning;
const areaM2 = parcelAreaM2(a);
const maxFar = coerceFloat(z?.max_far);
const farReal = maxFar != null;
const buildingPct = coerceFloat(z?.max_building_pct);
const maxHeight = coerceFloat(z?.max_height_m);
const maxFloors = z?.max_floors;
// КСИТ ёмкость (надземная GFA) = площадь × max_far — real with both inputs.
const capacityField: PticaField =
farReal && areaM2 != null
? {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: `КСИТ ${formatFar(maxFar)} · площадь × КСИТ`,
}
: notReal("нужен max_far (НСПД)");
// Пятно застройки = площадь × коэф. застройки (max_building_pct).
const spotField: PticaField =
areaM2 != null && buildingPct != null
? {
value: `${formatInt(areaM2 * (buildingPct / 100))} м²`,
isReal: true,
caption: `${formatInt(buildingPct)} % площади · НСПД`,
}
: notReal("нужен % застройки (НСПД)");
// Расчётная этажность — прямо из регламента, либо высота / 3 м (этаж).
const floorsField: PticaField =
maxFloors != null
? real(`${formatInt(maxFloors)} эт.`)
: maxHeight != null
? {
value: `${formatInt(maxHeight / 3)} эт.`,
isReal: true,
caption: `от предельной высоты ${formatInt(maxHeight)} м`,
}
: notReal("нужна предельная высота (НСПД)");
const rows: DrawerKvRow[] = [
{ k: "Площадь участка", field: areaField },
{
k: "КСИТ (max_far)",
field: farReal
? real(formatFar(maxFar))
: notReal("нужен max_far (НСПД)"),
},
{ k: "Ёмкость по КСИТ", field: capacityField },
{ k: "Пятно застройки", field: spotField },
{
k: "Продаваемая площадь",
field:
farReal && areaM2 != null
? {
value: `${formatInt(areaM2 * maxFar)} м²`,
isReal: true,
caption: "оценка по КСИТ-ёмкости · без вычета МОП/паркинга",
}
: notReal("после расчёта потенциала"),
},
{ k: "Расчётная этажность", field: floorsField },
];
const summary = farReal
? "Расчёт ёмкости по регламенту НСПД-зонирования: ёмкость по КСИТ = площадь × " +
"max_far, пятно — по коэффициенту застройки. Продаваемая площадь — оценка " +
"(без вычета МОП и паркинга), уточняется финмоделью."
: "Расчёт ёмкости (плотность, пятно, продаваемая площадь) требует предельного " +
"КСИТ (max_far) и высоты из НСПД-зонирования. Доступна только площадь участка.";
return { summary, rows };
}
// ── economy / finance — PLACEHOLDER (после финмодели) ──────────────────────────
export interface PticaEconomyDrawer {
summary: string;
rows: DrawerKvRow[];
}
export function adaptEconomyDrawer(): PticaEconomyDrawer {
return {
summary:
"Экономика проекта (выручка, маржа, ROI, IRR) рассчитывается после " +
"финансовой модели (§22). Пока показатели недоступны.",
rows: [
{ k: "Потенц. выручка (GDV)", field: notReal("после финмодели") },
{ k: "Себестоимость", field: notReal("после финмодели") },
{ k: "Маржа", field: notReal("после финмодели") },
{ k: "ROI", field: notReal("после финмодели") },
{ k: "IRR", field: notReal("после финмодели") },
],
};
}
export function adaptFinanceDrawer(
financial?: ParcelFinancialEstimate | null,
): PticaEconomyDrawer {
if (!financial) {
return {
summary:
"Финансовая модель (P&L, cash-flow по фазам, сценарный анализ) формируется " +
"в асинхронном расчёте §22. Откройте «Сценарии», чтобы запустить прогноз.",
rows: [
{ k: "GDV (выручка)", field: notReal("после финмодели") },
{ k: "Совокупные затраты", field: notReal("после финмодели") },
{ k: "Чистая прибыль", field: notReal("после финмодели") },
{ k: "ROI / IRR", field: notReal("после финмодели") },
{ k: "Срок проекта", field: notReal("после финмодели") },
],
};
}
const teap = financial.teap_synth;
const priceSourceSuffix = priceSourceCaption(financial.price_source);
const priceLabel = financial.price_is_calibrated
? "рынок (Objective)"
: `норматив класса${priceSourceSuffix ? ` · ${priceSourceSuffix}` : ""}`;
const irrLabel = financial.irr_is_proxy
? `${formatPctFromFraction(financial.irr)} % · оценочный (proxy)`
: `${formatPctFromFraction(financial.irr)} % · DCF`;
const paybackLabel =
financial.payback_months != null
? `${fmtNum(financial.payback_months / 12)} лет (${formatInt(
financial.payback_months,
)} мес)`
: "не окупается в горизонте модели";
// #1881 PR-5 — финансирование (кредит). Показываем строки только когда
// financing_enabled; проценты — реальная стоимость (чистая прибыль после
// финансирования < чистой прибыли до). financing_is_simplified → честный
// caveat в summary, чтобы финдиректор не принял упрощение за точную модель.
const financingRows: DrawerKvRow[] = financial.financing_enabled
? [
{
k: "Пиковый долг",
field: real(`${formatBln(financial.peak_debt_rub)} млрд ₽`),
},
{
k: "Проценты по кредиту",
field: {
value: `${formatBln(financial.total_interest_rub)} млрд ₽`,
isReal: true,
caption: `ставка ${formatPctFromFraction(
financial.annual_rate_used,
)} % годовых`,
},
},
{
k: "Чистая прибыль после финанс.",
field: real(
`${formatBln(financial.net_profit_after_financing_rub)} млрд ₽`,
),
},
{
// IRR на собственные средства (equity) при кредитном плече LTC 70 %.
// Может быть ВЫШЕ проектной IRR (положительный леверидж) ИЛИ НИЖЕ /
// отрицательной (проект не покрывает стоимость долга) — это корректная
// финансовая картина, не прячем знак. isReal только когда финмодель
// посчитала equity-IRR по DCF; proxy/выкл. финансирование → не real.
k: "IRR на собственные средства",
field: financial.levered_irr_is_proxy
? {
value: `${formatSignedPctFromFraction(financial.levered_irr)} %`,
isReal: false,
caption:
"доходность на собственные средства при кредите LTC 70 % · " +
"оценка (proxy), не DCF · может быть ниже проектной IRR, если " +
"проект не покрывает стоимость долга",
}
: {
value: `${formatSignedPctFromFraction(financial.levered_irr)} %`,
isReal: true,
caption:
"доходность на собственные средства при кредите LTC 70 % · " +
"может быть ниже проектной IRR, если проект не покрывает " +
"стоимость долга",
},
},
]
: [];
const summary =
financial.financing_enabled && financial.financing_is_simplified
? `${financial.caveat} Финансирование: 70 % — банк (кредитная линия, норматив МКД РФ), ` +
"30 % — собственные средства инвестора (без процентов). Проценты капитализируются, " +
"эскроу не моделируется точно."
: financial.caveat;
return {
summary,
rows: [
...financingRows,
{
k: "GDV (выручка)",
field: real(`${formatBln(financial.revenue_rub)} млрд ₽`),
},
{
k: "Совокупные затраты",
field: real(`${formatBln(financial.cost_rub)} млрд ₽`),
},
{
k: "Чистая прибыль",
field: real(`${formatBln(financial.net_profit_rub)} млрд ₽`),
},
{ k: "ROI", field: real(`${formatPctFromFraction(financial.roi)} %`) },
{
k: "Маржа",
field: real(`${formatPctFromFraction(financial.margin_pct)} %`),
},
{ k: "IRR", field: real(irrLabel) },
{ k: "NPV", field: real(`${formatBln(financial.npv_rub)} млрд ₽`) },
{
k: "Ставка дисконт.",
field: real(`${formatPctFromFraction(financial.discount_rate_used)} %`),
},
{ k: "Срок окупаемости", field: real(paybackLabel) },
{
k: "Цена ₽/м²",
field: real(
`${formatRubPerM2(financial.price_per_sqm_used)} · ${priceLabel}`,
),
},
{
k: "Класс жилья",
field: real(
_HOUSING_CLASS_RU[financial.housing_class_inferred] ??
financial.housing_class_inferred,
),
},
{
k: "Тип застройки",
field: real(
_DEVELOPMENT_TYPE_RU[financial.development_type_inferred] ??
financial.development_type_inferred,
),
},
{
k: "Горизонт продаж",
field:
financial.sales_duration_months != null
? {
value: `${fmtNum(financial.sales_duration_months, 0)} мес`,
isReal: true,
caption: financial.schedule_is_default
? "дефолт-норматив"
: "по темпу поглощения рынка",
}
: notReal("нет данных"),
},
{
k: "ТЭП · жилая площадь",
field: real(`${formatInt(teap.residential_area_sqm)} м²`),
},
{
k: "ТЭП · общая площадь",
field: real(`${formatInt(teap.total_floor_area_sqm)} м²`),
},
{ k: "ТЭП · квартир", field: real(formatInt(teap.apartments_count)) },
{ k: "ТЭП · машино-мест", field: real(formatInt(teap.parking_spaces)) },
],
};
}
// ── oks — Нет данных ──────────────────────────────────────────────────────────
export interface PticaOksDrawer {
summary: string;
available: boolean;
}
export function adaptOksDrawer(): PticaOksDrawer {
return {
summary:
"Объекты капитального строительства на участке в текущей выгрузке /analyze " +
"не раскрываются. Раздел появится после интеграции реестра ОКС (ЕГРН).",
available: false,
};
}
// ══════════════════════════════════════════════════════════════════════════════
// COCKPIT LOWER-GRID / BOTTOM-GRID SECTIONS — typed view-models for the cards
// rendered below DevelopmentScan (porting the prototype `.lower-grid` /
// `.bottom-grid`). Same {value, isReal, caption} honesty contract: REAL where the
// backend provides it, honest "—"/placeholder otherwise. No fabricated numbers.
// ══════════════════════════════════════════════════════════════════════════════
// ── ОКС (lower-grid card) — empty-state «Нет данных» ───────────────────────────
export interface PticaOksLowerCard {
rows: DrawerKvRow[];
/** When false the card shows the honest empty-state instead of rows. */
hasData: boolean;
emptyState: string;
}
/**
* ОКС on the parcel are not surfaced by /analyze, so this is an honest
* empty-state card. The isometric building visual is rendered by the React
* component; this adapter only carries the (currently empty) data + message.
*/
export function adaptOksLowerCard(): PticaOksLowerCard {
return {
rows: [
{ k: "ОКСы на участке", field: notReal("нет реестра ОКС (ЕГРН)") },
{ k: "Общая площадь", field: notReal("нет реестра ОКС (ЕГРН)") },
{ k: "Статус", field: notReal("нет реестра ОКС (ЕГРН)") },
],
hasData: false,
emptyState: "Нет данных",
};
}
// ── Development Potential (lower-grid card) — REAL (post-#1847) ─────────────────
/** One big-metric tile: a label, a value (split unit) + real/placeholder flag. */
export interface PticaPotentialMetric {
label: string;
value: string;
unit?: string;
isReal: boolean;
caption?: string;
/** Larger type for the two headline tiles (площадь, КСИТ). */
lead?: boolean;
}
export interface PticaPotentialCard {
metrics: PticaPotentialMetric[];
}
/**
* Development Potential — REAL ёмкость-метрики из геометрии + НСПД-регламента,
* mirroring `adaptPotentialDrawer` but as the compact lower-grid tiles. Площадь
* участка (geometry), КСИТ (max_far), плотность = площадь×КСИТ, предельная
* высота, пятно = площадь×max_building_pct, продаваемая ≈ ёмкость по КСИТ.
* Honest «—» (placeholder) where an input is absent.
*/
export function adaptPotentialCard(a: ParcelAnalysis): PticaPotentialCard {
const areaHa = a.geometry_suitability?.area_ha;
const areaM2 = parcelAreaM2(a);
const z = a.nspd_zoning;
const maxFar = coerceFloat(z?.max_far);
const maxHeight = coerceFloat(z?.max_height_m);
const buildingPct = coerceFloat(z?.max_building_pct);
const areaMetric: PticaPotentialMetric =
areaM2 != null
? {
label: "Площадь участка",
value: formatInt(areaM2),
unit: "м²",
isReal: true,
lead: true,
}
: {
label: "Площадь участка",
value: PLACEHOLDER,
isReal: false,
caption: "нет геометрии",
lead: true,
};
const farMetric: PticaPotentialMetric =
maxFar != null
? {
label: "КСИТ",
value: formatFar(maxFar),
isReal: true,
caption: "НСПД",
lead: true,
}
: {
label: "КСИТ",
value: PLACEHOLDER,
isReal: false,
caption: SRC_NSPD_ZONING,
lead: true,
};
const densityMetric: PticaPotentialMetric =
maxFar != null && areaM2 != null
? {
label: "Плотность (КСИТ)",
value: formatInt(areaM2 * maxFar),
unit: "м²",
isReal: true,
caption: `площадь × КСИТ ${formatFar(maxFar)}`,
}
: {
label: "Плотность (КСИТ)",
value: PLACEHOLDER,
isReal: false,
caption: SRC_NSPD_ZONING,
};
const heightMetric: PticaPotentialMetric =
maxHeight != null
? {
label: "Предельная высота",
value: formatInt(maxHeight),
unit: "м",
isReal: true,
caption: "НСПД",
}
: {
label: "Предельная высота",
value: PLACEHOLDER,
isReal: false,
caption: SRC_NSPD_ZONING,
};
const spotMetric: PticaPotentialMetric =
areaM2 != null && buildingPct != null
? {
label: "Пятно застройки",
value: formatInt(areaM2 * (buildingPct / 100)),
unit: "м²",
isReal: true,
caption: `${formatInt(buildingPct)} % площади · НСПД`,
}
: {
label: "Пятно застройки",
value: PLACEHOLDER,
isReal: false,
caption: SRC_NSPD_ZONING,
};
const sellableMetric: PticaPotentialMetric =
maxFar != null && areaM2 != null
? {
label: "Продаваемая",
value: `${formatInt(areaM2 * maxFar)}`,
unit: "м²",
isReal: true,
caption: "оценка по КСИТ-ёмкости · без МОП/паркинга",
}
: {
label: "Продаваемая",
value: PLACEHOLDER,
isReal: false,
caption: "после расчёта потенциала",
};
return {
metrics: [
areaMetric,
farMetric,
densityMetric,
heightMetric,
spotMetric,
sellableMetric,
],
};
}
// ── Investment Clearance (bottom-grid) — finance bignums (PLACEHOLDER, §23) ─────
export interface PticaClearanceBignum {
label: string;
value: string;
unit?: string;
isReal: boolean;
caption?: string;
}
export interface PticaInvestmentClearance {
bignums: PticaClearanceBignum[];
caption: string;
}
/**
* Investment Clearance — GDV/COST/PROFIT + ROI/IRR/NPV/срок окуп.
*
* When the backend supplies `financial_estimate` (#1881) the bignums carry REAL
* numbers (isReal:true): суммы в млрд ₽, ROI/IRR в %, NPV, срок окупаемости. Цены
* либо откалиброваны под рынок (Objective), либо взяты по нормативу класса —
* caption честно говорит источник. IRR помечается «оценочный (proxy)» когда
* `irr_is_proxy`. Caveat backend-а сжимается в общий caption — расчёт по
* МАКСИМАЛЬНОЙ застройке НСПД, а не по реальной концепции.
*
* When `financial` is null/undefined the §23 model is not yet available, so EVERY
* value stays an honest placeholder «—» with a «после финмодели (§23)» caption —
* NO fabricated numbers.
*/
export function adaptInvestmentClearance(
financial?: ParcelFinancialEstimate | null,
): PticaInvestmentClearance {
if (!financial) {
const fin = "после финмодели (§23)";
return {
bignums: [
{
label: "GDV · выручка",
value: PLACEHOLDER,
unit: "млрд ₽",
isReal: false,
caption: fin,
},
{
label: "Cost · затраты",
value: PLACEHOLDER,
unit: "млрд ₽",
isReal: false,
caption: fin,
},
{
label: "Profit · прибыль",
value: PLACEHOLDER,
unit: "млрд ₽",
isReal: false,
caption: fin,
},
{ label: "ROI", value: PLACEHOLDER, isReal: false, caption: fin },
{ label: "IRR", value: PLACEHOLDER, isReal: false, caption: fin },
],
caption: "после финмодели (§23)",
};
}
const priceSourceSuffix = priceSourceCaption(financial.price_source);
const priceCaption = financial.price_is_calibrated
? "цена: рынок (Objective)"
: `цена: норматив класса${
priceSourceSuffix ? ` · ${priceSourceSuffix}` : ""
}`;
const irrCaption = financial.irr_is_proxy ? "оценочный (proxy)" : "DCF";
const paybackValue =
financial.payback_months != null
? `${(financial.payback_months / 12).toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})} лет`
: "не окуп.";
return {
bignums: [
{
label: "GDV · выручка",
value: formatBln(financial.revenue_rub),
unit: "млрд ₽",
isReal: true,
caption: priceCaption,
},
{
label: "Cost · затраты",
value: formatBln(financial.cost_rub),
unit: "млрд ₽",
isReal: true,
},
{
label: financial.financing_enabled
? "Profit · до финанс."
: "Profit · прибыль",
value: formatBln(financial.net_profit_rub),
unit: "млрд ₽",
isReal: true,
},
...(financial.financing_enabled
? [
{
label: "После финанс.",
value: formatBln(financial.net_profit_after_financing_rub),
unit: "млрд ₽",
isReal: true,
caption: `кредит ${formatPctFromFraction(financial.annual_rate_used)} %`,
},
]
: []),
{
label: "ROI",
value: `${formatPctFromFraction(financial.roi)} %`,
isReal: true,
},
{
label: "IRR",
value: `${formatPctFromFraction(financial.irr)} %`,
isReal: true,
caption: irrCaption,
},
{
label: "NPV",
value: formatBln(financial.npv_rub),
unit: "млрд ₽",
isReal: true,
},
{
label: "Срок окуп.",
value: paybackValue,
isReal: true,
},
],
caption: "по макс. застройке НСПД · ориентировочно",
};
}
// ── Buy Signal (bottom-grid) — gauge from §22 forecast deficit_index ───────────
export interface PticaBuySignalCard {
gauge: PticaGauge;
/** The verdict word shown under the gauge (Приобретать / Наблюдать / Пропустить). */
word: string;
available: boolean;
caption: string;
}
/**
* Buy Signal — derived from the §22 forecast exec-summary deficit_index, the SAME
* logic as `adaptInvestScore`'s buySignal (>0.05 приобретать / <0.05 пропустить /
* else наблюдать). The gauge value maps the (signed) deficit_index onto 0..100 for
* the visual; advisory (§22). While the forecast is pending → «после прогноза».
*/
export function adaptBuySignalGauge(
report?: ForecastReport,
): PticaBuySignalCard {
if (!report) {
return {
gauge: {
value: null,
label: "после прогноза",
tone: "neutral",
isReal: false,
footnote: "Scenarios §22",
},
word: PLACEHOLDER,
available: false,
caption: "после прогноза (Scenarios)",
};
}
const di = report.exec_summary.key_numbers.deficit_index;
// Report present but no deficit_index → the gauge has no real metric. Mirror
// the "no report" branch: muted track, neutral tone, isReal:false. Never dress
// a synthetic mid-scale 50 up as a live number.
if (di == null) {
return {
gauge: {
value: null,
label: "после прогноза",
tone: "neutral",
isReal: false,
footnote: "Scenarios §22",
},
word: PLACEHOLDER,
available: false,
caption: "после прогноза (Scenarios)",
};
}
// Map signed deficit_index (roughly 0.5..+0.5) onto a 0..100 gauge value.
const gaugeValue = Math.max(0, Math.min(100, Math.round(50 + di * 100)));
let tone: PticaGauge["tone"] = "neutral";
let word = "Наблюдать";
if (di > 0.05) {
tone = "good";
word = "Приобретать";
} else if (di < -0.05) {
tone = "bad";
word = "Пропустить";
}
return {
gauge: {
value: gaugeValue,
label: word,
tone,
isReal: true,
footnote: "advisory · §22",
},
word,
available: true,
caption: "advisory · §22",
};
}
// ── Legal Status (bottom-grid) — ОКН / сервитуты / красные линии / ЗОУИТ ────────
export interface PticaLegalStatus {
rows: DrawerKvRow[];
}
/**
* Legal Status — REAL where /analyze provides it (ЗОУИТ count, красные линии,
* gate verdict label), honest placeholder for the rest (ОКН / сервитуты /
* аренда / ограничения оборота / обременения — not in the dump). Mirrors the
* prototype's Legal table (Нет / значение rows).
*/
export function adaptLegalStatus(a: ParcelAnalysis): PticaLegalStatus {
const zouit = a.nspd_zouit_overlaps ?? null;
const redLines = a.nspd_red_lines ?? null;
const gate = a.gate_verdict;
const rows: DrawerKvRow[] = [
{
k: "ЗОУИТ",
field:
zouit != null
? real(formatInt(zouit.length))
: notReal("источник НСПД"),
},
{
k: "Красные линии",
field:
redLines != null
? real(redLines.length > 0 ? formatInt(redLines.length) : "Нет")
: notReal("источник НСПД-граддок"),
},
{ k: "ОКН", field: notReal("источник НСПД") },
{ k: "Сервитуты", field: notReal("источник ЕГРН") },
{ k: "Аренда", field: notReal("источник ЕГРН") },
{
k: "Огранич. оборота",
field: gate
? real(
gate.blockers.length > 0
? `${formatInt(gate.blockers.length)} блок.`
: "Нет",
)
: notReal("источник ЕГРН"),
},
];
return { rows };
}
// ── Site Verdict (bottom-grid) — headline plaque + checklist ───────────────────
export interface PticaVerdictCheckItem {
text: string;
tone: "ok" | "warn" | "bad";
}
export interface PticaSiteVerdict {
/** The big verdict word (Приобретать / Можно / Нельзя / Нужна проверка). */
word: string;
tone: "good" | "warn" | "bad";
checklist: PticaVerdictCheckItem[];
}
/**
* Site Verdict — the JTBD «стоит ли брать участок» answer. The verdict word comes
* from gate_verdict.can_build_mkd (true → «Приобретать» green, false → «Нельзя»
* red, "unknown" → «Нужна проверка» amber). The checklist is derived from REAL
* signals where available (рыночный спрос via median price, потенциал застройки
* via max_far, инженерная обеспеченность via utilities count) and falls back to
* the gate blockers/warnings.
*/
export function adaptSiteVerdict(
a: ParcelAnalysis,
report?: ForecastReport,
): PticaSiteVerdict {
const gate = a.gate_verdict;
let word = "Нужна проверка";
let tone: PticaSiteVerdict["tone"] = "warn";
if (gate) {
if (gate.can_build_mkd === true) {
if (gate.verdict_label === "С ограничениями") {
word = "С ограничениями";
tone = "warn";
} else {
word = "Приобретать";
tone = "good";
}
} else if (gate.can_build_mkd === false) {
word = "Нельзя";
tone = "bad";
}
}
const checklist: PticaVerdictCheckItem[] = [];
// Рыночный спрос — derived from §22 deficit_index when ready, else from the
// presence of a district median (proxy «рынок отслеживается»).
const di = report?.exec_summary.key_numbers.deficit_index ?? null;
if (di != null) {
if (di > 0.05) {
checklist.push({ text: "Высокий спрос", tone: "ok" });
checklist.push({ text: "Дефицит предложения", tone: "ok" });
} else if (di < -0.05) {
checklist.push({ text: "Признаки затоварки рынка", tone: "warn" });
} else {
checklist.push({ text: "Рынок сбалансирован", tone: "warn" });
}
} else if (a.district?.median_price_per_m2 != null) {
checklist.push({ text: "Рынок района отслеживается", tone: "ok" });
}
// Потенциал застройки — REAL when КСИТ flows from НСПД.
if (a.nspd_zoning?.max_far != null) {
checklist.push({ text: "Потенциал застройки (КСИТ)", tone: "ok" });
} else {
checklist.push({ text: "Потенциал застройки не рассчитан", tone: "warn" });
}
// Инженерная обеспеченность — REAL from utilities count.
const utilCount = a.utilities?.summary?.length ?? 0;
if (utilCount >= 3) {
checklist.push({ text: "Хорошая инженерная обеспеченность", tone: "ok" });
} else if (utilCount > 0) {
checklist.push({
text: "Частичная инженерная обеспеченность",
tone: "warn",
});
} else {
checklist.push({ text: "Нет данных по инженерии", tone: "warn" });
}
// Gate blockers/warnings — surface the real risk items.
if (gate) {
for (const b of gate.blockers.slice(0, 2)) {
checklist.push({ text: b.detail || b.code, tone: "bad" });
}
for (const w of gate.warnings.slice(0, 2)) {
checklist.push({ text: w.detail || w.code, tone: "warn" });
}
}
// Always show at least one item so the plaque never reads empty.
if (checklist.length === 0) {
checklist.push({ text: "Недостаточно данных для чек-листа", tone: "warn" });
}
return { word, tone, checklist };
}
// ── Map geometry ──────────────────────────────────────────────────────────────
/** EKB fallback center (lat, lon). */
export const EKB_CENTER: [number, number] = [56.838, 60.6];