Сверка финансового отчёта Wildberries двумя независимыми источниками: почему «сверху» сходится, а по артикулу нет
Итог по кабинету у меня сходился с отчётом Wildberries до копейки, а прибыль по артикулам при этом была посчитана неверно.
Я пишу собственный движок финансового разбора кабинета WB на Python (pandas и openpyxl): на входе Excel-выгрузки, на выходе результат по каждому артикулу и объяснение, куда ушли деньги. Ранние версии проходили сверку с отчётом WB, и я считал задачу решённой. Зря: совпадение итога почти ничего не говорит о раскладке по товарам. Все числа ниже демонстрационные, реальных кабинетов в статье нет.
Почему итог сходится, а артикул нет
В детализации WB деньги лежат в строках разных типов. Строка продажи или возврата несёт артикул, выручку, вознаграждение WB и «К перечислению продавцу». Строка логистики несёт артикул и стоимость доставки. Хранение, платная приёмка и прочие удержания в наших выгрузках к артикулу не привязаны, штрафы движок тоже относит к кабинету целиком.
Сумма колонки не зависит от того, какому товару приписана строка. Можно потерять логистику у трёх артикулов, отдать чужое хранение четвёртому, и итог останется верным до копейки. Совпадение сверху подтверждает, что файл прочитан полностью, а раскладку по артикулам не подтверждает.
В финансовых отчётах, которые я сверяю, контрольной суммы по артикулу нет: сводный разложен по статьям кабинета. Поэтому правильность по товару я доказываю косвенно: инвариантами, которые связывают сумму по артикулам с итогом кабинета.
Два источника
Первый: сводный еженедельный отчёт. Одна строка на отчёт, и WB сам разложил в ней кабинет на статьи: продажа, «К перечислению за товар», логистика, хранение, операции на приёмке, прочие удержания, штрафы, корректировка вознаграждения, лояльность, «Итого к оплате». Эти числа я не пересчитываю, для меня это контрольные суммы.
Второй: построчная детализация, те самые «обоснования для оплаты». Из неё строится всё, что относится к артикулу, и каждая статья сводного обязана совпасть с её агрегатом до копейки.
Оба файла выпускает WB, так что независимость здесь в агрегировании: сводный посчитан на их стороне, агрегат детализации на моей. Сверка доказывает, что я прочитал цифры WB полностью и правильно. Что WB начислил верно, она не доказывает, поэтому слов «ошибка WB» в выдаче нет.
Независимость кода, которую я сначала упустил
Первая версия проверки брала тот же резолвер колонок и те же агрегаты, что и основной расчёт. На ревью нашёлся сценарий: переименуй колонку хранения во что-то похожее, и нечёткое сопоставление уведёт на одну и ту же чужую колонку и расчёт, и проверку. Расхождение выйдет ровно нулевым.
Теперь верификатор читает исходный .xlsx или .zip своим кодом, через openpyxl напрямую, без pandas-слоя движка, и сопоставляет шапку только по точным именам колонок WB:
# белый список точных имён: чего нет в списке, того нет в файле
CANON = {
"doc_type": {"тип документа"},
"to_seller": {"к перечислению продавцу за реализованный товар"},
"storage": {"хранение", "стоимость хранения"},
# ... ещё девять статей
}
hits = [i for i, n in enumerate(norm) if n in variants]
# одно совпадение: колонка найдена; два: ambiguous; ноль: missing
Если колонки нет в списке, а движок нашёл по этой статье деньги, выдача блокируется. Считает верификатор двумя методами. Метод A складывает строки со знаком по «Типу документа» и сверяется с движком по выручке и «К перечислению». Метод B восстанавливает «Итого к оплате» по тождеству WB и сверяется со сводным.
Третий уровень независимости в тестах: генератор синтетики ведёт собственный учёт сумм, отдельно от парсера, и парсер проверяется против заранее известной истины, а не против себя.
Как устроена сверка
Приём и колонки
Тип файла определяется по содержимому: у детализации есть «Тип документа», «Обоснование для оплаты», «Артикул поставщика», у сводного есть «Итого к оплате». Ключ недели: дата начала. За неделю WB может отдать два отчёта, «Основной» и «По выкупам». Это части одной недели, их строки складываются. Дублем считается повтор того же типа за ту же неделю: он не суммируется второй раз и называется в выводе прогона.
В белом списке лежат имена для форматов на 73, 74, 82 и 83 колонки, номера колонок между ними не совпадают. Движок обращается к колонкам только по имени. Резолвер идёт в два прохода: сначала точные совпадения, которые резервируют колонку, потом нечёткие среди свободных, и каждое нечёткое пишется в предупреждения.
Знак и тождество
Выручку и «К перечислению» движок суммирует со знаком по «Типу документа»: продажа с плюсом, возврат с минусом. Остальные статьи берутся суммой своей колонки. Итог недели восстанавливается тождеством:
def net_from_components(r):
# компенсация лояльности уже внутри to_seller, второй раз не прибавляется
return (r["to_seller"] - r["logistics"] - r["storage"] - r["acceptance"]
- r["withhold"] - r["fines"] - r["correction"]
- r["loyalty_participation"] - r["loyalty_points"] - r["onetime"])
Комиссии в тождестве нет: вознаграждение WB уже вычтено внутри «К перечислению». По артикулу я показываю его справочно, из колонок вознаграждения и НДС с него.
Инварианты по артикулу
Сумма выручки по артикулам равна «Продаже» сводного, сумма «К перечислению» равна одноимённой статье, сумма логистики равна логистике. «Остаётся от WB» (к перечислению минус логистика) по всем артикулам за вычетом кабинетных расходов равно «Итого к оплате». Штрафы и удержания, разложенные по видам, в сумме дают свою статью. Кабинетные расходы идут отдельным блоком и по товарам не разносятся.
Что считается расхождением
Допуск в сверке статей копейка, в верификаторе полкопейки: это поправки на float. В выдаче расхождение должно быть ровно 0,00 ₽. Блокирует любое из условий: статья недели не совпала со сводным, метод A разошёлся с движком, метод B разошёлся со сводным, колонка встретилась дважды, колонки нет в списке при ненулевых деньгах. Тогда вместо разбора выводится список расхождений по неделям.
Неполный сводный расхождением не считается: недели без него помечаются «проверено только тождеством». Неделя, где строк в разы меньше медианы периода, получает пометку, но выдачу не блокирует.
Пример: ART-001 и соседи
Четыре недели, три артикула. У ART-001 продажи на 10 000,00 ₽ и возврат на 1 000,00 ₽, у ART-002 пять продаж, у ART-003 в периоде только возврат, продажа была раньше. Числа демонстрационные.
Артикул | Продано / возврат, шт | Выручка, ₽ | К перечислению, ₽ | Логистика, ₽ | Остаётся от WB, ₽ |
|---|---|---|---|---|---|
ART-001 | 10 / 1 | 9 000,00 | 7 200,00 | 1 200,00 | 6 000,00 |
ART-002 | 5 / 0 | 5 000,00 | 4 100,00 | 900,00 | 3 200,00 |
ART-003 | 0 / 1 | -700,00 | -560,00 | 150,00 | -710,00 |
Итого по товарам | 13 300,00 | 10 740,00 | 2 250,00 | 8 490,00 |
Без артикула: хранение 600,00, приёмка 150,00, штрафы 200,00, прочие удержания 500,00, всего 1 450,00. Итого к оплате: 8 490,00 минус 1 450,00, то есть 7 040,00. Сводный показывает те же 13 300,00, 10 740,00, 2 250,00, 1 450,00 и 7 040,00, разница по каждой статье 0,00.
Теперь четыре способа сломать этот набор.
Сложить выручку без знака. Выйдет 16 700,00, разница 3 400,00 равна удвоенным возвратам. Сверху видно сразу: не совпала «Продажа».
Прибавить компенсацию скидки по программе лояльности ещё раз. Если внутри «К перечислению» у ART-001 сидит 300,00 компенсации, итог станет 7 340,00, ошибка ровно равна компенсации. Сверху тоже видно.
Собрать P&L по артикулу только из строк «Продажа» и «Возврат». В наших выгрузках строки логистики идут с пустым «Типом документа», хотя артикул несут. У ART-001 получится 7 200,00 вместо 6 000,00, ART-003 станет на 150,00 лучше, чем есть. Логистику кабинета легко взять суммой колонки, и «Итого к оплате» снова 7 040,00. Ловит только инвариант: логистика по артикулам 0,00 против 2 250,00 в статье.
Разнести 1 450,00 кабинетных расходов пропорционально выручке. ART-001 получит 932,14, ART-002 517,86, ART-003 ноль, положительной выручки у него нет. Сумма 1 450,00, итог сходится. Но хранение зависит от объёма и срока лежания, а не от выручки, и медленный товар получает заниженную нагрузку. Цифра с копейками выглядит точной, хотя это оценка, поэтому у меня кабинетные расходы идут отдельным листом.
Две ошибки из четырёх сверху не видны.
Где ловили ошибки
Чужая колонка
В ранней выдаче «Хранение» за неделю выглядело как выручка той же недели: нечёткое сопоставление взяло не ту колонку. Отсюда резервирование точных совпадений и белый список в верификаторе.
Потерянный знак
Кабинетные расходы на одном листе вычитались, а на обложке прибавлялись: «вычли расход, стало больше денег». Инвариант «остаётся по товарам минус кабинетные равно итогу к оплате» такое не пропускает. Теперь удержания везде положительные, минус стоит только в водопаде.
Отбор по чужой метрике
В блок «меньше всего остаётся» попал товар номер один по сумме: ключ сортировки не совпадал с подписанной колонкой. Там же стояло «Товаров в минусе: 0», хотя такие строки были. Фильтр отсекал артикулы без продаж в окне, как раз товары вроде ART-003. Сверху не видно ни того, ни другого.
Короткий период
В нашей практике между заказом и доставкой проходит от 1 до 12 дней, продажа и её логистика могут попасть в разные недели. Поэтому P&L по товарам движок выводит от четырёх недель, а на коротком периоде показывает только структуру удержаний без вердиктов «прибыльный» и «убыточный».
Файл, который врёт о своём размере
Реальные выгрузки WB, которые мне попадались, объявляют лист размером в одну ячейку (<dimension ref="A1">). openpyxl в режиме read-only верит метке и отдаёт одну ячейку. Верификатор не находил шапку, насчитывал ноль и блокировал каждый реальный разбор. Движок проблему не замечал: pandas сам пересчитывает границы листа, а синтетика в тестах пишется с честным размером. Лечится вызовом reset_dimensions(). Хорошо, что сломанная проверка дала ложную блокировку, а не ложное «сошлось».
Спорное и необычное
Первая версия поиска сомнительных удержаний нашла десять «находок» на синтетике без единой аномалии: спорными объявлялись доля логистики выше фиксированного порога, обычные списания за продвижение и логистика по старым заказам. Теперь спорным считается только явный признак в самом отчёте. Категорий пять: двойная логистика по одному отправлению, повторная логистика по одному Srid в разных неделях, двойное списание одной услуги, штраф без вида нарушения и основания, сторно без исходной операции.
Остальное идёт в наблюдения, без суммы претензии. Неделя сравнивается с медианой той же статьи по периоду этого кабинета и отмечается при превышении в 1,5 раза. Фиксированные пороги на реальном кабинете с дорогой по природе логистикой горели каждую неделю и ничего не сообщали.
Что автоматизировано, а что ручное
Автоматически: определение типа файлов, сборка недель, пропуски и дубли, разрешение колонок, сверка каждой статьи каждой недели, независимый пересчёт с блокировкой, P&L с инвариантами, спорное и наблюдения, вырезание ИНН и идентификаторов из оснований удержаний, Excel и текстовое резюме. Тестов на синтетике сейчас 202.
Ручным остаётся прогон на реальном архиве. Реальные отчёты конфиденциальны и в git не попадают, в CI их нет, а баг с размером листа как раз прошёл всю синтетику. Движок по архиву я запускаю сам и жду 0,00 ₽ по каждой неделе.
Интерпретацию делает человек. Движок готовит текст запроса как просьбу о пересчёте и обосновании, без юридических утверждений, а стоит ли его отправлять, решаю я.
Часть данных в финотчёте отсутствует. Себестоимость приходит отдельным справочником, без него колонок прибыли в выдаче нет совсем. Реклама с рекламного баланса в финотчёт не попадает, видны только списания за продвижение среди прочих удержаний. Процент выкупа я не считаю: финотчёт описывает денежные движения, а не заказы, и отменённых до отгрузки заказов в нём нет.
И есть непроверенное: правила спорного ещё не проверены на подтверждённых срабатываниях в реальных данных, а выгрузку на 83 колонки с реального кабинета я пока не прогонял.
Выводы
Совпадение итога со сводным WB необходимо, но проверяет оно полноту чтения, а не раскладку по товарам.
Правильность по артикулу доказывается инвариантами: суммы по товарам дают статьи сводного, остаток по товарам за вычетом кабинетных расходов даёт «Итого к оплате».
Проверка должна быть независимой по коду: своё чтение файла, свой список колонок, свой метод счёта. Если она делит резолвер с расчётом, нулевое расхождение ничего не доказывает.
Расходы без артикула честнее показать отдельной строкой, чем размазать по товарам с копеечной точностью. А синтетика ловит ошибки логики, но не форматы реальных выгрузок, поэтому прогон на настоящих файлах у меня остаётся ручным шагом.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.