gendesign/tradein-mvp/backend/data/sql/285_domklik_diff_percent_recompute.sql
bot-backend afa93f9a1e
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m56s
fix(tradein): миграция 285 не трогает строки триггера (deep-review блокер)
Утверждение «формула триггера = формула миграции» было ложным по ИСТОЧНИКУ базы:
record_listing_price_change (131:76-86) считает процент от listings.OLD.price_rub,
а не от предыдущей строки offer_price_history. Пересчёт по lag() портил честные
значения: у листинга, чья история начинается с триггерной строки, lag() = NULL →
−0.82 уходил в NULL; у триггерной строки с соседом-строкой загрузчика база чужая.

Теперь пересчитываем и берём как базу ТОЛЬКО строки загрузчика
(change_time <> recorded_at — триггер ставит обе метки одним now()). Это ровно то,
что делает починенный код: процент внутри истории самой карточки. Окно lag()
сужено до листингов с domklik-строками — иначе оконная функция шла по всей
таблице под lock_timeout = 5s и валила деплой.

Признак закреплён тестом на INSERT загрузчика (recorded_at не указан → DEFAULT
NOW()); при добавлении recorded_at в INSERT тест краснеет — проверено.
В шапке миграции отмечена асимметрия: старые cian-строки с |x| > 100 не чиним.
2026-09-06 01:08:09 +05:00

132 lines
9.1 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 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;