Пара цифр в прайсе поставщика: было 12 490, пришло 1 249. Потерялась десятичная запятая или лишний ноль, и в таблице выглядит это совершенно невинно. Если расписание ночью берёт файл и сразу кладёт числа на витрину, к утру магазин торгует дорогим товаром по цене чехла для него. Заказы пойдут быстрее, чем кто-нибудь проснётся.

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

Два разных события: получить и выпустить

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

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

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

Нормализация: число — это ещё не цена

Строка «1 249,00 ₽/шт» и строка «1249.00» — одна и та же цена, но программа этого не знает. Прежде чем сравнивать что-либо с чем-либо, входные значения приводят к единому виду. Что именно нужно привести:

  • Формат числа. Пробелы и неразрывные пробелы как разделители тысяч, запятая или точка как десятичный знак. Хранить деньги лучше как decimal или целое число в минимальных единицах, а не как число с плавающей точкой.
  • Валюта. Если прайс может прийти в разных валютах, валюта — отдельное поле, а не часть строки. Цена без валюты не должна выпускаться, если в магазине их несколько. Пересчёт по курсу — отдельное бизнес-решение со своим источником курса и датой.
  • Единицы. «За штуку», «за упаковку 10 шт.», «за метр», «за кг». Одна и та же цифра в разных единицах отличается в разы. Если в магазине товар продаётся поштучно, а поставщик прислал цену за упаковку, нужен коэффициент, и он должен быть явным полем, а не знанием в чьей-то голове.
  • Идентификатор позиции. Артикул нужно нормализовать так же строго, как цену: обрезать пробелы, привести регистр, сохранить ведущие нули. Пустой артикул — не «артикул без значения», а отсутствие артикула, и такая строка в выпуск не идёт. В PostgreSQL пустая строка не тождественна NULL (документация по ограничениям), поэтому проверять придётся оба случая: и NULL, и пустую строку после обрезки.

Нормализация должна быть детерминированной и записываться: для каждой строки сохраняем и исходное значение, и результат. Когда через неделю выяснится, что «0,5» было прочитано как пятьдесят копеек, а не как полцены, вы сможете увидеть, что именно пришло.

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

Сравнение с действующей ценой до публикации

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

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

Условный пример: три цены из одного прайса
ПозицияДействуетПришлоРешениеОснование
A-1001 2001 260выпуститьрост 5% укладывается в порог ±15%
B-2053400отклонитьцена не больше нуля
C-31012 4901 249на ручную проверкупадение на 90% выходит за порог

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

Набор правил обычно складывается из нескольких слоёв:

  1. Жёсткие запреты: ноль, отрицательное значение, отсутствие валюты, неизвестная единица. Нарушение — отклонение без вариантов.
  2. Относительный порог изменения за один выпуск, например отдельно для роста и для падения. Падение чаще опаснее, поэтому порог для него можно сделать строже.
  3. Абсолютные границы для категории: цена товара не должна опускаться ниже закупочной плюс минимальная наценка, если закупка у вас известна.
  4. Общий предохранитель: если подозрительными оказались, скажем, больше определённой доли позиций, подозрителен весь прайс. Массовое смещение почти всегда означает сбой формата (например, сдвинулись колонки), а не одновременное решение поставщика пересмотреть весь каталог.

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

Выпуск как отдельный объект

Проверенные изменения не отправляются в магазин «потоком» по мере проверки. Вместо этого собирается выпуск — самостоятельная запись со своим идентификатором. В нём лежит:

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

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

Номер выпуска стоит держать уникальным на уровне базы, а не только в коде. PRIMARY KEY задаёт уникальность и запрещает NULL, а UNIQUE может охватывать несколько столбцов (PostgreSQL: Constraints). Составной UNIQUE по выпуску и артикулу гарантирует, что одна позиция не попадёт в один выпуск дважды с разными ценами. Ограничение в базе надёжнее проверки в коде: оно сработает, даже если два процесса запустятся одновременно.

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

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

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

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

Что делает транзакция, а чего не делает

Когда выпуск утверждён, нужно изменить собственные данные: обновить таблицу действующих цен, записать прежние значения, сменить статус выпуска. Всё это — операции вашей базы, и здесь транзакция работает именно так, как нужно. Она объединяет шаги в операцию «всё или ничего»: ROLLBACK отменяет изменения незавершённой транзакции, а COMMIT фиксирует их (PostgreSQL: Transactions). Если на тысяча первой позиции что-то пошло не так, не останется ситуации, когда тысяча позиций обновлена, а остальные нет.

Внутри одной транзакции разумно сделать следующее:

  1. Сохранить для каждой позиции выпуска текущую действующую цену как «прежнюю» в записи выпуска (если она ещё не там со сборки).
  2. Обновить действующие цены новыми значениями.
  3. Перевести выпуск в состояние «зафиксирован локально, ожидает отправки».

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

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

Повтор выпуска: когда ответа не было

Самый неприятный случай — обрыв связи. Запрос ушёл, а ответа нет. Магазин мог принять цены, мог не принять, мог принять часть. Слепо отправить всё заново — не всегда безопасно, а ничего не делать — значит оставить витрину в неизвестном состоянии.

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

Надёжный порядок повтора выглядит так:

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

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

Откат, который не стирает чужую работу

Допустим, выпуск ушёл, а через час выяснилось, что часть цен неверна. Хочется нажать «вернуть как было». Но с момента выпуска прошло время, и в магазине могло произойти многое: менеджер вручную поправил цену на горячий товар, пришёл другой выпуск, прошли новые заказы.

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

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

Условный откат выпуска по трём позициям
ПозицияПрежняяВыпущенаСейчас в магазинеДействие
A-1001 2001 2601 260вернуть 1 200
C-31012 4901 24911 990не трогать, показать человеку
D-007580610610вернуть 580

Вторая строка — как раз та, ради которой откат делается условным. Кто-то увидел аномальную цену и уже выставил свою. Автоматический возврат 12 490 перезаписал бы разумное исправление. Система сообщает: «позиция изменилась после выпуска, вот три значения» — и решение остаётся за человеком.

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

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

Расписание и ресурсы сервера

Всё описанное — лёгкая по ресурсам работа: чтение прайса, десятки тысяч сравнений, несколько запросов к базе. Под неё не нужна мощная машина, но нужна предсказуемость: непрерывно работающий планировщик, локальная база и место для журнала, который со временем растёт, потому что хранить историю выпусков стоит долго. На VPS MAATRIX доступны NVMe-накопитель, процессоры AMD EPYC и root-доступ, так что поставить рядом PostgreSQL и планировщик можно без сторонних ограничений на состав софта. Для небольшого каталога начальные тарифы линейки, от Ba с 1 vCPU, 2 ГБ памяти и 40 ГБ NVMe, выглядят разумной отправной точкой, но реальную достаточность покажет ваш объём прайса и частота запусков, а не название тарифа.

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

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

Прайс, который не дошёл до витрины

Теперь соберём всё в одну ночь. В три часа планировщик забирает прайс и складывает сырые строки. Нормализация читает «1 249,00 ₽/шт» как 1249.00 рубля за штуку, а нулевую ячейку у позиции B-205 фиксирует как ошибку. Сравнение с действующими ценами показывает, что у C-310 падение на 90%, а ещё у ста с лишним позиций цены почему-то разом упали примерно в десять раз. Общий предохранитель срабатывает: подозрительной оказалась слишком большая доля каталога.

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

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