0053. Единый формат KPI-эндпоинтов GET /<module>/summary
Копия ADR из репозитория:
backend/docs/adr/0053-module-summary-kpi-format.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-09-18
- Связанные задачи: OP-224 (кросс-модульный); закрывает самый массовый gap
frontend
docs/BACKEND-NEEDS.md; опирается на ADR-0018 (P&L), ADR-0021 (история цен), ADR-0030 (витрина РНП), ADR-0052 (каталог)
Контекст и проблема
Около десяти модулей фронта показывают «KPI-ленту» — верхнюю полосу карточек с показателем и его изменением к прошлому периоду. Бэкенд таких эндпоинтов не имел, поэтому фронт собирал каждую ленту сам: выкачивал list-эндпоинт (иногда все страницы) и складывал числа на клиенте.
Два следствия, и второе хуже первого:
- Лишние запросы и неверные итоги. Агрегат по странице — не агрегат по
набору:
avgDiscountпо 20 товарам из 4000 не имеет смысла, а честно посчитать его на клиенте можно только выкачав весь каталог. - Дельт не было вовсе. Во всех адаптерах фронта поля
*Deltaзахардкожены вnull(digitization/adapters/mappers.ts,autobidder/adapters/mappers.ts): чтобы получить изменение, нужен второй агрегат за предыдущий период, а это уже удвоение и без того дорогого клиентского цикла.
Отдельная проблема — расхождение форм. Каждый модуль, дойдя до KPI, изобрёл бы
свой конверт ({ total, prev } / { value, change } / плоские поля), и фронту
пришлось бы держать по адаптеру на модуль.
Рассмотренные варианты
-
Один общий эндпоинт
GET /summary?modules=a,b,c. Плюс: одна дорога вместо десяти. Минусы решающие: ответ — union разнородных схем, которую Swagger описывает только черезoneOfбез дискриминатора; RBAC и ограничения периода у модулей разные; отказ одного модуля роняет всю ленту. -
*Deltaкак абсолютная разница. Плюс: нет нулевого знаменателя. Минус: фронт уже рисует дельту процентом —MetricDeltaвызываетformatPercent(delta), а тот делит на 100. Абсолютная разница отобразилась бы как «+1 200 000 %». -
Свободные
dateFrom/dateToвместоperiod. Плюс: гибкость. Минусы: каждое окно считается дважды (текущий + предыдущий), поэтому произвольный период удваивает и без того тяжёлую свёртку, а кеш по ключу периода теряет смысл. Отчёты за произвольный период уже есть — это профильные эндпоинты. -
Per-module
GET /<module>/summaryс общим конвертом и общим хелпером периодов.
Решение
Выбран вариант 4.
Контракт.
GET /<module>/summary?wbAccountId={uuid}&period=7d|30d
→ { period, currency, updatedAt, <поля модуля>, <поле>Delta… }
period— закрытый перечень (7d/30d), по умолчанию30d;<field>Delta— относительное изменение к предыдущему периоду той же длины, в процентах (100= «вдвое больше»). Совпадает с тем, что уже рисуетMetricDeltaна фронте;nullв дельте означает «сравнивать не с чем» (база неизвестна или равна нулю), а не «нет изменений». Нулевой знаменатель — не бесконечный рост;currency+updatedAtесть всегда: лента показывает время расчёта ответа. Свежесть данных — отдельные поля источника (priceSyncedAt,lastSyncAt).
Переиспользуемый хелпер — src/core/summary:
resolveSummaryWindows(period, now?)→ границы текущего и предыдущего окна;computeDelta(current, previous)→ процент илиnull;SummaryPeriodQueryDto(толькоperiod) иSummaryEnvelopeDto(period/currency/updatedAt) — модуль добавляет своё наследованием.
Окна — календарные сутки UTC, встык, без пересечения. Границы нормализуются
до дня: KPI сравнивает сутки, поэтому запрос в 00:30 и в 23:30 одного дня обязан
дать одинаковый ответ — иначе дельта «дышит» в течение дня, а кеш по ключу
периода бесполезен. previous.dateTo — день перед current.dateFrom:
пересечение окон завысило бы базу и систематически занижало дельту.
core остаётся свободным от домена. wbAccountId в core/summary не
живёт — его добавляет DTO модуля. Это сохраняет правило раскладки ADR-0002.
Фаза 1 (этот тикет): pricing, repricer, autobidder, autoreply,
digitization. Остальные модули — по мере обновления адаптеров; форма для них
уже зафиксирована.
Последствия
-
Положительные:
- KPI-лента — один запрос на модуль вместо обхода list-эндпоинтов;
- дельты появились впервые: фронт снимает захардкоженные
null; - агрегаты честные — считаются в SQL по всему набору, а не по странице;
- новый модуль получает форму KPI бесплатно (два класса + два вызова).
-
Отрицательные / компромиссы:
period≠ период list-эндпоинта. Лента и таблица под ней могут показывать разные окна, если UI даёт таблице свой фильтр. Это осознанно: альтернатива — свободный период, см. вариант 3;- показатели-срезы (сколько товаров в каталоге сейчас) не имеют
исторического ряда, поэтому их дельта честно отдаётся как
null, а не подделывается. Дельта есть у показателей-потоков (изменения цен, прогоны, ставки, продажи); - дельта в процентах теряет знакомую величину при малой базе: рост 1 → 3 это «+200 %». Осознанно: так рисует фронт;
- два окна = примерно двойная стоимость свёртки. Отсюда закрытый перечень периодов и кеш там, где агрегат тяжёлый.
-
Дальше: распространить на §5.7–5.15 отдельными подзадачами (бухгалтерия, товарный учёт, A/B, база знаний).