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