Перейти к содержанию

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, авторизация по токену клиента):

  1. Если тип «доставка» и адрес не передан — адрес создаётся на лету из тела запроса (CreateAddressHandler).
  2. CreateOrderHandler: старт транзакции → блокировка корзины (Locker) → загрузка корзины.
  3. Добавляются подарки (первый заказ, подарок за персональный промокод) — вне скоупа документа.
  4. Загружаются цены по выбранной точке (PriceLoader), считается итог (PriceCalculator).
  5. Фильтр стоп-листа отключён. В коде:
    // если блюдо находится на стопе, то оно удаляется из заказа
    // хотя клиент заплатил, отключение проверки на стоп до появления ТЗ
    // $cart = $this->productFilter->filter($cart, $pizzeria->getStopList());
    
    То есть заказ с позицией из стоп-листа уходит в iiko как есть.
  6. Время исполнения берётся из запроса как строка и парсится в таймзоне точки: Carbon::createFromFormat('Y-m-d H:i', $deliveryAt, $pizzeria->timezone), с комментарием «Время доставки, передается всегда иначе IIKO не распределяет заказ».
  7. Проверка ограничений наличной оплаты (единственная содержательная проверка оплаты): CashPaymentCheck — время до cash_payment_end_time и сумма ≤ cash_payment_maximum_sum.
  8. OrderService::createOrder — проверки из §8.4 (напомню: нет минимальной суммы, нет зоны, валидация времени отключена return true).
  9. Присваивается случайный номер заказа: crm_order_number = mt_rand(10000, 99999) — он живёт до первого ответа iiko, после чего заменяется настоящим номером.
  10. Маршрутизация: pizzeria_id = организация call-центра, pizzeria_payment_id = выбранная клиентом точка (она же уедет в iiko как organizationId).
  11. Стартовый статус — по способу оплаты:
    • карта → NEED_CHECK_PAYMENT (или WAIT_EXTERNAL_PAYMENT, если pay_with_external_service);
    • клиент «высокого риска» → WAIT_CONFIRM;
    • иначе → ACCEPTED.
  12. Позиции переносятся в order_items снимком: dish_config (jsonb со всей конфигурацией блюда), name, price, amount, import_dish_id, dictionary_dish_id, dish_type.
  13. Ответ клиенту: order_id, wait_confirm и message — текст из таблицы order_messages, выбираемый по комбинации (оплачен / самовывоз / ко времени) с подстановкой переменных {client_name}, {order_time}, {pizzeria_address}, {pizzeria_description}.

Отдельно: additional_info — это {browser, resolution}, которые фронт отправляет о себе.

9.4. Оплата

Как устроено

Способ Путь
наличные / карта курьеру заказ сразу ACCEPTED, платежа нет
карта онлайн (виджет ЮKassa) payment/createconfirmation_token → виджет в браузере
СБП / SberPay pay_with_external_service, статус WAIT_EXTERNAL_PAYMENT, ссылка на оплату

site не ходит в платёжный шлюз сам: PaymentController::createPaymentCreatePaymentHandlerPaymentService (реализация — HTTP-клиент к Go-сервису payment), локально создаётся запись order_payments со store_id = iiko_transport_id точки. Go-сервис (/api/payment/create, /api/payment/status, /api/payment/webhooks) работает с ЮKassa и СБП Сбера.

Три независимых пути узнать об оплате

  1. Вебхук. ЮKassa → payment-сервис (/api/payment/webhooks, проверка metadata.secret, отвечает 200 до обработки) → site /webhook/payment/paid (bearer-токен интеграции) → UpdatePaymentViaWebhookHandler: платёж CLOSED, заказ ACCEPTED, paid = true, события OrderAcceptEvent + OrderPayed.
  2. Опрос по расписанию. payments:update каждую минуту берёт все платежи за 4 часа в статусе PENDING и опрашивает Go-сервис (UpdatePaymentHandler) — те же переходы, плюс OrderNotPaidEvent, если платёж закрыт неоплаченным.
  3. Опрос из браузера. Страница оформления сама дёргает 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 CreateInCrmJobIikoCreateDelivery по очереди
commands/status по correlationIdCRM_CREATED либо CRM_CREATE_ERROR orders:check-creationCheckAsyncOperationJob каждую минуту
deliveries/by_id для заказов в CRM_CREATED orders:update-crm-statusUpdateCrmStatusJob (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_variantiiko.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 дн

Скидки, промокоды и подарки в эту оценку не входят — по ним отдельный документ и отдельная оценка.