Merge pull request 'feat(generative): full static developer financial cascade (epic #1881 PR-1)' (#1882) from feat/finmodel-cost-cascade into main
This commit is contained in:
commit
29bbe47ed8
8 changed files with 557 additions and 72 deletions
|
|
@ -29,11 +29,47 @@ class TEAP(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
|
||||
cost_rub: float
|
||||
gross_margin_rub: 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):
|
||||
strategy: Literal["max_area", "max_insolation", "balanced"]
|
||||
|
|
|
|||
|
|
@ -93,14 +93,41 @@ def _teap_table(variants: Sequence[ConceptVariant]) -> str:
|
|||
|
||||
|
||||
def _financial_table(variants: Sequence[ConceptVariant]) -> str:
|
||||
"""HTML-таблица финмодели (деньги в млн руб; IRR — proxy, помечен)."""
|
||||
"""HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР; IRR proxy)."""
|
||||
headers = "".join(
|
||||
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" for v in variants
|
||||
)
|
||||
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.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]),
|
||||
]
|
||||
body = "".join(
|
||||
|
|
@ -132,9 +159,12 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str:
|
|||
f"<p class='sub'>{html.escape(_SUBTITLE)}</p>"
|
||||
f"{_teap_table(variants)}"
|
||||
f"{_financial_table(variants)}"
|
||||
"<p class='sub'>IRR-proxy — аннуализированная маржа-на-затраты без "
|
||||
"дисконтирования (не настоящий IRR). Цены и себестоимость — рыночные "
|
||||
"ориентиры, не калиброванная модель ценообразования.</p>"
|
||||
"<p class='sub'>Статический девелоперский расчёт (без дисконтирования / DCF). "
|
||||
"НДС — упрощённая оценка (встроенный НДС в положительной марже, не полная "
|
||||
"механика входного/выходного НДС НК РФ). Налог на прибыль — 25% (с 2025). "
|
||||
"IRR-proxy — аннуализированный чистый ROI без дисконтирования (не настоящий "
|
||||
"IRR). Цены и себестоимость — рыночные ориентиры, не калиброванная модель. "
|
||||
"Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
* ``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.
|
||||
Cascade (in :func:`compute_financial`):
|
||||
|
||||
Prices/costs are coarse RU-market proxies for an MVP (см. константы ниже); they are
|
||||
deliberately conservative round numbers, not a calibrated pricing engine. The IRR
|
||||
field is a *proxy*: a real internal rate of return needs a dated cashflow series,
|
||||
which is out of MVP scope — we return an annualised margin ratio so the field is
|
||||
populated with a plausible, monotonic number rather than zero.
|
||||
* **Revenue (GDV)** = residential area × sale price + parking spaces × parking price.
|
||||
* **Cost cascade** = construction (residential GFA + parking) + ПИР (design) +
|
||||
networks (ТУ) + developer services + contingency + marketing/realtor + land.
|
||||
* **БДР / taxes** = gross margin − VAT − profit tax → net profit.
|
||||
* **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
|
||||
|
|
@ -42,7 +68,38 @@ _CONSTRUCTION_COST_PER_SQM: dict[HousingClass, float] = {
|
|||
"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
|
||||
|
||||
|
||||
|
|
@ -52,44 +109,92 @@ def compute_financial(
|
|||
housing_class: HousingClass,
|
||||
land_cost_rub: float | None,
|
||||
) -> FinancialModel:
|
||||
"""Свести ТЭП + класс + стоимость земли в :class:`FinancialModel`.
|
||||
"""Свести ТЭП + класс + стоимость земли в полный статический :class:`FinancialModel`.
|
||||
|
||||
Полный девелоперский каскад: выручка (жильё + паркинг) → каскад затрат (СМР +
|
||||
ПИР + сети + услуги заказчика + резерв + маркетинг + земля) → БДР с НДС и
|
||||
налогом на прибыль → чистая прибыль + ROI. НЕ DCF (PR-3); НДС — упрощение
|
||||
(см. docstring модуля); офисов нет в TEAP — пропущены.
|
||||
|
||||
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: задаёт цену продажи и себестоимость СМР.
|
||||
land_cost_rub: стоимость участка (опционально); None -> 0 в затратах.
|
||||
"""
|
||||
sale_price = _SALE_PRICE_PER_SQM[housing_class]
|
||||
construction_cost = _CONSTRUCTION_COST_PER_SQM[housing_class]
|
||||
|
||||
revenue = teap.residential_area_sqm * sale_price
|
||||
construction = teap.total_floor_area_sqm * construction_cost
|
||||
land = land_cost_rub if land_cost_rub is not None else 0.0
|
||||
cost = construction + land
|
||||
gross_margin = revenue - cost
|
||||
# ── Выручка (GDV) ──────────────────────────────────────────────────────────
|
||||
revenue_residential = teap.residential_area_sqm * sale_price
|
||||
revenue_parking = teap.parking_spaces * _PARKING_PRICE_PER_SPOT
|
||||
revenue = revenue_residential + revenue_parking
|
||||
|
||||
# IRR-proxy: аннуализированная маржа-на-затраты. НЕ настоящий IRR (нет дисконта/
|
||||
# дат денежных потоков — отложено в Phase 1). Защита от деления на ноль и
|
||||
# клампинг в разумный диапазон, чтобы поле было монотонным и читаемым.
|
||||
if cost > 0:
|
||||
margin_on_cost = gross_margin / cost
|
||||
irr = margin_on_cost / _PROJECT_YEARS
|
||||
else:
|
||||
irr = 0.0
|
||||
# ── Затраты (каскад) ───────────────────────────────────────────────────────
|
||||
construction_resid = teap.total_floor_area_sqm * construction_cost
|
||||
construction_parking = teap.parking_spaces * _PARKING_COST_PER_SPOT
|
||||
construction = construction_resid + construction_parking
|
||||
pir = construction * _PIR_PCT_OF_CONSTRUCTION
|
||||
networks = teap.total_floor_area_sqm * _NETWORKS_COST_PER_SQM
|
||||
dev_services = construction * _DEVELOPER_SERVICES_PCT
|
||||
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))
|
||||
|
||||
model = FinancialModel(
|
||||
# legacy summary
|
||||
revenue_rub=round(revenue, 2),
|
||||
cost_rub=round(cost, 2),
|
||||
gross_margin_rub=round(gross_margin, 2),
|
||||
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(
|
||||
"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.cost_rub,
|
||||
model.gross_margin_rub,
|
||||
model.irr,
|
||||
model.vat_rub,
|
||||
model.profit_tax_rub,
|
||||
model.net_profit_rub,
|
||||
model.roi,
|
||||
)
|
||||
return model
|
||||
|
||||
|
|
|
|||
|
|
@ -108,8 +108,29 @@ def test_concepts_response_matches_contract_keys() -> None:
|
|||
"parking_spaces",
|
||||
}
|
||||
assert set(variant["financial"].keys()) == {
|
||||
# legacy summary (backward-compat)
|
||||
"revenue_rub",
|
||||
"cost_rub",
|
||||
"gross_margin_rub",
|
||||
"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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,29 +67,88 @@ def test_teap_empty_placement_is_zeroed() -> None:
|
|||
assert result.parking_spaces == 0
|
||||
|
||||
|
||||
def _teap(residential: float, gfa: float) -> TEAP:
|
||||
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=10,
|
||||
parking_spaces=parking,
|
||||
)
|
||||
|
||||
|
||||
def test_financial_revenue_cost_margin() -> None:
|
||||
t = _teap(residential=1000.0, gfa=1300.0)
|
||||
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.
|
||||
assert model.revenue_rub == 1000.0 * 145_000.0
|
||||
# cost = 1300 * 88_000 + land.
|
||||
assert model.cost_rub == 1300.0 * 88_000.0 + 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)
|
||||
|
|
@ -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.land_rub == 10_000_000.0
|
||||
assert no_land.land_rub == 0.0
|
||||
|
||||
|
||||
def test_financial_irr_proxy_clamped() -> None:
|
||||
# Огромная маржа -> irr-proxy зажат в [-1, 1].
|
||||
t = _teap(residential=100_000.0, gfa=1.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)
|
||||
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.irr == 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)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
STRATEGY_HINTS,
|
||||
STRATEGY_LABELS,
|
||||
type ConceptVariant,
|
||||
type FinancialModel,
|
||||
} from "@/lib/concept-api";
|
||||
import type { Polygon } from "geojson";
|
||||
|
||||
|
|
@ -75,10 +76,10 @@ interface PanelProps {
|
|||
|
||||
function VariantPanel({ parcel, variant }: PanelProps) {
|
||||
const { teap, financial } = variant;
|
||||
const marginPositive =
|
||||
financial.gross_margin_rub > 0
|
||||
const netPositive =
|
||||
financial.net_profit_rub > 0
|
||||
? true
|
||||
: financial.gross_margin_rub < 0
|
||||
: financial.net_profit_rub < 0
|
||||
? false
|
||||
: null;
|
||||
|
||||
|
|
@ -96,8 +97,9 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
|||
<div style={{ fontSize: 15, fontWeight: 600 }}>
|
||||
{STRATEGY_LABELS[variant.strategy]}:{" "}
|
||||
{formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
|
||||
{formatInt(teap.apartments_count)} квартир · валовая прибыль{" "}
|
||||
{formatMoneyCompact(financial.gross_margin_rub)}
|
||||
{formatInt(teap.apartments_count)} квартир · чистая прибыль{" "}
|
||||
{formatMoneyCompact(financial.net_profit_rub)} · ROI{" "}
|
||||
{formatPct(financial.roi)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -108,8 +110,9 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
|||
color: "var(--fg-on-dark-muted)",
|
||||
}}
|
||||
>
|
||||
{STRATEGY_HINTS[variant.strategy]} IRR-proxy{" "}
|
||||
{formatPct(financial.irr)} · плотность (FAR){" "}
|
||||
{STRATEGY_HINTS[variant.strategy]} IRR {formatPct(financial.irr)}
|
||||
{financial.irr_is_proxy ? " (оценочный, не DCF)" : ""} · плотность
|
||||
(FAR){" "}
|
||||
{teap.density.toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
|
|
@ -180,7 +183,7 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
|||
{/* Финмодель */}
|
||||
<Section
|
||||
title="Финансовая модель"
|
||||
subtitle="Оценка выручки и доходности по ценам класса жилья и стоимости участка."
|
||||
subtitle="Статический девелоперский расчёт: выручка, каскад затрат, НДС и налог на прибыль → чистая прибыль и ROI."
|
||||
right={<ConceptExportButtons variant={variant} />}
|
||||
>
|
||||
<div
|
||||
|
|
@ -191,40 +194,188 @@ function VariantPanel({ parcel, variant }: PanelProps) {
|
|||
}}
|
||||
>
|
||||
<KpiCard
|
||||
label="Выручка"
|
||||
label="Выручка (GDV)"
|
||||
value={formatMoneyCompact(financial.revenue_rub)}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Затраты"
|
||||
label="Итого затраты"
|
||||
value={formatMoneyCompact(financial.cost_rub)}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Валовая прибыль"
|
||||
value={formatMoneyCompact(financial.gross_margin_rub)}
|
||||
label="Чистая прибыль"
|
||||
value={formatMoneyCompact(financial.net_profit_rub)}
|
||||
delta={{
|
||||
value:
|
||||
marginPositive === true
|
||||
? "Положительная маржа"
|
||||
: marginPositive === false
|
||||
? "Отрицательная маржа"
|
||||
: "Нулевая маржа",
|
||||
positive: marginPositive,
|
||||
netPositive === true
|
||||
? "Положительная (после НДС и налога на прибыль)"
|
||||
: netPositive === false
|
||||
? "Отрицательная (после НДС и налога на прибыль)"
|
||||
: "Нулевая",
|
||||
positive: netPositive,
|
||||
}}
|
||||
/>
|
||||
<KpiCard
|
||||
label="IRR-proxy"
|
||||
value={formatPct(financial.irr)}
|
||||
label="ROI на затраты"
|
||||
value={formatPct(financial.roi)}
|
||||
delta={{
|
||||
value: "Упрощённая оценка: маржа на затраты, без дисконтирования",
|
||||
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
|
||||
positive: null,
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
|
|
|
|||
|
|
@ -3484,7 +3484,18 @@ export interface components {
|
|||
*/
|
||||
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: {
|
||||
/** Revenue Rub */
|
||||
revenue_rub: number;
|
||||
|
|
@ -3494,6 +3505,41 @@ export interface components {
|
|||
gross_margin_rub: number;
|
||||
/** Irr */
|
||||
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
|
||||
|
|
|
|||
|
|
@ -49,10 +49,31 @@ export interface Teap {
|
|||
}
|
||||
|
||||
export interface FinancialModel {
|
||||
// Legacy summary (backward-compat)
|
||||
revenue_rub: number;
|
||||
cost_rub: number;
|
||||
gross_margin_rub: 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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue