fix(tradein/domklik): diff_percent — проценты из соседних price_rub вместо рублей источника, гейт отвергает |x|>100, миграция 285 пересчитывает историю #3370
Утверждение «формула триггера = формула миграции» было ложным по ИСТОЧНИКУ базы:
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 не чиним.
offer_price_history.diff_percent у domklik содержал РУБЛИ: загрузчик карточки
клал поле источника priceHistory.diff как есть, а clamp_diff_percent только
зажимал его в ±999999.99 — заведомо неправдоподобное значение проходило молча.
Прод 29.08.2026: 8900 из 11 075 непустых значений с |diff| > 50, p50 = -50 010.
- парсер Домклика считает процент сам из соседних price_rub (сортировка по
change_time); у самой ранней записи предыдущей цены нет -> NULL, не 0;
- clamp_diff_percent -> validate_diff_percent: |x| > 100 не зажимается, а
отвергается (NULL + warning с listing_id и сырым значением). Гейт стоит в
общем хелпере, поэтому закрывает и cian-путь;
- миграция 285 пересчитывает уже собранные domklik-строки оконной lag() по
(listing_id, change_time); идемпотентна (UPDATE только IS DISTINCT FROM).
Closes#3225