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

444 lines
16 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)
# Нежилое (comfort share 0.05) вырезается из GFA: office = GFA × 0.05.
assert teap.office_area_sqm == round(area * far * 0.05, 1)
# residential = (GFA office) × efficiency[comfort=0.78]
assert teap.residential_area_sqm == round(area * far * (1.0 - 0.05) * 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"
# ── geo_radius_price: приоритет МЕЖДУ quarter-MV и district_reference ──────────
def test_financial_quarter_mv_wins_over_geo_radius() -> None:
"""Есть quarter-MV median → geo-радиус игнорируется (объективный приоритет MV)."""
fin = synthesize_parcel_financial(
area_m2=5000.0,
nspd_zoning=_zoning_far(),
market_price={"median": 150_000.0},
geo_radius_price_median=200_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["price_source"] == "objective_district_median"
assert fin["price_per_sqm_used"] == 150_000.0
def test_financial_price_source_geo_radius_when_no_quarter_mv() -> None:
"""Нет quarter-MV, но есть geo-радиус → source=objective_geo_radius, цена из него."""
fin = synthesize_parcel_financial(
area_m2=5000.0,
nspd_zoning=_zoning_far(),
market_price={"deals_count": 0, "source": "no_data"},
geo_radius_price_median=185_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["price_source"] == "objective_geo_radius"
assert fin["price_is_calibrated"] is True
assert fin["price_per_sqm_used"] == 185_000.0
assert fin["housing_class_inferred"] == "business" # 185k → business (>=177k)
def test_financial_district_fallback_when_no_quarter_mv_no_geo_radius() -> None:
"""Нет quarter-MV и geo-радиуса, но есть district → district_reference (без регресса)."""
fin = synthesize_parcel_financial(
area_m2=5000.0,
nspd_zoning=_zoning_far(),
market_price={"deals_count": 0, "source": "no_data"},
geo_radius_price_median=None,
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_per_sqm_used"] == 130_000.0
def test_financial_class_norm_when_all_price_sources_absent() -> None:
"""Все источники цены отсутствуют (incl. geo-радиус) → class_norm."""
fin = synthesize_parcel_financial(
area_m2=5000.0,
nspd_zoning=_zoning_far(),
market_price=None,
geo_radius_price_median=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
# ── интеграция: реалистичный участок ──────────────────────────────────────────
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