11. Блюдо в подарок — компенсация клиенту¶
11.1. Задача¶
Если доставка сильно задержалась или произошла другая сервисная ошибка, ресторан обещает клиенту бесплатное блюдо в следующем заказе. Сейчас оператор работает непосредственно в iiko и пишет в свободном комментарии клиента фразу вроде «добавить блюдо пирог яблочный». При следующем телефонном заказе оператор читает комментарий и вручную добавляет специальную позицию iiko с нулевой ценой. Для заказа с сайта legacy использует персональный промокод и скрытое блюдо.
Новая система должна обеспечить один результат независимо от канала заказа:
- оператор может выдать компенсацию, не покидая iiko;
- клиент видит обещанный подарок на сайте;
- сайт или оператор добавляет в заказ правильную нулевую позицию iiko;
- компенсация используется не более одного раза;
- отмена или ошибка создания заказа не сжигает подарок;
- выдача и погашение имеют автора, причину и историю;
- заказ, созданный оператором в обход сайта, также погашает компенсацию.
Ни категория, ни комментарий iiko не являются источником истины о жизненном цикле компенсации. Они служат каналом ввода и отображения для оператора. Источник истины — запись в нашей БД.
11.2. Термины и границы¶
Тип подарка — разрешённая специальная позиция iiko с нулевой ценой, например «Пирог яблочный в подарок». Такая позиция импортируется из iiko, но не публикуется в каталоге, поиске и обычном API корзины.
Компенсация — право конкретного клиента один раз получить определённый подарок. Это не скидка на произвольное блюдо и не обычный промокод.
Маркер iiko — категория клиента или служебный блок в его комментарии, через который оператор передаёт системе факт выдачи компенсации.
Обычную платную позицию нельзя сделать подарком простым обнулением суммы в нашем backend: iiko может пересчитать или отклонить заказ. Для каждого типа подарка используется заведённая в iiko нулевая позиция, доступная нужным организациям и терминальным группам.
11.3. Общая доменная модель¶
Оба варианта интеграции используют одну модель и один процесс погашения.
create schema compensation;
-- Справочник разрешённых подарков. Заполняется в админке на основе зеркала iiko.
create table compensation.reward (
id bigserial primary key,
code text not null unique, -- APPLE_PIE
name text not null, -- Пирог яблочный
iiko_item_id uuid not null references iiko.item(id),
iiko_item_size_id bigint references iiko.item_size(id),
organization_id uuid references iiko.organization(id), -- null = вся сеть
image_id bigint references media.file(id),
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Одно обязательство ресторана перед одним клиентом.
create table compensation.client_compensation (
id uuid primary key,
client_id uuid not null, -- FK на клиента после его миграции
reward_id bigint not null references compensation.reward(id),
quantity int not null default 1 check (quantity > 0),
status text not null default 'GRANTED',
-- GRANTED | RESERVED | REDEEMED | EXPIRED | CANCELLED | BLOCKED
reason text not null, -- LATE_DELIVERY | SERVICE_ERROR | OTHER
reason_comment text,
source text not null, -- IIKO_CATEGORY | IIKO_COMMENT | ADMIN | AUTO
source_marker text, -- UUID категории или id блока комментария
source_order_id bigint,
granted_by text, -- оператор iiko или пользователь админки
granted_at timestamptz not null default now(),
expires_at timestamptz,
reserved_order_id bigint,
reserved_at timestamptz,
redeemed_order_id bigint,
redeemed_at timestamptz,
version int not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index compensation_source_marker_uq
on compensation.client_compensation (source, source_marker)
where source_marker is not null;
create index compensation_client_active_idx
on compensation.client_compensation (client_id, granted_at)
where status in ('GRANTED', 'RESERVED');
Точные FK на клиента и заказ добавляются вместе с соответствующими модулями. Каждая смена состояния дополнительно пишется в аудит с предыдущим и новым значением, причиной и исполнителем.
Состояния¶
stateDiagram-v2
[*] --> GRANTED: оператор выдал компенсацию
GRANTED --> RESERVED: началось оформление заказа
RESERVED --> REDEEMED: iiko подтвердил создание заказа
RESERVED --> GRANTED: заказ отменён до отправки\nили создание окончательно не удалось
GRANTED --> EXPIRED: истёк срок
GRANTED --> CANCELLED: отменено сотрудником
GRANTED --> BLOCKED: позиция iiko удалена\nили настроена неверно
BLOCKED --> GRANTED: привязка исправлена
Компенсация не переводится в REDEEMED при нажатии «Оформить». Погашение выполняется только
после подтверждения создания заказа в iiko. Резервирование делается транзакционно с блокировкой
строки, поэтому два параллельных заказа не используют одно право дважды.
11.4. Вариант A — категория клиента в iiko¶
Настройка¶
В iiko создаётся отдельная категория клиента для каждого типа подарка:
| Категория для оператора | Стабильный код в нашей системе | Позиция iiko |
|---|---|---|
| Компенсация: яблочный пирог | APPLE_PIE |
«Пирог яблочный в подарок», 0 ₽ |
| Компенсация: напиток | DRINK |
«Напиток в подарок», 0 ₽ |
UUID категории сохраняется в настройке compensation.reward. Перед реализацией нужно проверить
на тестовом контуре, что категории доступны оператору в используемом интерфейсе iiko и одинаково
видимы во всех организациях сети.
Требует согласования с заказчиком: для варианта с категориями у клиента может быть только одна активная компенсация одновременно, независимо от типа подарка. Пока первая компенсация не погашена, отменена или просрочена, повторно назначить яблочный пирог либо другой подарок нельзя.
Используемые методы iikoCloud API:
POST /api/1/loyalty/iiko/customer/info— получить клиента и назначенные категории;POST /api/1/loyalty/iiko/customer_category/add— назначить категорию;POST /api/1/loyalty/iiko/customer_category/remove— снять категорию.
Выдача оператором¶
- Оператор находит клиента в iiko по телефону.
- Назначает категорию «Компенсация: яблочный пирог».
- При необходимости оставляет обычный комментарий с причиной, но этот текст не участвует в автоматике.
- Backend читает карточку клиента при авторизации, открытии корзины и перед checkout.
- Для неизвестного маркера
(iikoCustomerId, categoryId)создаётclient_compensation, только если у клиента нет другой активной компенсации из категории. - Клиент видит подарок на сайте.
Если оператор назначил вторую компенсационную категорию при уже активной компенсации, backend не создаёт второе право и фиксирует конфликт для ручной обработки. Если при первом импорте обнаружено сразу несколько компенсационных категорий, backend также не выбирает подарок самостоятельно.
Фоново получить список всех клиентов с изменёнными категориями iikoCloud API не позволяет, поэтому синхронизация выполняется лениво для конкретного клиента. Для активных компенсаций можно добавить периодическую сверку.
Погашение¶
После успешного создания заказа backend переводит компенсацию в REDEEMED и снимает категорию
через /customer_category/remove. Операции выполняются идемпотентно: повтор webhook или задачи
не должен создать новую компенсацию и не должен повторно погасить существующую.
Если оператор оформил заказ непосредственно в iiko, backend получает DeliveryOrderUpdate,
находит специальную нулевую позицию и клиента по customer.id, а при его отсутствии — по
нормализованному телефону. Затем он погашает соответствующую компенсацию и снимает категорию.
Ограничения¶
- категория выражает только наличие права и не позволяет повторно назначить тот же подарок;
- принято предварительное ограничение «не более одной активной компенсации из категории на клиента»; его необходимо подтвердить с заказчиком до реализации;
- категория не хранит срок, причину и исходный заказ — эти данные живут только в нашей БД;
- снятие и повторное назначение той же категории после погашения должно породить новое право;
поэтому одного ключа
(customerId, categoryId)недостаточно навсегда: импорт хранит поколения назначения или сбрасывает маркер только после подтверждённого снятия; - оператор может снять категорию вручную. Это не должно молча удалять уже импортированную компенсацию: создаётся расхождение для проверки либо применяется отдельно согласованное правило.
Оценка¶
Это предпочтительный вариант: оператор выбирает структурированное значение без опечаток, backend не изменяет чужой текст, а тип подарка однозначно определяется по UUID категории.
11.5. Вариант B — служебный блок в комментарии клиента¶
iikoCloud API позволяет получить comment клиента через
POST /api/1/loyalty/iiko/customer/info и изменить его через
POST /api/1/loyalty/iiko/customer/create_or_update.
Свободная фраза «добавить блюдо пирог яблочный» не является контрактом: в ней возможны опечатки, другие формулировки и неоднозначность. Оператор добавляет только короткий маркер с названием блюда:
Backend обрезает пробелы по краям и сравнивает текст без учёта регистра с названиями активных
compensation.reward. Подарок применяется только при единственном совпадении с импортированной
позицией iiko, цена которой для выбранной организации равна нулю. Если блюдо не найдено, цена не
нулевая или название неоднозначно, маркер и комментарий остаются без изменений, а обработка
подарка пропускается.
Количество одного маркера всегда равно одному. Для двух подарков оператор добавляет два блока. Другие атрибуты — причина, срок и исходный заказ — через простой комментарий не передаются.
Выдача оператором¶
- Оператор пишет
[COMP]яблочный пирог[/COMP]в комментарии клиента. - Backend получает карточку клиента при авторизации, открытии корзины и перед checkout.
- Парсер извлекает полные блоки
[COMP]…[/COMP]и ищет нулевые позиции по названию. - Для каждого однозначно найденного блюда создаётся или восстанавливается компенсация.
- Неизвестные и неоднозначные названия полностью игнорируются.
- Текст вне блоков сохраняется без изменений.
Произвольные legacy-фразы можно распознавать только как кандидаты для ручного подтверждения в админке. Они не должны автоматически выдавать подарок.
У маркера нет уникального идентификатора. Чтобы повторное чтение комментария не создавало новые
подарки, backend хранит для пары (client, reward) состояние присутствия маркера. Новое право
можно создать только после того, как backend хотя бы один раз увидел маркер удалённым, а затем
увидел его снова. Если одинаковых блоков несколько, учитывается их количество.
Изменение комментария¶
У iiko нет операции PATCH comment и нет версии записи для optimistic locking. Поэтому действует
read-modify-write:
- непосредственно перед записью снова получить клиента через
/customer/info; - снова разобрать комментарий и найти точный блок
[COMP]название[/COMP]; - удалить только один использованный блок;
- сохранить остальной комментарий побайтно;
- отправить актуальные обязательные поля через
/customer/create_or_update; - повторно прочитать клиента и проверить результат.
Даже этот алгоритм не исключает lost update: оператор может одновременно изменить комментарий между чтением и записью. Поэтому backend удаляет маркер только после подтверждённого создания заказа в iiko. Если повторное чтение показывает, что исходный комментарий изменился и безопасно удалить ровно один блок нельзя, комментарий не перезаписывается. Компенсация остаётся погашенной, а маркер считается уже обработанным и не создаёт второй подарок; очистка повторяется позже.
Погашение операторского заказа¶
Как и в варианте с категорией, webhook заказа содержит позиции и клиента. Наличие в заказе
специального iiko_item_id переводит компенсацию в REDEEMED. После этого backend помечает
соответствующую компенсацию погашенной и удаляет один совпавший блок из комментария. Если
подходящих активных компенсаций несколько, выбирается самая ранняя совместимая.
Ограничения¶
- название должно совпасть с одним настроенным нулевым блюдом; опечатка просто не обработается;
- нет webhook об изменении комментария и списка изменённых клиентов;
- read-modify-write может затереть параллельную правку оператора;
- длина комментария и отображение многострочного текста зависят от конкретного iikoFront;
- комментарий смешивает человекочитаемые заметки и машинное состояние;
- без идентификатора блока нельзя передать причину, срок и ссылку на исходный заказ;
- ручное удаление блока не отменяет уже импортированную компенсацию в нашей БД.
Этот вариант допустим как переходный или резервный, если категории невозможно использовать в рабочем интерфейсе операторов.
11.6. Сайт и API¶
Backend сам добавляет подарок при preflight; клиент не может передать произвольный
iiko_item_id или признак бесплатной позиции.
{
"compensations": [
{
"id": "01K2Q6M7R2QH0J4D8A1F",
"rewardCode": "APPLE_PIE",
"name": "Пирог яблочный",
"quantity": 1,
"price": 0.00,
"image": { "url": "/media/compensation/apple-pie.webp" },
"expiresAt": null,
"available": true
}
]
}
В корзине подарок показывается отдельным блоком «Ваш подарок за задержку заказа» и автоматически входит в состав заказа. В истории он не скрывается:
{
"type": "COMPENSATION",
"name": "Пирог яблочный",
"quantity": 1,
"unitPrice": 0.00,
"total": 0.00,
"orderable": false,
"unavailableReason": null
}
Специальная позиция не получает публичную карточку каталога и не участвует в «Повторить заказ».
Создание заказа с сайта¶
- В транзакционном preflight выбрать
GRANTED-компенсацию с блокировкой строки. - Проверить срок, организацию, актуальность привязки, нулевую цену и стоп-лист.
- Перевести её в
RESERVEDи связать с локальным заказом. - Добавить специальную позицию в payload
POST /api/1/deliveries/create. - После
creationStatus = Successперевести вREDEEMED. - Снять категорию либо обновить служебный блок комментария.
- При терминальной ошибке создания или отмене до отправки вернуть
RESERVED → GRANTED.
Если подарочная позиция в стоп-листе, право не сгорает. Клиенту предлагается разрешённая замена или перенос подарка на следующий заказ; автоматическая замена без подтверждения не выполняется.
11.7. Импорт заказов, созданных оператором¶
Заказы, оформленные по телефону непосредственно в iiko, поступают через DeliveryOrderUpdate.
Периодический POST /api/1/deliveries/by_revision страхует пропуски webhook; его окно истории
ограничено тремя часами. Историческая досинхронизация выполняется методами поиска доставок по
телефону.
Каждая позиция заказа хранится снимком. Для специального нулевого блюда связь с публичным
каталогом отсутствует, но оно показывается в истории как COMPENSATION. Для остальных позиций
применяются правила снимков и nullable-связи с catalog.product_variant, описанные в
09-order-flow.md.
Алгоритм погашения:
- идемпотентно сохранить заказ и его позиции по идентификатору iiko;
- определить клиента по
customer.id, при его отсутствии — по нормализованному телефону; - найти позиции, совпадающие с активными
compensation.reward; - найти совместимую компенсацию
GRANTEDилиRESERVED; - перевести её в
REDEEMEDсо ссылкой на импортированный заказ; - обновить маркер iiko выбранным способом;
- если подарок есть в заказе, но права нет, не ломать принятый заказ, а создать инцидент
UNAUTHORIZED_COMPENSATIONдля разбора.
11.8. Админка и наблюдаемость¶
Нужны экраны:
- справочник подарков и привязок к нулевым позициям iiko;
- активные, зарезервированные, погашенные и проблемные компенсации;
- ручная выдача, отмена и исправление привязки с обязательной причиной;
- конфликты категорий/комментариев;
- подарочные позиции в заказах без найденного права;
- журнал всех переходов состояния.
Метрики и алерты:
- компенсации в
RESERVEDдольше допустимого времени; - неизвестный код или повреждённый блок комментария;
- категория iiko без настроенного
reward; - подарочная позиция имеет ненулевую цену или попала в стоп-лист;
- неуспешное снятие категории/обновление комментария;
- повторное или неавторизованное погашение.
11.9. Сравнение вариантов и рекомендация¶
| Критерий | Категория iiko | Комментарий iiko |
|---|---|---|
| Структурированность | UUID категории | простой маркер [COMP]название[/COMP] |
| Ошибки оператора | низкий риск при выборе из списка | опечатка пропускается без применения |
| Изменение чужих данных | не требуется | read-modify-write всего комментария |
| Конфликт параллельных правок | низкий | высокий, версии записи нет |
| Несколько одинаковых подарков | требует дополнительного учёта поколений | по количеству одинаковых блоков |
| Причина, срок, исходный заказ | только в нашей БД | не передаются маркером |
| Удобство очистки после погашения | снять категорию отдельным методом | удалить один точный блок |
| Пригодность как основной вариант | да, после проверки UI iiko | резервный/переходный |
Рекомендация: основной канал — категории клиента iiko, комментарий — резервный вариант и средство
миграции текущих обещаний. Окончательное решение принимается после проверки на тестовом контуре,
что оператор может назначить категорию в привычном интерфейсе и что она возвращается
/api/1/loyalty/iiko/customer/info для всех нужных организаций.
11.10. Открытые вопросы¶
- Видят ли операторы категории клиента и могут ли менять их непосредственно в используемой версии iikoFront?
- Общие ли категории и карточки клиентов для всех организаций сети?
- Подтверждает ли заказчик ограничение: через категории у клиента может быть только одна активная компенсация одновременно, независимо от типа подарка?
- Подарок добавляется автоматически или клиент выбирает один из разрешённого набора?
- Какие нулевые позиции уже заведены в iiko и одинаковы ли их UUID по организациям?
- Разрешены ли модификаторы подарочного блюда и кто оплачивает платные добавки?
- Что делать при стоп-листе: предложить замену, перенести право или дать оператору решить?
- Может ли оператор вручную отменить уже выданную компенсацию и как это отражается в нашей БД?
- Приходят ли
DeliveryOrderUpdateдля заказов, созданных непосредственно оператором в iiko? - Какой объём существующих свободных комментариев нужно перенести в структурированную модель?