-- Контекст: follow-up ревью #2240 — best-layouts supply-only запрос уходит в
-- heap scan + external-merge Sort на тяжёлых проектах, потому что покрывающий
-- индекс 176-й (objective_lots_physflat_covering_idx) НЕ несёт price_per_m2_rub.
--
-- best_layouts._SUPPLY_ONLY_LOTS_SQL дедуплицирует objective_lots до последнего
-- снапшота на каждый ФИЗИЧЕСКИЙ лот через
--   SELECT DISTINCT ON (project_name, corpus_name, section, floor, lot_number) …
--   ORDER BY …, snapshot_date DESC, id DESC
-- по квартирам (premise_kind='квартира'), а ПОСЛЕ DISTINCT ON проецирует
-- (best_layouts.py:381-386): project_name (ключ), rooms_int, area_pd,
-- price_per_m2_rub, is_sold, status. Все они, КРОМЕ price_per_m2_rub, уже лежат
-- в INCLUDE-нагрузке 176-го covering-индекса. price_per_m2_rub там сознательно
-- НЕ клали (см. коммент 176: «его проецируют только analyze-inline запросы,
-- которые не фильтруют premise_kind='квартира'») — но supply-only запрос
-- #2240 как раз фильтрует premise_kind='квартира' И проецирует price_per_m2_rub,
-- поэтому одной недостающей колонки хватает, чтобы Index-Only стал невозможен:
-- планировщик добирает price_per_m2_rub из кучи → Bitmap/Heap fetch + внешняя
-- сортировка проекции.
--
-- ЗАМЕР (prod EXPLAIN ANALYZE, два тяжелейших проекта, 2026-07-02):
--   ДО (176-й covering без price_per_m2_rub):
--        heap scan + external-merge Sort (~7 MB temp на диск), Execution ~500 ms.
--   ПОСЛЕ (covering + price_per_m2_rub в INCLUDE, создан в BEGIN…ROLLBACK для
--        проверки плана):
--        Index Only Scan using objective_lots_physflat_covering_v2_idx + Unique,
--        внешней сортировки НЕТ, Execution ~54 ms (≈9×).
--
-- Пересоздаём покрывающий индекс, добавляя price_per_m2_rub в INCLUDE. Ключ
-- (physflat + snapshot_date DESC, id DESC) и partial-WHERE (premise_kind=
-- 'квартира') НЕ меняются → тот же DISTINCT ON обслуживает ВСЕХ прежних
-- потребителей 176-го (market_metrics._STOCK_SQL / _SALES_WINDOW_SQL,
-- competitors._SOLD_COUNT_SQL inline, #1959 inline) без регрессии — расширенная
-- нагрузка их только дополняет, ключ и порядок идентичны. Дополнительно теперь
-- Index-Only обслуживается и best_layouts supply-only.
--
-- ── ИМЯ / ПОРЯДОК ────────────────────────────────────────────────────────────
-- Новое имя objective_lots_physflat_covering_v2_idx + DROP старого
-- objective_lots_physflat_covering_idx (тем же приёмом, что 176: CREATE-new →
-- DROP-old). Нельзя `CREATE INDEX IF NOT EXISTS` под ТЕМ ЖЕ именем: старый индекс
-- уже существует под этим именем → IF NOT EXISTS стал бы no-op и молча пропустил
-- бы пересборку (расширение INCLUDE не применилось бы). v2-суффикс + DROP старого:
--   • идемпотентно при ре-apply (IF NOT EXISTS / IF EXISTS);
--   • ни одного окна без индекса под DISTINCT ON — новый уже на месте, когда
--     дропаем старый (оба кратковременно сосуществуют).
-- Имя индекса нигде в backend-коде не хардкодится (планировщик выбирает индекс
-- по форме запроса, не по имени) — переименование прозрачно для приложения.
--
-- ── LOCK-ПРОФИЛЬ / CONCURRENTLY ──────────────────────────────────────────────
-- Обычный (НЕ CONCURRENTLY) CREATE INDEX берёт SHARE-lock на objective_lots —
-- блокирует writes на таблицу на время сборки (~252 MB + одна numeric-колонка на
-- ~1.9M строк). CONCURRENTLY здесь НЕВОЗМОЖЕН: deploy.yml гоняет каждый .sql
-- целиком через `psql < file`, а этот файл (как 176/168/173) само-оборачивается
-- в BEGIN/COMMIT; CREATE INDEX CONCURRENTLY внутри транзакции PostgreSQL
-- запрещает. Тот же класс риска и то же решение, что 176 и 168 (см. их
-- lock-комменты). Поэтому: обычный CREATE INDEX в BEGIN/COMMIT.
-- ⚠ Деплой ВНЕ недельного окна objective-scrape (bulk INSERT/UPDATE на
--   objective_lots): миграция создаёт ~252 MB индекс на ~1.9M строк под
--   кратковременным SHARE-lock на objective_lots, затем дропает старый.
--
-- #5-эхо: ANALYZE objective_lots в конце tx — освежает оценки под новый индекс
-- (тот же приём, что 176; выполняется в транзакции штатно).
--
-- Idempotent (IF NOT EXISTS / IF EXISTS), self-wrapped BEGIN/COMMIT.
-- Порядок применения для prod: миграция применяется деплоем в NN-порядке
-- (трекер _schema_migrations). Backend-код НЕ требует изменений — индекс
-- прозрачен для существующего _SUPPLY_ONLY_LOTS_SQL.
BEGIN;

CREATE INDEX IF NOT EXISTS objective_lots_physflat_covering_v2_idx
    ON objective_lots (
        project_name,
        corpus_name,
        section,
        floor,
        lot_number,
        snapshot_date DESC,
        id DESC
    )
    INCLUDE (
        rooms_int,
        area_pd,
        sales_start_date,
        is_sold,
        contract_date,
        status,
        class,
        price_per_m2_rub
    )
    WHERE premise_kind = 'квартира';

COMMENT ON INDEX objective_lots_physflat_covering_v2_idx IS
    'Покрывающий physflat-индекс v2 (#2240): 176-й covering + price_per_m2_rub в '
    'INCLUDE. Обслуживает Index-Only Scan + Unique для DISTINCT ON (physflat) '
    'ORDER BY snapshot_date DESC, id DESC по квартирам у ВСЕХ прежних потребителей '
    '176-го (market_metrics._STOCK/_SALES, competitors, #1959) И для '
    'best_layouts._SUPPLY_ONLY_LOTS_SQL, который проецирует price_per_m2_rub. Без '
    'этой колонки supply-only уходил в heap scan + external-merge Sort '
    '(~7 MB temp, ~500 ms); с ней — Index Only Scan (~54 ms). См. 176-й.';

DROP INDEX IF EXISTS objective_lots_physflat_covering_idx;

ANALYZE objective_lots;

COMMIT;
