0021. История изменений цен/скидок
Копия ADR из репозитория:
backend/docs/adr/0021-price-change-history.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-06-25
- Связанные задачи: OP-61 (5.2 · Управление ценами и скидками)
Контекст и проблема
ТЗ 5.2: хранить и отдавать историю изменений цен/скидок по артикулу с указанием источника
(ручное / репрайсер) и актора — для аудита и анализа влияния репрайсера (5.3). Изменения цен
уже применяются модулем pricing (OP-59/60), single и bulk. Нужен журнал, не дублирующий
существующее:
- Аудит (F6, OP-45) фиксирует факт операции (кто/когда/действие/исход) — но не значение цены и
не строит ряд «старое→новое» по артикулу, и фильтра по
nmId/типу там нет. price_bulk_jobs(OP-59) — тех-статус массового прогона, не история по товару.
Решение
Отдельная append-only сущность PriceChangeEntity (price_changes, миграция
CreatePriceChanges): wb_account_id (FK → wb_accounts, CASCADE), nm_id, change_type
(enum price/discount), old_value (nullable), new_value, source (enum manual/repricer),
actor_id/actor_label (nullable — система), created_at. Индексы (wb_account_id, nm_id) и
(wb_account_id, created_at) под выборку по артикулу/периоду.
- Одна строка = одно изменённое поле.
setPriceсо скидкой пишет две записи (цена + скидка) — ряды по типам независимы. - Запись при каждом успешном изменении (AC1) через
PriceHistoryService:- single —
recordSingleвPricingService.submitSingleпосле успеха WB (sourcemanual, актор = пользователь); - bulk —
recordBulkвPricingProcessorпо каждому успешно отправленному чанку (sourcemanual, актор = владелец прогона). Сбойные чанки не журналируются.
- single —
old_value: для single — быстрый lookup предыдущей записи по(account, nmId, changeType); для bulk —null(выводится из предыдущей записи; избегаем N запросов на прогон до 1000 позиций).- Best-effort: сбой журналирования логируется и не валит уже применённое на WB изменение (та же философия, что у аудита, ADR-0012).
- Чтение —
GET /pricing/history: scope по кабинету (getById, 404 для чужого), фильтры (артикул/тип/источник/период), пагинация (OP-27). Сортировка — новые первыми.
Рассмотренные варианты
- Отдельная сущность
price_changes(выбран) — чистый журнал по товару, не смешан с аудитом и тех-статусом прогона; готов к источникуrepricer(5.3). - Расширить аудит-журнал (F6) полями цены/типа. Отвергнут: аудит — общий журнал действий, перегружать
его доменными полями и индексами по
nmIdнеуместно; разные модели доступа (аудит — только Admin). - Хранить
old_valueвсегда через lookup (и для bulk). Отвергнут: до 1000 lookup-ов на прогон; «старое» и так выводится из предыдущей записи последовательности.
Последствия
- Положительные: появилась история цен/скидок по артикулу с источником/актором — основа для
анализа эффективности репрайсера (5.3) и разбора инцидентов.
source=repricerуже заложен — репрайсер вызоветPriceHistoryService.record*без изменений схемы. - Границы / вне scope:
old_valueдля bulk не заполняется (выводится из предыдущей записи) — осознанный компромисс производительности.- История пишется по факту отправки изменения в WB (как и отчёт прогона OP-59); подтверждение фактического применения на стороне WB — follow-up из ADR-0020.
- Ретеншн/архивация журнала — отдельный вопрос (пока не ограничен).