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 давал не топ по выручке, а
десять наименьших артикулов кабинета — для инструмента ИИ-помощника, читающего первую
страницу, это фактически произвольная выборка.
Рассмотренные варианты
- Поднять
MAX_LIMITпагинации. Одна строка правки, но ломает общий контракт пагинации (OP-27) ради одного экрана и не устраняет ни повторную агрегацию, ни отсутствие сортировки — просто делает страницу больше. - Отдавать первую страницу с totals. Клиент всё равно не получает per-SKU количества по всем артикулам, а «первая страница» без сортировки — не топ.
- Отдельный эндпоинт «KPI за период» (одни агрегаты, без разреза по артикулам). Закрывает KPI, но не закрывает таблицу и топ SKU — экрану всё равно нужен per-SKU разрез, то есть второй обход.
- Счётчики в строке
/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) — все читатели фида считают одинаковые числа за одинаковый период.
- Экран оцифровки собирается двумя запросами вместо 50–100: исчез клиентский
обход
- Отрицательные / компромиссы:
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к предыдущему периоду — отдельным тикетом.