Перейти к содержанию

03. Синхронизация с iiko

3.1. Инвентарь используемых методов

https://api-ru.iiko.services, авторизация — Bearer-токен.

Метод Назначение Этап
POST /api/v2/access_token получение токена по apiLogin 1
POST /api/1/organizations список организаций (+ returnAdditionalInfo для адреса/координат) 1
POST /api/1/terminal_groups группы терминалов организаций 1
POST /api/2/menu список внешних меню и ценовых категорий 1
POST /api/2/menu/by_id выгрузка внешнего меню (номенклатура + цены + модификаторы) 1
POST /api/1/stop_lists стоп-листы по организациям 1
POST /api/1/commands/status статус асинхронной операции 1
POST /api/1/deliveries/create создание заказа 2
POST /api/1/deliveries/by_id статус заказа 2
POST /api/1/deliveries/order_types типы заказов 2
POST /api/1/discounts скидки и типы оплат 2
POST /api/1/payment_types типы оплат 2
POST /api/1/cities, /api/1/streets/by_city адресный справочник 2
POST /api/1/delivery_restrictions зоны и ограничения доставки 2

Первый этап реализует только верхний блок; клиент проектируется так, чтобы добавление методов второго этапа было добавлением интерфейса, а не переписыванием транспорта.

3.2. Токен

  • Токен живёт 1 час; обновляется по расписанию и по получению 401 (единожды, с ретраем запроса).
  • Хранится в памяти процесса + в Redis (чтобы при нескольких инстансах не выпрашивать токен параллельно). Обновление под распределённой блокировкой.
  • В legacy токен переполучался каждые 10 минут — это лишние запросы; берём TTL с запасом 5 минут до истечения.
  • apiLogin — только из переменных окружения. Логирование запросов маскирует токен и apiLogin.

3.3. Состав и расписание задач

Задача Периодичность Режим
ORGANIZATIONS 1 час полный
TERMINALS 1 час полный
MENU_LIST (внешние меню + ценовые категории) 1 час полный
MENU (по каждому активному внешнему меню) 5 минут инкрементальный по revision
MENU полный 1 раз в сутки, 04:30 полный, startRevision не передаётся
STOP_LIST 45 секунд полный (метод не инкрементальный)

Все задачи — под ShedLock (platform.shedlock), с индивидуальным именем блокировки. Ручной запуск из админки идёт через ту же блокировку: параллельный импорт одного меню невозможен.

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

3.4. Импорт меню

Запрос

POST /api/2/menu/by_id
{
  "externalMenuId": "41188",
  "organizationIds": ["1d4d1278-…", "…"],   // не более 10 за запрос
  "priceCategoryId": "1878b87e-…",           // опционально, из настройки внешнего меню
  "startRevision": 128374,                   // не передаётся при полном импорте
  "asyncMode": false
}

Батчинг по 10 организаций. В legacy это зашито константой groupsLength = 10 — ограничение подтверждено практикой, сохраняем. Ответы по батчам мержатся: категории и позиции объединяются по id, массивы prices конкатенируются (у разных батчей — разные организации, пересечений нет).

Ценовая категория. В legacy соответствие «внешнее меню → ценовая категория» было хардкодом в коде (resolvePriceCategory с картой {"41188": "1878b87e-…", "50865": "8e9a8104-…"} и комментарием, что в iiko поменялось API). В новой системе это строка в iiko.external_menu с редактированием из админки. Соответствие меняется без релиза.

Асинхронный режим. При больших меню iiko отдаёт correlationId; тогда идём в /api/1/commands/status с интервалом 2 с и таймаутом 3 мин. Режим включается настройкой внешнего меню (по умолчанию выключен: текущий объём 19 МБ отдаётся синхронно).

Семантика ревизии

rev_saved := iiko.external_menu.revision
ответ.revision > rev_saved  →  данные изменились, импортируем и обновляем revision
ответ.revision = rev_saved  →  изменений нет, импорт пропускается (sync_run: SKIPPED)

Ответ обрабатывается как полный слепок переданных организаций, а не как дельта, — так делает и legacy. Точная семантика startRevision (отдаёт ли iiko только изменения) проверяется на боевом ключе на старте разработки; от неё зависит только одно: можно ли включать mark-and-sweep удаление на инкрементальном импорте. До проверки удаление выполняется только на полном ночном импорте — это безопасно при любой семантике.

Разбор ответа

Ответ 19 МБ. Требования к парсеру:

  1. Потоковый разбор (Jackson JsonParser, не readValue в дерево). Дерево на 19 МБ даёт 200–400 МБ heap; при трёх меню и параллельных батчах это OOM.
  2. Интернирование строк для повторяющихся значений (id организаций, названия групп).
  3. Модификаторы обрабатываются потоково с записью в буфер на 10 000 строк и сбросом через COPY.

Порядок записи

1. upsert iiko.product_category        (productCategories)
2. upsert iiko.menu_category           (itemCategories, без items)
3. для каждой позиции:
     upsert iiko.item                  (роль DISH)
     upsert iiko.menu_category_item
     upsert iiko.item_size
     replace iiko.item_price           (по паре меню+организация из батча)
     для каждой группы модификаторов:
       upsert iiko.item_modifier_group  (ключ item_size_id + group_key)
       replace iiko.item_modifier
       replace iiko.item_modifier_price
       upsert iiko.item                 (роль MODIFIER — модификатор тоже позиция)
4. mark-and-sweep удалений (только полный импорт)
5. регистрация новых позиций в catalog.item_triage
6. commit
7. публикация события IikoMenuImported → пересборка снапшотов

Шаг 5 — единственное место, где импорт «касается» схемы catalog, и делается это не импортом, а слушателем события в модуле catalog. Модуль iiko про catalog по-прежнему не знает.

Мягкое удаление и защита от «пустого ответа»

Каждый запуск получает sync_run_id; все затронутые строки помечаются synced_at = run.started_at. После успешной обработки всех батчей:

update iiko.item i set deleted_at = now()
where i.deleted_at is null
  and i.synced_at < :run_started_at
  and exists (select 1 from iiko.menu_category_item mci
              where mci.item_id = i.id and mci.external_menu_id = :menu_id);

Перед удалением — предохранитель: если доля исчезнувших позиций больше порога (по умолчанию 30%), удаление не выполняется, импорт завершается статусом FAILED с причиной SUSPICIOUS_SHRINK, в админку уходит уведомление. Такой сценарий бывает при частично собранном меню в iiko и приводит в legacy к обнулению витрины.

Восстановление: если позиция вернулась, deleted_at сбрасывается, привязка к товару витрины оживает автоматически (варианты не удалялись).

Обработка привязок после импорта (слушатель события в модуле catalog, см. 02 §2.4):

для каждой живой позиции, на которую есть вариант:
    обновить variant_binding.last_seen_* (имя, sku, цена, слепок)
    если state = MISSING → закрыть инцидент (ITEM_RETURNED), state = OK
    если имя изменилось  → инцидент NAME_CHANGED (INFO)

для каждого варианта, чья позиция помечена удалённой:
    если прошло меньше binding.missing_grace → ничего не делать (мигание выгрузки)
    иначе → state = MISSING, missing_since, инцидент ITEM_MISSING (CRITICAL)
            искать кандидатов на замену среди позиций с first_seen_at > missing_since:
              совпадение sku            → confidence 0.95
              совпадение имени + группы → confidence 0.8
            при наличии кандидатов → инцидент POSSIBLE_REPLACEMENT с details.candidates
            если у товара не осталось живых вариантов → снять с публикации с указанием причины

binding.missing_grace (по умолчанию: 2 подряд импорта или 30 минут) существует именно потому, что iiko иногда отдаёт неполное меню. Без окна ожидания каждая такая выгрузка порождала бы вал ложных инцидентов и снятий товаров с публикации.

Инциденты не удаляются автоматически — они закрываются, оставляя историю. Это то, что позволяет через месяц ответить на вопрос «почему пропал товар и кто что с этим сделал».

3.5. Стоп-листы

POST /api/1/stop_lists
{ "organizationIds": ["…"] }
  • Ответ полный; таблица iiko.stop_list перезаписывается для затронутых организаций одной транзакцией (delete where organization_id in (…) + COPY).
  • Сравнение с предыдущим состоянием даёт diff → событие IikoStopListChanged(orgId, added, removed) → точечное обновление снапшота (не полная пересборка) и инвалидация Redis-ключей.
  • Позиция в стоп-листе не удаляется из витрины: помечается available: false. Поведение витрины (скрывать/показывать серым) — настройка catalog.settings.stoplist_behaviour.
  • Стоп-лист в разрезе терминала: организация может иметь несколько терминалов, товар недоступен, если он в стопе на том терминале, который обслуживает адрес. На этапе каталога API отдаёт стоп-лист по организации; выбор терминала появится вместе с доставкой (этап 2).

3.6. Техника массовой записи

Построчные save() через JPA на 21 231 строку модификаторов дают минуты и «пилу» по WAL. Используется:

// временная таблица на транзакцию
create temp table tmp_item_price (like iiko.item_price) on commit drop;
// COPY через PgCopyManager
copyManager.copyIn("copy tmp_item_price from stdin (format binary)", stream)
// одна операция слияния
insert into iiko.item_price
select * from tmp_item_price
on conflict (item_size_id, external_menu_id, organization_id)
do update set price = excluded.price, synced_at = excluded.synced_at
where iiko.item_price.price is distinct from excluded.price;

where … is distinct from важен: без него каждый импорт переписывает все строки и раздувает таблицу, даже когда цены не менялись.

Целевые показатели (проверяются тестом на фикстуре response.json): полный импорт одного внешнего меню на 10 организаций — ≤ 60 с, пиковый heap — ≤ 512 МБ.

3.7. Отказоустойчивость запросов к iiko

Механизм Настройка
Таймауты connect 5 с, read 120 с (меню), 15 с (остальное)
Ретраи 3 попытки, экспоненциальная задержка 1/3/9 с, только на 5xx, 429 и таймауты
429 Too Many Requests уважать Retry-After, глобальный семафор на исходящие запросы
Circuit breaker Resilience4j: 50% ошибок на окне 20 запросов → открыт на 60 с
Идемпотентность все методы импорта — чтение, повтор безопасен
Частичный успех батчей если упал батч N из M, транзакция откатывается целиком; частичное меню не коммитится

Полный лог запроса/ответа iiko включается флагом (в legacy — logIikoResponse), пишется в файл с ротацией, не в основной лог. Ответ 19 МБ в общий лог не попадает никогда.

3.8. Webhooks

iiko умеет слать webhook'и (обновление стоп-листов, статусы доставок, персональные смены). На первом этапе принимается только стоп-лист:

POST /api/webhooks/iiko    — приём
  проверка подписи/секрета в query или заголовке
  быстрый ответ 200, обработка асинхронно через очередь
  дедупликация по eventId + типу

Webhook — ускоритель, не источник истины: опрос по расписанию продолжает работать, даже если webhook'и настроены. Пропущенный или дублированный webhook не должен приводить к расхождению.

3.9. Наблюдаемость

Каждый запуск пишет iiko.sync_run со статистикой:

{
  "externalMenuId": "41188",
  "organizations": 12,
  "batches": 2,
  "categories": {"created": 0, "updated": 15},
  "items":      {"created": 3, "updated": 240, "deleted": 1},
  "prices":     {"upserted": 2916, "unchanged": 0},
  "modifiers":  {"groups": 704, "links": 21231},
  "payloadBytes": 19268903,
  "revisionFrom": 128374, "revisionTo": 128390
}

Метрики Prometheus: iiko_sync_duration_seconds{job}, iiko_sync_items_total{job,action}, iiko_http_requests_total{endpoint,status}, iiko_last_success_timestamp{job}.

Алерты: - time() - iiko_last_success_timestamp{job="MENU"} > 1800 - time() - iiko_last_success_timestamp{job="STOP_LIST"} > 300 - SUSPICIOUS_SHRINK — немедленно - доля 5xx от iiko > 5% на окне 5 минут

3.10. Сборка снапшота витрины

Снапшот — материализованный ответ публичного API на одну организацию.

Триггеры пересборки: - IikoMenuImported (изменилась ревизия) — полная пересборка для организаций меню; - IikoStopListChanged — патч поля доступности; - изменение в catalog (публикация товара, сортировка, картинки, категории) — полная пересборка затронутых организаций, дебаунс 5 с (контент-менеджер тянет мышкой сортировку — не пересобирать на каждое движение).

Сборка идёт в фоне; пока новый снапшот не готов, отдаётся предыдущий. Публикация нового — атомарная замена строки в catalog.menu_snapshot + SET в Redis + инкремент revision. etag = sha256(payload); при If-None-Match отдаётся 304.

Redis — кеш, не источник: при пустом Redis снапшот читается из Postgres, при пустой таблице — собирается синхронно.