feat(generative): POST /concepts/recompute — live финрасчёт по massing-program (#1965 Stage 2a) #2024

Merged
bot-backend merged 3 commits from feat/concept-recompute-1965-stage2a into main 2026-06-27 19:53:54 +00:00
6 changed files with 621 additions and 2 deletions

View file

@ -8,9 +8,16 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.concept import ConceptInput, ConceptOutput
from app.schemas.concept import (
ConceptInput,
ConceptOutput,
MassingProgram,
MassingRecomputeOutput,
)
from app.services.generative import geometry
from app.services.generative.financial import compute_financial
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
from app.services.generative.teap import synthesize_teap_from_program
logger = logging.getLogger(__name__)
@ -33,6 +40,11 @@ _DISTRICT_SEARCH_RADIUS_M: int = 5000
_PRICE_MIN_RUB: int = 30_000
_PRICE_MAX_RUB: int = 600_000
# Stage 2a (#1965): дефолтный лейбл source для ПРЕДРЕЗОЛВЛЕННОЙ цены в /recompute, когда
# фронт НЕ форвардит подлинный price_source. Консервативный fallback: цена пришла извне
# (фронт её уже резолвил), но без явного происхождения берём нейтральный district-median.
_DEFAULT_PRERESOLVED_SOURCE: str = "objective_district_median"
# Район ЕКБ (ekb_districts) ближайший к точке центроида + его справочная медиана цены.
# Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён).
# ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT.
@ -210,3 +222,70 @@ async def create_concept(
logger.warning("concept generation rejected parcel: %s", exc)
raise HTTPException(status_code=422, detail=str(exc)) from exc
return ConceptOutput(variants=variants)
@router.post("/recompute", response_model=MassingRecomputeOutput)
async def recompute_massing(
payload: MassingProgram,
db: Annotated[Session, Depends(get_db)],
) -> MassingRecomputeOutput:
"""Stage 2a (#1965, эпик #1953) — LIVE-пересчёт экономики из 3D-массинга.
Hot, debounced-эндпоинт для интерактивных слайдеров Stage 2b: фронтовый
``computeModel`` отдаёт агрегированную программу (суммарное пятно × этажность),
мы синтезируем :class:`TEAP` (``synthesize_teap_from_program`` те же нормативные
константы, что и обычная генерация) и прогоняем готовый чистый ``compute_financial``
пересчитанный ТЭП + финмодель.
Цена продажи жилья:
* ``market_price_per_sqm`` в теле используется как есть (FAST path, БЕЗ БД)
фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе. Источник
берём из ``payload.price_source`` (ПОДЛИННЫЙ source, который фронт получил из
financial_estimate objective_geo_radius / objective_district_median /
district_reference / class_norm); если фронт его не прислал дефолтный лейбл.
НЕ хардкодим source иначе honest-флаг в UI/PDF врёт о происхождении цены.
* иначе, если передан ``parcel_centroid_wkt`` ``_lookup_market_price`` по центроиду
через ``run_in_threadpool`` (тот же приём, что и в ``create_concept``; sync SQLAlchemy
не должен блокировать event loop). Падение lookup'а → норматив класса, не 500.
* иначе (нет ни цены, ни центроида) норматив класса (``price_source="class_norm"``).
"""
# ── Цена продажи: предрезолвленная (fast) → DB-fallback → норматив класса ───────
market_price: float | None = payload.market_price_per_sqm
price_source: str = "class_norm"
if market_price is not None:
# FAST path: фронт уже резолвил рыночную цену — НЕ ходим в БД на каждый keystroke.
# Используем ПОДЛИННЫЙ source, который фронт форвардит из financial_estimate; если
# его нет — дефолтный лейбл. (При market_price=None compute_financial всё равно
# форсит class_norm, но сюда мы попадаем только при заданной цене.)
price_source = payload.price_source or _DEFAULT_PRERESOLVED_SOURCE
elif payload.parcel_centroid_wkt:
# Fallback: резолвим по центроиду. Sync-lookup → threadpool (как в create_concept).
# Любая ошибка → норматив класса (graceful, без 500) — деградация уже внутри
# _lookup_market_price, защитный пояс на непредвиденное оставляем тут.
try:
market_price, price_source = await run_in_threadpool(
_lookup_market_price, db, payload.parcel_centroid_wkt
)
except Exception as exc:
logger.warning("recompute market-price: unexpected lookup error, class_norm: %s", exc)
market_price, price_source = None, "class_norm"
teap = synthesize_teap_from_program(
total_footprint_sqm=payload.total_footprint_sqm,
floors=payload.floors,
site_area_sqm=payload.site_area_sqm,
housing_class=payload.housing_class,
sections=payload.sections,
)
# compute_financial — чистая, CPU-bound, лёгкая (без БД) → можно вызывать прямо в
# event loop (hot path; threadpool-overhead тут не нужен). price_source игнорируется
# внутри, когда market_price_per_sqm is None (форсится class_norm) — корректно.
financial = compute_financial(
teap=teap,
housing_class=payload.housing_class,
land_cost_rub=payload.land_cost_rub,
market_price_per_sqm=market_price,
price_source=price_source,
development_type=payload.development_type,
)
return MassingRecomputeOutput(teap=teap, financial=financial)

View file

@ -151,3 +151,54 @@ class ConceptVariant(BaseModel):
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

View file

@ -136,4 +136,76 @@ def compute_teap(
return teap
__all__ = ["HousingClass", "compute_teap"]
def synthesize_teap_from_program(
*,
total_footprint_sqm: float,
floors: int,
site_area_sqm: float,
housing_class: HousingClass,
sections: int = 1,
) -> TEAP:
"""Свести АГРЕГИРОВАННУЮ массинг-программу (скалярное пятно × этажность) в :class:`TEAP`.
Stage 2a (#1965): LIVE-пересчёт экономики из интерактивного 3D-массинга. Фронтовый
``computeModel`` отдаёт уже СВЁРНУТУЮ программу суммарное пятно застройки (кв.м) и
этажность, без покомпонентной геометрии секций. Здесь мы строим из этого тот же
:class:`TEAP`, что и :func:`compute_teap`/``synthesize_teap_from_buildability``, чтобы
прогнать его через готовый :func:`compute_financial`.
Зеркалит :func:`synthesize_teap_from_buildability` ОДИН-В-ОДИН, только источник
площадей СКАЛЯРНОЕ пятно, а не ``max_far × area``:
* ``built`` (пятно) = ``total_footprint_sqm`` (как есть, сумма по секциям).
* ``GFA`` (total_floor) = ``total_footprint_sqm × floors``.
* ``office`` (нежилое) = ``GFA × _OFFICE_SHARE_OF_GFA[class]`` вырезается из GFA
до расчёта жилой (жилая ужимается ровно на эту площадь, total GFA не меняется).
* ``residential`` = ``(GFA office) × _EFFICIENCY_BY_CLASS[class]``.
* ``apartments`` = ``floor(residential / _AVG_APARTMENT_SQM[class])``.
* ``parking`` = ``ceil(apartments × _PARKING_PER_APARTMENT[class])``.
* ``density`` = ``GFA / site_area_sqm`` (FAR), защита от деления на ноль.
Чистая функция без БД / LLM / внешних API. Те же нормативные константы, что и
остальные синтезаторы (single source of truth, без новых магических чисел).
Args:
total_footprint_sqm: суммарное пятно застройки всех секций, кв.м (скаляр).
floors: этажность (общая для программы); вне диапазона клампится к >= 0.
site_area_sqm: площадь участка для плотности (FAR).
housing_class: класс жилья задаёт эффективность/средний лот/парковку/нежилое.
sections: число секций метаданные программы; на ТЭП НЕ влияет (площади уже
свёрнуты во ``total_footprint_sqm``). Принимается для симметрии с контрактом
фронта и будущей пер-секционной логики; здесь намеренно не используется.
"""
del sections # метаданные программы; площади уже агрегированы — на ТЭП не влияют
built_area = max(0.0, total_footprint_sqm)
total_floor_area = built_area * max(0, floors)
# Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA ДО расчёта жилой — точно как
# compute_teap / synthesize_teap_from_buildability (single source of truth по долям).
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 = residential_gfa * efficiency
avg_apartment = _AVG_APARTMENT_SQM[housing_class]
apartments_count = math.floor(residential_area / avg_apartment) if avg_apartment else 0
density = total_floor_area / site_area_sqm if site_area_sqm > 0 else 0.0
parking_norm = _PARKING_PER_APARTMENT[housing_class]
parking_spaces = math.ceil(apartments_count * parking_norm)
return 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,
)
__all__ = ["HousingClass", "compute_teap", "synthesize_teap_from_program"]

View file

@ -0,0 +1,162 @@
"""Stage 2a (#1965, эпик #1953) — POST /api/v1/concepts/recompute endpoint tests.
LIVE-пересчёт экономики из агрегированной массинг-программы (пятно × этажность) для
дебаунс-слайдеров Stage 2b. Проверяем: 200 + связный вывод (ТЭП-математика, revenue>0,
NPV посчитан), FAST path с предрезолвленной ценой (БЕЗ обращения к БД) и DB-fallback по
центроиду (через dependency override get_db мок-сессией). compute_financial остаётся
чистой её арифметика покрыта в tests/services/generative; тут контракт эндпоинта.
"""
from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from app.core.db import get_db
from app.main import app
def _program(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"total_footprint_sqm": 3000.0,
"floors": 12,
"sections": 2,
"site_area_sqm": 10_000.0,
"housing_class": "comfort",
"development_type": "mid_rise",
}
base.update(overrides)
return base
def test_recompute_returns_200_and_coherent_output() -> None:
client = TestClient(app)
resp = client.post(
"/api/v1/concepts/recompute",
json=_program(market_price_per_sqm=150_000.0),
)
assert resp.status_code == 200, resp.text
body = resp.json()
teap = body["teap"]
fin = body["financial"]
# ТЭП-математика: GFA = пятно × этажность; пятно = total_footprint_sqm.
assert teap["built_area_sqm"] == 3000.0
assert teap["total_floor_area_sqm"] == 3000.0 * 12
# Финмодель связна: выручка/затраты > 0, NPV посчитан (поле присутствует).
assert fin["revenue_rub"] > 0
assert fin["cost_rub"] > 0
assert isinstance(fin["npv_rub"], float)
def test_recompute_market_price_passthrough_skips_db() -> None:
# Предрезолвленная цена в теле → используется как есть, БЕЗ обращения к БД.
# get_db переопределён сессией, которая ВЗРЫВАЕТСЯ при любом execute — если бы
# эндпоинт пошёл в БД, тест бы упал. Зелёный тест доказывает skip DB lookup.
class _ExplodingSession:
def execute(self, *_a: object, **_k: object) -> None:
raise AssertionError("DB lookup must be skipped when market_price_per_sqm given")
app.dependency_overrides[get_db] = lambda: _ExplodingSession()
try:
client = TestClient(app)
resp = client.post(
"/api/v1/concepts/recompute",
json=_program(market_price_per_sqm=180_000.0),
)
assert resp.status_code == 200, resp.text
fin = resp.json()["financial"]
# Цена прокинута в выручку жилья → price_per_sqm_used == переданная цена.
assert fin["price_per_sqm_used"] == 180_000.0
assert fin["price_is_calibrated"] is True
# Без body price_source → дефолтный лейбл (фронт не форвардил подлинный source).
assert fin["price_source"] == "objective_district_median"
finally:
app.dependency_overrides.pop(get_db, None)
def test_recompute_body_price_source_echoed_through() -> None:
# Фронт форвардит ПОДЛИННЫЙ source цены (из financial_estimate, напр. geo_radius) —
# эндпоинт НЕ подменяет его захардкоженным дефолтом; honest-флаг доходит до UI/PDF.
# get_db взрывается → доказывает, что и source-passthrough идёт по FAST path без БД.
class _ExplodingSession:
def execute(self, *_a: object, **_k: object) -> None:
raise AssertionError("DB lookup must be skipped when market_price_per_sqm given")
app.dependency_overrides[get_db] = lambda: _ExplodingSession()
try:
client = TestClient(app)
resp = client.post(
"/api/v1/concepts/recompute",
json=_program(market_price_per_sqm=160_000.0, price_source="objective_geo_radius"),
)
assert resp.status_code == 200, resp.text
fin = resp.json()["financial"]
assert fin["price_per_sqm_used"] == 160_000.0
assert fin["price_is_calibrated"] is True
# Подлинный source эхнут наружу, НЕ перетёрт дефолтом "objective_district_median".
assert fin["price_source"] == "objective_geo_radius"
finally:
app.dependency_overrides.pop(get_db, None)
def test_recompute_db_fallback_when_no_price_but_centroid() -> None:
# Нет market_price_per_sqm, но передан centroid → эндпоинт делает _lookup_market_price.
# Мок-сессия отдаёт район + Objective-медиану (n>=10) → цена калибруется из БД.
class _Result:
def __init__(self, row: dict[str, Any] | None) -> None:
self._row = row
def mappings(self) -> _Result:
return self
def first(self) -> dict[str, Any] | None:
return self._row
class _FakeSession:
def __init__(self, rows: list[dict[str, Any] | None]) -> None:
self._rows = rows
self.calls = 0
def execute(self, *_a: object, **_k: object) -> _Result:
row = self._rows[self.calls] if self.calls < len(self._rows) else None
self.calls += 1
return _Result(row)
rows = [
{"district_name": "Кировский", "median_price_per_m2": 130_000},
{"median_ppm2": 175_000.0, "sample_size": 42},
]
app.dependency_overrides[get_db] = lambda: _FakeSession(rows)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/concepts/recompute",
json=_program(parcel_centroid_wkt="POINT (60.6 56.83)"),
)
assert resp.status_code == 200, resp.text
fin = resp.json()["financial"]
# Цена пришла из Objective-медианы района (DB-fallback).
assert fin["price_per_sqm_used"] == 175_000.0
assert fin["price_source"] == "objective_district_median"
assert fin["price_is_calibrated"] is True
finally:
app.dependency_overrides.pop(get_db, None)
def test_recompute_no_price_no_centroid_uses_class_norm() -> None:
# Ни цены, ни центроида → норматив класса comfort (145_000), не калибровано.
client = TestClient(app)
resp = client.post("/api/v1/concepts/recompute", json=_program())
assert resp.status_code == 200, resp.text
fin = resp.json()["financial"]
assert fin["price_per_sqm_used"] == 145_000.0
assert fin["price_is_calibrated"] is False
assert fin["price_source"] == "class_norm"
def test_recompute_validates_floors_bounds() -> None:
# floors вне [1, 40] → 422 (Pydantic-валидация контракта).
client = TestClient(app)
resp = client.post("/api/v1/concepts/recompute", json=_program(floors=99))
assert resp.status_code == 422

View file

@ -322,3 +322,111 @@ def test_financial_econom_parking_cheaper_than_business() -> None:
econ_parking_smr = econ.construction_rub - 1300.0 * 72_000.0
biz_parking_smr = biz.construction_rub - 1300.0 * 120_000.0
assert econ_parking_smr < biz_parking_smr
# ── Stage 2a (#1965): synthesize_teap_from_program (скалярное пятно × этажность) ──
def test_synthesize_program_gfa_is_footprint_times_floors() -> None:
# GFA = суммарное пятно × этажность; пятно = total_footprint_sqm как есть.
result = teap.synthesize_teap_from_program(
total_footprint_sqm=3000.0,
floors=12,
site_area_sqm=10_000.0,
housing_class="comfort",
sections=2,
)
assert result.built_area_sqm == 3000.0
assert result.total_floor_area_sqm == 3000.0 * 12 # 36_000
# FAR = GFA / участок.
assert result.density == round(36_000.0 / 10_000.0, 3)
def test_synthesize_program_matches_compute_teap_for_equivalent_input() -> None:
# synthesize_teap_from_program(footprint, floors) ДОЛЖЕН дать ровно тот же ТЭП,
# что compute_teap из одного прямоугольного пятна той же площади — единая логика.
footprint = box(0, 0, 50, 40) # 2000 кв.м
from_program = teap.synthesize_teap_from_program(
total_footprint_sqm=2000.0,
floors=9,
site_area_sqm=5000.0,
housing_class="comfort",
)
from_geometry = teap.compute_teap(
footprints=[footprint],
floors=9,
site_area_sqm=5000.0,
housing_class="comfort",
)
assert from_program == from_geometry
def test_synthesize_program_office_carveout_and_efficiency_by_class() -> None:
# Нежилое вырезается из GFA (comfort share 0.05), жилая = (GFAнежилое)*efficiency.
result = teap.synthesize_teap_from_program(
total_footprint_sqm=2000.0,
floors=10,
site_area_sqm=5000.0,
housing_class="comfort",
)
gfa = 2000.0 * 10 # 20_000
assert result.office_area_sqm == round(gfa * 0.05, 1) # 1000
assert result.residential_area_sqm == round((gfa - gfa * 0.05) * 0.78, 1)
def test_synthesize_program_class_changes_efficiency_and_lot() -> None:
common = dict(total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0)
econ = teap.synthesize_teap_from_program(housing_class="econom", **common) # type: ignore[arg-type]
biz = teap.synthesize_teap_from_program(housing_class="business", **common) # type: ignore[arg-type]
# Эконом эффективнее (0.82 vs 0.72) + нет нежилого carve-out → больше жилой.
assert econ.residential_area_sqm > biz.residential_area_sqm
# Бизнес — крупнее средний лот → меньше квартир.
assert biz.apartments_count < econ.apartments_count
# Бизнес — выше норма парковки на квартиру.
assert biz.parking_spaces / max(1, biz.apartments_count) >= 1.4
def test_synthesize_program_zero_site_area_no_division_error() -> None:
result = teap.synthesize_teap_from_program(
total_footprint_sqm=1000.0,
floors=5,
site_area_sqm=0.0,
housing_class="comfort",
)
assert result.density == 0.0
def test_synthesize_program_sections_do_not_affect_teap() -> None:
# sections — метаданные программы; площади уже свёрнуты → ТЭП от них не зависит.
one = teap.synthesize_teap_from_program(
total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0,
housing_class="comfort", sections=1,
)
six = teap.synthesize_teap_from_program(
total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0,
housing_class="comfort", sections=6,
)
assert one == six
def test_synthesize_program_then_financial_is_coherent() -> None:
# Сквозь финмодель: жизнеспособная программа → выручка > 0, NPV посчитан.
t = teap.synthesize_teap_from_program(
total_footprint_sqm=3000.0,
floors=12,
site_area_sqm=10_000.0,
housing_class="comfort",
)
model = financial.compute_financial(
teap=t,
housing_class="comfort",
land_cost_rub=None,
market_price_per_sqm=150_000.0,
price_source="objective_district_median",
)
assert model.revenue_rub > 0
assert model.cost_rub > 0
assert isinstance(model.npv_rub, float)
# Цена прокинута в выручку жилья.
assert model.price_per_sqm_used == 150_000.0
assert model.price_is_calibrated is True

View file

@ -34,6 +34,44 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/concepts/recompute": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Recompute Massing
* @description Stage 2a (#1965, эпик #1953) LIVE-пересчёт экономики из 3D-массинга.
*
* Hot, debounced-эндпоинт для интерактивных слайдеров Stage 2b: фронтовый
* ``computeModel`` отдаёт агрегированную программу (суммарное пятно × этажность),
* мы синтезируем :class:`TEAP` (``synthesize_teap_from_program`` те же нормативные
* константы, что и обычная генерация) и прогоняем готовый чистый ``compute_financial``
* пересчитанный ТЭП + финмодель.
*
* Цена продажи жилья:
* * ``market_price_per_sqm`` в теле используется как есть (FAST path, БЕЗ БД)
* фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе. Источник
* берём из ``payload.price_source`` (ПОДЛИННЫЙ source, который фронт получил из
* financial_estimate objective_geo_radius / objective_district_median /
* district_reference / class_norm); если фронт его не прислал дефолтный лейбл.
* НЕ хардкодим source иначе honest-флаг в UI/PDF врёт о происхождении цены.
* * иначе, если передан ``parcel_centroid_wkt`` ``_lookup_market_price`` по центроиду
* через ``run_in_threadpool`` (тот же приём, что и в ``create_concept``; sync SQLAlchemy
* не должен блокировать event loop). Падение lookup'а норматив класса, не 500.
* * иначе (нет ни цены, ни центроида) норматив класса (``price_source="class_norm"``).
*/
post: operations["recompute_massing_api_v1_concepts_recompute_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/chat/ask": {
parameters: {
query?: never;
@ -4065,6 +4103,82 @@ export interface components {
*/
updated_at: string;
};
/**
* MassingProgram
* @description 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 по допустимым значениям).
*/
MassingProgram: {
/**
* Total Footprint Sqm
* @description Суммарное пятно застройки всех секций, кв.м (скаляр)
*/
total_footprint_sqm: number;
/**
* Floors
* @description Этажность программы
*/
floors: number;
/**
* Sections
* @description Число секций (метаданные программы)
* @default 1
*/
sections: number;
/**
* Site Area Sqm
* @description Площадь участка для плотности (FAR)
*/
site_area_sqm: number;
/**
* Housing Class
* @default comfort
* @enum {string}
*/
housing_class: "econom" | "comfort" | "business";
/**
* Development Type
* @default mid_rise
* @enum {string}
*/
development_type: "spot" | "mid_rise" | "high_rise";
/**
* Land Cost Rub
* @description Стоимость участка для финмодели (опционально)
*/
land_cost_rub?: number | null;
/**
* Market Price Per Sqm
* @description Предрезолвленная рыночная цена жилья, руб/кв.м (skip DB lookup)
*/
market_price_per_sqm?: number | null;
/**
* Price Source
* @description Подлинный источник предрезолвленной цены (forward из financial_estimate)
*/
price_source?: string | null;
/**
* Parcel Centroid Wkt
* @description WKT-точка центроида участка (WGS84) для DB-резолва цены при fallback
*/
parcel_centroid_wkt?: string | null;
};
/**
* MassingRecomputeOutput
* @description Stage 2a (#1965) результат LIVE-пересчёта: синтезированный ТЭП + финмодель.
*/
MassingRecomputeOutput: {
teap: components["schemas"]["TEAP"];
financial: components["schemas"]["FinancialModel"];
};
/**
* ObjectiveConfigUpdateRequest
* @description PATCH-style обновляет только переданные поля.
@ -4911,6 +5025,39 @@ export interface operations {
};
};
};
recompute_massing_api_v1_concepts_recompute_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MassingProgram"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MassingRecomputeOutput"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
ask_api_v1_chat_ask_post: {
parameters: {
query?: never;