feat(site-finder): bridge финмодели (DCF) в кокпит Investment Clearance (epic #1881 PR-4) #1885

Merged
bot-backend merged 1 commit from feat/finmodel-sitefinder-bridge into main 2026-06-23 17:55:03 +00:00
11 changed files with 1067 additions and 42 deletions

View file

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

View file

@ -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 · 58 → 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",
]

View file

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

View file

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

View file

@ -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("ТЭП · квартир");
});
});

View file

@ -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 (
<div className={styles.card}>

View file

@ -28,7 +28,7 @@ export function PticaBottomGrid({
}: Props) {
return (
<section id="ptica-economy" className={styles.bottomGrid}>
<InvestmentClearance onOpenDrawer={onOpenDrawer} />
<InvestmentClearance analysis={analysis} onOpenDrawer={onOpenDrawer} />
<BuySignalCard forecastReport={forecastReport} />
<LegalStatus analysis={analysis} onOpenDrawer={onOpenDrawer} />
<SiteVerdictCard analysis={analysis} forecastReport={forecastReport} />

View file

@ -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 (
<>
<DrawerSection>
<SummaryBox>{d.summary}</SummaryBox>
</DrawerSection>
<DrawerSection heading="ФИНАНСОВАЯ МОДЕЛЬ · после финмодели">
<DrawerSection
heading={
hasData
? "ФИНАНСОВАЯ МОДЕЛЬ · по макс. застройке НСПД"
: "ФИНАНСОВАЯ МОДЕЛЬ · после финмодели"
}
>
<KvRows rows={d.rows} />
</DrawerSection>
<SourceLine source="Источник: финмодель §22 (DCF, асинхронно)" />
<SourceLine
source={
hasData
? "Источник: финмодель /analyze (DCF · по макс. застройке НСПД)"
: "Источник: финмодель §22 (DCF, асинхронно)"
}
/>
</>
);
}

View file

@ -121,7 +121,7 @@ export function getDrawerEntry(
return {
title: "Финансовая модель",
sub: "Investment Clearance",
content: <FinanceContent />,
content: <FinanceContent analysis={analysis} />,
};
case "legal":
return {

View file

@ -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<string, string> = {
econom: "эконом",
comfort: "комфорт",
business: "бизнес",
};
const _DEVELOPMENT_TYPE_RU: Record<string, string> = {
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: "по макс. застройке НСПД · ориентировочно",
};
}

View file

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