"""Stage 1c tests — ТЭП and financial model arithmetic. Pure-arithmetic unit tests against known footprint geometry: GFA, residential area, apartment count, FAR, parking, revenue/cost/margin and the IRR-proxy clamp. """ from __future__ import annotations from shapely.geometry import box from app.schemas.concept import TEAP from app.services.generative import financial, teap # Один прямоугольник 20 x 10 = 200 кв.м пятна. _FOOTPRINT = box(0, 0, 20, 10) def test_teap_basic_arithmetic() -> None: result = teap.compute_teap( footprints=[_FOOTPRINT], floors=10, site_area_sqm=1000.0, housing_class="comfort", ) assert result.built_area_sqm == 200.0 # GFA = пятно * этажность. assert result.total_floor_area_sqm == 2000.0 # FAR = GFA / участок. assert result.density == 2.0 # Нежилое (comfort share 0.05) вырезается из GFA: office = 2000 * 0.05 = 100. assert result.office_area_sqm == 100.0 # Жилая = (GFA − нежилое) * efficiency(comfort=0.78) = 1900 * 0.78 = 1482. assert result.residential_area_sqm == 1482.0 # Квартир = floor(жилая / avg(comfort=55)). assert result.apartments_count == int(1482.0 // 55.0) # Парковка = ceil(квартир * 1.0). assert result.parking_spaces == result.apartments_count def test_teap_class_changes_efficiency_and_lot() -> None: econ = teap.compute_teap( footprints=[_FOOTPRINT], floors=10, site_area_sqm=1000.0, housing_class="econom" ) biz = teap.compute_teap( footprints=[_FOOTPRINT], floors=10, site_area_sqm=1000.0, housing_class="business" ) # Эконом эффективнее по площади -> больше жилой при той же GFA. assert econ.residential_area_sqm > biz.residential_area_sqm # Бизнес — крупнее лот -> меньше квартир. assert biz.apartments_count < econ.apartments_count # Бизнес — выше норма парковки на квартиру. assert biz.parking_spaces / max(1, biz.apartments_count) >= 1.4 def test_teap_zero_site_area_no_division_error() -> None: result = teap.compute_teap( footprints=[_FOOTPRINT], floors=5, site_area_sqm=0.0, housing_class="comfort" ) assert result.density == 0.0 def test_teap_empty_placement_is_zeroed() -> None: result = teap.compute_teap( footprints=[], floors=9, site_area_sqm=1000.0, housing_class="comfort" ) assert result.built_area_sqm == 0.0 assert result.total_floor_area_sqm == 0.0 assert result.apartments_count == 0 assert result.parking_spaces == 0 def _teap(residential: float, gfa: float, parking: int = 10) -> TEAP: return TEAP( built_area_sqm=100.0, total_floor_area_sqm=gfa, residential_area_sqm=residential, apartments_count=10, density=1.0, parking_spaces=parking, ) def test_financial_revenue_includes_parking() -> None: t = _teap(residential=1000.0, gfa=1300.0, parking=10) model = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=50_000_000.0 ) # revenue = жильё (1000 * 145_000) + паркинг comfort (10 * 1_300_000). assert model.revenue_residential_rub == 1000.0 * 145_000.0 assert model.revenue_parking_rub == 10 * 1_300_000.0 assert model.revenue_rub == model.revenue_residential_rub + model.revenue_parking_rub def test_financial_cost_cascade_includes_all_lines() -> None: t = _teap(residential=1000.0, gfa=1300.0, parking=10) model = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=50_000_000.0 ) # СМР = GFA*СМР + паркинг comfort*себест (10 * 1_000_000). assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_000_000.0 # Каждая статья каскада > 0 при ненулевых вводных. assert model.pir_rub > 0 assert model.networks_rub > 0 assert model.developer_services_rub > 0 assert model.contingency_rub > 0 assert model.marketing_rub > 0 assert model.land_rub == 50_000_000.0 # Итог затрат = сумма всех статей. expected_cost = ( model.construction_rub + model.pir_rub + model.networks_rub + model.developer_services_rub + model.contingency_rub + model.marketing_rub + model.land_rub ) assert abs(model.cost_rub - expected_cost) < 1.0 assert model.gross_margin_rub == model.revenue_rub - model.cost_rub def test_financial_net_profit_below_gross_margin_when_profitable() -> None: # Прибыльный сценарий: налоги срезают валовую маржу -> net < gross. t = _teap(residential=10_000.0, gfa=11_000.0, parking=50) model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None) assert model.gross_margin_rub > 0 assert model.vat_rub > 0 assert model.profit_tax_rub > 0 assert model.net_profit_rub < model.profit_before_tax_rub < model.gross_margin_rub # ROI и маржа% согласованы с чистой прибылью (поля округлены до 4 знаков). assert model.roi == round(model.net_profit_rub / model.cost_rub, 4) assert model.margin_pct == round(model.net_profit_rub / model.revenue_rub, 4) def test_financial_no_profit_tax_on_loss() -> None: # Убыток: налог на прибыль = 0, НДС = 0; net == pbt == gross_margin. t = _teap(residential=1.0, gfa=10_000.0, parking=0) model = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None) assert model.gross_margin_rub < 0 assert model.profit_tax_rub == 0.0 assert model.vat_rub == 0.0 assert model.profit_before_tax_rub == model.gross_margin_rub assert model.net_profit_rub == model.gross_margin_rub def test_financial_parking_zero_yields_zero_parking_lines() -> None: t = _teap(residential=1000.0, gfa=1300.0, parking=0) model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None) assert model.revenue_parking_rub == 0.0 # СМР без паркинговой составляющей = ровно GFA*СМР. assert model.construction_rub == 1300.0 * 88_000.0 def test_financial_land_cost_optional() -> None: t = _teap(residential=1000.0, gfa=1300.0) no_land = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None) with_land = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=10_000_000.0 ) # Земля увеличивает затраты ровно на свою стоимость. assert with_land.cost_rub - no_land.cost_rub == 10_000_000.0 assert with_land.land_rub == 10_000_000.0 assert no_land.land_rub == 0.0 def test_financial_irr_proxy_clamped_and_flagged() -> None: # Огромная маржа -> irr-proxy зажат в [-1, 1]; флаг proxy выставлен. t = _teap(residential=100_000.0, gfa=1.0, parking=0) model = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None) assert -1.0 <= model.irr <= 1.0 assert model.irr_is_proxy is True def test_financial_zero_cost_no_division_error() -> None: t = _teap(residential=0.0, gfa=0.0, parking=0) model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None) assert model.cost_rub == 0.0 assert model.revenue_rub == 0.0 assert model.roi == 0.0 assert model.margin_pct == 0.0 assert model.irr == 0.0 def test_financial_backward_compat_fields_present() -> None: t = _teap(residential=1000.0, gfa=1300.0) model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None) # Старые summary-поля контракта остаются. assert isinstance(model.revenue_rub, float) assert isinstance(model.cost_rub, float) assert isinstance(model.gross_margin_rub, float) assert isinstance(model.irr, float) # PR-3 DCF-поля additive — присутствуют с дефолтами. assert isinstance(model.npv_rub, float) assert model.discount_rate_used == 0.15 assert model.schedule_is_default is True assert model.payback_months is None or isinstance(model.payback_months, float) # ── PR-2: рыночная калибровка цены продажи жилья ─────────────────────────────── def test_financial_no_market_price_uses_class_norm() -> None: # Без market_price_per_sqm — норматив класса comfort (145_000), флаг не калибровано. t = _teap(residential=1000.0, gfa=1300.0) model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None) assert model.price_per_sqm_used == 145_000.0 assert model.price_is_calibrated is False assert model.price_source == "class_norm" # Выручка жилья считается по нормативу. assert model.revenue_residential_rub == 1000.0 * 145_000.0 def test_financial_market_price_overrides_class_norm() -> None: # market_price_per_sqm передан → используется он, флаг калибровано, source проброшен. t = _teap(residential=1000.0, gfa=1300.0) model = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=None, market_price_per_sqm=180_000.0, price_source="objective_district_median", ) assert model.price_per_sqm_used == 180_000.0 assert model.price_is_calibrated is True assert model.price_source == "objective_district_median" # Выручка жилья считается по рыночной цене, НЕ по нормативу класса. assert model.revenue_residential_rub == 1000.0 * 180_000.0 def test_financial_market_price_does_not_calibrate_parking_or_smr() -> None: # Калибруем ТОЛЬКО цену жилья: паркинг (класс comfort) и себестоимость СМР # (88_000) — норма, не зависят от market_price_per_sqm. t = _teap(residential=1000.0, gfa=1300.0, parking=10) model = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=None, market_price_per_sqm=200_000.0, price_source="district_reference", ) # Паркинг — норматив класса comfort (10 * 1_300_000), не зависит от цены жилья. assert model.revenue_parking_rub == 10 * 1_300_000.0 # СМР — норматив класса comfort (GFA*88_000 + паркинг*1_000_000). assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_000_000.0 def test_financial_none_market_price_forces_class_norm_source() -> None: # Даже если source передан, при market_price_per_sqm=None он форсится в class_norm — # нельзя выдавать норматив за рыночные данные. t = _teap(residential=1000.0, gfa=1300.0) model = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=None, market_price_per_sqm=None, price_source="objective_district_median", ) assert model.price_is_calibrated is False assert model.price_source == "class_norm" assert model.price_per_sqm_used == 145_000.0 # ── Классозависимая экономика паркинга (econom наземный / business подземный) ──── def test_financial_parking_econom_uses_surface_price_and_cost() -> None: # Эконом — наземный паркинг: цена 800k, себестоимость 350k за м/м. t = _teap(residential=1000.0, gfa=1300.0, parking=10) model = financial.compute_financial(teap=t, housing_class="econom", land_cost_rub=None) assert model.revenue_parking_rub == 10 * 800_000.0 # СМР = GFA*72_000 (econom) + паркинг наземный 10 * 350_000. assert model.construction_rub == 1300.0 * 72_000.0 + 10 * 350_000.0 def test_financial_parking_comfort_uses_mixed_price_and_cost() -> None: # Комфорт — частично подземный: цена 1.3М, себестоимость 1.0М за м/м. t = _teap(residential=1000.0, gfa=1300.0, parking=10) model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None) assert model.revenue_parking_rub == 10 * 1_300_000.0 assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_000_000.0 def test_financial_parking_business_matches_excel_avangardnaya13() -> None: # РЕГРЕСС-ПИН: бизнес-паркинг подземный — без изменений vs Excel «Авангардная 13» # (продажа 1.9М / себестоимость 1.8М за м/м). Любой дрейф этих чисел — провал. t = _teap(residential=1000.0, gfa=1300.0, parking=10) model = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None) assert model.revenue_parking_rub == 10 * 1_900_000.0 assert model.construction_rub == 1300.0 * 120_000.0 + 10 * 1_800_000.0 # Прямая сверка с константами-словарями (теперь dict, не скаляр). assert financial._PARKING_PRICE_PER_SPOT["business"] == 1_900_000.0 assert financial._PARKING_COST_PER_SPOT["business"] == 1_800_000.0 def test_financial_parking_margin_positive_for_all_classes() -> None: # Цена > себестоимости во всех классах → паркинг не убыточен ни в одном классе. for hc in ("econom", "comfort", "business"): price = financial._PARKING_PRICE_PER_SPOT[hc] # type: ignore[index] cost = financial._PARKING_COST_PER_SPOT[hc] # type: ignore[index] assert price > cost, hc # Конкретные маржи из спецификации: econom +450k, comfort +300k, business +100k. assert financial._PARKING_PRICE_PER_SPOT["econom"] - financial._PARKING_COST_PER_SPOT[ "econom" ] == 450_000.0 assert financial._PARKING_PRICE_PER_SPOT["comfort"] - financial._PARKING_COST_PER_SPOT[ "comfort" ] == 300_000.0 assert financial._PARKING_PRICE_PER_SPOT["business"] - financial._PARKING_COST_PER_SPOT[ "business" ] == 100_000.0 def test_financial_econom_parking_cheaper_than_business() -> None: # Тот же участок: econom-паркинг (наземный) даёт МЕНЬШЕ паркинговой выручки и # МЕНЬШЕ паркинговой себестоимости, чем business (подземный) — проверка, что # классозависимость реально применяется в каскаде. t = _teap(residential=1000.0, gfa=1300.0, parking=10) econ = financial.compute_financial(teap=t, housing_class="econom", land_cost_rub=None) biz = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None) assert econ.revenue_parking_rub < biz.revenue_parking_rub # Паркинговая часть СМР: econom 350k/м < business 1.8М/м. econ_parking_smr = econ.construction_rub - 1300.0 * 72_000.0 biz_parking_smr = biz.construction_rub - 1300.0 * 120_000.0 assert econ_parking_smr < biz_parking_smr # ── Stage 2a (#1965): synthesize_teap_from_program (скалярное пятно × этажность) ── def test_synthesize_program_gfa_is_footprint_times_floors() -> None: # GFA = суммарное пятно × этажность; пятно = total_footprint_sqm как есть. result = teap.synthesize_teap_from_program( total_footprint_sqm=3000.0, floors=12, site_area_sqm=10_000.0, housing_class="comfort", sections=2, ) assert result.built_area_sqm == 3000.0 assert result.total_floor_area_sqm == 3000.0 * 12 # 36_000 # FAR = GFA / участок. assert result.density == round(36_000.0 / 10_000.0, 3) def test_synthesize_program_matches_compute_teap_for_equivalent_input() -> None: # synthesize_teap_from_program(footprint, floors) ДОЛЖЕН дать ровно тот же ТЭП, # что compute_teap из одного прямоугольного пятна той же площади — единая логика. footprint = box(0, 0, 50, 40) # 2000 кв.м from_program = teap.synthesize_teap_from_program( total_footprint_sqm=2000.0, floors=9, site_area_sqm=5000.0, housing_class="comfort", ) from_geometry = teap.compute_teap( footprints=[footprint], floors=9, site_area_sqm=5000.0, housing_class="comfort", ) assert from_program == from_geometry def test_synthesize_program_office_carveout_and_efficiency_by_class() -> None: # Нежилое вырезается из GFA (comfort share 0.05), жилая = (GFA−нежилое)*efficiency. result = teap.synthesize_teap_from_program( total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0, housing_class="comfort", ) gfa = 2000.0 * 10 # 20_000 assert result.office_area_sqm == round(gfa * 0.05, 1) # 1000 assert result.residential_area_sqm == round((gfa - gfa * 0.05) * 0.78, 1) def test_synthesize_program_class_changes_efficiency_and_lot() -> None: common = dict(total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0) econ = teap.synthesize_teap_from_program(housing_class="econom", **common) # type: ignore[arg-type] biz = teap.synthesize_teap_from_program(housing_class="business", **common) # type: ignore[arg-type] # Эконом эффективнее (0.82 vs 0.72) + нет нежилого carve-out → больше жилой. assert econ.residential_area_sqm > biz.residential_area_sqm # Бизнес — крупнее средний лот → меньше квартир. assert biz.apartments_count < econ.apartments_count # Бизнес — выше норма парковки на квартиру. assert biz.parking_spaces / max(1, biz.apartments_count) >= 1.4 def test_synthesize_program_zero_site_area_no_division_error() -> None: result = teap.synthesize_teap_from_program( total_footprint_sqm=1000.0, floors=5, site_area_sqm=0.0, housing_class="comfort", ) assert result.density == 0.0 def test_synthesize_program_sections_do_not_affect_teap() -> None: # sections — метаданные программы; площади уже свёрнуты → ТЭП от них не зависит. one = teap.synthesize_teap_from_program( total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0, housing_class="comfort", sections=1, ) six = teap.synthesize_teap_from_program( total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0, housing_class="comfort", sections=6, ) assert one == six def test_synthesize_program_then_financial_is_coherent() -> None: # Сквозь финмодель: жизнеспособная программа → выручка > 0, NPV посчитан. t = teap.synthesize_teap_from_program( total_footprint_sqm=3000.0, floors=12, site_area_sqm=10_000.0, housing_class="comfort", ) model = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=None, market_price_per_sqm=150_000.0, price_source="objective_district_median", ) assert model.revenue_rub > 0 assert model.cost_rub > 0 assert isinstance(model.npv_rub, float) # Цена прокинута в выручку жилья. assert model.price_per_sqm_used == 150_000.0 assert model.price_is_calibrated is True