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 МБ. Требования к парсеру:
- Потоковый разбор (Jackson
JsonParser, неreadValueв дерево). Дерево на 19 МБ даёт 200–400 МБ heap; при трёх меню и параллельных батчах это OOM. - Интернирование строк для повторяющихся значений (id организаций, названия групп).
- Модификаторы обрабатываются потоково с записью в буфер на 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. Стоп-листы¶
- Ответ полный; таблица
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, при пустой таблице — собирается синхронно.