feat(financial): model ground-floor нежилое (office/commercial) tranche
Carve a documented office share out of GFA before residential (fixes prior overstatement where all non-parking GFA = residential). total_floor_area unchanged → СМР already covers office GFA (no double-count): office is pure additive revenue at +15% premium. Offices нежилое → VAT-able: vat base = parking_value_added + office_value_added (consistent with #1898). Σ invariant holds. Additive schema (office_area_sqm, revenue_office_rub) → api-types regen. Refs #1881
This commit is contained in:
parent
d2773e0c91
commit
df1299175d
10 changed files with 281 additions and 39 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,9 @@ export interface ParcelFinancialEstimate {
|
|||
teap_synth: {
|
||||
residential_area_sqm: number;
|
||||
total_floor_area_sqm: number;
|
||||
// Нежилое (коммерция/офисы 1-го этажа), кв.м. Вырезано из GFA до расчёта жилой
|
||||
// (total_floor_area не меняется). 0 когда нежилое не моделировалось.
|
||||
office_area_sqm: number;
|
||||
parking_spaces: number;
|
||||
apartments_count: number;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue