fix(best-layouts): per-object latest snapshot для supply → срок продажи/% продано (#1956) #2020
3 changed files with 88 additions and 43 deletions
|
|
@ -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,12 +543,11 @@ 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:
|
||||
# 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(
|
||||
|
|
@ -532,7 +556,6 @@ def get_best_layouts(
|
|||
"center_lon": center_lon,
|
||||
"center_lat": center_lat,
|
||||
"radius_m": radius_m,
|
||||
"latest_snap": latest_snap,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ───────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue