-
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.