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.
Рассмотренные варианты
- Курсорная/постраничная выборка строк отчёта. Ограничивает пик памяти, но
сохраняет чтение сотен тысяч строк и
rawпо сети; период по-прежнему не ограничен, а время ответа растёт линейно. - Только лимит периода. Дёшево, но при 366 днях активного кабинета выборка всё
равно измеряется сотнями тысяч строк с
jsonb. - Материализованная витрина агрегатов (как
rnp_daily). Самое быстрое чтение, но это отдельная подсистема с синхронизацией и миграциями — несоразмерно задаче закрытия находки. - Лимит периода +
GROUP BYв SQL +statement_timeout— выбранный вариант.
Решение
Три уровня защиты, каждый закрывает свой класс отказа:
- Потолок периода. Общая утилита
assertPeriodWithinLimit(from, to, maxDays)вsrc/core/pagination/date-range.util.ts; лимит —AppConfigService.reportMaxPeriodDays(envREPORT_MAX_PERIOD_DAYS, по умолчанию 366). Проверка выполняется до обращения к БД, во всех перечисленных эндпоинтах и на постановке джобы пользовательского импорта отчёта. Бухгалтерия наследует проверку от оцифровки —AccountingServiceработает черезprofitByArticle. - Свёртка в 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(...)— детерминированно, тогда как раньше бралось значение первой строки неупорядоченной выборки. 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, как в РНП) и, при росте объёмов, — материализованная витрина по дням.