gendesign/backend/tests/services/generative/test_teap_financial.py
Light1YT 65bab902e1
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Successful in 1m5s
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 10m10s
feat(generative): full static developer financial cascade (epic #1881 PR-1)
Объединяет наше (площади из реальной геометрии 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
2026-06-23 16:45:58 +05:00

189 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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