• 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.
    zichun authored
     
    Browse Code »