Статусная модель заказа — текущее состояние кода

Кратко (на одной странице)

Заказ в системе проходит один из трёх сценариев в зависимости от того, что в нём лежит и как он создан:

1. Быстрая продажа (за секунду)

Применяется когда: товар не требует кухни (бутылка воды, сигареты, упаковка кофе в зёрнах) и тип заказа takeaway.

Путь: new → closed в одной транзакции при вызове /internal/orders/checkout с POS.

Пример: кассир пробил бутылку воды → сразу оплачено → сразу закрыто. Кухня не трогается.

2. Кухонный / доставочный flow (основной)

Применяется когда: хотя бы один товар в заказе имеет requires_kitchen=true, либо заказ на доставку/в зал.

Полный путь: new → accepted → ready → handed_over → in_delivery → delivered → closed

  • new — пробит, ждёт кухню
  • accepted — кухня приняла, начали готовить (kitchen_started_at)
  • ready — готово, ждёт выдачу/курьера
  • handed_over — передан курьеру (только delivery)
  • in_delivery — курьер везёт
  • delivered — клиент получил
  • closed — финализирован (для закрытия обязательно paid_at)

Для dine_in/takeaway с кухней доставочные статусы пропускаются: new → accepted → ready → closed.

3. Агрегаторский flow (Яндекс.Еда и т.п.)

Создаётся Aggregator Service’ом по вебхуку от маркетплейса. Идёт по той же доставочной цепочке, плюс каждый переход пушится обратно в агрегатор через Kafka order.status.changed.

Ключевые инварианты

  • Paid ≠ Closed — оплата только ставит paid_at, закрытие требует отдельного /complete. Закрыть без paid_at нельзя (кроме legacy-записей).
  • Cancel vs Refund — пока не оплачено можно cancel, после оплаты только refund.
  • Редактировать состав заказа (добавить/удалить позиции, обновить коммент) можно только в new (или new/accepted/ready для dine-in add-items).
  • Все переходы — строго по таблице «из → в»: невозможно прыгнуть из new в ready, минуя accepted.

События в Kafka

На каждый переход публикуется два сообщения: узкое (order.cooking_started, order.ready, order.handed_over, order.in_delivery, order.delivered, order.closed) + универсальное (order.status.changed). Универсальное используется Aggregator Service для проталкивания в маркетплейсы и для webhook-подписок внешних POS.


Подробный разбор

1. Все возможные статусы

СтатусЗначениеКогда появился
newЗаказ создан, не начат001-create-orders.xml
in_progressУстаревший синоним accepted (остался для совместимости)001
acceptedКухня взяла в работу005-add-aggregator-statuses.xml
readyГотов, ожидает выдачу/курьера001
handed_overПередан курьеру005
in_deliveryКурьер в пути008-br-2-5-status-model.xml
deliveredКлиент получил008
closedФинализирован001
cancelledОтменён001

CHECK-constraint в БД: CHECK (status IN ('new', 'accepted', 'ready', 'handed_over', 'in_delivery', 'delivered', 'closed', 'cancelled')) — источник: 008-br-2-5-status-model.xml:12-14.

Почему в списке нет in_progress

В коде и JWT встречается in_progress как legacy-значение (например kitchenQueuePage), но в текущем CHECK-constraint его нет. Это техдолг — название осталось в одном-двух местах UI, в БД новые записи имеют accepted.

2. State machine — полный список переходов

ИзВМетод в OrderServiceHTTP endpointУсловие
newclosedcheckout()POST /internal/orders/checkout!requires_kitchen && takeaway (fast-checkout)
newacceptedacceptAggregatorOrder()POST /api/v1/orders/{id}/acceptchannel != INTERNAL/POS
newacceptedacceptAggregatorOrderInternal()POST /internal/orders/{id}/aggregator/acceptservice-token
newacceptedstartCookingInternal()POST /internal/orders/{id}/start-cookingkitchen trigger
newcancelledrejectAggregatorOrder()POST /api/v1/orders/{id}/rejectновый заказ агрегатора
newcancelledcancel()POST /api/v1/orders/{id}/cancelpaid_at IS NULL
newclosedcloseWithPayment()POST /internal/orders/{id}/close-with-paymentdine-in split-pay
acceptedreadymarkAggregatorOrderReady()POST /api/v1/orders/{id}/ready
readyhanded_overhandOverAggregatorOrder()POST /api/v1/orders/{id}/hand-over
readyclosedcomplete()POST /api/v1/orders/{id}/completepaid_at IS NOT NULL
handed_overin_deliverystartDelivery()POST /api/v1/orders/{id}/start-deliverypermission orders.delivery
in_deliverydeliveredconfirmDelivery()POST /api/v1/orders/{id}/confirm-deliverypermission orders.delivery
deliveredclosedcomplete()POST /api/v1/orders/{id}/completepaid_at IS NOT NULL
* (кроме closed/cancelled)cancelledcancel()POST /api/v1/orders/{id}/cancelpaid_at IS NULL

Основной код: OrderService.java — методы checkout(), pay(), complete(), cancel(), acceptAggregatorOrder(), markAggregatorOrderReady(), handOverAggregatorOrder(), startDelivery(), confirmDelivery(), transitionStatus().

Обобщённый хелпер transitionStatus(orderId, expected, target) в OrderService.java:564 — проверяет что текущий статус равен expected, иначе 422 INVALID_STATUS_TRANSITION.

stateDiagram-v2
    [*] --> new : checkout / create / openDineIn
    new --> closed : fast-checkout<br/>(!requires_kitchen && takeaway)
    new --> accepted : accept / start-cooking
    new --> cancelled : cancel / reject (до оплаты)
    accepted --> ready : ready
    ready --> handed_over : hand-over<br/>(только delivery)
    ready --> closed : complete<br/>(если paid_at != null)
    handed_over --> in_delivery : start-delivery<br/>(orders.delivery)
    in_delivery --> delivered : confirm-delivery<br/>(orders.delivery)
    delivered --> closed : complete
    closed --> [*]
    cancelled --> [*]

3. Инварианты (бизнес-правила, проверяемые кодом)

ИнвариантГдеHTTP-код при нарушении
Редактирование состава: status = 'new'validateEditable()422 ORDER_NOT_EDITABLE
Повторная оплата запрещена: payment_method IS NULLpay()422 ALREADY_PAID
Закрытие требует оплату: paid_at IS NOT NULLcomplete()422 ORDER_NOT_PAID
Закрытие только из ready или deliveredcomplete()422 INVALID_STATUS_TRANSITION
Cancel требует paid_at IS NULLcancel()422 ORDER_ALREADY_PAID
Cancel нельзя из closed/cancelledcancel()422 INVALID_STATUS_TRANSITION
Reject только из new и только для агрегатораrejectAggregatorOrder()422 INVALID_STATUS_TRANSITION
Refund требует paid_at IS NOT NULLcreateRefund()422 ORDER_NOT_PAID
Сумма рефандов ≤ totalcreateRefund()422 REFUND_EXCEEDS_TOTAL
add-items только для dine-in и только в new/accepted/readyaddItems()409 CONFLICT
Переходы агрегаторского flow допустимы только для channel ∉ {INTERNAL, POS}assertAggregatorOrder()422 NOT_AGGREGATOR_ORDER
Доставочные переходы требуют permission orders.deliveryAuth middleware403 FORBIDDEN

Ключевой инвариант BR 2.5: status='closed' ⇒ paid_at IS NOT NULL. Проверяется в сервисе (complete()), не в БД-CHECK — чтобы legacy-записи без paid_at не ломали систему.

4. Timestamps по статусам

Каждый переход ставит свой timestamp (так можно восстановить всю хронологию заказа):

ПолеКогда ставитсяКто ставит
created_atСоздание@PrePersist
updated_atЛюбое изменение@PreUpdate
kitchen_started_atПереход в acceptedacceptAggregatorOrder(), startCookingInternal()
accepted_atПереход в acceptedacceptAggregatorOrder()
accepted_byПереход в acceptedЗаписывает user_id кассира
ready_atПереход в readymarkAggregatorOrderReady()
handed_over_atПереход в handed_overhandOverAggregatorOrder()
in_delivery_atПереход в in_deliverystartDelivery()
delivered_atПереход в deliveredconfirmDelivery()
paid_atОплатаpay(), checkout(), closeWithPayment()
completed_atЗакрытиеcomplete(), closeWithPayment()
cancelled_atОтменаcancel(), rejectAggregatorOrder()

Вся миграция BR 2.5 колонок: 008-br-2-5-status-model.xml:19-31.

5. Как определяется flow: флаг requires_kitchen

Момент расчёта: один раз при POST /internal/orders/checkout. После checkout’а флаг фиксируется в поле orders.requires_kitchen (boolean) и больше не меняется.

Алгоритм (OrderService.checkout()):

  1. Собрать product_ids из позиций заказа
  2. Вызвать GET /internal/products/require-kitchen?ids=<uuid1>,<uuid2> в Catalog Service
  3. Ответ { "data": { "requires_kitchen": true|false } }true если хотя бы один товар требует кухни
  4. Записать в order.requires_kitchen
  5. Если !requires_kitchen && order_type='takeaway' — закрыть заказ сразу (fast checkout)

При ошибке связи с Catalog Service возвращается false (best-effort, не блокируем checkout).

Источник: CatalogServiceClient.anyProductRequiresKitchen() + OrderService.checkout():234-258.

В самом Catalog: товар имеет два поля — requires_kitchen (boolean) и kitchen_station_id (FK на kitchen_stations). При requires_kitchen=true станция обязательна (валидация в ProductService).

6. Kafka-события — полный справочник

Публикатор: OrderEventPublisher.java.

ТопикКогда публикуетсяPayload (ключевое)
order.createdСоздан заказorderId, storeId, franchiseId, orderNumber, orderType, status, createdBy
order.paidОплатаorderId, total, paymentMethod, paidAmount, status
order.cooking_startednew → acceptedorderId, orderNumber, acceptedAt, acceptedBy
order.readyaccepted → readyorderId, orderNumber, readyAt
order.handed_overready → handed_overorderId, courierId, handedOverAt
order.in_deliveryhanded_over → in_deliveryorderId, courierId, inDeliveryAt
order.deliveredin_delivery → deliveredorderId, customerId, courierId, deliveredAt
order.completed* → closed (legacy alias)orderId, customerId, total, status
order.closed* → closed (BR 2.5 явный алиас)Аналогично order.completed
order.cancelled* → cancelledorderId, cancelReason
order.refundedВозвратorderId, refundId, refundAmount, isFullRefund
order.status.changedКаждый переход статусаprevious_status, new_status, channel, external_provider, reason

Два слоя событий — задумано не случайно:

  • Узкие (order.cooking_started, order.delivered и т.д.) — для специализированных подписчиков, типизированный payload.
  • Универсальное order.status.changed — для Aggregator Service (проталкивание статуса в Яндекс.Еду) и для webhook-подписок внешних POS/KDS. Можно подписаться один раз и получать все переходы.

7. Кто вызывает Order Service — матрица инициаторов

ИнициаторСоздание заказаПереходы статусов
POS кассаPOST /internal/orders/checkout (быстрая продажа) или POST /internal/orders/open-dine-in (в зал)close-with-payment, start-cooking, mark-ready, pay
Админка франшизыНе создаёт заказы напрямуюТолько агрегаторские: accept, ready, hand-over, reject. Для доставки: start-delivery, confirm-delivery (permission orders.delivery). Также cancel/refund-requests.
Aggregator ServiceИз webhook Яндекс.Еды → публикует Kafka aggregator.order.received → Order Service создаёт записьСлушает order.status.changed, ретранслирует в маркетплейс
Customer ServiceПока не создаёт заказы (не реализовано)Слушает order.completed для пересчёта динамических групп CRM (BR 3.1)
Клиентский сайт / моб. приложениеПока не подключено (в планах через Customer BFF)
Webhook-подписчики (внешние POS, KOALa)Создают заказы через Order Service APIПолучают все переходы через webhook-рассылку из Aggregator Service

8. UI-представление статусов (Админка Франшизы)

Названия бейджей (одинаковы на всех страницах: Active/History/Detail):

СтатусРусское название
newНовый
in_progressВ работе
acceptedПринят
readyГотов
handed_overПередан курьеру
in_deliveryВ доставке
deliveredДоставлен
closedЗакрыт
cancelledОтменён

Цвета (токены бренда Альфы, web/src/lib/tokens.ts):

СтатусЦвет фона / текста
newredSoft / red
in_progressbgSoft / warning
acceptedredSoft / red
ready / handed_over / in_delivery / deliveredbgSoft / success
closedborder / text
cancelledredSoft / red

Страницы:

  • /orders/active — активные (new, in_progress, accepted, ready), автообновление каждые 30с
  • /orders/history — закрытые/отменённые/переданные, с пагинацией и фильтром по дате
  • /orders/:id — карточка заказа с действиями для агрегаторских (Принять/Готов/Передать/Отклонить) и для доставочных (Начать доставку/Подтвердить доставку)
  • /kitchen/queue — канбан-доска для кухни (колонки new / accepted / ready)
  • /catalog/kitchen-stations — управление кухонными станциями (BR 2.5)

9. Чего сейчас нет (технический долг / следующие фазы)

  • Item-level статусы — сейчас статус на весь заказ, а не на позицию. Для KDS в будущем потребуется отдельный статус на каждой order_item.
  • KDS как отдельное приложение — кухонный экран пока часть админки (KitchenQueuePage). Полноценный KDS вынесут в отдельный фронт.
  • Split-payment по стульям — при dine_in с несколькими гостями нельзя разделить оплату по конкретным позициям.
  • Автозакрытие по таймеру — в Yuma есть настройка «закрыть заказ через N минут, если клиент не пришёл».
  • Кухонный принтер (ESC/POS) — не реализовано.
  • Клиентский канал создания заказа (Customer BFF) — сервис слушает события для CRM, но сам не создаёт заказы.
  • Item-level status (pending / cooking / done) — для гранулярного KDS.

10. Ссылки на ключевые файлы

Order Service (erp-order-service):

  • Логика переходов: src/main/java/com/erp/order/service/OrderService.java
  • Публичный API: src/main/java/com/erp/order/controller/OrderController.java
  • Internal API: src/main/java/com/erp/order/controller/InternalOrderController.java
  • Kafka: src/main/java/com/erp/order/event/OrderEventPublisher.java, OrderEventPayloads.java
  • Клиент Catalog: src/main/java/com/erp/order/client/CatalogServiceClient.java
  • Entity: src/main/java/com/erp/order/entity/Order.java
  • Миграции: src/main/resources/db/changelog/001-create-orders.xml, 005-add-aggregator-statuses.xml, 008-br-2-5-status-model.xml

Aggregator Service (erp-aggregator-service):

  • Приём заказов от маркетплейсов: service/OrderReceptionService.java
  • Проталкивание статусов назад: service/StatusChangedConsumer.java + StatusPushWorker.java
  • Webhook-диспетчер (BR 2.5): service/WebhookDispatcher.java, WebhookDeliveryWorker.java

Customer Service (erp-customer-service):

  • Слушатель завершений: event/OrderCompletedConsumer.java
  • Клиент статистики: client/CustomerOrderStatsClient.java

Admin (erp-admin):

  • BFF-прокси: bff/src/routes/orders.ts
  • Страницы: web/src/pages/orders/*.tsx, web/src/pages/kitchen/KitchenQueuePage.tsx, web/src/pages/catalog/KitchenStationsPage.tsx

POS (erp-pos):

  • BFF: bff/src/routes/orders.ts
  • Мобильный экран чека: mobile/src/screens/OrderSummaryScreen.tsx

Ссылки на спеки и BR