Merge pull request 'fix(best-layouts): per-object latest snapshot для supply → срок продажи/% продано (#1956)' (#2020) from fix/best-layouts-per-object-snapshot-1956 into main
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m49s
Deploy / build-worker (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m26s
Deploy / changes (push) Successful in 7s

This commit is contained in:
bot-backend 2026-06-27 18:43:19 +00:00
commit 96d5794b5c
3 changed files with 88 additions and 43 deletions

View file

@ -236,6 +236,24 @@ _INLINE_VELOCITY_SQL = text("""
# #
# MATERIALIZED у nearby — обязателен: без него планировщик инлайнит CTE и # MATERIALIZED у nearby — обязателен: без него планировщик инлайнит CTE и
# возвращается к flats-first seq scan (domrf_kn_objects не имеет GIST-индекса). # возвращается к 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(""" _SUPPLY_BATCH_SQL = text("""
WITH latest_obj AS ( WITH latest_obj AS (
SELECT DISTINCT ON (o.obj_id) SELECT DISTINCT ON (o.obj_id)
@ -255,6 +273,13 @@ _SUPPLY_BATCH_SQL = text("""
)::geography, )::geography,
CAST(:radius_m AS float) 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 SELECT
CASE CASE
@ -273,10 +298,10 @@ _SUPPLY_BATCH_SQL = text("""
ELSE '100+' ELSE '100+'
END AS ab, END AS ab,
COUNT(*) AS units COUNT(*) AS units
FROM nearby n FROM flats_latest fl
JOIN domrf_kn_flats f JOIN domrf_kn_flats f
ON f.obj_id = n.obj_id ON f.obj_id = fl.obj_id
AND f.snapshot_date = CAST(:latest_snap AS date) AND f.snapshot_date = fl.snapshot_date
GROUP BY rb, ab GROUP BY rb, ab
""") """)
@ -518,29 +543,27 @@ def get_best_layouts(
) )
# ── Step 5: supply side (батч-запрос) ──────────────────────────────────── # ── Step 5: supply side (батч-запрос) ────────────────────────────────────
# Pre-compute последний snapshot_date один раз — избегаем subquery на каждый scan. # Fix #1956: supply берёт ПЕР-ОБЪЕКТНЫЙ последний снимок из domrf_kn_flats
latest_snap: dt.date | None = db.scalar(text("SELECT MAX(snapshot_date) FROM domrf_kn_flats")) # (каждый ЖК скрейпится в свой день). Прежний единый глобальный
if latest_snap is None: # MAX(snapshot_date) давал 0 квартир почти у всех объектов → «Срок продажи
logger.warning("best_layouts: domrf_kn_flats пустой (нет snapshot_date), supply=0 fallback") # 0 мес» / «% продано —». Per-object latest snapshot встроен в _SUPPLY_BATCH_SQL
supply_rows = [] # (flats_latest CTE) → отдельный pre-compute MAX больше не нужен.
else: try:
try: supply_rows = (
supply_rows = ( db.execute(
db.execute( _SUPPLY_BATCH_SQL,
_SUPPLY_BATCH_SQL, {
{ "center_lon": center_lon,
"center_lon": center_lon, "center_lat": center_lat,
"center_lat": center_lat, "radius_m": radius_m,
"radius_m": radius_m, },
"latest_snap": latest_snap,
},
)
.mappings()
.all()
) )
except Exception: .mappings()
logger.warning("best_layouts: supply query failed, supply=0 fallback") .all()
supply_rows = [] )
except Exception:
logger.warning("best_layouts: supply query failed, supply=0 fallback")
supply_rows = []
supply_map: dict[tuple[str, str], int] = { supply_map: dict[tuple[str, str], int] = {
(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

View file

@ -4,12 +4,14 @@ 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 (Fix SF-01 inline velocity): Порядок вызовов в get_best_layouts (Fix SF-01 inline velocity):
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. _INLINE_VELOCITY_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 from __future__ import annotations
@ -85,22 +87,19 @@ def _make_db(
id_rows: list[MagicMock] | None = None, id_rows: list[MagicMock] | None = None,
vel_rows: list[MagicMock] | None = None, vel_rows: list[MagicMock] | None = None,
supply_rows: list[MagicMock] | None = None, supply_rows: list[MagicMock] | None = None,
latest_snap: dt.date | None = None,
) -> MagicMock: ) -> MagicMock:
"""Сконструировать mock Session. """Сконструировать mock Session.
db.scalar() возвращает latest_snap (MAX snapshot_date) вызывается перед supply. Fix #1956: per-object последний снимок считается внутри _SUPPLY_BATCH_SQL,
отдельного db.scalar(MAX(snapshot_date)) больше нет.
Порядок db.execute(): Порядок db.execute():
1. centroid .mappings().first() 1. centroid .mappings().first()
2. competitors-in-radius .mappings().all() 2. competitors-in-radius .mappings().all()
3. velocity .mappings().all() 3. velocity .mappings().all()
4. supply .mappings().all() (только если latest_snap is not None) 4. supply .mappings().all()
""" """
db = MagicMock() 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] = [] results: list[MagicMock] = []
# 1: centroid # 1: centroid
@ -378,7 +377,6 @@ def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None:
id_rows=id_rows, id_rows=id_rows,
vel_rows=vel_rows, vel_rows=vel_rows,
supply_rows=supply_rows, supply_rows=supply_rows,
latest_snap=dt.date.today(),
) )
from app.core.db import get_db 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, id_rows=id_rows,
vel_rows=vel_rows, vel_rows=vel_rows,
supply_rows=supply_rows, supply_rows=supply_rows,
latest_snap=dt.date.today(),
) )
from app.core.db import get_db from app.core.db import get_db

View file

@ -7,8 +7,10 @@ Mock-стратегия: патчим db.execute с side_effect, повторя
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. _INLINE_VELOCITY_SQL .mappings().all() 3. _INLINE_VELOCITY_SQL .mappings().all()
4. db.scalar() MAX(snapshot_date) через .return_value 4. _SUPPLY_BATCH_SQL .mappings().all()
5. _SUPPLY_BATCH_SQL .mappings().all()
Fix #1956: supply берёт per-object последний snapshot внутри _SUPPLY_BATCH_SQL
(flats_latest CTE), отдельного db.scalar(MAX(snapshot_date)) больше нет.
Ключевые asserts: Ключевые asserts:
- last_month (1 мес) velocity = deals_window / 1.0 - last_month (1 мес) velocity = deals_window / 1.0
@ -93,19 +95,17 @@ def _make_db(
id_rows: list[MagicMock] | None = None, id_rows: list[MagicMock] | None = None,
vel_rows: list[MagicMock] | None = None, vel_rows: list[MagicMock] | None = None,
supply_rows: list[MagicMock] | None = None, supply_rows: list[MagicMock] | None = None,
latest_snap: dt.date | None = None,
) -> MagicMock: ) -> MagicMock:
"""Сконструировать mock Session. """Сконструировать mock Session.
Порядок db.execute(): Порядок db.execute() (Fix #1956: db.scalar(MAX(snapshot_date)) удалён —
per-object последний снимок считается внутри _SUPPLY_BATCH_SQL):
1. centroid .mappings().first() 1. centroid .mappings().first()
2. competitors .mappings().all() 2. competitors .mappings().all()
3. velocity .mappings().all() 3. velocity .mappings().all()
4. supply .mappings().all() (только если latest_snap is not None) 4. supply .mappings().all()
db.scalar() latest_snap (MAX snapshot_date).
""" """
db = MagicMock() db = MagicMock()
db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY
r0 = MagicMock() r0 = MagicMock()
r0.mappings.return_value.first.return_value = coord 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 # objects-first CTE материализован — иначе планнер инлайнит → flats-first seq scan
assert "MATERIALIZED" in sql_text, "nearby CTE должен быть MATERIALIZED (план objects-first)" assert "MATERIALIZED" in sql_text, "nearby CTE должен быть MATERIALIZED (план objects-first)"
# flats джойнятся к дедуплицированному nearby, НЕ напрямую к raw objects # flats джойнятся к дедуплицированному nearby, НЕ напрямую к raw objects
assert "FROM nearby" in sql_text
assert "JOIN domrf_kn_objects" not in sql_text, "прямой flats→objects join = fan-out по снимкам" 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) ─────────────────────────── # ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────