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

0046. Потолок периода отчётов и агрегация на стороне БД

Копия ADR из репозитория: backend/docs/adr/0046-report-period-limit-and-sql-aggregation.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-08-05
  • Связанные задачи: аудит безопасности 2026-08-05 (находка 7, CWE-770; H10)

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

Читающие эндпоинты оцифровки и бухгалтерии — GET /digitization/financial-report/summary, GET /digitization/profit, GET /digitization/reports/sales, GET /digitization/reports/profit, GET /accounting/reports/{ledger,reconciliation,ledger/export} — принимали произвольный период (@IsISO8601() без верхней границы) и читали строки целыми сущностями (getMany() без take/select).

Обе таблицы-источника (wb_report_lines, wb_sales) несут колонку @Column({type: 'jsonb'}) raw — полную сырую строку ответа WB. Один запрос вида dateFrom=2000-01-01&dateTo=2030-01-01 поднимал в heap всю историю кабинета вместе с сырыми JSON-слепками, после чего сворачивал её в десяток строк в JavaScript. Аутентифицированный арендатор мог уронить процесс одним GET-запросом.

Готовый образец правильного поведения в проекте уже был — RnpAnalyticsService с константой MAX_PERIOD_DAYS = 366, но он не распространялся за пределы РНП.

Смежно (H10): TypeORM по умолчанию пишет ошибки собственным AdvancedConsoleLogger в console — мимо структурного лога, без correlation-id и с дампом bind-параметров, в которых ездят хэши паролей и зашифрованные ключи WB.

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

  1. Курсорная/постраничная выборка строк отчёта. Ограничивает пик памяти, но сохраняет чтение сотен тысяч строк и raw по сети; период по-прежнему не ограничен, а время ответа растёт линейно.
  2. Только лимит периода. Дёшево, но при 366 днях активного кабинета выборка всё равно измеряется сотнями тысяч строк с jsonb.
  3. Материализованная витрина агрегатов (как rnp_daily). Самое быстрое чтение, но это отдельная подсистема с синхронизацией и миграциями — несоразмерно задаче закрытия находки.
  4. Лимит периода + GROUP BY в SQL + statement_timeout — выбранный вариант.

Решение​

Три уровня защиты, каждый закрывает свой класс отказа:

  1. Потолок периода. Общая утилита assertPeriodWithinLimit(from, to, maxDays) в src/core/pagination/date-range.util.ts; лимит — AppConfigService.reportMaxPeriodDays (env REPORT_MAX_PERIOD_DAYS, по умолчанию 366). Проверка выполняется до обращения к БД, во всех перечисленных эндпоинтах и на постановке джобы пользовательского импорта отчёта. Бухгалтерия наследует проверку от оцифровки — AccountingService работает через profitByArticle.
  2. Свёртка в SQL. src/modules/digitization/wb-report-aggregate.query.ts переводит выборки в агрегирующие запросы: GROUP BY nm_id (строки отчёта) и GROUP BY nm_id, type (продажи) с SUM(COALESCE(...)) по денежным полям и явным списком select без колонки raw. Чистые агрегаторы (aggregateByArticle, computeProfitByArticle, aggregateSalesByArticle) не переписывались: свёртка предагрегированных строк тождественна свёртке исходных, поэтому они принимают и те и другие (у продаж добавлено поле-вес count из COUNT(*)). Представитель группы для supplierArticle/brand берётся как MIN(...) — детерминированно, тогда как раньше бралось значение первой строки неупорядоченной выборки.
  3. statement_timeout. Задаётся в buildTypeOrmOptions через extra драйвера pg из AppConfigService.dbStatementTimeoutMs (DB_STATEMENT_TIMEOUT_MS, 0 — не задавать). Это предохранитель уровня всего проекта, а не только отчётов. CLI миграций (data-source.ts) значение не передаёт: DDL на больших таблицах законно идёт дольше.

Заодно (H10) TypeORM получает logger: new TypeOrmNestLogger() — вывод идёт через логгер приложения (JSON + correlation-id + маскирование), текст SQL обрезается и прогоняется через redactText, значения bind-параметров не логируются никогда — только их количество.

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

  • Положительные:
    • Пик памяти на отчётный запрос перестал зависеть от объёма истории кабинета: БД возвращает по строке на артикул (сотни) вместо сотен тысяч сущностей с jsonb.
    • Суммирование выполняется в PostgreSQL по индексу (wb_account_id, rr_dt) — меньше трафика и CPU в Node.
    • Пагинация отчётов стала детерминированной (ORDER BY nm_id).
    • Ошибки БД больше не текут в stdout мимо структурного лога и не раскрывают параметры запросов.
  • Отрицательные / компромиссы:
    • Отчёт больше чем за REPORT_MAX_PERIOD_DAYS дней запросить нельзя — клиент обязан резать период. Инструменты ИИ-помощника (article_profit, article_sales) теперь могут получить 400, как уже могли получить его от РНП-аналитики.
    • statement_timeout снимает и легитимные долгие прикладные запросы, если бюджет выставлен слишком жёстко; значение вынесено в env именно поэтому.
    • Агрегация зафиксирована в SQL-выражениях — новое денежное поле нужно добавлять и в REPORT_MONEY_FIELDS, иначе оно не попадёт в расчёт (покрыто тестом на список алиасов выборки).
  • Дальше: кеширование агрегатов отчётов (F4, как в РНП) и, при росте объёмов, — материализованная витрина по дням.