gendesign/backend/tests/services/generative/test_api_concepts.py
Light1YT d350d27325
All checks were successful
CI / frontend-tests (pull_request) Successful in 59s
CI / openapi-codegen-check (pull_request) Successful in 1m53s
CI / backend-tests (pull_request) Successful in 10m1s
CI / changes (pull_request) Successful in 7s
feat(finmodel): financing overlay (кредит 15%) поверх unlevered DCF (epic #1881 PR-5)
Финансирование как ДОПОЛНИТЕЛЬНЫЙ overlay поверх готового unlevered cashflow:
кредитная линия покрывает накопительный кассовый разрыв, проценты капитализируются
(compound), ставка _CREDIT_RATE_ANNUAL=15% (норматив, ОТДЕЛЬНО от ставки
дисконтирования — разная семантика: стоимость долга vs требуемая доходность).

Backend (financial.py):
- _compute_financing(operating_cf, annual_rate) — чистая (READ-ONLY вход, не
  мутирует): процент на остаток на начало месяца ДО движений → капитализация;
  draw=-cf при оттоке, repay=min(cf,debt) при притоке (долг не уходит в минус).
  Возвращает peak_debt_rub / total_interest_rub / ending_debt_rub.
- compute_financial: вызов после DCF, net_profit_after_financing = net − interest.
- _build_monthly_cashflow и unlevered NPV/IRR/PBP НЕ ТРОНУТЫ → инвариант
  Σ unlevered cashflow == net_profit сохранён (регресс-тест).
- FinancialModel +6 полей (financing_enabled/annual_rate_used/peak_debt_rub/
  total_interest_rub/net_profit_after_financing_rub/financing_is_simplified).

Frontend: ParcelFinancialEstimate +6 полей; adaptFinanceDrawer + concept-каскад
показывают пиковый долг/проценты/чистую прибыль после финанс. (когда enabled).

MVP: только агрегаты, БЕЗ levered IRR (хрупко на хардкод-100%-долге — отдельный
PR). HEAVY caveat: проценты — реальная стоимость (net после < net до); 100%
покрытие разрыва (нет equity/LTC); compound; эскроу не моделируется; unlevered
headline не затронут. financing_is_simplified=True флаг.

mypy strict clean, +9 backend + 3 frontend тестов (compound точное значение,
no-mutation, инвариант-регресс, монотонность, net_after<net), api-types
регенерён авторитетно, deps не тронуты (hand-roll).

Refs #1881
2026-06-24 01:12:47 +05:00

181 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",
# 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",
}