0018. Расчёт прибыли (P&L) по товару и периоду
Копия ADR из репозитория:
backend/docs/adr/0018-pnl-calculation.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-06-24
- Связанные задачи: OP-56 (5.1 · Оцифровка продаж / финансовый учёт)
Контекст и проблема
ТЗ 5.1: расчёт прибыли/маржинальности по артикулу за период — ключевой показатель для селлера и вход для рекомендаций ИИ. Блокеры OP-54 (импорт продаж/заказов/возвратов) и OP-55 (учёт комиссий/логистики/штрафов из отчёта о реализации) — Done. Нужно: определить источник «выручки» и формулу прибыли, корректно учесть возвраты, дать стабильный, покрытый тестами результат (AC3).
Открытый вопрос ТЗ — источник себестоимости (закупочные цены). На текущем этапе его нет: товарный учёт (5.11) не реализован. Поэтому P&L считается до себестоимости (операционный результат к перечислению), а себестоимость подключается отдельным тикетом, когда появится источник.
Рассмотренные варианты
Источник выручки и возвратов
- Единый источник — отчёт о реализации
wb_report_lines(выбран). Канонический финотчёт WB на уровне строки уже содержит и продажи, и возвраты (строки возврата несут отрицательныйppvz_for_pay), и все составляющие затрат. Период фильтруется тем же MSK-правилом, что и сводка начислений (OP-55).- Плюсы: один согласованный финансовый источник; возвраты учитываются автоматически (без сшивки с
wb_sales); переиспользуется готовая инфраструктура OP-55 (extractCharges, фильтр периода).
- Плюсы: один согласованный финансовый источник; возвраты учитываются автоматически (без сшивки с
- Кросс-таблица: выручка из
wb_sales(forPay/finishedPrice), затраты изwb_report_lines, join поsrid. Отвергнут: два разных поля дат и два MSK-фильтра, ненадёжная сшивка поsrid, риск двойного учёта и расхождений между упрощённым фидом продаж и финотчётом.
Определение «выручки» и формула прибыли
- Выручка =
ppvz_for_pay+ комиссия; прибыль = выручка − затраты + доплаты (выбран). WB-полеppvz_for_pay(«К перечислению продавцу за реализованный товар») уже за вычетом комиссии WB; прибавляя комиссию обратно, получаем выручку «до удержания комиссии». Тогдаприбыль = выручка − (комиссия + логистика + штраф + эквайринг + хранение + удержания) + доплатытождественно равна WB-итогу к перечислению (ppvz_for_pay − логистика − штраф − эквайринг − хранение − удержания + доплаты).- Плюсы: совпадает с фактической выплатой WB (аудируемо); даёт прозрачную разбивку «выручка − затраты»
как просит ТЗ; комиссия — отдельная видимая строка затрат, не теряется внутри
ppvz_for_pay.
- Плюсы: совпадает с фактической выплатой WB (аудируемо); даёт прозрачную разбивку «выручка − затраты»
как просит ТЗ; комиссия — отдельная видимая строка затрат, не теряется внутри
- Выручка =
retail_amount(валовая розница) − все затраты. Отвергнут:retail_amount(«Вайлдберриз реализовал Товар (Пр)») в данных WB не согласуется сretail_price/ppvz_for_pay(разные базы), поэтому даёт нестабильный, трудно аудируемый результат.
Слой расчёта
- Чистая
computeProfitByArticle(lines)(выбран): группировка поnm_id, суммы в копейках, переиспользуетextractCharges(DRY с OP-55). Детерминированно, легко покрыть тестами на эталонных данных (AC3). Сервис читает строки за период и зовёт функцию. - SQL
GROUP BY … SUM(...). Отвергнут как основной: расчёт «размазан» по SQL, сложнее юнит-тестировать; возможная оптимизация позже.
Решение
В модуле digitization (расширение OP-55):
ProfitBearingLine— поля строки для P&L: расширяетChargeBearingLine(затраты) полямиppvzForPayиadditionalPayment. РеализуетсяWbReportLineEntityи тест-фикстурами.computeProfitByArticle(lines)— чистая функция: на артикул считаетrevenue(=ppvz_for_pay+ комиссия), разбивкуchargesпоChargeType(черезextractCharges),chargesTotal,additionalPayment,profit(= revenue − chargesTotal + доплаты) иmargin(= profit/revenue·100;nullпри нулевой выручке). Суммы — в копейках (без float-дрейфа).WbFinancialReportService.profitByArticle(actor, dto): scope кабинета черезgetById, чтение строк за период (общий приватныйfindLinesForPeriod, вынесенный изsummaryByArticle— DRY), расчёт. ЭндпоинтGET /digitization/profit.- Общий хелпер
centsToStringвынесен вwb-mapping.util.tsи переиспользован агрегатором начислений (OP-55) и калькулятором прибыли.
Последствия
- Положительные: появился расчёт прибыли/маржинальности по артикулу за период — вход для API отчётов (OP-57) и рекомендаций ИИ. Расчёт — чистая функция, покрыт тестами на эталонных данных. Прибыль аудируема: равна фактической выплате WB до себестоимости.
- Границы / вне scope:
- Себестоимость (закупочные цены) — не учитывается: нет источника до товарного учёта (5.11). Подключается отдельным тикетом; текущая «прибыль» — операционный результат к перечислению.
- Кеширование агрегатов (F4) — отложено: AC требует корректности и стабильности, не производительности; расчёт — один SQL-read + in-memory reduce. Вводится при росте объёмов.
- Редкие cost-поля (
acceptance/rebill_logistic_cost) остаются вrawи вadditional_paymentне входят; расширяются при необходимости.
- Маржинальность не определена при нулевой/отрицательной нетто-выручке (например, период чистых
возвратов) — возвращается
null, чтобы не показывать бессмысленное значение. - MSK-фильтр периода — общий с OP-55 (один источник логики).