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

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

Кто владеет числом

Самая частая причина расхождений — не баг в коде, а отсутствие хозяина у величины. Если Ozon сам вычитает проданное, Wildberries сам вычитает проданное, а ваша учётка тоже списывает по заказам и периодически «выравнивает» обе площадки, то у числа три автора, и каждый считает себя главным.

Практичная схема: у вас есть один источник истины — ваша база с физическим остатком по складам. Из него вычисляется доступный к продаже остаток: физический минус резерв под уже принятые, но не отгруженные заказы, минус страховой запас, который вы решили не выставлять. Именно это вычисленное значение — то, что вы хотите видеть на витринах. Площадки же в вашей модели — потребители, у которых есть собственное «наблюдаемое» значение, и оно может отставать.

Важно разделить три понятия, которые в разговорах сливаются в одно слово «остаток»:

  • физический — сколько единиц реально лежит на складе;
  • желаемый — что вы хотите выставить на конкретной площадке и конкретном складе;
  • наблюдаемый — что площадка показывает по результатам вашей последней проверки.

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

Один товар — разные имена

Для примера возьмём условный товар: кроссовки модели «Тропа» в двух размерах, 41 и 42. В вашей учётке это один артикул с двумя вариантами. Для маркетплейсов это уже разные сущности с разными идентификаторами, причём у каждой площадки своя логика.

У Wildberries показательна история с идентификаторами. Официальный канал WB API сообщал о переходе управления остатками складов продавца с устаревшего sku на chrtId — идентификатор размера товара. Практический вывод для проектирования: остаток привязан не к карточке «в целом», а к конкретному размеру, и ключ в вашей таблице соответствий должен это отражать. Идентификатор размера получается из данных карточки, поэтому справочник соответствий стоит наполнять из карточек, а не набирать руками.

Минимальная таблица соответствий выглядит так (значения условные):

Условные соответствия одного товара в учётной системе и на площадках
Внутренний вариантРазмерИдентификатор на OzonИдентификатор размера на WB
TROPA-4141ozon-offer-Awb-size-1
TROPA-4242ozon-offer-Bwb-size-2

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

Как закрепить это в PostgreSQL. По документации, PRIMARY KEY задаёт уникальность и запрещает NULL, а UNIQUE может охватывать несколько столбцов. Это ровно то, что нужно: уникальность пары «площадка + внешний идентификатор» не даст привязать два ваших варианта к одному размеру на витрине. Заодно стоит помнить, что пустая строка в том же источнике не тождественна NULL: если «идентификатор не назначен» хранится как пустая строка, ограничения такую запись не отвергнут, а соответствие окажется пустым по сути. Отсутствие идентификатора лучше выражать явно — отсутствием записи или NULL с проверкой.

CREATE TABLE channel_item_map (
  variant_id   bigint NOT NULL,
  channel      text   NOT NULL,
  external_id  text   NOT NULL CHECK (external_id <> ''),
  PRIMARY KEY (variant_id, channel),
  UNIQUE (channel, external_id)
);

Склады: отдельная карта, а не поле в товаре

Товар с размерами — только половина координат. Остаток всегда существует в точке: на конкретном складе. Допустим, у вас два склада: «Основной» (ваш собственный, откуда вы отгружаете по схеме продавца) и «Выносной» — небольшая точка в другом городе. Остатки по ним нужно хранить и отправлять раздельно, иначе одно число придётся делить на глаз.

Склад площадки и склад вашей учётной системы — разные объекты, и их соответствие нужно вести отдельной таблицей, по аналогии с товарами. Ошибка, которую легко совершить: «встроить» склад в запись о товаре. Тогда при открытии третьей точки придётся перестраивать модель товара, а не добавить одну строку соответствия.

Покажем условный пример. Пусть физическое состояние такое:

Условное состояние по двум складам (шт.)
ВариантОсновной складВыносной складРезерв под заказыСтраховой запас
TROPA-41621 (основной)1 (основной)
TROPA-42301 (основной)1 (основной)

Желаемые значения считаются по каждому складу отдельно: для TROPA-41 основной склад даёт 6 − 1 − 1 = 4, выносной — 2; для TROPA-42 основной даёт 3 − 1 − 1 = 1, выносной — 0. Четыре желаемых значения — четыре потенциальных задания. Склад-ноль тоже значение: «закончилось» нужно отправить явно, а не молчать, иначе витрина будет показывать прежнее число.

Заодно полезно решить заранее, выставляете ли вы оба склада на обеих площадках. Это бизнес-решение, а не техническое, но его последствия техническая: если Выносной склад привязан только к одной площадке, то для второй его остаток просто не попадает в расчёт, и очередь не должна пытаться его отправить.

Очередь как журнал намерений

Теперь центральная часть. Самая соблазнительная архитектура — вызывать API площадки прямо в момент продажи: заказ пришёл, остаток уменьшился, тут же уходит запрос. Она ломается на первой же сетевой ошибке: изменение в вашей базе уже произошло, а внешний вызов нет, и вы не знаете, что делать дальше.

Здесь нужно честно разделить два мира. Транзакция в PostgreSQL, согласно документации, объединяет шаги в операцию «всё или ничего»: ROLLBACK отменяет изменения незавершённой транзакции, COMMIT фиксирует. Но эти гарантии относятся к операциям базы данных, а не к произвольному внешнему HTTP API. Нельзя написать BEGIN, обновить остаток, вызвать площадку и ожидать, что откат базы заодно отменит чужую витрину.

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

Задание в очереди — это не «команда сделать запрос», а зафиксированное желание: «на площадке X для склада Y у варианта Z должно быть значение N, версия V». Примерная структура:

CREATE TABLE stock_push (
  id           bigserial PRIMARY KEY,
  variant_id   bigint NOT NULL,
  channel      text   NOT NULL,
  warehouse_id bigint NOT NULL,
  desired_qty  integer NOT NULL CHECK (desired_qty >= 0),
  version      bigint NOT NULL,
  state        text   NOT NULL DEFAULT 'pending',
  attempts     integer NOT NULL DEFAULT 0,
  last_error   text,
  created_at   timestamptz NOT NULL DEFAULT now(),
  UNIQUE (variant_id, channel, warehouse_id, version)
);

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

Ещё одно следствие такой модели: очередь можно не только дрейнить, но и сжимать. Если для одной точки накопилось три ожидающих задания — 4, 3, 2, — то отправлять первые два нет смысла: актуально только последнее. Воркер берёт самую свежую версию для каждой точки, а более старые помечает как заменённые. Это уменьшает нагрузку на внешний API и, что важнее, исключает ситуацию, когда старое значение уходит после нового из-за разного времени обработки.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Пакет прошёл не целиком

Отправлять по одному значению неудобно, и почти любой интерфейс обновления остатков допускает групповую отправку. А групповая отправка порождает самый неприятный вид результата — частичный: часть позиций принята, часть отвергнута. Как именно площадка сообщает о таких случаях и какие бывают коды, определяется документацией конкретного метода; в источниках, на которые мы опираемся, этой семантики нет, и придумывать её не стоит. Но архитектура должна быть готова к любому из вариантов, потому что состояние «успех всего пакета» — лишь один из исходов.

Из этого следуют несколько правил для вашего воркера.

  • Состояние хранится на уровне задания, а не пакета. Пакет — способ доставки, а не единица учёта. Ответ разбирается по позициям, и каждое задание получает собственный исход: принято, отвергнуто с причиной, неизвестно.
  • «Неизвестно» — отдельное состояние. Таймаут или обрыв соединения не означают ни успеха, ни неудачи. Такие задания нельзя молча считать выполненными и нельзя считать проваленными; их судьбу решает проверка наблюдаемого значения на площадке.
  • Повторять можно только то, что безопасно повторить. Отправка абсолютного значения («на складе должно быть 4») при повторе даёт тот же результат. Отправка дельты («уменьшить на 1») при повторе ломает число. Поэтому очередь хранит и передаёт абсолютные значения, а не изменения.
  • Отвергнутое по причине данных не повторяется слепо. Если позиция отвергнута из-за неверного идентификатора, сотый повтор ничего не изменит; задание переходит в состояние «требует разбора», а запись о проблеме видна человеку.

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

Если в какой-то момент вы решите работать через файловый обмен, помните, что это другая модель. Ozon, например, в официальном анонсе об отчёте по остаткам FBS и realFBS описывал выгрузку списка товаров, когда-либо добавленных на склад, и списка закончившихся позиций, а также обновление остатков через XLS в личном кабинете после изменения количества. Это файловый ручной сценарий. Он удобен для разовой сверки или аварийного выравнивания, но из него не следуют ни действующая схема программного интерфейса, ни лимиты, ни гарантии повторов, и регулярным автоматическим каналом он сам по себе не становится. К тому же путь по меню из исторического анонса мог измениться; ориентируйтесь на то, что видите в кабинете сегодня.

Старая версия не должна затирать новую

Теперь о самом коварном дефекте: гонке версий. Сценарий выглядит безобидно. В 12:00 остаток по TROPA-41 на основном складе равен 4, создаётся задание версии 17. Воркер берёт его и отправляет, но сеть тормозит. В 12:01 приходит продажа, остаток становится 3, создаётся версия 18, и второй воркер отправляет её быстро. Ответ по версии 17 приходит последним, и если вы отмечаете «в витрине сейчас 4», то в вашей картине мира число откатилось, хотя на самом деле порядок доставки мог быть любым.

Версия здесь — не украшение, а способ однозначно упорядочить намерения. Правила простые:

  1. Версия растёт монотонно в пределах точки «вариант + площадка + склад» и назначается внутри той же транзакции, где изменился остаток. Источник версии — ваша база, а не часы воркера: показания часов на разных процессах расходятся.
  2. Перед отправкой воркер проверяет, нет ли для этой точки более новой версии. Если есть, старая помечается как заменённая и не уходит.
  3. При записи результата действует условие: принятую версию можно записать как «последнюю подтверждённую» только если она не меньше уже записанной. Опоздавший ответ про версию 17 не перетрёт подтверждение версии 18.

В SQL последнее правило выражается одним условным обновлением:

UPDATE stock_confirmed
SET confirmed_version = $1, confirmed_qty = $2, confirmed_at = now()
WHERE variant_id = $3 AND channel = $4 AND warehouse_id = $5
  AND confirmed_version < $1;

Если запрос затронул ноль строк, значит, более новое подтверждение уже есть, и ответ про старую версию просто фиксируется в журнале как устаревший.

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

Что сверять и с чем

Очередь, даже аккуратная, не доказывает, что витрина сейчас показывает то, что вы хотите. Доказывает только наблюдение — запрос текущего значения у площадки и сравнение с желаемым. И здесь легко подставить не тот источник.

Официальный канал WB API различает Историю остатков с периодическим обновлением и методы получения текущих данных по складам WB и продавца. История остатков представлена отдельными срезами — по группам, товарам, размерам и складам — и служит аналитике: динамике, оборачиваемости, плановым закупкам. Но она не синоним текущего оперативного остатка. Если сверять желаемое значение с историческим отчётом, вы будете находить «расхождения», которые на самом деле просто время обновления отчёта, и запускать по ним исправления, которые ничего не чинят, а то и мешают. Тем более что гарантий времени обновления витрины в целом эти сообщения не дают: сроки относятся к названным отчётам.

Поэтому сверка делится на два разных контура:

  • Оперативная. Для каждой точки берётся текущее значение из метода, который по документации возвращает именно текущие остатки склада продавца. Сравниваем с последним подтверждённым и с желаемым.
  • Аналитическая. Исторические отчёты используются для поиска систематических перекосов за период: например, склад регулярно уходит в ноль раньше, чем ожидалось. Выводы из них — повод пересмотреть страховой запас, а не повод отправить исправляющее задание.

Для Ozon в файловом сценарии та же дисциплина: выгрузка из кабинета — это снимок на момент выгрузки, и сравнивать его стоит с желаемым значением той же эпохи, а не с тем, что у вас в базе уже изменилось после снимка. Для этого и нужны версии: вы сопоставляете снимок не с «сегодняшним остатком», а с последней версией, которая была к моменту снимка уже отправлена и подтверждена.

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

Где всё это жить: ресурсы VPS

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

В аренде VPS MAATRIX есть конфигурации от Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) до Djed (16 vCPU, 32 ГБ, 320 ГБ); промежуточные — Uab, Heka и Maat. Для небольшого каталога с парой складов начальная конфигурация с базой и воркерами на одной машине выглядит разумной отправной точкой, но это ориентир для проверки, а не обещание ёмкости. Диск NVMe помогает журналу заданий и наблюдений, который растёт быстрее, чем кажется: каждая попытка оставляет строку. Root-доступ позволяет поставить PostgreSQL и менеджер процессов так, как требует ваша схема, а наличие в каталоге n8n с установкой может пригодиться, если часть сценариев вы предпочтёте собирать как потоки; точное наличие образов и ОС проверяется в форме заказа. Резервные копии базы заданий и справочников соответствий — ваша забота: наличие автоматических копий в тарифах не подтверждено, поэтому настройте дамп PostgreSQL и храните его вне машины.

Обновления самой системы тоже стоит планировать: ядро и PostgreSQL нельзя перезапускать посреди пакета. Подход к порядку и срокам обновлений разобран в материале о регламенте обновлений; для очереди важен сам принцип — сначала остановить взятие новых заданий, дождаться завершения запросов в полёте, затем обслуживать.

Одна разница, пройденная до конца

Вернёмся к кроссовкам. Допустим, вечерняя оперативная сверка обнаружила, что на Wildberries для размера 41 на основном складе витрина показывает 5, тогда как желаемое по вашей базе — 4. Запись наблюдения выглядит так (значения условные):

Разбор одной разницы в остатках
ТочкаЖелаемоеВерсия желаемогоНаблюдаетсяПоследнее подтверждение
TROPA-41 / WB / Основной4185версия 16, значение 5

Теперь шаги читаются прямо по таблице. Витрина совпадает с подтверждением версии 16, то есть до площадки дошла только она. Значит, версии 17 и 18 либо не отправлялись, либо их исход «неизвестно». Смотрим журнал заданий: версия 17 помечена заменённой, версия 18 находится в состоянии «неизвестно» после таймаута. Источник расхождения установлен — не гонка и не сбой справочника, а неподтверждённая отправка.

Следующий шаг — повторить отправку версии 18 с абсолютным значением 4: это безопасно, потому что повтор того же числа не меняет результата. После ответа ещё раз читаем текущее значение. Если витрина показывает 4, обновляем подтверждение условным UPDATE и закрываем задание. Если нет — задание переходит в «требует разбора» с приложенной записью наблюдения, и дальше проверяется соответствие идентификаторов и склада, а не число попыток.

Обратите внимание, чего в этом разборе нет: мгновенной синхронизации, ожидания «чудес» и общей фразы «остатки расходятся». У расхождения есть адрес — точка, версия и состояние задания, — и поэтому следующий шаг очевиден. Обещать, что витрины всегда совпадут секунда в секунду, нельзя, но можно гарантировать другое: любую разницу вы увидите, объясните и исправите по записи, а не по догадке.