gendesign/backend/tests/api/v1/test_parcel_best_layouts.py
lekss361 fdc64569d2 feat(parcels): best-layouts endpoint + service (#113 PR C)
POST /api/v1/parcels/{cad_num}/best-layouts — top layout patterns
(room_bucket × area_bin) у конкурентов в радиусе с ranking by velocity
из mv_layout_velocity + unit-mix recommendation для архитектурного ТЗ.

Service (backend/app/services/site_finder/best_layouts.py, 563 LOC):
- centroid lookup с fallback (cad_parcels → cad_quarters)
- competitors-in-radius через ST_DWithin
- aggregate по mv_layout_velocity per room_bucket
- supply batch query (один SQL вместо N) для sold_pct_of_supply
- Hamilton largest-remainder normalizer (sum_pct=100 invariant)

Endpoint (backend/app/api/v1/parcels.py, +24 LOC) после /competitors.

Tests (backend/tests/api/v1/test_parcel_best_layouts.py, 10 mock tests):
404, empty competitors, MV empty, exclude/filter, min_velocity, time_window
scaling, obj_class_filter, target_total_flats, 3-bucket ranking.

Phase 2.1 (без layout_type / balcony_count — отсутствуют в БД,
ждут B2B Объектив #52). Reuses PR A schemas/helpers, PR B MV.

NOTE: competitors.py:143 status='sold' carryover bug НЕ fixed —
отдельный follow-up issue. PR C использует MV (через
objective_corpus_room_month) что работает корректно.
2026-05-16 11:42:20 +03:00

372 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты для POST /api/v1/parcels/{cad_num}/best-layouts (Issue #113 Phase 2.1).
Mock-based — не требуют живой БД.
Паттерн mock DB: аналогично test_parcel_competitors.py — dependency_overrides[get_db].
Порядок вызовов db.execute в get_best_layouts:
1. _PARCEL_CENTROID_SQL → .mappings().first()
2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
3. _VELOCITY_BY_ROOM_SQL → .mappings().all()
4. _SUPPLY_BATCH_SQL → .mappings().all()
"""
from __future__ import annotations
import datetime as dt
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from app.main import app
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
CAD_NUM = "66:41:0303161:123"
_TODAY = dt.date.today()
def _coord_row(x: float = 6747000.0, y: float = 7846000.0) -> MagicMock:
"""Центроид участка в EPSG:3857."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"x_3857": x, "y_3857": y}[k]
return r
def _obj_id_row(obj_id: int) -> MagicMock:
"""Строка obj_id из _COMPETITORS_IN_RADIUS_SQL."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k]
return r
def _vel_row(
room_bucket: str = "2",
sum_deals: float = 48.0,
avg_area: float = 55.0,
avg_price_rub: float | None = 120000.0,
obj_ids: list[int] | None = None,
window_start: dt.date | None = None,
window_end: dt.date | None = None,
) -> MagicMock:
"""Строка из mv_layout_velocity GROUP BY room_bucket."""
oids = obj_ids if obj_ids is not None else [1]
ws = window_start or _TODAY - dt.timedelta(days=730)
we = window_end or _TODAY
r = MagicMock()
r.__getitem__ = lambda self, k: {
"room_bucket": room_bucket,
"sum_deals": sum_deals,
"avg_area_m2": avg_area,
"avg_price_per_m2_rub": avg_price_rub,
"competitor_obj_ids": oids,
"competitor_count": len(oids),
"window_start": ws,
"window_end": we,
}[k]
return r
def _supply_row(rb: str, ab: str, units: int) -> MagicMock:
"""Строка из _SUPPLY_BATCH_SQL."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"rb": rb, "ab": ab, "units": units}[k]
return r
# ── Построение mock DB ────────────────────────────────────────────────────────
def _make_db(
coord: MagicMock | None = None,
id_rows: list[MagicMock] | None = None,
vel_rows: list[MagicMock] | None = None,
supply_rows: list[MagicMock] | None = None,
) -> MagicMock:
"""Сконструировать mock Session.
Порядок execute:
1. centroid → .mappings().first()
2. competitors-in-radius → .mappings().all()
3. velocity → .mappings().all()
4. supply → .mappings().all()
"""
db = MagicMock()
results: list[MagicMock] = []
# 1: centroid
r0 = MagicMock()
r0.mappings.return_value.first.return_value = coord
results.append(r0)
# 2: competitors-in-radius
r1 = MagicMock()
r1.mappings.return_value.all.return_value = id_rows or []
results.append(r1)
# 3: velocity (only queried if id_rows non-empty)
r2 = MagicMock()
r2.mappings.return_value.all.return_value = vel_rows or []
results.append(r2)
# 4: supply
r3 = MagicMock()
r3.mappings.return_value.all.return_value = supply_rows or []
results.append(r3)
db.execute.side_effect = results
return db
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
def _post(client: TestClient, cad: str = CAD_NUM, **body_kwargs) -> dict:
payload = {"radius_km": 1.0, "time_window": "last_quarter", **body_kwargs}
resp = client.post(f"/api/v1/parcels/{cad}/best-layouts", json=payload)
return resp
# ── Тесты ─────────────────────────────────────────────────────────────────────
def test_parcel_not_found_404() -> None:
"""Если центроид не найден → 404."""
db = _make_db(coord=None)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), cad="99:99:9999999:999")
assert resp.status_code == 404, resp.text
finally:
app.dependency_overrides.clear()
def test_empty_competitor_set_returns_low_confidence() -> None:
"""Нет конкурентов в радиусе → пустые top_layouts + confidence=low."""
db = _make_db(coord=_coord_row(), id_rows=[])
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app))
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["top_layouts"] == []
assert body["data_quality"]["confidence"] == "low"
assert body["data_quality"]["objects_total_in_radius"] == 0
rec = body["recommendation_for_tz"]
assert rec["based_on_obj_count"] == 0
assert rec["based_on_total_deals"] == 0
assert rec["mix"] == []
finally:
app.dependency_overrides.clear()
def test_three_obj_ids_ranking_and_pct_sum_100() -> None:
"""3 obj_id, 3 room_buckets — ranking по velocity, sum pct = 100."""
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
vel_rows = [
_vel_row("studio", sum_deals=8.0, avg_area=26.0, obj_ids=[1]),
_vel_row("1", sum_deals=32.0, avg_area=40.0, obj_ids=[2]),
_vel_row("2", sum_deals=48.0, avg_area=55.0, obj_ids=[3]),
]
supply_rows = [
_supply_row("studio", "25-40", 20),
_supply_row("1", "40-60", 60),
_supply_row("2", "40-60", 80),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), time_window="last_quarter")
assert resp.status_code == 200, resp.text
body = resp.json()
top = body["top_layouts"]
assert len(top) == 3
# rank 1 = самая высокая velocity (2-комн: 48/8=6.0 per month)
assert top[0]["rank"] == 1
assert top[0]["room_bucket"] == "2"
# все ранги уникальны
assert sorted(t["rank"] for t in top) == [1, 2, 3]
# sum pct = 100
mix = body["recommendation_for_tz"]["mix"]
assert sum(m["pct"] for m in mix) == 100
finally:
app.dependency_overrides.clear()
def test_exclude_competitor_obj_ids_filter() -> None:
"""exclude_competitor_obj_ids исключает obj_id: при all excluded → пустой ответ."""
# Если после исключения obj_id_list пуст → _empty_response → top_layouts=[]
id_rows = [_obj_id_row(20)] # единственный конкурент
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[])
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), exclude_competitor_obj_ids=[20])
assert resp.status_code == 200, resp.text
body = resp.json()
# После исключения obj_id=20 список пуст → пустой ответ
assert body["top_layouts"] == []
assert body["data_quality"]["confidence"] == "low"
# objects_total_in_radius = 1 (до исключения)
assert body["data_quality"]["objects_total_in_radius"] == 1
finally:
app.dependency_overrides.clear()
def test_min_velocity_per_month_filters_low_rows() -> None:
"""min_velocity_per_month=5 → строки с velocity<5 не попадают в top_layouts."""
id_rows = [_obj_id_row(1), _obj_id_row(2)]
# last_quarter divisor=8 → 16/8=2.0 (ниже порога), 80/8=10.0 (выше)
vel_rows = [
_vel_row("studio", sum_deals=16.0, obj_ids=[1]),
_vel_row("1", sum_deals=80.0, obj_ids=[2]),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), min_velocity_per_month=5.0)
assert resp.status_code == 200, resp.text
body = resp.json()
top = body["top_layouts"]
assert len(top) == 1
assert top[0]["room_bucket"] == "1"
assert top[0]["velocity_per_month"] == pytest.approx(10.0)
finally:
app.dependency_overrides.clear()
def test_time_window_velocity_scaling() -> None:
"""last_month vs last_year дают разный velocity_per_month для одних deals."""
# sum_deals=24 → last_month: 24/24=1.0, last_year: 24/2=12.0
id_rows = [_obj_id_row(1)]
vel_rows_fixed = [_vel_row("2", sum_deals=24.0, obj_ids=[1])]
from app.core.db import get_db
# last_month
db_m = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows_fixed)
app.dependency_overrides[get_db] = _override_db(db_m)
try:
resp_m = _post(TestClient(app), time_window="last_month")
assert resp_m.status_code == 200, resp_m.text
v_month = resp_m.json()["top_layouts"][0]["velocity_per_month"]
finally:
app.dependency_overrides.clear()
# last_year
db_y = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows_fixed)
app.dependency_overrides[get_db] = _override_db(db_y)
try:
resp_y = _post(TestClient(app), time_window="last_year")
assert resp_y.status_code == 200, resp_y.text
v_year = resp_y.json()["top_layouts"][0]["velocity_per_month"]
finally:
app.dependency_overrides.clear()
# last_year velocity должна быть выше (делитель меньше: 2 vs 24)
assert v_year > v_month
assert v_month == pytest.approx(1.0)
assert v_year == pytest.approx(12.0)
def test_obj_class_filter_passes_through() -> None:
"""obj_class_filter передаётся в SQL — endpoint не ломается, возвращает 200."""
db = _make_db(
coord=_coord_row(),
id_rows=[_obj_id_row(5)],
vel_rows=[_vel_row("2", obj_ids=[5])],
supply_rows=[],
)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), obj_class_filter="comfort")
assert resp.status_code == 200, resp.text
body = resp.json()
assert len(body["top_layouts"]) > 0
finally:
app.dependency_overrides.clear()
def test_mv_empty_for_competitors_returns_empty_top_layouts() -> None:
"""Конкуренты есть в радиусе, но MV пустой → top_layouts=[], data_quality.confidence=low."""
id_rows = [_obj_id_row(1), _obj_id_row(2)]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[])
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app))
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["top_layouts"] == []
dq = body["data_quality"]
assert dq["objects_total_in_radius"] == 2
assert dq["objects_with_velocity_data"] == 0
assert dq["confidence"] == "low"
finally:
app.dependency_overrides.clear()
def test_target_total_flats_fills_abs_units() -> None:
"""target_total_flats=100 → abs_units заполнен в mix, sum примерно = 100."""
id_rows = [_obj_id_row(1), _obj_id_row(2)]
vel_rows = [
_vel_row("1", sum_deals=60.0, obj_ids=[1]),
_vel_row("2", sum_deals=40.0, obj_ids=[2]),
]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), target_total_flats=100)
assert resp.status_code == 200, resp.text
mix = resp.json()["recommendation_for_tz"]["mix"]
# все abs_units заполнены
for m in mix:
assert m["abs_units"] is not None
# сумма abs_units близка к 100 (round-off ±1)
total_abs = sum(m["abs_units"] for m in mix)
assert 98 <= total_abs <= 102
finally:
app.dependency_overrides.clear()
def test_filter_competitor_obj_ids_applied() -> None:
"""filter_competitor_obj_ids=[1] оставляет только obj_id=1."""
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
# После фильтрации остаётся только obj_id=1, velocity запрос получит [1]
vel_rows = [_vel_row("2", sum_deals=24.0, obj_ids=[1])]
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
resp = _post(TestClient(app), filter_competitor_obj_ids=[1])
assert resp.status_code == 200, resp.text
body = resp.json()
top = body["top_layouts"]
assert len(top) >= 1
# competitor_obj_ids должен содержать только 1
for row in top:
for oid in row["competitor_obj_ids"]:
assert oid == 1
finally:
app.dependency_overrides.clear()