Что болело до нас

Отдел маркетплейсов вёл P&L и юнит-экономику примерно сорока клиентов Ozon и Wildberries в Google-таблицах. Цифры переносили из кабинетов руками. Ошибка в одной формуле жила месяцами и ничем себя не выдавала: таблица считалась, итог выглядел правдоподобно.

Боль была глубже таблиц. То, что маркетплейс показывает как прибыль, прибылью не является.

  • Wildberries удерживает из выручки не всю рекламу: та, что оплачена пополнением рекламного счёта с расчётного счёта, в отчёт реализации не попадает вовсе
  • Удержания приходят одним числом, без расшифровки по видам
  • Прошлые дни площадка переписывает задним числом неделями, вчерашняя цифра сегодня уже другая
  • Итог: продавец видел завышенную прибыль, и размер завышения зависел от того, каким способом он оплатил рекламу

Что сделали

Собрали сервис, где день по каждому кабинету лежит одной строкой и разворачивается до строк отчёта площадки.

  • Ежедневный P&L отдельно по Ozon и по Wildberries: расходы развёрнуты до строк кабинета (комиссия, логистика, хранение, приёмка, эквайринг, штрафы, прочие удержания), сходимость «к перечислению за товар минус расходы = итого к оплате» видно глазами
  • Юнит-экономика по каждому SKU, FBO и FBS параллельно: логистика по объёму с индексом локализации и коэффициентом склада, процент выкупа, возвратная логистика, налог по режиму клиента. Себестоимость грузится пачкой из Excel, расчёт выгружается в CSV
  • Расшифровка удержаний Wildberries: вид удержания, сумма, доплата, нетто, список дней. Если площадка не прислала названия видов, сервис пишет об этом прямо
  • Диагностика оплаты рекламы: сколько ушло со счёта, сколько с баланса, сколько бонусами, сколько площадка удержала за продвижение и сколько рекламы физически отсутствует в прибыли. Бонусы отделены, это не деньги
  • Рубильник «реклама как расход в прибыли» на уровне проекта, по умолчанию выключен. Налог пересчитывается сам, удержанное за продвижение возвращается обратно, чтобы реклама не вычлась дважды
  • Колонка «Доп. правка» для расхода, которого нет в отчёте площадки: вводится по дням и переживает ночной пересбор
  • Вокруг ядра выросли разделы: автобидер WB и Ozon, дневник артикулов, оборачиваемость с сезонностью, снимок остатков по складам, ABC-анализ, отчёт клиенту картинкой
!
Правило, которое мы записали себе

Готовое поле в базе не равно тому, что человек видит на экране. Любая выгрузка обязана повторять расчёт страницы. Формула живёт в двух местах, править надо оба, иначе ошибки не будет нигде, просто цифры в двух системах разойдутся.

Как это устроено

Ночью сервис сам обходит по API кабинеты каждого клиента и складывает день в одну строку базы: выручка, удержания площадки по видам, реклама, себестоимость, налог. Днём страницы читают только эту базу. Поэтому таблица за месяц открывается сразу и не зависит от того, отвечает ли сейчас маркетплейс. Сбор по сорока клиентам разнесён по времени с интервалом 75 секунд и укладывается примерно в 50 минут.

Площадки правят прошлое задним числом, поэтому каждую ночь пересобирается скользящее окно: 30 дней у Wildberries, 14 у Ozon. Параллельно хранится замороженный снимок дня, чтобы отчёт, показанный клиенту неделю назад, не поменялся сам собой.

Всё спорное (себестоимость, налог, ручные правки) считается в момент показа. В дневную строку эти величины не пишутся, поэтому ночной сбор их не затирает.

  • Python 3.12 и FastAPI, PostgreSQL 16, ночные задачи на Celery и Redis
  • Next.js на TypeScript и Tailwind для интерфейса
  • Ozon Seller API и Performance API, у Wildberries Statistics, Finance, Content, Analytics и Promotion
  • Ключи кабинетов зашифрованы AES-256-GCM, авторизация по JWT
  • Docker, сборка образов в изолированном облаке, сервер забирает готовое сам за 2-3 минуты после пуша

Грабли: где цифры расходились

Готовое поле в базе не равно тому, что человек видит на экране. Обожглись дважды подряд. Первый раз с фактом: во внутреннюю отчётность агентства уходило готовое поле дневной прибыли, а страница P&L считает себестоимость на лету из продаж по SKU и налог по текущему режиму клиента. За один день у одного из клиентов это дало 176 491 ₽ в отчёте против 72 083 ₽ на экране. Внутри разницы 93 800 ₽ чистой себестоимости, которая просто оставалась в прибыли.

Второй раз с планом. Плановая цифра лежит в базе только у тех клиентов, кому ячейку однажды заполнили руками. У остальных страница досчитывает её из плана продаж по коэффициентам. План молча не приезжал у большинства.

Прибыль по Wildberries считается как «итого к оплате минус себестоимость», где «итого к оплате» это то, что площадка реально перевела. У одного из клиентов рекламный API показал 279 613 ₽ за месяц, а удержаний за продвижение в отчёте нашлось на 36 391 ₽. Остальное отсутствовало в расчёте, и прибыль была завышена ровно на эту разницу. Просто вычесть рекламу было нельзя: удержанная часть уже сидит внутри «итого к оплате» и вычлась бы дважды. Продвижение вынесли отдельной колонкой, вернули в прибыль и оставили сходимость, которую видно глазами.

Столбец удержаний не сходился со своим итогом. Ячейки рисовались функцией, которая показывала значение только больше нуля, остальное превращала в прочерк, а итоговая строка суммировала со знаком. Вышло «итого 42 736» при видимых днях 42 990 и 35 583. Минусы были на месте, просто невидимы. Сам минус законный: это нетто удержаний и доплат, и в день, когда площадка доплатила больше, чем удержала, значение уходит в минус в пользу продавца.

Грабли: тишина, площадки, раскатка

Тишина вместо ошибки хуже ошибки. У одного клиента P&L Wildberries был пуст, у остальных работал. Причина: в токене оказался пробел в конце, заголовок авторизации уезжал битым, площадка отбивала запросы. Страницы читают только базу и честно отдавали пустой список, интерфейс не видел никакой ошибки. Теперь сборщик в таких случаях ставит статус «частично» с причиной по-русски, а ответ 401 или 403 превращается в подсказку про категорию токена.

Площадки меняются под ногами. Ozon отдаёт метрики массивом по позиции, без имён. Когда часть метрик закрыта подпиской, ответ приходит короче запроса, и значения молча съезжают в чужие колонки: заказы оказывались в показах, выручка в кликах. Лечится пробой длины перед сбором и явной таблицей соответствия. Wildberries отключил метод остатков, наш код гасил 404 как некритичный, остатки перестали обновляться незаметно. Новый метод не отдаёт артикул поставщика вообще, поэтому все 52 строки отбраковывались, товар пришлось опознавать по внутреннему идентификатору. Вывод: где площадка не даёт метрику, ставим пустое значение. Ноль подставлять нельзя, иначе команда решит, что товар не показывается. Закрытое подпиской показываем закрытым, лимиты площадок уважаем.

Арифметику сверяли с кабинетом вручную, до штуки и до рубля. Платное хранение сначала считали как цену умножить на количество, выходил раздув в десятки раз: в ответе уже лежит готовая стоимость строки. Заказы собираем из двух источников и по каждой дате берём тот, где заказов больше, потому что в финотчёте текущая незакрытая неделя ещё пуста. Логистика начисляется на каждый заказанный товар, поэтому считаем уникальные отправления.

Любая функция, меняющая деньги на экране клиента, включается рубильником на одном проекте, по умолчанию выключено. Новые поля сборщик заполняет всегда, независимо от рубильника, иначе при включении нечего было бы пересчитать задним числом. Щелчок меняет прибыль, налог и маржу сразу за все месяцы, поэтому делается после согласования.

Частые вопросы

Почему прибыль на Wildberries не сходится с расчётным счётом?

Площадка удерживает из выручки не всю рекламу. Та, что оплачена пополнением рекламного счёта с расчётного счёта, в отчёт реализации не попадает вовсе. У одного клиента рекламный API показал 279 613 ₽ за месяц, а удержаний за продвижение в отчёте нашлось только на 36 391 ₽. Эта разница оставалась внутри прибыли, и продавец видел её завышенной ровно на неё.

Как считать юнит-экономику по каждому SKU на Ozon и Wildberries?

Расчёт идёт по каждому SKU отдельно, FBO и FBS параллельно. Учитываются логистика по объёму с индексом локализации и коэффициентом склада, процент выкупа, возвратная логистика и налог по режиму клиента. Себестоимость грузится пачкой из Excel, готовый расчёт выгружается в CSV.

Почему вчерашние цифры из кабинета маркетплейса меняются задним числом?

Площадки правят прошлые дни неделями, поэтому цифра за вчера сегодня уже другая. Сервис каждую ночь пересобирает скользящее окно: 30 дней у Wildberries и 14 дней у Ozon. Параллельно хранится замороженный снимок дня, чтобы отчёт, показанный клиенту неделю назад, не поменялся сам собой.

Чем заменить Google-таблицы для P&L по маркетплейсам?

До сервиса отдел вёл P&L примерно сорока клиентов в Google-таблицах и переносил цифры из кабинетов руками, ошибка в одной формуле жила месяцами. Теперь ночью сервис сам обходит кабинеты по API и складывает день в одну строку базы. Сбор по сорока клиентам разнесён с интервалом 75 секунд и укладывается примерно в 50 минут. Днём страницы читают только базу, поэтому таблица за месяц открывается сразу.

Как понять, что данные из кабинета маркетплейса не пришли?

Тишина вместо ошибки хуже самой ошибки. У одного клиента P&L Wildberries был пуст из-за пробела в конце токена: заголовок авторизации уезжал битым, площадка отбивала запросы, а интерфейс молча показывал пустой список. Теперь сборщик ставит статус «частично» с причиной по-русски, а ответы 401 и 403 превращаются в подсказку про категорию токена. Там, где площадка не отдаёт метрику, ставится пустое значение: ноль подставлять нельзя, иначе можно решить, что товар не показывается.