МойСклад после переезда с InSales: где ломается связка сайта с учётом
Разбираем интеграцию интернет-магазина с МойСкладом на собственном движке, Next.js и PostgreSQL, после переезда с InSales. Если ваш магазин живёт в связке с МойСкладом и вы собираетесь уходить с платформы, эти места стоит проверить до запуска. У нас они всплыли на живых заказах, и два самых неприятных нашли сотрудники заказчика, а не наши тесты.
Перенос каталога, заказов и адресов с InSales здесь не разбираем, это отдельная тема. Всё ниже про то, что начинается после: остатки, резервы, статусы и деньги в двух системах, которые должны показывать одно и то же.
Как устроена связка
Остатки и цены приходят из МойСклада по ключу: у товара на сайте хранится externalCode позиции из учёта. Заказы уходят обратно документами «заказ покупателя». Статусы менеджер ведёт в МойСкладе, и сайт забирает их оттуда.
Статусы мы забираем опросом. Вебхук требует настройки в чужой учётной записи и публичного адреса, то есть ставит интеграцию в зависимость от чужих рук. Опросу нужен только токен, который уже есть, а отметка последнего изменения делает каждый проход дешёвым.
Теперь по граблям, в том порядке, в каком они больнее всего бьют.
У МойСклада нет тестового контура
Первое, что стоит знать до строчки кода: любая запись через API попадает в боевой учёт. Отдельной песочницы с копией данных нет. Это главное, что делает интеграцию тяжёлой: правильность выгрузки окончательно проверяется только на настоящих заказах и только людьми заказчика, которые открывают документ у себя.
Поэтому выгрузка заказа у нас по умолчанию ничего не отправляет. Она собирает документ, проверяет его и останавливается. Запись происходит только при переменной окружения, разрешающей запись, и явном confirm: true в вызове. Два условия сразу, чтобы случайный прогон с разработческой машины физически не мог создать документ.
Повторную выгрузку помогло распознать соглашение, которое уже сложилось в учёте заказчика: имя документа совпадает с externalCode и с номером заказа на сайте. Мы взяли то же соглашение, и перед созданием выгрузка спрашивает, нет ли документа с таким externalCode. Работает это только при одном условии: учёт на этот вопрос действительно ответил. Об этом следующий раздел.
Отказ учёта - это не «ничего не нашлось»
Сотрудница заказчика открыла в учёте свежий заказ примерно на три десятка позиций. Одна позиция стояла на родительской карточке товара, а не на модификации, которую выбрал покупатель. Резерв встал на карточку без остатка, а нужный размер остался свободным.
В нашей базе строка заказа была верной, и при повторном запуске код находил модификацию правильно. Причина нашлась в журнале службы: в минуту оформления учёт ответил 429. Выгрузка делала по запросу на каждую карточку, около тридцати подряд, и упёрлась в лимит. Повтора у транспорта выгрузки не было, а функции поиска считали любой ответ, кроме 200, пустым результатом. «Учёт не ответил» и «модификации нет» выглядели в коде одинаково, и позиция молча легла на карточку, без предупреждения менеджеру.
С этим классом ошибки мы уже встречались. Ещё в августе запрос «есть ли уже такой документ» уходил без заголовка авторизации. Боевой API ответил бы 401, код прочитал бы это как «документа нет», и повторная выгрузка создала бы в учёте второй. Тест был зелёным: подставной транспорт в нём заголовки не проверял.
Правила, к которым мы пришли:
429повторяем для любого метода, в том числе для записи:429значит «запрос не принят», документа не создано. Паузу берём из заголовкаX-Lognex-Retry-TimeInterval(в миллисекундах), не больше трёх повторов и около десяти секунд ожидания на запрос.5xxна записи не повторяем никогда. Учёт мог создать документ и упасть уже после, и повтор завёл бы второй. Дубль в учёте заказчика хуже отказа: отказ менеджер видит в истории заказа и выгружает кнопкой, а дубль кто-то должен найти и удалить руками.5xxна чтении в выгрузке тоже не повторяем. Лежащий учёт паузой не поднять, а оформление заказа ждало бы: на подставном учёте, который отвечает503на всё, выгрузка с повтором чтений растянулась с секунды до почти четырёх минут.Функции поиска возвращают отказ отдельно от пустого результата. Если модификацию найти не удалось, позиция может остаться на карточке, но в предупреждениях выгрузки появляется строка с названием позиции и причиной: «модификация не найдена в учёте» или «учёт не ответил».
Первая версия исправления открыла новую дыру, и нашла её независимая проверка перед выкаткой. Пока запись на 429 не повторялась, отказ на проверке существующего документа обычно сопровождался и отказом записи. С повтором запись стала проходить: проверка получает 429, запись со второй попытки 201, и если документ в учёте уже был, рядом появляется второй. Правило пришлось сформулировать жёстче: создавать документ можно, только когда его отсутствие доказано ответом 200. Любой другой ответ откладывает выгрузку. Оформление заказа это не ломает: выгрузку можно повторить кнопкой, и она повторится сама при следующем событии заказа, оплате или смене статуса.
Если документ уже есть, мы обновляем в нём только состояние и проект. Позиции не трогаем: сотрудники заказчика правят документы руками, и перезапись затёрла бы их работу.
После исправления мы прошли запросами только на чтение все заказы за месяц и сравнили, куда ссылается каждая позиция в учёте, с тем, куда должна. Расхождение нашлось одно, тот самый заказ.
Остаток считался по всем складам сразу
В одном аккаунте МойСклада у заказчика несколько складов и два юрлица: российское и второе, в другой стране. Эндпоинт ассортимента без фильтра отдаёт остаток суммой по всем складам.
Сначала мы переносили на сайт именно эту сумму. В неё попадали склад брака и склад с отрицательным остатком, а главное - склад второго юрлица. Карточка на российском сайте показывала «в наличии» товар, который физически лежал только в другой стране. Это тоже нашла сотрудница заказчика на живом товаре, а не наши проверки.
Лечится фильтром по складу, с которого сайт реально продаёт:
export function assortmentUrl(pageSize: number, offset: number, storeId = stockStoreId()): string { const storeFilter = storeId ? `&filter=stockStore=${BASE}/entity/store/${storeId}` : ""; return `${BASE}/entity/assortment?limit=${pageSize}&offset=${offset}${storeFilter}`;} const storeFilter = storeId ? &filter=stockStore=${BASE}/entity/store/${storeId} : "";
return ${BASE}/entity/assortment?limit=${pageSize}&offset=${offset}${storeFilter};
}
Полезная деталь про этот фильтр: он не выкидывает позиции из ответа, он пересчитывает stock и quantity по одному складу. Размер ассортимента с фильтром и без него одинаковый. Если у вас есть предохранитель вида «из учёта пришло подозрительно мало позиций, не применяем», фильтр склада его не ослабит.
externalCode и внутренний идентификатор - два разных ключа
У каждой позиции в МойСкладе два идентификатора. externalCode вида aEEimfViiO9Pbtt3x1LSa1 - его мы храним у себя. Внутренний UUID вида c4341551-f7a8-11ec-… - он стоит в ссылках документов. На этой разнице мы споткнулись дважды.
Первый раз - при выгрузке заказа. Внешний код подставлялся прямо в путь /entity/product/<код>. Замер на боевом учёте дал 404 на трёх товарах из трёх: путь сущности строится по внутреннему идентификатору, внешний код туда не годится.
Второй раз - при чтении резерва по заказу. Первая версия сопоставляла позиции документа по ссылке и не сошлась ни на одной строке из одиннадцати. Тест при этом был зелёным: он проверял разбор ссылки, то есть нашу же неверную посылку. Ошибка нашлась только запросом к настоящим документам.
И ещё одна мелочь на ту же тему: чтобы в позиции документа пришёл externalCode, нужен expand=positions.assortment. Без него в позиции лежит только ссылка.
Резерв: модификация важнее товара
Покупатель выбирает размер или цвет, и позиция заказа обязана ссылаться на модификацию, а не на родительский товар. У нас сначала уходила ссылка на родителя. В учёте это выглядело так: у родительской карточки остаток ноль и доступно минус один, а у модификации лежат десять штук без всякого резерва. Товар в минусе при полном складе, и списывать нечего.
Второе: без поля reserve в позиции МойСклад товар не держит. Документ создаётся, заказ в учёте есть, а остаток остаётся свободным, и тот же товар продают второй раз.
Третье - что просить под резерв, когда остаток неизвестен. Мы выбрали резервировать всё заказанное:
export function reserveForLine(quantity: number, availableStock?: number | null): number { // Остаток неизвестен - просим всё заказанное. Молча уронить резерв // в ноль было бы хуже: товар остался бы свободным и его продали бы второй раз. if (availableStock == null || !Number.isFinite(availableStock)) return quantity; const free = Math.max(0, Math.floor(availableStock)); return Math.max(0, Math.min(quantity, free));} // Остаток неизвестен - просим всё заказанное. Молча уронить резерв
// в ноль было бы хуже: товар остался бы свободным и его продали бы второй раз.
if (availableStock == null || !Number.isFinite(availableStock)) return quantity;
const free = Math.max(0, Math.floor(availableStock));
return Math.max(0, Math.min(quantity, free));
}
Функция вынесена отдельно ради проверки: правило про предзаказ тестируется без сети и без базы.
И про отображение. В карточке заказа в админке менеджер видел «заказано 1, резерв 2», потому что колонка показывала резерв товара по всем неотгруженным заказам. Резерв конкретного заказа берётся из документа в учёте, и только оттуда: менеджер мог поменять его руками, а после отгрузки учёт его освобождает.
Статусы: только идентификаторы
Номера расходятся на истории. При переезде исторические заказы получили префикс IS-, а в МойСкладе у тех же заказов остались голые номера. Новые заказы совпадали точно, а по истории замер дал ноль совпадений из восьми. Статусы по мигрированным заказам, которые ещё в работе, просто не доезжали. Решение - два кандидата на поиск:
const candidates = [externalCode];if (/^\d+$/.test(externalCode)) candidates.push(`IS-${externalCode}`);if (/^\d+$/.test(externalCode)) candidates.push(IS-${externalCode});
Обратной неоднозначности нет: наши номера всегда с префиксом, поэтому голое число ни с чем другим совпасть не может. Номера второго юрлица имеют свой формат и не совпадают ни точно, ни с префиксом, как и должно быть.
Имена состояний нельзя сравнивать. В учёте заказчика есть и «Ждём оплату», и «Ждем оплату», и это два разных состояния. Похожие пары нашлись и среди других состояний. Сравнение без учёта регистра или с заменой «ё» на «е» связало бы заказ не с тем состоянием. Поэтому состояние выбирается по идентификатору, а по имени - только точным совпадением, и имя, которое встречается дважды, не угадывается вовсе.
Соответствие статусов взяли из данных. Какое состояние учёта соответствует нашему «доставлен» или «отменён», решает заказчик. Сначала идентификаторы задавались только переменными окружения. Замер показал две вещи. По всей истории заказов имя совпадало с именем в 99,9% случаев. А все переменные оказались пустыми и локально, и на стенде: выгрузка ушла бы в учёт без состояния. Настройку, которую надо не забыть задать, однажды забудут, и у нас это случилось на двух средах из двух. Замер стал умолчанием, переменная осталась переопределением.
Одно исключение оставили пустым намеренно: оплаченный заказ не имеет состояния по умолчанию. Это решение владельца, оплата в учёте ведётся отдельной осью. Пустоту закрепили тестом, чтобы её никто не «дозаполнил» из лучших побуждений.
Вместе со статусом двигать и видимое имя. На стенде учёт прислал «Доставлен», внутренний статус сменился, а видимое имя из справочника у трёх заказов осталось «Согласован». Внутренний статус не видит никто, имя видят все экраны. Пишем только имя, которое есть в справочнике заказчика: чужая строка дала бы заказу значок вне всех групп, и фильтры перестали бы его находить.
Деньги сходятся только из одного источника
Единица измерения должна быть в имени поля. Наценка за опции хранилась на единицу товара, а при выгрузке читалась как наценка на всю позицию. У заказа с двумя одинаковыми позициями с опцией документ в учёте выходил меньше заказа ровно на стоимость опции. Тест был зелёным: он проверял заказ на одну штуку, а при количестве один числа совпадают. Поле переименовали так, чтобы единица читалась из названия.
Ручная скидка менеджера. Выгрузка передавала состав заказа без скидки на весь заказ. После выгрузки сумма в учёте становилась больше, чем на сайте, и сотрудники заказчика видели «в системе одна сумма, в МойСкладе другая». Скидку и списанные бонусы раскладываем по ценам позиций так же, как в чеке по 54-ФЗ.
Корзина для платёжного шлюза собирается из фискального чека. Состав, который покупатель видит на странице оплаты, и состав, который уходит в ОФД, обязаны совпадать. Если собирать их двумя функциями, однажды они разойдутся, и у нас это случилось на той же наценке за опции.
У шлюза при этом есть требование, которое легко принять за формальность: сумма позиции, делённая на количество, не длиннее двух знаков после запятой. Раскладка скидки копейка в копейку спокойно даёт позицию, которая на количество не делится: 1000 ₽ × 3 минус 10 ₽ скидки это 2990 ₽, а 2990 / 3 = 996,666… Шлюз на такое отвечает ORDER_CART_DATA_ERROR, то есть отказом на кассе.
Такую позицию мы делим на две с ценами, различающимися на одну копейку. Две единицы по 996,66 ₽ и одна по 996,67 ₽ дают те же 2990 ₽, и каждая часть делится на своё количество без остатка.
Мелочи API, на каждой из которых теряется время
Без заголовка
Accept-Encoding: gzipAPI отвечает415. Nodefetchставит его сам, а вот curl и часть HTTP-клиентов нет, и первый запрос из консоли выглядит как поломка.У МойСклада ограничение на параллельные запросы одного пользователя. У товара с десятком размеров все коды разом дают
429, поэтому запросы идут пачками фиксированного размера.У фоновой синхронизации повтор с растущей паузой был с самого начала: до пяти попыток и около пятнадцати секунд в сумме. Выгрузка заказа ходила в учёт своим транспортом и этим повтором не пользовалась, отсюда история из второго раздела. Если к одному API у вас ходят два клиента, проверьте, что повтор есть в обоих.
Экран менеджера пятнадцать секунд ждать не может: там один запрос с таймаутом в три-четыре секунды.
Недоступный учёт не имеет права ломать карточку заказа, которую менеджер открывает десятки раз в день. При отказе в поле ставится прочерк, карточка открывается.
Что проверить до запуска
Запись в учёт закрыта двумя условиями сразу, по умолчанию выгрузка только собирает и проверяет документ.
Отказ учёта не читается как пустой ответ. Документ создаётся, только когда его отсутствие подтверждено ответом
200.429повторяется для любого метода,5xxна записи не повторяется.Подставной транспорт в тестах проверяет заголовки авторизации, иначе запрос без токена будет зелёным.
Остатки берутся с фильтром по складу, с которого сайт продаёт, если складов или юрлиц в аккаунте больше одного.
Везде понятно, где хранится
externalCode, а где нужен внутренний идентификатор, и это проверено запросом к настоящим документам.Позиция заказа ссылается на модификацию и несёт
reserve.Статусы сопоставляются по идентификаторам, мигрированные номера ищутся и с префиксом, и без.
Суммы документа в учёте, заказа на сайте, чека и корзины шлюза сверены на заказе с количеством больше одного, со скидкой и с опциями.
Экраны, которые читают учёт вживую, открываются при недоступном МойСкладе.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.