From 37b4330eae8fef8c40a6a27f65936912257d39b6 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 3 Jun 2026 01:20:04 +0500 Subject: [PATCH] feat(db): supply_layers store + latest view (#950 PR B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durable 3-layer supply store for Site Finder v2 EPIC 6 (open/hidden/future) at district grain (+ optional per-ЖК). TABLE not MV because Layer3 holds non-derivable rows (граддок stubs #956, manual insider) that the worker recompute must not clobber; Layer1/2 are UPSERT-ed by the #950 Step 5 worker. UNIQUE NULLS NOT DISTINCT on (layer, district_name, complex_id, obj_class, source, snapshot_date) so region-wide NULL-grain rows dedupe and ON CONFLICT matches (PG16). CHECKs on layer/confidence/source fail loud; FK to complexes(id) ON DELETE SET NULL. v_supply_layers_latest returns the newest snapshot per logical key (deterministic tiebreak). Idempotent: guarded DO-blocks for UNIQUE+FK, IF NOT EXISTS elsewhere. Dry-run-verified on a PG16.4/PostGIS3.4 container (re-applied 3x, behavioral tests pass). --- data/sql/125_supply_layers.sql | 177 +++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 data/sql/125_supply_layers.sql diff --git a/data/sql/125_supply_layers.sql b/data/sql/125_supply_layers.sql new file mode 100644 index 00000000..95f4ed30 --- /dev/null +++ b/data/sql/125_supply_layers.sql @@ -0,0 +1,177 @@ +-- 125_supply_layers.sql +-- #950 (Site Finder v2 / 3-layer склад предложения, EPIC 6, «GG-форсайт») — PR B: durable supply store. +-- +-- Создаёт долговременную таблицу supply_layers — единый склад для 3 слоёв будущего/скрытого +-- предложения по districts (и опционально per-ЖК), плюс read-view v_supply_layers_latest +-- (свежий снапшот на логический ключ). Это фундамент future-supply-pressure (#950 Step 6). +-- +-- layer=1 (open) — открытое предложение в текущей продаже (Objective / выставлено сейчас). +-- layer=2 (hidden) — скрытый запас застройщика: flat_count − free_flats по ПД (#950 PR A / м.124). +-- layer=3 (future) — будущее предложение, ещё НЕ на рынке: граддок-stubs (#956, позже), +-- multiphase-проекты ДОМ.РФ, ручные insider-строки. +-- +-- Почему TABLE, а не materialized view: +-- Layer1/Layer2 ПЕРЕсчитываются worker'ом (#950 Step 5) из источников и UPSERT-ятся сюда. +-- НО Layer3 держит строки, КОТОРЫЕ НЕЛЬЗЯ вывести запросом — граддок-stubs (#956) и ручной +-- insider-ввод. MV каждый refresh затёр бы их. Поэтому это реальная таблица: worker UPSERT-ит +-- свои layer1/2 строки, layer3 живёт независимо (вставляется отдельно, переживает пересчёт). +-- Future-supply-pressure индекс считается НА ЛЕТУ в #950 Step 6 (future_supply.py) — здесь НЕ хранится. +-- +-- Зерно (grain): primary — district_name; optional — complex_id (per-ЖК, в основном L3-stubs / +-- reviewed L2). NO per-flat строк (это склад агрегатов, не сделок). district_name/complex_id/ +-- obj_class — NULLABLE (region-wide / класс-agnostic строки допустимы). +-- +-- Зависимости: +-- • complexes(id) (м.73, BIGSERIAL PK) — FK complex_id → complexes(id) ON DELETE SET NULL +-- (complex_id — опциональное обогащение; удаление ЖК НЕ должно удалять supply-строку). +-- • PostgreSQL 16 (postgis/postgis:16-3.4, docker-compose.prod.yml) — нужен для +-- UNIQUE NULLS NOT DISTINCT (PG15+). Прецедент в репо: м.110 (uq_infra_dedupe). +-- +-- Логический UPSERT-ключ (что таргетит ON CONFLICT в worker'е #950 Step 5): +-- (layer, district_name, complex_id, obj_class, source, snapshot_date) +-- КРИТИЧНО: district_name/complex_id/obj_class NULLABLE. По умолчанию Postgres в UNIQUE +-- считает NULL DISTINCT → (а) дубли region-wide строк (district_name IS NULL) проскочили бы, +-- (б) ON CONFLICT НЕ матчил бы их (NULL <> NULL). Поэтому ключ объявлен +-- UNIQUE NULLS NOT DISTINCT — NULL'ы сравниваются как РАВНЫЕ, дубли ловятся, ON CONFLICT +-- по этому column-list срабатывает. Constraint именованный (uq_supply_layers_logical) — +-- ON CONFLICT () таргетит его по списку колонок (имя для diagnostics/idempotency). +-- +-- Deploy: auto-applied by deploy.yml через _schema_migrations tracking (ровно один раз по NN). +-- Idempotent (acceptance #1 — должен ре-applied чисто ДВАЖДЫ без ошибки): CREATE TABLE/INDEX +-- IF NOT EXISTS + CREATE OR REPLACE VIEW. Ловушка идемпотентности — повторное добавление +-- UNIQUE и FK (их нельзя добавить дважды): обёрнуты в guarded DO-блоки с проверкой +-- pg_constraint (прецедент — м.40). Re-apply конвергентен даже если таблица была создана +-- руками без этих constraint'ов. +-- Migration order: эта SQL-миграция применяется ПЕРВОЙ; worker/reader (#950 Step 5/6) — следом. +-- НИКАКИХ строк здесь не вставляется (layer3-stubs приходят из #956; layer1/2 — из worker'а). + +BEGIN; + +-- ── 1. Таблица supply_layers ─────────────────────────────────────────────────── +-- PK и CHECK-и объявлены инлайн: CREATE TABLE IF NOT EXISTS на существующей таблице +-- пропускает весь statement целиком, поэтому инлайн-CHECK'и НЕ передобавляются (re-apply safe). +-- UNIQUE и FK вынесены в DO-блоки ниже (их нельзя добавить дважды → guard по pg_constraint). +CREATE TABLE IF NOT EXISTS supply_layers ( + id BIGSERIAL PRIMARY KEY, + layer SMALLINT NOT NULL, -- 1=open | 2=hidden | 3=future + district_name TEXT, -- primary grain (NULLABLE: region-wide строки) + complex_id BIGINT, -- optional per-ЖК grain (L3-stubs / reviewed L2); FK complexes(id) + obj_class TEXT, -- эконом..премиум (NULLABLE: класс-agnostic) + units_estimate INTEGER, -- оценка кол-ва квартир (NULLABLE) + area_estimate NUMERIC, -- оценка площади, м² (NULLABLE) + expected_online_date DATE, -- когда предложение выйдет на рынок (L3); NULL для L1/L2 + source TEXT NOT NULL, -- 'objective'|'domrf_declared'|'domrf_multiphase'|'graddoc_stub'|'insider_manual' + confidence TEXT NOT NULL, -- 'high'|'medium'|'low' (зеркало market_metrics.py) + method TEXT, -- короткая provenance-строка (как считалось/откуда) + snapshot_date DATE NOT NULL DEFAULT CURRENT_DATE, -- дата пересчёта/ввода; часть логического ключа + computed_at timestamptz NOT NULL DEFAULT now(), -- момент записи строки (tiebreak для latest-view) + -- CHECK'и: плохие данные worker'а должны падать ГРОМКО на write. + CONSTRAINT supply_layers_layer_chk + CHECK (layer IN (1, 2, 3)), + CONSTRAINT supply_layers_confidence_chk + CHECK (confidence IN ('high', 'medium', 'low')), + -- source ограничен 5 known-значениями — новый источник = осознанное расширение CHECK + -- отдельной миграцией (не молчаливый free-text). Если появится потребность в произвольном + -- источнике — этот CHECK снять отдельной ревизией с комментарием. + CONSTRAINT supply_layers_source_chk + CHECK (source IN ('objective', 'domrf_declared', 'domrf_multiphase', + 'graddoc_stub', 'insider_manual')) +); + +COMMENT ON TABLE supply_layers IS + 'Durable 3-layer склад предложения (#950 EPIC 6, GG-форсайт). layer 1=open / 2=hidden / ' + '3=future. Grain: district_name (+ optional complex_id per-ЖК). NO per-flat строк. ' + 'Layer1/2 UPSERT-ятся worker''ом (#950 Step5) по uq_supply_layers_logical; layer3 — ' + 'граддок-stubs (#956) + ручной insider-ввод (поэтому TABLE, не MV — refresh не должен ' + 'затирать невыводимые строки). Future-supply-pressure считается на лету (#950 Step6), тут НЕ ' + 'хранится. Читать свежий снапшот через v_supply_layers_latest.'; + +COMMENT ON COLUMN supply_layers.layer IS '1=open (в продаже) | 2=hidden (запас ПД) | 3=future (ещё не на рынке)'; +COMMENT ON COLUMN supply_layers.expected_online_date IS 'Когда предложение выйдет на рынок (L3-прогноз); NULL для L1/L2 (уже/скрыто на рынке)'; +COMMENT ON COLUMN supply_layers.source IS 'objective|domrf_declared|domrf_multiphase|graddoc_stub|insider_manual (CHECK-ограничено)'; +COMMENT ON COLUMN supply_layers.confidence IS 'high|medium|low — зеркало market_metrics.py (CHECK-ограничено)'; +COMMENT ON COLUMN supply_layers.snapshot_date IS 'Дата пересчёта/ввода; часть логического UPSERT-ключа. Latest-view берёт max(snapshot_date) на ключ.'; + +-- ── 2. Логический UNIQUE-ключ (NULLS NOT DISTINCT, PG15+) ────────────────────── +-- Цель ON CONFLICT для worker'а (#950 Step5). NULLS NOT DISTINCT — чтобы region-wide +-- строки (district_name/complex_id/obj_class IS NULL) НЕ дублировались и ON CONFLICT их матчил +-- (иначе NULL <> NULL → дубли + промах UPSERT). Guard по pg_constraint → idempotent re-apply +-- (constraint нельзя добавить дважды). Прецедент паттерна: м.110 (uq_infra_dedupe), м.40 (FK guard). +DO $$ +BEGIN + 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, source, snapshot_date); + END IF; +END +$$; + +-- ── 3. FK complex_id → complexes(id) (ON DELETE SET NULL) ────────────────────── +-- complex_id — опциональное per-ЖК обогащение; удаление ЖК НЕ должно удалять supply-строку, +-- поэтому SET NULL (не CASCADE). Guard по pg_constraint → re-apply не падает «constraint already +-- exists». DELETE CASCADE здесь был бы неверен: потеряли бы агрегат предложения из-за чистки ЖК. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_supply_layers_complex' + ) THEN + ALTER TABLE supply_layers + ADD CONSTRAINT fk_supply_layers_complex + FOREIGN KEY (complex_id) REFERENCES complexes(id) ON DELETE SET NULL; + END IF; +END +$$; + +-- ── 4. Индексы ───────────────────────────────────────────────────────────────── +-- Primary read-path: фильтр по слою + району + дате (consumer'ы Step6 / UI / forecast). +CREATE INDEX IF NOT EXISTS idx_supply_layers_layer_district_date + ON supply_layers (layer, district_name, snapshot_date DESC); +-- FK-lookups + per-ЖК выборки (и ускоряет ON DELETE SET NULL). +CREATE INDEX IF NOT EXISTS idx_supply_layers_complex + ON supply_layers (complex_id) WHERE complex_id IS NOT NULL; +-- snapshot_date в одиночку — для latest-view (отбор max-снапшота) и housekeeping по дате. +CREATE INDEX IF NOT EXISTS idx_supply_layers_snapshot_date + ON supply_layers (snapshot_date DESC); + +-- ── 5. Read-view: свежий снапшот на логический ключ ──────────────────────────── +-- Что читают #950 Step6 (future_supply.py) и любой UI/forecast-consumer. Возвращает по одной +-- строке на логический ключ БЕЗ snapshot_date (layer, district_name, complex_id, obj_class, +-- source) — самую свежую по snapshot_date. Tiebreak полностью детерминирован: при равной +-- snapshot_date берём бОльший computed_at, затем бОльший id (на случай двух записей в день). +-- DISTINCT ON требует, чтобы ведущие ORDER BY-выражения совпадали с DISTINCT-ключом. +CREATE OR REPLACE VIEW v_supply_layers_latest AS +SELECT DISTINCT ON (layer, district_name, complex_id, obj_class, source) + id, + layer, + district_name, + complex_id, + obj_class, + units_estimate, + area_estimate, + expected_online_date, + source, + confidence, + method, + snapshot_date, + computed_at +FROM supply_layers +ORDER BY + layer, + district_name, + complex_id, + obj_class, + source, + snapshot_date DESC, + computed_at DESC, + id DESC; + +COMMENT ON VIEW v_supply_layers_latest IS + 'Свежий снапшот supply_layers на логический ключ (layer, district_name, complex_id, ' + 'obj_class, source) — max(snapshot_date), детерминир. tiebreak computed_at→id DESC. ' + 'Это canonical read-path для future-supply (#950 Step6) и UI/forecast. Пустая таблица → 0 строк.'; + +COMMIT;