Шпаргалка по форматам цен, остатков и SKU при синхронизации Ozon, WB и Яндекс.Маркета

Зачем нужна единая модель данных для трёх маркетплейсов

Три маркетплейса, три разных способа думать о товаре. Ozon идентифицирует его через offer_id, который вы сами придумываете. WB хранит два кода сразу: nmID генерирует платформа, а vendorCode задаёте вы, и это разные сущности, которые легко перепутать в коде. Яндекс.Маркет использует пару shopSku и offerId, причём в разных методах API ведущим может быть то один, то другой. Если у вас нет слоя, который это нормализует, вы рано или поздно обновите остаток не того товара.

С ценами ситуация ещё веселее. WB принимает цены в рублях как целое число. Ozon, в рублях, но уже decimal с двумя знаками после запятой. Яндекс.Маркет ожидает значение в копейках как целое. Казалось бы, мелочь, просто умножить или поделить на 100. Но когда у вас общий прайс-лист на 5000 SKU и автоматическое обновление цен по расписанию, одна лишняя или потерянная операция деления кладёт либо маржу, либо продажи.

С остатками принципиально другая история. FBO и FBS на каждой площадке считаются отдельными вызовами API и отдельными логическими сущностями. Ozon возвращает fbo_present и fbs_present разными полями, WB разделяет склады поставщика и склады маркетплейса в разных эндпоинтах, Маркет требует явно указывать тип склада при обновлении. Просто сложить цифры и отправить единое число нельзя: площадка будет показывать товар доступным там, откуда фактически отгрузить невозможно.

Последствия конкретны. Овербукинг при рассинхроне остатков между FBO и FBS приводит к отменам заказов. WB штрафует за отмены по вине продавца до 30% от стоимости товара, это не абстрактный риск, это строчка в акте взаимозачёта. На Ozon систематические отмены и жалобы блокируют карточку товара, иногда без предупреждения и с долгим ручным разбором для разблокировки.

Решение, которое работает: внутренний справочник товаров, где каждый SKU хранит свой internal_id и таблицу маппинга на коды каждой площадки. Выглядит это примерно так:

products
  internal_id: UUID (первичный ключ, генерируете вы)
  name, base_price, ...

marketplace_mappings
  internal_id → FK
  platform: enum(ozon, wb, yandex)
  platform_id: string  // offer_id / nmID / shopSku
  platform_sku: string // vendorCode / offerId / второй код при необходимости

Вся логика синхронизации цен и остатков между маркетплейсами работает через internal_id. Адаптеры для каждой платформы берут из маппинга нужные идентификаторы и сами знают, в каких единицах и в каком формате передавать цену или остаток. Это скучная схема. Но именно она не даёт штрафу от WB прийти из-за того, что кто-то в коде написал price вместо price_in_kopecks.

Схема маппинга единого внутреннего SKU на идентификаторы Ozon, Wildberries и Яндекс.Маркета

Один внутренний SKU связывается с тремя разными идентификаторами площадок через центральную таблицу маппинга.

SKU и артикулы: таблица соответствий

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

Вот как раскладываются идентификаторы по площадкам.

Ozon. Два ключевых поля. offer_id это ваш внутренний код, строку задаёте вы сами при создании товара, проверяйте актуальные ограничения по длине и символам в документации Ozon. product_id числовой, его генерит Ozon после модерации карточки. В API почти все методы (остатки, цены, FBS-заказы) принимают либо одно, либо другое, но я держусь за offer_id как за основной ключ, потому что он мой и стабильный.

Wildberries. Здесь иерархия глубже. nmID числовой, его выдаёт WB на уровне карточки товара (одна карточка = один цвет/модель). vendorCode это ваш артикул, его задаёте вы, ограничения по длине уточняйте в актуальной документации WB. А вот размеры внутри карточки это отдельная сущность с chrtID (числовой id характеристики/размера) и barcode (баркод конкретного размера). То есть футболка красная с размерами S, M, L это один nmID и три chrtID с тремя баркодами.

Яндекс.Маркет. shopSku ваш SKU, до 80 символов, разрешены латиница, кириллица и цифры. marketSku числовой, выдаёт сам Маркет после привязки к карточке в каталоге. В YML-фидах то же самое поле фигурирует как offerId атрибут тега <offer>, путаница терминологии классическая.

Правила нормализации, которые я выработал и теперь зашиваю в валидатор на входе:

  • только латиница A-Z, цифры 0-9, дефис -, подчёркивание _
  • никаких пробелов, слешей, точек, кириллицы (даже там, где площадка её формально разрешает)
  • регистр сохраняется как есть, но сравнение делаю case-insensitive чтобы ловить дубли Tshirt-Red-M и TSHIRT-RED-M
  • длину держу компактной, с запасом под самый узкий лимит из трёх площадок, который нужно уточнить по документации каждой

Кириллицу в артикулах принципиально не пускаю. Один раз Футболка-Кр-М улетел в Маркет нормально, а в выгрузке для Ozon превратился в URL-encoded мусор и сломал маппинг остатков на сутки.

Теперь про варианты. Один товар с цветами и размерами раскладывается по площадкам по-разному:

  • На Ozon каждый вариант это отдельный offer_id и отдельный product_id. Красная M, красная L, синяя M это три записи. Они объединяются в одну карточку через атрибут связки.
  • На WB одна карточка nmID на сочетание модель+цвет, а размеры это массив sizes с разными chrtID и баркодами внутри.
  • На Маркете похоже на Ozon: каждый размер это свой shopSku, привязанный к общей карточке через marketSku.

Минимальная запись в моей мастер-таблице для одного варианта выглядит так:

{
  "internal_sku": "TSHIRT-RED-M",
  "ozon": {
    "offer_id": "TSHIRT-RED-M",
    "product_id": 845123901
  },
  "wb": {
    "nmID": 178293012,
    "vendorCode": "TSHIRT-RED",
    "chrtID": 459123,
    "barcode": "2000123456789"
  },
  "yandex": {
    "shopSku": "TSHIRT-RED-M",
    "marketSku": 101923847
  }
}

Обратите внимание: vendorCode на WB равен TSHIRT-RED без размера, потому что размер у WB живёт ниже уровня карточки. А internal_sku, offer_id и shopSku я держу одинаковыми. Это сильно упрощает дебаг: видишь в логе TSHIRT-RED-M и сразу понимаешь, о каком физическом SKU речь, не лазая в справочник.

product_id, nmID и marketSku подтягиваются после первой публикации карточки, до этого момента поля null. Логика синхронизатора должна это переживать: если внешний id ещё не получен, операция откладывается в очередь, а не падает.

Таблица соответствий артикулов и SKU для Ozon, Wildberries и Яндекс.Маркета с примерами значений

Каждая площадка использует собственный формат идентификатора: числовой offer_id у Ozon, nmId у Wildberries и числовой SKU у Яндекс.Маркета.

Форматы цен: где копейки, где рубли, где строка

Каждый маркетплейс придумал свой способ представить число. Один и тот же ценник в 1299.50 ₽ в трёх API выглядит по-разному, и если унифицировать его в коде через Decimal или float, на стыке начнётся веселье.

Ozon хочет строку. Все три поля (price, old_price, min_price) передаются как строки в рублях:

POST /v1/product/import/prices
{"prices": [{"offer_id": "TSHIRT-RED-M", "price": "1299", "old_price": "1599", "min_price": "999"}]}

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

Ещё момент: резкое изменение цены вверх может потребовать дополнительной проверки на стороне Ozon. Конкретные правила модерации цен меняются, актуальный порог уточняйте в документации или через поддержку.

Wildberries живёт в целых числах:

POST /api/v2/upload/task
{"data": [{"nmId": 178293012, "price": 1299}]}

Никаких строк, никаких дробей, никаких скидок в этом же запросе. Скидка едет отдельной задачей на /api/v2/upload/task/discount, и это два независимых асинхронных таска со своими статусами. То есть атомарной операции "поставить цену 1299 со скидкой 20%" в WB API нет, всегда две точки, между которыми товар может торговаться по старой скидке к новой цене или наоборот.

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

Яндекс.Маркет единственный, кто работает с дробным числом и валютой явно:

POST /campaigns/123/offer-prices/updates
{"offers": [{"offerId": "TSHIRT-RED-M", "price": {"value": 1299.00, "currencyId": "RUR"}}]}

value это число (не строка) с двумя знаками после точки, currencyId обязательно RUR. С точки зрения чистоты модели данных Маркет выглядит самым адекватным из трёх: цена это объект с валютой, а не голое число непонятно в чём.

В коде я обычно держу единый внутренний тип Money с Decimal и копейками, а конвертеры под каждый канал делают своё:

  • Ozon: str(int(amount.rub)) плюс защита от резких скачков цены
  • WB: int(round(amount.rub)) плюс отдельная очередь на discount
  • Маркет: float(amount.quantize("0.01")) с currencyId: "RUR"

Один общий price: float для всех трёх каналов в коде это путь к расхождению на копейку, которое потом неделю ищешь в логах синхронизации.

Сравнительная таблица форматов передачи цены в API Ozon, Wildberries и Яндекс.Маркета

Ozon принимает цену в копейках целым числом, Wildberries ожидает дробное значение в рублях, а Яндекс.Маркет требует отдельное поле для валюты.

Остатки FBS: эндпоинты и единицы измерения

Три площадки, три разных контракта на одно и то же действие "сообщить, что у меня на складе 42 штуки". Разбираю по очереди, потому что путаница в единицах стоит реально потерянных заказов.

Ozon. Метод POST /v2/products/stocks. В теле массив stocks, каждый элемент содержит offer_id или product_id, обязательный warehouse_id и stock целым числом штук. Размер батча и лимиты запросов проверяйте в актуальной документации Ozon. Ответ синхронный: приходит массив result с полем errors по каждому offer_id, так что разбирать надо построчно, не по HTTP-статусу всего запроса.

Wildberries. PUT /api/v3/stocks/{warehouseId}, где warehouseId это ID склада продавца из /api/v3/warehouses. Главная ловушка: sku в теле запроса это не nmID и не артикул продавца, а баркод конкретного размера. Если у футболки пять размеров, у вас пять баркодов и пять отдельных остатков. Установка остатка на nmID невозможна в принципе.

// WB FBS
PUT /api/v3/stocks/507
{"stocks": [{"sku": "2000123456789", "amount": 42}]}

Чтение через POST /api/v3/stocks/{warehouseId} с теми же баркодами в теле.

Яндекс.Маркет. PUT /campaigns/{campaignId}/offers/stocks. В теле skus, каждый элемент с полем sku (ваш SHOP-SKU) и вложенным items[].count плюс опциональный updatedAt. Обновление идёт по складу, привязанному к кампании FBS, отдельный warehouse_id передавать не нужно. Структура с двойной вложенностью items.count.count это исторический артефакт, не баг документации.

Резервы. При появлении нового заказа все три площадки сами уменьшают доступный остаток на стороне маркетплейса, дублировать вычитание из своей системы не надо, иначе получите двойное списание и уйдёте в минус. Но синхронизировать обратно (когда заказ отменён или ваш WMS списал товар) обязательно нужно явным PUT/POST. У Ozon тут плюс: разделение present и reserved в ответе позволяет сверять расхождения без угадывания, что именно ушло в резерв.

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

Таблица эндпоинтов и ключевых полей для обновления остатков FBS на Ozon, Wildberries и Яндекс.Маркете

Для обновления FBS-остатков каждая площадка предоставляет отдельный эндпоинт с уникальным набором обязательных полей и ограничениями на размер батча.

Остатки FBO: что синхронизировать, а что нельзя

Главное правило, которое я повторяю каждому новому интегратору в команде: остатки FBO через API только читаются. Точка. Физически товар лежит на складе площадки, и единственный способ изменить количество это поставка, возврат, списание или утилизация. Никакой PUT /stocks с FBO не работает, и слава богу, иначе расхождения с фактом были бы катастрофой.

Что мы реально дёргаем:

  • Ozon: POST /v1/analytics/stock_on_warehouses. Отдаёт остатки с разбивкой по складам (Хоругвино, Тверь, Казань и т.д.), причём отдельно free_to_sell_amount, promised_amount и reserved_amount. Для аналитики берём именно free_to_sell_amount, всё остальное это резервы под уже оформленные заказы.
  • Wildberries: GET /api/v1/supplier/stocks (Statistics API). Тут особенность: WB обновляет отчёт не мгновенно, и lastChangeDate это не "момент изменения остатка", а отметка пересборки отчёта. Частоту опроса и реальную задержку уточняйте экспериментально или через документацию.
  • Яндекс.Маркет: GET /campaigns/{campaignId}/offers/stocks для модели FBY. Возвращает фактический остаток на складе Маркета по каждому SKU, с типами FIT, DEFECT, EXPIRED. В витрину пускаем только FIT.

Типичная ошибка, на которой я однажды погорел: коллега написал синхронизатор, который при списании со склада в 1С пытался дёрнуть Ozon API и уменьшить остаток FBO, чтобы "выровнять". Получили 400-е ошибки в логах, а через неделю менеджер заметил, что часть товара на Ozon продолжает продаваться, хотя физически его уже отгрузили клиенту с собственного склада. Никакого конфликта на самом деле не было: товар на Ozon реально лежал в Хоругвино, а списали мы свой FBS-сток. Просто учёт был построен неверно.

Отсюда схема, которую я считаю единственно рабочей. В админке для каждого SKU показываем суммарный остаток: FBO_ozon + FBO_wb + FBO_ym + FBS_свой_склад. Это нужно категорийщику и закупке, чтобы видеть полную картину товародвижения. Но при оформлении заказа списываем только тот сток, откуда заказ реально уходит. FBS-заказ режет наш собственный склад. FBO-заказ нам показывают уже постфактум через /postings, и он сам уменьшится при следующем чтении остатков с площадки.

То есть FBO для нас это read-only зеркало, которое мы кэшируем и никогда не пытаемся "поправить". Все изменения количества это физика: машина с поставкой заехала на склад площадки, либо товар утилизировали по акту. Любой API-вызов на запись в сторону FBO-стоков это попытка обмануть учёт, и она всегда заканчивается расхождением.

Диаграмма разделения остатков FBO и FBS в единой системе учёта с потоками чтения и записи

Остатки FBO читаются из API площадки и не редактируются вручную, тогда как FBS-остатки система записывает сама на основе данных собственного склада.

Лимиты, троттлинг и батчинг запросов

Каждая площадка считает свой бюджет вызовов по-своему, и если просто пинговать API на каждое изменение остатка, ты упрёшься в 429 уже через пару минут после старта распродажи.

Конкретные числа лимитов (размер батча, запросов в минуту/секунду, бюджет на магазин) меняются и не всегда полно отражены в публичной документации. Перед запуском синхронизации сверяйся с актуальными справочниками Ozon, WB и Яндекс.Маркета, а лучше уточняй через техподдержку или на форумах интеграторов.

Базовая стратегия одна: накапливать изменения окном в 30-60 секунд и отправлять пачкой. Если SKU обновляется дважды за окно, в очередь идёт только последнее значение. Это убирает значительную часть мусорного трафика на хайповых товарах, где цена и остаток дёргаются десятки раз в минуту.

async function pushStocks(items) {
  const BATCH = 100; // уточни лимит батча в документации Ozon
  const chunks = [];
  for (let i = 0; i < items.length; i += BATCH) chunks.push(items.slice(i, i + BATCH));
  for (const c of chunks) {
    await ozonClient.post('/v2/products/stocks', {stocks: c});
    await sleep(800); // пауза между чанками, подбирается под реальный лимит
  }
}

Паузу между чанками подбирай экспериментально с запасом под реальный лимит площадки. Если идти впритык, периодически прилетает 429 на ровном месте из-за разъезда часов.

Про ретраи. Повторять можно только 429 и 5xx, с экспоненциальной задержкой и джиттером. На 400 и 409 ретраить бессмысленно и вредно: это ошибка данных (битый штрихкод, отсутствующий offer_id, конфликт версий), повтор её не починит, а только засчитается в лимит. Я в обработчике явно делю ошибки на retryable и terminal, terminal летят в DLQ с телом запроса для разбора руками.

Идемпотентность держится по-разному. У WB естественный ключ это taskId, повторный POST с теми же данными создаст вторую задачу, поэтому дедуп нужен на стороне клиента, через хэш payload в Redis с TTL минут на пять. У Ozon идемпотентности на уровне протокола нет вообще: повторный запрос просто перезапишет остатки тем же значением, что в большинстве случаев безопасно, но если между ретраями успело пройти новое обновление, ты откатишь актуальные данные. Лечится тем же дедупом плюс проверкой версии записи перед отправкой.

Схема стратегии батчинга: накопление изменений в очереди и отправка пачкой с учётом лимитов API

Изменения накапливаются в очереди в течение заданного интервала, после чего отправляются одним запросом, не превышая лимит площадки по числу позиций.

Баркоды и штрихкоды: GTIN, EAN-13 и внутренние коды

Путаница с баркодами обходится дорого. Ozon отклонит поставку, WB смешает размеры в одну кучу, Маркет создаст дублирующую карточку. Разберём конкретику по каждой платформе.

Какие форматы принимают площадки

Ozon работает с EAN-13, EAN-8, UPC-A, GTIN-14 и ISBN. Одно из главных правил: один баркод привязан ровно к одному товару. Если попытаться использовать один и тот же EAN-13 для двух разных SKU, система заблокирует добавление или, хуже, смержит карточки. Дубли не просто нежелательны, они запрещены на уровне валидации.

WB устроен жёстче: баркод требуется на каждый размер отдельно. Там это называется chrtID (идентификатор характеристики), и для каждого chrtID нужен свой 13-значный код. Генерировать их можно через API: метод POST /content/v2/barcodes, передаём количество нужных кодов, в ответ получаем массив готовых значений. Их можно сохранить у себя и назначить размерам.

Яндекс.Маркет передаёт баркоды через поле barcodes как массив строк. Поддерживаются EAN-13 и собственные коды магазина для непродовольственных товаров. Несколько баркодов для одного SKU (например, если товар перемаркировался) можно передать все сразу.

Когда производственного баркода нет

Часть товаров, особенно товары под собственной торговой маркой или мелкое производство, приходят без баркода от производителя. В этом случае генерируем EAN-13 самостоятельно по схеме: префикс 200 (зарезервирован для внутреннего использования по стандарту GS1) + ваш внутренний ID товара, дополненный нулями до 12 цифр + контрольная цифра.

Контрольную цифру считают по алгоритму: берём 12 цифр, цифры на нечётных позициях (1, 3, 5...) умножаем на 1, на чётных (2, 4, 6...) на 3, суммируем, берём остаток от деления на 10, и от 10 вычитаем этот остаток (если остаток 0, то и контрольная цифра 0). Это стандартная формула EAN-13. Лучше не считать вручную: в Python библиотека barcode сделает это за секунду.

Этикетки для FBS

Размеры и форматы этикеток расходятся по площадкам, и это реальная боль при мультиканальных отгрузках:

  • Ozon: 120×75 мм (PDF или ZPL)
  • WB: 58×40 мм для термопринтера или 120×75 мм для обычного
  • Яндекс.Маркет: 75×120 мм (PDF)

Обратите внимание: Ozon и Маркет дают одинаковые физические размеры (120×75 и 75×120), но ориентация разная. Если шаблон не перевернуть, принтер выдаст нечитаемую этикетку. ZPL-формат удобнее для автоматизации, он генерируется на лету и не требует PDF-рендеринга.

Один практический совет: заложите в систему хранения баркодов поле source (manual, wb_generated, ean_manufacturer). Через полгода, когда будете разбираться, откуда взялся конкретный код, скажете себе спасибо.

Таблица поддерживаемых форматов штрихкодов EAN-13, Code128 и их привязки на Ozon, Wildberries и Яндекс.Маркете

Wildberries принимает EAN-13 и Code128 через отдельное поле баркода, Ozon требует штрихкод при создании карточки, а Яндекс.Маркет привязывает его к офферу через feed.

Обработка ошибок и расхождений

Каждая площадка ругается по-своему, и единого формата ошибок ждать не приходится. У Ozon ответ всегда 200, а реальные проблемы лежат в result.errors[]: OFFER_ID_NOT_FOUND если SKU не привязан к кабинету, PRICE_VALIDATION_ERROR при нарушении границ min/max цены, общий ITEM_FAILED как обёртка для всего остального. Парсим массив целиком, иначе один битый SKU из батча на 1000 позиций молча потеряется.

WB живёт по HTTP-кодам. 409 это конфликт остатков (чаще всего расхождение с буфером резервов на стороне склада), 422 валидация payload. Тело отдаёт массив errors[] с полями field и description, парсить нужно построчно, потому что в одном ответе может прилететь и валидный апдейт, и ошибка по соседнему barcode.

Отдельная боль WB: ручка вернула 200 и taskId, ты записал в лог "успех", а цена

Блок-схема обработки ошибок API с ретрай-политикой и маршрутизацией по кодам HTTP-ответа

Ошибки 429 и 5xx уходят на повтор с экспоненциальной задержкой, ошибки 4xx фиксируются в журнал и требуют ручного разбора без автоматического повтора.