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

0016. Импорт данных WB (продажи/заказы/возвраты): модуль digitization

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

  • Статус: accepted
  • Дата: 2026-06-24
  • Связанные задачи: OP-54 (5.1 · Оцифровка продаж / финансовый учёт)

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

ТЗ 5.1: загрузка продаж, заказов и возвратов из Wildberries — основа финансового учёта и источник для РНП/репрайсера. Первая модульная фича. Нужен импорт за период, идемпотентный, с привязкой к артикулу/бренду/кабинету, устойчивый к ошибкам отдельных записей. Источник — WB API через F3 (OP-33). Блокеры OP-33/38/41 — Done.

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

Источники WB​

  1. /api/v1/supplier/orders + /api/v1/supplier/sales (выбран, scope statistics). Заказы — из orders; продажи И возвраты — из sales (один фид: saleID S…=продажа, R…=возврат). Пагинация инкрементальная по dateFrom=lastChangeDate.
    • Плюсы: канонические seller-эндпоинты, одна модель для продаж+возвратов (как отдаёт WB).
  2. reportDetailByPeriod (v5, finance). Отвергнут для базового импорта: это детализация реализации (комиссии/логистика) — относится к OP-55, не к сырому импорту продаж/заказов.

Модель хранения​

  1. Две таблицы wb_orders + wb_sales (выбран), wb_sales.type = enum sale|return. Денорм. nm_id/supplier_article/brand из фида + raw jsonb. FK на wb_accounts (CASCADE).
    • Плюсы: повторяет структуру источника, привязка self-contained, raw future-proof.
  2. Единая таблица wb_documents с document_type. Отвергнут: orders и sales имеют разный набор полей (orders — isCancel/cancelDate/discountPercent; sales — forPay/saleID), смешение в одну таблицу = много nullable-колонок и слабая типизация.

Идемпотентность​

  1. Unique-ключ + upsert (update on conflict) (выбран): wb_orders (wb_account_id, srid), wb_sales (wb_account_id, sale_id). On-conflict = update, т.к. WB актуализирует finishedPrice/priceWithDisc/forPay в течение 24 часов — повторный импорт обновляет суммы.
  2. INSERT … ON CONFLICT DO NOTHING. Отвергнут: теряет 24-часовую актуализацию сумм.

Триггер​

  1. Синхронный эндпоинт POST /digitization/import (выбран) под аутентифицированным пользователем — getDecryptedApiKey(actor, id) требует actor для RBAC-скоупинга.
  2. Фоновый BullMQ-джоб с системным актором. Отвергнут для OP-54: фоновый актор/RBAC-обход и ежедневный fan-out — это OP-58 (ADR-0009 называет его первым потребителем планировщика).

Решение​

Модуль src/modules/digitization/:

  • Сущности WbOrderEntity (wb_orders), WbSaleEntity (wb_sales, type enum) — extends BaseEntity, FK wb_account_id → wb_accounts (CASCADE), unique-индексы для идемпотентности, индексы по nm_id/date, raw jsonb.
  • WbImportService.importForAccount(actor, {wbAccountId, dateFrom}): получает ключ, постранично тянет orders и sales через WbApiClient (scope statistics), маппит чистым wb-import.mapper, батч-upsert. Per-record ошибки маппинга — лог + счётчик, прогон продолжается. Возвращает summary {orders, sales, returns: {fetched, saved, failed}}.
  • Контроллер POST /digitization/import — JwtAuthGuard, Swagger, @CurrentUser actor.
  • Миграция CreateWbImportTables; модуль подключён в AppModule; путь добавлен в openapi.e2e-spec.

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

  • Положительные: появился базовый импорт WB — фундамент для учёта комиссий (OP-55), P&L (OP-56), отчётов (OP-57). Продажи+возвраты в одной модели (как у WB). Идемпотентный повторный импорт с актуализацией сумм. Полностью покрыт unit-тестами на моках (живой WB-токен не нужен для Gate 1).
  • Отступления/границы: ежедневная авто-синхронизация и фоновый fan-out по кабинетам — OP-58 (здесь только синхронный per-account импорт). Кросс-линковка на catalog.products/brands (FK по nm_id) — будущая работа; пока артикул/бренд хранятся денормализованно из фида. Детализация реализации (комиссии/логистика/штрафы из reportDetailByPeriod) — OP-55.
  • Деньги хранятся как numeric (точность), в TS — строка (конвенция TypeORM numeric).
  • Сырой объект WB в raw jsonb — трассируемость и устойчивость к новым полям без миграций.