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
This commit is contained in:
parent
ae90f84c90
commit
67b65c8d8a
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.developer_attribution import get_developer_attribution
|
||||||
from app.services.site_finder.gate_verdict import compute_gate_verdict
|
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.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 (
|
from app.services.site_finder.poi_score import (
|
||||||
PoiScoreResponse,
|
PoiScoreResponse,
|
||||||
compute_poi_routing_decay,
|
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] = {
|
result_payload: dict[str, Any] = {
|
||||||
"cad_num": cad_num,
|
"cad_num": cad_num,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
|
@ -3052,6 +3078,10 @@ def analyze_parcel(
|
||||||
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
||||||
# (вычислен выше, до сборки payload — нужен для gate_caveat #1740).
|
# (вычислен выше, до сборки payload — нужен для gate_caveat #1740).
|
||||||
"gate_verdict": gate_verdict,
|
"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 для прозрачности.
|
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
|
||||||
"weights_profile": {
|
"weights_profile": {
|
||||||
"source": _weights_source,
|
"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;
|
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 {
|
.bignumRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(auto-fit, minmax(72px, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.bignum {
|
.bignum {
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,17 @@ import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
adaptBuySignalGauge,
|
adaptBuySignalGauge,
|
||||||
|
adaptFinanceDrawer,
|
||||||
|
adaptInvestmentClearance,
|
||||||
adaptLegalDrawer,
|
adaptLegalDrawer,
|
||||||
adaptPassport,
|
adaptPassport,
|
||||||
adaptPassportDrawer,
|
adaptPassportDrawer,
|
||||||
buySignalFromForecast,
|
buySignalFromForecast,
|
||||||
} from "@/components/site-finder/ptica/ptica-adapt";
|
} 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";
|
import type { ForecastReport } from "@/types/forecast";
|
||||||
|
|
||||||
// Tracked mock fixtures (NEXT_PUBLIC_USE_MOCKS source). The dev-only
|
// 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);
|
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";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* InvestmentClearance — bottom-grid «Investment Clearance» card. The §23 finance
|
* InvestmentClearance — bottom-grid «Investment Clearance» card. When the backend
|
||||||
* model is NOT in the backend, so every bignum (GDV / Cost / Profit / ROI / IRR)
|
* supplies `analysis.financial_estimate` (#1881) the bignums (GDV / Cost / Profit /
|
||||||
* is an honest «—» placeholder with a «после финмодели (§23)» caption — NO
|
* ROI / IRR / NPV / срок окуп.) carry REAL numbers по макс. застройке НСПД; the
|
||||||
* fabricated numbers. The bignum layout matches the prototype. "Подробнее" opens
|
* caption stays honest that it's ориентировочно. Otherwise every value is an
|
||||||
* the finance drawer.
|
* 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 styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||||
import { adaptInvestmentClearance } from "@/components/site-finder/ptica/ptica-adapt";
|
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";
|
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
analysis?: ParcelAnalysis;
|
||||||
onOpenDrawer: (key: DrawerKey) => void;
|
onOpenDrawer: (key: DrawerKey) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvestmentClearance({ onOpenDrawer }: Props) {
|
export function InvestmentClearance({ analysis, onOpenDrawer }: Props) {
|
||||||
const { bignums, caption } = adaptInvestmentClearance();
|
const { bignums, caption } = adaptInvestmentClearance(
|
||||||
|
analysis?.financial_estimate,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export function PticaBottomGrid({
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<section id="ptica-economy" className={styles.bottomGrid}>
|
<section id="ptica-economy" className={styles.bottomGrid}>
|
||||||
<InvestmentClearance onOpenDrawer={onOpenDrawer} />
|
<InvestmentClearance analysis={analysis} onOpenDrawer={onOpenDrawer} />
|
||||||
<BuySignalCard forecastReport={forecastReport} />
|
<BuySignalCard forecastReport={forecastReport} />
|
||||||
<LegalStatus analysis={analysis} onOpenDrawer={onOpenDrawer} />
|
<LegalStatus analysis={analysis} onOpenDrawer={onOpenDrawer} />
|
||||||
<SiteVerdictCard analysis={analysis} forecastReport={forecastReport} />
|
<SiteVerdictCard analysis={analysis} forecastReport={forecastReport} />
|
||||||
|
|
|
||||||
|
|
@ -784,17 +784,31 @@ export function EconomyContent() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FinanceContent() {
|
export function FinanceContent({ analysis }: { analysis: ParcelAnalysis }) {
|
||||||
const d = adaptFinanceDrawer();
|
const financial = analysis.financial_estimate;
|
||||||
|
const d = adaptFinanceDrawer(financial);
|
||||||
|
const hasData = financial != null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DrawerSection>
|
<DrawerSection>
|
||||||
<SummaryBox>{d.summary}</SummaryBox>
|
<SummaryBox>{d.summary}</SummaryBox>
|
||||||
</DrawerSection>
|
</DrawerSection>
|
||||||
<DrawerSection heading="ФИНАНСОВАЯ МОДЕЛЬ · после финмодели">
|
<DrawerSection
|
||||||
|
heading={
|
||||||
|
hasData
|
||||||
|
? "ФИНАНСОВАЯ МОДЕЛЬ · по макс. застройке НСПД"
|
||||||
|
: "ФИНАНСОВАЯ МОДЕЛЬ · после финмодели"
|
||||||
|
}
|
||||||
|
>
|
||||||
<KvRows rows={d.rows} />
|
<KvRows rows={d.rows} />
|
||||||
</DrawerSection>
|
</DrawerSection>
|
||||||
<SourceLine source="Источник: финмодель §22 (DCF, асинхронно)" />
|
<SourceLine
|
||||||
|
source={
|
||||||
|
hasData
|
||||||
|
? "Источник: финмодель /analyze (DCF · по макс. застройке НСПД)"
|
||||||
|
: "Источник: финмодель §22 (DCF, асинхронно)"
|
||||||
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ export function getDrawerEntry(
|
||||||
return {
|
return {
|
||||||
title: "Финансовая модель",
|
title: "Финансовая модель",
|
||||||
sub: "Investment Clearance",
|
sub: "Investment Clearance",
|
||||||
content: <FinanceContent />,
|
content: <FinanceContent analysis={analysis} />,
|
||||||
};
|
};
|
||||||
case "legal":
|
case "legal":
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
import type {
|
import type {
|
||||||
ParcelAnalysis,
|
ParcelAnalysis,
|
||||||
ParcelAnalysisCompetitor,
|
ParcelAnalysisCompetitor,
|
||||||
|
ParcelFinancialEstimate,
|
||||||
} from "@/types/site-finder";
|
} from "@/types/site-finder";
|
||||||
import type { NspdZoning } from "@/types/nspd";
|
import type { NspdZoning } from "@/types/nspd";
|
||||||
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
|
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
|
||||||
|
|
@ -100,6 +101,36 @@ function formatRubPerM2(n: number): string {
|
||||||
return `${formatInt(n)} ₽/м²`;
|
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 ──────────────────────────────────────────────
|
// ── НСПД-регламент (ПЗЗ) helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
/** КСИТ (max_far) as a plain RU number — e.g. "3,5". */
|
/** КСИТ (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 {
|
return {
|
||||||
summary:
|
summary: financial.caveat,
|
||||||
"Финансовая модель (P&L, cash-flow по фазам, сценарный анализ) формируется " +
|
|
||||||
"в асинхронном расчёте §22. Откройте «Сценарии», чтобы запустить прогноз.",
|
|
||||||
rows: [
|
rows: [
|
||||||
{ k: "GDV (выручка)", field: notReal("после финмодели") },
|
{ k: "GDV (выручка)", field: real(`${formatBln(financial.revenue_rub)} млрд ₽`) },
|
||||||
{ k: "Совокупные затраты", field: notReal("после финмодели") },
|
{
|
||||||
{ k: "Чистая прибыль", field: notReal("после финмодели") },
|
k: "Совокупные затраты",
|
||||||
{ k: "ROI / IRR", field: notReal("после финмодели") },
|
field: real(`${formatBln(financial.cost_rub)} млрд ₽`),
|
||||||
{ k: "Срок проекта", field: notReal("после финмодели") },
|
},
|
||||||
|
{
|
||||||
|
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
|
* Investment Clearance — GDV/COST/PROFIT + ROI/IRR/NPV/срок окуп.
|
||||||
* in the backend, so EVERY value is an honest placeholder «—» (NO fabricated
|
*
|
||||||
* numbers). The bignum layout matches the prototype; the caption explains why.
|
* 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 {
|
export function adaptInvestmentClearance(
|
||||||
const fin = "после финмодели (§23)";
|
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 {
|
return {
|
||||||
bignums: [
|
bignums: [
|
||||||
{
|
{
|
||||||
label: "GDV · выручка",
|
label: "GDV · выручка",
|
||||||
value: PLACEHOLDER,
|
value: formatBln(financial.revenue_rub),
|
||||||
unit: "млрд ₽",
|
unit: "млрд ₽",
|
||||||
isReal: false,
|
isReal: true,
|
||||||
caption: fin,
|
caption: priceCaption,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Cost · затраты",
|
label: "Cost · затраты",
|
||||||
value: PLACEHOLDER,
|
value: formatBln(financial.cost_rub),
|
||||||
unit: "млрд ₽",
|
unit: "млрд ₽",
|
||||||
isReal: false,
|
isReal: true,
|
||||||
caption: fin,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Profit · прибыль",
|
label: "Profit · прибыль",
|
||||||
value: PLACEHOLDER,
|
value: formatBln(financial.net_profit_rub),
|
||||||
unit: "млрд ₽",
|
unit: "млрд ₽",
|
||||||
isReal: false,
|
isReal: true,
|
||||||
caption: fin,
|
},
|
||||||
|
{
|
||||||
|
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;
|
power_line_охранная_зона_flag?: boolean;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
} | 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 =
|
export type PoiCategory =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue