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

06. Решения, открытые вопросы, план работ

6.1. Принятые решения (ADR)

ADR-001. Модульный монолит на Spring Modulith вместо набора сервисов

Контекст. Legacy: PHP site, Go menu, Go payment, Go user, Node notification. Меню из iiko импортировалось дважды — в site/app/Models/Import/* и в menu — в две разные схемы с разной моделью данных. Решение. Один процесс, одна БД, модули с явными границами; границы держит Spring Modulith. Следствия. Один импорт, одна модель данных, одна транзакция. Границы проверяются тестом (ApplicationModules.verify()), а не договорённостями. Транзакционный outbox получаем из коробки (Event Publication Registry) — событие «меню импортировано → пересобрать снапшот» не теряется при падении процесса. Цена — общий деплой и требования Modulith к структуре пакетов.

ADR-011. JDK 25 и актуальный Spring Boot

Решение. JDK 25 (LTS), Spring Boot 4.x, Kotlin 2.x; версии проверяются на пустом каркасе в первый день. Обоснование. Виртуальные потоки без ограничений pinning — прямая выгода для IO-bound импорта (до 10 параллельных запросов к iiko) и для API витрины; generational ZGC и compact object headers уменьшают пиковую память при разборе многомегабайтного JSON меню. Проект новый, легаси-зависимостей нет — брать устаревший рантайм не на чем.

ADR-002. Разделение слоёв «источник» и «витрина»

Решение. Схема iiko пишется только импортом; схема catalog — только админкой. Переопределения — отдельные nullable-колонки, чтение через coalesce. Следствия. Импорт не может затереть ручную работу; ручная работа не может исказить данные, уходящие в заказ.

ADR-003. iiko — источник истины по цене, составу и наличию

Решение. Цены не редактируются в админке. Обоснование. Цена, по которой продано, должна совпадать с ценой в чеке. Любое переопределение на стороне сайта создаёт класс расхождений, который дороже гибкости. Следствия. Акции и скидки (этап 2) реализуются как отдельный слой поверх цены iiko со своими правилами, а не правкой цены товара.

ADR-004. Собственное дерево категорий витрины

Решение. catalog.category создаётся вручную; itemCategories и productCategories из iiko — только сырьё. Обоснование. Переименование категории в iiko («Роллы Доставка») не должно менять URL и SEO; витрине нужны вложенность, картинки, тексты и порядок, которых в iiko нет.

ADR-005. Товар с вариантами, привязка вручную

Решение. Один товар = одна карточка, вариант = одна позиция iiko, привязку делает человек с автоподсказкой. Обоснование. В iiko размеры пиццы — 4 разных блюда в 4 разных категориях; автоматика по названию ошибается на нестандартных наименованиях, а ошибка привязки означает неверную цену. Инвариант. unique (iiko_item_size_id) — позиция iiko принадлежит максимум одному товару.

ADR-006. Состав группы — на уровне блюда, цена модификатора — глобальная

Решение. iiko.item_modifier_group и ограничения вхождения привязаны к размеру блюда. Цена хранится у глобального модификатора в контексте импортированного меню/организации и не дублируется в связи с блюдом. Обоснование (данные и бизнес-правило). Ранее исследованный снимок v2 содержал три случая цены 35 ₽ / 60 ₽ для одного UUID в разных блюдах; клиент подтвердил, что это была ошибка настройки iiko и цена должна совпадать для всех блюд и размеров. В актуальных контрольных ответах меню в форматах v2 и v3 набор из 236 modifier ID и все цены полностью совпадают; среди 21 031 вхождения нет ни одного ID с разными ценами. В v3 цена физически вынесена в глобальный modifiers[], а ссылки из блюд её не содержат. Контекст блюда сохраняется для состава и ограничений: одинаковый ID группы всё ещё может иметь разные наборы позиций, а 11 групп приходят без ID.

ADR-007. Один generic-товар вместо таблиц на каждый тип

Решение. Никаких dish_pizza / dish_snacks / dish_drinks / dish_non_food. Различия типов выражаются данными: категория, ось вариативности, набор модификаторов, бейджи. Обоснование. В legacy добавление типа товара = миграция + модель + репозиторий + экран + ветка в сборке меню. Мерч и «пиво разливное» из выгрузки в такую модель не помещаются вовсе.

ADR-008. Снапшот меню вместо сборки на каждый запрос

Решение. Материализованный JSON на (организация, внешнее меню) в Postgres + Redis, пересборка по событию, отдача с ETag. Обоснование. Сборка меню — это join по 8 таблицам с 21 тыс. связок модификаторов; на каждый запрос витрины это неприемлемо, а данные меняются несколько раз в сутки.

ADR-009. Админка — отдельное SPA

Решение. React+AntD собирается независимо, общается только через /api/admin/v1. Обоснование. Orchid в legacy связал вёрстку админки с релизом бэкенда. Отдельное SPA позволяет менять админку без деплоя бэкенда и наоборот, а также переиспользовать API для интеграций.

ADR-012. Каталог проектируется и заполняется с нуля, legacy-данные не мигрируются

Контекст. В legacy товары разложены по таблицам dish_pizza, dish_snacks, dish_drinks, dish_non_food с десятками таблиц-спутников (dish_pizza_ingredients, dish_pizza_dough, dish_snack_sauces, dish_drink_toppings …), два параллельных дерева категорий и два импорта iiko. Решение. Каталог не мигрируется: категории и карточки клиент собирает заново в новой админке, изображения загружаются заново, заказы удаляются. Мигрируются только пользователи, и это задача следующего этапа. Обоснование. Перенос данных потянул бы за собой разбиение товаров по типам — ровно ту структуру, от которой отказываемся (ADR-007). Экран «Новые позиции iiko» делает пересборку обозримой: вся номенклатура лежит готовой очередью с автоподсказкой вариантов. Следствия. Нужен список URL, важных для SEO, и редиректы. Legacy используется только как источник разбора ошибок, не как источник данных и не как ориентир схемы.

ADR-013. Снимок привязки к позиции iiko

Контекст. Сотрудники случайно удаляют и пересоздают блюда в iiko; пересозданное блюдо приходит с новым itemId. Ссылка варианта на позицию становится нерабочей, товар молча пропадает. Решение. Вариант хранит слепок позиции iiko на момент привязки и последнее известное живое состояние (catalog.variant_binding), пропажа порождает инцидент (catalog.binding_incident), замена подбирается по SKU и названию, перепривязка — одно действие. Обоснование. Карточка — это ручной труд: тексты, фото, SEO, сортировка. Ошибка в iiko не должна стоить его повторения. Плюс появляется точный ответ на вопрос «почему товара нет на сайте». Следствия. Удаление в iiko никогда не удаляет витринные сущности; окно ожидания (missing_grace) отсекает шум от неполных выгрузок.

ADR-010. Публичное API отдаёт нормализованное меню

Решение. Модификаторы — отдельным словарём, товары ссылаются по id; группы с одинаковым составом схлопываются. Обоснование. 5,4 МБ ответа iiko при 241 блюде — прямое следствие денормализации. Витрина должна получать сотни килобайт.

ADR-014. Франшиза — единая платформа, платформа не участвует в расчётах

Контекст. Франчайзи работают через собственные юридические лица со своими договорами с iiko, ЮKassa и ОФД. Нужно было выбрать между единой платформой и деплоем на каждого франчайзи, а также определить продавца и денежный поток. Решение. Одна платформа, один домен и бренд на всю сеть. Продавец — юридическое лицо франчайзи, которому принадлежит ресторан заказа. Деньги покупателя идут напрямую в ЮKassa этого юрлица, чек пробивает его касса, возврат выполняет он сам. Платформа не является продавцом, платёжным агентом и получателем денег. Роялти считается вне платформы по всей выручке из iiko франчайзи. Следствия. Реквизиты ЮKassa и доступ к ОФД хранятся на уровне юрлица в зашифрованном виде. Продавец определяется по ресторану до создания платежа и не меняется, заказ нельзя переназначить на ресторан другого юрлица. Реестры платформы покрывают только онлайн-канал и к роялти отношения не имеют. Распределение сервисов — Франшиза.

ADR-015. Собственный аккаунт iiko у юридического лица франчайзи

Контекст. ОВ-1 рассматривал три сценария: франчайзи — организации в аккаунте головной компании (A), собственный аккаунт iiko при общем бренде и каталоге (B), собственный бренд и каталог (C). Решение. Сценарий B. Цепочка франчайзи → юрлицо → iiko.connection → организация → ресторан: iiko.connection принадлежит юрлицу (legal_entity_id неизменяем), apiLogin вводит франчайзи в Личном кабинете. У юрлица может быть несколько подключений. Ресторан навсегда привязан к паре (connection, organization); смена юрлица — закрытие ресторана и создание нового. Бренд и каталог общие, сценарий C не планируется. Следствия. Данные слоя iiko привязаны к подключению. apiLogin хранится в БД, зашифрованный мастер-ключом из Secret, а не в переменных окружения; до шифрования боевые учётные данные не используются (модель франчайзинга). Одинаковые по смыслу блюда, модификаторы, скидки и типы оплаты имеют разные ID в разных аккаунтах — это ОВ-1.

ADR-016. Секреты деплоя — файлами (Compose secrets), не переменными окружения

Контекст. deploy/monolith/docker-compose.yml вручную копируется на test и prod. Приложение уже умеет читать секреты (JWT, smsc, telegram, max) из файла через *_SECRET_FILE-свойства. Решение. На test/prod эти секреты передаются файлами через secrets: Docker Compose (без Swarm — просто bind-mount файла в /run/secrets/), а не значением в .env. Обоснование. Значение переменной окружения контейнера виден через docker inspect кому угодно с доступом к Docker API (шире, чем shell на сервере), а также часто улетает целиком во внешние APM/crash-репортеры, которые снимают снапшот окружения при падении процесса. Файл отдаётся только тому, кто явно читает конкретный путь — это другой, более узкий класс уязвимости. Локальная разработка (.env.example в корне, task run) продолжает использовать значения напрямую — там это не боевые секреты. Следствия. Пароль Postgres для приложения — исключение: у spring.datasource.password нет файлового варианта, поэтому в контейнере app он передаётся обычной переменной (виден только внутри приватной сети compose); сам контейнер postgres при этом использует POSTGRES_PASSWORD_FILE. Список секретов и порядок разворачивания на сервере — deploy/monolith/README.md.

6.2. Открытые вопросы

ОВ-1. Сопоставление каталога сети с номенклатурой iiko франчайзи

Модель аккаунтов выбрана (ADR-015): у каждого юрлица франчайзи собственный apiLogin. Поэтому одно и то же блюдо и его модификаторы имеют разные ID в разных аккаунтах iiko, и виртуальный вариант Панам нельзя связать с единственным глобальным iiko.item.

Что уже заложено:

  1. Слой iiko рассчитан на несколько подключений: данные iiko привязаны к подключению напрямую (connection_id) или через ресторан, apiLogin берётся из подключения юрлица, а не из глобальной настройки.
  2. Витринные ключи (slug категорий и товаров) уникальны глобально — бренд один на всю сеть.
  3. Публичное API принимает organization_id в каждом query string — точкой входа остаётся организация ресторана.

Блокирует проектирование привязок и создание заказа. Привязка должна разрешаться в контексте выбранного ресторана:

variant_id + connection_id [+ organization_id] → iiko_item_id

До фиксации схемы БД и контракта заказа нужно получить ответы:

  1. Совпадают ли ID блюд, размеров и модификаторов между организациями одного подключения?
  2. Кто и как сопоставляет общие виртуальные варианты Панам с локальными позициями каждого франчайзи: по артикулу, который головная компания обяжет проставлять одинаково, или вручную с подтверждением при подключении ресторана? Кто отвечает за полноту соответствия?
  3. Должен ли товар автоматически становиться недоступным в конкретном ресторане, если для него или обязательного модификатора нет активной локальной привязки, или ресторан не активируется, пока меню не сопоставлено полностью?
  4. Как обнаруживать расхождения, когда франчайзи переименует или пересоздаст позицию у себя?

До получения ответов принимается безопасное архитектурное допущение: привязки блюд, размеров и модификаторов хранятся отдельно для каждого iiko.connection, а при необходимости — и для каждой организации. Создание заказа разрешено только после успешного разрешения всех локальных ID.

Той же природы ещё три вопроса из раздела «Открытые вопросы» документа Франшиза:

Вопрос Что из этого следует для реализации
Скидки и акции сети нужна таблица соответствия акции сети скидке iiko ресторана; поведение при отсутствии скидки не выбрано
Один покупатель и несколько баз гостей iiko до решения заказ отправляется в iiko без привязки к гостю; история покупателя — только в платформе
Типы оплаты нужно соответствие «способ оплаты витрины → тип оплаты iiko» на уровне ресторана или подключения; GUID в код не зашиваются

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

ОВ-2. Семантика startRevision в /api/menu/v3/by_id

Отдаёт ли iiko при указании startRevision только изменения или полное меню. До проверки на боевом ключе удаление позиций выполняется только на полном ночном импорте. Проверяется в первую неделю разработки.

ОВ-3. Ценовые категории

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

ОВ-4. Поведение при стоп-листе

Скрывать товар или показывать неактивным с пометкой «нет в наличии»? Влияет на конверсию и на SEO (исчезающие товары). Предлагается настройка с умолчанием «показывать неактивным».

ОВ-5. Несколько внешних меню на организацию

В legacy их два (41188, 50865) — предположительно доставка и зал. Если витрина использует только одно, второе не нужно синхронизировать. Требуется подтверждение, чтобы не гонять лишний объём каждые 5 минут.

ОВ-6. Комбо и наборы

В выгрузке comboCategories пуст, но механизм в iiko есть. Если сеты («Сеты» есть в учётных группах) собираются как комбо, потребуется отдельная модель. Сейчас предполагается, что сет — обычное блюдо. Требуется подтверждение.

ОВ-7. Судьба legacy-сервисов

payment, user, notification — переносятся в монолит или остаются? На этап каталога не влияет, но определяет план миграции. Предложение: перенести в монолит модулями на своих этапах, notification может остаться отдельным (у него другой профиль нагрузки и внешние интеграции).

6.3. План работ по этапу «Каталог»

# Работа Результат Оценка
0 Инвентаризация размеров изображений (см. 07-media.md §7.1) — совместно с фронтендом витрины заполненная таблица мест использования и утверждённый список пресетов 1–2 дн, блокирует п. 6
1 Каркас монолита: Gradle, JDK 25, Spring Boot + Modulith, Flyway, Docker, CI, структура модулей, ApplicationModules.verify() собирается и деплоится «hello world» с миграциями и проверкой границ 3–4 дн
2 Схема БД: iiko, catalog, media, platform миграции + тесты на Testcontainers 3 дн
3 iiko-клиент: токен, организации, терминалы, ценовые категории, внешние меню зелёные тесты на WireMock, данные в БД 4 дн
4 Импорт меню: потоковый парсер, bulk-запись, ревизии, mark-and-sweep, предохранитель полный импорт снимка меню v3 (5,4 МБ) ≤ 60 с, ≤ 512 МБ heap 6–8 дн
5 Импорт стоп-листов + события + diff стоп-лист на витрине ≤ 60 с 2 дн
5а Снимки привязок и инциденты: детект пропаж, окно ожидания, подбор замены, перепривязка сценарий «удалили блюдо в iiko → перепривязал в один клик» проходит на тестах 3 дн
6 Модуль media: загрузка, S3, нормализация, нарезка по утверждённым пресетам, точка фокуса, дедупликация, перегенерация загрузка картинки, все пресеты, отдача через CDN, srcset в API 4–5 дн
7 Домен каталога: категории, товары, варианты, размещения, публикация с правилами покрытые тестами сценарии 5 дн
8 Сборка снапшота + публичное API витрины /api/v1/menu p95 ≤ 80 мс, ETag 4 дн
9 Админский API + OpenAPI + RBAC + аудит спецификация, по которой пишется фронт 5 дн
10 Автоподсказка вариантов и триаж новых позиций экран «Новые позиции iiko» работает 3 дн
11 Админка: каркас SPA, авторизация, layout, генерация клиента ходит в API, показывает /me 3 дн
12 Админка: категории (дерево, DnD, формы, медиа) экран готов 4 дн
13 Админка: список товаров, фильтры, массовые операции экран готов 4 дн
14 Админка: карточка товара (все вкладки) экран готов 6–8 дн
15 Админка: новые позиции iiko, проблемы привязок, модификаторы, импорты, справочники экраны готовы 6 дн
16 Загрузка изображений в админке: кроп, точка фокуса, предпросмотр во всех пресетах, понятные ошибки валидации экран, на котором невозможно «загрузить и не увидеть результат» 3 дн
17 Нагрузочное тестирование, наблюдаемость, алерты, документация, рантбук дашборд и рантбук 3 дн
18 Первичное наполнение каталога вместе с клиентом (сопровождение, исправления по ходу) каталог собран, витрина работает 3 дн сопровождения

Итого по этапу каталога: ≈ 68–76 человеко-дней. При двух разработчиках (бэкенд + фронт) с параллельной работой начиная с п. 9 — примерно 8–9 недель календарно.

Критический путь: п. 2 → 4 → 7 → 8 → 9 → 14. Работы 11–16 упираются в готовность спецификации (п. 9), поэтому OpenAPI фиксируется раньше реализации — фронт стартует на моках. Пункт 0 не на критическом пути, но блокирует п. 6 и 16, поэтому запускается в первый же день.

Наполнение каталога — работа клиента, а не разработки: примерно 250 позиций, из них ~80 пицц, собираемых в ~20 карточек с вариантами. При наличии готовых фотографий это несколько дней работы контент-менеджера, и это единственная цена отказа от миграции legacy-данных.

6.4. Что нужно от клиента для старта

  1. Доступ к боевому и тестовому apiLogin iiko (для проверки ОВ-2 и ОВ-3).
  2. Правило сопоставления каталога сети с локальными ID блюд, размеров и модификаторов в iiko франчайзи (ОВ-1) — блокирует проектирование привязок и создание заказа. Схема аккаунтов решена: у каждого юрлица франчайзи свой apiLogin (ADR-015).
  3. Подтверждение назначения внешних меню 41188 и 50865 (ОВ-5).
  4. Размеры изображений от фронтенда витрины — таблица 07-media.md §7.1. Блокирует нарезку.
  5. Список организаций, которые должны быть на витрине в момент запуска.
  6. Список URL каталога, которые важно сохранить для SEO (каталог собирается заново, старые адреса автоматически не наследуются — нужны редиректы).
  7. Готовность контент-менеджера к первичному наполнению каталога и наличие фотографий блюд в исходном разрешении.

6.5. Пользователи из legacy

Единственные данные, которые переносятся. К этапу каталога отношения не имеют — переносятся вместе с модулем клиентов на следующем этапе. Что нужно решить заранее:

  • как хранились пароли в legacy (алгоритм хеша) — от этого зависит, переживут ли пользователи миграцию без сброса пароля;
  • авторизация по телефону с кодом (как сейчас) означает, что пароли могут быть вообще не нужны — тогда мигрируются телефон, имя, история адресов и бонусы;
  • бонусы и накопления, если они хранятся в iiko, а не в legacy, вообще не мигрируются.

Заказы, по решению клиента, не переносятся.