fix(site-finder): align competitor flats_sold to objective_lots.contract_date (#1926)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m50s
CI / backend-tests (pull_request) Successful in 11m17s

Audit #1871/#1926: competitor block computed flats_sold from
domrf_kn_flats.status (LIKE '%прод%'/'sold'), populated in only ~0.2% of
rows (99.8% NULL) -> massive undercount (~1600 units in reported case) and
divergence from MarketMetrics, which counts sold via objective_lots.contract_date
(100% populated for sold lots).

Fix: new _SOLD_COUNT_SQL counts sold lots from objective_lots.contract_date
IS NOT NULL, bridged domrf obj_id -> objective_complex_mapping.domrf_obj_id ->
objective_complex_mapping.objective_complex_name == objective_lots.project_name
(same 1:1 mapping pattern as _OBJECTIVE_PRICE_FALLBACK_SQL primary_price CTE).
This is the exact source MarketMetrics._STOCK_SQL uses, so the two paths agree.
Dropped the domrf_kn_flats.status count from _AVG_PRICE_SQL (price-only now).

No schema/Pydantic field change (flats_sold/sold_pct already existed) ->
codegen not needed. psycopg v3 CAST style preserved.

Tests: updated mock DB ordering (+ sold-count execute), added
test_competitors_flats_sold_from_contract_date (120/200 -> 60% sold) and
phantom-column EXPLAIN check for _SOLD_COUNT_SQL.
This commit is contained in:
Light1YT 2026-06-26 23:03:13 +05:00
parent 87364eebca
commit c336f91182
3 changed files with 143 additions and 33 deletions

View file

@ -445,26 +445,15 @@ _COMPETITORS_SQL = text("""
# (99.8% NULL) — фильтр давал 0 строк и avg_price_per_m2 всегда None (Issue #112/227).
# AVG по всем квартирам с price_per_m2 IS NOT NULL даёт корректную среднюю цену ЖК.
#
# #949 PR B: добавлена колонка flats_sold (COUNT квартир со status LIKE '%прод%')
# — нужна для stage_at_horizon (available = flat_count - flats_sold). status
# заполнен слабо (~0.2%), поэтому flats_sold чаще будет 0/мало → stage_at_horizon
# падает в нейтраль 0.5 (graceful, см. helper). Колонка добавлена в ТОТ ЖЕ запрос
# (без 4-го db.execute), чтобы не ломать существующие callers/тесты.
# #1210: domrf_kn_flats версионируется (UNIQUE(id, snapshot_date) — м.50,
# scraper UPSERT per snapshot). Без фильтра snapshot_date AVG усреднял
# ИСТОРИЮ цен (stale на растущем рынке), а COUNT '%прод%' множил sold ×N
# снапшотов (raw_sold/flat_count кратно завышен → попадал в гард-нейтраль
# 0.5 или искажал stage_at_horizon). Берём ТОЛЬКО latest snapshot — зеркало
# ИСТОРИЮ цен (stale на растущем рынке). Берём ТОЛЬКО latest snapshot — зеркало
# паттерна best_layouts._SUPPLY_BATCH_SQL и _COMPETITORS_SQL DISTINCT ON.
_AVG_PRICE_SQL = text("""
SELECT
f.obj_id,
AVG(f.price_per_m2) FILTER (WHERE f.price_per_m2 IS NOT NULL)
AS avg_price_per_m2,
COUNT(*) FILTER (
WHERE LOWER(COALESCE(f.status, '')) LIKE '%прод%'
OR LOWER(COALESCE(f.status, '')) = 'sold'
) AS flats_sold
AS avg_price_per_m2
FROM domrf_kn_flats f
WHERE f.obj_id = ANY(:obj_ids)
AND f.snapshot_date = (
@ -473,6 +462,39 @@ _AVG_PRICE_SQL = text("""
GROUP BY f.obj_id
""")
# Кол-во проданных квартир (flats_sold) для набора obj_id — нужно для
# stage_at_horizon (available = flat_count - flats_sold).
#
# #1926 (аудит #1871): РАНЬШЕ flats_sold считался из domrf_kn_flats.status
# (LIKE '%прод%'/'sold'). status заполнен в ~0.2% строк (99.8% NULL) → массовый
# UNDERCOUNT (в репортнутом кейсе ~1600 ед. недосчёта): почти все конкуренты
# получали flats_sold≈0 → stage_at_horizon вырождался в нейтраль/«ничего не
# продано», и блок конкурентов РАСХОДИЛСЯ с MarketMetrics, который считает sold
# по objective_lots.contract_date.
#
# FIX: считаем sold ИЗ ТОГО ЖЕ ИСТОЧНИКА, что MarketMetrics (_STOCK_SQL /
# _SALES_WINDOW_SQL): objective_lots.contract_date IS NOT NULL — «Дата договора»
# заполнена у 100% проданных лотов (cumulative-sold, как n_sold в _STOCK_SQL),
# поэтому совпадает с рыночными метриками и не недосчитывает.
#
# Мост domrf-obj → лоты — РОВНО тот же, что в _OBJECTIVE_PRICE_FALLBACK_SQL
# (primary_price CTE): domrf_kn_objects.obj_id == objective_complex_mapping
# .domrf_obj_id → objective_complex_mapping.objective_complex_name ==
# objective_lots.project_name. Маппинг 1:1 (прод: 263 строки, 263 distinct
# obj_id, 0 obj_id с >1 именем) → fan-out по obj_id нет. objective_lots —
# current-state UPSERT (ON CONFLICT (objective_lot_id) DO UPDATE, ровно одна
# строка на лот) → snapshot-дедуп НЕ нужен (как и в market_metrics._STOCK_SQL).
_SOLD_COUNT_SQL = text("""
SELECT
cm.domrf_obj_id AS obj_id,
COUNT(*) FILTER (WHERE ol.contract_date IS NOT NULL) AS flats_sold
FROM objective_complex_mapping cm
JOIN objective_lots ol
ON ol.project_name = cm.objective_complex_name
WHERE cm.domrf_obj_id = ANY(:obj_ids)
GROUP BY cm.domrf_obj_id
""")
# OBJ-3 (#307): additive fallback цены м² из objective_lots, когда domrf-цены нет.
# Замер прода 2026-06: на ПОСЛЕДНЕМ snapshot domrf_kn_flats.price_per_m2 = 0 строк
# (скрапер перестал отдавать per-flat цены), поэтому _AVG_PRICE_SQL выше даёт
@ -687,13 +709,9 @@ def get_competitors(
obj_ids: list[int] = [int(r["obj_id"]) for r in rows]
# ── 4. Средняя цена м² + sold-count (graceful — таблица может быть пуста) ─
# flats_sold нужен для stage_at_horizon (available = flat_count - flats_sold).
# Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2,
# отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage.
# ── 4. Средняя цена м² (graceful — таблица может быть пуста) ──────────────
avg_price_map: dict[int, float] = {}
price_source_map: dict[int, str] = {}
sold_count_map: dict[int, int] = {}
try:
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
for r in price_rows:
@ -702,11 +720,25 @@ def get_competitors(
if price is not None:
avg_price_map[oid] = float(price)
price_source_map[oid] = "domrf"
except Exception:
logger.warning("competitors: avg_price query failed, continuing without prices")
# ── 4·sold. flats_sold из objective_lots.contract_date (#1926) ───────────
# flats_sold нужен для stage_at_horizon (available = flat_count - flats_sold).
# Источник выровнен с MarketMetrics (contract_date 100%-заполнен у проданных),
# см. _SOLD_COUNT_SQL — ушли от domrf_kn_flats.status (0.2%, недосчёт).
# Отдельный db.execute (не ломает мок-тесты _AVG_PRICE_SQL); _row_get
# graceful: отсутствующий ключ flats_sold → None → нейтральный stage.
sold_count_map: dict[int, int] = {}
try:
sold_rows = db.execute(_SOLD_COUNT_SQL, {"obj_ids": obj_ids}).mappings().all()
for r in sold_rows:
oid = int(r["obj_id"])
sold = _row_get(r, "flats_sold")
if sold is not None:
sold_count_map[oid] = int(sold)
except Exception:
logger.warning("competitors: avg_price query failed, continuing without prices")
logger.warning("competitors: sold-count query failed, continuing without flats_sold")
# ── 4a. OBJ-3 (#307) additive fallback: где domrf-цены НЕТ → objective_lots ──
# Заполняем ТОЛЬКО пробелы (obj_id без domrf-цены). Существующие domrf-выводы
@ -743,9 +775,10 @@ def get_competitors(
dominant_class = _dominant_class([r["obj_class"] for r in rows])
# ── 5. Сборка результата + relevance_weight (ТЗ §9.1) ────────────────────
# flats_sold: COUNT из domrf_kn_flats по status LIKE '%прод%' (заполнен слабо
# ~0.2%, поэтому чаще 0/None). При known flats_sold считаем sold_pct и
# available для stage_at_horizon; иначе оба поля None и stage → нейтраль 0.5.
# flats_sold: COUNT проданных лотов из objective_lots.contract_date (#1926,
# выровнено с MarketMetrics; раньше domrf_kn_flats.status ~0.2% → недосчёт).
# При known flats_sold считаем sold_pct и available для stage_at_horizon;
# иначе оба поля None и stage → нейтраль 0.5.
competitors: list[Competitor] = []
for r in rows:
obj_id = int(r["obj_id"])

View file

@ -53,6 +53,18 @@ def _price_row(obj_id: int, price: float) -> MagicMock:
return r
def _sold_row(obj_id: int, flats_sold: int) -> MagicMock:
"""Строка sold-count (_SOLD_COUNT_SQL, #1926).
flats_sold теперь COUNT проданных лотов из objective_lots.contract_date
(выровнено с MarketMetrics), а не COUNT по domrf_kn_flats.status последний
заполнен ~0.2% и давал массовый недосчёт.
"""
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "flats_sold": flats_sold}[k]
return r
def _obj_price_row(obj_id: int, price: float) -> MagicMock:
"""Строка objective-fallback цены (_OBJECTIVE_PRICE_FALLBACK_SQL, OBJ-3 #307)."""
r = MagicMock()
@ -67,15 +79,18 @@ def _make_db(
coord: MagicMock | None = None,
obj_rows: list[MagicMock] | None = None,
price_rows: list[MagicMock] | None = None,
sold_rows: list[MagicMock] | None = None,
obj_price_rows: list[MagicMock] | None = None,
) -> MagicMock:
"""Сконструировать mock Session.
Порядок вызовов execute:
Порядок вызовов execute (#1926: добавлен отдельный sold-count запрос):
1. centroid query coord
2. competitors query obj_rows
3. domrf avg_price query price_rows
4. objective price fallback (OBJ-3 #307) → obj_price_rows
4. sold-count query (_SOLD_COUNT_SQL, objective_lots.contract_date) sold_rows
(по умолчанию [] = sold неизвестен flats_sold None, нейтральный stage)
5. objective price fallback (OBJ-3 #307) → obj_price_rows
(вызывается ТОЛЬКО если у части obj_id нет domrf-цены почти всегда,
т.к. domrf на последнем snapshot пуст; по умолчанию [] = fallback пуст)
"""
@ -86,6 +101,7 @@ def _make_db(
(coord, True),
(obj_rows or [], False),
(price_rows or [], False),
(sold_rows or [], False),
(obj_price_rows or [], False),
]:
result = MagicMock()
@ -351,13 +367,14 @@ def test_competitors_empty_radius() -> None:
def test_competitors_sold_pct_null() -> None:
"""sold_pct и flats_sold — None (MVP: данные недоступны из domrf_kn_objects).
"""sold_pct и flats_sold — None, когда sold-count запрос пуст (graceful).
Полный расчёт продаж требует JOIN с domrf_kn_flats COUNT WHERE status='sold'
отложен за пределы текущего PR.
#1926: flats_sold берётся из objective_lots.contract_date (_SOLD_COUNT_SQL).
Если у obj_id нет маппинга в objective_complex_mapping (coverage ~2.5%)
sold-count строки нет flats_sold/sold_pct None, stage нейтраль 0.5.
"""
rows = [_obj_row(obj_id=1, flat_count=200)]
db = _make_db(coord=_coord_row(), obj_rows=rows)
db = _make_db(coord=_coord_row(), obj_rows=rows) # sold_rows default [] → None
from app.core.db import get_db
@ -377,6 +394,37 @@ def test_competitors_sold_pct_null() -> None:
app.dependency_overrides.clear()
def test_competitors_flats_sold_from_contract_date() -> None:
"""flats_sold/sold_pct заполняются из objective_lots.contract_date (#1926).
Регрессия аудита #1871/#1926: РАНЬШЕ flats_sold считался из
domrf_kn_flats.status (LIKE '%прод%'), заполненного ~0.2% массовый
недосчёт и расхождение с MarketMetrics. Теперь источник contract_date
(100%-заполнен у проданных), как в market_metrics._STOCK_SQL. Здесь
sold-count запрос возвращает 120 проданных из 200 sold_pct 60.0%.
"""
rows = [_obj_row(obj_id=1, flat_count=200)]
sold_rows = [_sold_row(obj_id=1, flats_sold=120)]
db = _make_db(coord=_coord_row(), obj_rows=rows, sold_rows=sold_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
comp = resp.json()["competitors"][0]
assert comp["flats_sold"] == 120
assert comp["sold_pct"] == pytest.approx(60.0)
assert comp["flats_total"] == 200
finally:
app.dependency_overrides.clear()
def test_competitors_avg_price_populated() -> None:
"""avg_price_per_m2 не None если domrf_kn_flats возвращает строки с ценой.

View file

@ -290,18 +290,25 @@ class TestDomrfKnFlats:
""",
)
def test_avg_price_sold_query(self, phantom_check_session: Session) -> None:
"""competitors._AVG_PRICE_SQL — price_per_m2, status='sold'."""
def test_avg_price_query(self, phantom_check_session: Session) -> None:
"""competitors._AVG_PRICE_SQL — price_per_m2 на последнем snapshot.
#1926: status больше НЕ используется (был ~0.2%-заполнен). avg_price
считается по всем квартирам с known price_per_m2 на latest snapshot;
sold-count вынесен в _SOLD_COUNT_SQL (objective_lots.contract_date).
"""
_explain(
phantom_check_session,
"""
SELECT
f.obj_id,
AVG(f.price_per_m2) AS avg_price_per_m2
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(ARRAY[1, 2, 3])
AND f.price_per_m2 IS NOT NULL
AND f.status = 'sold'
AND f.snapshot_date = (
SELECT MAX(snapshot_date) FROM domrf_kn_flats
)
GROUP BY f.obj_id
""",
)
@ -325,6 +332,28 @@ class TestObjectiveTables:
""",
)
def test_sold_count_query(self, phantom_check_session: Session) -> None:
"""competitors._SOLD_COUNT_SQL (#1926) — mapping → objective_lots.contract_date.
flats_sold выровнен с MarketMetrics: COUNT проданных лотов по
objective_lots.contract_date (100%-заполнен), мост через
objective_complex_mapping (domrf_obj_id objective_complex_name ==
objective_lots.project_name).
"""
_explain(
phantom_check_session,
"""
SELECT
cm.domrf_obj_id AS obj_id,
COUNT(*) FILTER (WHERE ol.contract_date IS NOT NULL) AS flats_sold
FROM objective_complex_mapping cm
JOIN objective_lots ol
ON ol.project_name = cm.objective_complex_name
WHERE cm.domrf_obj_id = ANY(ARRAY[1, 2, 3])
GROUP BY cm.domrf_obj_id
""",
)
def test_objective_corpus_room_month_columns(self, phantom_check_session: Session) -> None:
"""velocity._VELOCITY_BY_ROOM_SQL — deals_total_count, deals_total_vol_m2."""
_explain(