From 67b65c8d8ad4f3bca44d5e6d6a0600a21daf66e7 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 23 Jun 2026 22:44:04 +0500 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20bridge=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BD=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20(DCF)=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BA=D0=BF=D0=B8=D1=82=20Investment=20Clearance?= =?UTF-8?q?=20(epic=20#1881=20PR-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investment Clearance в site-finder кокпите больше не «—»: синтезируем лёгкий ТЭП из buildability участка (площадь + предельные параметры зоны НСПД/ПЗЗ — КСИТ/%застройки/этажность) → прогоняем через полноценную финмодель (каскад затрат PR-1 + калибр.цена PR-2 + помесячный DCF PR-3) → GDV/Cost/Profit/ROI/ IRR/NPV/PBP в кокпите. Backend: - new app/services/site_finder/parcel_financial.py (ЧИСТЫЕ функции, без БД): synthesize_teap_from_buildability (GFA=area×max_far, degradation pct+floors; residential=GFA×eff; apartments/parking — нормативы teap.py, не дублируются) + synthesize_parcel_financial (housing_class из цены, development_type из этажей, land_cost=кадастровая). None когда нельзя строить МКД / нет зонинга / нет площади. - parcels.py: 1 вызов в try/except (hot-path-safe → None при сбое) + ключ financial_estimate в /analyze. Схема не тронута (AnalyzeResponse extra=allow, codegen не нужен). +18 тестов. Frontend: - ParcelFinancialEstimate тип (site-finder.ts, hand-typed). - adaptInvestmentClearance(financial)/adaptFinanceDrawer(financial): реальные числа (GDV/Cost/Profit/ROI/IRR + NPV + срок окуп.) или честный placeholder когда null. Локализация класса/типа (high_rise→высотная). Прокинут analysis через PticaBottomGrid/drawer-registry. +6 тестов, 144 passed. HEAVY caveat везде: ОРИЕНТИРОВОЧНАЯ модель по МАКС.застройке НСПД, НЕ реальная концепция; land=кадастровая (не рыночная); график — типовой; IRR proxy-флаг. mypy strict clean (generative.*), ruff/tsc/lint clean. Закрывает эпик #1881 (финмодель = полноценная DCF, видна в кокпите). Follow-up: финансирование (кредит/займы), §22 sales-pace, гео-радиус калибровки. Refs #1881 --- backend/app/api/v1/parcels.py | 30 ++ .../services/site_finder/parcel_financial.py | 295 ++++++++++++++ .../site_finder/test_parcel_financial.py | 373 ++++++++++++++++++ .../analysis/[cad]/ptica/ptica.module.css | 6 +- .../__tests__/ptica-adapt.honesty.test.ts | 94 ++++- .../ptica/cockpit/InvestmentClearance.tsx | 19 +- .../ptica/cockpit/PticaBottomGrid.tsx | 2 +- .../ptica/drawers/DrawerContent.tsx | 22 +- .../ptica/drawers/drawer-registry.tsx | 2 +- .../site-finder/ptica/ptica-adapt.ts | 232 +++++++++-- frontend/src/types/site-finder.ts | 34 ++ 11 files changed, 1067 insertions(+), 42 deletions(-) create mode 100644 backend/app/services/site_finder/parcel_financial.py create mode 100644 backend/tests/services/site_finder/test_parcel_financial.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 3cda9516..f9a1aa02 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -69,6 +69,7 @@ from app.services.site_finder.custom_pois import ( from app.services.site_finder.developer_attribution import get_developer_attribution from app.services.site_finder.gate_verdict import compute_gate_verdict from app.services.site_finder.ird_analyze import build_ird_analyze_block +from app.services.site_finder.parcel_financial import synthesize_parcel_financial from app.services.site_finder.poi_score import ( PoiScoreResponse, compute_poi_routing_decay, @@ -2953,6 +2954,31 @@ def analyze_parcel( "рекомендация носит условный характер" ) + # PR-4 (#1881): мост финмодели — синтез лёгкого ТЭП из buildability участка + # (площадь + предельные параметры зоны НСПД/ПЗЗ) → полноценный DCF + # (compute_financial). ОРИЕНТИРОВОЧНАЯ оценка по максимальной застройке, НЕ по + # реальной концепции (caveat внутри результата). Hot-path-safe: любой сбой → + # financial_estimate=None, analyze НЕ падает. Чистый сервис (без БД). + financial_estimate: dict[str, Any] | None = None + try: + _fin_area_m2 = egrn_block.get("area_m2") + if _fin_area_m2 is None: + _fin_area_m2 = _polygon_suitability(geom_wkt).get("area_m2") + _fin_cad_value = egrn_block.get("cadastral_value_rub") + if _fin_cad_value is None and parcel_meta is not None: + _fin_cad_value = parcel_meta.cad_cost + financial_estimate = synthesize_parcel_financial( + area_m2=_fin_area_m2, + nspd_zoning=nspd_dump_data["nspd_zoning"], + market_price=market_price, + district_price_median=district_price_block["district_price_per_m2_median"], + cadastral_value_rub=_fin_cad_value, + gate_verdict=gate_verdict, + ) + except Exception as e: + logger.warning("financial_estimate bridge failed for %s: %s", cad_num, e) + financial_estimate = None + result_payload: dict[str, Any] = { "cad_num": cad_num, "source": source, @@ -3052,6 +3078,10 @@ def analyze_parcel( # #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner # (вычислен выше, до сборки payload — нужен для gate_caveat #1740). "gate_verdict": gate_verdict, + # PR-4 (#1881): мост финмодели — ОРИЕНТИРОВОЧНЫЙ DCF по максимальной + # застройке зоны (синтез ТЭП из buildability). dict|None (None когда gate + # против / нет чисел зоны / нет площади). caveat внутри. См. parcel_financial. + "financial_estimate": financial_estimate, # #114/#201: кастомные веса POI — source + applied dict для прозрачности. "weights_profile": { "source": _weights_source, diff --git a/backend/app/services/site_finder/parcel_financial.py b/backend/app/services/site_finder/parcel_financial.py new file mode 100644 index 00000000..80e2b773 --- /dev/null +++ b/backend/app/services/site_finder/parcel_financial.py @@ -0,0 +1,295 @@ +"""Site Finder ↔ финмодель bridge — лёгкий ТЭП из buildability → DCF (PR-4, #1881). + +Участок в ``/analyze`` несёт **buildability** (площадь + предельные параметры зоны +НСПД/ПЗЗ: КСИТ/max_far, % застройки, этажность) + рыночную цену + gate-вердикт, но +НЕ полную проектную концепцию (нет реальной расстановки секций). Чтобы дать +девелоперу ОРИЕНТИРОВОЧНУЮ финмодель прямо на карточке участка, синтезируем лёгкий +ТЭП из предельных параметров зоны и прогоняем его через готовый +:func:`compute_financial` (полноценный статический каскад + помесячный DCF, PR-1/2/3). + +Обе функции **чистые** — без БД / LLM / внешних API. Данные участка прокидывает +caller (analyze-эндпоинт) уже извлечёнными. Результат — ``dict`` (``FinancialModel`` ++ bridge-метаданные) или ``None``, когда считать нельзя (gate против, нет чисел зоны, +нет площади). + +ВАЖНО — HONEST CAVEAT +--------------------- +Это финмодель по МАКСИМАЛЬНОЙ застройке согласно градрегламенту, НЕ по реальной +концепции: ТЭП синтезирован из предельных параметров зоны; класс жилья и темп +продаж — рыночные/нормативные прокси; ``land_cost`` = кадастровая стоимость (не +рыночная); график фаз — типовой. Caveat кладётся в результат (ключ ``caveat``) и +обязан показываться в UI/PDF рядом с цифрами. +""" + +from __future__ import annotations + +import logging +import math +from typing import Any + +from app.schemas.concept import TEAP +from app.services.generative.financial import compute_financial + +# Переиспользуем нормативные константы ТЭП из Stage 1c (НЕ дублируем): эффективность +# площади, средний лот, норма парковки по классу. Имена private (`_`) — импортируем +# их напрямую как single-source-of-truth, чтобы синтез не разошёлся с compute_teap. +from app.services.generative.teap import ( + _AVG_APARTMENT_SQM, + _EFFICIENCY_BY_CLASS, + _PARKING_PER_APARTMENT, + HousingClass, +) + +logger = logging.getLogger(__name__) + +# ── HEAVY caveat — обязателен в любом результате (UI/PDF MUST показать) ───────── +_FINANCIAL_CAVEAT: str = ( + "ОРИЕНТИРОВОЧНАЯ финмодель по МАКСИМАЛЬНОЙ застройке согласно градрегламенту " + "НСПД/ПЗЗ (КСИТ/высота/%застройки), НЕ по реальной проектной концепции. " + "ТЭП синтезирован из предельных параметров зоны; класс жилья и темп продаж — " + "рыночные/нормативные прокси; land_cost = кадастровая стоимость (не рыночная); " + "график фаз — типовой." +) + +# ── Класс жилья по цене продажи, руб/кв.м (рыночный прокси) ───────────────────── +# price < 125k → эконом · < 177k → комфорт · иначе → бизнес. None цены → комфорт +# (нейтральный дефолт массового сегмента ЕКБ). +_HOUSING_CLASS_ECONOM_MAX: float = 125_000.0 +_HOUSING_CLASS_COMFORT_MAX: float = 177_000.0 + +# ── Тип застройки по этажности (длительность СМР в DCF-графике) ───────────────── +# <=4 → spot · 5–8 → mid_rise · >=9 → high_rise. +_DEV_TYPE_SPOT_MAX_FLOORS: int = 4 +_DEV_TYPE_MID_RISE_MAX_FLOORS: int = 8 + + +def _infer_housing_class(price_per_sqm: float | None) -> HousingClass: + """Класс жилья из цены продажи (рыночный прокси). None → comfort (дефолт).""" + if price_per_sqm is None: + return "comfort" + if price_per_sqm < _HOUSING_CLASS_ECONOM_MAX: + return "econom" + if price_per_sqm < _HOUSING_CLASS_COMFORT_MAX: + return "comfort" + return "business" + + +def _infer_development_type(max_floors: int | None) -> str: + """Тип застройки из этажности зоны. None → mid_rise (нейтральный дефолт).""" + if max_floors is None: + return "mid_rise" + if max_floors <= _DEV_TYPE_SPOT_MAX_FLOORS: + return "spot" + if max_floors <= _DEV_TYPE_MID_RISE_MAX_FLOORS: + return "mid_rise" + return "high_rise" + + +def synthesize_teap_from_buildability( + *, + area_m2: float | None, + max_far: float | None, + max_building_pct: float | None, + max_floors: int | None, + housing_class: HousingClass, +) -> TEAP | None: + """Синтезировать лёгкий :class:`TEAP` из buildability-параметров зоны. + + Формулы (максимальная застройка по градрегламенту): + + * ``GFA`` (total_floor_area) = ``area × max_far``; если ``max_far`` нет, но есть + ``max_building_pct`` + ``max_floors`` → ``GFA = area × (pct/100) × floors``. + * ``built`` (пятно) = ``area × (max_building_pct/100)``; если % нет → + оценка ``GFA / max_floors`` (или ``GFA`` при отсутствии этажности). + * ``residential`` = ``GFA × efficiency[class]`` (вычет МОП/тех по классу). + * ``apartments`` = ``floor(residential / avg_apt[class])``. + * ``parking`` = ``ceil(apartments × parking_norm[class])``. + * ``density`` = ``GFA / area`` (= ``max_far``). + + Возвращает ``None``, если нет площади ИЛИ нельзя вычислить GFA (нет ни + ``max_far``, ни пары ``max_building_pct`` + ``max_floors``). + """ + if area_m2 is None or area_m2 <= 0: + return None + + # ── GFA: предпочитаем КСИТ/max_far; иначе % застройки × этажность ─────────── + gfa: float + if max_far is not None and max_far > 0: + gfa = area_m2 * max_far + elif ( + max_building_pct is not None + and max_building_pct > 0 + and max_floors is not None + and max_floors > 0 + ): + gfa = area_m2 * (max_building_pct / 100.0) * max_floors + else: + # Ни КСИТ, ни (%застройки + этажность) — GFA не вывести. + return None + + # ── Пятно застройки (built) ──────────────────────────────────────────────── + if max_building_pct is not None and max_building_pct > 0: + built_area = area_m2 * (max_building_pct / 100.0) + elif max_floors is not None and max_floors > 0: + # Нет %застройки → пятно ≈ GFA / этажность. + built_area = gfa / max_floors + else: + built_area = gfa + + efficiency = _EFFICIENCY_BY_CLASS[housing_class] + residential_area = gfa * efficiency + + avg_apartment = _AVG_APARTMENT_SQM[housing_class] + apartments_count = math.floor(residential_area / avg_apartment) if avg_apartment else 0 + + parking_norm = _PARKING_PER_APARTMENT[housing_class] + parking_spaces = math.ceil(apartments_count * parking_norm) + + # Плотность застройки = FAR = GFA / площадь участка (= max_far, если он задан). + density = gfa / area_m2 if area_m2 > 0 else 0.0 + + return TEAP( + built_area_sqm=round(built_area, 1), + total_floor_area_sqm=round(gfa, 1), + residential_area_sqm=round(residential_area, 1), + apartments_count=apartments_count, + density=round(density, 3), + parking_spaces=parking_spaces, + ) + + +def synthesize_parcel_financial( + *, + area_m2: float | None, + nspd_zoning: dict[str, Any] | None, + market_price: dict[str, Any] | None, + district_price_median: float | None, + cadastral_value_rub: float | None, + gate_verdict: dict[str, Any] | None, +) -> dict[str, Any] | None: + """Мост buildability участка → ОРИЕНТИРОВОЧНАЯ финмодель (DCF) или ``None``. + + Gate-guard: считаем ТОЛЬКО когда gate допускает МКД и есть числа зоны + площадь. + + Args: + area_m2: площадь участка, кв.м (из parcel_meta/EGRN или geometry_suitability). + nspd_zoning: блок зоны НСПД/ПЗЗ — берём ``max_far`` / ``max_building_pct`` / + ``max_floors`` (merged regulation-поля из EKB-geoportal, #1067). + market_price: квартальная ценовая статистика (берём ``median``). + district_price_median: медиана цены по району, руб/кв.м (fallback к market). + cadastral_value_rub: кадастровая стоимость участка → land_cost (НЕ рыночная). + gate_verdict: can-build-MKD вердикт — гейт на расчёт. + + Returns: + ``dict`` = ``FinancialModel.model_dump()`` + bridge-метаданные + (``housing_class_inferred`` / ``development_type_inferred`` / ``teap_synth`` / + ``caveat`` / ``schedule_is_default``), либо ``None`` когда считать нельзя. + """ + # ── Gate-guard: только при разрешённом МКД ───────────────────────────────── + can_build = (gate_verdict or {}).get("can_build_mkd") + if can_build is not True: + # False (запрещено) или "unknown" (нет данных) — финмодель не строим. + return None + + # ── Числа зоны обязательны ───────────────────────────────────────────────── + if not nspd_zoning: + return None + max_far = _as_float(nspd_zoning.get("max_far")) + max_building_pct = _as_float(nspd_zoning.get("max_building_pct")) + max_floors = _as_int(nspd_zoning.get("max_floors")) + # Нужен хотя бы КСИТ ИЛИ (%застройки + этажность) — иначе GFA не вывести. + if max_far is None and not (max_building_pct is not None and max_floors is not None): + return None + + # ── Площадь обязательна ──────────────────────────────────────────────────── + if area_m2 is None or area_m2 <= 0: + return None + + # ── Цена продажи: market median → district median (рыночный прокси) ───────── + price: float | None = None + price_source = "class_norm" + if market_price and _as_float(market_price.get("median")) is not None: + price = _as_float(market_price.get("median")) + price_source = "objective_district_median" + elif district_price_median is not None: + price = float(district_price_median) + price_source = "district_reference" + + housing_class = _infer_housing_class(price) + development_type = _infer_development_type(max_floors) + + # ── Синтез ТЭП из предельных параметров зоны ─────────────────────────────── + teap = synthesize_teap_from_buildability( + area_m2=area_m2, + max_far=max_far, + max_building_pct=max_building_pct, + max_floors=max_floors, + housing_class=housing_class, + ) + if teap is None: + return None + + # ── Полноценный статический каскад + помесячный DCF (PR-1/2/3) ────────────── + fin = compute_financial( + teap=teap, + housing_class=housing_class, + land_cost_rub=cadastral_value_rub, + market_price_per_sqm=price, + price_source=price_source, + development_type=development_type, + ) + + logger.info( + "parcel financial bridge: area=%.0f far=%s floors=%s class=%s dev=%s " + "gfa=%.0f resid=%.0f apts=%d npv=%.0f irr=%.3f%s", + area_m2, + max_far, + max_floors, + housing_class, + development_type, + teap.total_floor_area_sqm, + teap.residential_area_sqm, + teap.apartments_count, + fin.npv_rub, + fin.irr, + " (proxy)" if fin.irr_is_proxy else "", + ) + + return { + # Полный FinancialModel-каскад (revenue/cost/net/roi/margin/irr/npv/pbp + цена). + **fin.model_dump(), + # ── Bridge-метаданные (синтез из buildability) ───────────────────────── + "housing_class_inferred": housing_class, + "development_type_inferred": development_type, + "teap_synth": { + "residential_area_sqm": teap.residential_area_sqm, + "total_floor_area_sqm": teap.total_floor_area_sqm, + "parking_spaces": teap.parking_spaces, + "apartments_count": teap.apartments_count, + }, + # HEAVY caveat — обязан показываться в UI/PDF рядом с цифрами. + "caveat": _FINANCIAL_CAVEAT, + } + + +def _as_float(value: Any) -> float | None: + """Best-effort numeric coercion → ``float`` или ``None`` (нечисла/bool → None).""" + if value is None or isinstance(value, bool): + return None + try: + out = float(value) + except (ValueError, TypeError): + return None + return out if math.isfinite(out) else None + + +def _as_int(value: Any) -> int | None: + """Best-effort numeric coercion → ``int`` или ``None`` (нечисла/bool → None).""" + f = _as_float(value) + return int(f) if f is not None else None + + +__all__ = [ + "synthesize_parcel_financial", + "synthesize_teap_from_buildability", +] diff --git a/backend/tests/services/site_finder/test_parcel_financial.py b/backend/tests/services/site_finder/test_parcel_financial.py new file mode 100644 index 00000000..347f8960 --- /dev/null +++ b/backend/tests/services/site_finder/test_parcel_financial.py @@ -0,0 +1,373 @@ +"""Unit-тесты моста финмодели Site Finder ↔ DCF (PR-4, #1881). + +Тестируем чистые функции parcel_financial (без БД, без app.main): + • synthesize_teap_from_buildability: GFA по max_far / по (%застройки × этажность); + None когда нет ни far, ни (pct+floors), нет площади; + • synthesize_parcel_financial gate-guard: can_build_mkd False/"unknown"/нет zoning/ + нет площади → None; валидный участок → dict с gdv/npv/irr + caveat; + • housing_class по порогам цены; development_type по этажности; + • интеграция реалистичного участка → нормальный DCF (irr_is_proxy False). + +Детерминированно, без LLM / БД. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import math + +from app.schemas.concept import TEAP +from app.services.site_finder.parcel_financial import ( + _infer_development_type, + _infer_housing_class, + synthesize_parcel_financial, + synthesize_teap_from_buildability, +) + + +def _gate_ok() -> dict: + return {"can_build_mkd": True, "verdict_label": "Можно"} + + +def _zoning_far(max_far: float = 3.0, max_floors: int = 12) -> dict: + return { + "zone_code": "Ж-4", + "max_far": max_far, + "max_building_pct": 40.0, + "max_floors": max_floors, + } + + +# ── synthesize_teap_from_buildability ───────────────────────────────────────── + + +def test_teap_from_max_far() -> None: + """max_far есть → GFA = area × far, residential = GFA × eff, parking > 0.""" + area = 5000.0 + far = 3.0 + teap = synthesize_teap_from_buildability( + area_m2=area, + max_far=far, + max_building_pct=40.0, + max_floors=12, + housing_class="comfort", + ) + assert teap is not None + # GFA = area × far + assert teap.total_floor_area_sqm == round(area * far, 1) + # residential = GFA × efficiency[comfort=0.78] + assert teap.residential_area_sqm == round(area * far * 0.78, 1) + # density = FAR + assert teap.density == round(far, 3) + assert teap.apartments_count > 0 + assert teap.parking_spaces > 0 + # built = area × pct/100 + assert teap.built_area_sqm == round(area * 0.40, 1) + + +def test_teap_from_pct_and_floors_when_no_far() -> None: + """Нет max_far, но есть %застройки + этажность → GFA = area × pct/100 × floors.""" + area = 4000.0 + pct = 30.0 + floors = 9 + teap = synthesize_teap_from_buildability( + area_m2=area, + max_far=None, + max_building_pct=pct, + max_floors=floors, + housing_class="econom", + ) + assert teap is not None + expected_gfa = area * (pct / 100.0) * floors + assert teap.total_floor_area_sqm == round(expected_gfa, 1) + # residential = GFA × efficiency[econom=0.82] + assert teap.residential_area_sqm == round(expected_gfa * 0.82, 1) + + +def test_teap_none_when_no_far_no_pct_floors() -> None: + """Ни max_far, ни (%застройки + этажность) → None (GFA не вывести).""" + assert ( + synthesize_teap_from_buildability( + area_m2=5000.0, + max_far=None, + max_building_pct=None, + max_floors=None, + housing_class="comfort", + ) + is None + ) + # %застройки есть, но этажности нет → тоже None. + assert ( + synthesize_teap_from_buildability( + area_m2=5000.0, + max_far=None, + max_building_pct=40.0, + max_floors=None, + housing_class="comfort", + ) + is None + ) + + +def test_teap_none_when_no_area() -> None: + """Нет площади → None.""" + assert ( + synthesize_teap_from_buildability( + area_m2=None, + max_far=3.0, + max_building_pct=40.0, + max_floors=12, + housing_class="comfort", + ) + is None + ) + assert ( + synthesize_teap_from_buildability( + area_m2=0.0, + max_far=3.0, + max_building_pct=40.0, + max_floors=12, + housing_class="comfort", + ) + is None + ) + + +def test_teap_parking_uses_class_norm() -> None: + """parking = ceil(apartments × norm[class]); business норма 1.5 > comfort 1.0.""" + teap = synthesize_teap_from_buildability( + area_m2=5000.0, + max_far=3.0, + max_building_pct=40.0, + max_floors=12, + housing_class="business", + ) + assert teap is not None + expected_parking = math.ceil(teap.apartments_count * 1.5) + assert teap.parking_spaces == expected_parking + + +# ── housing_class по порогам цены ───────────────────────────────────────────── + + +def test_infer_housing_class_thresholds() -> None: + assert _infer_housing_class(None) == "comfort" # None → comfort + assert _infer_housing_class(100_000.0) == "econom" # < 125k + assert _infer_housing_class(124_999.0) == "econom" + assert _infer_housing_class(125_000.0) == "comfort" # >= 125k, < 177k + assert _infer_housing_class(150_000.0) == "comfort" + assert _infer_housing_class(176_999.0) == "comfort" + assert _infer_housing_class(177_000.0) == "business" # >= 177k + assert _infer_housing_class(250_000.0) == "business" + + +# ── development_type по этажности ───────────────────────────────────────────── + + +def test_infer_development_type_thresholds() -> None: + assert _infer_development_type(None) == "mid_rise" # дефолт + assert _infer_development_type(3) == "spot" # <= 4 + assert _infer_development_type(4) == "spot" + assert _infer_development_type(5) == "mid_rise" # 5-8 + assert _infer_development_type(8) == "mid_rise" + assert _infer_development_type(9) == "high_rise" # >= 9 + assert _infer_development_type(24) == "high_rise" + + +# ── synthesize_parcel_financial gate-guard ──────────────────────────────────── + + +def test_financial_none_when_gate_forbids() -> None: + """can_build_mkd False → None.""" + assert ( + synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning=_zoning_far(), + market_price={"median": 150_000.0}, + district_price_median=140_000.0, + cadastral_value_rub=50_000_000.0, + gate_verdict={"can_build_mkd": False, "verdict_label": "Нельзя"}, + ) + is None + ) + + +def test_financial_none_when_gate_unknown() -> None: + """can_build_mkd "unknown" → None.""" + assert ( + synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning=_zoning_far(), + market_price={"median": 150_000.0}, + district_price_median=140_000.0, + cadastral_value_rub=50_000_000.0, + gate_verdict={"can_build_mkd": "unknown", "verdict_label": "Нужна проверка"}, + ) + is None + ) + + +def test_financial_none_when_no_zoning() -> None: + """Нет zoning-чисел → None.""" + assert ( + synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning=None, + market_price={"median": 150_000.0}, + district_price_median=140_000.0, + cadastral_value_rub=50_000_000.0, + gate_verdict=_gate_ok(), + ) + is None + ) + # zoning есть, но без числовых параметров → None. + assert ( + synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning={"zone_code": "Ж-4"}, + market_price={"median": 150_000.0}, + district_price_median=140_000.0, + cadastral_value_rub=50_000_000.0, + gate_verdict=_gate_ok(), + ) + is None + ) + + +def test_financial_none_when_no_area() -> None: + """Нет площади → None.""" + assert ( + synthesize_parcel_financial( + area_m2=None, + nspd_zoning=_zoning_far(), + market_price={"median": 150_000.0}, + district_price_median=140_000.0, + cadastral_value_rub=50_000_000.0, + gate_verdict=_gate_ok(), + ) + is None + ) + + +def test_financial_valid_parcel_returns_dict_with_caveat() -> None: + """Валидный участок → dict с gdv/npv/irr + caveat присутствует.""" + fin = synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning=_zoning_far(max_far=3.0, max_floors=12), + market_price={"median": 150_000.0}, + district_price_median=140_000.0, + cadastral_value_rub=50_000_000.0, + gate_verdict=_gate_ok(), + ) + assert fin is not None + # GDV (revenue) и DCF-метрики присутствуют. + assert fin["revenue_rub"] > 0 + assert fin["cost_rub"] > 0 + assert "net_profit_rub" in fin + assert "npv_rub" in fin + assert "irr" in fin + assert "roi" in fin + assert "margin_pct" in fin + # Bridge-метаданные. + assert fin["housing_class_inferred"] == "comfort" # price 150k + assert fin["development_type_inferred"] == "high_rise" # 12 этажей + assert fin["schedule_is_default"] is True + # teap_synth контракт для фронта. + teap_synth = fin["teap_synth"] + assert teap_synth["total_floor_area_sqm"] == round(5000.0 * 3.0, 1) + assert teap_synth["residential_area_sqm"] > 0 + assert teap_synth["parking_spaces"] > 0 + assert teap_synth["apartments_count"] > 0 + # HEAVY caveat обязателен. + assert "caveat" in fin + assert "ОРИЕНТИРОВОЧНАЯ" in fin["caveat"] + assert "градрегламент" in fin["caveat"] + + +def test_financial_price_source_district_fallback() -> None: + """Нет market median → fallback к district median, source=district_reference.""" + fin = synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning=_zoning_far(), + market_price={"deals_count": 0, "source": "no_data"}, + district_price_median=130_000.0, + cadastral_value_rub=50_000_000.0, + gate_verdict=_gate_ok(), + ) + assert fin is not None + assert fin["price_source"] == "district_reference" + assert fin["price_is_calibrated"] is True + assert fin["price_per_sqm_used"] == 130_000.0 + assert fin["housing_class_inferred"] == "comfort" # 130k → comfort + + +def test_financial_price_source_class_norm_when_no_market() -> None: + """Нет ни market, ни district цены → class_norm, class=comfort (None→comfort).""" + fin = synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning=_zoning_far(), + market_price=None, + district_price_median=None, + cadastral_value_rub=50_000_000.0, + gate_verdict=_gate_ok(), + ) + assert fin is not None + assert fin["price_source"] == "class_norm" + assert fin["price_is_calibrated"] is False + assert fin["housing_class_inferred"] == "comfort" + + +# ── интеграция: реалистичный участок ────────────────────────────────────────── + + +def test_financial_realistic_parcel_real_dcf() -> None: + """area 5000, max_far 3, max_floors 12, price 150000 → нормальный DCF (irr реал).""" + fin = synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning=_zoning_far(max_far=3.0, max_floors=12), + market_price={"median": 150_000.0}, + district_price_median=140_000.0, + cadastral_value_rub=80_000_000.0, + gate_verdict=_gate_ok(), + ) + assert fin is not None + # Прибыльный девелоперский поток → реальный IRR (не proxy). + assert fin["irr_is_proxy"] is False + assert fin["net_profit_rub"] > 0 + assert fin["npv_rub"] != 0.0 + assert fin["roi"] > 0 + # land_cost = кадастровая стоимость (не рыночная). + assert fin["land_rub"] == 80_000_000.0 + # high_rise (12 эт.) → development_type прокинут в DCF. + assert fin["development_type_inferred"] == "high_rise" + # price калибрована по market median. + assert fin["price_per_sqm_used"] == 150_000.0 + assert fin["price_source"] == "objective_district_median" + + +def test_synthesized_teap_matches_compute_financial_contract() -> None: + """teap_synth поля совпадают с синтезированным TEAP (sanity на контракт).""" + teap = synthesize_teap_from_buildability( + area_m2=5000.0, + max_far=3.0, + max_building_pct=40.0, + max_floors=12, + housing_class="comfort", + ) + assert isinstance(teap, TEAP) + fin = synthesize_parcel_financial( + area_m2=5000.0, + nspd_zoning=_zoning_far(max_far=3.0, max_floors=12), + market_price={"median": 150_000.0}, + district_price_median=140_000.0, + cadastral_value_rub=50_000_000.0, + gate_verdict=_gate_ok(), + ) + assert fin is not None + assert fin["teap_synth"]["total_floor_area_sqm"] == teap.total_floor_area_sqm + assert fin["teap_synth"]["residential_area_sqm"] == teap.residential_area_sqm + assert fin["teap_synth"]["apartments_count"] == teap.apartments_count + assert fin["teap_synth"]["parking_spaces"] == teap.parking_spaces diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index c7058a79..6633ceff 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -2665,10 +2665,12 @@ align-items: stretch; } -/* Investment Clearance — bignum row */ +/* Investment Clearance — bignum row. auto-fit so it stays one clean row for the + 5 placeholder tiles, and wraps gracefully to a second row when the #1881 + financial estimate adds NPV + срок окуп. (7 tiles). */ .bignumRow { display: grid; - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); gap: 8px; } .bignum { diff --git a/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts b/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts index 1029d081..eb6b2092 100644 --- a/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts +++ b/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts @@ -16,12 +16,17 @@ import { describe, expect, it } from "vitest"; import { adaptBuySignalGauge, + adaptFinanceDrawer, + adaptInvestmentClearance, adaptLegalDrawer, adaptPassport, adaptPassportDrawer, buySignalFromForecast, } from "@/components/site-finder/ptica/ptica-adapt"; -import type { ParcelAnalysis } from "@/types/site-finder"; +import type { + ParcelAnalysis, + ParcelFinancialEstimate, +} from "@/types/site-finder"; import type { ForecastReport } from "@/types/forecast"; // Tracked mock fixtures (NEXT_PUBLIC_USE_MOCKS source). The dev-only @@ -96,3 +101,90 @@ describe("ptica-adapt honesty (#1871 P1.3)", () => { expect(legalRow?.field.isReal).toBe(false); }); }); + +// ── #1881 PR-4 — финмодель в Investment Clearance ────────────────────────────── + +function financialFixture(): ParcelFinancialEstimate { + return { + revenue_rub: 4_200_000_000, + cost_rub: 3_100_000_000, + net_profit_rub: 1_100_000_000, + roi: 0.355, + margin_pct: 0.262, + irr: 0.214, + irr_is_proxy: false, + npv_rub: 640_000_000, + payback_months: 42, + discount_rate_used: 0.18, + price_per_sqm_used: 145_000, + price_is_calibrated: true, + price_source: "objective", + housing_class_inferred: "comfort", + development_type_inferred: "high_rise", + schedule_is_default: true, + teap_synth: { + residential_area_sqm: 28_000, + total_floor_area_sqm: 34_000, + parking_spaces: 280, + apartments_count: 520, + }, + caveat: "Расчёт по максимальной застройке НСПД, а не реальной концепции.", + }; +} + +describe("ptica-adapt finance (#1881 PR-4)", () => { + it("adaptInvestmentClearance: null → all placeholders, never real", () => { + const c = adaptInvestmentClearance(null); + expect(c.bignums.every((b) => b.isReal === false)).toBe(true); + expect(c.bignums.every((b) => b.value === "—")).toBe(true); + expect(c.caption).toContain("финмодели"); + }); + + it("adaptInvestmentClearance: estimate → real bignums + NPV + payback", () => { + const c = adaptInvestmentClearance(financialFixture()); + expect(c.bignums.every((b) => b.isReal === true)).toBe(true); + const labels = c.bignums.map((b) => b.label); + expect(labels).toContain("NPV"); + expect(labels).toContain("Срок окуп."); + // GDV 4.2 млрд ₽ formatted ru (запятая-разделитель дробной части). + const gdv = c.bignums.find((b) => b.label === "GDV · выручка"); + expect(gdv?.value).toBe("4,20"); + // ROI 35,5 %. + const roi = c.bignums.find((b) => b.label === "ROI"); + expect(roi?.value).toBe("35,5 %"); + }); + + it("adaptInvestmentClearance: payback_months=null → «не окуп.»", () => { + const c = adaptInvestmentClearance({ + ...financialFixture(), + payback_months: null, + }); + const payback = c.bignums.find((b) => b.label === "Срок окуп."); + expect(payback?.value).toBe("не окуп."); + }); + + it("adaptInvestmentClearance: irr_is_proxy flips the IRR caption", () => { + const proxy = adaptInvestmentClearance({ + ...financialFixture(), + irr_is_proxy: true, + }); + const irr = proxy.bignums.find((b) => b.label === "IRR"); + expect(irr?.caption).toContain("proxy"); + }); + + it("adaptFinanceDrawer: null → placeholder rows + §22 summary", () => { + const d = adaptFinanceDrawer(null); + expect(d.rows.every((r) => r.field.isReal === false)).toBe(true); + expect(d.summary).toContain("§22"); + }); + + it("adaptFinanceDrawer: estimate → real rows + caveat summary", () => { + const fin = financialFixture(); + const d = adaptFinanceDrawer(fin); + expect(d.rows.every((r) => r.field.isReal === true)).toBe(true); + expect(d.summary).toBe(fin.caveat); + const keys = d.rows.map((r) => r.k); + expect(keys).toContain("IRR"); + expect(keys).toContain("ТЭП · квартир"); + }); +}); diff --git a/frontend/src/components/site-finder/ptica/cockpit/InvestmentClearance.tsx b/frontend/src/components/site-finder/ptica/cockpit/InvestmentClearance.tsx index 8681b887..6e15e59c 100644 --- a/frontend/src/components/site-finder/ptica/cockpit/InvestmentClearance.tsx +++ b/frontend/src/components/site-finder/ptica/cockpit/InvestmentClearance.tsx @@ -1,23 +1,28 @@ "use client"; /** - * InvestmentClearance — bottom-grid «Investment Clearance» card. The §23 finance - * model is NOT in the backend, so every bignum (GDV / Cost / Profit / ROI / IRR) - * is an honest «—» placeholder with a «после финмодели (§23)» caption — NO - * fabricated numbers. The bignum layout matches the prototype. "Подробнее" opens - * the finance drawer. + * InvestmentClearance — bottom-grid «Investment Clearance» card. When the backend + * supplies `analysis.financial_estimate` (#1881) the bignums (GDV / Cost / Profit / + * ROI / IRR / NPV / срок окуп.) carry REAL numbers по макс. застройке НСПД; the + * caption stays honest that it's ориентировочно. Otherwise every value is an + * honest «—» placeholder with a «после финмодели (§23)» caption — NO fabricated + * numbers. "Подробнее" opens the finance drawer. */ import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; import { adaptInvestmentClearance } from "@/components/site-finder/ptica/ptica-adapt"; +import type { ParcelAnalysis } from "@/types/site-finder"; import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys"; interface Props { + analysis?: ParcelAnalysis; onOpenDrawer: (key: DrawerKey) => void; } -export function InvestmentClearance({ onOpenDrawer }: Props) { - const { bignums, caption } = adaptInvestmentClearance(); +export function InvestmentClearance({ analysis, onOpenDrawer }: Props) { + const { bignums, caption } = adaptInvestmentClearance( + analysis?.financial_estimate, + ); return (
diff --git a/frontend/src/components/site-finder/ptica/cockpit/PticaBottomGrid.tsx b/frontend/src/components/site-finder/ptica/cockpit/PticaBottomGrid.tsx index 262b407a..977cb68f 100644 --- a/frontend/src/components/site-finder/ptica/cockpit/PticaBottomGrid.tsx +++ b/frontend/src/components/site-finder/ptica/cockpit/PticaBottomGrid.tsx @@ -28,7 +28,7 @@ export function PticaBottomGrid({ }: Props) { return (
- + diff --git a/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx b/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx index 5c8be87b..90fc4971 100644 --- a/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx +++ b/frontend/src/components/site-finder/ptica/drawers/DrawerContent.tsx @@ -784,17 +784,31 @@ export function EconomyContent() { ); } -export function FinanceContent() { - const d = adaptFinanceDrawer(); +export function FinanceContent({ analysis }: { analysis: ParcelAnalysis }) { + const financial = analysis.financial_estimate; + const d = adaptFinanceDrawer(financial); + const hasData = financial != null; return ( <> {d.summary} - + - + ); } diff --git a/frontend/src/components/site-finder/ptica/drawers/drawer-registry.tsx b/frontend/src/components/site-finder/ptica/drawers/drawer-registry.tsx index ea509cc3..14b53ac4 100644 --- a/frontend/src/components/site-finder/ptica/drawers/drawer-registry.tsx +++ b/frontend/src/components/site-finder/ptica/drawers/drawer-registry.tsx @@ -121,7 +121,7 @@ export function getDrawerEntry( return { title: "Финансовая модель", sub: "Investment Clearance", - content: , + content: , }; case "legal": return { diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index 728f05c7..db0ed7a7 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -13,6 +13,7 @@ import type { ParcelAnalysis, ParcelAnalysisCompetitor, + ParcelFinancialEstimate, } from "@/types/site-finder"; import type { NspdZoning } from "@/types/nspd"; import type { ForecastReport, ProductMixEntry } from "@/types/forecast"; @@ -100,6 +101,36 @@ 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, + }); +} + +// #1881 PR-4: backend отдаёт inferred-токены латиницей (housing_class / +// development_type) — локализуем для финмодель-drawer, чтобы финдиректор не +// видел "high_rise" в русском UI. Неизвестный токен → как есть (fallback). +const _HOUSING_CLASS_RU: Record = { + econom: "эконом", + comfort: "комфорт", + business: "бизнес", +}; +const _DEVELOPMENT_TYPE_RU: Record = { + spot: "точечная", + mid_rise: "среднеэтажная", + high_rise: "высотная", +}; + // ── НСПД-регламент (ПЗЗ) helpers ────────────────────────────────────────────── /** КСИТ (max_far) as a plain RU number — e.g. "3,5". */ @@ -1660,17 +1691,92 @@ export function adaptEconomyDrawer(): PticaEconomyDrawer { }; } -export function adaptFinanceDrawer(): PticaEconomyDrawer { +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 priceLabel = financial.price_is_calibrated + ? "рынок (Objective)" + : `норматив класса${ + financial.price_source ? ` · ${financial.price_source}` : "" + }`; + 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, + )} мес)` + : "не окупается в горизонте модели"; + return { - summary: - "Финансовая модель (P&L, cash-flow по фазам, сценарный анализ) формируется " + - "в асинхронном расчёте §22. Откройте «Сценарии», чтобы запустить прогноз.", + summary: financial.caveat, rows: [ - { k: "GDV (выручка)", field: notReal("после финмодели") }, - { k: "Совокупные затраты", field: notReal("после финмодели") }, - { k: "Чистая прибыль", field: notReal("после финмодели") }, - { k: "ROI / IRR", field: notReal("после финмодели") }, - { k: "Срок проекта", field: notReal("после финмодели") }, + { 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: 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)) }, ], }; } @@ -1882,39 +1988,113 @@ export interface PticaInvestmentClearance { } /** - * Investment Clearance — GDV/COST/PROFIT + ROI/IRR. The §23 finance model is NOT - * in the backend, so EVERY value is an honest placeholder «—» (NO fabricated - * numbers). The bignum layout matches the prototype; the caption explains why. + * 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(): PticaInvestmentClearance { - const fin = "после финмодели (§23)"; +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 priceCaption = financial.price_is_calibrated + ? "цена: рынок (Objective)" + : "цена: норматив класса"; + 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: PLACEHOLDER, + value: formatBln(financial.revenue_rub), unit: "млрд ₽", - isReal: false, - caption: fin, + isReal: true, + caption: priceCaption, }, { label: "Cost · затраты", - value: PLACEHOLDER, + value: formatBln(financial.cost_rub), unit: "млрд ₽", - isReal: false, - caption: fin, + isReal: true, }, { label: "Profit · прибыль", - value: PLACEHOLDER, + value: formatBln(financial.net_profit_rub), unit: "млрд ₽", - isReal: false, - caption: fin, + isReal: true, + }, + { + 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, }, - { label: "ROI", value: PLACEHOLDER, isReal: false, caption: fin }, - { label: "IRR", value: PLACEHOLDER, isReal: false, caption: fin }, ], - caption: "после финмодели (§23)", + caption: "по макс. застройке НСПД · ориентировочно", }; } diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 5e3e5598..6a219336 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -491,6 +491,40 @@ export interface ParcelAnalysis { power_line_охранная_зона_flag?: boolean; note?: string | null; } | null; + // Эпик #1881 PR-4 — финмодель из /analyze (extra=allow на backend, hand-typed + // здесь). null когда нельзя строить МКД / нет зонинга / нет площади. + financial_estimate?: ParcelFinancialEstimate | null; +} + +/** + * Финансовая оценка участка (#1881). Считается по МАКСИМАЛЬНОЙ застройке НСПД — + * это ориентир, а НЕ реальная концепция. Все суммы в ₽ (rub), ставки/маржа — доли + * (0..1), payback_months — null когда проект не окупается в горизонте модели. + */ +export interface ParcelFinancialEstimate { + revenue_rub: number; + cost_rub: number; + net_profit_rub: number; + roi: number; + margin_pct: number; + irr: number; + irr_is_proxy: boolean; + npv_rub: number; + payback_months: number | null; + discount_rate_used: number; + price_per_sqm_used: number; + price_is_calibrated: boolean; + price_source: string; + housing_class_inferred: string; + development_type_inferred: string; + schedule_is_default: boolean; + teap_synth: { + residential_area_sqm: number; + total_floor_area_sqm: number; + parking_spaces: number; + apartments_count: number; + }; + caveat: string; } export type PoiCategory =