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
295 lines
14 KiB
Python
295 lines
14 KiB
Python
"""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",
|
||
]
|