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.
| Status | Set when | Terminal? |
|---|---|---|
pending | The row is first written inside the placement transaction | No |
dispatching | At least one dispatch job is queued, and no roll-up has resolved yet | No |
completed | Every dispatch on the order is submitted or confirmed | Until a reroute |
partial | At least one dispatch failed but not all of them | Until a reroute |
failed | Every dispatch failed — including the "nothing was enqueued" case | Until 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.
| Status | Meaning | Who acts |
|---|---|---|
pending | Created inside the placement transaction, not yet on the wire | Nobody |
submitted | Its dispatch reached the supplier's channel | Nobody |
confirmed | The supplier acknowledged it (Sysco X12 855 only) | Nobody |
backordered | The supplier reported it cannot fill the line | The system, automatically |
rerouted | A reroute dispatch to the alternate supplier was created | Nobody |
dispatch_failed | The channel failed after all retries | Administrator |
backorder_no_alternate | Back-ordered with no alternate to fall back to | Administrator |
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.
| Status | Meaning | Reference field holds |
|---|---|---|
pending | Created, job queued | — |
submitted | The channel accepted it | EDI filename, or SMTP message id |
confirmed | The supplier acknowledged it | unchanged; confirmed_at is set |
failed | Retries 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.
- Placement. The order and its line are created; the line is
pending. A supplier-dispatch job for Sysco is enqueued and the order becomesdispatching. - Dispatch. The worker generates the X12 850, drops it on Sysco's outbound directory, sets the
dispatch and the line to
submitted, and writesSUPPLIER_SUBMITTEDcarrying the filename. - Acknowledgment. The Sysco inbound poll CronJob runs every 15 minutes and picks up an X12 855.
The
PO1loop for the tomato line carries twoACKsegments — five accepted, three back-ordered. - Interpretation. Because one
ACKreportsIB, the line becomesbackordered,LINE_ITEM_BACKORDEREDis 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. - Reroute. The reroute handler resolves the alternate, creates a reroute dispatch, enqueues a
fresh dispatch job, sets the line to
reroutedand writesBACKORDER_REROUTEDnaming both suppliers. - Second dispatch. The Central Kitchen handler renders the purchase order to PDF, uploads it to
object storage, emails it, and marks its dispatch
submitted. - Roll-up. The order recomputes —
completedif every dispatch now succeeded,partialif 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
| Code | Meaning | Reroute? |
|---|---|---|
IA | Item accepted | No |
IC | Item changed | No |
IP | Accepted, price changed | No |
IQ | Accepted, quantity changed | No |
IB | Item back-ordered | Yes |
BP | Item partially accepted | Yes |
IR | Item rejected | Yes |
DR | Item deleted / cannot ship | Yes |
SP | Item substituted | Yes |
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.
| Area | Event types |
|---|---|
| Orders | ORDER_PLACED, ORDER_STATUS_CHANGED |
| Dispatch | SUPPLIER_DISPATCH_CREATED, SUPPLIER_SUBMITTED, SUPPLIER_DISPATCH_FAILED, SUPPLIER_CONFIRMED |
| Back orders | LINE_ITEM_BACKORDERED, BACKORDER_REROUTED, BACKORDER_NO_ALTERNATE |
| Inbound | VENDOR_DOCUMENT_RECEIVED, EDI_REJECTED |
| Catalog | CATALOG_ITEM_CREATED, CATALOG_ITEM_UPDATED, CATALOG_ITEM_DEACTIVATED |
| Suppliers | SUPPLIER_CREATED, SUPPLIER_UPDATED |
| Mappings | MAPPING_CREATED, MAPPING_UPDATED |
| Restaurants | RESTAURANT_CREATED, RESTAURANT_UPDATED |
| Assignments | CHEF_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_PLACED → SUPPLIER_DISPATCH_CREATED → ORDER_STATUS_CHANGED → SUPPLIER_SUBMITTED →
VENDOR_DOCUMENT_RECEIVED → LINE_ITEM_BACKORDERED → BACKORDER_REROUTED →
SUPPLIER_DISPATCH_CREATED → SUPPLIER_SUBMITTED → ORDER_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.