diff --git a/backend/.coverage b/backend/.coverage deleted file mode 100644 index bd524d7c..00000000 Binary files a/backend/.coverage and /dev/null differ diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..6350e986 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +.coverage diff --git a/backend/app/services/generative/financial.py b/backend/app/services/generative/financial.py index a15fbccc..c25517cb 100644 --- a/backend/app/services/generative/financial.py +++ b/backend/app/services/generative/financial.py @@ -87,7 +87,8 @@ The housing **sale price** can be calibrated to real market data: callers pass reference at the API layer. When ``None`` we fall back to the per-class norm and flag the source honestly (``price_source="class_norm"``) so the UI/PDF never passes a class norm off as a market figure. Construction cost (СМР) is *not* -calibrated in PR-2 — it stays a per-class norm. Parking price/cost also stay norms. +calibrated in PR-2 — it stays a per-class norm. Parking price/cost are also +per-class norms (см. ``_PARKING_PRICE_PER_SPOT`` / ``_PARKING_COST_PER_SPOT``). FINANCING — OVERLAY (PR-5) -------------------------- @@ -148,10 +149,37 @@ _CONSTRUCTION_COST_PER_SQM: dict[HousingClass, float] = { "business": 120_000.0, } -# ── Паркинг (Excel-эталон девелоперской модели) ─────────────────────────────── -# Цена продажи / себестоимость одного машиноместа, руб. -_PARKING_PRICE_PER_SPOT: float = 1_900_000.0 # Excel: цена продажи м/м -_PARKING_COST_PER_SPOT: float = 1_800_000.0 # Excel: себестоимость м/м +# ── Паркинг — ЦЕНА ПРОДАЖИ / СЕБЕСТОИМОСТЬ м/м по классу, руб ────────────────── +# TUNABLE ASSUMPTIONS (РФ-рынок паркинга). Раньше эти константы были СКАЛЯРАМИ, +# взятыми из Excel-эталона «Авангардная 13» (premium, ПОДЗЕМНЫЙ паркинг, near +# break-even: 1.9М продажа / 1.8М себестоимость). Применять их ПЛОСКО ко всем +# классам некорректно: эконом-жильё в РФ строит ДЕШЁВЫЙ НАЗЕМНЫЙ паркинг, а не +# подземный — плоская подземная себестоимость 1.8М × 0.8 м/м на квартиру раздувала +# near-break-even капитал → пиковый долг → проценты по финансированию (прод: Ж-5, +# 212 м/м × 1.8М = 382М break-even капитал → ~120М процентов, душивших +# net_after_financing). +# +# Поэтому цена/себестоимость теперь КЛАССОЗАВИСИМЫ (зеркалит паттерн +# _SALE_PRICE_PER_SQM / _CONSTRUCTION_COST_PER_SQM). ВАЖНО: НОРМА паркинга +# (_PARKING_PER_APARTMENT в teap.py: econom 0.8 / comfort 1.0 / business 1.5) НЕ +# трогается — она может отражать нормы ПЗЗ (out of scope). +# +# business 1.9 / 1.8 — ПОДЗЕМНЫЙ, matches Excel «Авангардная 13» (без изменений). +# econom 0.8 / 0.35 — НАЗЕМНЫЙ (surface): дешёвый СМР ~350k, продажа ~800k. +# comfort 1.3 / 1.0 — ЧАСТИЧНО подземный (интерполяция econom↔business). +# +# Маржа на м/м: econom +450k, comfort +300k, business +100k (цена > себестоимости +# во всех классах — паркинг не убыточен ни в одном классе). +_PARKING_PRICE_PER_SPOT: dict[HousingClass, float] = { + "econom": 800_000.0, # наземный паркинг — продажа + "comfort": 1_300_000.0, # частично подземный — продажа + "business": 1_900_000.0, # подземный (Excel «Авангардная 13») — продажа +} +_PARKING_COST_PER_SPOT: dict[HousingClass, float] = { + "econom": 350_000.0, # наземный паркинг — себестоимость + "comfort": 1_000_000.0, # частично подземный — себестоимость + "business": 1_800_000.0, # подземный (Excel «Авангардная 13») — себестоимость +} # ── Доли каскада затрат (источник — Excel-эталон + RU-рынок) ────────────────── # ПИР (проектирование + экспертиза + РД) как доля от СМР. @@ -521,15 +549,19 @@ def compute_financial( price_is_calibrated = False resolved_price_source = "class_norm" construction_cost = _CONSTRUCTION_COST_PER_SQM[housing_class] + # Паркинг — классозависимы цена/себестоимость (econom наземный, business + # подземный); НОРМА м/м на квартиру задаётся в teap (не здесь). + parking_price = _PARKING_PRICE_PER_SPOT[housing_class] + parking_cost = _PARKING_COST_PER_SPOT[housing_class] # ── Выручка (GDV) ────────────────────────────────────────────────────────── revenue_residential = teap.residential_area_sqm * sale_price - revenue_parking = teap.parking_spaces * _PARKING_PRICE_PER_SPOT + revenue_parking = teap.parking_spaces * parking_price revenue = revenue_residential + revenue_parking # ── Затраты (каскад) ─────────────────────────────────────────────────────── construction_resid = teap.total_floor_area_sqm * construction_cost - construction_parking = teap.parking_spaces * _PARKING_COST_PER_SPOT + construction_parking = teap.parking_spaces * parking_cost construction = construction_resid + construction_parking pir = construction * _PIR_PCT_OF_CONSTRUCTION networks = teap.total_floor_area_sqm * _NETWORKS_COST_PER_SQM diff --git a/backend/tests/services/generative/test_financial_dcf.py b/backend/tests/services/generative/test_financial_dcf.py index dd5a803d..90e5a9e6 100644 --- a/backend/tests/services/generative/test_financial_dcf.py +++ b/backend/tests/services/generative/test_financial_dcf.py @@ -134,6 +134,18 @@ def test_invariant_cashflow_sum_equals_net_profit_loss() -> None: assert abs(sum(cf) - model.net_profit_rub) < 1.0 +def test_invariant_cashflow_sum_equals_net_profit_econom_parking() -> None: + # Классозависимый паркинг (econom: цена 800k / себестоимость 350k) кормит тот же + # каскад → инвариант Σ cashflow == net_profit держится (магнитуды паркинга + # изменились, но cascade-структура та же). + t = _teap(residential=10_000.0, gfa=11_000.0, parking=50) + model = financial.compute_financial( + teap=t, housing_class="econom", land_cost_rub=150_000_000.0, development_type="mid_rise" + ) + cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0) + assert abs(sum(cf) - model.net_profit_rub) < 1.0 + + def test_invariant_holds_for_all_development_types() -> None: t = _teap(residential=8_000.0, gfa=9_000.0, parking=40) months = {"spot": 24, "mid_rise": 36, "high_rise": 48} diff --git a/backend/tests/services/generative/test_teap_financial.py b/backend/tests/services/generative/test_teap_financial.py index b64d8aaa..5b08ff0e 100644 --- a/backend/tests/services/generative/test_teap_financial.py +++ b/backend/tests/services/generative/test_teap_financial.py @@ -83,9 +83,9 @@ def test_financial_revenue_includes_parking() -> None: model = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=50_000_000.0 ) - # revenue = жильё (1000 * 145_000) + паркинг (10 * 1_900_000). + # 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_900_000.0 + assert model.revenue_parking_rub == 10 * 1_300_000.0 assert model.revenue_rub == model.revenue_residential_rub + model.revenue_parking_rub @@ -94,8 +94,8 @@ def test_financial_cost_cascade_includes_all_lines() -> None: model = financial.compute_financial( teap=t, housing_class="comfort", land_cost_rub=50_000_000.0 ) - # СМР = GFA*СМР + паркинг*себест. - assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_800_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 @@ -226,7 +226,8 @@ def test_financial_market_price_overrides_class_norm() -> None: def test_financial_market_price_does_not_calibrate_parking_or_smr() -> None: - # Калибруем ТОЛЬКО цену жилья: паркинг (1.9М) и себестоимость СМР (88_000) — норма. + # Калибруем ТОЛЬКО цену жилья: паркинг (класс comfort) и себестоимость СМР + # (88_000) — норма, не зависят от market_price_per_sqm. t = _teap(residential=1000.0, gfa=1300.0, parking=10) model = financial.compute_financial( teap=t, @@ -235,10 +236,10 @@ def test_financial_market_price_does_not_calibrate_parking_or_smr() -> None: market_price_per_sqm=200_000.0, price_source="district_reference", ) - # Паркинг — норматив (10 * 1_900_000), не зависит от рыночной цены жилья. - assert model.revenue_parking_rub == 10 * 1_900_000.0 - # СМР — норматив класса comfort (GFA*88_000 + паркинг*1_800_000). - assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_800_000.0 + # Паркинг — норматив класса 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: @@ -255,3 +256,67 @@ def test_financial_none_market_price_forces_class_norm_source() -> None: 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