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

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 считается до себестоимости (операционный результат к перечислению), а себестоимость подключается отдельным тикетом, когда появится источник.

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

Источник выручки и возвратов​

  1. Единый источник — отчёт о реализации wb_report_lines (выбран). Канонический финотчёт WB на уровне строки уже содержит и продажи, и возвраты (строки возврата несут отрицательный ppvz_for_pay), и все составляющие затрат. Период фильтруется тем же MSK-правилом, что и сводка начислений (OP-55).
    • Плюсы: один согласованный финансовый источник; возвраты учитываются автоматически (без сшивки с wb_sales); переиспользуется готовая инфраструктура OP-55 (extractCharges, фильтр периода).
  2. Кросс-таблица: выручка из wb_sales (forPay/finishedPrice), затраты из wb_report_lines, join по srid. Отвергнут: два разных поля дат и два MSK-фильтра, ненадёжная сшивка по srid, риск двойного учёта и расхождений между упрощённым фидом продаж и финотчётом.

Определение «выручки» и формула прибыли​

  1. Выручка = ppvz_for_pay + комиссия; прибыль = выручка − затраты + доплаты (выбран). WB-поле ppvz_for_pay («К перечислению продавцу за реализованный товар») уже за вычетом комиссии WB; прибавляя комиссию обратно, получаем выручку «до удержания комиссии». Тогда прибыль = выручка − (комиссия + логистика + штраф + эквайринг + хранение + удержания) + доплаты тождественно равна WB-итогу к перечислению (ppvz_for_pay − логистика − штраф − эквайринг − хранение − удержания + доплаты).
    • Плюсы: совпадает с фактической выплатой WB (аудируемо); даёт прозрачную разбивку «выручка − затраты» как просит ТЗ; комиссия — отдельная видимая строка затрат, не теряется внутри ppvz_for_pay.
  2. Выручка = retail_amount (валовая розница) − все затраты. Отвергнут: retail_amount («Вайлдберриз реализовал Товар (Пр)») в данных WB не согласуется с retail_price/ppvz_for_pay (разные базы), поэтому даёт нестабильный, трудно аудируемый результат.

Слой расчёта​

  1. Чистая computeProfitByArticle(lines) (выбран): группировка по nm_id, суммы в копейках, переиспользует extractCharges (DRY с OP-55). Детерминированно, легко покрыть тестами на эталонных данных (AC3). Сервис читает строки за период и зовёт функцию.
  2. 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 (один источник логики).