-- 285_domklik_diff_percent_recompute.sql -- Пересчитать offer_price_history.diff_percent у domklik из соседних price_rub (#3225). -- -- ЧТО БЫЛО НЕ ТАК -- Загрузчик карточки Домклика клал в diff_percent поле источника priceHistory.diff, -- а там РУБЛИ, не проценты. Замер на проде 29.08.2026: 8900 из 11 075 непустых -- значений с |diff| > 50; перцентили p05 = −600 000, p50 = −50 010, p95 = +300 000. -- У одного и того же listing_id 406163 в колонке соседствуют −200000.00 (рубли, из -- загрузчика) и −0.82 (настоящий процент, из триггера record_listing_price_change). -- Код починен в том же PR (providers/domclick/detail.py + offer_price_history.py: -- процент считается из соседних цен, неправдоподобное значение отвергается, а не -- зажимается). Эта миграция отрабатывает задним числом по уже собранным строкам. -- -- ТРОГАЕМ ТОЛЬКО СТРОКИ ЗАГРУЗЧИКА: change_time <> recorded_at -- В таблице два писателя с РАЗНОЙ базой отсчёта, и смешивать их нельзя: -- • загрузчик (domclick/detail.py) считает процент внутри истории самой карточки — -- база это предыдущая запись priceHistory, то есть предыдущая строка загрузчика; -- • триггер record_listing_price_change (131_fix_diff_percent_overflow.sql:76-86) -- считает от listings.OLD.price_rub — от цены В КАРТОЧКЕ ЛИСТИНГА на момент -- upsert'а. Эта база в offer_price_history может вообще не лежать отдельной -- строкой. -- Поэтому раннее утверждение «формула триггера = формула миграции, пересчёт даст то -- же значение» ЛОЖНО, и опереться на признак происхождения как раз нужно. Без него -- миграция портит честные данные двумя способами: (1) у листинга, чья история -- начинается с триггерной строки, lag() = NULL → честный −0.82 перезаписывается в -- NULL; (2) у триггерной строки, соседом которой по lag() оказалась строка -- загрузчика, честный процент пересчитывается от чужой базы. -- Признак точный, а не эвристический: триггер подставляет now() и в change_time, и -- в recorded_at ОДНИМ INSERT'ом, а now() стабилен внутри транзакции → у триггерной -- строки метки равны побайтово. Загрузчик пишет в change_time дату источника, а -- recorded_at не указывает вовсе (DEFAULT NOW(), 023_offer_price_history.sql:19) → -- расхождение в месяцы. Совпадение исторической даты с моментом вставки с точностью -- до микросекунды недостижимо. Признак закреплён тестом -- test_save_detail_enrichment_leaves_recorded_at_to_default. -- -- ОКНО lag() — ТОЖЕ ТОЛЬКО ПО СТРОКАМ ЗАГРУЗЧИКА -- Триггерные строки не переписываем И не используем как базу. Иначе строка -- загрузчика получила бы базой триггерную строку, которой в истории карточки нет, — -- и результат разошёлся бы с тем, что теперь пишет починенный код. Задача миграции -- ровно в том, чтобы задним числом дать те же значения, что даёт код: prev — это -- предыдущая запись priceHistory карточки. По source окно не сужаем: «предыдущая -- цена» — это предыдущая запись листинга, а у листинга со смешанными источниками -- фильтр по source подсунул бы не ту строку. -- -- ФИЛЬТР ПО |x| > 100 НЕ БЕРЁМ -- Он пропустил бы испорченные строки с мелким рублёвым diff (−50 рублей выглядит -- как правдоподобные −50%). -- -- АСИММЕТРИЯ: СТАРЫЕ CIAN-СТРОКИ НЕ ЧИНИМ -- Гейт validate_diff_percent общий для всех источников (|x| > 100 → NULL + warning), -- а миграция — только про domklik. Уже лежащие в таблице cian-строки с |x| > 100 -- остаются как есть: их история приходит из cian_price_history.py со своим полем и -- своей базой, отдельным замером не подтверждена, а править вслепую по чужому -- источнику — это второй #3225, а не его починка. Отдельная задача. -- -- САМАЯ РАННЯЯ ЗАПИСЬ ЛИСТИНГА → NULL, НЕ 0 -- Предыдущей цены нет — процента не существует. Ноль здесь читался бы как «цена не -- менялась», то есть как измерение, которого не было. Так же ведёт себя и код. -- -- ИДЕМПОТЕНТНОСТЬ -- Пересчёт детерминирован (те же строки → те же значения), а UPDATE ограничен -- `IS DISTINCT FROM` — повторный прогон трогает 0 строк. Новых объектов схемы нет. BEGIN; -- Конвенция проекта (#2752): массовый UPDATE берёт блокировки на строках -- offer_price_history и без lock_timeout встанет в очередь за чужой сессией, -- утащив за собой запросы приложения. SET LOCAL lock_timeout = '5s'; DO $$ DECLARE before_notnull bigint; before_bad bigint; after_notnull bigint; after_bad bigint; touched bigint; BEGIN SELECT count(*) FILTER (WHERE diff_percent IS NOT NULL), count(*) FILTER (WHERE abs(diff_percent) > 100) INTO before_notnull, before_bad FROM offer_price_history WHERE source = 'domklik' AND change_time <> recorded_at; RAISE NOTICE 'domklik (строки загрузчика) ДО: diff_percent непустых = %, из них |x| > 100 = %', before_notnull, before_bad; WITH neighbours AS ( -- Окно только по листингам, у которых есть domklik-строки: без этого сужения -- lag() прогоняется по ВСЕЙ таблице, и под lock_timeout = 5s деплой падает. -- Оба скана идут по индексам 023: oph_source_time_idx (source, change_time) -- для подзапроса и oph_listing_time_idx (listing_id, change_time) для окна. SELECT id, source, price_rub, lag(price_rub) OVER (PARTITION BY listing_id ORDER BY change_time, id) AS prev_price FROM offer_price_history WHERE listing_id IN ( SELECT DISTINCT listing_id FROM offer_price_history WHERE source = 'domklik' ) AND change_time <> recorded_at ), recomputed AS ( SELECT id, CASE WHEN prev_price > 0 THEN round((price_rub - prev_price) / prev_price * 100, 2) END AS new_diff FROM neighbours WHERE source = 'domklik' ) UPDATE offer_price_history oph SET diff_percent = r.new_diff FROM recomputed r WHERE oph.id = r.id AND oph.diff_percent IS DISTINCT FROM r.new_diff; GET DIAGNOSTICS touched = ROW_COUNT; SELECT count(*) FILTER (WHERE diff_percent IS NOT NULL), count(*) FILTER (WHERE abs(diff_percent) > 100) INTO after_notnull, after_bad FROM offer_price_history WHERE source = 'domklik' AND change_time <> recorded_at; RAISE NOTICE 'domklik (строки загрузчика) ПОСЛЕ: diff_percent непустых = % (было %), |x| > 100 = % (было %), переписано строк = %', after_notnull, before_notnull, after_bad, before_bad, touched; END $$; COMMIT;