feat(generative): POST /concepts/recompute — live финрасчёт по massing-program (#1965 Stage 2a) #2024
6 changed files with 621 additions and 2 deletions
|
|
@ -8,9 +8,16 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import get_db
|
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 import geometry
|
||||||
|
from app.services.generative.financial import compute_financial
|
||||||
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
|
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
|
||||||
|
from app.services.generative.teap import synthesize_teap_from_program
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -33,6 +40,11 @@ _DISTRICT_SEARCH_RADIUS_M: int = 5000
|
||||||
_PRICE_MIN_RUB: int = 30_000
|
_PRICE_MIN_RUB: int = 30_000
|
||||||
_PRICE_MAX_RUB: int = 600_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) ближайший к точке центроида + его справочная медиана цены.
|
# Район ЕКБ (ekb_districts) ближайший к точке центроида + его справочная медиана цены.
|
||||||
# Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён).
|
# Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён).
|
||||||
# ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT.
|
# ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT.
|
||||||
|
|
@ -210,3 +222,70 @@ async def create_concept(
|
||||||
logger.warning("concept generation rejected parcel: %s", exc)
|
logger.warning("concept generation rejected parcel: %s", exc)
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
return ConceptOutput(variants=variants)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -151,3 +151,54 @@ class ConceptVariant(BaseModel):
|
||||||
|
|
||||||
class ConceptOutput(BaseModel):
|
class ConceptOutput(BaseModel):
|
||||||
variants: list[ConceptVariant]
|
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
|
||||||
|
|
|
||||||
|
|
@ -136,4 +136,76 @@ def compute_teap(
|
||||||
return 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"]
|
||||||
|
|
|
||||||
162
backend/tests/api/v1/test_concept_recompute.py
Normal file
162
backend/tests/api/v1/test_concept_recompute.py
Normal 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
|
||||||
|
|
@ -322,3 +322,111 @@ def test_financial_econom_parking_cheaper_than_business() -> None:
|
||||||
econ_parking_smr = econ.construction_rub - 1300.0 * 72_000.0
|
econ_parking_smr = econ.construction_rub - 1300.0 * 72_000.0
|
||||||
biz_parking_smr = biz.construction_rub - 1300.0 * 120_000.0
|
biz_parking_smr = biz.construction_rub - 1300.0 * 120_000.0
|
||||||
assert econ_parking_smr < biz_parking_smr
|
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
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,44 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/chat/ask": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -4065,6 +4103,82 @@ export interface components {
|
||||||
*/
|
*/
|
||||||
updated_at: string;
|
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
|
* ObjectiveConfigUpdateRequest
|
||||||
* @description PATCH-style — обновляет только переданные поля.
|
* @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: {
|
ask_api_v1_chat_ask_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue