From 0acd72a32538f738128f019e97c7674b67436aec Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 27 Jun 2026 23:24:28 +0500 Subject: [PATCH] fix(best-layouts): per-object latest snapshot for supply (#1956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _SUPPLY_BATCH_SQL джойнил domrf_kn_flats по ОДНОЙ глобальной дате (f.snapshot_date = MAX(snapshot_date) по всей таблице). Но domrf_kn_flats — ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в свой день. На единственной глобал-max дате присутствует обычно 1 объект → у остальных 0 квартир → supply_units_in_radius=0 для всех строк 4.2 Планировки → frontend показывал «Срок продажи 0 мес» и «% продано —». Регрессия от #1944 (objects-first дедуп snapshot'ов объектов, который сам по себе корректен). Фикс: flats_latest CTE (DISTINCT ON (obj_id) ... ORDER BY obj_id, snapshot_date DESC, id DESC) берёт для КАЖДОГО obj_id его собственный последний снимок и джойнится к nearby. objects-first MATERIALIZED дедуп (#1944) сохранён → fan-out по снимкам не возвращается. Глобальный db.scalar(MAX(snapshot_date)) + :latest_snap bind удалены. Прод (66:41:0205010:287, r=1км, 9 объектов): supply 0 (global-max) → 2675 (per-object, 4 объекта имеют flats на разных датах 2026-05-17/05-05; ни один не на глобал-max 2026-06-22). Данные flats частично сломаны (#1945, отдельно), но фикс корректно двигает supply с 0 к реальным per-object числам. Тесты: новый guard test_supply_joins_flats_per_object_latest_snapshot; обновлены mock-фабрики (db.scalar больше не вызывается). --- .../app/services/site_finder/best_layouts.py | 73 ++++++++++++------- .../tests/api/v1/test_parcel_best_layouts.py | 17 ++--- .../services/site_finder/test_best_layouts.py | 41 +++++++++-- 3 files changed, 88 insertions(+), 43 deletions(-) diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index a92be650..c6da2e1d 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -236,6 +236,24 @@ _INLINE_VELOCITY_SQL = text(""" # # MATERIALIZED у nearby — обязателен: без него планировщик инлайнит CTE и # возвращается к flats-first seq scan (domrf_kn_objects не имеет GIST-индекса). +# +# === PER-OBJECT snapshot fix (#1956, regression от #1944) === +# domrf_kn_flats — это ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в СВОЙ день, +# UNIQUE-снимок (obj_id, snapshot_date). Прежний джойн фильтровал flats по ОДНОЙ +# глобальной дате `f.snapshot_date = MAX(snapshot_date по всей таблице)`. На этой +# единственной глобал-max дате присутствует обычно лишь 1 объект (скрейпнутый +# последним) → у остальных объектов в радиусе 0 квартир → supply_units_in_radius=0 +# для ВСЕХ строк → frontend salesPeriodMonths(0,v)=0 («Срок продажи 0 мес»), +# sold_pct_of_supply=null («% продано —»). Прод-факт (66:41:0205010:287): 9 +# объектов в радиусе = 5344 квартир суммарно, но 0 на глобал-max снимке. +# Фикс: flats_latest берёт для КАЖДОГО obj_id его СОБСТВЕННЫЙ последний снимок +# (DISTINCT ON (obj_id) ... ORDER BY obj_id, snapshot_date DESC, id DESC) и +# джойнится к nearby по obj_id → supply = реальные per-object счётчики (~2675). +# Дедуп nearby (objects-first MATERIALIZED, #1944) сохранён — flats_latest даёт +# ровно одну строку на (obj_id) → fan-out по снимкам flats невозможен. +# NB: данные domrf_kn flats частично сломаны (#1945 — per-object снимки низкие/ +# несвежие пока loader не починен), поэтому supply всё ещё ниже здорового +# состояния, но движется с 0 к реальным per-object числам. Loader — НЕ здесь. _SUPPLY_BATCH_SQL = text(""" WITH latest_obj AS ( SELECT DISTINCT ON (o.obj_id) @@ -255,6 +273,13 @@ _SUPPLY_BATCH_SQL = text(""" )::geography, CAST(:radius_m AS float) ) + ), + flats_latest AS ( + SELECT DISTINCT ON (f.obj_id) + f.obj_id, f.snapshot_date + FROM domrf_kn_flats f + JOIN nearby n ON n.obj_id = f.obj_id + ORDER BY f.obj_id, f.snapshot_date DESC, f.id DESC ) SELECT CASE @@ -273,10 +298,10 @@ _SUPPLY_BATCH_SQL = text(""" ELSE '100+' END AS ab, COUNT(*) AS units - FROM nearby n + FROM flats_latest fl JOIN domrf_kn_flats f - ON f.obj_id = n.obj_id - AND f.snapshot_date = CAST(:latest_snap AS date) + ON f.obj_id = fl.obj_id + AND f.snapshot_date = fl.snapshot_date GROUP BY rb, ab """) @@ -518,29 +543,27 @@ def get_best_layouts( ) # ── Step 5: supply side (батч-запрос) ──────────────────────────────────── - # Pre-compute последний snapshot_date один раз — избегаем subquery на каждый scan. - latest_snap: dt.date | None = db.scalar(text("SELECT MAX(snapshot_date) FROM domrf_kn_flats")) - if latest_snap is None: - logger.warning("best_layouts: domrf_kn_flats пустой (нет snapshot_date), supply=0 fallback") - supply_rows = [] - else: - try: - supply_rows = ( - db.execute( - _SUPPLY_BATCH_SQL, - { - "center_lon": center_lon, - "center_lat": center_lat, - "radius_m": radius_m, - "latest_snap": latest_snap, - }, - ) - .mappings() - .all() + # Fix #1956: supply берёт ПЕР-ОБЪЕКТНЫЙ последний снимок из domrf_kn_flats + # (каждый ЖК скрейпится в свой день). Прежний единый глобальный + # MAX(snapshot_date) давал 0 квартир почти у всех объектов → «Срок продажи + # 0 мес» / «% продано —». Per-object latest snapshot встроен в _SUPPLY_BATCH_SQL + # (flats_latest CTE) → отдельный pre-compute MAX больше не нужен. + try: + supply_rows = ( + db.execute( + _SUPPLY_BATCH_SQL, + { + "center_lon": center_lon, + "center_lat": center_lat, + "radius_m": radius_m, + }, ) - except Exception: - logger.warning("best_layouts: supply query failed, supply=0 fallback") - supply_rows = [] + .mappings() + .all() + ) + except Exception: + logger.warning("best_layouts: supply query failed, supply=0 fallback") + supply_rows = [] supply_map: dict[tuple[str, str], int] = { (str(r["rb"]), str(r["ab"])): int(r["units"]) for r in supply_rows diff --git a/backend/tests/api/v1/test_parcel_best_layouts.py b/backend/tests/api/v1/test_parcel_best_layouts.py index a649d32a..478cd332 100644 --- a/backend/tests/api/v1/test_parcel_best_layouts.py +++ b/backend/tests/api/v1/test_parcel_best_layouts.py @@ -4,12 +4,14 @@ Mock-based — не требуют живой БД. Паттерн mock DB: аналогично test_parcel_competitors.py — dependency_overrides[get_db]. Порядок вызовов в 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. _INLINE_VELOCITY_SQL → .mappings().all() - 4. _SUPPLY_BATCH_SQL → .mappings().all() (пропускается если latest_snap is None) + 4. _SUPPLY_BATCH_SQL → .mappings().all() + +Fix #1956: supply берёт per-object последний snapshot внутри _SUPPLY_BATCH_SQL +(flats_latest CTE) — отдельного db.scalar(MAX(snapshot_date)) больше нет. """ from __future__ import annotations @@ -85,22 +87,19 @@ def _make_db( 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.scalar() возвращает latest_snap (MAX snapshot_date) — вызывается перед supply. + Fix #1956: per-object последний снимок считается внутри _SUPPLY_BATCH_SQL, + отдельного db.scalar(MAX(snapshot_date)) больше нет. Порядок db.execute(): 1. centroid → .mappings().first() 2. competitors-in-radius → .mappings().all() 3. velocity → .mappings().all() - 4. supply → .mappings().all() (только если latest_snap is not None) + 4. supply → .mappings().all() """ db = MagicMock() - # db.scalar — pre-computed MAX(snapshot_date) для supply query - db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY - results: list[MagicMock] = [] # 1: centroid @@ -378,7 +377,6 @@ def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None: id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows, - latest_snap=dt.date.today(), ) from app.core.db import get_db @@ -407,7 +405,6 @@ def test_sold_pct_below_100_is_not_oversold() -> None: id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows, - latest_snap=dt.date.today(), ) 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 index 22e679d4..2174b074 100644 --- a/backend/tests/services/site_finder/test_best_layouts.py +++ b/backend/tests/services/site_finder/test_best_layouts.py @@ -7,8 +7,10 @@ Mock-стратегия: патчим db.execute с side_effect, повторя 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() + 4. _SUPPLY_BATCH_SQL → .mappings().all() + +Fix #1956: supply берёт per-object последний snapshot внутри _SUPPLY_BATCH_SQL +(flats_latest CTE), отдельного db.scalar(MAX(snapshot_date)) больше нет. Ключевые asserts: - last_month (1 мес) → velocity = deals_window / 1.0 @@ -93,19 +95,17 @@ def _make_db( 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(): + Порядок db.execute() (Fix #1956: db.scalar(MAX(snapshot_date)) удалён — + per-object последний снимок считается внутри _SUPPLY_BATCH_SQL): 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). + 4. supply → .mappings().all() """ 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 @@ -412,10 +412,35 @@ def test_supply_dedups_objects_to_latest_snapshot() -> None: # objects-first CTE материализован — иначе планнер инлайнит → flats-first seq scan assert "MATERIALIZED" in sql_text, "nearby CTE должен быть MATERIALIZED (план objects-first)" # flats джойнятся к дедуплицированному nearby, НЕ напрямую к raw objects - assert "FROM nearby" in sql_text assert "JOIN domrf_kn_objects" not in sql_text, "прямой flats→objects join = fan-out по снимкам" +def test_supply_joins_flats_per_object_latest_snapshot() -> None: + """Fix #1956 regression guard: supply джойнит flats по ПЕР-ОБЪЕКТНОМУ снимку. + + domrf_kn_flats — ПО-ОБЪЕКТНЫЙ time-series (каждый ЖК скрейпится в свой день). + Регрессия #1944 джойнила flats по ОДНОЙ глобальной дате + `f.snapshot_date = MAX(snapshot_date по всей таблице)` → на ней присутствует + обычно 1 объект → supply_units_in_radius=0 для всех строк → frontend + «Срок продажи 0 мес» / «% продано —». Фикс: flats_latest CTE с + DISTINCT ON (obj_id) ... ORDER BY obj_id, snapshot_date DESC — у каждого + объекта свой последний снимок. Этот guard ловит возврат глобал-max джойна. + """ + from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL + + sql_text = str(_SUPPLY_BATCH_SQL.text) + # per-object последний снимок flats + assert "flats_latest" in sql_text, "нет flats_latest CTE → supply снова на глобал-max снимке" + assert ( + "DISTINCT ON (f.obj_id)" in sql_text + ), "flats не дедупятся per-object → нет per-obj снимка" + # НЕ должно быть джойна по единой внешней дате-параметру (регрессия #1944) + assert ":latest_snap" not in sql_text, "глобальный :latest_snap вернулся → supply=0 регрессия" + assert ( + "MAX(snapshot_date)" not in sql_text + ), "глобальный MAX(snapshot_date) → supply=0 регрессия" + + # ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────