diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py
index 7c0b53cd..371fc0c8 100644
--- a/backend/app/schemas/concept.py
+++ b/backend/app/schemas/concept.py
@@ -22,6 +22,11 @@ class TEAP(BaseModel):
built_area_sqm: float
total_floor_area_sqm: float
+ # Нежилое (коммерция/офисы 1-го этажа) = GFA × нормативная доля по классу
+ # (см. ``_OFFICE_SHARE_OF_GFA`` в teap.py). Вырезается из GFA до расчёта жилой —
+ # жилая ужимается ровно на эту площадь, total_floor_area_sqm не меняется. Additive
+ # поле (default 0.0) → backward-compat: 0.0 = нежилое не моделировалось/не нужно.
+ office_area_sqm: float = 0.0
residential_area_sqm: float
apartments_count: int
density: float
@@ -62,6 +67,13 @@ class FinancialModel(BaseModel):
# ── Revenue breakdown ──────────────────────────────────────────────────────
revenue_residential_rub: float
revenue_parking_rub: float
+ # Выручка нежилого (коммерция/офисы 1-го этажа) = office_area_sqm × цена нежилого
+ # (см. ``_OFFICE_SALE_PRICE_PER_SQM`` в financial.py). Чисто ADDITIVE выручка:
+ # СМР нежилого уже сидит в ``construction_rub`` (нежилая GFA входит в total_floor_area
+ # → construction = total_floor_area × ставка уже её покрывает; double-count'а нет).
+ # Нежилое ОБЛАГАЕТСЯ НДС (в отличие от льготы жилья ст.149 НК) — его value-added
+ # входит в базу НДС. Default 0.0 → backward-compat (нежилое не моделировалось).
+ revenue_office_rub: float = 0.0
# ── Cost cascade ───────────────────────────────────────────────────────────
construction_rub: float
diff --git a/backend/app/services/generative/exporters/pdf.py b/backend/app/services/generative/exporters/pdf.py
index b2b2b86b..ef3d1414 100644
--- a/backend/app/services/generative/exporters/pdf.py
+++ b/backend/app/services/generative/exporters/pdf.py
@@ -119,6 +119,10 @@ def _financial_table(variants: Sequence[ConceptVariant]) -> str:
"Выручка — паркинг, млн руб",
[_fmt_money(v.financial.revenue_parking_rub) for v in variants],
),
+ (
+ "Выручка — нежилое (1-й этаж), млн руб",
+ [_fmt_money(v.financial.revenue_office_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]),
@@ -132,7 +136,10 @@ def _financial_table(variants: Sequence[ConceptVariant]) -> str:
("Земля, млн руб", [_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.vat_rub) for v in variants],
+ ),
(
"Прибыль до налога, млн руб",
[_fmt_money(v.financial.profit_before_tax_rub) for v in variants],
diff --git a/backend/app/services/generative/financial.py b/backend/app/services/generative/financial.py
index 332bfd76..ad85d104 100644
--- a/backend/app/services/generative/financial.py
+++ b/backend/app/services/generative/financial.py
@@ -46,13 +46,28 @@ DCF can never silently diverge from the static cascade.
Cascade (in :func:`compute_financial`):
-* **Revenue (GDV)** = residential area × sale price + parking spaces × parking price.
-* **Cost cascade** = construction (residential GFA + parking) + ПИР (design) +
- networks (ТУ) + developer services + contingency + marketing/realtor + land.
+* **Revenue (GDV)** = residential area × sale price + parking spaces × parking price
+ + office area × office price (нежилое 1-го этажа — коммерция/офисы).
+* **Cost cascade** = construction (residential + OFFICE 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 DCF NPV/IRR/payback.
-НДС — ОСВОБОЖДЕНИЕ ЖИЛЬЯ, НДС ТОЛЬКО НА ПАРКИНГ (ст. 149 НК РФ)
+OFFICES / COMMERCIAL — нежилое 1-го этажа (ADDITIVE ВЫРУЧКА, БЕЗ double-count СМР)
+--------------------------------------------------------------------------------
+TEAP теперь несёт ``office_area_sqm`` — нежилое (коммерция/офисы 1-го этажа),
+ВЫРЕЗАННОЕ из GFA до расчёта жилой (см. ``_OFFICE_SHARE_OF_GFA`` в teap.py: жилая
+ужимается ровно на эту площадь, total_floor_area_sqm НЕ меняется). Финмодель:
+
+* **Выручка** ``revenue_office = office_area_sqm × _OFFICE_SALE_PRICE_PER_SQM[class]``
+ (умеренная премия к жилью; см. константу).
+* **СМР нежилого — НЕ отдельная строка, double-count'а НЕТ.** Нежилая GFA осталась
+ в ``total_floor_area_sqm`` → ``construction_resid = total_floor_area_sqm × ставка
+ СМР`` УЖЕ её покрывает (как покрывала, когда эта площадь считалась жилой). Сдвиг
+ carve-out жилой ↔ нежилой меняет только классификацию площади и ВЫРУЧКУ, а СМР по
+ total_floor_area инвариантен. Поэтому нежилое = чисто additive ВЫРУЧКА.
+
+НДС — ОСВОБОЖДЕНИЕ ЖИЛЬЯ, НДС НА ПАРКИНГ + НЕЖИЛОЕ (ст. 149 НК РФ)
------------------------------------------------------------
Реализация жилых помещений и услуги застройщика по ДДУ ОСВОБОЖДЕНЫ от НДС
(пп.22 и пп.23.1 п.3 ст. 149 НК РФ) → выходной НДС на жильё = 0. Входной НДС по
@@ -60,13 +75,19 @@ Cascade (in :func:`compute_financial`):
себестоимости (рыночные ориентиры ``_CONSTRUCTION_COST_PER_SQM`` даны С НДС) →
чистая нагрузка по НДС на жилую часть ≈ 0.
-Машиноместа — НЕЖИЛЫЕ помещения: их реализация ОБЛАГАЕТСЯ НДС 20%, входной НДС
-вычитается. Поэтому НДС начисляется ТОЛЬКО на нежилой value-added паркинга
-(встроенный НДС в добавленной стоимости машиномест):
+Машиноместа И нежилое (коммерция/офисы) — НЕЖИЛЫЕ помещения: их реализация
+ОБЛАГАЕТСЯ НДС 20%, входной НДС вычитается. Поэтому НДС начисляется на нежилой
+value-added ОБОИХ нежилых частей (встроенный НДС в добавленной стоимости):
- vat = max(0, revenue_parking − construction_parking) × VAT_RATE / (1 + VAT_RATE)
+ parking_value_added = max(0, revenue_parking − construction_parking)
+ office_value_added = max(0, revenue_office − office_construction)
+ где office_construction = office_area_sqm × _CONSTRUCTION_COST_PER_SQM[class]
+ (СМР-доля нежилого по той же ставке, что и весь СМР; та же доля УЖЕ сидит
+ в construction_rub — здесь она лишь ВОССТАНАВЛИВАЕТСЯ как база НДС, в
+ каскад затрат повторно НЕ добавляется → double-count'а нет).
+ vat = (parking_value_added + office_value_added) × VAT_RATE / (1 + VAT_RATE)
-Если паркинг тоже окажется под ДДУ-льготой — оценка консервативна (чуть занижает
+Если паркинг/нежилое окажутся под ДДУ-льготой — оценка консервативна (чуть занижает
прибыль завышенным НДС), что соответствует honest/conservative-политике модуля.
Полную механику входного/выходного НДС с эскроу-спецификой не моделируем —
жильё освобождено, входной НДС встроен в gross-СМР.
@@ -80,11 +101,6 @@ bisection cannot bracket a root; we then fall back to the PR-1 annualised net-RO
proxy (``roi / _PROJECT_YEARS``, clamped ±1) and flag ``irr_is_proxy=True`` so the
frontend shows 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.
-
PRICE CALIBRATION — PR-2
------------------------
The housing **sale price** can be calibrated to real market data: callers pass
@@ -194,6 +210,26 @@ _PARKING_COST_PER_SPOT: dict[HousingClass, float] = {
"business": 1_800_000.0, # подземный (Excel «Авангардная 13») — себестоимость
}
+# ── Цена ПРОДАЖИ нежилого (коммерция/офисы 1-го этажа), руб/кв.м по классу ─────
+# НОРМАТИВ (РФ-рынок встроенно-пристроенной коммерции). Ликвидный стрит-ритейл 1-го
+# этажа в городском МКД обычно продаётся С ПРЕМИЕЙ к жилью того же дома (лучшая
+# проходимость/витрина, инвестиционный спрос на ГАБ — готовый арендный бизнес).
+# Берём УМЕРЕННУЮ премию ~+15% к нормативу продажи жилья класса (консервативно — не
+# задираем; в реальных проектах премия бывает выше, но зависит от локации/трафика):
+# econom ~127k = 110k × 1.15 (доля нежилого = 0 → на выручку не влияет, но
+# константа задана для полноты/будущих сценариев).
+# comfort ~167k = 145k × 1.15
+# business ~242k = 210k × 1.15
+# СЕБЕСТОИМОСТЬ нежилого ОТДЕЛЬНО не вводим: нежилая GFA уже в total_floor_area_sqm →
+# ``construction = total_floor_area × ставка СМР`` её УЖЕ покрывает (см. docstring
+# раздел OFFICES). Поэтому нежилое — чисто additive ВЫРУЧКА; для базы НДС берём
+# СМР-долю нежилого по той же ставке ``_CONSTRUCTION_COST_PER_SQM`` (см. ниже).
+_OFFICE_SALE_PRICE_PER_SQM: dict[HousingClass, float] = {
+ "econom": 126_500.0, # 110_000 × 1.15
+ "comfort": 166_750.0, # 145_000 × 1.15
+ "business": 241_500.0, # 210_000 × 1.15
+}
+
# ── Доли каскада затрат (источник — Excel-эталон + RU-рынок) ──────────────────
# ПИР (проектирование + экспертиза + РД) как доля от СМР.
# Excel: ПИР 147М при СМР 6123М ≈ 2.4% -> округлённо 2.5%.
@@ -557,12 +593,13 @@ def compute_financial(
) -> FinancialModel:
"""Свести ТЭП + класс + стоимость земли в полный :class:`FinancialModel` с DCF.
- Полный девелоперский каскад: выручка (жильё + паркинг) → каскад затрат (СМР +
- ПИР + сети + услуги заказчика + резерв + маркетинг + земля) → БДР с НДС и
- налогом на прибыль → чистая прибыль + ROI. Поверх статики — помесячный DCF
- (NPV/IRR/PBP) по дефолт-графику фаз (см. docstring модуля). НДС — жильё
- освобождено (ст. 149 НК РФ), начисляется только на паркинг; офисов нет в
- TEAP — пропущены.
+ Полный девелоперский каскад: выручка (жильё + паркинг + нежилое 1-го этажа) →
+ каскад затрат (СМР + ПИР + сети + услуги заказчика + резерв + маркетинг + земля) →
+ БДР с НДС и налогом на прибыль → чистая прибыль + ROI. Поверх статики — помесячный
+ DCF (NPV/IRR/PBP) по дефолт-графику фаз (см. docstring модуля). НДС — жильё
+ освобождено (ст. 149 НК РФ), начисляется на нежилые части (паркинг + коммерция/
+ офисы); СМР нежилого уже в total_floor_area → отдельной строки затрат нет
+ (см. раздел OFFICES в docstring модуля).
Чистая функция: НЕ ходит в БД. Калибровку цены продажи жилья делает API-слой
(lookup рынка) и прокидывает сюда через ``market_price_per_sqm``.
@@ -605,13 +642,20 @@ def compute_financial(
# подземный); НОРМА м/м на квартиру задаётся в teap (не здесь).
parking_price = _PARKING_PRICE_PER_SPOT[housing_class]
parking_cost = _PARKING_COST_PER_SPOT[housing_class]
+ # Нежилое (коммерция/офисы 1-го этажа) — цена продажи по классу (премия к жилью).
+ office_price = _OFFICE_SALE_PRICE_PER_SQM[housing_class]
# ── Выручка (GDV) ──────────────────────────────────────────────────────────
revenue_residential = teap.residential_area_sqm * sale_price
revenue_parking = teap.parking_spaces * parking_price
- revenue = revenue_residential + revenue_parking
+ # Нежилое — чисто ADDITIVE выручка (СМР по нему уже в construction ниже).
+ revenue_office = teap.office_area_sqm * office_price
+ revenue = revenue_residential + revenue_parking + revenue_office
# ── Затраты (каскад) ───────────────────────────────────────────────────────
+ # ВАЖНО: ``total_floor_area_sqm`` — это ВСЯ GFA (жилая + нежилое 1-го этажа; см.
+ # teap: нежилое вырезано из ЖИЛОЙ, но осталось в total). Значит СМР по нежилой GFA
+ # УЖЕ покрыт здесь → отдельной строки затрат нежилого НЕ добавляем (no double-count).
construction_resid = teap.total_floor_area_sqm * construction_cost
construction_parking = teap.parking_spaces * parking_cost
construction = construction_resid + construction_parking
@@ -627,12 +671,17 @@ def compute_financial(
gross_margin = revenue - cost # валовая (как было)
# НДС: жилые помещения и услуги застройщика по ДДУ освобождены (пп.22 / пп.23.1
# п.3 ст. 149 НК РФ) → output-НДС на жильё = 0, а невозмещаемый входной НДС по СМР
- # уже сидит в gross-ставках себестоимости. НДС начисляется ТОЛЬКО на нежилую часть
- # (машиноместа — нежилые, их реализация облагается): встроенный НДС в value-added
- # паркинга. Если паркинг тоже окажется по ДДУ-льготе — оценка консервативна (чуть
- # занижает прибыль), что соответствует «honest/conservative» политике модуля.
+ # уже сидит в gross-ставках себестоимости. НДС начисляется на НЕЖИЛЫЕ части —
+ # машиноместа И коммерция/офисы 1-го этажа (их реализация облагается): встроенный
+ # НДС в их value-added. Если паркинг/нежилое тоже окажутся по ДДУ-льготе — оценка
+ # консервативна (чуть занижает прибыль), что соответствует «honest» политике модуля.
parking_value_added = revenue_parking - construction_parking
- vat = max(0.0, parking_value_added) * _VAT_RATE / (1.0 + _VAT_RATE)
+ # СМР-доля нежилого по той же ставке СМР (она УЖЕ внутри construction_resid выше —
+ # здесь лишь восстанавливаем её как базу НДС, в каскад затрат повторно НЕ кладём).
+ office_construction = teap.office_area_sqm * construction_cost
+ office_value_added = revenue_office - office_construction
+ nonresidential_value_added = max(0.0, parking_value_added) + max(0.0, office_value_added)
+ vat = nonresidential_value_added * _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
@@ -719,6 +768,7 @@ def compute_financial(
# revenue breakdown
revenue_residential_rub=round(revenue_residential, 2),
revenue_parking_rub=round(revenue_parking, 2),
+ revenue_office_rub=round(revenue_office, 2),
# cost cascade
construction_rub=round(construction, 2),
pir_rub=round(pir, 2),
diff --git a/backend/app/services/generative/teap.py b/backend/app/services/generative/teap.py
index 1d12f4f4..d9995406 100644
--- a/backend/app/services/generative/teap.py
+++ b/backend/app/services/generative/teap.py
@@ -5,8 +5,11 @@ the ``TEAP`` contract block with *real* numbers:
* ``built_area_sqm`` — пятно застройки (сумма площадей footprint-ов).
* ``total_floor_area_sqm`` — общая поэтажная площадь (GFA) = пятно * этажность.
-* ``residential_area_sqm`` — продаваемая жилая = GFA * коэффициент эффективности
- (вычет МОП/лестниц/тех.помещений; зависит от класса жилья).
+* ``office_area_sqm`` — нежилое (встроенно-пристроенный 1-й этаж: коммерция/
+ офисы) = GFA * нормативная доля нежилого (зависит от класса жилья). Вырезается
+ из GFA ПЕРЕД расчётом жилой → жилая ужимается на эту площадь (total GFA не меняется).
+* ``residential_area_sqm`` — продаваемая жилая = (GFA − нежилое) * коэффициент
+ эффективности (вычет МОП/лестниц/тех.помещений; зависит от класса жилья).
* ``apartments_count`` — жилая / средняя площадь квартиры (зависит от класса).
* ``density`` — плотность застройки = FAR = GFA / площадь участка.
* ``parking_spaces`` — машиноместа по упрощённой норме (мест на квартиру).
@@ -52,6 +55,27 @@ _PARKING_PER_APARTMENT: dict[HousingClass, float] = {
"business": 1.5,
}
+# ── Доля нежилого (коммерция/офисы 1-го этажа) в GFA по классу ─────────────────
+# НОРМАТИВ (не догадка): городские МКД РФ почти всегда несут встроенно-пристроенные
+# нежилые помещения 1-го этажа (магазины/услуги/офисы). По обзорам застройки РФ их
+# доля в общей поэтажной площади (GFA) типично 3–8% для городских МКД (1-й этаж из
+# 9–25). Берём КОНСЕРВАТИВНЫЕ значения нижней границы диапазона:
+# econom 0.0 — массовый эконом часто строят БЕЗ коммерции (спальные районы,
+# "квартиры на 1-м этаже"); нулём не завышаем выручку.
+# comfort 0.05 — комфорт почти всегда имеет коммерческий стрит-ритейл 1-го этажа.
+# business 0.05 — бизнес: представительный 1-й этаж (лобби+коммерция), но та же
+# умеренная доля (бОльшая часть нижних уровней — лобби/МОП, а не
+# продаваемая коммерция) — консервативно не задираем.
+# Нежилое ВЫРЕЗАЕТСЯ из GFA до расчёта жилой (жилая ужимается ровно на эту площадь),
+# total GFA при этом не меняется — это и есть фикс реализма (раньше ВСЯ непаркинговая
+# GFA считалась жилой, чуть завышая жилую площадь). Источник доли — отраслевой обзор,
+# НЕ проектный факт → caveat в финмодели честно это помечает.
+_OFFICE_SHARE_OF_GFA: dict[HousingClass, float] = {
+ "econom": 0.0,
+ "comfort": 0.05,
+ "business": 0.05,
+}
+
def compute_teap(
*,
@@ -71,8 +95,15 @@ def compute_teap(
built_area = float(sum(fp.area for fp in footprints))
total_floor_area = built_area * max(0, floors)
+ # Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA ДО расчёта жилой: жилая
+ # считается только по ОСТАВШЕЙСЯ (непаркинговой, нежилой-исключённой) GFA. total
+ # GFA не меняется — нежилое остаётся в нём (СМР по нему уже покрыт в финмодели).
+ office_share = _OFFICE_SHARE_OF_GFA[housing_class]
+ office_area = total_floor_area * office_share
+ residential_gfa = total_floor_area - office_area
+
efficiency = _EFFICIENCY_BY_CLASS[housing_class]
- residential_area = total_floor_area * efficiency
+ residential_area = residential_gfa * efficiency
avg_apartment = _AVG_APARTMENT_SQM[housing_class]
apartments_count = math.floor(residential_area / avg_apartment) if avg_apartment else 0
@@ -86,15 +117,17 @@ def compute_teap(
teap = TEAP(
built_area_sqm=round(built_area, 1),
total_floor_area_sqm=round(total_floor_area, 1),
+ office_area_sqm=round(office_area, 1),
residential_area_sqm=round(residential_area, 1),
apartments_count=apartments_count,
density=round(density, 3),
parking_spaces=parking_spaces,
)
logger.info(
- "TEAP: built=%.0f GFA=%.0f resid=%.0f apts=%d FAR=%.2f parking=%d",
+ "TEAP: built=%.0f GFA=%.0f office=%.0f resid=%.0f apts=%d FAR=%.2f parking=%d",
teap.built_area_sqm,
teap.total_floor_area_sqm,
+ teap.office_area_sqm,
teap.residential_area_sqm,
teap.apartments_count,
teap.density,
diff --git a/backend/app/services/site_finder/parcel_financial.py b/backend/app/services/site_finder/parcel_financial.py
index 79a052ec..03f9d8f3 100644
--- a/backend/app/services/site_finder/parcel_financial.py
+++ b/backend/app/services/site_finder/parcel_financial.py
@@ -36,6 +36,7 @@ from app.services.generative.financial import compute_financial
from app.services.generative.teap import (
_AVG_APARTMENT_SQM,
_EFFICIENCY_BY_CLASS,
+ _OFFICE_SHARE_OF_GFA,
_PARKING_PER_APARTMENT,
HousingClass,
)
@@ -48,7 +49,8 @@ _FINANCIAL_CAVEAT: str = (
"НСПД/ПЗЗ (КСИТ/высота/%застройки), НЕ по реальной проектной концепции. "
"ТЭП синтезирован из предельных параметров зоны; класс жилья и темп продаж — "
"рыночные/нормативные прокси; land_cost = кадастровая стоимость (не рыночная); "
- "график фаз — типовой."
+ "график фаз — типовой. Доля нежилого (коммерция/офисы 1-го этажа) — нормативный "
+ "прокси по классу (комфорт/бизнес ~5% GFA, эконом 0%), не проектный факт."
)
# ── Класс жилья по цене продажи, руб/кв.м (рыночный прокси) ─────────────────────
@@ -101,7 +103,9 @@ def synthesize_teap_from_buildability(
``max_building_pct`` + ``max_floors`` → ``GFA = area × (pct/100) × floors``.
* ``built`` (пятно) = ``area × (max_building_pct/100)``; если % нет →
оценка ``GFA / max_floors`` (или ``GFA`` при отсутствии этажности).
- * ``residential`` = ``GFA × efficiency[class]`` (вычет МОП/тех по классу).
+ * ``office`` (нежилое 1-го этажа) = ``GFA × office_share[class]``, вырезается из
+ GFA до расчёта жилой (зеркалит ``compute_teap``; см. ``_OFFICE_SHARE_OF_GFA``).
+ * ``residential`` = ``(GFA − office) × efficiency[class]`` (вычет МОП/тех по классу).
* ``apartments`` = ``floor(residential / avg_apt[class])``.
* ``parking`` = ``ceil(apartments × parking_norm[class])``.
* ``density`` = ``GFA / area`` (= ``max_far``).
@@ -136,8 +140,14 @@ def synthesize_teap_from_buildability(
else:
built_area = gfa
+ # Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA до расчёта жилой — точно
+ # как compute_teap: жилая считается по ОСТАВШЕЙСЯ GFA, total (gfa) не меняется.
+ office_share = _OFFICE_SHARE_OF_GFA[housing_class]
+ office_area = gfa * office_share
+ residential_gfa = gfa - office_area
+
efficiency = _EFFICIENCY_BY_CLASS[housing_class]
- residential_area = gfa * efficiency
+ residential_area = residential_gfa * efficiency
avg_apartment = _AVG_APARTMENT_SQM[housing_class]
apartments_count = math.floor(residential_area / avg_apartment) if avg_apartment else 0
@@ -151,6 +161,7 @@ def synthesize_teap_from_buildability(
return TEAP(
built_area_sqm=round(built_area, 1),
total_floor_area_sqm=round(gfa, 1),
+ office_area_sqm=round(office_area, 1),
residential_area_sqm=round(residential_area, 1),
apartments_count=apartments_count,
density=round(density, 3),
@@ -293,6 +304,7 @@ def synthesize_parcel_financial(
"teap_synth": {
"residential_area_sqm": teap.residential_area_sqm,
"total_floor_area_sqm": teap.total_floor_area_sqm,
+ "office_area_sqm": teap.office_area_sqm,
"parking_spaces": teap.parking_spaces,
"apartments_count": teap.apartments_count,
},
diff --git a/backend/tests/services/generative/test_api_concepts.py b/backend/tests/services/generative/test_api_concepts.py
index 1ccf54bd..eae62840 100644
--- a/backend/tests/services/generative/test_api_concepts.py
+++ b/backend/tests/services/generative/test_api_concepts.py
@@ -131,6 +131,7 @@ def test_concepts_response_matches_contract_keys() -> None:
assert set(variant["teap"].keys()) == {
"built_area_sqm",
"total_floor_area_sqm",
+ "office_area_sqm",
"residential_area_sqm",
"apartments_count",
"density",
@@ -145,6 +146,7 @@ def test_concepts_response_matches_contract_keys() -> None:
# revenue breakdown
"revenue_residential_rub",
"revenue_parking_rub",
+ "revenue_office_rub",
# cost cascade
"construction_rub",
"pir_rub",
diff --git a/backend/tests/services/generative/test_financial_dcf.py b/backend/tests/services/generative/test_financial_dcf.py
index c3d55040..e16aeccb 100644
--- a/backend/tests/services/generative/test_financial_dcf.py
+++ b/backend/tests/services/generative/test_financial_dcf.py
@@ -26,10 +26,11 @@ from app.services.generative.financial import (
)
-def _teap(residential: float, gfa: float, parking: int = 10) -> TEAP:
+def _teap(residential: float, gfa: float, parking: int = 10, office: float = 0.0) -> TEAP:
return TEAP(
built_area_sqm=100.0,
total_floor_area_sqm=gfa,
+ office_area_sqm=office,
residential_area_sqm=residential,
apartments_count=10,
density=1.0,
@@ -213,6 +214,118 @@ def test_vat_invariant_holds_with_parking_vat() -> None:
assert abs(sum(cf) - model.net_profit_rub) < 1.0
+# ── НЕЖИЛОЕ (коммерция/офисы 1-го этажа) ────────────────────────────────────────
+
+
+def test_office_revenue_zero_when_no_office_area() -> None:
+ # Нет нежилого (office=0, как у эконома/spot) → revenue_office_rub == 0, выручка =
+ # жильё + паркинг (без нежилого слагаемого).
+ t = _teap(residential=10_000.0, gfa=11_000.0, parking=50, office=0.0)
+ model = financial.compute_financial(
+ teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
+ )
+ assert model.revenue_office_rub == 0.0
+ assert model.revenue_rub == pytest.approx(
+ model.revenue_residential_rub + model.revenue_parking_rub, abs=0.01
+ )
+
+
+def test_office_revenue_added_when_office_area_present() -> None:
+ # office > 0 → revenue_office_rub = office_area × цена нежилого класса; общая выручка
+ # = жильё + паркинг + нежилое.
+ cls = "comfort"
+ office = 550.0 # ~5% от GFA 11000
+ t = _teap(residential=9_450.0, gfa=11_000.0, parking=50, office=office)
+ model = financial.compute_financial(
+ teap=t, housing_class=cls, land_cost_rub=200_000_000.0, development_type="mid_rise"
+ )
+ expected_office = office * financial._OFFICE_SALE_PRICE_PER_SQM[cls]
+ assert model.revenue_office_rub == pytest.approx(round(expected_office, 2), abs=0.01)
+ assert model.revenue_office_rub > 0.0
+ assert model.revenue_rub == pytest.approx(
+ model.revenue_residential_rub + model.revenue_parking_rub + model.revenue_office_rub,
+ abs=0.01,
+ )
+
+
+def test_office_construction_not_double_counted() -> None:
+ # СМР нежилого НЕ добавляется отдельной строкой: construction_rub зависит ТОЛЬКО от
+ # total_floor_area (+паркинг), а не от office_area. Два проекта с одинаковым GFA, но
+ # разной долей нежилого → одинаковый construction_rub (нежилое лишь меняет выручку).
+ no_office = _teap(residential=8_580.0, gfa=11_000.0, parking=50, office=0.0)
+ with_office = _teap(residential=8_151.0, gfa=11_000.0, parking=50, office=550.0)
+ m_no = financial.compute_financial(
+ teap=no_office, housing_class="comfort", land_cost_rub=None, development_type="mid_rise"
+ )
+ m_off = financial.compute_financial(
+ teap=with_office, housing_class="comfort", land_cost_rub=None, development_type="mid_rise"
+ )
+ # СМР идентичен (нежилое уже в total_floor_area → double-count'а нет).
+ assert m_off.construction_rub == m_no.construction_rub
+ # Но нежилое даёт дополнительную выручку.
+ assert m_off.revenue_office_rub > 0.0
+ assert m_no.revenue_office_rub == 0.0
+
+
+def test_office_vat_includes_office_value_added() -> None:
+ # НДС с нежилым = НДС(паркинг value-added) + НДС(office value-added). office_value_added
+ # = revenue_office − office_area × ставка СМР. Проверяем точную формулу.
+ cls = "comfort"
+ parking = 50
+ office = 550.0
+ t = _teap(residential=8_151.0, gfa=11_000.0, parking=parking, office=office)
+ model = financial.compute_financial(
+ teap=t, housing_class=cls, land_cost_rub=200_000_000.0, development_type="mid_rise"
+ )
+ revenue_parking = parking * financial._PARKING_PRICE_PER_SPOT[cls]
+ construction_parking = parking * financial._PARKING_COST_PER_SPOT[cls]
+ parking_va = max(0.0, revenue_parking - construction_parking)
+ revenue_office = office * financial._OFFICE_SALE_PRICE_PER_SQM[cls]
+ office_construction = office * financial._CONSTRUCTION_COST_PER_SQM[cls]
+ office_va = max(0.0, revenue_office - office_construction)
+ expected_vat = (parking_va + office_va) * financial._VAT_RATE / (1.0 + financial._VAT_RATE)
+ assert model.vat_rub == pytest.approx(round(expected_vat, 2), abs=0.01)
+ # НДС с нежилым строго БОЛЬШЕ, чем НДС только паркинга (нежилое добавляет базу).
+ parking_only_vat = parking_va * financial._VAT_RATE / (1.0 + financial._VAT_RATE)
+ assert model.vat_rub > parking_only_vat
+
+
+def test_office_invariant_cashflow_sum_equals_net_profit() -> None:
+ # Инвариант Σ cashflow == net_profit держится с нежилым (office-выручка течёт по той
+ # же revenue → cashflow дорожке; НДС нежилого прокидывается в построитель потока).
+ t = _teap(residential=8_151.0, gfa=11_000.0, parking=50, office=550.0)
+ model = financial.compute_financial(
+ teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
+ )
+ assert model.revenue_office_rub > 0.0
+ cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=8_151.0)
+ assert abs(sum(cf) - model.net_profit_rub) < 1.0
+
+
+def test_office_carveout_shrinks_residential_via_compute_teap() -> None:
+ # РЕАЛИЗМ-ФИКС на уровне compute_teap: нежилое вырезается из GFA → жилая ужимается на
+ # office_area × efficiency, а total_floor_area не меняется. Comfort (share 0.05) vs
+ # econom (share 0.0) при той же геометрии: у comfort office>0, жилая ниже на office-вклад.
+ from shapely.geometry import box
+
+ from app.services.generative import teap as teap_mod
+
+ footprint = box(0, 0, 20, 10) # 200 кв.м пятна
+ comfort = teap_mod.compute_teap(
+ footprints=[footprint], floors=10, site_area_sqm=1000.0, housing_class="comfort"
+ )
+ # GFA 2000; office = 2000 × 0.05 = 100; residential = (2000−100) × 0.78 = 1482.
+ assert comfort.total_floor_area_sqm == 2000.0
+ assert comfort.office_area_sqm == 100.0
+ assert comfort.residential_area_sqm == pytest.approx(1482.0, abs=0.1)
+ # econom: share 0 → office 0, residential по полной GFA × 0.82 = 1640 (unchanged).
+ econom = teap_mod.compute_teap(
+ footprints=[footprint], floors=10, site_area_sqm=1000.0, housing_class="econom"
+ )
+ assert econom.office_area_sqm == 0.0
+ assert econom.residential_area_sqm == pytest.approx(1640.0, abs=0.1)
+
+
# ── Настоящий IRR vs proxy fallback ─────────────────────────────────────────────
diff --git a/backend/tests/services/generative/test_teap_financial.py b/backend/tests/services/generative/test_teap_financial.py
index 5b08ff0e..3fc14d04 100644
--- a/backend/tests/services/generative/test_teap_financial.py
+++ b/backend/tests/services/generative/test_teap_financial.py
@@ -27,10 +27,12 @@ def test_teap_basic_arithmetic() -> None:
assert result.total_floor_area_sqm == 2000.0
# FAR = GFA / участок.
assert result.density == 2.0
- # Жилая = GFA * efficiency(comfort=0.78).
- assert result.residential_area_sqm == 1560.0
+ # Нежилое (comfort share 0.05) вырезается из GFA: office = 2000 * 0.05 = 100.
+ assert result.office_area_sqm == 100.0
+ # Жилая = (GFA − нежилое) * efficiency(comfort=0.78) = 1900 * 0.78 = 1482.
+ assert result.residential_area_sqm == 1482.0
# Квартир = floor(жилая / avg(comfort=55)).
- assert result.apartments_count == int(1560.0 // 55.0)
+ assert result.apartments_count == int(1482.0 // 55.0)
# Парковка = ceil(квартир * 1.0).
assert result.parking_spaces == result.apartments_count
diff --git a/backend/tests/services/site_finder/test_parcel_financial.py b/backend/tests/services/site_finder/test_parcel_financial.py
index bcc85d60..4428a01e 100644
--- a/backend/tests/services/site_finder/test_parcel_financial.py
+++ b/backend/tests/services/site_finder/test_parcel_financial.py
@@ -58,8 +58,10 @@ def test_teap_from_max_far() -> None:
assert teap is not None
# GFA = area × far
assert teap.total_floor_area_sqm == round(area * far, 1)
- # residential = GFA × efficiency[comfort=0.78]
- assert teap.residential_area_sqm == round(area * far * 0.78, 1)
+ # Нежилое (comfort share 0.05) вырезается из GFA: office = GFA × 0.05.
+ assert teap.office_area_sqm == round(area * far * 0.05, 1)
+ # residential = (GFA − office) × efficiency[comfort=0.78]
+ assert teap.residential_area_sqm == round(area * far * (1.0 - 0.05) * 0.78, 1)
# density = FAR
assert teap.density == round(far, 3)
assert teap.apartments_count > 0
diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx
index 3d5b3b3e..fcc1e769 100644
--- a/frontend/src/components/concept/ConceptVariantsResult.tsx
+++ b/frontend/src/components/concept/ConceptVariantsResult.tsx
@@ -392,6 +392,10 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
label="Выручка — паркинг"
value={formatMoneyCompact(financial.revenue_parking_rub)}
/>
+