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
336 lines
18 KiB
Python
336 lines
18 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,
|
||
_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 · 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`` при отсутствии этажности).
|
||
* ``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",
|
||
]
|