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 и
|
# 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,12 +543,11 @@ 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(
|
||||||
|
|
@ -532,7 +556,6 @@ def get_best_layouts(
|
||||||
"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()
|
.mappings()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue