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/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/lib/api-types.ts b/frontend/src/lib/api-types.ts index df1fc61d..87696669 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -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 */ diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index cadf612b..37d092d7 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -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; };