fix(objective): physflat-dedup current-state consumers + honest n_sold window (#1964)

objective_lots — current-state UPSERT (UNIQUE objective_lot_id, 5 snapshot_date),
но Объектив присваивает ОДНОМУ физлоту (project,corpus,section,floor,lot_number)
несколько lot_id за пере-листинги → таблица раздута ~2.91× (прод: 1.75M квартир-
строк vs 603k физлотов). История — в отдельной objective_lots_history (не трогаем).

STEP 1: миграция 175 — VIEW v_objective_lots_latest (DISTINCT ON physflat-ключ,
последний снапшот). ol.* стабилен (51==51 колонок). DRY-RUN на проде: 603 049
квартир vs 1 753 283 raw.

STEP 2: репойнт current-state консьюмеров objective_lots → v_objective_lots_latest:
- supply_layers._L1_OPEN_SQL (L1 открытое предложение → дефицит-форсайт; прод:
  Юго-Западный комфорт 58 606 → 12 620)
- competitors._SOLD_COUNT_SQL (+ комментарий: COUNT(DISTINCT lot_id) СОХРАНЁН для
  fan-out-защиты маппинга, не COUNT(*) — view гарантирует physflat-дедуп, DISTINCT
  гарантирует mapping-fan-out-safety)
- parcels.py obj_pricing CTE (карточка конкурента units_sold/available)
- special_indices._ARTIFICIAL_DEMAND_SQL
- parcels.py district price block + geo-radius median (sample_size/n)
- concepts._OBJECTIVE_MEDIAN_SQL (гейт n≥10)
- landing KPI3 (% квартир с ценой)
- admin_scrape coverage: `lots` оставлен сырым (ETL-fidelity vs SQLite), добавлен
  `lots_physflat`
#1959 inline-дедуп в market_metrics НЕ рефакторим — добавлен комментарий об общем
physflat-ключе с view.

STEP 3: report_assembler._deal_count теперь = unit_velocity × window_months
(оконные продажи), НЕ кумулятивный n_sold. confidence_engine помечает фактор
«за 6 мес» и гейтит порогами окна (high≥50) — кумулятив (прод EKB ~380 921) делал
гейт бессмысленным и подпись лживой; оконное (~24 876 за 6 мес) честно.

Тесты: +guard'ы (L1/sold-count читают view, deal_count оконный). 963 passed.
This commit is contained in:
Light1YT 2026-06-28 04:27:43 +05:00
parent 91edef1524
commit e67cb721bf
13 changed files with 181 additions and 33 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,11 +72,14 @@ _DISTRICT_FOR_POINT_SQL = text(
# Медиана цены продажи жилья из объявлений Objective по району + размер выборки.
# price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3.
# #1964: читаем v_objective_lots_latest (physflat-дедуп view) — сырой objective_lots
# раздут ~2.91× (мульти lot_id на физлот) → sample_size и гейт n≥:_MIN_OBJECTIVE_SAMPLE
# были по пере-листингам, а не по физическим квартирам; вес медианы тоже смещался.
_OBJECTIVE_MEDIAN_SQL = text(
"""
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
COUNT(*) AS sample_size
FROM objective_lots
FROM v_objective_lots_latest
WHERE district = CAST(:dn AS text)
AND 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

@ -1874,6 +1874,11 @@ def analyze_parcel(
-- AVG/COUNT per domrf_obj_id независимы между группами, поэтому
-- ограничение scope даёт байт-идентичные значения для тех obj_id,
-- что реально джойнятся ниже (далёкие медианы раньше считались зря).
-- #1964: читаем из v_objective_lots_latest (physflat-дедуп view) —
-- сырой objective_lots раздут ~2.91× (мульти lot_id на физлот)
-- units_sold/units_available карточки конкурента были завышены.
-- View = 1 строка на физлот; маппинг прод 1:1 по domrf_obj_id
-- (303 строки = 303 distinct) COUNT(*) корректен.
SELECT
cm.domrf_obj_id,
ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
@ -1884,7 +1889,8 @@ def analyze_parcel(
WHERE ol.price_per_m2_rub IS NOT NULL
) AS lots_with_price
FROM objective_complex_mapping cm
JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name
JOIN v_objective_lots_latest 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
)
@ -2535,6 +2541,10 @@ def analyze_parcel(
dp_row = (
db.execute(
text("""
-- #1964: physflat-дедуп view вместо сырого objective_lots
-- (раздут ~2.91× мульти lot_id на физлот) sample_size и
-- вес медианы теперь по физическим квартирам, не по
-- пере-листингам.
SELECT
MIN(price_per_m2_rub) AS price_min,
MAX(price_per_m2_rub) AS price_max,
@ -2542,7 +2552,7 @@ def analyze_parcel(
ORDER BY price_per_m2_rub
) AS price_median,
COUNT(*) AS sample_size
FROM objective_lots
FROM v_objective_lots_latest
WHERE district = CAST(:dn AS text)
AND price_per_m2_rub IS NOT NULL
AND price_per_m2_rub BETWEEN 30000 AND 600000
@ -2903,13 +2913,16 @@ def analyze_parcel(
grp_row = (
db.execute(
text("""
-- #1964: physflat-дедуп view (сырой objective_lots
-- раздут ~2.91× мульти lot_id на физлот) n и вес
-- медианы по физическим квартирам, гейт n10 честный.
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
FROM complexes c
JOIN objective_lots ol
JOIN v_objective_lots_latest ol
ON ol.complex_id = c.id
AND ol.price_per_m2_rub IS NOT NULL
WHERE c.latitude IS NOT NULL

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
return None
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

@ -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

@ -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,53 @@
-- Контекст: #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.
--
-- Ключ/направления DISTINCT ON совпадают с partial-индексом
-- objective_lots_physflat_latest_idx (миграция 173, WHERE premise_kind='квартира')
-- → для квартир-scoped потребителей планировщик берёт Index Scan + Unique без
-- полной сортировки. Все current-state потребители #1964 — premise_kind='квартира'.
-- Новый индекс НЕ добавляем (173 покрывает).
--
-- #1959 уже устранил инфляцию ВНУТРИ compute_market_metrics (inline DISTINCT ON в
-- _STOCK_SQL/_SALES_WINDOW_SQL) и его 9 forecast/scoring-вызовов — НЕ дублируем.
-- Этот view — для ОСТАЛЬНЫХ current-state консьюмеров (supply_layers L1, конкурент-
-- карточки, district-цены, sample_size-гейты).
--
-- НЕ трогаем (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;