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

Что делаем

  1. Flow зависит от типа заказаtakeaway без кухонных товаров идёт коротким путём new → closed. dine_in / delivery / takeaway с кухонными товарами идут полным путём.
  2. Явная смена статусов через API — endpoint’ы для каждого перехода (start-cooking, mark-ready, pay, close, delivery-substatus).
  3. Маршрутизация товар → станция (kitchen / bar / none) — флаг на Product, используется KDS-фронтом в будущем; сейчас просто проставляется.
  4. Полная delivery substatus-chainhanded_over → in_delivery → delivered → closed с событиями на каждом переходе.
  5. Paid ≠ Closed — оплата отдельно от закрытия.
  6. 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/ordersnewKOALa создаёт заказ
POST /api/v1/orders/{id}/start-cookingnewacceptedКассир/официант
POST /api/v1/orders/{id}/mark-readyacceptedreadyПовар/кассир
POST /api/v1/orders/{id}/payready / deliveredбез смены статуса, ставит paid_atКассир (или автоматически из интеграции с эквайером)
POST /api/v1/orders/{id}/closeready / deliveredpaid_at != NULL)closedКассир
POST /api/v1/orders/{id}/cancelnew / accepted / ready (не оплачен)cancelledКассир (с указанием причины)
POST /api/v1/orders/{id}/hand-over-to-courierreadyhanded_overКассир выдаёт курьеру
POST /api/v1/orders/{id}/start-deliveryhanded_overin_deliveryКурьер начал путь
POST /api/v1/orders/{id}/confirm-deliveryin_deliverydeliveredКурьер передал клиенту

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_PAIDclose на заказе с paid_at IS NULL (кроме cancel-flow)
409 ORDER_ALREADY_PAIDpay на уже оплаченном заказе
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)

Каждая смена статуса или важное действие публикует событие в двух каналах:

  1. Kafka — для внутренних консьюмеров (Warehouse Service, Customer Service, etc.) — как сейчас
  2. Webhook — для внешних подписчиков (KOALa) — новое

6.1. Типы событий

Тип событияКогдаОсновные поля payload
order.createdСоздан заказorder_id, order_type, total, items, customer_id (nullable), requires_kitchen
order.cooking_startednew → acceptedorder_id, started_at, started_by_user_id
order.readyaccepted → readyorder_id, ready_at
order.paidВыставлено paid_atorder_id, paid_at, payment_method, paid_amount
order.closed→ closedorder_id, closed_at, total, payment_method
order.cancelled→ cancelledorder_id, cancelled_at, reason
order.handed_overready → handed_overorder_id, courier_id
order.in_deliveryhanded_over → in_deliveryorder_id, started_at
order.deliveredin_delivery → deliveredorder_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)
Рефакторинг acceptedin_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/) — сейчас вызывает OrderService API для всех переходов. Проверить:
    • Есть ли вызовы 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: OrderStatus union 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 (согласованное состояние данных)
  • Заказы в статусе 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? По логике — да, клиент оплатил и имеет право на возврат до получения товара.
  • RefundRequest workflow — без изменений, но проверить что новые статусы 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.

12. Ссылки