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

0020. API изменения цен и скидок (одиночное и массовое)

Копия ADR из репозитория: backend/docs/adr/0020-pricing-api.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-06-25
  • Связанные задачи: OP-59 (изменение цен), OP-60 (настройка скидок) — 5.2 · Управление ценами и скидками

Расширение OP-60 (скидки). WB-эндпоинт POST /api/v2/upload/task принимает и цену, и скидку ({ nmID, price?, discount? }), поэтому настройка скидок переиспользует ту же инфраструктуру модуля pricing без новой миграции/очереди: эндпоинты POST /pricing/discount (single) и POST /pricing/discount/bulk (массово, общий прогон {@link PriceBulkJobEntity} и очередь). Скидка — целое 0–99, 0 = снятие скидки. Общие submitSingle/enqueueItems в PricingService параметризованы элементом загрузки (цена шлёт {nmID,price,discount?}, скидка — {nmID,discount}) и действием аудита (pricing.discount.set/pricing.discount.bulk). Статус прогона — общий GET /pricing/bulk/:id.

Контекст и проблема​

ТЗ 5.2: эндпоинты установки цен по артикулам (одиночное + массовое), применяемые на WB, с валидацией, асинхронной обработкой массовых изменений и отчётом об успехах/ошибках, аудитом (F6). Блокеры OP-33 (WB-клиент), OP-38 (сущности), OP-41 (очереди) — Done. Новый модуль pricing.

WB-API цен двухшаговый и асинхронный: POST /api/v2/upload/task (scope prices, домен discounts-prices-api) принимает батч { data: [{ nmID, price, discount? }] } (≤1000) и возвращает taskId; реальное применение и пер-товарные ошибки — отдельными методами GET /api/v2/history/tasks и GET /api/v2/history/goods/task. Лимит — 10 запросов / 6 сек на кабинет.

Решение​

Модуль pricing:

  • Single (POST /pricing/price): PricingService.setPrice валидирует DTO (цена — целое ≥ 1, скидка 0–99; невалидное → 400, AC3), берёт ключ кабинета (RBAC, 404 чужой), вызывает WB upload/task с одним элементом, возвращает taskId, пишет аудит (F6). Синхронно.
  • Bulk (POST /pricing/bulk): создаёт прогон {@link PriceBulkJobEntity} (price_bulk_jobs: входные items, счётчики, result), ставит джобу в очередь pricing-bulk (OP-41), возвращает bulkJobId (202). Воркер PricingProcessor чанкует (≤1000), отправляет каждый чанк в WB, агрегирует успехи/ошибки по чанкам в result (taskId загрузок + ошибки), помечает прогон Completed (частичные ошибки — в отчёте; сбой чанка не валит остальные). GET /pricing/bulk/:id отдаёт статус/отчёт (скоуп — по владельцу прогона; Admin — все).
  • Валидация — на DTO (class-validator): цена/скидка/массив (≤1000). Для OP-59 цена обязательна (изменение цен); скидки целиком — OP-60.
  • Лимит WB — точечный override prices:/api/v2/upload/task = 10/6с в WB_ENDPOINT_RATE_LIMITS.

Рассмотренные варианты (отчёт об ошибках bulk)​

  1. Чанк-уровневый отчёт при отправке (выбран): фиксируем приём WB (taskId) / отклонение чанка (errorText). Не требует поллинга; покрывает AC2 «отчёт об успехах/ошибках» на уровне отправки.
  2. Пер-товарный поллинг history/goods/task (подтверждение применения, карантин). Точнее, но добавляет двухшаговый асинхронный поллинг — отложено как follow-up (документировано ниже).
  3. Поэлементная отправка (1 товар = 1 вызов WB) ради пер-товарных ошибок. Отвергнут: при лимите 10/6с массовая отправка деградирует; батч-загрузка — целевой режим WB.

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

  • Положительные: появились single/bulk изменения цен на WB с валидацией, аудитом и отчётом прогона — основа для репрайсера (5.3) и скидок (OP-60). Очередь даёт ретраи (BullMQ) и изоляцию чанков.
  • Границы / вне scope (follow-up):
    • Подтверждение применения на стороне WB (history/tasks + history/goods/task) и пер-товарные ошибки/карантин — отдельный шаг (отчёт сейчас — на уровне приёма загрузки WB).
    • История изменений цен — OP-61 (не дублируем; price_bulk_jobs — тех-статус прогона, не история).
    • Скидки целиком / discount-only — OP-60.
  • Цены — целые рубли (WB принимает integer); валюта кабинета берётся из методов получения цен.
  • Аудит (F6) — на pricing.set (single, после WB) и pricing.bulk (постановка прогона).