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

0019. API отчётов оцифровки + brand-scope (cabinet live, brand_id dormant)

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

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

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

ТЗ 5.1: эндпоинты агрегированных отчётов (продажи/заказы/возвраты/прибыль) с фильтрами (период/артикул/бренд), пагинацией в едином формате (F1/OP-27) и ограничением доступа по бренду (F2). Блокеры OP-56 (P&L) и OP-27 (пагинация) — Done.

Ключевая проблема (AC3 / F2). Строки оцифровки (wb_sales/wb_orders/wb_report_lines) несут brand как строку-название, а доступ (BrandAccess, OP-31) выдаётся на brand-id (uuid). Прямого моста name↔id на строках нет. Сверх того: таблица brands никогда не наполняется — catalog-сервисы (BrandsService/ProductsService) не имеют вызывающих, catalog-sync (OP-33+) ещё не построен. Значит чистый brand_id-scoping сегодня скрыл бы все данные у не-админов (listAccessibleBrandIds → пусто).

Решение (выбор согласован с заказчиком)​

Live-граница доступа — по кабинету; brand_id — дормантная инфраструктура.

  • Скоуп по кабинету (live, безопасно сегодня): каждый отчёт делает accounts.getById(actor, wbAccountId) — RBAC-скоуп кабинета (404 для чужого), как summaryByArticle/profitByArticle. Seller/Manager видят только свои кабинеты, Admin — все.
  • brand_id — инфраструктура, дормантная до catalog-sync:
    • Миграция добавляет nullable brand_id FK (→ brands, ON DELETE SET NULL) + индекс (wb_account_id, brand_id) на три таблицы; backfill существующих строк по совпадению (wb_account_id, brand-название) с brands (сейчас no-op — brands пуст).
    • Отчёты принимают опциональный фильтр brandId (и brand по названию).
    • Активация brand-level доступа (фильтр brand_id IN listAccessibleBrandIds) и резолв brand_id на импорте — отдельный шаг, включается, когда catalog-sync наполнит brands/products.
  • Отчёты (scope OP-57): sales (агрегат wb_sales по артикулу — продажи+возвраты, чистая aggregateSalesByArticle) и profit (переиспользует computeProfitByArticle из OP-56). Пагинация — in-memory по агрегированному списку артикулов (buildPaginated/OP-27). Эндпоинты GET /digitization/reports/sales|profit (ReportsController/ReportsService).

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

  1. Мост по названию (brand IN (names)) — без миграции, но хрупок к рассинхрону названий между фидами WB.
  2. brand_id FK + backfill — надёжно, но требует наполненного каталога; сегодня brands пуст → нерабочий.
  3. Кабинет-скоуп (live) + brand_id дормантно (выбран) — функционально и безопасно сегодня (граница по кабинету), инфраструктура brand_id готова и автоматически активируется с catalog-sync. Компромисс: brand-level частичный доступ (Manager на часть брендов кабинета) пока не enforce-ится — до catalog-sync.

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

  • Положительные: появились пагинированные отчёты с фильтрами; доступ безопасно ограничен по кабинету; схема brand_id готова к brand-level доступу без повторной миграции. Агрегации — чистые функции, покрыты тестами; profit-отчёт переиспользует валидированный P&L (OP-56).
  • Границы / вне scope:
    • brand-level enforcement (F2) — дормантно: включается, когда catalog-sync наполнит brands (тогда же — резолв brand_id на импорте + повторный backfill). Отдельный тикет.
    • Orders-отчёт — не выделен отдельно: возвраты и продажи покрыты sales (фид продаж WB несёт оба); расширяется при необходимости.
    • Кеширование тяжёлых агрегатов (F4) — отложено: агрегация in-memory + пагинация по списку артикулов; при росте объёмов — SQL GROUP BY + кеш.
  • Деньги — суммы в копейках в чистых агрегаторах, на выходе строки numeric (как OP-55/56).
  • MSK-фильтр периода — общий с OP-55/56 (один источник логики).