fix(site-finder): competitors avg-price по per-object снапшоту, не глобальному (#2445-A5) (#2447)
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled

This commit is contained in:
bot-backend 2026-07-05 18:54:36 +00:00
parent 9b6aba42dc
commit c8c96a87ee
3 changed files with 153 additions and 5 deletions

View file

@ -449,16 +449,42 @@ _COMPETITORS_SQL = text("""
# scraper UPSERT per snapshot). Без фильтра snapshot_date AVG усреднял
# ИСТОРИЮ цен (stale на растущем рынке). Берём ТОЛЬКО latest snapshot — зеркало
# паттерна best_layouts._SUPPLY_BATCH_SQL и _COMPETITORS_SQL DISTINCT ON.
#
# === PER-OBJECT snapshot fix (#2445 A5, byte-identical bug к #1956 в best_layouts) ===
# domrf_kn_flats — ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в СВОЙ день,
# UNIQUE-снимок (obj_id, snapshot_date), см. migration 174_domrf_kn_flats_obj_
# snapshot_idx.sql. Прежний фильтр брал ОДНУ глобальную дату
# `f.snapshot_date = MAX(snapshot_date по всей таблице)` — на этой единственной
# глобал-max дате присутствует обычно лишь 1 объект (скрейпнутый последним) →
# у ВСЕХ остальных конкурентов в радиусе 0 строк flats → avg_price_per_m2=NULL
# → _price_similarity(None, ...) молча возвращает нейтраль 0.5, тихо деградируя
# relevance-ранжирование конкурентов без единого флага.
# Фикс (зеркало best_layouts.flats_latest, #1956): flats_latest берёт для КАЖДОГО
# obj_id его СОБСТВЕННЫЙ последний снимок (DISTINCT ON (f.obj_id) ... ORDER BY
# f.obj_id, snapshot_date DESC, id DESC), затем self-join к domrf_kn_flats по
# (obj_id, snapshot_date) достаёт квартиры именно этого снимка. AVG считается
# per-object на СВЕЖЕЙ дате объекта, а не на единственной глобал-max дате.
# Слоение с OBJ-3 (#307) не меняется: этот запрос остаётся ПЕРВЫМ источником
# (domrf), _OBJECTIVE_PRICE_FALLBACK_SQL — additive fallback ТОЛЬКО для obj_id,
# у которых и после фикса нет domrf-цены (см. get_competitors шаг 4a). Ожидаемый
# эффект: domrf возвращает реальные цены для БОЛЬШЕ конкурентов → fallback нужен
# реже, но композиция (missing_price_ids / price_source) не меняется.
_AVG_PRICE_SQL = text("""
WITH flats_latest AS (
SELECT DISTINCT ON (f.obj_id)
f.obj_id, f.snapshot_date
FROM domrf_kn_flats f
WHERE f.obj_id = ANY(:obj_ids)
ORDER BY f.obj_id, f.snapshot_date DESC, f.id DESC
)
SELECT
f.obj_id,
AVG(f.price_per_m2) FILTER (WHERE f.price_per_m2 IS NOT NULL)
AS avg_price_per_m2
FROM domrf_kn_flats f
WHERE f.obj_id = ANY(:obj_ids)
AND f.snapshot_date = (
SELECT MAX(snapshot_date) FROM domrf_kn_flats
)
FROM flats_latest fl
JOIN domrf_kn_flats f
ON f.obj_id = fl.obj_id
AND f.snapshot_date = fl.snapshot_date
GROUP BY f.obj_id
""")

View file

@ -545,6 +545,52 @@ def test_competitors_no_price_anywhere_is_none() -> None:
app.dependency_overrides.clear()
def test_competitors_multi_object_radius_yields_per_object_prices() -> None:
"""Регрессия #2445 A5 (byte-identical к best_layouts #1956): несколько
конкурентов в радиусе КАЖДЫЙ получает свою domrf-цену, а не «1 объект на
global-max снимке, у остальных NULL».
Мок здесь не исполняет реальный SQL (_AVG_PRICE_SQL строковые инварианты
см. test_competitors_avg_price_sql.py), но фиксирует ОЖИДАЕМЫЙ КОНТРАКТ:
_make_db может вернуть price_rows для ВСЕХ obj_id разом (per-object latest
snapshot делает это возможным на живой БД) раньше баг гарантировал, что
price_rows содержал не более 1 строки на весь набор конкурентов.
"""
rows = [
_obj_row(obj_id=1, distance_m=100.0),
_obj_row(obj_id=2, distance_m=200.0),
_obj_row(obj_id=3, distance_m=300.0),
]
price_rows = [
_price_row(obj_id=1, price=140_000.0),
_price_row(obj_id=2, price=155_000.0),
_price_row(obj_id=3, price=162_000.0),
]
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=price_rows)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comps = {c["obj_id"]: c for c in resp.json()["competitors"]}
assert len(comps) == 3
expected = {1: 140_000.0, 2: 155_000.0, 3: 162_000.0}
for obj_id, price in expected.items():
assert comps[obj_id]["avg_price_per_m2"] == pytest.approx(price), (
f"obj_id={obj_id}: все конкуренты должны получить СВОЮ domrf-цену, "
"не only-one-object-on-global-max-snapshot регрессия"
)
assert comps[obj_id]["price_source"] == "domrf"
finally:
app.dependency_overrides.clear()
def test_competitors_is_active_flag() -> None:
"""is_active=True для русского 'Строящиеся' (реальная prod-форма #1213),
False для 'Сданные'/null/прочих."""

View file

@ -0,0 +1,76 @@
"""Пин SQL-формы competitors._AVG_PRICE_SQL (эпик #2445, пункт A5).
Byte-identical бага к best_layouts._SUPPLY_BATCH_SQL (#1956): domrf_kn_flats —
ПО-ОБЪЕКТНЫЙ time-series (каждый ЖК скрейпится в свой день, UNIQUE(obj_id,
snapshot_date), migration 174_domrf_kn_flats_obj_snapshot_idx.sql). Прежний
запрос фильтровал по ОДНОЙ глобальной дате
`f.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_flats)` на этой
единственной дате обычно присутствует лишь 1 объект (скрейпнутый последним),
поэтому у ВСЕХ остальных конкурентов в радиусе получалось 0 строк flats
avg_price_per_m2 = NULL _price_similarity(None, ...) молча возвращал
нейтраль 0.5, тихо деградируя relevance-ранжирование без единого флага.
FIX (порт #1956 в этот файл): flats_latest CTE — DISTINCT ON (f.obj_id) ...
ORDER BY f.obj_id, snapshot_date DESC, id DESC берёт для КАЖДОГО obj_id его
СОБСТВЕННЫЙ последний снимок, затем self-join к domrf_kn_flats по
(obj_id, snapshot_date) достаёт квартиры именно этого снимка.
Эти тесты строковые инварианты на скомпилированном SQL (без живой БД), чтобы
будущий рефактор случайно не вернул глобал-max-date регрессию.
"""
from __future__ import annotations
import os
import re
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.site_finder.competitors import _AVG_PRICE_SQL
_SQL = str(_AVG_PRICE_SQL)
_SQL_FLAT = re.sub(r"\s+", " ", _SQL).lower()
def test_avg_price_uses_flats_latest_per_object_cte() -> None:
"""flats_latest CTE обязателен — без него avg_price снова считается на
единственной глобал-max дате (регрессия #2445 A5, byte-identical к #1956)."""
assert "flats_latest" in _SQL_FLAT, "нет flats_latest CTE → возврат к global-max-date багу"
assert "distinct on (f.obj_id)" in _SQL_FLAT, "flats не дедупятся per-object"
def test_avg_price_orders_latest_snapshot_first() -> None:
"""ORDER BY последним снапшотом вперёд (snapshot_date DESC, id DESC) — DISTINCT ON
берёт первую строку группы собственный последний снимок объекта."""
assert "snapshot_date desc" in _SQL_FLAT
assert "id desc" in _SQL_FLAT
m = re.search(r"order by f\.obj_id, f\.snapshot_date desc, f\.id desc", _SQL_FLAT)
assert m is not None, "DISTINCT ON должен сортировать per obj_id, затем latest snapshot"
def test_avg_price_no_global_max_snapshot_date() -> None:
"""Регрессия #2445 A5: единственный глобальный MAX(snapshot_date) по всей
таблице не должен вернуться это и есть исходный баг (1 объект на весь
global-max день, остальные competitors получают avg_price=NULL)."""
assert "select max(snapshot_date) from domrf_kn_flats" not in _SQL_FLAT
assert "(select max(snapshot_date)" not in _SQL_FLAT
def test_avg_price_joins_flats_by_obj_id_and_snapshot_date() -> None:
"""Self-join к domrf_kn_flats обязан фильтровать И по obj_id, И по
snapshot_date дедуплицированного flats_latest иначе снова fan-out по
истории снимков объекта (см. #1210)."""
assert re.search(r"join\s+domrf_kn_flats\s+f\b", _SQL_FLAT)
assert "f.obj_id = fl.obj_id" in _SQL_FLAT
assert "f.snapshot_date = fl.snapshot_date" in _SQL_FLAT
def test_avg_price_keeps_price_not_null_filter() -> None:
"""AVG(...) FILTER (WHERE price_per_m2 IS NOT NULL) сохранён (Issue #112/227
status='sold' фильтр убран, но NULL-цены по-прежнему исключаются из AVG)."""
assert "avg(f.price_per_m2) filter (where f.price_per_m2 is not null)" in _SQL_FLAT
def test_avg_price_no_double_colon_cast() -> None:
"""psycopg v3 / backend.md: никаких :name::type (игнорируется парсером)."""
assert not re.search(r":[a-z_]+::[a-z]", _SQL)