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
373 lines
14 KiB
Python
373 lines
14 KiB
Python
"""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
|