Интеграция с Wildberries: почему артикул — не уникальный ключ

Интеграция с Wildberries: почему артикул — не уникальный ключ

Три года назад я впервые подключил 1С к маркетплейсу. Казалось: ну что там сложного? API отдаёт заказы, в базе есть номенклатура, нужно просто связать одно с другим. Найти товар, проверить остатки, создать документ. Делов на пару дней.

С тех пор я собрал коллекцию историй, каждая из которых начинается одинаково: "У нас что-то не то отгружается". И почти каждая сводится к одному и тому же: мы неправильно определяем, какой именно товар имеется в виду.

Эта статья — про конкретный случай с Wildberries, который обошёлся в несколько десятков ошибочных отгрузок и одну неделю разбирательств. И про более широкую проблему: почему привычки из розничного учёта ломаются, когда вы выходите на маркетплейс.

Один артикул — два товара

Торговая сеть спортивной одежды. Несколько сотен SKU: кроссовки, футболки, худи, аксессуары. Продажи идут через собственные точки и через Wildberries. Задача интеграции стандартная: API маркетплейса отдаёт список новых заказов, для каждого нужно найти номенклатуру в базе 1С, подобрать склад с остатками, сформировать документ перемещения на точку выдачи.

Всё работало несколько месяцев без единой жалобы. А потом со склада пришло сообщение: клиент заказал футболку размера L/XL, а в документе перемещения стоит S/M. Товар тот же — тот же бренд, та же модель, тот же цвет. Размер другой.

Первая мысль: кладовщик ошибся. Проверяю заказ в личном кабинете WB — размер L/XL. Проверяю данные из API — размер L/XL. Проверяю документ в 1С — номенклатура "Футболка Nike Dri-FIT DX0006 S/M". Ошибка не на складе. Ошибка в коде.

Два товара с одинаковым артикулом DX0006 но разными размерами в справочнике номенклатуры 1С

Как работал код сопоставления

Открываю модуль интеграции. Логика поиска номенклатуры простая до прозрачности:

Запрос = Новый Запрос;
Запрос.Текст = 
    "ВЫБРАТЬ
    |   Номенклатура.Ссылка КАК Номенклатура
    |ИЗ
    |   Справочник.Номенклатура КАК Номенклатура
    |ГДЕ
    |   Номенклатура.АртикулПоставщика = &Артикул";
Запрос.УстановитьПараметр("Артикул", ДанныеЗаказа.supplierArticle);
Результат = Запрос.Выполнить().Выбрать();
Если Результат.Следующий() Тогда
    НайденнаяНоменклатура = Результат.Номенклатура;
КонецЕсли;

Видите проблему? Запрос ищет по артикулу поставщика и берёт первую попавшуюся запись. Если артикул уникален — всё отлично. Если нет — лотерея.

А в спортивной одежде артикул — это модель, не единица хранения. Футболка Nike Dri-FIT с артикулом DX0006 существует в справочнике как две разные номенклатуры: DX0006 S/M и DX0006 L/XL. Два товара. Один артикул. Запрос возвращает первый попавшийся — какой окажется выше в индексе таблицы, тот и попадёт в документ.

Иногда правильный. Иногда нет. Классический случай: баг, который не воспроизводится стабильно, потому что зависит от порядка записей в базе данных.

Почему проблема не проявлялась раньше

Обувь. У кроссовок каждый размер — это отдельный артикул. Полуботинки Nike Revolution 7 размера 42 имеют артикул FB2207-001, а размер 43 — FB2207-002. Аксессуары тоже: один артикул — один товар. Система работала корректно для 80% ассортимента.

Проблема вылезла только когда на Wildberries выставили одежду с размерным рядом. Это произошло тихо: менеджер загрузил карточки, заказы пошли, документы создавались. Никто не заметил разницу, пока клиент не пожаловался. А до того — сколько отгрузок было с неправильным размером? Мы проверили задним числом. 23 документа за две недели. В 9 из них размер не совпадал.

Решение: штрихкод вместо артикула

В API Wildberries у каждого заказа есть поле skus — массив штрихкодов. Штрихкод (баркод, EAN) уникален для конкретной комбинации модель + размер + цвет. Это именно тот уровень детализации, который нужен для однозначного сопоставления. И этот же штрихкод хранится в карточке номенклатуры в 1С — в реквизите или в регистре сведений "Штрихкоды".

Поле skus было в ответе API с самого начала. Код интеграции его просто игнорировал.

Фрагмент JSON-ответа API Wildberries с полями supplierArticle и skus для одного заказа

Исправленная логика работает в два шага:

  1. Приоритет — штрихкод. Берём массив skus из заказа и ищем номенклатуру, у которой штрихкод совпадает с любым значением из этого массива. Если нашли — готово, однозначное соответствие.
  2. Фолбэк — артикул. Если массив skus пуст (бывает в старых заказах) или штрихкод не найден в базе, тогда ищем по supplierArticle. Но теперь с оговоркой: если запрос вернул больше одной записи, документ не создаётся, а в лог пишется предупреждение для ручного разбора.
// Шаг 1: поиск по штрихкоду
НайденнаяНоменклатура = Неопределено;
Если ДанныеЗаказа.skus.Количество() > 0 Тогда
    Для Каждого Штрихкод Из ДанныеЗаказа.skus Цикл
        НайденнаяНоменклатура = НайтиНоменклатуруПоШтрихкоду(Штрихкод);
        Если ЗначениеЗаполнено(НайденнаяНоменклатура) Тогда
            Прервать;
        КонецЕсли;
    КонецЦикла;
КонецЕсли;

// Шаг 2: фолбэк на артикул
Если НЕ ЗначениеЗаполнено(НайденнаяНоменклатура) Тогда
    Результат = НайтиНоменклатуруПоАртикулу(ДанныеЗаказа.supplierArticle);
    Если Результат.Количество = 1 Тогда
        НайденнаяНоменклатура = Результат.Номенклатура;
    Иначе
        ЗаписатьВЛог("Неоднозначное сопоставление: артикул " 
            + ДанныеЗаказа.supplierArticle 
            + ", найдено записей: " + Результат.Количество);
    КонецЕсли;
КонецЕсли;

Одно поле. Один уровень приоритета. Разница — клиент получает свой размер или чужой.

После изменения мы прогнали все заказы за последний месяц через новую логику в тестовом режиме. Расхождения нашлись только в тех 9 документах, которые уже знали. Новых ошибок — ноль. Правка заняла полдня, включая тестирование.

Что вообще такое "артикул" на маркетплейсе

Вот в чём корень проблемы: слово "артикул" значит разные вещи для разных участников процесса.

  • Для поставщика артикул — это внутренний код модели. Он может быть уникальным (обувь), а может и нет (одежда с размерным рядом).
  • Для Wildberries supplierArticle — это то, что поставщик написал при создании карточки. Маркетплейс его не валидирует на уникальность. Может быть что угодно, вплоть до "футболка синяя".
  • Для 1С артикул — обычно реквизит справочника "Номенклатура". Уникальность зависит от настроек конкретной базы.
  • Для OZON артикул (offer_id) — это уже ключ конкретного SKU, уникальный в рамках продавца. Совсем другая семантика.

Когда разработчик интеграции пишет НайтиПоАртикулу(supplierArticle), он неявно предполагает, что все четыре определения совпадают. В нашем случае первые три разошлись, и система стала работать "иногда".

Универсальное правило: если маркетплейс даёт штрихкод — используйте штрихкод. Если даёт свой внутренний ID — храните его отдельно и сопоставляйте по нему. Артикул поставщика — последний рубеж обороны, а не первый.

Другие грабли из практики интеграций с маркетплейсами

За три года работы с маркетплейсами я накопил достаточно историй, чтобы заполнить отдельный блог. Вот несколько, которые запомнились.

OZON FBO и партионный учёт: когда FIFO играет против вас

Крупный поставщик продуктов питания, склад FBO на OZON. Возврат товара. По логике OZON, при возврате товар возвращается на остатки. В 1С нужно оприходовать возврат и привязать его к правильной партии — для учёта сроков годности и себестоимости.

Проблема: API возврата не говорит, из какой именно партии был товар. Разработчик интеграции решил вопрос "по логике FIFO": берём самую старую партию с остатками, к ней и привязываем возврат. Звучит разумно.

На практике получилось иначе. Когда в один день пришло 14 возвратов одного и того же SKU, система привязала все 14 к одной партии — самой старой. В результате на этой партии образовался отрицательный виртуальный остаток (возвратов больше, чем было продано из этой партии), а на других партиях остатки зависли. Бухгалтерия потратила два дня на ручной разбор.

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

Тестирование без реальных заказов

Отдельная боль — как тестировать интеграцию, если реальных заказов ещё нет или их нельзя трогать.

У Wildberries нет полноценного sandbox. Есть тестовые методы для некоторых эндпоинтов, но полную цепочку "заказ-сборка-отгрузка" в тестовом режиме прогнать нельзя. У OZON sandbox формально существует, но данные в нём устаревшие и поведение отличается от продуктива.

Что работает на практике:

  • Записывать реальные ответы API в файлы и прогонять обработку по ним в тестовой базе 1С. Грубо, но надёжно. Я храню набор из 15-20 типовых ответов: простой заказ, заказ с несколькими позициями, заказ с размерным рядом, отмена, частичная отмена, возврат.
  • Мок-сервер на Python. Простейший Flask-сервис, который отдаёт JSON из файлов. В 1С меняем URL API на localhost:5000. 30 строк кода — и можно тестировать любые сценарии, включая таймауты и ошибки HTTP 429.
  • Параллельный запуск "только чтение". Подключаем интеграцию к реальному API, но документы не проводим, а записываем в отдельный регистр сведений. Сравниваем с тем, что менеджеры создают вручную. Через неделю параллельной работы видно все расхождения.

Третий способ — самый ценный. Именно он помог обнаружить баг с артикулами: в параллельном режиме расхождения по размерам были видны сразу, но к тому моменту интеграцию уже запустили в боевом режиме без этого этапа.

Схема приоритетов сопоставления товаров: штрихкод, затем внутренний ID маркетплейса, затем артикул как фолбэк

Маппинг данных: одно поле, пять интерпретаций

Каждый маркетплейс называет одни и те же сущности по-разному. Вот что значит "цена" в разных контекстах:

  • Wildberries: цена до скидки (price), скидка продавца (discount), итоговая цена рассчитывается маркетплейсом. Комиссия и логистика вычитаются позже, в отчёте о продажах.
  • OZON: цена "до" (old_price), цена "после" (price), маркетинговая цена (marketing_price), минимальная цена (min_price). Четыре поля вместо одного, и каждое влияет на видимость товара.
  • Яндекс.Маркет: базовая цена, цена со скидкой, цена для программы лояльности. Плюс отдельная логика "привязки к рекомендованной цене".

Когда пишешь универсальный модуль обмена для нескольких маркетплейсов, соблазн велик: сделать одно поле "Цена" и маппить его на все площадки. Не делайте так. Через месяц окажется, что менеджер выставил цену 5000 на WB, имея в виду цену до скидки, а модуль отправил 5000 как финальную цену на OZON, потому что маппинг одинаковый. Потеря маржи — 15% на каждой продаже, пока заметили.

Каждый маркетплейс — это отдельная ценовая модель. Хранить нужно все поля отдельно и конвертировать при выгрузке.

Чему меня научили маркетплейсы

Маркетплейсы — это отдельный мир со своей логикой. Они не ложатся на привычную схему розничного учёта "поступление-продажа-списание". У них своё понимание остатков (FBO vs FBS, транзитные склады, виртуальные резервы). Своя модель ценообразования. Своя система идентификации товаров.

Несколько выводов, которые я сделал за эти годы:

  1. Читайте документацию API целиком, а не только нужный эндпоинт. Поле skus было описано в документации Wildberries. Я его видел, когда читал описание метода. Но разработчик, который писал первую версию интеграции, прочитал только те поля, которые были нужны для минимально работающей версии. supplierArticle выглядел достаточным. И был достаточным — до определённого момента.
  2. Не доверяйте полю, если не проверили его уникальность. Простой запрос ВЫБРАТЬ РАЗЛИЧНЫЕ АртикулПоставщика ИЗ Номенклатура СГРУППИРОВАТЬ ПО АртикулПоставщика ИМЕЮЩИЕ КОЛИЧЕСТВО(*) > 1 показал бы проблему за 5 секунд до того, как она стала проблемой.
  3. Фолбэк лучше, чем отказ. Новый код не ломает работу для товаров, где штрихкода нет в базе. Он просто возвращается к старой логике с дополнительной проверкой. Жёсткий подход "нет штрихкода — нет документа" правильный в теории, но парализует работу на практике.
  4. Параллельный режим перед боевым. Неделя работы в режиме "считаем, но не проводим" стоит дешевле одного дня ручного разбора ошибок.

Самое ироничное в этой истории: баг с артикулами — не баг. Это корректная работа кода в соответствии с его логикой. Код делал ровно то, что было написано: искал по артикулу, брал первый результат. Проблема была не в коде, а в предположении, на котором код был построен. Артикул уникален. Нет, не всегда.

В ритейле артикул — это "что". Штрихкод — это "какой именно". Пока разницу не замечаешь, система работает. Просто иногда наугад.