BR 2.5 — Статусная модель заказа: Фаза 1 (Yuma-паритет без KDS)
Фаза 1 из BR 2.3
BR 2.3 — research-заметка с полным Yuma-анализом. Эта BR (2.5) — implementation-BR только для того подмножества что мы делаем сейчас. Item-level статусы, KDS, split-payment, автозакрытие-таймер — отложены до следующей фазы.
1. Контекст
Целевой MVP: шаурмечные и маленькие рестораны. Есть кухня (шаурма готовится), есть dine-in (столы), есть доставка. Значит статусная модель должна:
- Различать быструю продажу (клиент просто взял колу из витрины) от кухонного заказа (нужно жарить)
- Иметь явную точку «отправили на кухню»
- Отдельно отслеживать оплату и выдачу (клиент оплатил кофе, ещё не забрал)
- Поддерживать полноценный flow доставки с курьером
Что мы НЕ строим сейчас:
- Item-level статусы (каждая позиция отдельно) — отложено до разработки KDS как отдельного функционала
- Split-payment по стульям
- Автозакрытие по таймеру
- Собственный POS-мобильный интерфейс — интеграция будет с внешним фронтом KOALa (стоит у пользователя локально)
Что даёт внешнему фронту (KOALa) эта BR: API + webhook-канал. KOALa создаёт заказы через наш API, двигает статусы через наш API, получает уведомления об изменениях через webhook. Мы не пишем фронт — только backend.
2. Scope
Что делаем
- Flow зависит от типа заказа —
takeawayбез кухонных товаров идёт коротким путёмnew → closed.dine_in/delivery/takeawayс кухонными товарами идут полным путём. - Явная смена статусов через API — endpoint’ы для каждого перехода (
start-cooking,mark-ready,pay,close, delivery-substatus). - Маршрутизация товар → станция (
kitchen/bar/none) — флаг наProduct, используется KDS-фронтом в будущем; сейчас просто проставляется. - Полная delivery substatus-chain —
handed_over → in_delivery → delivered → closedс событиями на каждом переходе. - Paid ≠ Closed — оплата отдельно от закрытия.
- Webhook-подписки — внешний фронт (KOALa) регистрирует webhook URL + secret, получает JSON на каждое изменение статуса заказа.
Что не делаем
- Item-level статусы (будет в BR про KDS)
- Собственный POS-mobile UI (отдельная задача если понадобится)
- Split-payment по стульям (отдельная BR)
- Автозакрытие по таймеру (отдельная маленькая BR когда понадобится)
- Рефакторинг нейминга
accepted → in_progress(устоялось, не трогаем; спеку обновим под код)
3. Статусная модель
3.1. Перечень статусов заказа
| Статус | Смысл | Когда ставится |
|---|---|---|
new | Заказ создан, в работу не ушёл | При создании |
accepted | Отправлен в работу (на кухню / в зал / готовится) | При переходе по кнопке «Начать готовку» |
ready | Готов, ждёт оплаты и/или выдачи | Когда кассир / повар отметил готовность |
handed_over | Передан курьеру (для delivery) | Курьер забрал с точки |
in_delivery | В пути к клиенту | Курьер начал доставку |
delivered | Доставлен клиенту | Курьер подтвердил передачу |
closed | Закрыт — оплачен И выдан | После pay + close |
cancelled | Отменён до оплаты | По команде отмены |
Нейминг остаётся текущим (accepted, не in_progress) — в коде устоялось, фронт и спеку актуализируем под него.
3.2. Flow по типу заказа
Takeaway (быстрая продажа или еда на вынос):
new → [pay → close]
└── если cart содержит товары с requires_kitchen=true:
new → (start-cooking) → accepted → (mark-ready) → ready → (pay) + (close) → closed
Dine-in:
new → (start-cooking) → accepted → (mark-ready) → ready → (pay) → (close) → closed
Delivery (собственная доставка):
new → (start-cooking) → accepted → (mark-ready) → ready
→ (hand-over) → handed_over
→ (start-delivery) → in_delivery
→ (confirm-delivery) → delivered
→ (pay если postpay) → (close) → closed
Delivery через агрегатор (Яндекс.Еда и т.п.):
Существующий flow остаётся (через Aggregator Service): new → accepted → ready → handed_over → closed с внешней оплатой. Добавляются промежуточные in_delivery / delivered если агрегатор их умеет сообщать.
3.3. Отмена — откуда возможна
cancelled можно выставить из любого статуса до pay. После оплаты — только возврат (RefundRecord), не отмена.
new → cancelled ✅
accepted → cancelled ✅
ready (не оплачен) → cancelled ✅
ready (оплачен) → cancelled ❌ только refund
handed_over → cancelled ❌ только refund
closed → cancelled ❌ только refund
3.4. Paid ≠ Closed
- Оплата — проставляется
paid_at, статус не меняется (остаётсяreadyилиdelivered). - Закрытие — статус →
closed, проставляетсяcompleted_at. - Инвариант:
closeвозможен только еслиpaid_at IS NOT NULL(кроме специального случая — закрытие без оплаты разрешено только дляcancelledперевода).
4. Data model — изменения
4.1. catalog-service — новые поля на Product
ALTER TABLE products ADD COLUMN requires_kitchen BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE products ADD COLUMN kitchen_station VARCHAR(20) NOT NULL DEFAULT 'none';
-- CHECK (kitchen_station IN ('kitchen', 'bar', 'none'))Дефолты:
requires_kitchen = false— по умолчанию товар не требует приготовления (вода, печенье в витрине)kitchen_station = 'none'— нигде не готовится
Если выставлен requires_kitchen = true, но kitchen_station = 'none' — валидация отклоняет: требует конкретную станцию.
4.2. order-service — расширение CHECK-констрейнта
ALTER TABLE orders DROP CONSTRAINT IF EXISTS chk_status;
ALTER TABLE orders ADD CONSTRAINT chk_status
CHECK (status IN ('new', 'accepted', 'ready', 'handed_over',
'in_delivery', 'delivered',
'closed', 'cancelled'));Добавляем in_delivery и delivered к тому что уже есть (миграция 005).
4.3. order-service — флаг «требует кухни» на заказе
ALTER TABLE orders ADD COLUMN requires_kitchen BOOLEAN NOT NULL DEFAULT false;Рассчитывается на чекауте: true если хотя бы один item в cart имеет requires_kitchen=true. Используется для определения flow:
requires_kitchen=false+order_type=takeaway→ сокращённый flow (new → closed)requires_kitchen=true→ полный flow
4.4. order-service — webhook subscriptions
CREATE TABLE webhook_subscriptions (
id UUID PRIMARY KEY,
franchise_id UUID NOT NULL,
store_id UUID, -- NULL = все ТТ франшизы
subscriber_name VARCHAR(100) NOT NULL, -- 'koala', 'kitchen-display', ...
webhook_url TEXT NOT NULL,
secret VARCHAR(100) NOT NULL, -- для HMAC-SHA256 подписи
events TEXT[] NOT NULL, -- массив типов событий (см. §6)
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_webhook_subs_franchise_store
ON webhook_subscriptions(franchise_id, store_id) WHERE is_active = true;Для MVP управление подпиской — через SQL или internal-endpoint (без UI). Одна подписка на одну KOALa-инсталляцию.
5. API — endpoint’ы переходов
Все endpoint’ы публичные (/api/v1/...), требуют JWT + permission orders.edit (или analog) + scope-проверку (store_id должен быть в allowed).
| Endpoint | Из статуса | В статус | Кто вызывает |
|---|---|---|---|
POST /api/v1/orders | — | new | KOALa создаёт заказ |
POST /api/v1/orders/{id}/start-cooking | new | accepted | Кассир/официант |
POST /api/v1/orders/{id}/mark-ready | accepted | ready | Повар/кассир |
POST /api/v1/orders/{id}/pay | ready / delivered | без смены статуса, ставит paid_at | Кассир (или автоматически из интеграции с эквайером) |
POST /api/v1/orders/{id}/close | ready / delivered (с paid_at != NULL) | closed | Кассир |
POST /api/v1/orders/{id}/cancel | new / accepted / ready (не оплачен) | cancelled | Кассир (с указанием причины) |
POST /api/v1/orders/{id}/hand-over-to-courier | ready | handed_over | Кассир выдаёт курьеру |
POST /api/v1/orders/{id}/start-delivery | handed_over | in_delivery | Курьер начал путь |
POST /api/v1/orders/{id}/confirm-delivery | in_delivery | delivered | Курьер передал клиенту |
5.1. Упрощённая точка для takeaway-без-кухни
POST /api/v1/orders/{id}/checkout
Сохраняется как удобный shortcut для takeaway без requires_kitchen. Внутри: pay + close в одной транзакции. Статусы проходит мгновенно.
Для dine_in / delivery этот endpoint возвращает 409 ORDER_FLOW_MISMATCH — нельзя сразу закрыть, нужно пройти полный flow.
5.2. Ошибки
| Код | Когда |
|---|---|
409 INVALID_TRANSITION | Попытка перевести из статуса из которого нельзя (например pay в new) |
409 ORDER_NOT_PAID | close на заказе с paid_at IS NULL (кроме cancel-flow) |
409 ORDER_ALREADY_PAID | pay на уже оплаченном заказе |
409 ORDER_FINALIZED | Любое действие на closed / cancelled |
409 ORDER_FLOW_MISMATCH | /checkout на dine_in / delivery |
422 MISSING_KITCHEN_STATION | При создании товара requires_kitchen=true + kitchen_station=none |
6. События (webhook + Kafka)
Каждая смена статуса или важное действие публикует событие в двух каналах:
- Kafka — для внутренних консьюмеров (Warehouse Service, Customer Service, etc.) — как сейчас
- Webhook — для внешних подписчиков (KOALa) — новое
6.1. Типы событий
| Тип события | Когда | Основные поля payload |
|---|---|---|
order.created | Создан заказ | order_id, order_type, total, items, customer_id (nullable), requires_kitchen |
order.cooking_started | new → accepted | order_id, started_at, started_by_user_id |
order.ready | accepted → ready | order_id, ready_at |
order.paid | Выставлено paid_at | order_id, paid_at, payment_method, paid_amount |
order.closed | → closed | order_id, closed_at, total, payment_method |
order.cancelled | → cancelled | order_id, cancelled_at, reason |
order.handed_over | ready → handed_over | order_id, courier_id |
order.in_delivery | handed_over → in_delivery | order_id, started_at |
order.delivered | in_delivery → delivered | order_id, delivered_at |
order.refunded | Возврат (уже есть, дополняется новой привязкой к фиск-данным) | order_id, refund_amount, full/partial, fiscal_data |
6.2. Структура webhook-запроса
POST {webhook_subscriptions.webhook_url}
Content-Type: application/json
X-Event-Type: order.paid
X-Event-Id: {uuid}
X-Signature: {HMAC-SHA256 base64 signature of body using webhook_subscriptions.secret}
X-Timestamp: {unix epoch}
{
"event_id": "uuid",
"event_type": "order.paid",
"timestamp": "2026-04-22T14:30:00Z",
"version": 1,
"franchise_id": "...",
"store_id": "...",
"payload": {
"order_id": "...",
"paid_at": "2026-04-22T14:30:00Z",
"payment_method": "card",
"paid_amount": 550.00,
"total": 550.00
}
}6.3. Retry-политика webhook
- Считаем доставленным если получен HTTP 2xx
- При не-2xx: ретраи через 10s, 30s, 2m, 10m, 1h, 6h, 24h — потом
dead_letter - Отдельная таблица
webhook_delivery_attemptsдля аудита - Endpoint
GET /api/v1/webhooks/{subscription_id}/dead-letters— чтобы оператор видел неуспешные доставки
6.4. Безопасность
- HMAC-SHA256 подпись тела запроса секретом подписки
- Timestamp в заголовке + правило отбрасывать запросы старше 5 минут (защита от replay)
- Подписчик (KOALa) проверяет подпись + timestamp перед обработкой
6.5. Синхронизация Kafka ↔ Webhook
Внутри order-service:
- Когда меняется статус → публикуется Kafka-событие (как сейчас)
- Отдельный компонент
WebhookDispatcherподписан на Kafka-события и пушит их в HTTP по подпискам - Это разделение позволяет внутренним консьюмерам не зависеть от HTTP-retry’ов
7. Правила бизнес-логики
7.1. Определение flow при создании заказа
IF cart contains at least one item with requires_kitchen=true:
requires_kitchen = true
full flow required
ELSE:
requires_kitchen = false
IF order_type == 'takeaway':
short flow allowed (new → closed via /checkout)
ELSE:
full flow required
7.2. Валидация переходов
Реализовано в OrderService — state machine в виде explicit allowed-transitions map:
private static final Map<String, Set<String>> ALLOWED_TRANSITIONS = Map.of(
"new", Set.of("accepted", "cancelled", "closed"), // closed только через /checkout shortcut
"accepted", Set.of("ready", "cancelled"),
"ready", Set.of("handed_over", "cancelled", "closed"),
"handed_over", Set.of("in_delivery"),
"in_delivery", Set.of("delivered"),
"delivered", Set.of("closed"),
"closed", Set.of(), // финал
"cancelled", Set.of() // финал
);7.3. Что делать с существующими заказами при накатывании
Миграция CHECK-констрейнта не удаляет ничего. Существующие заказы со статусами new/accepted/ready/handed_over/closed/cancelled остаются валидными.
Новые статусы in_delivery / delivered появляются только для новых заказов после накатки.
7.4. Permissions
Существующее разделение orders.read / orders.edit сохраняется. Дополнительно:
orders.cancel— отмена заказа (вnew/accepted/ready)orders.refund— возврат (дляclosed/delivered) — уже есть
Все новые endpoint’ы переходов требуют orders.edit.
8. Затронутые сервисы
| Сервис | Что меняется |
|---|---|
Order Service (:3005) | Новые endpoint’ы переходов, state machine, миграция статусов, Webhook subscription table + dispatcher, новое поле orders.requires_kitchen |
Catalog Service (:3004) | Новые поля products.requires_kitchen + products.kitchen_station, миграция, обновление API |
| Admin Franchise (web) | Форма товара: чекбокс «требует кухни» + селект «станция (кухня/бар)». Управление webhook-подписками — отложено (в MVP редактируем через SQL/internal endpoint) |
Admin BFF (:3020) | Проксирование новых эндпоинтов в catalog и order |
| Warehouse Service | Не меняется (уже слушает order.closed для списания ингредиентов) |
| Customer Service | Не меняется (слушает order.completed который маппится на order.closed) |
| POS (собственный) | Не трогаем — интеграция через webhook с внешним фронтом (KOALa) |
9. Out of scope
| Тема | Куда |
|---|---|
| Item-level статусы позиций в заказе | Отдельная BR после разработки KDS |
| Split-payment по стульям (dine-in) | Отдельная BR |
| Автозакрытие заказа по таймеру после оплаты | Отдельная маленькая BR если понадобится |
| Собственный POS-mobile UI под новый flow | Не в MVP |
| UI для webhook-подписок в админке | Отдельная фича (в MVP через SQL) |
Рефакторинг accepted → in_progress | Не делаем — нейминг устоялся |
| Интеграция с физическим кухонным принтером (ESC/POS) | Отдельная BR если понадобится |
10. Что может поломаться (risk checklist для разработчика)
Работа трогает самый центр order-service — нужно держать в голове эти места во время и после правок. Галочками отмечать в ходе декомпозиции и код-ревью.
10.1. Консьюмеры Kafka-событий — изменение семантики
- Warehouse Service слушает
order.closedдля списания ингредиентов со склада. После разделения pay/close — когда списываем? Варианты:- Оставить «списание на
closed» (как сейчас) — но тогда оплаченный но не выданный заказ не уменьшает остатки, отчёты склада будут ошибочны - Перенести списание на
order.paid— логичнее бухгалтерски, но нужна compat-логика для текущих оплат - Рекомендация в BR: на этапе реализации решить явно, задокументировать
- Оставить «списание на
- Customer Service слушает
order.completedдля пересчёта динамических групп клиентов. Сейчасorder.completed=closed. После разделения — триггер наpaidилиclosed? Для LTV логичнееpaid. Проверить чтоOrderCompletedConsumerне сломается. - Aggregator Service слушает
order.status.changedдля push-уведомлений в маркетплейс. Добавляются статусыin_delivery/delivered— убедиться что маркетплейс их понимает (или фильтруем на стороне Aggregator). - Остальные возможные подписчики — сделать
grep -r "order\." erp-*/**/*.javaдо начала работы, зафиксировать всех.
10.2. Существующие API-клиенты (BFF’ы и другие сервисы)
- POS BFF (
erp-pos/bff/) — сейчас вызываетOrderServiceAPI для всех переходов. Проверить:- Есть ли вызовы
checkoutс dine-in/delivery — после правки вернётся409 ORDER_FLOW_MISMATCH - Как вызывается оплата — нужна ли адаптация под новые
pay/close
- Есть ли вызовы
- Admin BFF (
erp-admin/bff/) — фильтрация заказов по статусам, возможно полагается наaccepted/closedв URL-параметрах - Aggregator Service сам создаёт заказы с
status=newчерез внутренний consumer — не трогать, но прогнать smoke-test после миграции
10.3. UI на фронтендах (мы их «не трогаем», но рискуем сломать)
- erp-admin/web — файлы подтверждённые ранее в аудите:
ActiveOrdersPage.tsx:ACTIVE_STATUSES = ["new", "in_progress", "accepted", "ready"]— нужно добавитьhanded_over,in_delivery,deliveredили объяснить почему их там не надоOrderDetailPage.tsx:STATUS_LABEL— добавить новые статусыKitchenQueuePage.tsx:STATUS_LABEL+STATUS_BG— добавитьshared/src/types/order.ts:OrderStatusunion type — расширить под новые статусы (TypeScript сломается если не добавить)
- erp-pos/mobile — user сказал «не смотри на POS», но миграция БД сломает текущий mobile если статусы не распознаются:
AggregatorOrdersScreen.tsx: switch/case по статусам — fallback на unknown статус должен бытьKitchenQueueScreen.tsx: условный рендер кнопок по статусам- Минимум: убедиться что mobile-app не крашится на новых статусах (показывает как «неизвестный»)
10.4. Существующие записи в БД (production + test VPS)
- Заказы со статусом
closedв БД — они имеютcompleted_at, но не имеют явной отметки «оплачен». Если после накатки появится инвариант «close только если paid_at не NULL», старые записи могут не валидироваться:- Вариант 1: скрипт миграции, проставляющий
paid_at = completed_atдля всех существующихclosed - Вариант 2: инвариант проверяется только для новых переходов
- Рекомендация: Вариант 1 (согласованное состояние данных)
- Вариант 1: скрипт миграции, проставляющий
- Заказы в статусе
accepted/handed_over— остаются валидными, ничего не меняется
10.5. Фискализация — когда фискальный чек?
- Сейчас фискализация происходит на
checkout(одновременно с закрытием). После разделения — наpay(логичнее, 54-ФЗ требует чек в момент оплаты). - Убедиться что PayKeeper-интеграция вызывается из
OrderService.pay, не изclose - Webhook от PayKeeper приходит когда чек пробит — у нас это триггер для автоматического
payв order-service (если оплата инициирована не нами) - Если заказ закрылся без оплаты (cancel) — никакой фискализации, как сейчас
10.6. Существующий checkout endpoint — breaking change
- Endpoint
POST /orders/{id}/checkoutсейчас делает pay+close атомарно. После правки: дляtakeawayбез кухни остаётся shortcut, для остального —409 ORDER_FLOW_MISMATCH - Найти всех вызывающих
checkoutчерезgrep -rn "checkout" erp-*и адаптировать каждого - Альтернатива: сохранить обратную совместимость —
checkoutработает всегда, внутри делаетpay+close. Это снимает backbreaking, но не развивает новый flow. Решить на этапе декомпозиции
10.7. Refund / RefundRequest — привязка к новым статусам
-
RefundRecordсейчас создаётся только дляclosedзаказов. Проверить:paid+ не-closed — можно ли делать refund? По логике — да, клиент оплатил и имеет право на возврат до получения товара. -
RefundRequestworkflow — без изменений, но проверить что новые статусыin_delivery/deliveredв нём корректно обрабатываются - Отмена vs refund на оплаченном заказе — граница чёткая: оплачен → только refund, даже если ещё
ready
10.8. Permissions
- Новые эндпоинты
/start-cooking,/mark-ready,/pay,/close,/hand-over-to-courier,/start-delivery,/confirm-delivery— все требуютorders.edit. Проверить:- У кассира эта permission обычно есть (проверить Demo Coffee роли)
- У курьера — отдельный вопрос: нужно ли ему
orders.editполностью, или только delivery-subset (/start-delivery,/confirm-delivery)? В BR ставлю полныйorders.edit, но подумать.
-
orders.cancel— новая permission? Или оставить подorders.edit? Сейчас cancel делается тем же правом что edit.
10.9. Миграция БД — порядок и откат
- Миграция catalog-service (
products.requires_kitchen,kitchen_station) должна пройти до миграции order-service (orders.requires_kitchenзависит от расчёта при чекауте из catalog). Если catalog ещё не задеплоен — чекаут может ломаться - Миграция order-service — расширение CHECK-constraint:
DROPстарого +ADDнового. Атомарно в одной транзакции (Liquibase это умеет) - Rollback-план: если после деплоя обнаружили проблему — какие статусы откатывать? В новых статусах (
in_delivery/delivered) заказы могут уже быть → откат CHECK-constraint невозможен. Рекомендация: не откатывать, исправлять вперёд - Webhook_subscriptions таблица — пустая на старте, риска нет
10.10. Load / Performance
- Webhook dispatcher может нагрузить order-service если подписчик отвечает медленно. Рекомендация: dispatcher в отдельном thread pool, не блокирует Kafka consumer
- Retry очередь — где хранится? В БД
webhook_delivery_attempts+ scheduled job? Или Redis? Решить на этапе декомпозиции.
10.11. Обратная совместимость внешних партнёров
- Агрегаторы (Яндекс.Еда) уже получают от нас status-changed events. Добавление
in_delivery/delivered— не ломает их (они смотрят на конкретные статусы которые понимают). Но если где-то в нашем адаптере к агрегатору естьswitch (status)с exhaustiveness check — компилятор ругнётся - PayKeeper — не зависит от наших статусов, он сам по себе
10.12. Monitoring / Observability
- После правки метрики «количество закрытых заказов» разъедутся с «количество оплаченных» — это нормально, но dashboard’ы могут показать провал. Предупредить команду аналитики
- Новые статусы
in_delivery/deliveredнужно добавить в мониторинг (напр., grafana-дашборд «orders by status»)
11. Открытые вопросы
- Retry webhook — какая политика реалистична? Сейчас предложено 10s..24h. Возможно надо доработать после первых недель эксплуатации.
- Что делать с заказом в
deliveredесли клиент отказался и курьер возвращает? — добавитьreturn_to_storeпереход? Пока out of scope, решим когда встретим. - KOALa может менять цену/состав заказа после
accepted? Сейчас order-service разрешаетaddItemsвnew. Послеaccepted— нужно проверить; возможно запретить (логично: кухня уже готовит). order.delivered+ оплата наличкой курьеру — как синхронизируем? Курьер сообщает через/pay? Тут может быть отдельный flow.- Когда делать списание со склада — на
paidили наclosed? См. §10.1. - Где хранить очередь retry webhook — БД или Redis? См. §10.10.
- Обратная совместимость
checkout-endpoint — ломать или сохранять? См. §10.6.