gendesign/backend/app/schemas/concept.py
bot-backend 975c400536
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 1m57s
Deploy / build-frontend (push) Successful in 3m34s
Deploy / build-worker (push) Successful in 3m41s
Deploy / deploy (push) Successful in 1m32s
feat(section7): editable section footprint + корпус plural/program hint (#1953) (#2102)
2026-06-30 09:48:11 +00:00

290 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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