from typing import Annotated, Any, Literal from pydantic import BaseModel, Field class BuildingProgramItem(BaseModel): """Stage 3a (#1965, эпик #1953) — один пункт программы застройки (типовой дом × N). Пользователь набирает программу из ТИПОВЫХ домов каталога (``app.services.generative.catalog.HOUSE_TYPES``) вместо max-FAR жадной раскладки: «поставь ``count`` секций типа ``section_type`` этажностью ``floors``». Габариты пятна секции берутся из каталога по ключу ``section_type`` (контракт несёт только ключ, не геометрию — единый справочник на бэке). """ # Ключ типа дома из каталога (``HouseType.section_type``). Валидируется на слое # размещения по ``catalog.available_section_types`` — неизвестный ключ → 422. section_type: str = Field(..., description="Catalog house-type key (HOUSE_TYPES)") # Этажность этой группы секций; диапазон шире, чем у target_floors (до 40 — башни). floors: int = Field(..., ge=1, le=40, description="Floors for this house-type group") # Сколько секций этого типа разместить. count: int = Field(..., ge=1, le=50, description="Number of sections to place") # ADDITIVE (Stage 3c): опциональное ручное пятно секции (ширина × глубина, м). # None → габариты берутся из каталога по ``section_type`` (byte-for-byte # backward-compat). Заданы → place_program строит footprint из них вместо # каталожных (пользователь «вписал пятно»). Оба должны быть заданы вместе; # частичное задание (только w / только d) трактуется как «не задано» на слое # размещения. Границы [4, 120] м — вменяемый габарит секции/корпуса. footprint_w_m: float | None = Field( None, ge=4, le=120, description="Optional manual section footprint width, m" ) footprint_d_m: float | None = Field( None, ge=4, le=120, description="Optional manual section footprint depth, m" ) class HouseTypeCatalogItem(BaseModel): """Stage 3b (#1965, эпик #1953) — один тип дома каталога, ОТДАВАЕМЫЙ фронту. Read-only проекция ``app.services.generative.catalog.HouseType`` для пикера Stage 3b: фронт показывает ``label_ru`` + габариты пятна + дефолтную этажность + класс, а в ``BuildingProgramItem.section_type`` кладёт ``section_type``. Это делает каталог ЕДИНЫМ источником истины — фронт ничего не хардкодит. Поля зеркалят dataclass на бэке (см. ``GET /api/v1/concepts/house-types``). """ # Стабильный машинный ключ типа — значение для BuildingProgramItem.section_type. section_type: str = Field(..., description="Catalog house-type key (HOUSE_TYPES)") # Человекочитаемый русский лейбл для UI. label_ru: str = Field(..., description="Human-readable Russian label") # Габариты пятна секции, метры (ширина × глубина). footprint_w_m: float = Field(..., description="Section footprint width, m") footprint_d_m: float = Field(..., description="Section footprint depth, m") # Площадь пятна секции, кв.м (helper для UI: ширина × глубина). footprint_sqm: float = Field(..., description="Section footprint area, m² (w × d)") # Дефолтная этажность типа — UI подставляет, пользователь меняет в [1, 40]. default_floors: int = Field(..., description="Default floors for this type") # Подходящий класс жилья (econom / comfort / business). housing_class: Literal["econom", "comfort", "business"] = Field( ..., description="Suitable housing class" ) class HouseTypeCatalog(BaseModel): """Stage 3b (#1965) — каталог типовых домов, отдаваемый ``GET /concepts/house-types``.""" house_types: list[HouseTypeCatalogItem] class ConceptInput(BaseModel): """Stage 1a — input contract. Frozen interface for frontend codegen.""" parcel_geojson: dict[str, Any] = Field( ..., description="GeoJSON Polygon of the parcel (WGS84 / EPSG:4326)" ) housing_class: Literal["econom", "comfort", "business"] = "comfort" target_floors: int = Field(9, ge=1, le=30) development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise" land_cost_rub: float | None = Field( None, ge=0, description="Optional land cost for financial model" ) # Stage 3a (#1965): ОПЦИОНАЛЬНАЯ программа застройки из типовых домов каталога. # ADDITIVE поле (default None). None → существующая жадная раскладка (Stage 1b, # byte-for-byte backward-compat). Задано → раскладка кладёт РОВНО эту программу # (place_program): для каждого пункта ставит count секций каталожного пятна с его # floors, вместо coverage-cap sweep. См. app.services.generative.placement. building_program: list[BuildingProgramItem] | None = Field( None, description="Optional typed house program; None → greedy max-FAR placement", ) 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 parking_spaces: int class FinancialModel(BaseModel): """Static developer P&L + monthly-DCF investment metrics (PR-1/PR-3, эпик #1881). Full static cost cascade + VAT + profit tax → net profit + ROI (PR-1). On top of that static P&L, PR-3 lays the cascade onto a **monthly cashflow schedule** (default phase norms — ПИР → СМР → продажи) and runs a real DCF: NPV, IRR (bisection over the monthly cashflow) and *undiscounted* cumulative payback. See ``compute_financial`` docstring for the schedule assumptions and the налоги/рассрочка phasing choices. ``irr`` is a **real** annualised DCF IRR when ``irr_is_proxy is False``; for a degenerate cashflow with no sign change (e.g. always-loss) the bisection cannot bracket a root, so we fall back to the PR-1 annualised-ROI proxy and flag ``irr_is_proxy=True``. VAT is a documented simplification (see docstring), not exact НК РФ mechanics. ``levered_irr`` — IRR на собственные средства (equity cashflow при LTC): тот же DCF-IRR + roi-proxy fallback, но по equity-потоку графика долга. При положительном леверидже выше unlevered ``irr``; вырожденные случаи → 0.0 + ``levered_irr_is_proxy``. The legacy summary fields (``revenue_rub`` / ``cost_rub`` / ``gross_margin_rub`` / ``irr``) are kept for backward-compat; the new fields expose the full cascade, the net P&L and the DCF metrics. """ # ── Legacy summary (backward-compat) ─────────────────────────────────────── revenue_rub: float cost_rub: float gross_margin_rub: float irr: float # ── Revenue breakdown ────────────────────────────────────────────────────── revenue_residential_rub: float revenue_parking_rub: float # Выручка нежилого (коммерция/офисы 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 pir_rub: float networks_rub: float developer_services_rub: float contingency_rub: float marketing_rub: float land_rub: float # ── БДР / taxes → net profit ─────────────────────────────────────────────── vat_rub: float profit_before_tax_rub: float profit_tax_rub: float net_profit_rub: float # ── Metrics ──────────────────────────────────────────────────────────────── roi: float # net profit / total cost margin_pct: float # net profit / revenue irr_is_proxy: bool = True # frontend caveat: True → IRR is the ROI-proxy, not DCF # ── DCF / investment metrics (PR-3, эпик #1881) ──────────────────────────── # NPV дисконтированного помесячного cashflow по ставке ``discount_rate_used``. npv_rub: float = 0.0 # Окупаемость (мес) по НЕдисконтированному накопительному cashflow, линейная # интерполяция дробного месяца. ``None`` — проект не окупается на горизонте. payback_months: float | None = None # Годовая ставка дисконтирования, фактически применённая в NPV (норматив). discount_rate_used: float = 0.0 # График фаз/продаж — типовые нормативные ДОПУЩЕНИЯ (не график конкретного # проекта). True → UI/PDF показывают caveat «график — типовое допущение». RANK 1: # False, когда окно продаж построено по реальной рыночной скорости абсорбции # района (а не дефолт-30-мес). schedule_is_default: bool = True # RANK 1: реализованное окно продаж (sales_end − sales_start), мес — для UI/PDF. # Драйвится рыночной абсорбцией района, когда ``schedule_is_default is False``; # иначе дефолт-норматив (с эскроу-привязкой к завершению стройки). ``None`` — # финмодель не строилась/окно неизвестно. sales_duration_months: float | None = None # ── Financing overlay (PR-5, эпик #1881) ─────────────────────────────────── # OVERLAY поверх unlevered cashflow: кредит покрывает кассовый разрыв, проценты # капитализируются (compound). Unlevered NPV/IRR (headline) НЕ затронуты. financing_enabled: bool = False # True когда overlay посчитан annual_rate_used: float = 0.0 # _CREDIT_RATE_ANNUAL (норматив, отдельно от дисконта) peak_debt_rub: float = 0.0 # «задолженность по кредиту» Excel — пик долга total_interest_rub: float = 0.0 # «поток по финдеятельности» Excel — Σ процентов net_profit_after_financing_rub: float = 0.0 # net_profit − total_interest # honest-флаг: 100% покрытие разрыва, compound %, эскроу не моделируется точно. financing_is_simplified: bool = True # Levered (equity) IRR: доходность на собственные средства (equity cashflow при LTC). # При положительном леверидже > unlevered ``irr``. Считается DCF-IRR'ом equity-потока # того же графика долга, с тем же roi-proxy fallback'ом, что и unlevered IRR. levered_irr: float = 0.0 # IRR на собственные средства (equity cashflow при LTC) # honest-флаг: True → levered_irr — roi-proxy (вырожденный equity-поток / нет equity # под риском при LTC>=1 / финансирование выключено), не настоящий DCF-IRR. levered_irr_is_proxy: bool = True # True → levered_irr — roi-proxy, не DCF # ── Price calibration (PR-2, эпик #1881) ─────────────────────────────────── # Цена продажи жилья, руб/кв.м, фактически использованная в выручке. Либо # калиброванная по рынку (Objective / district reference), либо норматив класса. # ``price_source`` honest-flag для UI/PDF: не выдавать норматив за рынок. price_per_sqm_used: float price_is_calibrated: bool = False # Значения: "objective_district_median" | "district_reference" | "class_norm". price_source: str = "class_norm" class ConceptVariant(BaseModel): strategy: Literal["max_area", "max_insolation", "balanced"] buildings_geojson: dict[str, Any] teap: TEAP financial: FinancialModel # Stage 3a (#1965) — честный сигнал частичного размещения для program-режима. # Когда задан ``ConceptInput.building_program``: ``requested_count`` — сколько секций # просили (Σ count по программе), ``placed_count`` — сколько реально влезло в участок. # placed < requested → участок мал, разместилось N из M (фронт Stage 3b показывает # «разместилось N из M», без hard-422). Оба None в greedy-режиме (программа не задана) # → ADDITIVE, backward-compat: старый ответ концепции не меняется. Annotated+`= None`: # рантайм-дефолт виден mypy (без pydantic-плагина), Field несёт только OpenAPI-описание. placed_count: Annotated[ int | None, Field(description="Stage 3a: sections actually placed (program mode only)") ] = None requested_count: Annotated[ int | None, Field(description="Stage 3a: sections requested by program (program mode only)") ] = None class ConceptOutput(BaseModel): variants: list[ConceptVariant] class MassingProgram(BaseModel): """Stage 2a (#1965) — агрегированная массинг-программа для LIVE-пересчёта экономики. Контракт POST ``/api/v1/concepts/recompute``: вход — уже СВЁРНУТАЯ программа из фронтового ``computeModel`` интерактивного 3D-массинга (суммарное пятно × этажность), без покомпонентной геометрии секций. Эндпоинт синтезирует из неё :class:`TEAP` и прогоняет готовый ``compute_financial`` → пересчитанная экономика для дебаунс-слайдеров Stage 2b. Класс жилья / тип застройки — те же Literal-множества, что у :class:`ConceptInput` и ``compute_financial`` (single source of truth по допустимым значениям). """ total_footprint_sqm: float = Field( ..., ge=0, description="Суммарное пятно застройки всех секций, кв.м (скаляр)" ) floors: int = Field(..., ge=1, le=40, description="Этажность программы") sections: int = Field(1, ge=1, le=6, description="Число секций (метаданные программы)") site_area_sqm: float = Field(..., ge=0, description="Площадь участка для плотности (FAR)") housing_class: Literal["econom", "comfort", "business"] = "comfort" development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise" land_cost_rub: float | None = Field( None, ge=0, description="Стоимость участка для финмодели (опционально)" ) # Предрезолвленная рыночная цена жилья, руб/кв.м — позволяет hot-эндпоинту ПРОПУСТИТЬ # per-keystroke DB-lookup (фронт резолвит цену один раз и шлёт её в каждом запросе). # None → эндпоинт сам сделает _lookup_market_price по центроиду (медленный путь). market_price_per_sqm: float | None = Field( None, ge=0, description="Предрезолвленная рыночная цена жилья, руб/кв.м (skip DB lookup)" ) # ПОДЛИННЫЙ источник предрезолвленной цены, который фронт получил из financial_estimate # (objective_geo_radius / objective_district_median / district_reference / class_norm). # Honest-флаг для UI/PDF: НЕ подменяем его захардкоженным лейблом. Применяется ТОЛЬКО # вместе с market_price_per_sqm; None → дефолтный лейбл (см. эндпоинт recompute_massing). price_source: str | None = Field( None, description="Подлинный источник предрезолвленной цены (forward из financial_estimate)" ) # Центроид участка (WKT-точка WGS84) для DB-fallback цены, когда market_price_per_sqm # не передан. None → пропускаем lookup и берём норматив класса (нет геопривязки). parcel_centroid_wkt: str | None = Field( None, description="WKT-точка центроида участка (WGS84) для DB-резолва цены при fallback" ) class MassingRecomputeOutput(BaseModel): """Stage 2a (#1965) — результат LIVE-пересчёта: синтезированный ТЭП + финмодель.""" teap: TEAP financial: FinancialModel