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_idFK (→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.
- Миграция добавляет nullable
- Отчёты (scope OP-57):
sales(агрегатwb_salesпо артикулу — продажи+возвраты, чистаяaggregateSalesByArticle) иprofit(переиспользуетcomputeProfitByArticleиз OP-56). Пагинация — in-memory по агрегированному списку артикулов (buildPaginated/OP-27). ЭндпоинтыGET /digitization/reports/sales|profit(ReportsController/ReportsService).
Рассмотренные варианты (AC3)
- Мост по названию (
brand IN (names)) — без миграции, но хрупок к рассинхрону названий между фидами WB. - brand_id FK + backfill — надёжно, но требует наполненного каталога; сегодня
brandsпуст → нерабочий. - Кабинет-скоуп (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+ кеш.
- brand-level enforcement (F2) — дормантно: включается, когда catalog-sync наполнит
- Деньги — суммы в копейках в чистых агрегаторах, на выходе строки numeric (как OP-55/56).
- MSK-фильтр периода — общий с OP-55/56 (один источник логики).