fix(sf-01): time_window honest velocity — inline SQL с реальным фильтром report_month #275
3 changed files with 432 additions and 48 deletions
|
|
@ -3,19 +3,23 @@
|
|||
Источники:
|
||||
cad_parcels_geom / cad_quarters_geom — центроид участка
|
||||
domrf_kn_objects — ЖК в радиусе (latitude/longitude → geography)
|
||||
mv_layout_velocity — (obj_id, room_bucket) → агрегат продаж 24 мес
|
||||
objective_corpus_room_month — ежемесячные сделки по (project_name, room_bucket)
|
||||
objective_complex_mapping — domrf_obj_id ↔ objective_complex_name
|
||||
domrf_kn_flats — supply count по (room_bucket, area_bin)
|
||||
|
||||
Алгоритм:
|
||||
Step 1: центроид участка (cad_parcels_geom → cad_quarters_geom fallback).
|
||||
Step 2: obj_id конкурентов в радиусе (domrf_kn_objects + фильтры).
|
||||
Step 3: JOIN mv_layout_velocity GROUP BY room_bucket.
|
||||
Step 4: scale velocity по time_window.
|
||||
Step 3: inline SQL из objective_corpus_room_month с честным WHERE report_month фильтром.
|
||||
Step 4: velocity_per_month = deals_window / months_in_window (честный time_window).
|
||||
Step 5: supply side из domrf_kn_flats — один батч-запрос.
|
||||
Step 6: per-row signature + sold_pct.
|
||||
Step 7: фильтр min_velocity + sort + rank.
|
||||
Step 8: build recommendation_for_tz (unit-mix, price, rationale).
|
||||
Step 9: data_quality (coverage + confidence).
|
||||
|
||||
Fix SF-01: раньше mv_layout_velocity (24 мес) делился на divisor (4/12) — данные
|
||||
не менялись при смене time_window. Теперь inline SQL с реальным фильтром report_month.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -44,11 +48,13 @@ logger = logging.getLogger(__name__)
|
|||
LAYOUT_CONFIDENCE_HIGH_PCT = 50.0
|
||||
LAYOUT_CONFIDENCE_MEDIUM_PCT = 20.0
|
||||
|
||||
# Делители velocity: 24 мес → масштаб на указанный window
|
||||
_VELOCITY_DIVISORS: dict[str, float] = {
|
||||
"last_month": 24.0,
|
||||
"last_quarter": 8.0,
|
||||
"last_year": 2.0,
|
||||
# Параметры time_window: (PostgreSQL interval string, months divisor для velocity_per_month).
|
||||
# Используются в _INLINE_VELOCITY_SQL — реальный фильтр по report_month.
|
||||
# Fix SF-01: убраны _VELOCITY_DIVISORS, которые делили MV (24 мес) без изменения данных.
|
||||
_TIME_WINDOW_PARAMS: dict[str, tuple[str, float]] = {
|
||||
"last_month": ("1 month", 1.0),
|
||||
"last_quarter": ("3 months", 3.0),
|
||||
"last_year": ("12 months", 12.0),
|
||||
}
|
||||
|
||||
# ── SQL: центроид участка ─────────────────────────────────────────────────────
|
||||
|
|
@ -94,21 +100,38 @@ _COMPETITORS_IN_RADIUS_SQL = text("""
|
|||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||
""")
|
||||
|
||||
# ── SQL: mv_layout_velocity GROUP BY room_bucket ─────────────────────────────
|
||||
# ── SQL: inline velocity из objective_corpus_room_month + mapping ─────────────
|
||||
# Fix SF-01: честный фильтр по report_month вместо деления mv_layout_velocity (24 мес).
|
||||
# Параметры:
|
||||
# :window_interval — PostgreSQL interval string ('1 month', '3 months', '12 months')
|
||||
# :competitor_obj_ids — list[int] obj_id конкурентов в радиусе
|
||||
# CAST(:window_interval AS interval) — psycopg v3 / SQLAlchemy 2.0 safe (не ::interval).
|
||||
|
||||
_VELOCITY_BY_ROOM_SQL = text("""
|
||||
_INLINE_VELOCITY_SQL = text("""
|
||||
SELECT
|
||||
room_bucket,
|
||||
SUM(total_deals_24mo) AS sum_deals,
|
||||
AVG(avg_area_m2) AS avg_area_m2,
|
||||
AVG(avg_price_thousand_rub_per_m2) * 1000.0 AS avg_price_per_m2_rub,
|
||||
array_agg(DISTINCT obj_id) AS competitor_obj_ids,
|
||||
COUNT(DISTINCT obj_id) AS competitor_count,
|
||||
MIN(window_start) AS window_start,
|
||||
MAX(window_end) AS window_end
|
||||
FROM mv_layout_velocity
|
||||
WHERE obj_id = ANY(:obj_ids)
|
||||
GROUP BY room_bucket
|
||||
CASE
|
||||
WHEN crm.room_bucket = 'студия' THEN 'studio'
|
||||
ELSE crm.room_bucket
|
||||
END AS room_bucket,
|
||||
SUM(crm.deals_total_count) AS deals_window,
|
||||
AVG(crm.deals_total_avg_area_m2) AS avg_area_m2,
|
||||
AVG(crm.deals_total_avg_price_thousand_rub_per_m2)
|
||||
* 1000.0 AS avg_price_per_m2_rub,
|
||||
array_agg(DISTINCT cm.domrf_obj_id) AS competitor_obj_ids,
|
||||
COUNT(DISTINCT cm.domrf_obj_id) AS competitor_count,
|
||||
MIN(crm.report_month) AS window_start,
|
||||
MAX(crm.report_month) AS window_end
|
||||
FROM objective_corpus_room_month crm
|
||||
JOIN objective_complex_mapping cm
|
||||
ON cm.objective_complex_name = crm.project_name
|
||||
WHERE crm.report_month >= (NOW() - CAST(:window_interval AS interval))::date
|
||||
AND cm.domrf_obj_id = ANY(:competitor_obj_ids)
|
||||
AND crm.room_bucket IS NOT NULL
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN crm.room_bucket = 'студия' THEN 'studio'
|
||||
ELSE crm.room_bucket
|
||||
END
|
||||
""")
|
||||
|
||||
# ── SQL: supply по (room_bucket, area_bin) за последний снимок ───────────────
|
||||
|
|
@ -207,6 +230,11 @@ def get_best_layouts(
|
|||
quarter = _quarter_from_cad(cad_num)
|
||||
radius_m = request.radius_km * 1000.0
|
||||
|
||||
# time_window → (interval_str, months divisor)
|
||||
window_interval, months_in_window = _TIME_WINDOW_PARAMS.get(
|
||||
request.time_window, ("3 months", 3.0)
|
||||
)
|
||||
|
||||
# ── Step 1: центроид участка ─────────────────────────────────────────────
|
||||
try:
|
||||
coord_row = (
|
||||
|
|
@ -265,12 +293,24 @@ def get_best_layouts(
|
|||
objects_total_in_radius=objects_total_in_radius,
|
||||
)
|
||||
|
||||
# ── Step 3: mv_layout_velocity GROUP BY room_bucket ─────────────────────
|
||||
# ── Step 3: inline velocity из objective_corpus_room_month ──────────────
|
||||
# Fix SF-01: честный фильтр report_month >= NOW() - window_interval.
|
||||
# Разные time_window → разные deals_window, разный mix.
|
||||
try:
|
||||
vel_rows = db.execute(_VELOCITY_BY_ROOM_SQL, {"obj_ids": all_obj_ids}).mappings().all()
|
||||
vel_rows = (
|
||||
db.execute(
|
||||
_INLINE_VELOCITY_SQL,
|
||||
{
|
||||
"window_interval": window_interval,
|
||||
"competitor_obj_ids": all_obj_ids,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"best_layouts: velocity query failed for cad_num=%s obj_count=%d",
|
||||
"best_layouts: inline velocity query failed for cad_num=%s obj_count=%d",
|
||||
cad_num,
|
||||
len(all_obj_ids),
|
||||
)
|
||||
|
|
@ -312,19 +352,20 @@ def get_best_layouts(
|
|||
(str(r["rb"]), str(r["ab"])): int(r["units"]) for r in supply_rows
|
||||
}
|
||||
|
||||
# ── Step 4 + 6: scale velocity и enrichment per row ──────────────────────
|
||||
divisor = _VELOCITY_DIVISORS[request.time_window]
|
||||
# ── Step 4 + 6: velocity из реального окна и enrichment per row ─────────
|
||||
# Fix SF-01: velocity_per_month = deals_window / months_in_window.
|
||||
# deals_window уже отфильтрован по report_month — разные time_window дают разные данные.
|
||||
|
||||
enriched: list[dict[str, Any]] = []
|
||||
window_start: dt.date | None = None
|
||||
window_end: dt.date | None = None
|
||||
|
||||
# Собираем obj_ids с данными в MV (для data_quality)
|
||||
# Собираем obj_ids с данными в objective_corpus_room_month (для data_quality)
|
||||
obj_ids_with_data: set[int] = set()
|
||||
|
||||
for r in vel_rows:
|
||||
room_bucket = str(r["room_bucket"])
|
||||
sum_deals = float(r["sum_deals"]) if r["sum_deals"] is not None else 0.0
|
||||
deals_window = float(r["deals_window"]) if r["deals_window"] is not None else 0.0
|
||||
avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else 0.0
|
||||
price_rub = (
|
||||
float(r["avg_price_per_m2_rub"]) if r["avg_price_per_m2_rub"] is not None else None
|
||||
|
|
@ -336,8 +377,8 @@ def get_best_layouts(
|
|||
|
||||
obj_ids_with_data.update(competitor_obj_ids)
|
||||
|
||||
# Step 4: scale
|
||||
velocity_per_month = round(sum_deals / divisor, 2)
|
||||
# Step 4: честный velocity = сделки за окно / длина окна в месяцах
|
||||
velocity_per_month = round(deals_window / months_in_window, 2)
|
||||
|
||||
# Step 6: area_bin по avg_area (layout_signature.area_bin)
|
||||
ab = area_bin(avg_area) if avg_area > 0 else "<25"
|
||||
|
|
@ -347,7 +388,7 @@ def get_best_layouts(
|
|||
sold_pct: float | None = None
|
||||
is_oversold = False
|
||||
if supply_count > 0:
|
||||
sold_pct_raw = sum_deals / supply_count * 100.0
|
||||
sold_pct_raw = deals_window / supply_count * 100.0
|
||||
is_oversold = sold_pct_raw > 100.0
|
||||
# Clamp at 100%: сделки за 24 мес / текущий snapshot supply несопоставимы.
|
||||
# Значения >100% артефакт окна, не реальная «распроданность».
|
||||
|
|
@ -377,7 +418,7 @@ def get_best_layouts(
|
|||
"signature": sig,
|
||||
"competitor_obj_ids": competitor_obj_ids,
|
||||
"competitor_count": competitor_count,
|
||||
"sum_deals": sum_deals,
|
||||
"sum_deals": deals_window,
|
||||
"velocity_per_month": velocity_per_month,
|
||||
"avg_price_per_m2_rub": price_rub,
|
||||
"avg_area_m2": avg_area,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
Mock-based — не требуют живой БД.
|
||||
Паттерн mock DB: аналогично test_parcel_competitors.py — dependency_overrides[get_db].
|
||||
|
||||
Порядок вызовов в get_best_layouts:
|
||||
Порядок вызовов в get_best_layouts (Fix SF-01 — inline velocity):
|
||||
db.scalar() → MAX(snapshot_date) (только когда vel_rows non-empty)
|
||||
db.execute() calls:
|
||||
1. _PARCEL_CENTROID_SQL → .mappings().first()
|
||||
2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
|
||||
3. _VELOCITY_BY_ROOM_SQL → .mappings().all()
|
||||
3. _INLINE_VELOCITY_SQL → .mappings().all()
|
||||
4. _SUPPLY_BATCH_SQL → .mappings().all() (пропускается если latest_snap is None)
|
||||
"""
|
||||
|
||||
|
|
@ -44,22 +44,22 @@ def _obj_id_row(obj_id: int) -> MagicMock:
|
|||
|
||||
def _vel_row(
|
||||
room_bucket: str = "2",
|
||||
sum_deals: float = 48.0,
|
||||
deals_window: 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."""
|
||||
"""Строка из _INLINE_VELOCITY_SQL (Fix SF-01: deals_window за честный интервал)."""
|
||||
oids = obj_ids if obj_ids is not None else [1]
|
||||
ws = window_start or _TODAY - dt.timedelta(days=730)
|
||||
ws = window_start or _TODAY - dt.timedelta(days=90)
|
||||
we = window_end or _TODAY
|
||||
|
||||
r = MagicMock()
|
||||
r.__getitem__ = lambda self, k: {
|
||||
"room_bucket": room_bucket,
|
||||
"sum_deals": sum_deals,
|
||||
"deals_window": deals_window,
|
||||
"avg_area_m2": avg_area,
|
||||
"avg_price_per_m2_rub": avg_price_rub,
|
||||
"competitor_obj_ids": oids,
|
||||
|
|
@ -178,15 +178,19 @@ def test_empty_competitor_set_returns_low_confidence() -> None:
|
|||
|
||||
|
||||
def test_three_obj_ids_ranking_and_pct_sum_100() -> None:
|
||||
"""3 obj_id, 3 room_buckets — ranking по velocity, sum pct = 100."""
|
||||
"""3 obj_id, 3 room_buckets — ranking по velocity, sum pct = 100.
|
||||
|
||||
last_quarter (3 мес): velocity = deals_window / 3.0
|
||||
studio: 9/3=3.0, 1: 24/3=8.0, 2: 48/3=16.0 → rank1="2"
|
||||
"""
|
||||
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]),
|
||||
_vel_row("studio", deals_window=9.0, avg_area=26.0, obj_ids=[1]),
|
||||
_vel_row("1", deals_window=24.0, avg_area=40.0, obj_ids=[2]),
|
||||
_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[3]),
|
||||
]
|
||||
supply_rows = [
|
||||
_supply_row("studio", "25-40", 20),
|
||||
_supply_row("studio", "<25", 20),
|
||||
_supply_row("1", "40-60", 60),
|
||||
_supply_row("2", "40-60", 80),
|
||||
]
|
||||
|
|
@ -200,7 +204,7 @@ def test_three_obj_ids_ranking_and_pct_sum_100() -> None:
|
|||
body = resp.json()
|
||||
top = body["top_layouts"]
|
||||
assert len(top) == 3
|
||||
# rank 1 = самая высокая velocity (2-комн: 48/8=6.0 per month)
|
||||
# rank 1 = самая высокая velocity (2-комн: 48/3=16.0 per month)
|
||||
assert top[0]["rank"] == 1
|
||||
assert top[0]["room_bucket"] == "2"
|
||||
# все ранги уникальны
|
||||
|
|
@ -234,12 +238,14 @@ def test_exclude_competitor_obj_ids_filter() -> None:
|
|||
|
||||
|
||||
def test_min_velocity_per_month_filters_low_rows() -> None:
|
||||
"""min_velocity_per_month=5 → строки с velocity<5 не попадают в top_layouts."""
|
||||
"""min_velocity_per_month=5 → строки с velocity<5 не попадают в top_layouts.
|
||||
|
||||
last_quarter (3 мес): studio=6/3=2.0 < 5.0 (убран), 1=30/3=10.0 > 5.0 (остаётся).
|
||||
"""
|
||||
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]),
|
||||
_vel_row("studio", deals_window=6.0, obj_ids=[1]),
|
||||
_vel_row("1", deals_window=30.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
|
||||
|
|
|
|||
337
backend/tests/services/site_finder/test_best_layouts.py
Normal file
337
backend/tests/services/site_finder/test_best_layouts.py
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
"""Unit-тесты для get_best_layouts (Fix SF-01: honest time_window velocity).
|
||||
|
||||
Проверяет, что разные time_window → разные deals_window → разный velocity_per_month.
|
||||
|
||||
Mock-стратегия: патчим db.execute с side_effect, повторяя порядок вызовов
|
||||
в get_best_layouts:
|
||||
1. _PARCEL_CENTROID_SQL → .mappings().first()
|
||||
2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
|
||||
3. _INLINE_VELOCITY_SQL → .mappings().all()
|
||||
4. db.scalar() → MAX(snapshot_date) — через .return_value
|
||||
5. _SUPPLY_BATCH_SQL → .mappings().all()
|
||||
|
||||
Ключевые asserts:
|
||||
- last_month (1 мес) → velocity = deals_window / 1.0
|
||||
- last_quarter (3 мес) → velocity = deals_window / 3.0
|
||||
- last_year (12 мес) → velocity = deals_window / 12.0
|
||||
- Разный deals_window при разных time_window → разный mix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.schemas.parcel import BestLayoutsRequest
|
||||
from app.services.site_finder.best_layouts import _TIME_WINDOW_PARAMS, get_best_layouts
|
||||
|
||||
_TODAY = dt.date.today()
|
||||
CAD_NUM = "66:41:0303161:123"
|
||||
|
||||
|
||||
# ── Фабрики mock-строк ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
|
||||
r = MagicMock()
|
||||
r.__getitem__ = lambda self, k: {"center_lon": lon, "center_lat": lat}[k]
|
||||
return r
|
||||
|
||||
|
||||
def _obj_id_row(obj_id: int) -> MagicMock:
|
||||
r = MagicMock()
|
||||
r.__getitem__ = lambda self, k: {"obj_id": obj_id}[k]
|
||||
return r
|
||||
|
||||
|
||||
def _vel_row(
|
||||
room_bucket: str = "2",
|
||||
deals_window: 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:
|
||||
"""Строка из _INLINE_VELOCITY_SQL.
|
||||
|
||||
deals_window — реальные сделки за честное окно (не 24 мес).
|
||||
"""
|
||||
oids = obj_ids if obj_ids is not None else [1]
|
||||
ws = window_start or _TODAY - dt.timedelta(days=90)
|
||||
we = window_end or _TODAY
|
||||
|
||||
r = MagicMock()
|
||||
r.__getitem__ = lambda self, k: {
|
||||
"room_bucket": room_bucket,
|
||||
"deals_window": deals_window,
|
||||
"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:
|
||||
r = MagicMock()
|
||||
r.__getitem__ = lambda self, k: {"rb": rb, "ab": ab, "units": units}[k]
|
||||
return r
|
||||
|
||||
|
||||
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,
|
||||
latest_snap: dt.date | None = None,
|
||||
) -> MagicMock:
|
||||
"""Сконструировать mock Session.
|
||||
|
||||
Порядок db.execute():
|
||||
1. centroid → .mappings().first()
|
||||
2. competitors → .mappings().all()
|
||||
3. velocity → .mappings().all()
|
||||
4. supply → .mappings().all() (только если latest_snap is not None)
|
||||
db.scalar() → latest_snap (MAX snapshot_date).
|
||||
"""
|
||||
db = MagicMock()
|
||||
db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY
|
||||
|
||||
r0 = MagicMock()
|
||||
r0.mappings.return_value.first.return_value = coord
|
||||
|
||||
r1 = MagicMock()
|
||||
r1.mappings.return_value.all.return_value = id_rows or []
|
||||
|
||||
r2 = MagicMock()
|
||||
r2.mappings.return_value.all.return_value = vel_rows or []
|
||||
|
||||
r3 = MagicMock()
|
||||
r3.mappings.return_value.all.return_value = supply_rows or []
|
||||
|
||||
db.execute.side_effect = [r0, r1, r2, r3]
|
||||
return db
|
||||
|
||||
|
||||
def _request(**kwargs) -> BestLayoutsRequest:
|
||||
defaults: dict = {
|
||||
"radius_km": 1.0,
|
||||
"time_window": "last_quarter",
|
||||
"min_velocity_per_month": 0.0,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return BestLayoutsRequest(**defaults)
|
||||
|
||||
|
||||
# ── Тесты TIME_WINDOW_PARAMS ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_time_window_params_keys() -> None:
|
||||
"""Все три time_window определены, months_in_window > 0."""
|
||||
for key in ("last_month", "last_quarter", "last_year"):
|
||||
assert key in _TIME_WINDOW_PARAMS
|
||||
interval_str, months = _TIME_WINDOW_PARAMS[key]
|
||||
assert isinstance(interval_str, str) and len(interval_str) > 0
|
||||
assert months > 0
|
||||
|
||||
|
||||
# ── Тест SF-01: разный deals_window → разный velocity ────────────────────────
|
||||
|
||||
|
||||
def test_last_month_velocity_divisor_1() -> None:
|
||||
"""time_window=last_month: velocity = deals_window / 1.0."""
|
||||
deals = 30.0
|
||||
db = _make_db(
|
||||
coord=_coord_row(),
|
||||
id_rows=[_obj_id_row(1)],
|
||||
vel_rows=[_vel_row("1", deals_window=deals, obj_ids=[1])],
|
||||
)
|
||||
req = _request(time_window="last_month")
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
assert len(resp.top_layouts) == 1
|
||||
assert resp.top_layouts[0].velocity_per_month == pytest.approx(30.0, rel=1e-3)
|
||||
|
||||
|
||||
def test_last_quarter_velocity_divisor_3() -> None:
|
||||
"""time_window=last_quarter: velocity = deals_window / 3.0."""
|
||||
deals = 30.0
|
||||
db = _make_db(
|
||||
coord=_coord_row(),
|
||||
id_rows=[_obj_id_row(1)],
|
||||
vel_rows=[_vel_row("1", deals_window=deals, obj_ids=[1])],
|
||||
)
|
||||
req = _request(time_window="last_quarter")
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
assert len(resp.top_layouts) == 1
|
||||
assert resp.top_layouts[0].velocity_per_month == pytest.approx(10.0, rel=1e-3)
|
||||
|
||||
|
||||
def test_last_year_velocity_divisor_12() -> None:
|
||||
"""time_window=last_year: velocity = deals_window / 12.0."""
|
||||
deals = 60.0
|
||||
db = _make_db(
|
||||
coord=_coord_row(),
|
||||
id_rows=[_obj_id_row(1)],
|
||||
vel_rows=[_vel_row("1", deals_window=deals, obj_ids=[1])],
|
||||
)
|
||||
req = _request(time_window="last_year")
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
assert len(resp.top_layouts) == 1
|
||||
assert resp.top_layouts[0].velocity_per_month == pytest.approx(5.0, rel=1e-3)
|
||||
|
||||
|
||||
def test_different_time_windows_produce_different_velocity() -> None:
|
||||
"""Одни и те же deals_window → разная velocity_per_month для разных time_window.
|
||||
|
||||
Главный acceptance-тест SF-01: time_window влияет на velocity, не только на масштаб.
|
||||
При одном и том же deals_window=30:
|
||||
last_month → 30.0
|
||||
last_quarter → 10.0
|
||||
last_year → 2.5
|
||||
"""
|
||||
deals = 30.0
|
||||
|
||||
velocities: dict[str, float] = {}
|
||||
for tw in ("last_month", "last_quarter", "last_year"):
|
||||
db = _make_db(
|
||||
coord=_coord_row(),
|
||||
id_rows=[_obj_id_row(1)],
|
||||
vel_rows=[_vel_row("2", deals_window=deals, obj_ids=[1])],
|
||||
)
|
||||
req = _request(time_window=tw)
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
assert len(resp.top_layouts) == 1, f"No layouts for {tw}"
|
||||
velocities[tw] = resp.top_layouts[0].velocity_per_month
|
||||
|
||||
# Все три значения различаются
|
||||
vals = list(velocities.values())
|
||||
assert vals[0] != vals[1] != vals[2], f"Velocities must differ: {velocities}"
|
||||
# last_month > last_quarter > last_year (одинаковые deals, разный знаменатель)
|
||||
assert velocities["last_month"] > velocities["last_quarter"] > velocities["last_year"]
|
||||
|
||||
|
||||
# ── Тест: ranking по velocity и sum pct = 100 ────────────────────────────────
|
||||
|
||||
|
||||
def test_ranking_and_pct_sum_100() -> None:
|
||||
"""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", deals_window=9.0, avg_area=26.0, obj_ids=[1]), # 9/3=3.0
|
||||
_vel_row("1", deals_window=24.0, avg_area=40.0, obj_ids=[2]), # 24/3=8.0
|
||||
_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[3]), # 48/3=16.0
|
||||
]
|
||||
supply_rows = [
|
||||
_supply_row("studio", "<25", 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)
|
||||
req = _request(time_window="last_quarter")
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
top = resp.top_layouts
|
||||
assert len(top) == 3
|
||||
# rank 1 = "2" (наибольший velocity 16.0)
|
||||
assert top[0].room_bucket == "2"
|
||||
assert top[0].rank == 1
|
||||
assert top[0].velocity_per_month == pytest.approx(16.0, rel=1e-3)
|
||||
# rank 2 = "1" (8.0)
|
||||
assert top[1].room_bucket == "1"
|
||||
assert top[1].velocity_per_month == pytest.approx(8.0, rel=1e-3)
|
||||
# ранги уникальны
|
||||
assert sorted(t.rank for t in top) == [1, 2, 3]
|
||||
# sum pct = 100
|
||||
mix = resp.recommendation_for_tz.mix
|
||||
assert sum(m.pct for m in mix) == 100
|
||||
|
||||
|
||||
# ── Тест: пустые конкуренты ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_competitors_returns_empty_response() -> None:
|
||||
"""Нет конкурентов в радиусе → пустые top_layouts + confidence=low."""
|
||||
db = _make_db(coord=_coord_row(), id_rows=[], vel_rows=[])
|
||||
req = _request()
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
assert resp.top_layouts == []
|
||||
assert resp.data_quality.confidence == "low"
|
||||
assert resp.recommendation_for_tz.based_on_obj_count == 0
|
||||
|
||||
|
||||
# ── Тест: centroid не найден ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_centroid_not_found_raises_value_error() -> None:
|
||||
"""Геометрия участка не найдена → ValueError."""
|
||||
db = _make_db(coord=None)
|
||||
req = _request()
|
||||
|
||||
with pytest.raises(ValueError, match="не найдена"):
|
||||
get_best_layouts(db, "99:99:9999999:999", req)
|
||||
|
||||
|
||||
# ── Тест: min_velocity фильтрует строки ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_min_velocity_filters_low_rows() -> None:
|
||||
"""min_velocity_per_month=5 → строки с velocity<5 не попадают в top_layouts.
|
||||
|
||||
last_quarter (3 мес):
|
||||
studio: 9 / 3 = 3.0 < 5.0 → отфильтрован
|
||||
1: 24 / 3 = 8.0 > 5.0 → остаётся
|
||||
"""
|
||||
id_rows = [_obj_id_row(1), _obj_id_row(2)]
|
||||
vel_rows = [
|
||||
_vel_row("studio", deals_window=9.0, obj_ids=[1]),
|
||||
_vel_row("1", deals_window=24.0, obj_ids=[2]),
|
||||
]
|
||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
||||
req = _request(time_window="last_quarter", min_velocity_per_month=5.0)
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
top = resp.top_layouts
|
||||
assert len(top) == 1
|
||||
assert top[0].room_bucket == "1"
|
||||
assert top[0].velocity_per_month == pytest.approx(8.0, rel=1e-3)
|
||||
|
||||
|
||||
# ── Тест: exclude_competitor_obj_ids ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_exclude_competitor_obj_ids() -> None:
|
||||
"""exclude_competitor_obj_ids=[20] при единственном конкуренте → пустой ответ."""
|
||||
id_rows = [_obj_id_row(20)]
|
||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[])
|
||||
req = _request(exclude_competitor_obj_ids=[20])
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
assert resp.top_layouts == []
|
||||
assert resp.data_quality.objects_total_in_radius == 1
|
||||
|
||||
|
||||
# ── Тест: total_sold_in_window совпадает с deals_window ──────────────────────
|
||||
|
||||
|
||||
def test_total_sold_in_window_matches_deals_window() -> None:
|
||||
"""total_sold_in_window в TopLayoutRow = deals_window (целое)."""
|
||||
deals = 37.0
|
||||
db = _make_db(
|
||||
coord=_coord_row(),
|
||||
id_rows=[_obj_id_row(5)],
|
||||
vel_rows=[_vel_row("3", deals_window=deals, obj_ids=[5])],
|
||||
)
|
||||
req = _request(time_window="last_quarter")
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
assert len(resp.top_layouts) == 1
|
||||
assert resp.top_layouts[0].total_sold_in_window == int(deals)
|
||||
Loading…
Add table
Reference in a new issue