gendesign/backend/app/services/site_finder/parcel_financial.py
Light1YT 67b65c8d8a
All checks were successful
CI / frontend-tests (pull_request) Successful in 57s
CI / openapi-codegen-check (pull_request) Successful in 1m49s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Successful in 9m54s
feat(site-finder): bridge финмодели (DCF) в кокпит Investment Clearance (epic #1881 PR-4)
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
2026-06-23 22:44:04 +05:00

295 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""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",
]