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

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

Сначала словарь: кто владеет данными и что значит «доступно»

У каждого поля в обмене должен быть один владелец. Для типичного интернет-магазина распределение обычно такое, и это наше рабочее предложение, а не требование системы:

  • МойСклад владеет артикулами, ценами закупки и розницы, количеством по складам, резервами, статусами отгрузки.
  • Сайт владеет описаниями для покупателя, фотографиями, SEO-текстами, порядком в каталоге, корзинами и заказами до момента передачи в учёт.
  • Спорная зона — названия товаров и цены. Здесь надо выбрать одно направление и записать его, иначе правка в двух местах превратится в бесконечную перетяжку каната.

Теперь о количестве. Для сайта полезны три разные величины, и путать их опасно:

Три «остатка» и кому они нужны
ВеличинаСмыслГде уместна
Физическое количествоСколько единиц лежит на складеВнутренняя сверка, инвентаризация
РезервСколько уже обещано другим заказамОбъяснение расхождений
Доступно к продажеФизическое минус резерв по складам, откуда вы отгружаетеЧисло, которое видит покупатель

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

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

Карта сущностей: что с чем связано

Прежде чем писать код, нарисуйте таблицу соответствия. Она короткая, но экономит недели споров:

Карта сущностей сайта и учёта
На сайтеВ МойСкладКлюч связи
Карточка товараТовар или модификацияИдентификатор сущности в учёте, хранится у вас в отдельном столбце
Вариант (размер, цвет)Модификация товараСвой идентификатор модификации
Остаток в каталогеКоличество по складу за вычетом резерваПара «товар или модификация + склад»
Заказ покупателяЗаказ покупателяНомер заказа сайта плюс идентификатор в учёте

Главное правило карты: связывайте по идентификатору, который выдал учёт, а не по названию и даже не только по артикулу. Артикул человек может поправить, название тем более. Идентификатор хранится в своей базе как отдельный столбец с ограничением уникальности. Если вы используете PostgreSQL, ограничение UNIQUE может охватывать несколько столбцов, а PRIMARY KEY заодно запрещает NULL (документация PostgreSQL о ограничениях). Это прямо подсказывает схему: остаток живёт в таблице с составным уникальным ключом «товар + склад», и второй записи для той же пары просто не может появиться.

Заодно обратите внимание на мелочь из той же документации: пустая строка и NULL — разные вещи. Если артикул у товара не заполнен и вы записали его как пустую строку, ограничение уникальности на артикул сработает на втором же таком товаре. Для «значение отсутствует» используйте NULL, а не пустую строку.

Токен: доверие в одну сторону

В обмене есть два направления, и доверие в них устроено по-разному. Исходящие запросы — это вы идёте в API учёта. Для них нужна аутентификация, и в примерах официального SDK документированы Basic и Bearer (официальный SDK МойСклад, Webhooks API). Токен здесь — ваш пропуск.

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

Что делать с токеном исходящих запросов. Рекомендации OWASP по управлению секретами сводятся к двум вещам, которые нам подходят: минимальные права для каждого секрета и ротация, по возможности автоматизированная (OWASP Secrets Management Cheat Sheet). Это общая рекомендация безопасности, а не свойство вашей интеграции, поэтому конкретику придётся обеспечить самим:

  • Заведите для сайта отдельную учётную запись или отдельный токен, не используйте токен администратора и не переиспользуйте тот, которым пользуется бухгалтерская выгрузка.
  • Дайте этой учётной записи только то, что нужно обмену: чтение товаров и остатков, создание и чтение заказов. Если интеграция не правит цены, права на правку цен ей ни к чему. Что именно можно ограничить, зависит от вашей редакции и настроек учёта; смотрите, что реально предлагает ваш аккаунт, и не переносите чужие возможности на свой тариф.
  • Храните токен в переменной окружения или файле секретов с правами только для служебного пользователя, не в репозитории и не в базе товаров.
  • Заранее решите, как заменить токен: выпустить новый, обновить конфигурацию, перезапустить воркер, отозвать старый. Если замена занимает полдня и требует звонка разработчику, её не будут делать.

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

Вебхук как звонок в дверь, а не как посылка

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

  1. Быстро отвечает успехом, чтобы отправитель не решил, что вы недоступны.
  2. Сохраняет у себя запись задачи: что за сущность, какого типа событие, когда принято.
  3. Откладывает реальную работу воркеру.

Воркер берёт задачу, идёт в API учёта своим токеном и читает текущее состояние сущности. Данные из тела уведомления он использует лишь как подсказку, куда смотреть. Так поддельный или повторный вызов в худшем случае приведёт к лишнему чтению из API, но не к записи чужих чисел в каталог. Заодно решается проблема устаревания: вы всегда применяете то, что видите сейчас, а не то, что было на момент отправки события.

Если вы хотите зарегистрировать вебхук из кода, это тот же JSON API: SDK описывает создание, список, изменение и удаление. Конкретные поля запроса сверяйте с актуальной документацией вашей версии, а не с памятью.

Очередь одного изменения

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

Путь одного изменения (условный пример)
ШагЧто происходитЧто сохраняется
1Приходит уведомление об изменении товараСтрока задачи: сущность, тип события, время приёма, статус «новая»
2Ответ отправителю без долгой работы—
3Воркер берёт задачу, помечает «в работе»Время начала, номер попытки
4Запрос текущего состояния товара и остатков по складамПолученные значения и время чтения
5Пересчёт «доступно» по списку продающих складовСтрока остатка «товар + склад»: количество, резерв, время последней обработки
6Задача закрываетсяСтатус «готово» или «ошибка» с текстом причины

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

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

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

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

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

Повторы: когда одно событие приходит дважды

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

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

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

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

Склады: одна карточка, много чисел

Когда складов несколько, одно изменение товара превращается в несколько фактов. Остаток на «Основном» упал, на «Магазине» вырос, на «Уценке» не изменился. Если хранить на сайте единственное число «остаток», вы потеряете ответ на вопрос «откуда оно взялось». Если хранить по строке на каждый склад и считать итог для витрины отдельно, вы увидите картину целиком.

Пример условный. Допустим, у товара в учёте: «Основной» — 4 шт., резерв 3; «Магазин» — 2 шт., резерв 0; «Уценка» — 5 шт., не продаётся онлайн. Список продающих складов: «Основной» и «Магазин». Доступно для витрины: (4 − 3) + (2 − 0) = 3. Если случайно включить «Уценку», витрина покажет 8, а реально можно продать 3. Расхождение в пять штук возникло не из-за сбоя синхронизации, а из-за неверного определения слова «доступно». Поэтому хранить нужно исходные компоненты, а итог — производная величина, которую можно пересчитать при смене правил.

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

Несколько событий об одной карточке

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

Два приёма делают это надёжнее:

  • Склейка задач. Если по одной сущности уже есть необработанная задача, новая не создаёт отдельную работу, а лишь обновляет метку «ещё раз посмотри после». Три события за минуту превращаются в одно чтение.
  • Защита от старой записи. Перед сохранением сравните время или версию данных, полученных из учёта, с уже сохранённой. Если пришедшее старше — не перезаписывайте. Какие именно поля версии и времени доступны в ответе API, смотрите в его описании; если версии нет, используйте время чтения, которое вы сами фиксируете.

Эти приёмы увеличивают объём хранимого, зато каждая строка остатка несёт сведения о том, когда и по какой задаче она была обновлена в последний раз. Именно они понадобятся в конце.

Сверка: сайт против источника

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

Как организовать сверку без лишней нагрузки:

  1. Разбейте каталог на порции и идите по ним по расписанию, а не одним запросом на весь ассортимент.
  2. Для каждой пары «товар + склад» сравните количество и резерв в учёте с сохранёнными.
  3. Расхождение не затирайте молча. Запишите его в отдельный журнал: что было у вас, что в учёте, когда сверка это увидела. Затем поставьте задачу на обновление.
  4. Следите за долей расхождений. Если после сверки их регулярно много на одном складе, проблема в определении или в пропущенном типе события, а не в случайности.

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

Где всё это живёт: ресурсы под воркер

Нагрузка у такой интеграции скромная: небольшой веб-обработчик, база с очередью задач, воркер и расписание сверки. Нужен постоянно работающий сервер с белым адресом и возможностью поставить сертификат, и тут подходит обычный VPS. На VPS MAATRIX доступен root, диски NVMe, процессоры AMD EPYC, есть локации UK, US, RU и FR. Для первого запуска магазина с умеренным каталогом логично начать с младших конфигураций вроде Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) или Uab (2 vCPU, 4 ГБ, 80 ГБ) и смотреть на реальные показатели памяти и очереди. Это ориентир по ресурсам, а не обещание ёмкости: сколько задач вы перемелете, покажет только ваш замер. В каталоге также предложены n8n и Nextcloud с установкой; если воркер проще собрать как сценарий автоматизации, точное наличие образа и операционной системы смотрите в форме заказа.

Если интеграцию когда-то делал подрядчик, а сейчас его не найти, начните с восстановления доступов и чтения самого воркера; об этом подробно написано в материале про возврат контроля над сайтом и сервером.

Расхождение, которое можно объяснить

Вернёмся к нашей последней единице. Покупатель увидел «1 шт.», а в учёте она зарезервирована. Теперь у вас есть чем разбираться. Открываете строку остатка по этой паре «товар + склад» и читаете три вещи.

  • Склад. Единица лежала на «Основном» или её засчитали с «Уценки»? Если витрина суммировала не те склады, это ошибка правила, а не данных.
  • Версия и время записи в учёте. Мы сохранили состояние до или после появления резерва? Если резерв позднее времени чтения, сайт показывал честное, но устаревшее число.
  • Время последней обработки и статус задачи. Было ли событие о резерве вообще? Если задачи нет, событие не пришло или не было подписано, и тогда проблема в наборе подписок; если задача есть, но застряла в «в работе», проблема в воркере.

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