feat(generative): full static developer financial cascade (epic #1881 PR-1)
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

Объединяет наше (площади из реальной геометрии 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
This commit is contained in:
Light1YT 2026-06-23 16:45:58 +05:00
parent 7ae96ad1af
commit 65bab902e1
8 changed files with 557 additions and 72 deletions

View file

@ -29,11 +29,47 @@ class TEAP(BaseModel):
class FinancialModel(BaseModel): class FinancialModel(BaseModel):
"""Static developer P&L for a concept variant (PR-1, эпик #1881).
Full static cost cascade + VAT + profit tax net profit + ROI. NOT a DCF
timing/discounting lands in PR-3. VAT is a documented simplification (see
``compute_financial`` docstring), not exact НК РФ mechanics.
The legacy summary fields (``revenue_rub`` / ``cost_rub`` /
``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields
expose the full cascade and the net P&L.
"""
# ── Legacy summary (backward-compat) ───────────────────────────────────────
revenue_rub: float revenue_rub: float
cost_rub: float cost_rub: float
gross_margin_rub: float gross_margin_rub: float
irr: float irr: float
# ── Revenue breakdown ──────────────────────────────────────────────────────
revenue_residential_rub: float
revenue_parking_rub: float
# ── Cost cascade ───────────────────────────────────────────────────────────
construction_rub: float
pir_rub: float
networks_rub: float
developer_services_rub: float
contingency_rub: float
marketing_rub: float
land_rub: float
# ── БДР / taxes → net profit ───────────────────────────────────────────────
vat_rub: float
profit_before_tax_rub: float
profit_tax_rub: float
net_profit_rub: float
# ── Metrics ────────────────────────────────────────────────────────────────
roi: float # net profit / total cost
margin_pct: float # net profit / revenue
irr_is_proxy: bool = True # frontend caveat: not a real DCF IRR (см. PR-3)
class ConceptVariant(BaseModel): class ConceptVariant(BaseModel):
strategy: Literal["max_area", "max_insolation", "balanced"] strategy: Literal["max_area", "max_insolation", "balanced"]

View file

@ -93,14 +93,41 @@ def _teap_table(variants: Sequence[ConceptVariant]) -> str:
def _financial_table(variants: Sequence[ConceptVariant]) -> str: def _financial_table(variants: Sequence[ConceptVariant]) -> str:
"""HTML-таблица финмодели (деньги в млн руб; IRR proxy, помечен).""" """HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР; IRR proxy)."""
headers = "".join( headers = "".join(
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
) )
rows: list[tuple[str, list[str]]] = [ rows: list[tuple[str, list[str]]] = [
("Выручка, млн руб", [_fmt_money(v.financial.revenue_rub) for v in variants]), (
("Затраты, млн руб", [_fmt_money(v.financial.cost_rub) for v in variants]), "Выручка — жильё, млн руб",
[_fmt_money(v.financial.revenue_residential_rub) for v in variants],
),
(
"Выручка — паркинг, млн руб",
[_fmt_money(v.financial.revenue_parking_rub) for v in variants],
),
("Выручка (GDV), млн руб", [_fmt_money(v.financial.revenue_rub) for v in variants]),
("СМР, млн руб", [_fmt_money(v.financial.construction_rub) for v in variants]),
("ПИР, млн руб", [_fmt_money(v.financial.pir_rub) for v in variants]),
("Сети (ТУ), млн руб", [_fmt_money(v.financial.networks_rub) for v in variants]),
(
"Услуги заказчика, млн руб",
[_fmt_money(v.financial.developer_services_rub) for v in variants],
),
("Резерв, млн руб", [_fmt_money(v.financial.contingency_rub) for v in variants]),
("Маркетинг + риэлтор, млн руб", [_fmt_money(v.financial.marketing_rub) for v in variants]),
("Земля, млн руб", [_fmt_money(v.financial.land_rub) for v in variants]),
("Итого затраты, млн руб", [_fmt_money(v.financial.cost_rub) for v in variants]),
("Валовая маржа, млн руб", [_fmt_money(v.financial.gross_margin_rub) for v in variants]), ("Валовая маржа, млн руб", [_fmt_money(v.financial.gross_margin_rub) for v in variants]),
("НДС (упрощ.), млн руб", [_fmt_money(v.financial.vat_rub) for v in variants]),
(
"Прибыль до налога, млн руб",
[_fmt_money(v.financial.profit_before_tax_rub) for v in variants],
),
("Налог на прибыль, млн руб", [_fmt_money(v.financial.profit_tax_rub) for v in variants]),
("Чистая прибыль, млн руб", [_fmt_money(v.financial.net_profit_rub) for v in variants]),
("ROI (на затраты)", [f"{v.financial.roi * 100:.1f}%" for v in variants]),
("Чистая маржа (на выручку)", [f"{v.financial.margin_pct * 100:.1f}%" for v in variants]),
("IRR-proxy", [f"{v.financial.irr * 100:.1f}%" for v in variants]), ("IRR-proxy", [f"{v.financial.irr * 100:.1f}%" for v in variants]),
] ]
body = "".join( body = "".join(
@ -132,9 +159,12 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
f"<p class='sub'>{html.escape(_SUBTITLE)}</p>" f"<p class='sub'>{html.escape(_SUBTITLE)}</p>"
f"{_teap_table(variants)}" f"{_teap_table(variants)}"
f"{_financial_table(variants)}" f"{_financial_table(variants)}"
"<p class='sub'>IRR-proxy — аннуализированная маржа-на-затраты без " "<p class='sub'>Статический девелоперский расчёт (без дисконтирования / DCF). "
"дисконтирования (не настоящий IRR). Цены и себестоимость — рыночные " "НДС — упрощённая оценка (встроенный НДС в положительной марже, не полная "
"ориентиры, не калиброванная модель ценообразования.</p>" "механика входного/выходного НДС НК РФ). Налог на прибыль — 25% (с 2025). "
"IRR-proxy — аннуализированный чистый ROI без дисконтирования (не настоящий "
"IRR). Цены и себестоимость — рыночные ориентиры, не калиброванная модель. "
"Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
"</body></html>" "</body></html>"
) )

View file

@ -1,20 +1,46 @@
"""Generative Design — Stage 1c: simplified financial model. """Generative Design — Stage 1c: static developer financial model (PR-1, эпик #1881).
From the Stage 1c ``TEAP`` block we derive the ``FinancialModel`` contract: We combine **our areas** (from real geometry :class:`TEAP`) with the **cost
completeness** of a reference developer Excel model: a full static cost cascade,
VAT, profit tax net profit + ROI. This is a *correct static* P&L there is no
DCF / discounting yet (that lands in PR-3); no new user inputs are required, only
norms + the existing TEAP.
* ``revenue_rub`` = residential_area_sqm * sale price per sqm (by housing class). Cascade (in :func:`compute_financial`):
* ``cost_rub`` = total_floor_area_sqm * construction cost per sqm + land cost.
* ``gross_margin_rub`` = revenue - cost.
* ``irr`` = simplified proxy (margin-on-cost / project years), NO time
discounting this is a static stand-in until the Phase 1 cashflow model lands.
Prices/costs are coarse RU-market proxies for an MVP (см. константы ниже); they are * **Revenue (GDV)** = residential area × sale price + parking spaces × parking price.
deliberately conservative round numbers, not a calibrated pricing engine. The IRR * **Cost cascade** = construction (residential GFA + parking) + ПИР (design) +
field is a *proxy*: a real internal rate of return needs a dated cashflow series, networks (ТУ) + developer services + contingency + marketing/realtor + land.
which is out of MVP scope we return an annualised margin ratio so the field is * **БДР / taxes** = gross margin VAT profit tax net profit.
populated with a plausible, monotonic number rather than zero. * **Metrics** = net ROI on cost, net margin on revenue, and an IRR *proxy*.
Детерминированно, без LLM / внешних API / БД. VAT DOCUMENTED SIMPLIFICATION
-------------------------------
Exact НК РФ VAT mechanics (output VAT on flats vs. input-VAT deductions on
construction/materials, эскроу specifics, residential-sale exemptions) are out of
PR-1 scope. We use a conservative, honest approximation on the project's
*value-added* (gross margin):
vat = max(0, gross_margin) × VAT_RATE / (1 + VAT_RATE)
i.e. the embedded VAT inside the positive margin. This deliberately *does not*
model the full input/output deduction chain it is a stand-in that keeps the net
P&L plausible and conservative. A loss-making project pays no VAT here. Replace
with proper output/input modelling when calibrating (PR-2/PR-3).
IRR PROXY UNTIL DCF
---------------------
A real IRR needs a dated cashflow series (PR-3). We expose ``irr`` as an
annualised net-ROI proxy (``roi / _PROJECT_YEARS``, clamped to ±1) and flag it via
``irr_is_proxy=True`` so the frontend can show the caveat.
OFFICES / COMMERCIAL SKIPPED
------------------------------
TEAP carries no office/commercial area (only residential + parking), so commercial
revenue/cost is intentionally **not** modelled here. Add when TEAP gains the field.
Prices/costs of housing & СМР stay as the existing hardcoded per-class proxies
(calibration is PR-2). Детерминированно, без LLM / внешних API / БД.
""" """
from __future__ import annotations from __future__ import annotations
@ -42,7 +68,38 @@ _CONSTRUCTION_COST_PER_SQM: dict[HousingClass, float] = {
"business": 120_000.0, "business": 120_000.0,
} }
# Условный горизонт проекта (лет) для аннуализации margin-on-cost в IRR-proxy. # ── Паркинг (Excel-эталон девелоперской модели) ───────────────────────────────
# Цена продажи / себестоимость одного машиноместа, руб.
_PARKING_PRICE_PER_SPOT: float = 1_900_000.0 # Excel: цена продажи м/м
_PARKING_COST_PER_SPOT: float = 1_800_000.0 # Excel: себестоимость м/м
# ── Доли каскада затрат (источник — Excel-эталон + RU-рынок) ──────────────────
# ПИР (проектирование + экспертиза + РД) как доля от СМР.
# Excel: ПИР 147М при СМР 6123М ≈ 2.4% -> округлённо 2.5%.
_PIR_PCT_OF_CONSTRUCTION: float = 0.025
# Сети / подключение по ТУ, руб/кв.м GFA.
# Excel: ТУ 330М / 55042 кв.м ≈ 6000 руб/кв.м.
_NETWORKS_COST_PER_SQM: float = 6_000.0
# Услуги заказчика / управление как доля от СМР.
# Excel: 76.5М при СМР 6123М ≈ 1.25% -> округлённо 1.5%.
_DEVELOPER_SERVICES_PCT: float = 0.015
# Резерв / непредвиденные как доля от (СМР + ПИР + сети). Excel: 3%.
_CONTINGENCY_PCT: float = 0.03
# Реклама + риэлтор + маркетинг как доля от выручки. Excel: 7%.
_MARKETING_REALTOR_PCT: float = 0.07
# ── Налоги ────────────────────────────────────────────────────────────────────
# НДС, ставка. РФ стандартная 20%.
_VAT_RATE: float = 0.20
# Налог на прибыль организаций. С 01.01.2025 базовая ставка повышена 20% -> 25%
# (ФЗ № 176-ФЗ от 12.07.2024). Используем актуальные 25%.
_PROFIT_TAX_RATE: float = 0.25
# Условный горизонт проекта (лет) для аннуализации net-ROI в IRR-proxy.
_PROJECT_YEARS: float = 3.0 _PROJECT_YEARS: float = 3.0
@ -52,44 +109,92 @@ def compute_financial(
housing_class: HousingClass, housing_class: HousingClass,
land_cost_rub: float | None, land_cost_rub: float | None,
) -> FinancialModel: ) -> FinancialModel:
"""Свести ТЭП + класс + стоимость земли в :class:`FinancialModel`. """Свести ТЭП + класс + стоимость земли в полный статический :class:`FinancialModel`.
Полный девелоперский каскад: выручка (жильё + паркинг) каскад затрат (СМР +
ПИР + сети + услуги заказчика + резерв + маркетинг + земля) БДР с НДС и
налогом на прибыль чистая прибыль + ROI. НЕ DCF (PR-3); НДС упрощение
(см. docstring модуля); офисов нет в TEAP пропущены.
Args: Args:
teap: Stage 1c ТЭП (берём residential_area_sqm и total_floor_area_sqm). teap: Stage 1c ТЭП (residential_area_sqm, total_floor_area_sqm, parking_spaces).
housing_class: задаёт цену продажи и себестоимость СМР. housing_class: задаёт цену продажи и себестоимость СМР.
land_cost_rub: стоимость участка (опционально); None -> 0 в затратах. land_cost_rub: стоимость участка (опционально); None -> 0 в затратах.
""" """
sale_price = _SALE_PRICE_PER_SQM[housing_class] sale_price = _SALE_PRICE_PER_SQM[housing_class]
construction_cost = _CONSTRUCTION_COST_PER_SQM[housing_class] construction_cost = _CONSTRUCTION_COST_PER_SQM[housing_class]
revenue = teap.residential_area_sqm * sale_price # ── Выручка (GDV) ──────────────────────────────────────────────────────────
construction = teap.total_floor_area_sqm * construction_cost revenue_residential = teap.residential_area_sqm * sale_price
land = land_cost_rub if land_cost_rub is not None else 0.0 revenue_parking = teap.parking_spaces * _PARKING_PRICE_PER_SPOT
cost = construction + land revenue = revenue_residential + revenue_parking
gross_margin = revenue - cost
# IRR-proxy: аннуализированная маржа-на-затраты. НЕ настоящий IRR (нет дисконта/ # ── Затраты (каскад) ───────────────────────────────────────────────────────
# дат денежных потоков — отложено в Phase 1). Защита от деления на ноль и construction_resid = teap.total_floor_area_sqm * construction_cost
# клампинг в разумный диапазон, чтобы поле было монотонным и читаемым. construction_parking = teap.parking_spaces * _PARKING_COST_PER_SPOT
if cost > 0: construction = construction_resid + construction_parking
margin_on_cost = gross_margin / cost pir = construction * _PIR_PCT_OF_CONSTRUCTION
irr = margin_on_cost / _PROJECT_YEARS networks = teap.total_floor_area_sqm * _NETWORKS_COST_PER_SQM
else: dev_services = construction * _DEVELOPER_SERVICES_PCT
irr = 0.0 contingency = (construction + pir + networks) * _CONTINGENCY_PCT
marketing = revenue * _MARKETING_REALTOR_PCT
land = land_cost_rub if land_cost_rub is not None else 0.0
cost = construction + pir + networks + dev_services + contingency + marketing + land
# ── БДР / налоги → чистая прибыль ─────────────────────────────────────────
gross_margin = revenue - cost # валовая (как было)
# НДС: УПРОЩЕНО — встроенный НДС в положительной марже (value-added),
# не полная механика входного/выходного НДС НК РФ (см. docstring модуля).
vat = max(0.0, gross_margin) * _VAT_RATE / (1.0 + _VAT_RATE)
profit_before_tax = gross_margin - vat
profit_tax = max(0.0, profit_before_tax) * _PROFIT_TAX_RATE
net_profit = profit_before_tax - profit_tax
# ── Метрики ────────────────────────────────────────────────────────────────
roi = net_profit / cost if cost > 0 else 0.0 # чистый ROI на затраты
margin_pct = net_profit / revenue if revenue > 0 else 0.0 # чистая маржа на выручку
# IRR-proxy: аннуализированный net-ROI. НЕ настоящий IRR (нет дат/дисконта —
# PR-3). Клампинг ±1, чтобы поле было монотонным и читаемым; флаг proxy.
irr = roi / _PROJECT_YEARS
irr = max(-1.0, min(1.0, irr)) irr = max(-1.0, min(1.0, irr))
model = FinancialModel( model = FinancialModel(
# legacy summary
revenue_rub=round(revenue, 2), revenue_rub=round(revenue, 2),
cost_rub=round(cost, 2), cost_rub=round(cost, 2),
gross_margin_rub=round(gross_margin, 2), gross_margin_rub=round(gross_margin, 2),
irr=round(irr, 4), irr=round(irr, 4),
# revenue breakdown
revenue_residential_rub=round(revenue_residential, 2),
revenue_parking_rub=round(revenue_parking, 2),
# cost cascade
construction_rub=round(construction, 2),
pir_rub=round(pir, 2),
networks_rub=round(networks, 2),
developer_services_rub=round(dev_services, 2),
contingency_rub=round(contingency, 2),
marketing_rub=round(marketing, 2),
land_rub=round(land, 2),
# БДР / taxes
vat_rub=round(vat, 2),
profit_before_tax_rub=round(profit_before_tax, 2),
profit_tax_rub=round(profit_tax, 2),
net_profit_rub=round(net_profit, 2),
# metrics
roi=round(roi, 4),
margin_pct=round(margin_pct, 4),
irr_is_proxy=True,
) )
logger.info( logger.info(
"financial: revenue=%.0f cost=%.0f margin=%.0f irr_proxy=%.3f", "financial: revenue=%.0f cost=%.0f gross=%.0f vat=%.0f tax=%.0f net=%.0f roi=%.3f",
model.revenue_rub, model.revenue_rub,
model.cost_rub, model.cost_rub,
model.gross_margin_rub, model.gross_margin_rub,
model.irr, model.vat_rub,
model.profit_tax_rub,
model.net_profit_rub,
model.roi,
) )
return model return model

View file

@ -108,8 +108,29 @@ def test_concepts_response_matches_contract_keys() -> None:
"parking_spaces", "parking_spaces",
} }
assert set(variant["financial"].keys()) == { assert set(variant["financial"].keys()) == {
# legacy summary (backward-compat)
"revenue_rub", "revenue_rub",
"cost_rub", "cost_rub",
"gross_margin_rub", "gross_margin_rub",
"irr", "irr",
# revenue breakdown
"revenue_residential_rub",
"revenue_parking_rub",
# cost cascade
"construction_rub",
"pir_rub",
"networks_rub",
"developer_services_rub",
"contingency_rub",
"marketing_rub",
"land_rub",
# БДР / taxes
"vat_rub",
"profit_before_tax_rub",
"profit_tax_rub",
"net_profit_rub",
# metrics
"roi",
"margin_pct",
"irr_is_proxy",
} }

View file

@ -67,29 +67,88 @@ def test_teap_empty_placement_is_zeroed() -> None:
assert result.parking_spaces == 0 assert result.parking_spaces == 0
def _teap(residential: float, gfa: float) -> TEAP: def _teap(residential: float, gfa: float, parking: int = 10) -> TEAP:
return TEAP( return TEAP(
built_area_sqm=100.0, built_area_sqm=100.0,
total_floor_area_sqm=gfa, total_floor_area_sqm=gfa,
residential_area_sqm=residential, residential_area_sqm=residential,
apartments_count=10, apartments_count=10,
density=1.0, density=1.0,
parking_spaces=10, parking_spaces=parking,
) )
def test_financial_revenue_cost_margin() -> None: def test_financial_revenue_includes_parking() -> None:
t = _teap(residential=1000.0, gfa=1300.0) t = _teap(residential=1000.0, gfa=1300.0, parking=10)
model = financial.compute_financial( model = financial.compute_financial(
teap=t, housing_class="comfort", land_cost_rub=50_000_000.0 teap=t, housing_class="comfort", land_cost_rub=50_000_000.0
) )
# revenue = 1000 * 145_000. # revenue = жильё (1000 * 145_000) + паркинг (10 * 1_900_000).
assert model.revenue_rub == 1000.0 * 145_000.0 assert model.revenue_residential_rub == 1000.0 * 145_000.0
# cost = 1300 * 88_000 + land. assert model.revenue_parking_rub == 10 * 1_900_000.0
assert model.cost_rub == 1300.0 * 88_000.0 + 50_000_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 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: def test_financial_land_cost_optional() -> None:
t = _teap(residential=1000.0, gfa=1300.0) t = _teap(residential=1000.0, gfa=1300.0)
no_land = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None) no_land = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
@ -98,17 +157,33 @@ def test_financial_land_cost_optional() -> None:
) )
# Земля увеличивает затраты ровно на свою стоимость. # Земля увеличивает затраты ровно на свою стоимость.
assert with_land.cost_rub - no_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() -> None: def test_financial_irr_proxy_clamped_and_flagged() -> None:
# Огромная маржа -> irr-proxy зажат в [-1, 1]. # Огромная маржа -> irr-proxy зажат в [-1, 1]; флаг proxy выставлен.
t = _teap(residential=100_000.0, gfa=1.0) t = _teap(residential=100_000.0, gfa=1.0, parking=0)
model = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None) model = financial.compute_financial(teap=t, housing_class="business", land_cost_rub=None)
assert -1.0 <= model.irr <= 1.0 assert -1.0 <= model.irr <= 1.0
assert model.irr_is_proxy is True
def test_financial_zero_cost_no_division_error() -> None: def test_financial_zero_cost_no_division_error() -> None:
t = _teap(residential=0.0, gfa=0.0) t = _teap(residential=0.0, gfa=0.0, parking=0)
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None) model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=None)
assert model.irr == 0.0
assert model.cost_rub == 0.0 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)

View file

@ -16,6 +16,7 @@ import {
STRATEGY_HINTS, STRATEGY_HINTS,
STRATEGY_LABELS, STRATEGY_LABELS,
type ConceptVariant, type ConceptVariant,
type FinancialModel,
} from "@/lib/concept-api"; } from "@/lib/concept-api";
import type { Polygon } from "geojson"; import type { Polygon } from "geojson";
@ -75,10 +76,10 @@ interface PanelProps {
function VariantPanel({ parcel, variant }: PanelProps) { function VariantPanel({ parcel, variant }: PanelProps) {
const { teap, financial } = variant; const { teap, financial } = variant;
const marginPositive = const netPositive =
financial.gross_margin_rub > 0 financial.net_profit_rub > 0
? true ? true
: financial.gross_margin_rub < 0 : financial.net_profit_rub < 0
? false ? false
: null; : null;
@ -96,8 +97,9 @@ function VariantPanel({ parcel, variant }: PanelProps) {
<div style={{ fontSize: 15, fontWeight: 600 }}> <div style={{ fontSize: 15, fontWeight: 600 }}>
{STRATEGY_LABELS[variant.strategy]}:{" "} {STRATEGY_LABELS[variant.strategy]}:{" "}
{formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "} {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
{formatInt(teap.apartments_count)} квартир · валовая прибыль{" "} {formatInt(teap.apartments_count)} квартир · чистая прибыль{" "}
{formatMoneyCompact(financial.gross_margin_rub)} {formatMoneyCompact(financial.net_profit_rub)} · ROI{" "}
{formatPct(financial.roi)}
</div> </div>
<div <div
style={{ style={{
@ -108,8 +110,9 @@ function VariantPanel({ parcel, variant }: PanelProps) {
color: "var(--fg-on-dark-muted)", color: "var(--fg-on-dark-muted)",
}} }}
> >
{STRATEGY_HINTS[variant.strategy]} IRR-proxy{" "} {STRATEGY_HINTS[variant.strategy]} IRR {formatPct(financial.irr)}
{formatPct(financial.irr)} · плотность (FAR){" "} {financial.irr_is_proxy ? " (оценочный, не DCF)" : ""} · плотность
(FAR){" "}
{teap.density.toLocaleString("ru-RU", { {teap.density.toLocaleString("ru-RU", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
@ -180,7 +183,7 @@ function VariantPanel({ parcel, variant }: PanelProps) {
{/* Финмодель */} {/* Финмодель */}
<Section <Section
title="Финансовая модель" title="Финансовая модель"
subtitle="Оценка выручки и доходности по ценам класса жилья и стоимости участка." subtitle="Статический девелоперский расчёт: выручка, каскад затрат, НДС и налог на прибыль → чистая прибыль и ROI."
right={<ConceptExportButtons variant={variant} />} right={<ConceptExportButtons variant={variant} />}
> >
<div <div
@ -191,40 +194,188 @@ function VariantPanel({ parcel, variant }: PanelProps) {
}} }}
> >
<KpiCard <KpiCard
label="Выручка" label="Выручка (GDV)"
value={formatMoneyCompact(financial.revenue_rub)} value={formatMoneyCompact(financial.revenue_rub)}
/> />
<KpiCard <KpiCard
label="Затраты" label="Итого затраты"
value={formatMoneyCompact(financial.cost_rub)} value={formatMoneyCompact(financial.cost_rub)}
/> />
<KpiCard <KpiCard
label="Валовая прибыль" label="Чистая прибыль"
value={formatMoneyCompact(financial.gross_margin_rub)} value={formatMoneyCompact(financial.net_profit_rub)}
delta={{ delta={{
value: value:
marginPositive === true netPositive === true
? "Положительная маржа" ? "Положительная (после НДС и налога на прибыль)"
: marginPositive === false : netPositive === false
? "Отрицательная маржа" ? "Отрицательная (после НДС и налога на прибыль)"
: "Нулевая маржа", : "Нулевая",
positive: marginPositive, positive: netPositive,
}} }}
/> />
<KpiCard <KpiCard
label="IRR-proxy" label="ROI на затраты"
value={formatPct(financial.irr)} value={formatPct(financial.roi)}
delta={{ delta={{
value: "Упрощённая оценка: маржа на затраты, без дисконтирования", value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
positive: null, positive: null,
}} }}
/> />
</div> </div>
{/* Каскад затрат + БДР — под аккордеоном (плотность > выкладки) */}
<details style={{ marginTop: 12 }}>
<summary
style={{
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
color: "var(--fg-secondary)",
}}
>
Подробнее каскад затрат и расчёт прибыли
</summary>
<div style={{ marginTop: 12 }}>
<FinancialCascadeTable financial={financial} />
<p
style={{
marginTop: 12,
fontSize: 12,
color: "var(--fg-tertiary)",
}}
>
Статический расчёт без дисконтирования (DCF следующий этап). НДС
упрощённая оценка (встроенный НДС в положительной марже, не
полная механика входного/выходного НДС НК РФ). Налог на прибыль
25% (с 2025 года). IRR{" "}
{financial.irr_is_proxy
? "— оценочный (аннуализированный чистый ROI), не настоящий IRR."
: "."}{" "}
Цены и себестоимость рыночные ориентиры, не калиброванная
модель. Коммерческие и офисные площади не учитываются.
</p>
</div>
</details>
</Section> </Section>
</div> </div>
); );
} }
// ── Cost cascade / P&L detail table ────────────────────────────────────────────
function CascadeRow({
label,
value,
emphasis = false,
}: {
label: string;
value: string;
emphasis?: boolean;
}) {
return (
<tr>
<td
style={{
padding: "6px 8px",
borderBottom: "1px solid var(--border-soft)",
color: emphasis ? "var(--fg-primary)" : "var(--fg-secondary)",
fontWeight: emphasis ? 600 : 400,
}}
>
{label}
</td>
<td
style={{
padding: "6px 8px",
borderBottom: "1px solid var(--border-soft)",
textAlign: "right",
fontWeight: emphasis ? 600 : 400,
fontVariantNumeric: "tabular-nums",
}}
>
{value}
</td>
</tr>
);
}
function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
return (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<tbody>
<CascadeRow
label="Выручка — жильё"
value={formatMoneyCompact(financial.revenue_residential_rub)}
/>
<CascadeRow
label="Выручка — паркинг"
value={formatMoneyCompact(financial.revenue_parking_rub)}
/>
<CascadeRow
label="Выручка (GDV)"
value={formatMoneyCompact(financial.revenue_rub)}
emphasis
/>
<CascadeRow
label="СМР"
value={formatMoneyCompact(financial.construction_rub)}
/>
<CascadeRow
label="ПИР (проектирование)"
value={formatMoneyCompact(financial.pir_rub)}
/>
<CascadeRow
label="Сети (ТУ)"
value={formatMoneyCompact(financial.networks_rub)}
/>
<CascadeRow
label="Услуги заказчика"
value={formatMoneyCompact(financial.developer_services_rub)}
/>
<CascadeRow
label="Резерв"
value={formatMoneyCompact(financial.contingency_rub)}
/>
<CascadeRow
label="Маркетинг и риэлтор"
value={formatMoneyCompact(financial.marketing_rub)}
/>
<CascadeRow
label="Земля"
value={formatMoneyCompact(financial.land_rub)}
/>
<CascadeRow
label="Итого затраты"
value={formatMoneyCompact(financial.cost_rub)}
emphasis
/>
<CascadeRow
label="Валовая маржа"
value={formatMoneyCompact(financial.gross_margin_rub)}
/>
<CascadeRow
label="НДС (упрощённо)"
value={formatMoneyCompact(financial.vat_rub)}
/>
<CascadeRow
label="Прибыль до налога"
value={formatMoneyCompact(financial.profit_before_tax_rub)}
/>
<CascadeRow
label="Налог на прибыль (25%)"
value={formatMoneyCompact(financial.profit_tax_rub)}
/>
<CascadeRow
label="Чистая прибыль"
value={formatMoneyCompact(financial.net_profit_rub)}
emphasis
/>
</tbody>
</table>
);
}
// ── Component ───────────────────────────────────────────────────────────────── // ── Component ─────────────────────────────────────────────────────────────────
interface Props { interface Props {

View file

@ -3484,7 +3484,18 @@ export interface components {
*/ */
rate_ms: number; rate_ms: number;
}; };
/** FinancialModel */ /**
* FinancialModel
* @description Static developer P&L for a concept variant (PR-1, эпик #1881).
*
* Full static cost cascade + VAT + profit tax net profit + ROI. NOT a DCF
* timing/discounting lands in PR-3. VAT is a documented simplification (see
* ``compute_financial`` docstring), not exact НК РФ mechanics.
*
* The legacy summary fields (``revenue_rub`` / ``cost_rub`` /
* ``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields
* expose the full cascade and the net P&L.
*/
FinancialModel: { FinancialModel: {
/** Revenue Rub */ /** Revenue Rub */
revenue_rub: number; revenue_rub: number;
@ -3494,6 +3505,41 @@ export interface components {
gross_margin_rub: number; gross_margin_rub: number;
/** Irr */ /** Irr */
irr: number; irr: number;
/** Revenue Residential Rub */
revenue_residential_rub: number;
/** Revenue Parking Rub */
revenue_parking_rub: number;
/** Construction Rub */
construction_rub: number;
/** Pir Rub */
pir_rub: number;
/** Networks Rub */
networks_rub: number;
/** Developer Services Rub */
developer_services_rub: number;
/** Contingency Rub */
contingency_rub: number;
/** Marketing Rub */
marketing_rub: number;
/** Land Rub */
land_rub: number;
/** Vat Rub */
vat_rub: number;
/** Profit Before Tax Rub */
profit_before_tax_rub: number;
/** Profit Tax Rub */
profit_tax_rub: number;
/** Net Profit Rub */
net_profit_rub: number;
/** Roi */
roi: number;
/** Margin Pct */
margin_pct: number;
/**
* Irr Is Proxy
* @default true
*/
irr_is_proxy: boolean;
}; };
/** /**
* GroundedIn * GroundedIn

View file

@ -49,10 +49,31 @@ export interface Teap {
} }
export interface FinancialModel { export interface FinancialModel {
// Legacy summary (backward-compat)
revenue_rub: number; revenue_rub: number;
cost_rub: number; cost_rub: number;
gross_margin_rub: number; gross_margin_rub: number;
irr: number; irr: number;
// Revenue breakdown
revenue_residential_rub: number;
revenue_parking_rub: number;
// Cost cascade
construction_rub: number;
pir_rub: number;
networks_rub: number;
developer_services_rub: number;
contingency_rub: number;
marketing_rub: number;
land_rub: number;
// БДР / taxes → net profit
vat_rub: number;
profit_before_tax_rub: number;
profit_tax_rub: number;
net_profit_rub: number;
// Metrics
roi: number;
margin_pct: number;
irr_is_proxy: boolean;
} }
export interface ConceptVariant { export interface ConceptVariant {