09. Заказ: как это работает в legacy и что меняется¶
Разбор жизненного цикла заказа в текущей системе: ../site (PHP/Laravel — владелец заказа),
../payment (Go — платежи), ../notification (Node — бот MAX), ../frontend (оформление).
Скидки, промокоды, персональные промокоды и подарки в этом документе намеренно не разбираются — по ним меняется постановка, они выносятся в отдельный документ. Здесь они упоминаются только там, где влияют на маршрут заказа (например, «промокод снимается перед созданием заказа»).
9.1. Маршрут заказа целиком¶
браузер site (Laravel) payment (Go) iiko
│ │ │ │
│ POST /order/create │ │ │
│─────────────────────────────▶│ CreateOrderHandler │ │
│ │ ├ lock корзины │ │
│ │ ├ подарки, цены, расчёт │ │
│ │ ├ проверки (см. §9.4) │ │
│ │ └ Order.status = 1 | 3 | 10 | 12 │
│◀─────────────────────────────│ order_id, message, wait_confirm │
│ │ │ │
│ POST /payment/create ────────▶│ CreatePaymentHandler ───────▶│ ЮKassa / СБП │
│◀──────────────── confirmation_token / url ──────────────────│ │
│ │ │ │
│ виджет ЮKassa │ │ │
│ + polling /payment/status ──────────────────────────────────▶│ (1 раз/сек) │
│ │◀── webhook /webhook/payment/paid ──│ │
│ │ status = 3 (ACCEPTED), paid = true │
│ │◀── cron payments:update (1 раз/мин) ──────────│
│ │ │
│ │ cron orders:create-in-crm (1/мин) │
│ │ status = 9 (WAIT_CRM) → job ────────────────▶│ deliveries/create
│ │ status = 11 (WAIT_CRM_ACCEPT) ◀──────────────│ correlationId
│ │ cron orders:check-creation (1/мин) ──────────▶│ commands/status
│ │ status = 4 (CRM_CREATED) │
│ │ │
│ │ cron orders:update-crm-status (1/мин) ───────▶│ deliveries/by_id
│ │◀── webhook /webhook/order/updated ────────────│ (без авторизации)
│ │ iiko_transport_status, crm_status, события │
│ GET /order/{id} ────────────▶│ статусы для витрины (6 шт.) │
Ключевая особенность: между «клиент нажал оформить» и «заказ появился в iiko» проходит до минуты — заказ не отправляется в iiko синхронно, его подхватывает планировщик.
9.2. Три параллельных набора статусов¶
В модели Order живут три независимых перечисления и четыре таблицы соответствий:
| Набор | Кол-во | Где | Назначение |
|---|---|---|---|
STATUS_* |
12 | orders.status |
внутренний статус сайта |
CRM_STATUS_* |
13 | orders.crm_status |
статус в iiko, числом |
IIKO_TRANSPORT_STATUS_* |
10 | orders.iiko_transport_status |
статус в iiko, строкой |
| витринные | 6 | вычисляются в OrderSiteStatus |
шкала для клиента |
Маппинги: TRANSPORT_CRM_STATUS_MAP (строка iiko → число), IIKO_STATUS_MAP (строка iiko →
русский текст), CRM_STATUS_MAP (число → русский текст), CRM_DB_STATUS_MAP (русский текст →
число — наследие прямого чтения БД iiko).
Внутренние статусы сайта:
| Код | Константа | Смысл |
|---|---|---|
| 1 | NEED_CHECK_PAYMENT |
создан, ждёт оплаты картой |
| 12 | WAIT_EXTERNAL_PAYMENT |
ждёт оплаты внешним сервисом (СБП/SberPay) |
| 2 | PAID_ERROR |
оплата не прошла |
| 10 | WAIT_CONFIRM |
ждёт ручного подтверждения (антифрод, §9.7) |
| 3 | ACCEPTED |
принят в работу — очередь на отправку в iiko |
| 9 | WAIT_CRM |
взят планировщиком, поставлен в очередь |
| 11 | WAIT_CRM_ACCEPT |
отправлен, iiko подтверждает асинхронно |
| 4 | CRM_CREATED |
создан в iiko — опрашивается на статусы |
| 5 | CRM_CREATE_ERROR |
ошибка создания в iiko |
| 6 | CLOSED |
отменён (пришло Cancelled из iiko) |
| 7 | CLOSED_FORCE |
закрыт принудительно (см. §9.6) |
| 8 | COMPLETED |
выполнен (пришло Closed из iiko) |
Витринная шкала (OrderSiteStatus) строится из двух журналов — order_statuses и
order_iiko_statuses — и различается для доставки и самовывоза:
доставка: accept → cooking → on_delivery → delivered (+ cancelled, closed)
самовывоз: accept → cooking → cooked → completed (+ cancelled, closed)
Проблема не в количестве статусов, а в том, что машины состояний нет: переходы разбросаны по
шести местам (OrderService, IikoCreateDelivery, IikoUpdateDeliveryStatus,
AsyncOperationSuccess/Error, UpdatePayment*Handler, пять cron-команд), каждое пишет
$order->status = …; $order->save(); напрямую. Допустимость перехода нигде не проверяется —
она подразумевается выборкой в планировщике.
9.3. Создание заказа¶
POST /api/order/create (OrderController::create, авторизация по токену клиента):
- Если тип «доставка» и адрес не передан — адрес создаётся на лету из тела запроса
(
CreateAddressHandler). CreateOrderHandler: старт транзакции → блокировка корзины (Locker) → загрузка корзины.- Добавляются подарки (первый заказ, подарок за персональный промокод) — вне скоупа документа.
- Загружаются цены по выбранной точке (
PriceLoader), считается итог (PriceCalculator). - Фильтр стоп-листа отключён. В коде: То есть заказ с позицией из стоп-листа уходит в iiko как есть.
- Время исполнения берётся из запроса как строка и парсится в таймзоне точки:
Carbon::createFromFormat('Y-m-d H:i', $deliveryAt, $pizzeria->timezone), с комментарием «Время доставки, передается всегда иначе IIKO не распределяет заказ». - Проверка ограничений наличной оплаты (единственная содержательная проверка оплаты):
CashPaymentCheck— время доcash_payment_end_timeи сумма ≤cash_payment_maximum_sum. OrderService::createOrder— проверки из §8.4 (напомню: нет минимальной суммы, нет зоны, валидация времени отключенаreturn true).- Присваивается случайный номер заказа:
crm_order_number = mt_rand(10000, 99999)— он живёт до первого ответа iiko, после чего заменяется настоящим номером. - Маршрутизация:
pizzeria_id= организация call-центра,pizzeria_payment_id= выбранная клиентом точка (она же уедет в iiko какorganizationId). - Стартовый статус — по способу оплаты:
- карта →
NEED_CHECK_PAYMENT(илиWAIT_EXTERNAL_PAYMENT, еслиpay_with_external_service); - клиент «высокого риска» →
WAIT_CONFIRM; - иначе →
ACCEPTED.
- карта →
- Позиции переносятся в
order_itemsснимком:dish_config(jsonb со всей конфигурацией блюда),name,price,amount,import_dish_id,dictionary_dish_id,dish_type. - Ответ клиенту:
order_id,wait_confirmиmessage— текст из таблицыorder_messages, выбираемый по комбинации (оплачен / самовывоз / ко времени) с подстановкой переменных{client_name},{order_time},{pizzeria_address},{pizzeria_description}.
Отдельно: additional_info — это {browser, resolution}, которые фронт отправляет о себе.
9.4. Оплата¶
Как устроено¶
| Способ | Путь |
|---|---|
| наличные / карта курьеру | заказ сразу ACCEPTED, платежа нет |
| карта онлайн (виджет ЮKassa) | payment/create → confirmation_token → виджет в браузере |
| СБП / SberPay | pay_with_external_service, статус WAIT_EXTERNAL_PAYMENT, ссылка на оплату |
site не ходит в платёжный шлюз сам: PaymentController::createPayment → CreatePaymentHandler
→ PaymentService (реализация — HTTP-клиент к Go-сервису payment), локально создаётся запись
order_payments со store_id = iiko_transport_id точки. Go-сервис (/api/payment/create,
/api/payment/status, /api/payment/webhooks) работает с ЮKassa и СБП Сбера.
Три независимых пути узнать об оплате¶
- Вебхук. ЮKassa →
payment-сервис (/api/payment/webhooks, проверкаmetadata.secret, отвечает200до обработки) →site/webhook/payment/paid(bearer-токен интеграции) →UpdatePaymentViaWebhookHandler: платёжCLOSED, заказACCEPTED,paid = true, событияOrderAcceptEvent+OrderPayed. - Опрос по расписанию.
payments:updateкаждую минуту берёт все платежи за 4 часа в статусеPENDINGи опрашивает Go-сервис (UpdatePaymentHandler) — те же переходы, плюсOrderNotPaidEvent, если платёж закрыт неоплаченным. - Опрос из браузера. Страница оформления сама дёргает
payment/status?id=раз в секунду и приis_paidуводит пользователя на/success(frontend/.../pages/Order.vue), с комментарием в коде, что колбэк ЮKassa не всегда срабатывает в Safari iOS.
Три пути ведут к одному переходу и защищены только тем, что isNeedPayment() проверяет статус.
Разные способы дают разные побочные эффекты (событие OrderNotPaidEvent есть только во втором).
Таймауты¶
getWaitPayment/getFailedPayment— по настройкеpayment.wait(минут).CloseUnpaidOrderHandlerпереводит заказ вPAID_ERROR.getMissingExternalPayment— заказы вWAIT_EXTERNAL_PAYMENTбез записи платежа старше 3 минут.
9.5. Отправка в iiko¶
| Шаг | Кто | Периодичность |
|---|---|---|
выбрать ACCEPTED → поставить WAIT_CRM → в очередь create_order_in_crm |
orders:create-in-crm |
каждую минуту |
deliveries/create, сохранить crm_id, поставить WAIT_CRM_ACCEPT |
CreateInCrmJob → IikoCreateDelivery |
по очереди |
commands/status по correlationId → CRM_CREATED либо CRM_CREATE_ERROR |
orders:check-creation → CheckAsyncOperationJob |
каждую минуту |
deliveries/by_id для заказов в CRM_CREATED |
orders:update-crm-status → UpdateCrmStatusJob (WithoutOverlapping по id заказа) |
каждую минуту, не чаще 1 раза в 5 мин на заказ |
| закрыть «зависшие» | orders:create-crm-failed (каждые 10 мин), orders:close (ежечасно) |
см. §9.6 |
Что стоит отметить:
- Идемпотентности нет. В
deliveries/createне передаётся ни наш идентификатор заказа, ни внешний номер — только состав, клиент и оплата. Защита от повторной отправки держится исключительно на статусе в нашей БД. - Синхронный
Successвозвращает заказ в очередь. ВIikoCreateDeliveryпосле успешного ответа:status = $response->created() ? STATUS_ACCEPTED : STATUS_WAIT_CRM_ACCEPT. ЗначениеACCEPTED— ровно то, по которомуorders:create-in-crmкаждую минуту выбирает заказы для отправки, аCreateInCrmJobпропускает заказы в статусахWAIT_CRM | CLOSED_FORCE | CRM_CREATE_ERROR. Практически iiko отвечаетInProgress, поэтому путь идёт черезWAIT_CRM_ACCEPT, но страховки от повторного создания заказа в iiko нет ни на одной стороне. - Приборы, сахар, сдача и комментарий склеиваются в один текст.
IikoDelivery::toArray()дописывает вorder.comment: «Сдача с …», «Сахар: N», «Приборы: N», комментарий клиента и пометкуTEST!!!на тестовом контуре. Количество персон уходит отдельным полемguests. - Заказ ко времени передаётся как
completeBeforeв форматеY-m-d H:i:s.v. - Модификаторы восстанавливаются из снимка
dish_configчерезDishService, часть товаров разбивается на несколько позиций (splitMarkedProducts).
Обновление статусов¶
Два источника, оба ведут в IikoUpdateDeliveryStatus::applyStatus():
- вебхук
POST|GET /api/webhook/order/updated— берётorder_id,status,numberпрямо из payload; при неполном payload откатывается на джобу с запросом в iiko. Маршрут объявлен какRoute::anyи, в отличие отwebhook/payment/paidиwebhook/client/registered, не имеет middleware авторизации; - опрос
deliveries/by_idпо расписанию.
applyStatus пишет iiko_transport_status, подменяет crm_order_number настоящим номером и
переводит заказ в COMPLETED при Closed и в CLOSED при Cancelled; попутно диспатчит
OrderCrmCreatedEvent, OrderClosedEvent, OrderCompletedEvent, OrderReadyEvent и
CrmStatusUpdatedEvent.
9.6. Отмена, удаление и принудительное закрытие¶
| Сценарий | Что происходит | Замечание |
|---|---|---|
| клиент отменяет заказ | ничего | OrderController::delete возвращает {successful: true}, вызов обработчика закомментирован; фронт использует этот эндпоинт при отмене SberPay и считает заказ удалённым |
DeleteOrderHandler (не вызывается из API) |
soft-delete при canDelete() |
живой код без точки входа |
| оплата не прошла | PAID_ERROR |
CloseUnpaidOrderHandler |
| iiko не принял заказ | CLOSED_FORCE |
orders:create-crm-failed, каждые 10 минут |
заказ «завис» в WAIT_CRM |
CLOSED_FORCE через 180 минут |
значение захардкожено; настройка order.timeout_create_in_crm из админки закомментирована с пояснением, что при долгой оплате она закрывала живые заказы |
| заказ старше 2 суток не в терминальном статусе | CLOSED_FORCE |
orders:close, ежечасно |
заказ в WAIT_CONFIRM старше 12 часов |
CLOSED_FORCE |
там же |
| отмена в iiko | CLOSED |
приходит статусом Cancelled |
Принудительное закрытие шлёт письмо и сообщение в Telegram менеджерам
(SendEmailNotify, SendTelegramNotify), клиенту — ничего.
9.7. Подтверждение «рискованных» заказов¶
Правило (Client::isHighRiskOrder / hasHighRiskOrder):
- если у клиента сегодня уже есть заказ с оплатой наличными или картой в пиццерии, не
закрытый и не выполненный, — новый такой заказ создаётся в статусе
WAIT_CONFIRM; - если у клиента уже есть заказ в
WAIT_CONFIRM, следующий заказ отклоняется исключениемSpamException— фронт показывает страницу/order-waitingпо коду ошибки, зашитому вfrontend/src/services/config.js.
Подтверждение — ручное, через админку; уведомление уходит событием
OrderNeedsConfirmationEvent. Через 12 часов неподтверждённый заказ закрывается принудительно.
9.8. Уведомления клиенту¶
OrderNotifier — единая точка, пять поводов: accepted, ready, onWay, delivered,
deliveredToDoor. Алгоритм: сначала мессенджеры (MAX, Telegram) через user-сервис
(NotificationClient), при неудаче — SMS, и только если SMS-шаблон существует и активен.
Тексты — частично шаблоны в БД, частично строки в коде («Курьер забрал ваш заказ и уже спешит к
вам»). Дедупликация — таблица order_status_notifications (одно уведомление на пару заказ+статус),
уведомления о доставке шлются только для типа «доставка».
Node-сервис notification — тонкий: Fastify + бот MAX (@maxhub/max-bot-api), четыре плагина.
Вся логика выбора канала и текста живёт в site и user.
9.9. Что из этого — задача новой платформы¶
Ниже — постановка для модуля order. Нумерация блоков продолжает §8.5.
N. Явная машина состояний заказа¶
Сейчас. 12 статусов сайта + 13 статусов CRM + 10 строк iiko, переходы присваиванием поля в шести местах, допустимость перехода нигде не проверяется.
Должно быть. Одна машина состояний с явным набором переходов и причин; запись перехода — единственный способ изменить статус; журнал переходов (кто, когда, из чего, во что, чем вызвано) как часть модели, а не как побочный эффект событий. Статусы iiko хранятся отдельно как «внешний статус» и не смешиваются с внутренним состоянием заказа. Витринная шкала — производная функция, а не третий источник истины.
O. Приём заказа: серверные проверки¶
Сейчас. Проверяются: непустая корзина, наличие адреса, включённость типа доставки, лимиты наличной оплаты, антифрод. Не проверяются: минимальная сумма, зона, график, стоп-лист, актуальность цен, соответствие точки адресу.
Должно быть. Приём заказа = повторный прогон checkout/preflight (§8.6) внутри транзакции.
Клиент присылает cartId, fulfillment (тип, адрес или точка, слот), способ оплаты и опции; всё
остальное — состав, цены, итог, обслуживающая точка, время исполнения — вычисляет сервер. Ответ
при отказе — коды блокеров, а не строка сообщения.
Отдельное решение требуется по стоп-листу (сейчас проверка отключена): либо заказ не принимается, либо позиция снимается с пересчётом суммы и явным подтверждением клиента. Это ОВ-18.
P. Отправка в iiko: идемпотентность и наблюдаемость¶
Сейчас. Отправка запускается сканом таблицы раз в минуту; повторная отправка предотвращается
только статусом; ключа идемпотентности нет; при синхронном Success заказ возвращается в статус,
который снова попадает в выборку отправки.
Должно быть.
- Отправка инициируется событием (OrderAccepted) через транзакционный outbox
(Event Publication Registry, ADR-001) — не сканом таблицы. Скан остаётся только как аварийный
подбор незавершённых публикаций.
- Наш идентификатор заказа уходит в iiko (order.id / externalNumber), чтобы повтор давал тот же
заказ, а не второй.
- Перед отправкой — проверка crm_id is null и блокировка строки.
- Асинхронное подтверждение (correlationId) — часть состояния заказа, с ограничением числа
попыток и явным терминальным исходом.
- Номер заказа для клиента не выдумывается случайным числом: до подтверждения iiko показывается
наш собственный идентификатор, потом — номер iiko.
Q. Статусы из iiko: вебхук как основной путь¶
Сейчас. Вебхук webhook/order/updated без авторизации + опрос deliveries/by_id раз в минуту
для всех активных заказов.
Должно быть. Вебхук с проверкой подписи/токена (как уже сделано для payment/paid), запись
события в журнал «как есть», применение — идемпотентное по (заказ, статус, время). Опрос остаётся
подстраховкой с экспоненциальным интервалом и только для заказов, по которым давно нет событий.
R. Оплата¶
Сейчас. Три независимых пути узнать об оплате, из которых один — опрос из браузера раз в секунду; побочные эффекты у путей разные.
Должно быть. Один обработчик перехода «платёж оплачен» с идемпотентным применением, вызываемый
из любого источника (вебхук провайдера, опрос, ручная операция). Клиенту — не опрос статуса, а
подписка/долгий опрос по заказу, возвращающий состояние заказа, а не платежа. Платёж — сущность
модуля payments со своими состояниями, связанная с заказом, а не поле paid в заказе.
S. Отмена заказа¶
Сейчас. Клиент отменить заказ не может: эндпоинт — заглушка. Автоматика закрывает заказы по таймаутам (180 минут / 2 суток / 12 часов), клиенту об этом не сообщают.
Должно быть. Отмена — нормальная операция с правилами: до какого статуса клиент может отменить
сам, что происходит с оплатой (возврат), что уходит в iiko (deliveries/cancel), какое уведомление
получает клиент. Таймауты — настройки, а не константы в коде, и каждое автоматическое закрытие
порождает уведомление и запись в журнал.
T. Состав заказа¶
Сейчас. order_items.dish_config — jsonb-снимок карточки блюда в том виде, в каком его собрал
фронт, плюс import_dish_id. Приборы, сахар, сдача — текстом в комментарии.
Должно быть. Позиция заказа ссылается на catalog.product_variant → iiko.item (ADR-005) и
одновременно хранит снимок того, что было продано: название, цена, модификаторы, итог строки.
Служебные позиции (приборы, соусы, «оставить у двери») — позиции с ролью (§8.5 F), а не текст в
комментарии. Комментарий клиента остаётся комментарием.
U. Уведомления¶
Сейчас. Пять поводов, каскад «мессенджеры → SMS», дедупликация таблицей, тексты частью в БД, частью в коде.
Должно быть. Уведомление — реакция на событие заказа через outbox, с шаблонами в админке,
единой дедупликацией и журналом доставки. Каскад каналов остаётся, выбор канала — по данным
клиента, а не по исключениям. Подробный разбор всех каналов и постановка — 10-notifications.md
(пункт 33 плана ниже поглощается пунктами 34–36 того документа).
9.10. Целевые контракты (дополняют §8.6)¶
| Endpoint | Назначение |
|---|---|
POST /api/v1/orders |
создание заказа по cartId + fulfillment + paymentMethod; повторяет preflight внутри транзакции; возвращает заказ с состоянием и, при онлайн-оплате, параметрами платежа |
GET /api/v1/orders/{id} |
состояние заказа: внутренний статус, внешний статус iiko, шкала для витрины, ETA, состав, суммы |
GET /api/v1/orders |
история с пагинацией |
POST /api/v1/orders/{id}/cancel |
отмена клиентом, с правилами и возвратом |
POST /api/v1/orders/{id}/repeat |
повтор заказа (сейчас реализован фронтом сборкой корзины) |
POST /api/v1/webhooks/iiko/order |
статусы из iiko, с проверкой подписи |
POST /api/v1/webhooks/payments/{provider} |
события платёжного провайдера |
9.11. Открытые вопросы¶
ОВ-18. Стоп-лист в момент оформления. Сейчас проверка отключена: «хотя клиент заплатил, отключение проверки на стоп до появления ТЗ». Варианты: (а) не принимать заказ, (б) снять позицию и пересчитать сумму с подтверждением клиента, (в) принять и решать вручную. Нужен ответ клиента — это прямое влияние на деньги и на возвраты.
ОВ-19. Может ли клиент отменять заказ и до какого момента? Сейчас — нет. Если да: до какого статуса iiko, что с возвратом оплаты, кто подтверждает.
ОВ-20. Антифрод. Правило «второй наличный заказ за день → ручное подтверждение, третий → отказ» переносим как есть, меняем пороги или заменяем на другую модель (лимит суммы, чёрный список, подтверждение телефона)?
ОВ-21. Номер заказа. Нужен ли клиенту собственный сквозной номер до подтверждения iiko, или достаточно показывать номер iiko после подтверждения? Сейчас показывается случайное число.
ОВ-22. Заказ ко времени и распределение в iiko. В коде — «время передаётся всегда, иначе iiko
не распределяет заказ». Нужно проверить на боевом ключе, обязателен ли completeBefore для
заказов «как можно скорее», и как это сочетается с deliveries/create без времени.
ОВ-23. Куда создавать заказ. Сейчас все заказы уходят в организацию call-центра, а точка используется как получатель оплаты (см. ОВ-16). Ответ определяет модель маршрутизации.
ОВ-24. Таймауты закрытия. 180 минут в WAIT_CRM, 2 суток на всё, 12 часов на подтверждение —
подтверждаем значения или пересматриваем? Настройка из админки сейчас намеренно игнорируется.
9.12. Влияние на план работ¶
Продолжение таблицы §8.10 (этап заказа):
| # | Работа | Оценка |
|---|---|---|
| 26 | Модель заказа: сущности, снимок позиций со ссылкой на catalog.product_variant, журнал переходов |
4 дн |
| 27 | Машина состояний + правила переходов + журнал, покрытые тестами | 4 дн |
| 28 | Приём заказа: транзакционный preflight, идемпотентность создания, ответ с кодами блокеров | 4 дн |
| 29 | Отправка в iiko через outbox: идемпотентный ключ, асинхронное подтверждение, ретраи, терминальные исходы | 5 дн |
| 30 | Статусы из iiko: вебхук с подписью + подстраховочный опрос, журнал внешних событий | 3 дн |
| 31 | Платежи: модуль, состояния, единый обработчик «оплачено», подписка клиента на состояние заказа | 5 дн |
| 32 | Отмена и автозакрытие: правила, возвраты, уведомления, настройки таймаутов | 4 дн |
| 33 | Уведомления по событиям заказа: шаблоны в админке, каскад каналов, журнал доставки | 4 дн |
Скидки, промокоды и подарки в эту оценку не входят — по ним отдельный документ и отдельная оценка.