feat(site-finder): bridge финмодели (DCF) в кокпит Investment Clearance (epic #1881 PR-4) #1885
11 changed files with 1067 additions and 42 deletions
|
|
@ -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,
|
||||
|
|
|
|||
295
backend/app/services/site_finder/parcel_financial.py
Normal file
295
backend/app/services/site_finder/parcel_financial.py
Normal 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 · 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",
|
||||
]
|
||||
373
backend/tests/services/site_finder/test_parcel_financial.py
Normal file
373
backend/tests/services/site_finder/test_parcel_financial.py
Normal 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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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("ТЭП · квартир");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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, асинхронно)"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export function getDrawerEntry(
|
|||
return {
|
||||
title: "Финансовая модель",
|
||||
sub: "Investment Clearance",
|
||||
content: <FinanceContent />,
|
||||
content: <FinanceContent analysis={analysis} />,
|
||||
};
|
||||
case "legal":
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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: "по макс. застройке НСПД · ориентировочно",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue