From 65bab902e16b9ca3ce6a893a741ed1c8d1e2bdf1 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 23 Jun 2026 16:45:58 +0500 Subject: [PATCH] feat(generative): full static developer financial cascade (epic #1881 PR-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Объединяет наше (площади из реальной геометрии 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 --- backend/app/schemas/concept.py | 36 ++++ .../app/services/generative/exporters/pdf.py | 42 +++- backend/app/services/generative/financial.py | 167 ++++++++++++--- .../services/generative/test_api_concepts.py | 21 ++ .../generative/test_teap_financial.py | 101 +++++++-- .../concept/ConceptVariantsResult.tsx | 193 ++++++++++++++++-- frontend/src/lib/api-types.ts | 48 ++++- frontend/src/lib/concept-api.ts | 21 ++ 8 files changed, 557 insertions(+), 72 deletions(-) diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py index e4625448..5b765519 100644 --- a/backend/app/schemas/concept.py +++ b/backend/app/schemas/concept.py @@ -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"] diff --git a/backend/app/services/generative/exporters/pdf.py b/backend/app/services/generative/exporters/pdf.py index ab99ef15..8c16f9a3 100644 --- a/backend/app/services/generative/exporters/pdf.py +++ b/backend/app/services/generative/exporters/pdf.py @@ -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"{html.escape(_strategy_label(v.strategy))}" 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"

{html.escape(_SUBTITLE)}

" f"{_teap_table(variants)}" f"{_financial_table(variants)}" - "

IRR-proxy — аннуализированная маржа-на-затраты без " - "дисконтирования (не настоящий IRR). Цены и себестоимость — рыночные " - "ориентиры, не калиброванная модель ценообразования.

" + "

Статический девелоперский расчёт (без дисконтирования / DCF). " + "НДС — упрощённая оценка (встроенный НДС в положительной марже, не полная " + "механика входного/выходного НДС НК РФ). Налог на прибыль — 25% (с 2025). " + "IRR-proxy — аннуализированный чистый ROI без дисконтирования (не настоящий " + "IRR). Цены и себестоимость — рыночные ориентиры, не калиброванная модель. " + "Коммерческие/офисные площади не учитываются (нет в ТЭП).

" "" ) diff --git a/backend/app/services/generative/financial.py b/backend/app/services/generative/financial.py index 2fee9952..56bc5d9d 100644 --- a/backend/app/services/generative/financial.py +++ b/backend/app/services/generative/financial.py @@ -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 diff --git a/backend/tests/services/generative/test_api_concepts.py b/backend/tests/services/generative/test_api_concepts.py index 1d436cca..16f59db2 100644 --- a/backend/tests/services/generative/test_api_concepts.py +++ b/backend/tests/services/generative/test_api_concepts.py @@ -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", } diff --git a/backend/tests/services/generative/test_teap_financial.py b/backend/tests/services/generative/test_teap_financial.py index c28176fd..d48a8222 100644 --- a/backend/tests/services/generative/test_teap_financial.py +++ b/backend/tests/services/generative/test_teap_financial.py @@ -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) diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index 97fa2c9c..6e5429bc 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -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) {
{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)}
- {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) { {/* Финмодель */}
} >
+ + {/* Каскад затрат + БДР — под аккордеоном (плотность > выкладки) */} +
+ + Подробнее — каскад затрат и расчёт прибыли + +
+ +

+ Статический расчёт без дисконтирования (DCF — следующий этап). НДС + — упрощённая оценка (встроенный НДС в положительной марже, не + полная механика входного/выходного НДС НК РФ). Налог на прибыль — + 25% (с 2025 года). IRR{" "} + {financial.irr_is_proxy + ? "— оценочный (аннуализированный чистый ROI), не настоящий IRR." + : "."}{" "} + Цены и себестоимость — рыночные ориентиры, не калиброванная + модель. Коммерческие и офисные площади не учитываются. +

+
+
); } +// ── Cost cascade / P&L detail table ──────────────────────────────────────────── + +function CascadeRow({ + label, + value, + emphasis = false, +}: { + label: string; + value: string; + emphasis?: boolean; +}) { + return ( + + + {label} + + + {value} + + + ); +} + +function FinancialCascadeTable({ financial }: { financial: FinancialModel }) { + return ( + + + + + + + + + + + + + + + + + + + +
+ ); +} + // ── Component ───────────────────────────────────────────────────────────────── interface Props { diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index d07c6c48..5f5f8a02 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -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 diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts index b65bb48b..f1ca6257 100644 --- a/frontend/src/lib/concept-api.ts +++ b/frontend/src/lib/concept-api.ts @@ -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 { -- 2.45.3