From 3a3f934bac68f55fdd9a2e3c240d32c0c6b70b68 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 22:31:51 +0300 Subject: [PATCH 1/2] feat(objective): diff-gated objective_lots_history writer trigger (#307 OBJ-6) --- .../sql/164_objective_lots_history_writer.sql | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 data/sql/164_objective_lots_history_writer.sql diff --git a/data/sql/164_objective_lots_history_writer.sql b/data/sql/164_objective_lots_history_writer.sql new file mode 100644 index 00000000..ba3186f7 --- /dev/null +++ b/data/sql/164_objective_lots_history_writer.sql @@ -0,0 +1,170 @@ +-- 164_objective_lots_history_writer.sql +-- #307 OBJ-6 — diff-gated objective_lots_history writer (DB-level trigger). +-- +-- ПРОБЛЕМА (ground-truth, измерено на prod 2026-06-17): +-- objective_lots_history содержит 974 071 строк, но это НЕ delta-tracking, а wholesale-дамп: +-- snapshot_date | rows +-- 2026-05-17 | 324 217 +-- 2026-05-19 | 324 510 +-- 2026-06-03 | 325 344 +-- = 3 даты × ~324k = по строке на КАЖДЫЙ лот в КАЖДЫЙ sync, вне зависимости от того, менялось +-- ли что-то. Источник: data/sql/70_parse_objective_raw.py пишет напрямую +-- INSERT INTO objective_lots_history (...) ... ON CONFLICT (objective_lot_id, snapshot_date) +-- DO NOTHING +-- для каждого лота при каждом sync (Module_Objective_Parser). Никакого diff нет — поэтому +-- таблица раздувается, а реальные изменения (price delta, free→reserved→sold, экспозиция) +-- утопают в шуме одинаковых снимков. Аудит SiteFinder_V2_Groundtruth_Audit_0602 уже отметил +-- «снапшотов всего 2-3 → динамика остатков data-starved». +-- +-- ЦЕЛЬ OBJ-6: каждый sync лота фиксирует в history строку ТОЛЬКО когда реально изменилось +-- tracked-поле (цена за м², суммарные цены, status, is_sold) относительно ПОСЛЕДНЕГО снимка +-- этого лота. Это даёт чистую дельта-историю: price delta, переход статуса +-- free→reserved→sold, длительность экспозиции — без раздувания одинаковыми строками. +-- +-- ПОЧЕМУ ТРИГГЕР (а не правка python-sync): +-- • Параллельный воркер сейчас редактирует objective-mapper (70_parse_objective_raw.py) → +-- избегаем file-overlap, не трогаем python. +-- • BEFORE/AFTER ROW-триггер на objective_lots ловит ВСЕ пути записи разом: основной +-- ON CONFLICT (objective_lot_id) DO UPDATE python-парсера (UPDATE-путь), INSERT новых лотов, +-- ручной SQL, будущие загрузчики. Один инвариант в БД вместо логики в каждом писателе. +-- • Diff-gate в БД дёшев: один индексный lookup последнего снимка по +-- objective_lots_history_lot_idx (objective_lot_id, snapshot_date DESC) — LIMIT 1. +-- +-- ТРИГГЕР: AFTER INSERT OR UPDATE FOR EACH ROW на objective_lots. +-- AFTER (не BEFORE): пишем в ДРУГУЮ таблицу (history), строка objective_lots уже +-- зафиксирована — побочная вставка, не мутация NEW. FOR EACH ROW: нужен per-lot diff. +-- +-- TRACKED-ПОЛЯ (diff-gate) — ровно «экономика лота», ради которой ведётся история: +-- price_per_m2_rub, price_calculated_total_rub, price_offer_total_rub, status, is_sold. +-- Сравнение через IS DISTINCT FROM — корректно трактует NULL (NULL vs NULL = НЕ изменилось, +-- value vs NULL = изменилось). Если последнего снимка НЕТ (новый лот) → пишем всегда. +-- +-- price_delta_pct в history: вычисляем snapshot-over-snapshot +-- (NEW.price_per_m2_rub / prev.price_per_m2_rub − 1) × 100, округление до 2 знаков. +-- Это ОТЛИЧАЕТСЯ от objective_lots.price_delta_pct (внутренняя «Дельта цена, %» Объектива +-- из исходного отчёта) — здесь нужна именно дельта между снимками для velocity-аналитики. +-- Если prev отсутствует или prev.price_per_m2_rub = 0/NULL → price_delta_pct = NULL. +-- +-- КОЛОНКИ history (точно по схеме objective_lots_history, ничего не выдумываем): +-- objective_lot_id, snapshot_date, status, is_sold, price_calculated_total_rub, +-- price_per_m2_rub, price_offer_total_rub, price_delta_pct, area_pd, contract_date, +-- registration_date, contract_type, bank_name, raw_id, complex_id +-- (id/fetched_at — defaults: nextval / now()). +-- +-- ON CONFLICT (objective_lot_id, snapshot_date) DO UPDATE — НЕ DO NOTHING: +-- если за тот же день уже есть снимок (в т.ч. от python-парсера) и пришло НОВОЕ значение +-- tracked-поля, обновляем снимок этого дня свежими цифрами + пересчитанным delta. Это чинит +-- баг текущего python-писателя (DO NOTHING терял внутридневное изменение). Дубликат-строка +-- при коллизии не создаётся (защищён UNIQUE-констрейнтом). +-- +-- СОСУЩЕСТВОВАНИЕ С PYTHON-ПИСАТЕЛЕМ: пока 70_parse_objective_raw.py продолжает свой прямой +-- INSERT ... DO NOTHING, триггер безопасен — его собственный INSERT уходит в DO UPDATE при +-- коллизии snapshot_date, дубль не создаётся. Чистый эффект: со следующего sync новые/ +-- изменённые лоты получают корректную diff-строку, а wholesale-INSERT парсера для +-- НЕизменившихся лотов перекрывается DO NOTHING (его строка остаётся, но триггер её не +-- плодит). FOLLOW-UP (backend-задача, отдельный PR): убрать прямой history-INSERT из +-- 70_parse_objective_raw.py, оставив запись только за триггером — тогда history станет +-- полностью diff-gated. Эта миграция самодостаточна и безопасна без той правки. +-- +-- BACKFILL не делаем: существующие 974k строк — исторический wholesale-дамп, не трогаем +-- (никаких DROP/TRUNCATE/DELETE). Триггер влияет только на будущие записи. +-- +-- ПРОИЗВОДИТЕЛЬНОСТЬ на bulk-sync (~1.1M upsert): на строку — один индексный lookup +-- последнего снимка (Index Scan по objective_lots_history_lot_idx, LIMIT 1) + диф 5 полей. +-- Для НЕизменившихся лотов (типичный sync — меняется меньшинство) — ранний выход без INSERT. +-- Пренебрежимо относительно стоимости самого upsert в objective_lots. +-- +-- Idempotent: CREATE OR REPLACE FUNCTION + DROP TRIGGER IF EXISTS перед CREATE TRIGGER +-- (PG16 не имеет CREATE OR REPLACE TRIGGER — паттерн как в 129_cad_parcels_quarter_cad_trigger). +-- BEGIN/COMMIT — атомарно. Re-apply чист. Auto-applied deploy.yml через _schema_migrations. +-- +-- Deploy order: чистая СХЕМА, без зависимости от кода — безопасно катить в любой момент. + +BEGIN; + +-- ── 1. Триггер-функция: diff-gated запись снимка лота в objective_lots_history ────────── +CREATE OR REPLACE FUNCTION objective_lots_write_history() RETURNS TRIGGER AS $$ +DECLARE + prev objective_lots_history%ROWTYPE; + has_prev boolean := false; + delta_pct numeric := NULL; +BEGIN + -- Последний снимок этого лота (любой более ранней или той же даты) — для diff и delta. + -- Index Scan по objective_lots_history_lot_idx (objective_lot_id, snapshot_date DESC). + SELECT * INTO prev + FROM objective_lots_history + WHERE objective_lot_id = NEW.objective_lot_id + AND snapshot_date <= NEW.snapshot_date + ORDER BY snapshot_date DESC + LIMIT 1; + has_prev := FOUND; + + -- Diff-gate: пишем только если снимка ещё нет ИЛИ изменилось хоть одно tracked-поле. + -- IS DISTINCT FROM — NULL-safe (NULL vs NULL = не изменилось). + IF has_prev + AND prev.price_per_m2_rub IS NOT DISTINCT FROM NEW.price_per_m2_rub + AND prev.price_calculated_total_rub IS NOT DISTINCT FROM NEW.price_calculated_total_rub + AND prev.price_offer_total_rub IS NOT DISTINCT FROM NEW.price_offer_total_rub + AND prev.status IS NOT DISTINCT FROM NEW.status + AND prev.is_sold IS NOT DISTINCT FROM NEW.is_sold + THEN + -- Ничего из tracked не поменялось → не плодим снимок. + RETURN NULL; -- AFTER-триггер: возвращаемое значение игнорируется, RETURN NULL = no-op. + END IF; + + -- snapshot-over-snapshot дельта цены за м² (для velocity-аналитики). + IF has_prev + AND prev.price_per_m2_rub IS NOT NULL + AND prev.price_per_m2_rub <> 0 + AND NEW.price_per_m2_rub IS NOT NULL + THEN + delta_pct := round((NEW.price_per_m2_rub / prev.price_per_m2_rub - 1) * 100, 2); + END IF; + + INSERT INTO objective_lots_history ( + objective_lot_id, snapshot_date, status, is_sold, + price_calculated_total_rub, price_per_m2_rub, price_offer_total_rub, + price_delta_pct, area_pd, contract_date, registration_date, + contract_type, bank_name, raw_id, complex_id + ) VALUES ( + NEW.objective_lot_id, NEW.snapshot_date, NEW.status, NEW.is_sold, + NEW.price_calculated_total_rub, NEW.price_per_m2_rub, NEW.price_offer_total_rub, + delta_pct, NEW.area_pd, NEW.contract_date, NEW.registration_date, + NEW.contract_type, NEW.bank_name, NEW.raw_id, NEW.complex_id + ) + ON CONFLICT (objective_lot_id, snapshot_date) DO UPDATE SET + status = EXCLUDED.status, + is_sold = EXCLUDED.is_sold, + price_calculated_total_rub = EXCLUDED.price_calculated_total_rub, + price_per_m2_rub = EXCLUDED.price_per_m2_rub, + price_offer_total_rub = EXCLUDED.price_offer_total_rub, + price_delta_pct = EXCLUDED.price_delta_pct, + area_pd = EXCLUDED.area_pd, + contract_date = EXCLUDED.contract_date, + registration_date = EXCLUDED.registration_date, + contract_type = EXCLUDED.contract_type, + bank_name = EXCLUDED.bank_name, + raw_id = EXCLUDED.raw_id, + complex_id = EXCLUDED.complex_id, + fetched_at = now(); + + RETURN NULL; -- AFTER ROW trigger — результат не используется. +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION objective_lots_write_history() IS + 'AFTER INSERT OR UPDATE-триггер objective_lots (#307 OBJ-6): diff-gated запись снимка в ' + 'objective_lots_history. Пишет строку ТОЛЬКО при изменении tracked-полей (price_per_m2_rub, ' + 'price_calculated_total_rub, price_offer_total_rub, status, is_sold) против последнего ' + 'снимка лота (IS DISTINCT FROM, NULL-safe). Считает snapshot-over-snapshot price_delta_pct. ' + 'ON CONFLICT(objective_lot_id,snapshot_date) DO UPDATE — внутридневное изменение обновляет ' + 'снимок дня, дубль не создаётся. Заменяет wholesale-дамп python-парсера на дельта-историю.'; + +-- ── 2. Триггер AFTER INSERT OR UPDATE на objective_lots ───────────────────────────────── +-- PG16 не имеет CREATE OR REPLACE TRIGGER → DROP IF EXISTS + CREATE (idempotent, паттерн 129). +DROP TRIGGER IF EXISTS objective_lots_write_history ON objective_lots; +CREATE TRIGGER objective_lots_write_history + AFTER INSERT OR UPDATE ON objective_lots + FOR EACH ROW EXECUTE FUNCTION objective_lots_write_history(); + +COMMIT; -- 2.45.3 From e4cc2d9625f910cde358781a285f5b2d3956e300 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 22:43:55 +0300 Subject: [PATCH 2/2] =?UTF-8?q?chore(sql):=20renumber=20objective=20histor?= =?UTF-8?q?y=20writer=20migration=20164=E2=86=92166=20(avoid=20collision)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...history_writer.sql => 166_objective_lots_history_writer.sql} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename data/sql/{164_objective_lots_history_writer.sql => 166_objective_lots_history_writer.sql} (99%) diff --git a/data/sql/164_objective_lots_history_writer.sql b/data/sql/166_objective_lots_history_writer.sql similarity index 99% rename from data/sql/164_objective_lots_history_writer.sql rename to data/sql/166_objective_lots_history_writer.sql index ba3186f7..427d2137 100644 --- a/data/sql/164_objective_lots_history_writer.sql +++ b/data/sql/166_objective_lots_history_writer.sql @@ -1,4 +1,4 @@ --- 164_objective_lots_history_writer.sql +-- 166_objective_lots_history_writer.sql -- #307 OBJ-6 — diff-gated objective_lots_history writer (DB-level trigger). -- -- ПРОБЛЕМА (ground-truth, измерено на prod 2026-06-17): -- 2.45.3