Перейти к основному содержимому

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 (source manual, актор = пользователь);
    • bulk — recordBulk в PricingProcessor по каждому успешно отправленному чанку (source manual, актор = владелец прогона). Сбойные чанки не журналируются.
  • old_value: для single — быстрый lookup предыдущей записи по (account, nmId, changeType); для bulk — null (выводится из предыдущей записи; избегаем N запросов на прогон до 1000 позиций).
  • Best-effort: сбой журналирования логируется и не валит уже применённое на WB изменение (та же философия, что у аудита, ADR-0012).
  • Чтение — GET /pricing/history: scope по кабинету (getById, 404 для чужого), фильтры (артикул/тип/источник/период), пагинация (OP-27). Сортировка — новые первыми.

Рассмотренные варианты​

  1. Отдельная сущность price_changes (выбран) — чистый журнал по товару, не смешан с аудитом и тех-статусом прогона; готов к источнику repricer (5.3).
  2. Расширить аудит-журнал (F6) полями цены/типа. Отвергнут: аудит — общий журнал действий, перегружать его доменными полями и индексами по nmId неуместно; разные модели доступа (аудит — только Admin).
  3. Хранить old_value всегда через lookup (и для bulk). Отвергнут: до 1000 lookup-ов на прогон; «старое» и так выводится из предыдущей записи последовательности.

Последствия​

  • Положительные: появилась история цен/скидок по артикулу с источником/актором — основа для анализа эффективности репрайсера (5.3) и разбора инцидентов. source=repricer уже заложен — репрайсер вызовет PriceHistoryService.record* без изменений схемы.
  • Границы / вне scope:
    • old_value для bulk не заполняется (выводится из предыдущей записи) — осознанный компромисс производительности.
    • История пишется по факту отправки изменения в WB (как и отчёт прогона OP-59); подтверждение фактического применения на стороне WB — follow-up из ADR-0020.
    • Ретеншн/архивация журнала — отдельный вопрос (пока не ограничен).