feat(financial): ground-floor нежилое (office/commercial) tranche #1905

Merged
bot-backend merged 2 commits from feat/offices-in-teap into main 2026-06-25 11:40:02 +00:00
14 changed files with 301 additions and 40 deletions

View file

@ -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

View file

@ -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],

View file

@ -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),

View file

@ -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) типично 38% для городских МКД (1-й этаж из
# 925). Берём КОНСЕРВАТИВНЫЕ значения нижней границы диапазона:
# 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,

View file

@ -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,
},

View file

@ -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",

View file

@ -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 = (2000100) × 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 ─────────────────────────────────────────────

View file

@ -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

View file

@ -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

View file

@ -392,6 +392,10 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
label="Выручка — паркинг"
value={formatMoneyCompact(financial.revenue_parking_rub)}
/>
<CascadeRow
label="Выручка — нежилое (1-й этаж)"
value={formatMoneyCompact(financial.revenue_office_rub)}
/>
<CascadeRow
label="Выручка (GDV)"
value={formatMoneyCompact(financial.revenue_rub)}

View file

@ -116,6 +116,8 @@ function financialFixture(): ParcelFinancialEstimate {
margin_pct: 0.262,
irr: 0.214,
irr_is_proxy: false,
// Нежилое (коммерция/офисы 1-го этажа) — additive выручка к GDV (#1881).
revenue_office_rub: 180_000_000,
npv_rub: 640_000_000,
payback_months: 42,
discount_rate_used: 0.18,

View file

@ -3550,6 +3550,11 @@ export interface components {
revenue_residential_rub: number;
/** Revenue Parking Rub */
revenue_parking_rub: number;
/**
* Revenue Office Rub
* @default 0
*/
revenue_office_rub: number;
/** Construction Rub */
construction_rub: number;
/** Pir Rub */
@ -4541,6 +4546,11 @@ export interface components {
built_area_sqm: number;
/** Total Floor Area Sqm */
total_floor_area_sqm: number;
/**
* Office Area Sqm
* @default 0
*/
office_area_sqm: number;
/** Residential Area Sqm */
residential_area_sqm: number;
/** Apartments Count */

View file

@ -43,6 +43,8 @@ export interface Teap {
built_area_sqm: number;
total_floor_area_sqm: number;
residential_area_sqm: number;
/** Нежилое (коммерция/офисы 1-го этажа), кв.м. Additive к жилой площади. */
office_area_sqm: number;
apartments_count: number;
density: number;
parking_spaces: number;
@ -57,6 +59,8 @@ export interface FinancialModel {
// Revenue breakdown
revenue_residential_rub: number;
revenue_parking_rub: number;
/** Нежилое (коммерция/офисы 1-го этажа) — additive выручка к GDV. */
revenue_office_rub: number;
// Cost cascade
construction_rub: number;
pir_rub: number;

View file

@ -509,6 +509,9 @@ export interface ParcelFinancialEstimate {
margin_pct: number;
irr: number;
irr_is_proxy: boolean;
// Нежилое (коммерция/офисы 1-го этажа) — additive выручка (СМР уже в construction).
// 0 когда нежилое не моделировалось (econom/spot share 0 либо старый кэш).
revenue_office_rub: number;
npv_rub: number;
payback_months: number | null;
discount_rate_used: number;
@ -525,6 +528,11 @@ export interface ParcelFinancialEstimate {
teap_synth: {
residential_area_sqm: number;
total_floor_area_sqm: number;
// Нежилое (коммерция/офисы 1-го этажа), кв.м. Вырезано из GFA до расчёта жилой
// (total_floor_area не меняется). 0 когда нежилое не моделировалось. Optional:
// живой backend всегда отдаёт, но dev-мок parcel-analyze.json (на main) его не
// содержит — required форсировал бы правку мока, конфликтную между ветками.
office_area_sqm?: number;
parking_spaces: number;
apartments_count: number;
};