fix(financial): парковка price/cost по классу (econom наземная, business Excel)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m57s
CI / backend-tests (pull_request) Successful in 11m44s

Финмодель брала ПЛОСКИЕ parking-константы для ВСЕХ классов: price 1.9М/cost 1.8М
(Excel «Авангардная 13» premium — подземная, near break-even). Но норма парковки
уже class-dependent (econom 0.8/comfort 1.0/business 1.5 м/м на квартиру). Econom
(дешёвое жильё) заряжался 1.8М/м/м ПОДЗЕМНОЙ застройки → огромный break-even капитал,
раздувающий пиковый долг/проценты. По РФ econom = наземная дешёвая парковка.

Фикс: _PARKING_PRICE_PER_SPOT / _PARKING_COST_PER_SPOT → dict[HousingClass]:
- econom 800k/350k (наземная), comfort 1.3М/1.0М (частично подземная),
  business 1.9М/1.8М (подземная, = Excel, unchanged).
- маржа/м/м: econom +450k, comfort +300k, business +100k.
- НОРМА парковки (_PARKING_PER_APARTMENT) НЕ тронута (возможно ПЗЗ-норматив).
- Константы помечены как TUNABLE ASSUMPTIONS (РФ-рынок), правятся в одном месте
  (паттерн _SALE_PRICE_PER_SQM / _CONSTRUCTION_COST_PER_SQM).

ВАЖНО (честно): парковка — ВТОРИЧНЫЙ драйвер. Улучшает econom в основном через
снижение peak-debt/процентов, НЕ переворачивает знак. ДОМИНИРУЮЩИЙ рычаг —
себестоимость СМР жилья к выручке (econom 72k на GFA vs выручка на sellable = 87%)
— отдельное продуктовое решение (не трогаю без согласования).

Инвариант Σ cashflow == net_profit сохранён (меняются только величины
revenue_parking/construction_parking в том же каскаде). Тесты: +6 (по классам,
маржа, инвариант econom+parking) + 3 существующих исправлены (хардкод comfort
1.9/1.8 → 1.3/1.0). tests/services/generative 90 passed. ruff+mypy(strict) чисто.
Схема не менялась.

Хозяйственное: untrack + gitignore backend/.coverage (артефакт блокировал checkout).

Refs #1881
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Light1YT 2026-06-24 16:58:21 +05:00
parent a45dfd4ee3
commit 4eeda46cfa
5 changed files with 126 additions and 16 deletions

Binary file not shown.

1
backend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.coverage

View file

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

View file

@ -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}

View file

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