From 5a4408e0ab88637bc37ca21543e07e9226691050 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 12:12:19 +0300 Subject: [PATCH] =?UTF-8?q?fix(sf-01):=20time=5Fwindow=20honest=20velocity?= =?UTF-8?q?=20=E2=80=94=20inline=20SQL=20=D1=81=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=BC=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BC=20report=5Fmonth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше _VELOCITY_DIVISORS делил агрегаты mv_layout_velocity (24 мес) на 4/12 для quarter/year, не меняя реальное окно данных. Теперь inline SQL из objective_corpus_room_month с CAST(:window_interval AS interval). velocity_per_month = deals_window / months_in_window (1.0/3.0/12.0). Разные time_window → разные строки из БД → разный mix/velocity/jk_count. Closes (epic part) #271 item 1 --- .../app/services/site_finder/best_layouts.py | 105 ++++-- .../tests/api/v1/test_parcel_best_layouts.py | 38 +- .../services/site_finder/test_best_layouts.py | 337 ++++++++++++++++++ 3 files changed, 432 insertions(+), 48 deletions(-) create mode 100644 backend/tests/services/site_finder/test_best_layouts.py diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index ac3ca21c..01578244 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -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" @@ -346,7 +387,7 @@ def get_best_layouts( supply_count = supply_map.get((room_bucket, ab), 0) sold_pct: float | None = None if supply_count > 0: - sold_pct = round(sum_deals / supply_count * 100.0, 1) + sold_pct = round(deals_window / supply_count * 100.0, 1) # data window if r["window_start"] is not None: @@ -372,7 +413,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, diff --git a/backend/tests/api/v1/test_parcel_best_layouts.py b/backend/tests/api/v1/test_parcel_best_layouts.py index 8b391f90..34591aa7 100644 --- a/backend/tests/api/v1/test_parcel_best_layouts.py +++ b/backend/tests/api/v1/test_parcel_best_layouts.py @@ -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 diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py new file mode 100644 index 00000000..bfb95af8 --- /dev/null +++ b/backend/tests/services/site_finder/test_best_layouts.py @@ -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)