Объединяет наше (площади из реальной геометрии teap) + полноту девелоперской Excel-модели (каскад затрат + БДР с налогами). Расширяет financial.py из статического GDV−COST в полный статический расчёт. БЕЗ DCF (PR-3), БЕЗ новых вводных от пользователя — только нормативы + существующий TEAP. Каскад (financial.py): - Выручка: жильё (residential×price) + ПАРКИНГ (spaces×1.9М) - Затраты: СМР(резид+паркинг) + ПИР 2.5% + сети/ТУ 6000₽/м² + услуги заказчика 1.5% + резерв 3% + маркетинг/риэлтор 7% выручки + земля - БДР: валовая маржа → НДС (упрощённо) → прибыль до налога → налог 25% (ФЗ 176-ФЗ) → ЧИСТАЯ прибыль → ROI + чистая маржа% - Нормативы документированы (источник: Excel-эталон «Авангардная 13» + RU-рынок) FinancialModel (concept.py): +16 additive-полей (revenue breakdown, cost cascade, БДР/налоги, roi, margin_pct, irr_is_proxy). Старые 4 поля сохранены (backward-compat). IRR честно помечен irr_is_proxy=True (annualized net ROI, не DCF) — настоящий IRR/NPV/PBP в PR-3 (помесячный cashflow). НДС — документированное упрощение, не точный НК РФ. Офисы пропущены (нет площади в TEAP). PDF + concept UI: полный БДР-каскад + чистая прибыль/ROI + честные сноски. api-types.ts регенерён авторитетно (openapi-typescript + prettier, как CI). mypy strict clean (generative.*), +тесты (каскад/паркинг/налоги/backward-compat/ edge cost=0), 36 passed. Refs #1881
189 lines
7.8 KiB
Python
189 lines
7.8 KiB
Python
"""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
|
||
# Жилая = GFA * efficiency(comfort=0.78).
|
||
assert result.residential_area_sqm == 1560.0
|
||
# Квартир = floor(жилая / avg(comfort=55)).
|
||
assert result.apartments_count == int(1560.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) + паркинг (10 * 1_900_000).
|
||
assert model.revenue_residential_rub == 1000.0 * 145_000.0
|
||
assert model.revenue_parking_rub == 10 * 1_900_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*СМР + паркинг*себест.
|
||
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_800_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)
|