gendesign/backend/tests/services/site_finder/test_parcel_financial.py
Light1YT 67b65c8d8a
All checks were successful
CI / frontend-tests (pull_request) Successful in 57s
CI / openapi-codegen-check (pull_request) Successful in 1m49s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Successful in 9m54s
feat(site-finder): bridge финмодели (DCF) в кокпит Investment Clearance (epic #1881 PR-4)
Investment Clearance в site-finder кокпите больше не «—»: синтезируем лёгкий
ТЭП из buildability участка (площадь + предельные параметры зоны НСПД/ПЗЗ —
КСИТ/%застройки/этажность) → прогоняем через полноценную финмодель (каскад
затрат PR-1 + калибр.цена PR-2 + помесячный DCF PR-3) → GDV/Cost/Profit/ROI/
IRR/NPV/PBP в кокпите.

Backend:
- new app/services/site_finder/parcel_financial.py (ЧИСТЫЕ функции, без БД):
  synthesize_teap_from_buildability (GFA=area×max_far, degradation pct+floors;
  residential=GFA×eff; apartments/parking — нормативы teap.py, не дублируются)
  + synthesize_parcel_financial (housing_class из цены, development_type из
  этажей, land_cost=кадастровая). None когда нельзя строить МКД / нет зонинга /
  нет площади.
- parcels.py: 1 вызов в try/except (hot-path-safe → None при сбое) + ключ
  financial_estimate в /analyze. Схема не тронута (AnalyzeResponse extra=allow,
  codegen не нужен). +18 тестов.

Frontend:
- ParcelFinancialEstimate тип (site-finder.ts, hand-typed).
- adaptInvestmentClearance(financial)/adaptFinanceDrawer(financial): реальные
  числа (GDV/Cost/Profit/ROI/IRR + NPV + срок окуп.) или честный placeholder
  когда null. Локализация класса/типа (high_rise→высотная). Прокинут analysis
  через PticaBottomGrid/drawer-registry. +6 тестов, 144 passed.

HEAVY caveat везде: ОРИЕНТИРОВОЧНАЯ модель по МАКС.застройке НСПД, НЕ реальная
концепция; land=кадастровая (не рыночная); график — типовой; IRR proxy-флаг.
mypy strict clean (generative.*), ruff/tsc/lint clean.

Закрывает эпик #1881 (финмодель = полноценная DCF, видна в кокпите).
Follow-up: финансирование (кредит/займы), §22 sales-pace, гео-радиус калибровки.

Refs #1881
2026-06-23 22:44:04 +05:00

373 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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