Appearance
04 — States and flows
How TMS v2 moves its records through their lives: one state diagram and one transition table per record, then five end-to-end sequences. Read this when you need to know "what status can this record be in, and what moves it".
Built or designed. Every state, transition and step is tagged:
- Built — in the code of
bigc-tms-core-v2, branchmain-v2-prototype, commit3d70475a(worktree/Users/thanosorn/Works/Software/tms/wt-v2-prototype-core). - Designed — only in the design documents (discovery module designs under
/Users/thanosorn/Works/Software/tms/agent-task/.work/tms-v2-discovery/modules/<module>/design.md, and the system-widedesign.mdbeside them). Not in code yet.
In diagrams, built states are green and designed states are grey with a dashed border; transition labels end in (B) or (D).
At a glance
| record | built in code today | designed | biggest gap |
|---|---|---|---|
| Delivery order (DO) | 6 status names; only PENDING is ever set by code; any other value can be written by admin PUT or by the Kafka payload, unchecked | 8 BRD v2 names, a guarded transition per edge | names differ, and code has no transition guard |
| Plan | pending → planned, stays planned on every re-run or edit; generated exists as a name, nothing sets it | 7 states incl. optimising, failed, generating, cancelled | generate is a stub; optimise runs inside the HTTP request, not as a job |
| Task (trip) | entity with task_no only; no status field | 6 states | not built |
| Stop | stops exist only inside plan.tasks[].stops[] with a stop type; no stop status | 5 states | not built |
| Duty record | not built | 7 states | not built |
| COD on a DO | is_cod and cod_amount fields only | cod_status UNPAID → PAID → RECEIVED + one handover per task | not built |
| Job (jobs engine row) | PENDING, RUNNING, DEAD; finished rows are deleted | same | small: schedule retry count, DEAD rows never expire |
| Kafka inbound message | claim row processing / done / failed + dead-letter row pending / resolved | intake_message PARKED / DEAD / REPLAYING / REPLAYED with replay | design's park-and-replay not built |
| Attachment | pending → scanning → ready / failed | same | none found |
Old planning notes in this folder (task-v2-plan.md, delivery-order-v2-plan.md, July–August) use the older names PENDING / PLANNED / IN_TRANSIT / FAILED / RE_SCHEDULED and a Trip with status PLANNED. The module designs replaced those names with the BRD v2 names below; treat the old notes as history.
1. Delivery order (DO)
One DO is one shipment to one customer address. Source: code src/common/enums/delivery_order.enum.ts, src/delivery_order/delivery_order.service.ts; design: system-wide design.md "DO status — the one shared table", intake/design.md "DO states as intake sees them", execution/design.md "State machines".
1a. As built
The enum has PENDING, PLANNED, IN_TRANSIT, DELIVERED, FAILED, CANCELLED, but the schema stores status as a free string. Code sets only PENDING (on create when no status is given). The planner reads PENDING DOs as candidates and never changes DO status.
1b. As designed
A redelivery after a failed attempt is the same DO back in UNASSIGNED with attempt_count > 0. An OMS redeliver of a CANCELLED or FAILED_DELIVERY order is a new DO (next attempt_no). An ops hold is a record on the DO, not a status; the DO status HOLD means "the driver recorded a failed attempt".
1c. Code name vs design name
| code name (built) | nearest design name | note |
|---|---|---|
| PENDING | UNASSIGNED (no approved_at, or ready to plan) | design also folds v1 RE_SCHEDULED into UNASSIGNED with attempt_count > 0 |
| PLANNED | ASSIGNED | design: on a route not yet departed, with or without a task |
| — | DISPATCHED | no code name |
| IN_TRANSIT | OUT_FOR_DELIVERY | |
| — | HOLD | no code name |
| DELIVERED | DELIVERED | |
| FAILED | FAILED_DELIVERY | |
| CANCELLED | CANCELLED |
1d. Transition table
| from → to | trigger | who | guard | status | code path or design section |
|---|---|---|---|---|---|
| — → PENDING | Kafka bigc.lastmiles.do-post message, no DO with that delivery_no | system (Kafka consumer) | delivery_no and message_id present; one transaction writes DO + activity row + outbound job | Built | delivery_order.service.ts upsertFromKafka → createDeliveryOrder |
| — → PENDING | POST /v1/delivery_order | admin | none beyond DTO validation; delivery_no generated DO + 8 digits if missing | Built | delivery_order.controller.ts, createDeliveryOrder |
| any → any | PUT /v1/delivery_order/:delivery_no with status | admin | none — any string is written | Built | updateByDeliveryNo |
| any → any | Kafka message for an existing delivery_no carrying status | system | none on status; a replay of the same message_id is a no-op (unique activity event_key) | Built | upsertFromKafka → updateByDeliveryNo |
| — → UNASSIGNED | OMS message, admin form, import | intake | routing table rows 1–3 (dedupe, park behind earlier message, create) | Designed | intake design.md "How a message is routed" |
| UNASSIGNED → CANCELLED | OMS cancel or admin cancel | intake / ops | OMS cancel refused once attempt_count > 0; admin cancel needs a reason then | Designed | intake "DO states as intake sees them" |
| UNASSIGNED → ASSIGNED | generate of the chosen plan variant, add-to-route | planning | conditional claim: order still has no plan id and is still eligible | Designed | planning "How an order is claimed and released" |
| ASSIGNED → UNASSIGNED | order removed from plan, plan cancelled, ops hold, end of plan date | planning | released in the same write that removes it from its route | Designed | planning "How an order is claimed and released" |
| ASSIGNED → DISPATCHED | depart (driver, staff PIN) | execution | one transaction across task, DOs, DO items; at least one unit loaded | Designed | execution "State machines", row on depart |
| ASSIGNED → ASSIGNED (off task) | depart with nothing loaded, or task cancelled | execution | stays on its route, no attempt counted | Designed | execution "State machines" |
| DISPATCHED → OUT_FOR_DELIVERY | driver opens first stop | execution | every DO of the task without an open ops hold moves at once | Designed | execution "State machines" |
| OUT_FOR_DELIVERY → DELIVERED | confirm delivered | driver | stop is ARRIVED; idempotency key; COD written in the same transaction | Designed | execution "State machines"; cod-payment "Lifecycle" |
| OUT_FOR_DELIVERY → HOLD | report failed attempt | driver | reason from reason master; attempt_count + 1 | Designed | execution "Failed attempts, holds and reschedule" |
| HOLD → OUT_FOR_DELIVERY | retry while still at that stop | driver | count unchanged | Designed | same |
| DISPATCHED / OUT_FOR_DELIVERY / HOLD → UNASSIGNED | close-out "redeliver" | store staff (unload PIN) or ops | not on-demand; counted attempts below max_delivery_attempts (default 3); goods scanned back | Designed | execution "Failed attempts…"; close-out row |
| DISPATCHED / OUT_FOR_DELIVERY / HOLD → FAILED_DELIVERY | close-out "fail" | store staff or ops | reason required; forced when goods not returned | Designed | same |
| ASSIGNED → CANCELLED | upstream cancel before the task left | intake / ops | task not departed | Designed | execution "State machines" |
| DISPATCHED / OUT_FOR_DELIVERY / HOLD → CANCELLED | upstream cancel after the task left | execution at close-out | stays on the task until close-out | Designed | execution "State machines" |
| CANCELLED / FAILED_DELIVERY → new DO | OMS sends a newer message for the order | intake | newest attempt is final | Designed | intake routing row 8 |
| DELIVERED → (no change) | any OMS message | intake | recorded as DELIVERED_SKIPPED | Designed | intake routing row 9 |
Status corrections (for example "revert delivered") are designed as named reverse edges, each with a reason and an audit row; a free status set from admin is rejected by design. Code today has no such edges and no guard.
2. Plan
One plan is one routing variant for one store and one date. A store and date may have several variants; only one may be generated. Source: code src/common/enums/plan.enum.ts, src/plan/plan.service.ts, plan_optimize.service.ts, plan_operation.service.ts; design: planning design.md "Plan states".
generated is green because the name exists in code and blocks re-plan, but no code path sets it.
| from → to | trigger | who | guard | status | code path or design section |
|---|---|---|---|---|---|
| — → pending | POST /v1/plan | admin, store operator | store and plan template exist; every template vehicle type exists | Built | plan.service.ts createPlan |
| pending → planned | POST /v1/plan/:code/re_plan (Google Route Optimization runs inside the request) | admin, store operator | plan not generated; has shifts; save only if optimize.revision unchanged, else 409 | Built | plan_optimize.service.ts rePlan → _execute → _save |
| planned → planned | re_plan again (reset: pins and hand removals dropped) | admin, store operator | same as above | Built | same |
| planned → planned | move_stop, assign_stop, unassign_stop (re-optimise only the touched routes) | admin, store operator | plan must be planned; revision check as above | Built | plan_operation.service.ts → recalculate |
| planned → planned (no change) | Google call fails | — | failure written to plan_history and activity log; plan untouched | Built | _execute catch → recordFailure |
| planned → generated | POST /v1/plan/:code/generate_task | admin, store operator | — | Not built: returns "Task generated successfully" and changes nothing | plan.service.ts generateTask (TODO) |
| pending / planned / failed → optimising | Optimise pressed; background job holds a lease | ops admin | state and revision still match | Designed | planning "Plan states"; "Prebuild a store's day" v2 steps 1–2 |
| optimising → planned / failed | job finishes / errors or lease lapses | system | earlier routes kept on failure | Designed | planning "Plan states" |
| planned → generating → generated | Generate pressed; job turns each open route into one task | ops admin | chosen-variant flag under a unique index on store + date; per-route order claim | Designed | planning "How an order is claimed and released" |
| generating → planned | error or lease lapses | system | routes that got a task keep it; a retry resumes | Designed | planning "Plan states" |
| generated → planned | manual route added, or execution cancels a route's task | ops admin / execution | — | Designed | planning "Plan states" |
| pending / planned / failed → cancelled | cancel | ops admin | no route has a task | Designed | planning "Plan states" |
Every built plan save writes a plan_history row and an activity_logs row in the same transaction (actions RE_PLAN, RECALCULATE, MOVE_STOP, ASSIGN_STOP, UNASSIGN_STOP; CREATE and RESET are declared but never written).
3. Task (one driver's trip)
Design only. In code, src/task/task.schema.ts holds just task_no (TK + 8 digits) and TaskService.createTask has no caller. Design: execution design.md "State machines".
| from → to | trigger | who | guard | status | design section |
|---|---|---|---|---|---|
| — → PENDING | generate of a plan route | planning job | one task per route, in the route's transaction | Designed | planning "Prebuild a store's day" step 7 |
| PENDING → ACCEPTED | accept | driver | compare-and-swap on status; two concurrent accepts → exactly one wins | Designed | execution lifecycle, accept row |
| ACCEPTED → DISPATCHED | depart (staff PIN + photo) | driver | store check-in within store radius (default 300 m); one transaction for task, DOs, DO items, outbox | Designed | execution lifecycle, depart row |
| DISPATCHED → IN_TRANSIT | open first stop | driver | — | Designed | execution "State machines" |
| IN_TRANSIT / DISPATCHED → COMPLETED | close-out | store staff (unload PIN), or ops for an overdue task | every undelivered DO decided; every stop final | Designed | execution close-out row; overdue-list row |
| PENDING / ACCEPTED → CANCELLED | ops cancel | ops admin | not left the store; route task link cleared, plan back to planned | Designed | execution "State machines" |
| PENDING / ACCEPTED → CANCELLED | scheduled job, 24 h after task date (task_start_grace_hours) | system | still not started; DOs stay ASSIGNED on their route | Designed (needs customer confirm) | execution never-started row |
A task is "held" while its driver's duty record is HELD; that is not a task status. A task that left the store cannot be cancelled; it ends only through close-out.
4. Stop (one customer address in a task)
Design only. In code a stop exists only inside a plan route (plan.tasks[].stops[]) with a type DEPARTURE, DELIVERY or RETURN and a pin flag; it has no status. Design: execution design.md "State machines".
| from → to | trigger | who | guard | status | design section |
|---|---|---|---|---|---|
| PENDING → DELIVERING | open_stop | driver | task DISPATCHED or IN_TRANSIT | Designed | execution lifecycle, open-stop row |
| PENDING / DELIVERING → ARRIVED | arrive | driver | server stores position and distance; beyond 2,000 m only flags out_of_location | Designed | execution arrival row |
| DELIVERING → PENDING | driver opens a different stop | driver | — | Designed | execution "State machines" |
| ARRIVED → DEPARTED | next stop opened, return check-in, or close-out | driver / close-out | every DO at the stop is settled (DELIVERED, HOLD, CANCELLED, or held/cancelled upstream) | Designed | execution "State machines" |
| PENDING / DELIVERING → CANCELLED | stop moved or emptied, task cancelled, or not reached by close-out | ops / close-out | — | Designed | execution "State machines" |
Each DO at a stop also gets a visit record stop_do (one per task × DO, one open visit per DO by unique index); its close command (redeliver, fail, reassign, …) stamps the DO values used. Design only.
5. Duty record (one driver, one date)
Design only; nothing in code. Shared by planning (time windows) and workforce (state). Design: workforce design.md "Duty states"; system-wide design.md "Shared duty record".
| from → to | trigger | who | guard | status | design section |
|---|---|---|---|---|---|
| — → SCHEDULED | window attached (plan template slot or confirmed booking) | system | driver ACTIVE, not on approved leave; windows may not overlap | Designed | workforce "Duty states" |
| SCHEDULED → ON_DUTY | clock-in | driver | photo + position; on-time mark set | Designed | same |
| ON_DUTY ⇄ ON_BREAK | start / end break | driver | start only with no task in progress | Designed | same |
| ON_DUTY / ON_BREAK → HELD | hold | ops admin, or on-demand acceptance timeout | the driver's not-started tasks of the date are unassigned | Designed | same |
| HELD → ON_DUTY | unhold | ops admin | windows untouched | Designed | same |
| ON_DUTY / HELD → OFF_DUTY | clock-out | driver | photo + position; no task in progress | Designed | same |
| ON_DUTY → OFF_DUTY | booking cancelled, no other window that day | ops admin | no task in progress; ended_by_cancel | Designed | same |
| SCHEDULED → CANCELLED | booking cancelled or window detached, no other window | ops admin / planning | record kept for history | Designed | same |
| ON_DUTY / ON_BREAK / HELD → OFF_DUTY | day close job | system | 2 h after last window end; auto_closed | Designed | same |
| SCHEDULED → ABSENT | day close job | system | never clocked in | Designed | same |
Booking states (designed): INVALID, CONFIRMED, CANCELLED, CLOSED (by day close). Leave: PENDING → APPROVED / REJECTED / CANCELLED. Every write is one conditional update (… where state = <from>), so a repeated request has one effect.
6. COD on an order, and the cash handover
Design only. Code has is_cod and cod_amount on the DO and passes them through to outbound Kafka; no COD status, no handover. Design: cod-payment design.md "Lifecycle of COD on one order", "Handing cash to the store".
| from → to | trigger | who | guard | status | design section |
|---|---|---|---|---|---|
| — → UNPAID | DO created from OMS with COD | intake | amount due > 0; later OMS updates may change cod_amount only while UNPAID | Designed | cod-payment "Lifecycle" |
| UNPAID → PAID | confirm delivered | driver | COD photo uploaded; same idempotency key returns first result; written in the DELIVERED transaction; server copies the amount due | Designed | cod-payment "Collecting cash at the stop" |
| PAID → RECEIVED | handover: one cod_handover per task | store cashier (PIN on driver's phone) | task closed out; no handover exists for the task; PIN belongs to the task's store; received between 0 and expected; a shortfall needs a reason | Designed | cod-payment "Handing cash to the store" |
| RECEIVED → — | none; corrections are adjustment rows on the handover | store supervisor | net received stays between 0 and expected | Designed | same |
The handover record has no status of its own. Per task the store sees a derived state: open cash (a PAID order, no handover), settled (variance 0), settled short (variance < 0). An optional store setting cod.block_dispatch_unsettled stops new assignments to a driver with open cash.
7. Job (jobs engine row) and schedule slot
Built. One jobs row is one piece of work that must not be lost: an outbound Kafka message, or one slot of a schedule. Source: src/jobs/jobs.constants.ts, fast_lane.worker.ts, long_lane.worker.ts, jobs.controller.ts, src/schedules/schedules.service.ts. Design: platform design.md rows on schedules and the jobs engine; tms-v2-jobs-engine-code-walkthrough.md.
| from → to | trigger | who | guard | status | code path |
|---|---|---|---|---|---|
| — → PENDING | sideEffect.enqueue in the caller's transaction | business code, schedule tick | unique dedupe_key: a repeat enqueue inserts nothing | Built | side_effect.service.ts enqueueMany |
| PENDING → deleted | handler succeeded | fast lane (pod owning the partition) | write guarded on the row still PENDING | Built | fast_lane.worker.ts JobOutcome._ok |
| PENDING → PENDING (retry) | handler threw an answered error | worker | attempts < max (default 6 runs = 1 + 5 retries); a retrying row holds later versions of its key on the same target | Built | JobOutcome._answered, dispatchOnce |
| PENDING → PENDING (unreachable) | TargetUnreachable (broker down, refused) | worker | attempts not counted; target paused 30 s, then one probe | Built | JobOutcome._unreachable, target_pause.ts |
| PENDING → DEAD | max attempts, or invalid record (dead_now), or outcome unknown on an at-most-once target, or partition taken over while the row was in flight | worker / heartbeat | at-most-once is the default for every topic | Built | JobOutcome._answered, FastLaneWorker._closeTakenOver |
| PENDING → RUNNING | long-lane reserve (schedules) | long lane | batch of ≤ 10; exclusive targets: one RUNNING row per code across pods (unique partial index on running_code) | Built | long_lane.worker.ts reserve |
| RUNNING → PENDING | answered failure with retries left, or lease (5 min, heartbeat 1 min) expired and another pod reclaims | long lane | claim token guards every write | Built | long_lane.worker.ts |
| DEAD → PENDING | POST /v1/jobs/:id/retry | admin | must be DEAD, else 409 | Built | jobs.controller.ts retry |
| DEAD → deleted | POST /v1/jobs/:id/skip | admin | must be DEAD, else 409 | Built | jobs.controller.ts skip |
| DEAD → deleted after 30 days | TTL | system | — | Designed (assumed, not built) | jobs retention decision; no TTL index in jobs.schema.ts |
A DEAD row does not hold later rows of its key. Schedule slots run on the long lane: the tick (every 15 s) enqueues one row per due slot with dedupe_key <code>:<slot>, so two pods ticking at once still make one row.
8. Supporting lifecycles
8a. Inbound Kafka message
| state | meaning | status | code or design |
|---|---|---|---|
kafka_consumed.processing | a pod claimed this message_id (or topic:partition:offset) | Built | kafka.consumer.ts _claim |
kafka_consumed.done | handler succeeded; a redelivery is skipped | Built | consumeBatch |
kafka_consumed.failed | handler returned a permanent reason; the message is copied to kafka_consume_dlq with status pending; a later delivery of the same id may claim it again | Built | consumeBatch |
kafka_consume_dlq.resolved | a later delivery of the same id succeeded | Built | consumeBatch |
| processing older than 5 min | treated as a crashed claim, taken again | Built | CONSUME_LEASE_MS |
intake_message PARKED → REPLAYING → REPLAYED, or DEAD | a message that could not be applied (missing store or channel, waiting behind an earlier message) is parked and replayed in order when the cause is fixed | Designed | intake "How a message is routed", "Data model" |
Code has no replay: a dead-lettered message is applied again only if Kafka delivers the same message_id again.
8b. Attachment
Code src/attachment/attachment.service.ts, attachment.sweep.processor.ts; matches platform design.md "Storage and attachments". The malware scanner is a stub that always answers clean.
9. End-to-end sequences
9.1 Order arrives from OMS → DO (Built, designed additions marked)
9.2 Plan → optimise → generate tasks (optimise Built in-request; generate Designed)
9.3 Driver run → delivered or failed → HOLD → close-out (Designed)
9.4 COD collection and cash handover (Designed)
9.5 DO status change → OMS and staffapp hear about it (Built)
10. Where code and design disagree
| # | record | code (Built) | design | effect |
|---|---|---|---|---|
| 1 | DO | names PENDING, PLANNED, IN_TRANSIT, DELIVERED, FAILED, CANCELLED | BRD v2 names UNASSIGNED, ASSIGNED, DISPATCHED, OUT_FOR_DELIVERY, HOLD, DELIVERED, FAILED_DELIVERY, CANCELLED | the enum must be renamed before execution is built; DISPATCHED and HOLD have no code name |
| 2 | DO | status is a free string; admin PUT and the Kafka payload write any value | every edge guarded (… where status = expected); free status set rejected; corrections only as named reverse edges | any status jump is possible today |
| 3 | DO | a Kafka message for a known delivery_no always updates it in place, including CANCELLED or DELIVERED DOs | routing table: stale skip, cancel refused after a field attempt, redeliver of a final DO as a new DO, DELIVERED_SKIPPED | v1 overwrite behaviour kept in code |
| 4 | DO | plan optimise reads PENDING DOs and never changes DO status or claims them | claim (plan id, ASSIGNED) at generate only | consistent with design for optimise; the claim itself is not built |
| 5 | Plan | optimise runs inside the HTTP request (Google timeout 120 s); states pending, planned, generated | optimise and generate are background jobs; adds optimising, failed, generating, cancelled | a slow Google call holds the request; no failed state (failure leaves the plan as it was) |
| 6 | Plan | generate_task is a stub; nothing ever sets generated | generate creates tasks, claims DOs, plan → generated | plans can be re-planned forever |
| 7 | Plan | writes both a plan_history row and an activity_logs row per save; CREATE / RESET actions declared, never written | the designs differ: planning keeps plan_history; platform moves plan rows into activity_logs only | two history stores for plans until this is settled |
| 8 | Task / stop | task has only task_no; stop has no status | 6 task and 5 stop states | not built |
| 9 | Duty, COD | not built | full state machines | not built |
| 10 | Kafka inbound | dedupe claim + dead-letter row, no replay | intake_message PARKED / DEAD / REPLAYING / REPLAYED with ordered replay | a failed order message waits for Kafka to redeliver it |
| 11 | Outbound emit | emits do_update_oms / do_update_staffapp on every create and on every update that changes status, routed by channel code (OMS: DONJAI, CHOKCHAI, HLH; staffapp: SPO, OD, HLH) | emit only where v1 emits, mapped per v2 edge from PROD kafka_logs | the v1-measured emit list is not yet applied |
| 12 | Job | schedule retry_count 3 is used as the max number of runs (3 runs) | "a failed run retries up to 3 times" (4 runs) | one fewer retry than designed |
| 13 | Job | DEAD rows never expire | TTL 30 days after closed_at (assumed decision) | DEAD rows with full payload grow without bound |