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

0050. Счётчики продаж в P&L и непагинированный агрегат продаж за период

Копия ADR из репозитория: backend/docs/adr/0050-digitization-screen-single-request-sales-counters.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-08-25
  • Связанные задачи: OP-222 (5.1 · оцифровка); опирается на ADR-0018 (расчёт P&L), ADR-0019 (агрегированные отчёты) и ADR-0046 (лимит периода + свёртка в SQL)

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

Экран оцифровки собирает P&L из GET /digitization/profit (массив без пагинации), а количества проданного и возвращённого — только из фида wb_sales, читающегося пагинированным GET /digitization/reports/sales (MAX_LIMIT = 100).

После ADR-0046 эта ручка выполняет полную SQL-агрегацию за период на каждый запрос и лишь потом режет результат до 100 строк в памяти. Чтобы получить KPI и топ SKU, фронт был вынужден обойти все страницы: 50–100 запросов, каждый — повтор одной и той же свёртки. Пагинация таблицы P&L от этого не зависит вовсе — она режется на клиенте из /profit.

Практический итог до этой доработки: фронт не ходил в sales, если за окно есть строки отчёта реализации, и показывал в KPI и таблице «—» вместо заказов и возвратов.

Дополнительно: выборка reports/sales сортировалась по nm_id ASC (порядок детерминирован, applySalesAggregation), но не по деньгам, поэтому limit=10 давал не топ по выручке, а десять наименьших артикулов кабинета — для инструмента ИИ-помощника, читающего первую страницу, это фактически произвольная выборка.

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

  1. Поднять MAX_LIMIT пагинации. Одна строка правки, но ломает общий контракт пагинации (OP-27) ради одного экрана и не устраняет ни повторную агрегацию, ни отсутствие сортировки — просто делает страницу больше.
  2. Отдавать первую страницу с totals. Клиент всё равно не получает per-SKU количества по всем артикулам, а «первая страница» без сортировки — не топ.
  3. Отдельный эндпоинт «KPI за период» (одни агрегаты, без разреза по артикулам). Закрывает KPI, но не закрывает таблицу и топ SKU — экрану всё равно нужен per-SKU разрез, то есть второй обход.
  4. Счётчики в строке /profit + непагинированный GET /digitization/sales — выбранный вариант.

Решение​

1. GET /digitization/profit отдаёт деньги и количества одной строкой. ArticleProfitWithSalesDto (наследник ArticleProfitDto) добавляет salesCount, returnsCount, returnsAmount. Деньги P&L (revenue/charges/profit) остаются исключительно из отчёта о реализации — фид на них не влияет; количества приходят из wb_sales за тот же период и сшиваются по nmId чистой функцией mergeSalesCountersIntoProfit. Состав ответа задаёт отчёт: артикул без строк фида получает нули (не пропуск поля), артикул без строк отчёта в P&L не появляется вовсе.

Фид читается одним SQL-агрегатом за период, параллельно (Promise.all) выборке строк отчёта — не запрос на артикул.

Счётчики живут в отдельном методе WbFinancialReportService.profitWithSalesByArticle; прежний profitByArticle остался чистым расчётом по отчёту. На нём стоит бухгалтерия (AccountingService: ledger, сверка, выгрузка) — она не платит за агрегат фида, результат которого всё равно отбросила бы.

2. Новый GET /digitization/sales?wbAccountId=&dateFrom=&dateTo= → ArticleSalesReportDto[]. Тот же FinancialReportPeriodDto, что у /profit, без page/limit. Нужен экрану, когда за окно ещё нет строк отчёта: KPI и топ считаются по фидам. Непагинированность — осознанное отступление от общего контракта OP-27: ответ ограничен не числом строк фида, а числом артикулов кабинета за период, а длина периода уже ограничена REPORT_MAX_PERIOD_DAYS (ADR-0046) — тем же потолком, под которым /profit и financial-report/summary возвращают массив с самого начала.

3. Единый источник свёртки — wb-sales-period.query.ts. querySalesByArticle(repo, filters) строит запрос (скоуп кабинета + salesPeriodClause + опциональные фильтры артикул/бренд), гоняет существующую applySalesAggregation и сортирует результат: выручка ↓, число продаж ↓, артикул ↑. Им пользуются все три читателя фида — /digitization/sales, счётчики в /digitization/profit и пагинированный /digitization/reports/sales. Проверки периода и доступа к кабинету остаются в сервисах: 400/404 должны возвращаться до похода в БД.

4. GET /digitization/reports/sales сохранён для ИИ-помощника и прочих списков — сигнатура и пагинация не менялись. Он получил ту же сортировку до среза, поэтому limit=10 стал реальным топом.

Артикулы сравниваются как строки (nm_id — bigint): для неотрицательных целых числовой порядок = «сначала короче, при равной длине — лексикографически», без риска потери разрядов в Number().

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

  • Положительные:
    • Экран оцифровки собирается двумя запросами вместо 50–100: исчез клиентский обход page=1…N, каждый круг которого повторял полную SQL-агрегацию.
    • Заказы/возвраты появились в KPI и таблице, когда есть отчёт реализации, — раньше там стояло «—».
    • Первая страница reports/sales стала осмысленной: инструмент помощника с limit=10 получает топ по выручке, а не десять наименьших артикулов.
    • Окно продаж описано одним выражением (salesPeriodClause) — все читатели фида считают одинаковые числа за одинаковый период.
  • Отрицательные / компромиссы:
    • GET /digitization/profit делает два запроса в БД вместо одного (параллельно). Второй — та же агрегация wb_sales, которую до этого выполняла каждая из десятков страниц; суммарная нагрузка резко ниже, но профиль одного запроса стал тяжелее. Бухгалтерия этой цены не платит — она ходит в profitByArticle.
    • Счётчики и деньги описывают разные окна. Фид отбирается по дате операции (wb_sales.date), отчёт о реализации — по дате строки отчёта (rr_dt, reportLinesPeriodClause). Возврат конца января попадёт в returnsCount/ returnsAmount январской строки, а его удержание — в февральский отчёт, то есть в profit уже следующего периода. Поэтому returnsAmount документирован как справочная сумма по фиду: складывать её с revenue/profit в одну сверку нельзя. Расхождение — свойство данных WB (в отчёте нет даты самой операции), убрать его сшивкой невозможно; альтернатива — не отдавать деньги фида вовсе, но тогда экран лишается суммы возвратов, ради которой тикет и заведён.
    • Фид попал на горячий путь /profit, поэтому окно периода в salesPeriodClause записано полуинтервалом по колонке date (границы дня переведены в МСК), а не как (date AT TIME ZONE 'Europe/Moscow')::date BETWEEN …: вычисление над колонкой не даёт использовать idx_wb_sales_date, и стоимость запроса росла бы со всей историей фида кабинета, а не с длиной периода. Формы эквивалентны (Москва без перехода на летнее время с 2014; проверено на PostgreSQL 17 по обе стороны границ периода при разных session-TZ). Составной индекс (wb_account_id, date) — следующий шаг, если профиль запроса на проде это покажет: сейчас планировщик комбинирует индекс кабинета с индексом даты.
    • /digitization/sales не пагинирован: ответ растёт с числом артикулов кабинета за период. Защита — только потолок периода; при кабинетах с очень широким ассортиментом придётся возвращаться к постраничности или курсору.
    • GET /digitization/profit и GET /digitization/reports/profit возвращают теперь разные формы строки (вторая — без счётчиков): пагинированная ручка помощника намеренно не платит за второй агрегат.
    • Изменился порядок элементов reports/sales (был nm_id ASC) — для клиента, полагавшегося на прежний порядок, страницы поедут; в проекте таких нет.
  • Дальше: кеш тяжёлых агрегатов (F4) — он снимет и стоимость второго запроса в /profit; *Delta к предыдущему периоду — отдельным тикетом.