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

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-эндпоинт (иногда все страницы) и складывал числа на клиенте.

Два следствия, и второе хуже первого:

  1. Лишние запросы и неверные итоги. Агрегат по странице — не агрегат по набору: avgDiscount по 20 товарам из 4000 не имеет смысла, а честно посчитать его на клиенте можно только выкачав весь каталог.
  2. Дельт не было вовсе. Во всех адаптерах фронта поля *Delta захардкожены в null (digitization/adapters/mappers.ts, autobidder/adapters/mappers.ts): чтобы получить изменение, нужен второй агрегат за предыдущий период, а это уже удвоение и без того дорогого клиентского цикла.

Отдельная проблема — расхождение форм. Каждый модуль, дойдя до KPI, изобрёл бы свой конверт ({ total, prev } / { value, change } / плоские поля), и фронту пришлось бы держать по адаптеру на модуль.

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

  1. Один общий эндпоинт GET /summary?modules=a,b,c. Плюс: одна дорога вместо десяти. Минусы решающие: ответ — union разнородных схем, которую Swagger описывает только через oneOf без дискриминатора; RBAC и ограничения периода у модулей разные; отказ одного модуля роняет всю ленту.

  2. *Delta как абсолютная разница. Плюс: нет нулевого знаменателя. Минус: фронт уже рисует дельту процентом — MetricDelta вызывает formatPercent(delta), а тот делит на 100. Абсолютная разница отобразилась бы как «+1 200 000 %».

  3. Свободные dateFrom/dateTo вместо period. Плюс: гибкость. Минусы: каждое окно считается дважды (текущий + предыдущий), поэтому произвольный период удваивает и без того тяжёлую свёртку, а кеш по ключу периода теряет смысл. Отчёты за произвольный период уже есть — это профильные эндпоинты.

  4. 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, база знаний).