Финмодель брала фиксированное окно продаж 30 мес независимо от рынка (schedule_is_default всегда true). Теперь окно считается из ЛОКАЛЬНОГО темпа поглощения, который уже вычисляется в том же /analyze, но финмодель его игнорировала. Корректность абсорбции (ключевое): velocity.monthly_velocity_sqm — это СУММА поглощения ВСЕГО конкурентного набора в радиусе (м²/мес), НЕ темп одного проекта. Поэтому per-project absorption = monthly_velocity / max(n_with_sales,1) (темп одного типичного локального продавца) — иначе модель считала бы, что новый проект забирает весь рыночный темп (дико оптимистично). Поле project_absorption_sqm_per_month добавлено в VelocityResult (objective-путь); rosreestr-fallback и вырожденные пути → None (поквартальный count без по-проектной декомпозиции не может задавать график). financial.py: окно = clamp(ceil(residential/velocity), MIN=6, MAX=120) при конечной velocity>0; иначе дефолт 30. Эскроу-инвариант сохранён: sales_end=max(sales_start+base, constr_end). Инвариант Σ cashflow == net_profit держится (перенос выручки во времени не меняет сумму). schedule_is_default флипается в false когда график рыночный; новое поле sales_duration_months (реализованное окно) для UI/PDF. Wiring: parcels.py → synthesize_parcel_financial(velocity_sqm_per_month) → compute_financial(market_velocity_sqm_per_month). Generative §1c путь пока передаёт None (out of scope, follow-up). Тесты: +13 (None→дефолт+инвариант; рыночная velocity; клампы MIN/MAX; эскроу; non-finite→fallback; rosreestr→None; инвариант по размерам окна; регресс PR-3 — ровно одна смена знака на коротком окне). Полный бэкенд: 3414 passed, 0 failed. ruff+mypy(strict financial.py) чисто. api-types перегенерены. Code-review: 2× approve, 0 majors (adversarial correctness-lens подтвердил семантику абсорбции, инвариант, не-proxy IRR, клампы, rosreestr-None). Refs #1881 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
182 lines
5.4 KiB
Python
182 lines
5.4 KiB
Python
"""End-to-end API test — POST /api/v1/concepts returns 3 filled variants.
|
|
|
|
Goes through the FastAPI route (TestClient) and asserts the contract is populated
|
|
with *real* numbers (non-zero ТЭП + financial), valid building GeoJSON, and that a
|
|
degenerate parcel yields 422 rather than empty variants.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.core.db import get_db
|
|
from app.main import app
|
|
|
|
|
|
class _NoDbSession:
|
|
"""Заглушка Session: любой execute падает → market-price lookup деградирует в
|
|
class_norm без реальной БД. Тесты API не должны зависеть от живого Postgres."""
|
|
|
|
def execute(self, *args: object, **kwargs: object) -> object:
|
|
raise RuntimeError("no DB in unit test")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _override_db() -> Iterator[None]:
|
|
"""get_db → заглушка без БД на время API-тестов концепции (conftest чистит overrides)."""
|
|
|
|
def _fake_get_db() -> Iterator[_NoDbSession]:
|
|
yield _NoDbSession()
|
|
|
|
app.dependency_overrides[get_db] = _fake_get_db
|
|
yield
|
|
app.dependency_overrides.pop(get_db, None)
|
|
|
|
|
|
_PARCEL = {
|
|
"type": "Polygon",
|
|
"coordinates": [
|
|
[
|
|
[60.60, 56.830],
|
|
[60.6045, 56.830],
|
|
[60.6045, 56.8328],
|
|
[60.60, 56.8328],
|
|
[60.60, 56.830],
|
|
]
|
|
],
|
|
}
|
|
|
|
|
|
def _post(payload: dict[str, object]) -> object:
|
|
client = TestClient(app)
|
|
return client.post("/api/v1/concepts", json=payload)
|
|
|
|
|
|
def test_concepts_returns_three_filled_variants() -> None:
|
|
response = _post(
|
|
{
|
|
"parcel_geojson": _PARCEL,
|
|
"housing_class": "comfort",
|
|
"target_floors": 9,
|
|
"development_type": "mid_rise",
|
|
"land_cost_rub": 150_000_000,
|
|
}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
variants = response.json()["variants"]
|
|
assert len(variants) == 3
|
|
assert {v["strategy"] for v in variants} == {"max_area", "max_insolation", "balanced"}
|
|
|
|
for v in variants:
|
|
teap = v["teap"]
|
|
fin = v["financial"]
|
|
# Реальные, ненулевые числа (не stub-нули).
|
|
assert teap["built_area_sqm"] > 0
|
|
assert teap["total_floor_area_sqm"] > 0
|
|
assert teap["residential_area_sqm"] > 0
|
|
assert teap["apartments_count"] > 0
|
|
assert teap["density"] > 0
|
|
assert teap["parking_spaces"] > 0
|
|
assert fin["revenue_rub"] > 0
|
|
assert fin["cost_rub"] > 0
|
|
# PR-2: без БД market-price lookup честно падает в норматив класса.
|
|
assert fin["price_is_calibrated"] is False
|
|
assert fin["price_source"] == "class_norm"
|
|
assert fin["price_per_sqm_used"] > 0
|
|
# GeoJSON застройки непустой.
|
|
fc = v["buildings_geojson"]
|
|
assert fc["type"] == "FeatureCollection"
|
|
assert len(fc["features"]) > 0
|
|
|
|
|
|
def test_concepts_degenerate_parcel_returns_422() -> None:
|
|
tiny = {
|
|
"type": "Polygon",
|
|
"coordinates": [
|
|
[
|
|
[60.60, 56.830],
|
|
[60.60015, 56.830],
|
|
[60.60015, 56.83015],
|
|
[60.60, 56.83015],
|
|
[60.60, 56.830],
|
|
]
|
|
],
|
|
}
|
|
response = _post(
|
|
{
|
|
"parcel_geojson": tiny,
|
|
"housing_class": "comfort",
|
|
"target_floors": 9,
|
|
"development_type": "mid_rise",
|
|
}
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_concepts_response_matches_contract_keys() -> None:
|
|
response = _post(
|
|
{
|
|
"parcel_geojson": _PARCEL,
|
|
"housing_class": "business",
|
|
"target_floors": 16,
|
|
"development_type": "high_rise",
|
|
}
|
|
)
|
|
assert response.status_code == 200
|
|
variant = response.json()["variants"][0]
|
|
assert set(variant.keys()) == {"strategy", "buildings_geojson", "teap", "financial"}
|
|
assert set(variant["teap"].keys()) == {
|
|
"built_area_sqm",
|
|
"total_floor_area_sqm",
|
|
"residential_area_sqm",
|
|
"apartments_count",
|
|
"density",
|
|
"parking_spaces",
|
|
}
|
|
assert set(variant["financial"].keys()) == {
|
|
# legacy summary (backward-compat)
|
|
"revenue_rub",
|
|
"cost_rub",
|
|
"gross_margin_rub",
|
|
"irr",
|
|
# revenue breakdown
|
|
"revenue_residential_rub",
|
|
"revenue_parking_rub",
|
|
# cost cascade
|
|
"construction_rub",
|
|
"pir_rub",
|
|
"networks_rub",
|
|
"developer_services_rub",
|
|
"contingency_rub",
|
|
"marketing_rub",
|
|
"land_rub",
|
|
# БДР / taxes
|
|
"vat_rub",
|
|
"profit_before_tax_rub",
|
|
"profit_tax_rub",
|
|
"net_profit_rub",
|
|
# metrics
|
|
"roi",
|
|
"margin_pct",
|
|
"irr_is_proxy",
|
|
# DCF (PR-3)
|
|
"npv_rub",
|
|
"payback_months",
|
|
"discount_rate_used",
|
|
"schedule_is_default",
|
|
"sales_duration_months",
|
|
# financing overlay (PR-5)
|
|
"financing_enabled",
|
|
"annual_rate_used",
|
|
"peak_debt_rub",
|
|
"total_interest_rub",
|
|
"net_profit_after_financing_rub",
|
|
"financing_is_simplified",
|
|
# price calibration (PR-2)
|
|
"price_per_sqm_used",
|
|
"price_is_calibrated",
|
|
"price_source",
|
|
}
|