Adds WorkOrderOperation child entity and two new verbs that gate
WorkOrder.complete() behind a strict sequential walk of shop-floor
steps. An empty operations list keeps the v2 behavior exactly; a
non-empty list forces every op to reach COMPLETED before the work
order can finish.
**New domain.**
- `production__work_order_operation` table with
`UNIQUE (work_order_id, line_no)` and a status CHECK constraint
admitting PENDING / IN_PROGRESS / COMPLETED.
- `WorkOrderOperation` @Entity mirroring the `WorkOrderInput` shape:
`lineNo`, `operationCode`, `workCenter`, `standardMinutes`,
`status`, `actualMinutes` (nullable), `startedAt` + `completedAt`
timestamps. No `ext` JSONB — operations are facts, not master
records.
- `WorkOrderOperationStatus` enum (PENDING / IN_PROGRESS / COMPLETED).
- `WorkOrder.operations` collection with the same @OneToMany +
cascade=ALL + orphanRemoval + @OrderBy("lineNo ASC") pattern as
`inputs`.
**State machine (sequential).**
- `startOperation(workOrderId, operationId)` — parent WO must be
IN_PROGRESS; target op must be PENDING; every earlier op must be
COMPLETED. Flips to IN_PROGRESS and stamps `startedAt`.
Idempotent no-op if already IN_PROGRESS.
- `completeOperation(workOrderId, operationId, actualMinutes)` —
parent WO must be IN_PROGRESS; target op must be IN_PROGRESS;
`actualMinutes` must be non-negative. Flips to COMPLETED and
stamps `completedAt`. Idempotent with the same `actualMinutes`;
refuses to clobber with a different value.
- `WorkOrder.complete()` gains a routings gate: refuses if any
operation is not COMPLETED. Empty operations list is legal and
preserves v2 behavior (auto-spawned orders from
`SalesOrderConfirmedSubscriber` continue to complete without
any gate).
**Why sequential, not parallel.** v3 deliberately forbids parallel
operations on one routing. The shop-floor dashboard story is
trivial when the invariant is "you are on step N of M"; the unit
test matrix is finite. Parallel routings (two presses in parallel)
wait for a real consumer asking for them. Same pattern as every
other pbc-production invariant — grow the PBC when consumers
appear, not on speculation.
**Why standardMinutes + actualMinutes instead of just timestamps.**
The variance between planned and actual runtime is the single
most interesting data point on a routing. Deriving it from
`completedAt - startedAt` at report time has to fight
shift-boundary and pause-resume ambiguity; the operator typing in
"this run took 47 minutes" is the single source of truth. `startedAt`
and `completedAt` are kept as an audit trail, not used for
variance math.
**Why work_center is a varchar not a FK.** Same cross-PBC discipline
as every other identifier in pbc-production: work centers will be
the seam for a future pbc-equipment PBC, and pinning a FK now
would couple two PBC schemas before the consumer even exists
(CLAUDE.md guardrail #9).
**HTTP surface.**
- `POST /api/v1/production/work-orders/{id}/operations/{operationId}/start`
→ `production.work-order.operation.start`
- `POST /api/v1/production/work-orders/{id}/operations/{operationId}/complete`
→ `production.work-order.operation.complete`
Body: `{"actualMinutes": "..."}`. Annotated with the
single-arg Jackson trap escape hatch (`@JsonCreator(mode=PROPERTIES)`
+ `@param:JsonProperty`) — same trap that bit
`CompleteWorkOrderRequest`, `ShipSalesOrderRequest`,
`ReceivePurchaseOrderRequest`. Caught at smoke-test time.
- `CreateWorkOrderRequest` accepts an optional `operations` array
alongside `inputs`.
- `WorkOrderResponse` gains `operations: List<WorkOrderOperationResponse>`
showing status, standardMinutes, actualMinutes, startedAt,
completedAt.
**Metadata.** Two new permissions in `production.yml`:
`production.work-order.operation.start` and
`production.work-order.operation.complete`.
**Tests (12 new).** create-with-ops happy path; duplicate line_no
refused; blank operationCode refused; complete() gated when any
op is not COMPLETED; complete() passes when every op is COMPLETED;
startOperation refused on DRAFT parent; startOperation flips
PENDING to IN_PROGRESS and stamps startedAt; startOperation
refuses skip-ahead over a PENDING predecessor; startOperation is
idempotent when already IN_PROGRESS; completeOperation records
actualMinutes and flips to COMPLETED; completeOperation rejects
negative actualMinutes; completeOperation refuses clobbering an
already-COMPLETED op with a different value.
**Smoke-tested end-to-end against real Postgres:**
- Created a WO with 3 operations (CUT → PRINT → BIND)
- `complete()` refused while DRAFT, then refused while IN_PROGRESS
with pending ops ("3 routing operation(s) are not yet COMPLETED")
- Skip-ahead `startOperation(op2)` refused ("earlier operation(s)
are not yet COMPLETED")
- Walked ops 1 → 2 → 3 through start + complete with varying
actualMinutes (17, 32.5, 18 vs standard 15, 30, 20)
- Final `complete()` succeeded, wrote exactly ONE
PRODUCTION_RECEIPT ledger row for 100 units of FG-BROCHURE —
no premature writes
- Separately verified a no-operations WO still walks DRAFT →
IN_PROGRESS → COMPLETED exactly like v2
24 modules, 349 unit tests (+12), all green.