Stage 2a of epic #1953: backend service + endpoint for live economic recompute driven by the interactive 3D massing (Stage 2b debounced sliders). - teap.py: add pure synthesize_teap_from_program(total_footprint_sqm, floors, site_area_sqm, housing_class, sections) — builds a TEAP from the SCALAR aggregate footprint × floors, mirroring synthesize_teap_from_buildability and reusing the same shared norm constants (_OFFICE_SHARE_OF_GFA / _EFFICIENCY_BY_CLASS / _AVG_APARTMENT_SQM / _PARKING_PER_APARTMENT) — single source of truth. - schemas/concept.py: add MassingProgram (program contract, optional pre-resolved market_price_per_sqm + parcel_centroid_wkt) and MassingRecomputeOutput (teap + financial). - api/v1/concepts.py: add POST /api/v1/concepts/recompute — synthesize TEAP → run the existing pure compute_financial. FAST path uses body market_price_per_sqm (no DB); else _lookup_market_price by centroid via run_in_threadpool; else class norm. - tests: synthesize_teap_from_program (gfa math, parity with compute_teap, class efficiency, sections no-op) + endpoint (200, coherent output, price passthrough skips DB, DB fallback, class-norm default, floors validation).
135 lines
5.8 KiB
Python
135 lines
5.8 KiB
Python
"""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
|
||
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
|