gendesign/backend/app/services/site_finder/parcel_financial.py
bot-backend b82963d12e
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m50s
Deploy / build-worker (push) Successful in 3m0s
Deploy / build-frontend (push) Successful in 3m18s
Deploy / deploy (push) Successful in 1m22s
feat(financial): ground-floor нежилое (office/commercial) tranche
Carve a documented office share (comfort/business 5%, econom 0) out of GFA before residential — fixes prior overstatement (all non-parking GFA = residential). total_floor_area unchanged → no double-count; office is additive revenue (+15% premium), нежилое → VAT-able (vat base = parking_va + office_va). Σ invariant holds. Additive schema + api-types regen + PDF/concept display. Deep-review . Refs #1881
2026-06-25 11:40:01 +00:00

336 lines
18 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,
_OFFICE_SHARE_OF_GFA,
_PARKING_PER_APARTMENT,
HousingClass,
)
logger = logging.getLogger(__name__)
# ── HEAVY caveat — обязателен в любом результате (UI/PDF MUST показать) ─────────
_FINANCIAL_CAVEAT: str = (
"ОРИЕНТИРОВОЧНАЯ финмодель по МАКСИМАЛЬНОЙ застройке согласно градрегламенту "
"НСПД/ПЗЗ (КСИТ/высота/%застройки), НЕ по реальной проектной концепции. "
"ТЭП синтезирован из предельных параметров зоны; класс жилья и темп продаж — "
"рыночные/нормативные прокси; land_cost = кадастровая стоимость (не рыночная); "
"график фаз — типовой. Доля нежилого (коммерция/офисы 1-го этажа) — нормативный "
"прокси по классу (комфорт/бизнес ~5% GFA, эконом 0%), не проектный факт."
)
# ── Класс жилья по цене продажи, руб/кв.м (рыночный прокси) ─────────────────────
# 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`` при отсутствии этажности).
* ``office`` (нежилое 1-го этажа) = ``GFA × office_share[class]``, вырезается из
GFA до расчёта жилой (зеркалит ``compute_teap``; см. ``_OFFICE_SHARE_OF_GFA``).
* ``residential`` = ``(GFA office) × 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
# Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA до расчёта жилой — точно
# как compute_teap: жилая считается по ОСТАВШЕЙСЯ GFA, total (gfa) не меняется.
office_share = _OFFICE_SHARE_OF_GFA[housing_class]
office_area = gfa * office_share
residential_gfa = gfa - office_area
efficiency = _EFFICIENCY_BY_CLASS[housing_class]
residential_area = residential_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),
office_area_sqm=round(office_area, 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,
geo_radius_price_median: float | None = None,
velocity_sqm_per_month: float | None = None,
) -> dict[str, Any] | None:
"""Мост buildability участка → ОРИЕНТИРОВОЧНАЯ финмодель (DCF) или ``None``.
Gate-guard: считаем ТОЛЬКО когда gate допускает МКД и есть числа зоны + площадь.
Цена продажи калибруется по приоритету (от точного рыночного к нормативу):
1. ``market_price.median`` — квартальная MV перепродаж (resale) →
``price_source="objective_district_median"``.
2. ``geo_radius_price_median`` — медиана новостроек Objective в гео-радиусе вокруг
центроида участка → ``price_source="objective_geo_radius"``. Закрывает пробел
district_reference (только 4 из 9 админ-районов ЕКБ матчатся по имени).
3. ``district_price_median`` — name-match медиана админ-района →
``price_source="district_reference"``.
4. норматив класса → ``price_source="class_norm"``.
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 вердикт — гейт на расчёт.
geo_radius_price_median: медиана цены новостроек Objective (``price_per_m2_rub``)
в гео-радиусе вокруг центроида участка, руб/кв.м. Приоритет МЕЖДУ квартальной
MV и ``district_price_median``. ``None`` → слой пропускается (back-compat).
velocity_sqm_per_month: RANK 1 — темп абсорбции ОДНОГО типового конкурента
района (``velocity.project_absorption_sqm_per_month``), м²/мес. Прокидывается
в :func:`compute_financial` как ``market_velocity_sqm_per_month`` → драйвит
окно продаж DCF. ``None`` → дефолт-30-мес график (``schedule_is_default``).
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
# ── Цена продажи: quarter-MV → гео-радиус → district median (рыночный прокси) ─
# Приоритет от точного рынка к нормативу. Гео-радиус вставлен МЕЖДУ квартальной MV
# и district_reference: даёт реальную цену 5 не-name-match районам ЕКБ.
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 _as_float(geo_radius_price_median) is not None:
price = _as_float(geo_radius_price_median)
price_source = "objective_geo_radius"
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,
# RANK 1: окно продаж по рыночной абсорбции района (один типовой конкурент).
market_velocity_sqm_per_month=velocity_sqm_per_month,
)
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 sales_dur=%sm sched=%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 "",
fin.sales_duration_months,
"default" if fin.schedule_is_default else "market",
)
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,
"office_area_sqm": teap.office_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",
]