Compare commits
No commits in common. "96d5794b5c92cccfcf7c209bbced7dfa20ad2edc" and "e39fb86a9b17fbc02354279c5108d8a35d95aec4" have entirely different histories.
96d5794b5c
...
e39fb86a9b
3 changed files with 43 additions and 88 deletions
|
|
@ -236,24 +236,6 @@ _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)
|
||||||
|
|
@ -273,13 +255,6 @@ _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
|
||||||
|
|
@ -298,10 +273,10 @@ _SUPPLY_BATCH_SQL = text("""
|
||||||
ELSE '100+'
|
ELSE '100+'
|
||||||
END AS ab,
|
END AS ab,
|
||||||
COUNT(*) AS units
|
COUNT(*) AS units
|
||||||
FROM flats_latest fl
|
FROM nearby n
|
||||||
JOIN domrf_kn_flats f
|
JOIN domrf_kn_flats f
|
||||||
ON f.obj_id = fl.obj_id
|
ON f.obj_id = n.obj_id
|
||||||
AND f.snapshot_date = fl.snapshot_date
|
AND f.snapshot_date = CAST(:latest_snap AS date)
|
||||||
GROUP BY rb, ab
|
GROUP BY rb, ab
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
@ -543,27 +518,29 @@ def get_best_layouts(
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Step 5: supply side (батч-запрос) ────────────────────────────────────
|
# ── Step 5: supply side (батч-запрос) ────────────────────────────────────
|
||||||
# Fix #1956: supply берёт ПЕР-ОБЪЕКТНЫЙ последний снимок из domrf_kn_flats
|
# Pre-compute последний snapshot_date один раз — избегаем subquery на каждый scan.
|
||||||
# (каждый ЖК скрейпится в свой день). Прежний единый глобальный
|
latest_snap: dt.date | None = db.scalar(text("SELECT MAX(snapshot_date) FROM domrf_kn_flats"))
|
||||||
# MAX(snapshot_date) давал 0 квартир почти у всех объектов → «Срок продажи
|
if latest_snap is None:
|
||||||
# 0 мес» / «% продано —». Per-object latest snapshot встроен в _SUPPLY_BATCH_SQL
|
logger.warning("best_layouts: domrf_kn_flats пустой (нет snapshot_date), supply=0 fallback")
|
||||||
# (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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.mappings()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("best_layouts: supply query failed, supply=0 fallback")
|
|
||||||
supply_rows = []
|
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()
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,12 @@ 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()
|
4. _SUPPLY_BATCH_SQL → .mappings().all() (пропускается если latest_snap is None)
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -87,19 +85,22 @@ 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.
|
||||||
|
|
||||||
Fix #1956: per-object последний снимок считается внутри _SUPPLY_BATCH_SQL,
|
db.scalar() возвращает latest_snap (MAX snapshot_date) — вызывается перед supply.
|
||||||
отдельного 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()
|
4. supply → .mappings().all() (только если latest_snap is not None)
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
@ -377,6 +378,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -405,6 +407,7 @@ 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,10 +7,8 @@ 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. _SUPPLY_BATCH_SQL → .mappings().all()
|
4. db.scalar() → MAX(snapshot_date) — через .return_value
|
||||||
|
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
|
||||||
|
|
@ -95,17 +93,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.execute() (Fix #1956: db.scalar(MAX(snapshot_date)) удалён —
|
Порядок db.execute():
|
||||||
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()
|
4. supply → .mappings().all() (только если latest_snap is not None)
|
||||||
|
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,35 +412,10 @@ 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