Skip to main content

Order lifecycle

An order has three nested state machines: the order as a whole, each line item on it, and each supplier dispatch created for it. They are related but not identical, and reading a problem correctly usually means looking at the right one. Every value below is taken from the shared enums in packages/types, which are the allow-list at the application boundary and mirror the database constraints.

The order status machine

There are exactly five order statuses: pending, dispatching, completed, partial and failed.

POST /api/orders
────────────► ┌─────────┐ row committed as pending, then
│ pending │ updated before the response is sent
└────┬────┘
│ at least one dispatch job enqueued

┌───────────────┐ roll-up after every dispatch outcome
│ dispatching │ ◄──────────────────────────────┐
└───────┬───────┘ │
│ │
┌───────────────┼───────────────┬───────────────┐ │
▼ ▼ ▼ ▼ │
┌───────────┐ ┌───────────┐ ┌──────────┐ (no dispatch │
│ completed │ │ partial │ │ failed │ reached the │
└───────────┘ └───────────┘ └──────────┘ queue at all) │
every dispatch some failed, every │
submitted or some did not dispatch │
confirmed failed │
└──────────── a reroute adds a dispatch ───────────────────┘

POST /api/orders never returns pending. The order row is created as pending inside the placement transaction, but by the time the handler responds it has already been updated: to dispatching if at least one supplier dispatch job reached the queue, or to failed if none did. A pending order in the database therefore means the placement request was interrupted between the commit and the fan-out, not that everything is proceeding normally.

StatusSet whenTerminal?
pendingThe row is first written inside the placement transactionNo
dispatchingAt least one dispatch job is queued, and no roll-up has resolved yetNo
completedEvery dispatch on the order is submitted or confirmedUntil a reroute
partialAt least one dispatch failed but not all of themUntil a reroute
failedEvery dispatch failed — including the "nothing was enqueued" caseUntil a reroute

The roll-up is a single shared function used by both the dispatch worker and the inbound acknowledgment processor, so an order cannot end up completed down one code path and partial down the other. It writes ORDER_STATUS_CHANGED only when the status actually moves. None of the three end states is permanently terminal: a back-order reroute creates another dispatch, which drops the order back to dispatching until that dispatch resolves.

If the queue hand-off itself fails, the order is deliberately kept. The affected dispatch is marked failed, a SUPPLIER_DISPATCH_FAILED event records the enqueue stage and the reason, and the chef still gets their order back. A stuck-but-visible dispatch an administrator can see beats an order that silently vanished after the chef was told nothing.

The line-item status machine

Line items carry the fulfilment detail. There are exactly seven statuses.

StatusMeaningWho acts
pendingCreated inside the placement transaction, not yet on the wireNobody
submittedIts dispatch reached the supplier's channelNobody
confirmedThe supplier acknowledged it (Sysco X12 855 only)Nobody
backorderedThe supplier reported it cannot fill the lineThe system, automatically
reroutedA reroute dispatch to the alternate supplier was createdNobody
dispatch_failedThe channel failed after all retriesAdministrator
backorder_no_alternateBack-ordered with no alternate to fall back toAdministrator
pending ──► submitted ──┬──► confirmed (855 ACK01 = IA)
│ │
│ ├──► backordered ──┬──► rerouted
│ │ (IB/BP/IR/DR/SP) └──► backorder_no_alternate
│ │
└────────────────────┴──► dispatch_failed

Costco and Central Kitchen have no inbound channel in this build, so their lines stop at submitted. Only Sysco lines reach confirmed, and only via a parsed X12 855 acknowledgment.

The supplier dispatch record

One supplier_dispatches row exists per supplier per order, plus one more for each reroute (flagged isReroute and pointing back at the dispatch it replaces). It is the unit the worker acts on, the unit the audit trail hangs references from, and the unit the order roll-up counts. There are exactly four dispatch statuses.

StatusMeaningReference field holds
pendingCreated, job queued
submittedThe channel accepted itEDI filename, or SMTP message id
confirmedThe supplier acknowledged itunchanged; confirmed_at is set
failedRetries exhausted, or the job could never be enqueued

The dispatch row is also the worker's idempotency guard: before any SFTP drop or email send, the handler checks whether this dispatch is already submitted and no-ops if it is. See queues and jobs.

Fan-out: one order, three channels

POST /api/orders (8 lines)

├── resolve every line in one query
│ unknown catalog item id → 404, nothing is created
│ item deactivated meanwhile → 409, nothing is created
│ no active supplier mapping → 422, nothing is created

├── one transaction: order + line items + dispatches + audit events

└── after the commit, enqueue one job per distinct supplier
├─► sysco → X12 850 → SFTP put → submitted
├─► costco → HTML order email → submitted
└─► central_kitchen → HTML → PDF → storage + email → submitted

Grouping is by supplier, not by line: eight lines that all belong to Sysco produce one EDI file with eight PO1 segments and one job, not eight jobs. Enqueuing happens strictly after the transaction commits, so the worker can never pick up a job for an order that is then rolled back.

The back-order reroute story

Say a chef at Lucille Downtown orders eight cases of Roma tomatoes, mapped to Sysco as primary with Central Kitchen as the alternate.

  1. Placement. The order and its line are created; the line is pending. A supplier-dispatch job for Sysco is enqueued and the order becomes dispatching.
  2. Dispatch. The worker generates the X12 850, drops it on Sysco's outbound directory, sets the dispatch and the line to submitted, and writes SUPPLIER_SUBMITTED carrying the filename.
  3. Acknowledgment. The Sysco inbound poll CronJob runs every 15 minutes and picks up an X12 855. The PO1 loop for the tomato line carries two ACK segments — five accepted, three back-ordered.
  4. Interpretation. Because one ACK reports IB, the line becomes backordered, LINE_ITEM_BACKORDERED is appended, and a reroute job is enqueued for the line. A redelivered 855 is a no-op: the processor checks the append-only trail for an existing back-order event from the same supplier before writing another.
  5. Reroute. The reroute handler resolves the alternate, creates a reroute dispatch, enqueues a fresh dispatch job, sets the line to rerouted and writes BACKORDER_REROUTED naming both suppliers.
  6. Second dispatch. The Central Kitchen handler renders the purchase order to PDF, uploads it to object storage, emails it, and marks its dispatch submitted.
  7. Roll-up. The order recomputes — completed if every dispatch now succeeded, partial if something else on the order failed.

If step 5 finds no alternate, the line becomes backorder_no_alternate, BACKORDER_NO_ALTERNATE is appended, an alert email goes to the configured administrator address, and the flow stops there deliberately. The administrator guide covers the manual reroute that resolves it.

Which ACK01 codes trigger a reroute

CodeMeaningReroute?
IAItem acceptedNo
ICItem changedNo
IPAccepted, price changedNo
IQAccepted, quantity changedNo
IBItem back-orderedYes
BPItem partially acceptedYes
IRItem rejectedYes
DRItem deleted / cannot shipYes
SPItem substitutedYes

Parsing detail, including why the quantities across all ACK segments in one PO1 loop must sum to the ordered quantity, is in Sysco EDI.

The audit vocabulary

The audit trail is append-only — a database trigger raises on any update or delete — and every transition above appends to it. The vocabulary has exactly 22 members, and the admin audit feed lets you filter on any of them.

AreaEvent types
OrdersORDER_PLACED, ORDER_STATUS_CHANGED
DispatchSUPPLIER_DISPATCH_CREATED, SUPPLIER_SUBMITTED, SUPPLIER_DISPATCH_FAILED, SUPPLIER_CONFIRMED
Back ordersLINE_ITEM_BACKORDERED, BACKORDER_REROUTED, BACKORDER_NO_ALTERNATE
InboundVENDOR_DOCUMENT_RECEIVED, EDI_REJECTED
CatalogCATALOG_ITEM_CREATED, CATALOG_ITEM_UPDATED, CATALOG_ITEM_DEACTIVATED
SuppliersSUPPLIER_CREATED, SUPPLIER_UPDATED
MappingsMAPPING_CREATED, MAPPING_UPDATED
RestaurantsRESTAURANT_CREATED, RESTAURANT_UPDATED
AssignmentsCHEF_ASSIGNED, CHEF_UNASSIGNED

There is no SUPPLIER_DEACTIVATED or MAPPING_DEACTIVATED member. Deactivations are recorded as SUPPLIER_UPDATED and MAPPING_UPDATED carrying action: 'deactivated' and the active transition, so the read-side allow-list stays closed while still describing what happened.

A typical Sysco order with one back order produces this sequence, oldest first:

ORDER_PLACEDSUPPLIER_DISPATCH_CREATEDORDER_STATUS_CHANGEDSUPPLIER_SUBMITTEDVENDOR_DOCUMENT_RECEIVEDLINE_ITEM_BACKORDEREDBACKORDER_REROUTEDSUPPLIER_DISPATCH_CREATEDSUPPLIER_SUBMITTEDORDER_STATUS_CHANGED

Where to go next

  • Chef guide — how these statuses are presented to chefs.
  • Queues and jobs — the machinery behind the transitions.
  • Data model — where each status is stored and constrained.