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 — центроид участка
|
cad_parcels_geom / cad_quarters_geom — центроид участка
|
||||||
domrf_kn_objects — ЖК в радиусе (latitude/longitude → geography)
|
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)
|
domrf_kn_flats — supply count по (room_bucket, area_bin)
|
||||||
|
|
||||||
Алгоритм:
|
Алгоритм:
|
||||||
Step 1: центроид участка (cad_parcels_geom → cad_quarters_geom fallback).
|
Step 1: центроид участка (cad_parcels_geom → cad_quarters_geom fallback).
|
||||||
Step 2: obj_id конкурентов в радиусе (domrf_kn_objects + фильтры).
|
Step 2: obj_id конкурентов в радиусе (domrf_kn_objects + фильтры).
|
||||||
Step 3: JOIN mv_layout_velocity GROUP BY room_bucket.
|
Step 3: inline SQL из objective_corpus_room_month с честным WHERE report_month фильтром.
|
||||||
Step 4: scale velocity по time_window.
|
Step 4: velocity_per_month = deals_window / months_in_window (честный time_window).
|
||||||
Step 5: supply side из domrf_kn_flats — один батч-запрос.
|
Step 5: supply side из domrf_kn_flats — один батч-запрос.
|
||||||
Step 6: per-row signature + sold_pct.
|
Step 6: per-row signature + sold_pct.
|
||||||
Step 7: фильтр min_velocity + sort + rank.
|
Step 7: фильтр min_velocity + sort + rank.
|
||||||
Step 8: build recommendation_for_tz (unit-mix, price, rationale).
|
Step 8: build recommendation_for_tz (unit-mix, price, rationale).
|
||||||
Step 9: data_quality (coverage + confidence).
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -44,11 +48,13 @@ logger = logging.getLogger(__name__)
|
||||||
LAYOUT_CONFIDENCE_HIGH_PCT = 50.0
|
LAYOUT_CONFIDENCE_HIGH_PCT = 50.0
|
||||||
LAYOUT_CONFIDENCE_MEDIUM_PCT = 20.0
|
LAYOUT_CONFIDENCE_MEDIUM_PCT = 20.0
|
||||||
|
|
||||||
# Делители velocity: 24 мес → масштаб на указанный window
|
# Параметры time_window: (PostgreSQL interval string, months divisor для velocity_per_month).
|
||||||
_VELOCITY_DIVISORS: dict[str, float] = {
|
# Используются в _INLINE_VELOCITY_SQL — реальный фильтр по report_month.
|
||||||
"last_month": 24.0,
|
# Fix SF-01: убраны _VELOCITY_DIVISORS, которые делили MV (24 мес) без изменения данных.
|
||||||
"last_quarter": 8.0,
|
_TIME_WINDOW_PARAMS: dict[str, tuple[str, float]] = {
|
||||||
"last_year": 2.0,
|
"last_month": ("1 month", 1.0),
|
||||||
|
"last_quarter": ("3 months", 3.0),
|
||||||
|
"last_year": ("12 months", 12.0),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── SQL: центроид участка ─────────────────────────────────────────────────────
|
# ── SQL: центроид участка ─────────────────────────────────────────────────────
|
||||||
|
|
@ -94,21 +100,38 @@ _COMPETITORS_IN_RADIUS_SQL = text("""
|
||||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
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
|
SELECT
|
||||||
room_bucket,
|
CASE
|
||||||
SUM(total_deals_24mo) AS sum_deals,
|
WHEN crm.room_bucket = 'студия' THEN 'studio'
|
||||||
AVG(avg_area_m2) AS avg_area_m2,
|
ELSE crm.room_bucket
|
||||||
AVG(avg_price_thousand_rub_per_m2) * 1000.0 AS avg_price_per_m2_rub,
|
END AS room_bucket,
|
||||||
array_agg(DISTINCT obj_id) AS competitor_obj_ids,
|
SUM(crm.deals_total_count) AS deals_window,
|
||||||
COUNT(DISTINCT obj_id) AS competitor_count,
|
AVG(crm.deals_total_avg_area_m2) AS avg_area_m2,
|
||||||
MIN(window_start) AS window_start,
|
AVG(crm.deals_total_avg_price_thousand_rub_per_m2)
|
||||||
MAX(window_end) AS window_end
|
* 1000.0 AS avg_price_per_m2_rub,
|
||||||
FROM mv_layout_velocity
|
array_agg(DISTINCT cm.domrf_obj_id) AS competitor_obj_ids,
|
||||||
WHERE obj_id = ANY(:obj_ids)
|
COUNT(DISTINCT cm.domrf_obj_id) AS competitor_count,
|
||||||
GROUP BY room_bucket
|
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) за последний снимок ───────────────
|
# ── SQL: supply по (room_bucket, area_bin) за последний снимок ───────────────
|
||||||
|
|
@ -207,6 +230,11 @@ def get_best_layouts(
|
||||||
quarter = _quarter_from_cad(cad_num)
|
quarter = _quarter_from_cad(cad_num)
|
||||||
radius_m = request.radius_km * 1000.0
|
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: центроид участка ─────────────────────────────────────────────
|
# ── Step 1: центроид участка ─────────────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
coord_row = (
|
coord_row = (
|
||||||
|
|
@ -265,12 +293,24 @@ def get_best_layouts(
|
||||||
objects_total_in_radius=objects_total_in_radius,
|
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:
|
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:
|
except Exception:
|
||||||
logger.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,
|
cad_num,
|
||||||
len(all_obj_ids),
|
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
|
(str(r["rb"]), str(r["ab"])): int(r["units"]) for r in supply_rows
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Step 4 + 6: scale velocity и enrichment per row ──────────────────────
|
# ── Step 4 + 6: velocity из реального окна и enrichment per row ─────────
|
||||||
divisor = _VELOCITY_DIVISORS[request.time_window]
|
# Fix SF-01: velocity_per_month = deals_window / months_in_window.
|
||||||
|
# deals_window уже отфильтрован по report_month — разные time_window дают разные данные.
|
||||||
|
|
||||||
enriched: list[dict[str, Any]] = []
|
enriched: list[dict[str, Any]] = []
|
||||||
window_start: dt.date | None = None
|
window_start: dt.date | None = None
|
||||||
window_end: 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()
|
obj_ids_with_data: set[int] = set()
|
||||||
|
|
||||||
for r in vel_rows:
|
for r in vel_rows:
|
||||||
room_bucket = str(r["room_bucket"])
|
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
|
avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else 0.0
|
||||||
price_rub = (
|
price_rub = (
|
||||||
float(r["avg_price_per_m2_rub"]) if r["avg_price_per_m2_rub"] is not None else None
|
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)
|
obj_ids_with_data.update(competitor_obj_ids)
|
||||||
|
|
||||||
# Step 4: scale
|
# Step 4: честный velocity = сделки за окно / длина окна в месяцах
|
||||||
velocity_per_month = round(sum_deals / divisor, 2)
|
velocity_per_month = round(deals_window / months_in_window, 2)
|
||||||
|
|
||||||
# Step 6: area_bin по avg_area (layout_signature.area_bin)
|
# Step 6: area_bin по avg_area (layout_signature.area_bin)
|
||||||
ab = area_bin(avg_area) if avg_area > 0 else "<25"
|
ab = area_bin(avg_area) if avg_area > 0 else "<25"
|
||||||
|
|
@ -347,7 +388,7 @@ def get_best_layouts(
|
||||||
sold_pct: float | None = None
|
sold_pct: float | None = None
|
||||||
is_oversold = False
|
is_oversold = False
|
||||||
if supply_count > 0:
|
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
|
is_oversold = sold_pct_raw > 100.0
|
||||||
# Clamp at 100%: сделки за 24 мес / текущий snapshot supply несопоставимы.
|
# Clamp at 100%: сделки за 24 мес / текущий snapshot supply несопоставимы.
|
||||||
# Значения >100% артефакт окна, не реальная «распроданность».
|
# Значения >100% артефакт окна, не реальная «распроданность».
|
||||||
|
|
@ -377,7 +418,7 @@ def get_best_layouts(
|
||||||
"signature": sig,
|
"signature": sig,
|
||||||
"competitor_obj_ids": competitor_obj_ids,
|
"competitor_obj_ids": competitor_obj_ids,
|
||||||
"competitor_count": competitor_count,
|
"competitor_count": competitor_count,
|
||||||
"sum_deals": sum_deals,
|
"sum_deals": deals_window,
|
||||||
"velocity_per_month": velocity_per_month,
|
"velocity_per_month": velocity_per_month,
|
||||||
"avg_price_per_m2_rub": price_rub,
|
"avg_price_per_m2_rub": price_rub,
|
||||||
"avg_area_m2": avg_area,
|
"avg_area_m2": avg_area,
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@
|
||||||
Mock-based — не требуют живой БД.
|
Mock-based — не требуют живой БД.
|
||||||
Паттерн mock DB: аналогично test_parcel_competitors.py — dependency_overrides[get_db].
|
Паттерн 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.scalar() → MAX(snapshot_date) (только когда vel_rows non-empty)
|
||||||
db.execute() calls:
|
db.execute() calls:
|
||||||
1. _PARCEL_CENTROID_SQL → .mappings().first()
|
1. _PARCEL_CENTROID_SQL → .mappings().first()
|
||||||
2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
|
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)
|
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(
|
def _vel_row(
|
||||||
room_bucket: str = "2",
|
room_bucket: str = "2",
|
||||||
sum_deals: float = 48.0,
|
deals_window: float = 48.0,
|
||||||
avg_area: float = 55.0,
|
avg_area: float = 55.0,
|
||||||
avg_price_rub: float | None = 120000.0,
|
avg_price_rub: float | None = 120000.0,
|
||||||
obj_ids: list[int] | None = None,
|
obj_ids: list[int] | None = None,
|
||||||
window_start: dt.date | None = None,
|
window_start: dt.date | None = None,
|
||||||
window_end: dt.date | None = None,
|
window_end: dt.date | None = None,
|
||||||
) -> MagicMock:
|
) -> 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]
|
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
|
we = window_end or _TODAY
|
||||||
|
|
||||||
r = MagicMock()
|
r = MagicMock()
|
||||||
r.__getitem__ = lambda self, k: {
|
r.__getitem__ = lambda self, k: {
|
||||||
"room_bucket": room_bucket,
|
"room_bucket": room_bucket,
|
||||||
"sum_deals": sum_deals,
|
"deals_window": deals_window,
|
||||||
"avg_area_m2": avg_area,
|
"avg_area_m2": avg_area,
|
||||||
"avg_price_per_m2_rub": avg_price_rub,
|
"avg_price_per_m2_rub": avg_price_rub,
|
||||||
"competitor_obj_ids": oids,
|
"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:
|
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)]
|
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
|
||||||
vel_rows = [
|
vel_rows = [
|
||||||
_vel_row("studio", sum_deals=8.0, avg_area=26.0, obj_ids=[1]),
|
_vel_row("studio", deals_window=9.0, avg_area=26.0, obj_ids=[1]),
|
||||||
_vel_row("1", sum_deals=32.0, avg_area=40.0, obj_ids=[2]),
|
_vel_row("1", deals_window=24.0, avg_area=40.0, obj_ids=[2]),
|
||||||
_vel_row("2", sum_deals=48.0, avg_area=55.0, obj_ids=[3]),
|
_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[3]),
|
||||||
]
|
]
|
||||||
supply_rows = [
|
supply_rows = [
|
||||||
_supply_row("studio", "25-40", 20),
|
_supply_row("studio", "<25", 20),
|
||||||
_supply_row("1", "40-60", 60),
|
_supply_row("1", "40-60", 60),
|
||||||
_supply_row("2", "40-60", 80),
|
_supply_row("2", "40-60", 80),
|
||||||
]
|
]
|
||||||
|
|
@ -200,7 +204,7 @@ def test_three_obj_ids_ranking_and_pct_sum_100() -> None:
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
top = body["top_layouts"]
|
top = body["top_layouts"]
|
||||||
assert len(top) == 3
|
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]["rank"] == 1
|
||||||
assert top[0]["room_bucket"] == "2"
|
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:
|
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)]
|
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_rows = [
|
||||||
_vel_row("studio", sum_deals=16.0, obj_ids=[1]),
|
_vel_row("studio", deals_window=6.0, obj_ids=[1]),
|
||||||
_vel_row("1", sum_deals=80.0, obj_ids=[2]),
|
_vel_row("1", deals_window=30.0, obj_ids=[2]),
|
||||||
]
|
]
|
||||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
||||||
from app.core.db import get_db
|
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