fix(objective): physflat-dedup objective_lots — view + inline (request-path) + n_sold window-fix (#1964) #2032

Merged
bot-backend merged 2 commits from fix/objective-lots-dedup-1964 into main 2026-06-28 00:21:46 +00:00
15 changed files with 362 additions and 79 deletions

View file

@ -684,12 +684,18 @@ def objective_coverage(
"""
from app.services.objective_etl import get_sqlite_info
# #1964: `lots` = СЫРОЙ COUNT(*) — намеренно. Этот счётчик сравнивается с
# SQLite-источником Антона (ETL-fidelity: «свежее ли SQLite, чем БД»), а SQLite
# хранит сырые строки → дедуп-view сломал бы сравнение. Рядом отдаём
# `lots_physflat` (v_objective_lots_latest — по физлоту) для прозрачности
# инфляции (~2.91×: сырых ~1.75M vs физлотов ~603k).
pg_row = (
db.execute(
text(
"""
SELECT
(SELECT COUNT(*) FROM objective_lots) AS lots,
(SELECT COUNT(*) FROM v_objective_lots_latest) AS lots_physflat,
(SELECT COUNT(*) FROM objective_corpus_room_month) AS crm,
(SELECT COUNT(*) FROM objective_complex_mapping) AS mappings,
(SELECT MAX(snapshot_date) FROM objective_lots) AS last_lot_snapshot,
@ -706,6 +712,7 @@ def objective_coverage(
return {
"pg": {
"lots": int(pg_row["lots"] or 0),
"lots_physflat": int(pg_row["lots_physflat"] or 0),
"corp_room_month": int(pg_row["crm"] or 0),
"mappings": int(pg_row["mappings"] or 0),
"last_lot_snapshot": pg_row["last_lot_snapshot"].isoformat()

View file

@ -72,13 +72,33 @@ _DISTRICT_FOR_POINT_SQL = text(
# Медиана цены продажи жилья из объявлений Objective по району + размер выборки.
# price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3.
#
# #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти objective_lot_id на
# один физлот через пере-листинги) → sample_size и гейт n≥:_MIN_OBJECTIVE_SAMPLE
# были по пере-листингам, вес медианы смещался. Дедупим INLINE через DISTINCT ON
# (physflat-ключ, последний снапшот snapshot_date DESC, id DESC) — НЕ через общий
# physflat-VIEW. Причина (deep-review #1964, прод-EXPLAIN): view не несёт
# district-фильтр внутри, qual не проталкивается ниже DISTINCT ON → view сортирует
# ВСЮ таблицу 1.76M (~5.8 s, request-path внутри analyze_parcel). Inline с district
# В CTE → bitmap по району ~240k строк + sort (~1.7 s), индекс по district работает.
# Сегмент-фильтры (price NOT NULL / диапазон) объективны по физлоту → применяем
# ПОСЛЕ дедупа. Зеркало паттерна market_metrics._STOCK_SQL.
_OBJECTIVE_MEDIAN_SQL = text(
"""
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
COUNT(*) AS sample_size
WITH latest AS (
SELECT DISTINCT ON (
project_name, corpus_name, section, floor, lot_number
)
price_per_m2_rub
FROM objective_lots
WHERE district = CAST(:dn AS text)
AND price_per_m2_rub IS NOT NULL
ORDER BY project_name, corpus_name, section, floor, lot_number,
snapshot_date DESC, id DESC
)
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
COUNT(*) AS sample_size
FROM latest
WHERE price_per_m2_rub IS NOT NULL
AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric)
"""
)

View file

@ -69,12 +69,14 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
deals_total = int(row_deals or 0)
# KPI 3: % objective_lots с ценой
# #1964: physflat-дедуп view — доля квартир с ценой по ФИЗИЧЕСКИМ лотам, а не
# по пере-листингам (сырой objective_lots раздут ~2.91× мульти lot_id/физлот).
row_price = db.execute(
text(
"SELECT"
" COUNT(*) FILTER (WHERE price_per_m2_rub IS NOT NULL) * 100.0"
" / NULLIF(COUNT(*), 0)"
" FROM objective_lots"
" FROM v_objective_lots_latest"
)
).scalar()
price_coverage_pct = round(float(row_price or 0.0), 1)

View file

@ -1869,24 +1869,52 @@ def analyze_parcel(
3000
)
),
obj_pricing AS (
-- Pushdown: агрегируем objective_lots ТОЛЬКО для ближних obj_id.
-- AVG/COUNT per domrf_obj_id независимы между группами, поэтому
-- ограничение scope даёт байт-идентичные значения для тех obj_id,
-- что реально джойнятся ниже (далёкие медианы раньше считались зря).
SELECT
cm.domrf_obj_id,
ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
ROUND(AVG(ol.area_pd)::numeric, 1) AS avg_area_pd,
COUNT(*) FILTER (WHERE ol.is_sold) AS units_sold,
COUNT(*) FILTER (WHERE NOT ol.is_sold) AS units_available,
COUNT(*) FILTER (
WHERE ol.price_per_m2_rub IS NOT NULL
) AS lots_with_price
nearby_projects AS (
-- Имена Objective-проектов ближних obj_id scope для physflat-дедупа.
SELECT DISTINCT cm.domrf_obj_id, cm.objective_complex_name
FROM objective_complex_mapping cm
JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name
WHERE cm.domrf_obj_id IN (SELECT obj_id FROM nearby_obj)
GROUP BY cm.domrf_obj_id
),
obj_lots_latest AS (
-- #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти lot_id
-- на физлот) units_sold/units_available карточки конкурента были
-- завышены. Дедупим INLINE (DISTINCT ON physflat-ключ, последний
-- снапшот), scope протолкнут через project_name ближних ЖК. НЕ через
-- общий physflat-VIEW: он материализует ВСЮ таблицу (join не проходит
-- ниже DISTINCT ON) seq-scan+sort 1.76M (~5.7 s на request-path
-- analyze_parcel); inline scoped ~доли секунды (прод-EXPLAIN #1964).
SELECT DISTINCT ON (
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
)
ol.project_name,
ol.price_per_m2_rub,
ol.area_pd,
ol.is_sold
FROM objective_lots ol
WHERE ol.project_name IN (
SELECT objective_complex_name FROM nearby_projects
)
ORDER BY ol.project_name, ol.corpus_name, ol.section, ol.floor,
ol.lot_number, ol.snapshot_date DESC, ol.id DESC
),
obj_pricing AS (
-- Pushdown: агрегируем ТОЛЬКО для ближних obj_id. AVG/COUNT per
-- domrf_obj_id независимы между группами scope даёт идентичные
-- значения для джойнящихся obj_id. Маппинг прод 1:1 по domrf_obj_id
-- (303 строки = 303 distinct) COUNT(*) по дедуп-физлотам корректен.
SELECT
np.domrf_obj_id,
ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd,
COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold,
COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available,
COUNT(*) FILTER (
WHERE oll.price_per_m2_rub IS NOT NULL
) AS lots_with_price
FROM nearby_projects np
JOIN obj_lots_latest oll
ON oll.project_name = np.objective_complex_name
GROUP BY np.domrf_obj_id
)
SELECT o.obj_id,
o.comm_name,
@ -2535,6 +2563,24 @@ def analyze_parcel(
dp_row = (
db.execute(
text("""
-- #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти
-- lot_id на физлот) sample_size/вес медианы были по
-- пере-листингам. Дедупим INLINE через DISTINCT ON
-- (physflat-ключ, последний снапшот), district протолкнут В
-- CTE. НЕ через v_objective_lots_latest: view не несёт
-- district внутри qual не проходит ниже DISTINCT ON
-- сортировка ВСЕЙ таблицы 1.76M (~5.8 s на request-path
-- analyze_parcel). Inline bitmap по району + sort (~1.7 s).
WITH latest AS (
SELECT DISTINCT ON (
project_name, corpus_name, section, floor, lot_number
)
price_per_m2_rub
FROM objective_lots
WHERE district = CAST(:dn AS text)
ORDER BY project_name, corpus_name, section, floor,
lot_number, snapshot_date DESC, id DESC
)
SELECT
MIN(price_per_m2_rub) AS price_min,
MAX(price_per_m2_rub) AS price_max,
@ -2542,9 +2588,8 @@ def analyze_parcel(
ORDER BY price_per_m2_rub
) AS price_median,
COUNT(*) AS sample_size
FROM objective_lots
WHERE district = CAST(:dn AS text)
AND price_per_m2_rub IS NOT NULL
FROM latest
WHERE price_per_m2_rub IS NOT NULL
AND price_per_m2_rub BETWEEN 30000 AND 600000
"""),
{"dn": district_row["district_name"]},
@ -2903,28 +2948,58 @@ def analyze_parcel(
grp_row = (
db.execute(
text("""
SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
AS median,
count(*) AS n,
count(DISTINCT c.id) AS n_complexes
-- #1964 physflat-дедуп: objective_lots раздут ~2.91×
-- (мульти lot_id на физлот) n/вес медианы/гейт n10
-- были по пере-листингам. Дедупим INLINE через DISTINCT ON
-- (physflat-ключ, последний снапшот), scope протолкнут В
-- CTE через complex_id ближних ЖК. НЕ через
-- v_objective_lots_latest: view материализует ВСЮ таблицу
-- (qual/join не проходят ниже DISTINCT ON) seq-scan+sort
-- 1.76M (~6.4 s на request-path analyze_parcel). Inline:
-- geo-index по complexes Nested Loop bitmap
-- objective_lots_complex_idx по ~186 ЖК ~120 ms
-- (прод-EXPLAIN deep-review #1964). Дедуп до price-фильтра:
-- цена объективна по физлоту (последний снапшот).
WITH nearby_cx AS (
SELECT c.id
FROM complexes c
JOIN objective_lots ol
ON ol.complex_id = c.id
AND ol.price_per_m2_rub IS NOT NULL
WHERE c.latitude IS NOT NULL
AND c.longitude IS NOT NULL
AND ST_DWithin(
ST_SetSRID(
ST_MakePoint(c.longitude, c.latitude), 4326
ST_MakePoint(c.longitude, c.latitude),
4326
)::geography,
ST_SetSRID(
ST_MakePoint(
CAST(:lon AS float), CAST(:lat AS float)
CAST(:lon AS float),
CAST(:lat AS float)
), 4326
)::geography,
CAST(:radius_m AS float)
)
),
latest AS (
SELECT DISTINCT ON (
ol.project_name, ol.corpus_name, ol.section,
ol.floor, ol.lot_number
)
ol.price_per_m2_rub,
ol.complex_id
FROM objective_lots ol
WHERE ol.complex_id IN (SELECT id FROM nearby_cx)
ORDER BY ol.project_name, ol.corpus_name, ol.section,
ol.floor, ol.lot_number,
ol.snapshot_date DESC, ol.id DESC
)
SELECT
percentile_cont(0.5) WITHIN GROUP (
ORDER BY price_per_m2_rub
) AS median,
count(*) AS n,
count(DISTINCT complex_id) AS n_complexes
FROM latest
WHERE price_per_m2_rub IS NOT NULL
"""),
{
"lon": centroid_lon,

View file

@ -152,17 +152,31 @@ def _as_dict_list(items: Sequence[Any] | None) -> list[dict[str, Any]]:
def _deal_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None) -> int | None:
"""Число сделок (продаж) за окно — для deal_count #990. PURE.
"""Число сделок (продаж) ЗА ОКНО — для deal_count #990. PURE.
Предпочитаем `market_metrics.n_sold` (§9.2 прямой счётчик проданных лотов выборки);
fallback на `analyze.market_pulse.competitors_total` НЕ берём (это число ЖК, не
сделок пошло бы в analog_count). Нет сигнала None (#990 → тянет в low).
#1964: НЕ берём `market_metrics.n_sold` — это КУМУЛЯТИВНЫЙ all-time проданный сток
(десятки-сотни тысяч), а #990/confidence_engine помечает фактор «за {window} мес»
(6) и гейтит порогами 6-мес окна (high50). n_sold 50 ВСЕГДА гейт бессмыслен и
подпись врёт. Берём оконное число = unit_velocity × window_months, где unit_velocity
(§9.2) = units_sold_window / window_months из _SALES_WINDOW_SQL (он
contract_date-windowed) произведение восстанавливает units_sold за окно. Так
подпись «за 6 мес» честная и порог 50/15 осмысленный (прод EKB: ~24 876 за 6 мес
vs ~380 921 кумулятивно).
unit_velocity отсутствует (None / нет market_metrics) None (#990 трактует как
«нет данных», тянет в low). window_months 0 / нет None (нечем масштабировать).
Fallback на `analyze.market_pulse.competitors_total` НЕ берём (это число ЖК, не
сделок пошло бы в analog_count).
"""
if market_metrics is not None:
n_sold = market_metrics.get("n_sold")
if isinstance(n_sold, int):
return n_sold
if market_metrics is None:
return None
unit_velocity = market_metrics.get("unit_velocity")
window_months = market_metrics.get("window_months")
if not isinstance(unit_velocity, int | float):
return None
if not isinstance(window_months, int) or window_months <= 0:
return None
return round(float(unit_velocity) * window_months)
def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None) -> int | None:
@ -239,7 +253,8 @@ def _deal_count_months(market_metrics: dict[str, Any] | None) -> int | None:
"""Окно наблюдения для deal_count (мес) — для deal_count_months #990. PURE.
Читает тот же `market_metrics.window_months` (§9.2), что и `_history_months`
именно за это окно считается n_sold. Нет None (#990 пропускает суффикс «за N мес»).
#1964: именно за это окно `_deal_count` считает сделки (unit_velocity ×
window_months). Нет None (#990 пропускает суффикс «за N мес»).
"""
if market_metrics is not None:
window = market_metrics.get("window_months")

View file

@ -828,13 +828,16 @@ def _unavailable(key: str, *, reason: str) -> SpecialIndex:
# `market_metrics._SALES_WINDOW_SQL`: bind `:has_district` (boolean) переключает
# ветку, `:districts` (text[]) — список микро. «Продан» = is_sold IS TRUE ИЛИ
# contract_date заполнена. psycopg v3: CAST, НИКОГДА :x::type.
# #1964: читаем из v_objective_lots_latest (physflat-дедуп view) — сырой
# objective_lots раздут ~2.91× (мульти lot_id на физлот); доля ипотеки —
# отношение, к дублям устойчива, но n_sold/n_mortgage теперь честные (по физлотам).
_ARTIFICIAL_DEMAND_SQL = text(
"""
WITH sold AS (
SELECT
ol.objective_lot_id,
(ol.encumbrance_type IS NOT NULL OR ol.bank_name IS NOT NULL) AS has_mortgage
FROM objective_lots ol
FROM v_objective_lots_latest ol
WHERE ol.premise_kind = CAST(:premise_kind AS text)
AND (ol.is_sold IS TRUE OR ol.contract_date IS NOT NULL)
AND (

View file

@ -491,10 +491,18 @@ _AVG_PRICE_SQL = text("""
# COUNT(DISTINCT ol.objective_lot_id) — дедуп на уровне лота, fan-out-safe.
# (_OBJECTIVE_PRICE_FALLBACK не прецедент: percentile/median dup-нечувствительны.)
#
# #1964: objective_lots — current-state UPSERT, но НЕ «ровно одна строка на лот».
# UNIQUE = objective_lot_id, а Объектив за пере-листинги присваивает ОДНОМУ
# физлоту (project,corpus,section,floor,lot) НЕСКОЛЬКО objective_lot_id → таблица
# раздута ~2.91× → flats_sold завышался. Читаем из v_objective_lots_latest
# (physflat-дедуп view, по строке на физлот). В view objective_lot_id уникален
# (603k строк = 603k distinct lot_id), поэтому COUNT(DISTINCT ol.objective_lot_id)
# == COUNT(*) по физлотам — НО оставляем DISTINCT: маппинг 1:N по domrf_obj_id
# (см. выше) при будущем fan-out задвоил бы строки view, COUNT(*) был бы не
# fan-out-safe. DISTINCT даёт И physflat-дедуп (через view), И fan-out-защиту.
#
# premise_kind='квартира' — как в _STOCK_SQL: без него проданные паркинги/
# кладовые/нежилое считались бы как проданные квартиры (over-count).
# objective_lots — current-state UPSERT (ON CONFLICT (objective_lot_id) DO
# UPDATE, ровно одна строка на лот) → snapshot-дедуп НЕ нужен (как в _STOCK_SQL).
#
# Дефолт premise_kind зеркалит market_metrics.compute_market_metrics
# (premise_kind="квартира") — оба считают продажи жилых лотов.
@ -508,7 +516,7 @@ _SOLD_COUNT_SQL = text("""
OR LOWER(COALESCE(ol.status, '')) = 'продан'
) AS flats_sold
FROM objective_complex_mapping cm
JOIN objective_lots ol
JOIN v_objective_lots_latest ol
ON ol.project_name = cm.objective_complex_name
WHERE cm.domrf_obj_id = ANY(:obj_ids)
AND ol.premise_kind = :premise_kind

View file

@ -290,8 +290,11 @@ def _room_bucket(rooms_int: int | None) -> str:
# только к форсайт-пути. Deep-review (#1959) подтвердил: безопасно для остальных
# потребителей (absorption/MoS/sell_through — отношения; velocity — saturated),
# т.к. inflation симметрична в числителе/знаменателе и съедается шкалой. Дедуп
# здесь касается ТОЛЬКО выборки внутри метрик — дедуп самой сырой таблицы
# objective_lots (для всех platform-консьюмеров) остаётся отдельной задачей #1964.
# здесь касается ТОЛЬКО выборки внутри метрик; #1964 ввёл общий view
# v_objective_lots_latest для ОСТАЛЬНЫХ current-state консьюмеров — он использует
# ТОТ ЖЕ physflat-ключ/направления (project,corpus,section,floor,lot_number;
# snapshot_date DESC, id DESC), что и этот inline DISTINCT ON (инвариант общий).
# Этот блок НЕ рефакторим на view (zero behavior change, лишний риск).
#
# Сегмент-фильтры (#1959): obj_class (class в objective_lots — lowercase, матчим
# LOWER=LOWER) + room_bucket. ROOM-bucket — Source-B room_area-вокабуляр

View file

@ -184,6 +184,12 @@ def _l2_confidence(n_objects_total: int, n_objects_with_free_flats: int) -> Conf
# Доступно = выставлено и НЕ продано. is_sold распознаём И флагом, И статусом 'продан'
# / наличием contract_date (Объектив заполняет неконсистентно — зеркало _STOCK_SQL).
# Группируем по (district, class). class — lowercase в lots (м.68 коммент).
#
# #1964: читаем из v_objective_lots_latest (physflat-дедуп view), НЕ из сырого
# objective_lots. Сырая таблица раздута ~2.91× (мульти objective_lot_id на один
# физлот через пере-листинги) → units_available по району был завышен ~3-4.6×
# (прод: Юго-Западный комфорт 58 606 → 12 620) → дефицит-форсайт переоценивал
# открытое предложение L1. View отдаёт по строке на физический лот.
_L1_OPEN_SQL = text(
"""
WITH lots AS (
@ -196,7 +202,7 @@ _L1_OPEN_SQL = text(
OR ol.contract_date IS NOT NULL
OR LOWER(COALESCE(ol.status, '')) = 'продан'
) AS sold_now
FROM objective_lots ol
FROM v_objective_lots_latest ol
WHERE ol.premise_kind = :premise_kind
AND (
CAST(:has_district AS boolean) IS FALSE

View file

@ -174,12 +174,36 @@ class TestObjPricingPushdown:
"совпадал с выводимыми конкурентами"
)
def test_aggregation_unchanged(self) -> None:
"""Агрегатные выражения (AVG/COUNT FILTER) не тронуты — менялся только scope."""
def test_aggregation_per_domrf_obj_id(self) -> None:
"""Агрегаты (AVG/COUNT FILTER) считаются per domrf_obj_id (логика цен #1332).
#1964: источник агрегатов сменился с сырого objective_lots (alias ol) на
physflat-дедуп CTE obj_lots_latest (alias oll) см. test_obj_pricing_*_physflat
ниже. Сами агрегатные выражения и группировка per-obj_id неизменны.
"""
sql = self._competitor_sql()
# Логика цен/fallback (#1332) неизменна: те же агрегаты per domrf_obj_id.
assert "ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
assert "COUNT(*) FILTER (WHERE ol.price_per_m2_rub IS NOT NULL )" in sql.replace(
assert "ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
assert "lots_with_price" in sql
assert "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
assert "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
assert "GROUP BY np.domrf_obj_id" in sql
def test_obj_pricing_dedups_physflat_inline(self) -> None:
"""#1964: obj_pricing агрегирует physflat-дедуп набор (DISTINCT ON), НЕ сырой.
objective_lots раздут ~2.91× (мульти objective_lot_id на физлот через
пере-листинги) units_sold/units_available карточки конкурента были завышены.
Дедупим INLINE (DISTINCT ON physflat-ключ, последний снапшот) со scope по
project_name ближних ЖК. НЕ через v_objective_lots_latest: view материализует
ВСЮ таблицу 1.76M (join не проходит ниже DISTINCT ON seq-scan+sort ~5.7 s на
request-path analyze_parcel; inline scoped ~0.4 s, прод-EXPLAIN #1964).
"""
sql = self._competitor_sql()
assert "obj_lots_latest AS (" in sql, "должен быть physflat-дедуп CTE (#1964)"
assert "DISTINCT ON ( ol.project_name, ol.corpus_name, ol.section," in sql.replace(
"\n", " "
) or "lots_with_price" in sql
assert "GROUP BY cm.domrf_obj_id" in sql
), "obj_lots_latest должен дедупить по physflat-ключу"
assert "snapshot_date DESC, ol.id DESC" in sql, "берём последний снапшот физлота"
assert (
"v_objective_lots_latest" not in sql
), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"

View file

@ -449,9 +449,9 @@ def test_competitors_avg_price_populated() -> None:
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), (
"avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
)
assert comp["avg_price_per_m2"] == pytest.approx(
150_000.0
), "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
# OBJ-3 #307: domrf-hit → price_source='domrf'.
assert comp["price_source"] == "domrf"
finally:
@ -471,9 +471,7 @@ def test_competitors_objective_price_fallback_fills_gap() -> None:
rows = [_obj_row(obj_id=1)]
# domrf price пуст → fallback на objective median.
obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)]
db = _make_db(
coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows
)
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows)
from app.core.db import get_db
@ -597,14 +595,30 @@ def test_sold_count_sql_is_fanout_safe() -> None:
НЕ domrf_obj_id один obj_id может иметь несколько mapping-строк (то же имя,
разный objective_group) JOIN по project_name задвоил бы лоты. DISTINCT по
objective_lot_id дедуплицирует на уровне лота. Регресс к COUNT(*) fail.
#1964: даже после перехода на v_objective_lots_latest (где objective_lot_id
уникален COUNT(DISTINCT)==COUNT(*) по физлотам) DISTINCT СОХРАНЯЕТСЯ: будущий
fan-out маппинга задвоил бы строки view, и COUNT(*) перестал бы быть fan-out-safe.
"""
sql = _sold_sql_text()
assert "COUNT(DISTINCT ol.objective_lot_id)" in sql, (
"fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
)
assert (
"COUNT(DISTINCT ol.objective_lot_id)" in sql
), "fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
assert "COUNT(*)" not in sql, "COUNT(*) задвоит лоты при 1:N маппинге"
def test_sold_count_sql_reads_physflat_view() -> None:
"""#1964: sold-count читает physflat-дедуп view, НЕ сырой objective_lots.
Сырой objective_lots раздут ~2.91× (мульти objective_lot_id на один физлот через
пере-листинги) flats_sold завышался. Репойнт на v_objective_lots_latest даёт по
строке на физический лот. Регресс к `FROM objective_lots` (без _latest) fail.
"""
sql = _sold_sql_text()
assert "v_objective_lots_latest" in sql, "должен читать physflat-дедуп view (#1964)"
assert "JOIN objective_lots " not in sql, "не должен джойнить сырой objective_lots"
def test_sold_count_sql_filters_premise_kind() -> None:
"""premise_kind-фильтр (как в MarketMetrics._STOCK_SQL) — без него проданные
паркинги/кладовые/нежилое считались бы проданными квартирами (over-count)."""

View file

@ -752,8 +752,18 @@ class TestSignalExtractionHelpers:
assert _as_dict_or(_FakeMetrics(1.0, 2, 3))["obj_count"] == 2
assert _as_dict_or(42) is None # мусор → None (graceful)
def test_deal_count_from_market_metrics(self) -> None:
assert _deal_count({}, {"n_sold": 88}) == 88
def test_deal_count_is_windowed_not_cumulative(self) -> None:
# #1964: deal_count = unit_velocity × window_months (продажи ЗА ОКНО), НЕ
# кумулятивный n_sold. confidence_engine помечает фактор «за N мес» и гейтит
# порогами окна — кумулятивный n_sold (≫50) сделал бы гейт бессмысленным и
# подпись лживой.
assert _deal_count({}, {"unit_velocity": 8.2, "window_months": 18}) == round(8.2 * 18)
# n_sold ИГНОРИРУЕТСЯ (кумулятив, не оконный) — даже когда присутствует.
assert _deal_count({}, {"n_sold": 99000, "unit_velocity": 5.0, "window_months": 6}) == 30
# нет unit_velocity / window / market_metrics → None (нет оконного сигнала)
assert _deal_count({}, {"n_sold": 88}) is None
assert _deal_count({}, {"unit_velocity": 8.2}) is None
assert _deal_count({}, {"unit_velocity": 8.2, "window_months": 0}) is None
assert _deal_count({}, None) is None
def test_analog_count_prefers_obj_count(self) -> None:

View file

@ -48,6 +48,24 @@ class _FakeSession:
_WKT = "POINT (60.6 56.83)"
def test_objective_median_sql_dedups_inline_not_via_view() -> None:
"""#1964: _OBJECTIVE_MEDIAN_SQL дедупит INLINE (DISTINCT ON physflat-ключ), НЕ
через v_objective_lots_latest.
Это REQUEST-PATH запрос (внутри analyze/концепт-генерации). View не несёт
district-фильтр внутри qual не проталкивается ниже DISTINCT ON view сортирует
ВСЮ таблицу 1.76M (~5.8 s, прод-EXPLAIN deep-review #1964). Inline DISTINCT ON с
district В CTE bitmap по району + sort (~1.7 s). Регресс к
`FROM v_objective_lots_latest` (whole-table materialize) fail.
"""
sql = str(concepts._OBJECTIVE_MEDIAN_SQL)
assert "DISTINCT ON (" in sql, "должен дедупить физлоты inline"
assert "snapshot_date DESC" in sql, "берём последний снапшот физлота"
assert (
"v_objective_lots_latest" not in sql
), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"
def test_objective_median_selected_when_sample_large_enough() -> None:
# Район найден + Objective n>=10 → выбирается медиана Objective.
db = _FakeSession(

View file

@ -229,6 +229,17 @@ class TestLayer1Open:
assert "CAST(:districts AS text[])" in sql
_assert_no_double_colon_cast(sql)
def test_sql_reads_physflat_view(self) -> None:
# #1964: L1 открытое предложение читает physflat-дедуп view, НЕ сырой
# objective_lots (раздут ~2.91× мульти lot_id/физлот → units_available был
# завышен ~3-4.6× → дефицит-форсайт переоценивал L1). Регресс к
# `FROM objective_lots` → fail.
db = _mock_db([])
compute_layer1_open(db, district="Автовокзал")
sql = _executed_sql(db)
assert "v_objective_lots_latest" in sql, "L1 должен читать physflat-дедуп view"
assert "FROM objective_lots " not in sql, "L1 не должен читать сырой objective_lots"
def test_params(self) -> None:
db = _mock_db([])
compute_layer1_open(db, district="Автовокзал", premise_kind="квартира")

View file

@ -0,0 +1,67 @@
-- Контекст: #1964 (эпик #1953) — physflat-дедуп current-state потребителей
-- objective_lots через общий VIEW.
--
-- objective_lots — current-state UPSERT (UNIQUE objective_lot_id, ON CONFLICT DO
-- UPDATE), НЕ snapshot-append: всего 5 distinct snapshot_date. Инфляция строк
-- (~2.91×: прод 2026-06-28 — 1 753 283 сырых квартир-строки vs 603 049 физлотов)
-- возникает потому, что Объектив за время пере-листингов присваивает ОДНОМУ
-- физическому лоту (project_name, corpus_name, section, floor, lot_number)
-- НЕСКОЛЬКО objective_lot_id. История лотов живёт в ОТДЕЛЬНОЙ objective_lots_history
-- (её НЕ трогаем — это корректный time-series).
--
-- View отдаёт ТОТ ЖЕ набор колонок, что и objective_lots (ol.* — прод-проверка:
-- 51 base cols == 51 view cols), по ОДНОЙ строке на физлот — последний снапшот
-- (snapshot_date DESC, tie-break id DESC). objective_lot_id в результате уникален
-- (603 049 строк = 603 049 distinct objective_lot_id, 0 NULL) → потребители,
-- которым нужна fan-out-защита по маппингу (competitors._SOLD_COUNT_SQL), могут
-- использовать COUNT(DISTINCT objective_lot_id) поверх view.
--
-- ⚠ ПЛАН/ИНДЕКС (deep-review #1964, прод-EXPLAIN 2026-06-28): partial-индекс
-- objective_lots_physflat_latest_idx (миграция 173, WHERE premise_kind='квартира')
-- НЕ обслуживает этот view. Внутри view НЕТ premise-фильтра, а внешний
-- WHERE premise_kind/district потребителя НЕ проталкивается НИЖЕ DISTINCT ON →
-- индекс-173 не применим. View материализует ВСЮ таблицу: Parallel Seq Scan +
-- external Sort 1.76M строк → ~5.8 s (count по одному району).
-- ПОЛНЫЙ индекс (project,corpus,section,floor,lot_number,snapshot_date DESC,id DESC)
-- НЕ помогает и НЕ добавлен: DISTINCT ON отдаёт ol.* (51 кол., width≈945) →
-- index-only-unique невозможен; 605k heap-проб через 142 MB индекс ДОРОЖЕ
-- seq-scan+sort (планировщик его игнорирует: forced index-path всё равно ~3.9 s).
-- 142 MB write-amp ради нуля на request-path + замедление bulk-INSERT objective-
-- scrape — не берём. Подтверждает исходное решение #1964 «новый индекс НЕ добавляем».
--
-- Следствие: view применяем ТОЛЬКО к BATCH/широким консьюмерам, где полная
-- материализация амортизирована или кэширована (supply_layers L1 группирует ВСЕ
-- районы; competitors._SOLD_COUNT_SQL — по mapping; special_indices; admin; landing).
-- REQUEST-PATH консьюмеры внутри analyze_parcel с фильтром по ОДНОМУ району
-- (concepts._OBJECTIVE_MEDIAN_SQL, parcels district-price/geo-median) view НЕ
-- используют — у них inline DISTINCT ON с district, протолкнутым В CTE (bitmap по
-- району ~240k строк + sort ~19 MB → ~1.7 s vs 5.8 s через view). Дедуп платится в
-- любом случае (старый 303 ms был быстрым потому что считал РАЗДУТЫЕ сырые строки).
--
-- #1959 уже устранил инфляцию ВНУТРИ compute_market_metrics (inline DISTINCT ON в
-- _STOCK_SQL/_SALES_WINDOW_SQL) и его 9 forecast/scoring-вызовов — НЕ дублируем.
--
-- НЕ трогаем (time-series, корректны): sales_series._SOURCE_B_SQL,
-- objective_lots_history, mv_sales_tracker_*.
--
-- Порядок: миграция ПЕРВОЙ (deploy first), затем backend-код, читающий view.
-- Idempotent: CREATE OR REPLACE VIEW. Self-wrapped BEGIN/COMMIT.
-- Прод DRY-RUN (BEGIN; CREATE OR REPLACE VIEW; SELECT count; ROLLBACK):
-- view kvartira = 603 049 (vs 1 753 283 raw), колонки 51==51 ✓.
BEGIN;
CREATE OR REPLACE VIEW v_objective_lots_latest AS
SELECT DISTINCT ON (project_name, corpus_name, section, floor, lot_number) ol.*
FROM objective_lots ol
ORDER BY project_name, corpus_name, section, floor, lot_number,
snapshot_date DESC, id DESC;
COMMENT ON VIEW v_objective_lots_latest IS
'#1964: physflat-deduped current state of objective_lots — one row per '
'(project_name, corpus_name, section, floor, lot_number) at the latest '
'snapshot (snapshot_date DESC, id DESC). objective_lots stores ~2.91x '
'inflated rows (multiple objective_lot_id per physical flat across '
're-listings); this view collapses to ~603k physical flats. History lives '
'in objective_lots_history (do not use this view for time-series).';
COMMIT;