-- 128_supply_layers_dev_group_key.sql -- #970 (REOPENED, CRITICAL) — Layer-3 future-supply collapse: добавляем dev_group_name в зерно -- и в логический UNIQUE-ключ supply_layers, чтобы dev_group'ы района перестали схлопываться. -- -- БАГ (ground-truth, измерено на prod): -- Layer-3 (future supply) в supply_layers.py (_L3_FUTURE_SQL) считается per -- (district_name, dev_group_name) — GROUP BY district_name, dev_group_name — но строки -- эмитятся с complex_id=NULL и БЕЗ dev_group_name (колонки нет с м.125). Логический ключ -- uq_supply_layers_logical (м.125) = (layer, district_name, complex_id, obj_class, source, -- snapshot_date) NULLS NOT DISTINCT — dev_group_name в нём НЕТ. Итог: ВСЕ dev_group'ы района -- дают ОДИН и тот же ключ (complex_id всегда NULL для L3) → UPSERT воркера -- (supply_layers_refresh.py, ON CONFLICT) перезатирает их друг другом → массовая потеря. -- Измерено (Академический, latest snapshot): должно быть 13 808 юнитов в 15 dev_group'ах / -- 54 объектах, выживает 1 строка / 607 юнитов (~95.6% потерь, недетерминированно per-run). -- -- ЧИНИМ (схема): dev_group_name становится частью L3-grain и частью логического ключа. -- 1. ADD COLUMN dev_group_name TEXT (nullable — у L1/L2 dev_group'а НЕТ, его ставит только L3). -- 2. Перестроить uq_supply_layers_logical, ДОБАВив dev_group_name: -- (layer, district_name, complex_id, obj_class, dev_group_name, source, snapshot_date) -- по-прежнему UNIQUE NULLS NOT DISTINCT (PG15+, как м.125). -- -- Почему NULLS NOT DISTINCT сохраняется (КРИТИЧНО — корректность дедупа обоих случаев): -- В обычном UNIQUE Postgres считает NULL DISTINCT → (а) region-wide / L1/L2 строки -- (district_name/complex_id/obj_class/dev_group_name IS NULL) дублировались бы, (б) ON CONFLICT -- их НЕ матчил бы (NULL <> NULL). NULLS NOT DISTINCT сравнивает NULL'ы как РАВНЫЕ. Следствия: -- • L1/L2 строки: dev_group_name=NULL у ВСЕХ из них → новая колонка для них «прозрачна», -- они продолжают дедуплиться по ОСТАЛЬНОЙ части ключа ровно как раньше (NULL=NULL по -- dev_group_name не ломает их уникальность). Семантика L1/L2 НЕ меняется. -- • L3 строки: теперь несут РАЗНЫЕ непустые dev_group_name → дают РАЗНЫЕ ключи → больше -- НЕ схлопываются; разные dev_group'ы района сосуществуют (15 строк вместо 1). -- Прецеденты NULLS NOT DISTINCT в репо: м.125 (исходный ключ), м.110 (uq_infra_dedupe). -- -- Зависимости / окружение: -- • supply_layers (м.125) — ALTER существующей таблицы (ADD COLUMN + rebuild constraint). -- • PostgreSQL 16 (postgis/postgis:16-3.4) — нужен для UNIQUE NULLS NOT DISTINCT (PG15+). -- • Unique-constraint сам по себе даёт индекс — отдельный supporting-индекс под этот ключ -- НЕ нужен (м.125 его и не заводила; read-path индексы idx_supply_layers_* остаются как есть). -- -- Idempotent (acceptance — ре-applied чисто ДВАЖДЫ): ADD COLUMN IF NOT EXISTS + guarded DROP/ADD -- констрейнта через DO-блок с проверкой pg_constraint (мирроринг м.125 / м.40). Re-apply -- конвергентен. BEGIN/COMMIT — атомарно. НИКАКИХ DROP TABLE/TRUNCATE, никаких INSERT/строк. -- -- Deploy order (КРИТИЧНО): эта SQL-миграция применяется ПЕРВОЙ (auto-apply через deploy.yml / -- _schema_migrations tracking, ровно один раз по NN). ЗАТЕМ — отдельным backend-изменением -- (НЕ в этом PR) воркер backend/app/workers/tasks/supply_layers_refresh.py: -- • _UPSERT_SQL INSERT column-list + VALUES должны ДОБАВИТЬ dev_group_name -- (CAST(:dev_group_name AS text)), -- • ON CONFLICT (...) должен стать -- (layer, district_name, complex_id, obj_class, dev_group_name, source, snapshot_date) — -- ровно под новый ключ, -- • а supply_layers.py L3-emit (SupplyLayerRow, layer=3) должен ПОПУЛИРОВАТЬ dev_group_name -- (сейчас он только пишет dev_group в method-строку, в саму строку — нет). -- Никогда наоборот: воркер с новым ON CONFLICT упадёт без этой колонки/ключа. -- Существующие схлопнутые данные САМО-залечиваются на следующем supply_layers_refresh — -- воркер перезапишет район строками по ИСПРАВЛЕННОМУ ключу (15 dev_group'ов вместо 1). BEGIN; -- ── 1. Новая колонка dev_group_name (L3-grain) ───────────────────────────────── -- Nullable: L1/L2 dev_group'а не имеют (ставит только L3 = бакет future-supply по группе -- застройщика). ADD COLUMN IF NOT EXISTS → re-apply пропускает добавление (safe). ALTER TABLE supply_layers ADD COLUMN IF NOT EXISTS dev_group_name TEXT; COMMENT ON COLUMN supply_layers.dev_group_name IS 'L3-grain (#970): бакет future-supply по группе застройщика (dev_group_name, м.113). ' 'Часть логического ключа uq_supply_layers_logical для L3 (domrf_multiphase). NULL для ' 'L1/L2 (у них dev_group''а нет). Без этой колонки в ключе все dev_group''ы района ' 'схлопывались в один NULL-key и UPSERT их перезатирал (~95.6% потерь L3).'; -- ── 2. Перестройка логического UNIQUE-ключа: ДОБАВляем dev_group_name ─────────── -- Цель ON CONFLICT воркера (#950 Step5 / #970 fix). NULLS NOT DISTINCT сохраняется (PG15+): -- L1/L2 (dev_group_name=NULL) дедуплятся по остальной части ключа как раньше; L3 с разными -- непустыми dev_group_name дают разные ключи и больше не схлопываются. Guarded DROP+ADD через -- pg_constraint → idempotent re-apply (констрейнт нельзя добавить дважды; имя то же — -- uq_supply_layers_logical, чтобы ON CONFLICT таргетил его по column-list). Паттерн зеркалит -- м.125 (constraint-add guard) + м.40 (FK guard). DROP с тем же именем безопасен: на «чистой» -- м.125-таблице он снимает старый 6-колоночный ключ, затем ставит новый 7-колоночный; на -- повторном прогоне старого имени уже нет → DROP no-op (IF EXISTS), ADD пропускается guard'ом. DO $$ BEGIN -- Снять старый ключ (м.125: без dev_group_name), если он есть. IF EXISTS → re-apply safe. ALTER TABLE supply_layers DROP CONSTRAINT IF EXISTS uq_supply_layers_logical; -- Поставить новый ключ (с dev_group_name) только если его ещё нет → re-apply конвергентен. IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'uq_supply_layers_logical' ) THEN ALTER TABLE supply_layers ADD CONSTRAINT uq_supply_layers_logical UNIQUE NULLS NOT DISTINCT (layer, district_name, complex_id, obj_class, dev_group_name, source, snapshot_date); END IF; END $$; COMMIT;