Commit fa867189d1d1fe4f400966fd7725dbced25ecc21

Authored by zichun
1 parent e4f7cf42

feat(production): P5.7 v3 — routings & operations with sequential walk

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.
distribution/src/main/resources/db/changelog/master.xml
... ... @@ -25,6 +25,7 @@
25 25 <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/>
26 26 <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/>
27 27 <include file="classpath:db/changelog/pbc-production/002-production-v2.xml"/>
  28 + <include file="classpath:db/changelog/pbc-production/003-production-v3.xml"/>
28 29 <include file="classpath:db/changelog/pbc-quality/001-quality-init.xml"/>
29 30 <include file="classpath:db/changelog/pbc-quality/002-quality-quarantine-locations.xml"/>
30 31 </databaseChangeLog>
... ...
distribution/src/main/resources/db/changelog/pbc-production/003-production-v3.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  5 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  6 +
  7 + <!--
  8 + pbc-production v3 schema additions — routings & operations.
  9 +
  10 + Creates production__work_order_operation — each row is one
  11 + step on a work order's routing. An empty list is legal and
  12 + preserves the v2 behavior exactly (no gating on complete).
  13 + A non-empty list gates WorkOrder.complete() behind every
  14 + operation's COMPLETED status and enforces a strict sequential
  15 + walk of started-then-completed.
  16 +
  17 + Per-operation state machine:
  18 + PENDING — created but not yet started
  19 + IN_PROGRESS — the current operation (at most ONE per WO)
  20 + COMPLETED — done; predecessor for the next operation to start
  21 +
  22 + Sequential rule, enforced in WorkOrderService:
  23 + - startOperation(op N) requires parent WO = IN_PROGRESS,
  24 + op N = PENDING,
  25 + op [1..N-1] all COMPLETED.
  26 + - completeOperation(op N) requires parent WO = IN_PROGRESS
  27 + and op N = IN_PROGRESS. Writes
  28 + actual_minutes + completed_at.
  29 + - WorkOrder.complete() refuses if ANY op is not COMPLETED.
  30 +
  31 + NOT MODELLED at v3:
  32 + - Parallel operations. v3 enforces a strict linear chain so
  33 + the shop floor walks one step at a time. Parallel routings
  34 + (two presses running in parallel) wait for a real consumer.
  35 + - Capacity / scheduling. work_center is just a free-form
  36 + label; v3 does not reserve anything.
  37 + - Operator assignment / labor logging. actual_minutes is the
  38 + only time value stored on completion; who did the work
  39 + is inferred from the standard audit columns.
  40 + - Machine FK. work_center is a varchar label, not a FK to
  41 + a future pbc-equipment table. Same rationale as every
  42 + other cross-PBC identifier in pbc-production: refs are
  43 + application-enforced, not DB-enforced (CLAUDE.md #9).
  44 + -->
  45 +
  46 + <changeSet id="production-v3-001-work-order-operation" author="vibe_erp">
  47 + <comment>Create production__work_order_operation (routing step child table)</comment>
  48 + <sql>
  49 + CREATE TABLE production__work_order_operation (
  50 + id uuid PRIMARY KEY,
  51 + work_order_id uuid NOT NULL
  52 + REFERENCES production__work_order(id) ON DELETE CASCADE,
  53 + line_no integer NOT NULL,
  54 + operation_code varchar(64) NOT NULL,
  55 + work_center varchar(64) NOT NULL,
  56 + standard_minutes numeric(10,2) NOT NULL,
  57 + status varchar(16) NOT NULL,
  58 + actual_minutes numeric(10,2),
  59 + started_at timestamptz,
  60 + completed_at timestamptz,
  61 + created_at timestamptz NOT NULL,
  62 + created_by varchar(128) NOT NULL,
  63 + updated_at timestamptz NOT NULL,
  64 + updated_by varchar(128) NOT NULL,
  65 + version bigint NOT NULL DEFAULT 0,
  66 + CONSTRAINT production__work_order_operation_status_check
  67 + CHECK (status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED')),
  68 + CONSTRAINT production__work_order_operation_line_no_pos
  69 + CHECK (line_no &gt; 0),
  70 + CONSTRAINT production__work_order_operation_std_min_pos
  71 + CHECK (standard_minutes &gt;= 0),
  72 + CONSTRAINT production__work_order_operation_act_min_pos
  73 + CHECK (actual_minutes IS NULL OR actual_minutes &gt;= 0),
  74 + CONSTRAINT production__work_order_operation_line_uk
  75 + UNIQUE (work_order_id, line_no)
  76 + );
  77 + CREATE INDEX production__work_order_operation_wo_idx
  78 + ON production__work_order_operation (work_order_id);
  79 + CREATE INDEX production__work_order_operation_status_idx
  80 + ON production__work_order_operation (status);
  81 + CREATE INDEX production__work_order_operation_work_center_idx
  82 + ON production__work_order_operation (work_center);
  83 + </sql>
  84 + <rollback>
  85 + DROP TABLE production__work_order_operation;
  86 + </rollback>
  87 + </changeSet>
  88 +
  89 +</databaseChangeLog>
... ...
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt
... ... @@ -13,11 +13,14 @@ import org.vibeerp.api.v1.ext.catalog.CatalogApi
13 13 import org.vibeerp.api.v1.ext.inventory.InventoryApi
14 14 import org.vibeerp.pbc.production.domain.WorkOrder
15 15 import org.vibeerp.pbc.production.domain.WorkOrderInput
  16 +import org.vibeerp.pbc.production.domain.WorkOrderOperation
  17 +import org.vibeerp.pbc.production.domain.WorkOrderOperationStatus
16 18 import org.vibeerp.pbc.production.domain.WorkOrderStatus
17 19 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
18 20 import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
19 21 import java.math.BigDecimal
20 22 import java.time.LocalDate
  23 +import java.time.OffsetDateTime
21 24 import java.util.UUID
22 25  
23 26 /**
... ... @@ -137,6 +140,29 @@ class WorkOrderService(
137 140 )
138 141 }
139 142  
  143 + // v3: validate every routing operation up-front. Same
  144 + // discipline as the BOM lines — catch bad line_nos and
  145 + // empty labels now, not halfway through the shop-floor walk.
  146 + val seenOpLineNos = HashSet<Int>(command.operations.size)
  147 + for (op in command.operations) {
  148 + require(op.lineNo > 0) {
  149 + "work order operation line_no must be positive (got ${op.lineNo})"
  150 + }
  151 + require(seenOpLineNos.add(op.lineNo)) {
  152 + "work order operation line_no ${op.lineNo} is duplicated"
  153 + }
  154 + require(op.operationCode.isNotBlank()) {
  155 + "work order operation line ${op.lineNo} operation_code must not be blank"
  156 + }
  157 + require(op.workCenter.isNotBlank()) {
  158 + "work order operation line ${op.lineNo} work_center must not be blank"
  159 + }
  160 + require(op.standardMinutes.signum() >= 0) {
  161 + "work order operation line ${op.lineNo} standard_minutes must be non-negative " +
  162 + "(got ${op.standardMinutes})"
  163 + }
  164 + }
  165 +
140 166 val order = WorkOrder(
141 167 code = command.code,
142 168 outputItemCode = command.outputItemCode,
... ... @@ -162,6 +188,22 @@ class WorkOrderService(
162 188 ),
163 189 )
164 190 }
  191 + // v3: attach routing operations — same cascade-from-parent
  192 + // pattern. All operations start in PENDING; startOperation
  193 + // + completeOperation drive them through IN_PROGRESS →
  194 + // COMPLETED in lineNo order.
  195 + for (op in command.operations) {
  196 + order.operations.add(
  197 + WorkOrderOperation(
  198 + workOrder = order,
  199 + lineNo = op.lineNo,
  200 + operationCode = op.operationCode,
  201 + workCenter = op.workCenter,
  202 + standardMinutes = op.standardMinutes,
  203 + status = WorkOrderOperationStatus.PENDING,
  204 + ),
  205 + )
  206 + }
165 207 val saved = orders.save(order)
166 208  
167 209 eventBus.publish(
... ... @@ -242,6 +284,23 @@ class WorkOrderService(
242 284 "only IN_PROGRESS can be completed"
243 285 }
244 286  
  287 + // v3 routings gate: if the work order has any operations at
  288 + // all, EVERY one must be COMPLETED before the header can
  289 + // complete. An empty operations list is legal and preserves
  290 + // the v2 behavior exactly (auto-spawned orders from
  291 + // SalesOrderConfirmedSubscriber have no operations and
  292 + // complete as before). This is the only call site that
  293 + // needs the gate — startOperation and completeOperation
  294 + // enforce the per-op state machine themselves.
  295 + val pendingOps = order.operations.filter {
  296 + it.status != WorkOrderOperationStatus.COMPLETED
  297 + }
  298 + require(pendingOps.isEmpty()) {
  299 + "cannot complete work order ${order.code}: " +
  300 + "${pendingOps.size} routing operation(s) are not yet COMPLETED " +
  301 + "(${pendingOps.joinToString { "line ${it.lineNo}=${it.status}" }})"
  302 + }
  303 +
245 304 // Issue every BOM input FIRST. Doing the materials before the
246 305 // output means a bad BOM (missing item, insufficient stock)
247 306 // fails the call before any PRODUCTION_RECEIPT is written —
... ... @@ -342,6 +401,125 @@ class WorkOrderService(
342 401 return order
343 402 }
344 403  
  404 + /**
  405 + * Start one routing operation on an IN_PROGRESS work order.
  406 + *
  407 + * **v3 sequential rule** (enforced here, not in SQL):
  408 + * - The parent work order must be IN_PROGRESS. A DRAFT work
  409 + * order hasn't started production; a COMPLETED or CANCELLED
  410 + * one is terminal.
  411 + * - The target operation must be in PENDING state.
  412 + * - Every operation with a smaller `lineNo` than the target
  413 + * must already be COMPLETED. This forces a strict linear
  414 + * walk of the routing; parallel operations are a future
  415 + * chunk when a real consumer asks for them.
  416 + *
  417 + * Stamps [WorkOrderOperation.startedAt] with the caller's
  418 + * wall clock (via [OffsetDateTime.now]); a later
  419 + * [completeOperation] call fills in `completedAt` + the
  420 + * operator-entered `actualMinutes`. No ledger row is written
  421 + * here — operations are about time and flow, not inventory.
  422 + *
  423 + * Idempotency: if the operation is already IN_PROGRESS and the
  424 + * parent work order is still IN_PROGRESS, the call is a no-op
  425 + * and returns the existing operation. Retrying the same
  426 + * `startOperation` on network failure is therefore safe. It
  427 + * does NOT grant "restart after complete"; a COMPLETED
  428 + * operation is terminal.
  429 + */
  430 + fun startOperation(workOrderId: UUID, operationId: UUID): WorkOrderOperation {
  431 + val order = orders.findById(workOrderId).orElseThrow {
  432 + NoSuchElementException("work order not found: $workOrderId")
  433 + }
  434 + require(order.status == WorkOrderStatus.IN_PROGRESS) {
  435 + "cannot start operation on work order ${order.code} in status ${order.status}; " +
  436 + "only IN_PROGRESS work orders can progress their operations"
  437 + }
  438 + val op = order.operations.firstOrNull { it.id == operationId }
  439 + ?: throw NoSuchElementException(
  440 + "operation $operationId not found on work order ${order.code}",
  441 + )
  442 + // Idempotent short-circuit for retries.
  443 + if (op.status == WorkOrderOperationStatus.IN_PROGRESS) {
  444 + return op
  445 + }
  446 + require(op.status == WorkOrderOperationStatus.PENDING) {
  447 + "cannot start operation line ${op.lineNo} of work order ${order.code} " +
  448 + "in status ${op.status}; only PENDING operations can be started"
  449 + }
  450 + // Sequential walk: every earlier operation must be done.
  451 + val unfinishedPredecessors = order.operations.filter {
  452 + it.lineNo < op.lineNo && it.status != WorkOrderOperationStatus.COMPLETED
  453 + }
  454 + require(unfinishedPredecessors.isEmpty()) {
  455 + "cannot start operation line ${op.lineNo} of work order ${order.code}: " +
  456 + "${unfinishedPredecessors.size} earlier operation(s) are not yet COMPLETED " +
  457 + "(${unfinishedPredecessors.joinToString { "line ${it.lineNo}=${it.status}" }})"
  458 + }
  459 +
  460 + op.status = WorkOrderOperationStatus.IN_PROGRESS
  461 + op.startedAt = OffsetDateTime.now()
  462 + return op
  463 + }
  464 +
  465 + /**
  466 + * Mark an IN_PROGRESS operation as COMPLETED. The parent work
  467 + * order must still be IN_PROGRESS (a completed or cancelled
  468 + * work order is terminal; v3 doesn't support re-opening one
  469 + * to finish a lagging operation — fix the data, don't hack
  470 + * the state machine).
  471 + *
  472 + * [actualMinutes] is the operator-entered time the step really
  473 + * took. Must be non-negative. v3 does NOT derive it from
  474 + * (completedAt - startedAt) because shift boundaries, pauses,
  475 + * and lunch breaks all make that subtraction unreliable; the
  476 + * operator typing in "yes this run took 47 minutes" is the
  477 + * single source of truth.
  478 + *
  479 + * Idempotency: if the operation is already COMPLETED with the
  480 + * same `actualMinutes`, the call is a no-op. If it's COMPLETED
  481 + * with a DIFFERENT value, the call refuses rather than
  482 + * silently clobbering the earlier recorded time. Use a future
  483 + * "correct operation" gesture when one is needed.
  484 + */
  485 + fun completeOperation(
  486 + workOrderId: UUID,
  487 + operationId: UUID,
  488 + actualMinutes: BigDecimal,
  489 + ): WorkOrderOperation {
  490 + require(actualMinutes.signum() >= 0) {
  491 + "actual_minutes must be non-negative (got $actualMinutes)"
  492 + }
  493 + val order = orders.findById(workOrderId).orElseThrow {
  494 + NoSuchElementException("work order not found: $workOrderId")
  495 + }
  496 + require(order.status == WorkOrderStatus.IN_PROGRESS) {
  497 + "cannot complete operation on work order ${order.code} in status ${order.status}; " +
  498 + "only IN_PROGRESS work orders can progress their operations"
  499 + }
  500 + val op = order.operations.firstOrNull { it.id == operationId }
  501 + ?: throw NoSuchElementException(
  502 + "operation $operationId not found on work order ${order.code}",
  503 + )
  504 + // Idempotent short-circuit for retries.
  505 + if (op.status == WorkOrderOperationStatus.COMPLETED) {
  506 + require(op.actualMinutes == actualMinutes) {
  507 + "operation line ${op.lineNo} of work order ${order.code} is already COMPLETED " +
  508 + "with actual_minutes=${op.actualMinutes}; refusing to overwrite with $actualMinutes"
  509 + }
  510 + return op
  511 + }
  512 + require(op.status == WorkOrderOperationStatus.IN_PROGRESS) {
  513 + "cannot complete operation line ${op.lineNo} of work order ${order.code} " +
  514 + "in status ${op.status}; only IN_PROGRESS operations can be completed"
  515 + }
  516 +
  517 + op.status = WorkOrderOperationStatus.COMPLETED
  518 + op.actualMinutes = actualMinutes
  519 + op.completedAt = OffsetDateTime.now()
  520 + return op
  521 + }
  522 +
345 523 fun cancel(id: UUID): WorkOrder {
346 524 val order = orders.findById(id).orElseThrow {
347 525 NoSuchElementException("work order not found: $id")
... ... @@ -385,6 +563,14 @@ data class CreateWorkOrderCommand(
385 563 */
386 564 val inputs: List<WorkOrderInputCommand> = emptyList(),
387 565 /**
  566 + * v3 routing operations — zero or more shop-floor steps that
  567 + * must be walked between start() and complete(). Empty list is
  568 + * legal (produces the v2 behavior: complete() has no gate
  569 + * beyond the BOM materials). A non-empty list forces a strict
  570 + * sequential walk through the operations' state machines.
  571 + */
  572 + val operations: List<WorkOrderOperationCommand> = emptyList(),
  573 + /**
388 574 * Tier 1 custom-field values. Validated against declarations
389 575 * under entity name `WorkOrder` in `metadata__custom_field` via
390 576 * [ExtJsonValidator.applyTo]. Null is legal and produces an
... ... @@ -403,3 +589,19 @@ data class WorkOrderInputCommand(
403 589 val quantityPerUnit: BigDecimal,
404 590 val sourceLocationCode: String,
405 591 )
  592 +
  593 +/**
  594 + * One routing operation on a work order create command.
  595 + *
  596 + * `standardMinutes` is the planned time per run of this step — NOT
  597 + * per unit of output. A 100-brochure cut step that's supposed to
  598 + * take 30 minutes passes `standardMinutes = 30`, not 0.3; the
  599 + * v3 model doesn't scale operation time with output quantity
  600 + * because shop-floor data rarely has that linearity anyway.
  601 + */
  602 +data class WorkOrderOperationCommand(
  603 + val lineNo: Int,
  604 + val operationCode: String,
  605 + val workCenter: String,
  606 + val standardMinutes: BigDecimal,
  607 +)
... ...
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt
... ... @@ -151,8 +151,30 @@ class WorkOrder(
151 151 @OrderBy("lineNo ASC")
152 152 var inputs: MutableList<WorkOrderInput> = mutableListOf()
153 153  
  154 + /**
  155 + * v3 routings — zero or more shop-floor operations that must be
  156 + * walked between `start()` and `complete()`. An empty list is a
  157 + * legal v3 state and preserves the v2 behavior exactly (no gate
  158 + * on complete). A non-empty list gates [complete] behind every
  159 + * operation reaching [WorkOrderOperationStatus.COMPLETED] and
  160 + * forces a strict sequential start (see [WorkOrderOperation]).
  161 + *
  162 + * Same fetch/cascade discipline as [inputs] — eager because
  163 + * reads almost always follow reads of the header, and the
  164 + * @OrderBy gives handlers a deterministic walk.
  165 + */
  166 + @OneToMany(
  167 + mappedBy = "workOrder",
  168 + cascade = [CascadeType.ALL],
  169 + orphanRemoval = true,
  170 + fetch = FetchType.EAGER,
  171 + )
  172 + @OrderBy("lineNo ASC")
  173 + var operations: MutableList<WorkOrderOperation> = mutableListOf()
  174 +
154 175 override fun toString(): String =
155   - "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', status=$status, inputs=${inputs.size})"
  176 + "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', " +
  177 + "status=$status, inputs=${inputs.size}, operations=${operations.size})"
156 178  
157 179 companion object {
158 180 /** Key under which WorkOrder's custom fields are declared in `metadata__custom_field`. */
... ...
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrderOperation.kt 0 → 100644
  1 +package org.vibeerp.pbc.production.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.EnumType
  6 +import jakarta.persistence.Enumerated
  7 +import jakarta.persistence.JoinColumn
  8 +import jakarta.persistence.ManyToOne
  9 +import jakarta.persistence.Table
  10 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  11 +import java.math.BigDecimal
  12 +import java.time.OffsetDateTime
  13 +
  14 +/**
  15 + * One step on a [WorkOrder]'s routing — a single unit of shop-floor
  16 + * work (cut, print, fold, bind, pack…) that must happen between the
  17 + * order being started and the order being completed.
  18 + *
  19 + * **Why v3 adds this.** v2 treated a work order as a single atomic
  20 + * "start → complete" event. Real shops want to know which step the
  21 + * job is ON right now, how long each step took, and which work
  22 + * center is running it. A [WorkOrderOperation] carries exactly that:
  23 + * a label ([operationCode]), where it runs ([workCenter]), how long
  24 + * it's supposed to take ([standardMinutes]), and how long it
  25 + * actually did ([actualMinutes], filled in on completion).
  26 + *
  27 + * **Per-operation state machine:**
  28 + * - **PENDING** — created with the work order, not yet started
  29 + * - **IN_PROGRESS** — currently running. At most ONE operation per
  30 + * work order is IN_PROGRESS at any time — v3
  31 + * enforces strict sequential execution.
  32 + * - **COMPLETED** — terminal. Predecessor for the next PENDING
  33 + * operation to start.
  34 + *
  35 + * **Sequential enforcement.** [WorkOrderService.startOperation]
  36 + * refuses to start op N if any op in [1..N-1] is not COMPLETED.
  37 + * This deliberately forbids parallel operations in v3 — a real
  38 + * shop with a multi-press routing that can run two steps in
  39 + * parallel is a future-chunk concern. Keeping the v3 invariant to
  40 + * "one step at a time in lineNo order" makes the on-call dashboard
  41 + * story trivial ("you are on step 3 of 5") and the test matrix
  42 + * finite.
  43 + *
  44 + * **Gate on the parent work order.** [WorkOrderService.complete]
  45 + * refuses if ANY operation is not COMPLETED. An empty operations
  46 + * list is legal and preserves the v2 behavior exactly (auto-spawned
  47 + * work orders from SalesOrderConfirmedSubscriber still ship with
  48 + * zero operations and complete as before).
  49 + *
  50 + * **Why [workCenter] is a free-form varchar, not a FK:** same
  51 + * cross-PBC rationale that governs [itemCode] and
  52 + * [sourceLocationCode] on [WorkOrderInput] — work centers are the
  53 + * seam for a future pbc-equipment PBC, and pinning a FK now would
  54 + * couple two PBCs' schemas before the consumer even exists
  55 + * (CLAUDE.md guardrail #9).
  56 + *
  57 + * **Why [standardMinutes] + [actualMinutes] instead of start/end
  58 + * timestamps alone:** the variance between "how long this step is
  59 + * supposed to take" and "how long it actually took" is the single
  60 + * most interesting data point on a routing, and a report that has
  61 + * to derive it from `completed_at - started_at` (with all the
  62 + * shift-boundary and pause/resume ambiguity that implies) is much
  63 + * less accurate than a value the operator enters on completion.
  64 + * v3 stores both; [startedAt] and [completedAt] are kept as an
  65 + * audit trail but not used for variance math.
  66 + *
  67 + * **No `ext` JSONB on operations** — same rationale as
  68 + * [WorkOrderInput]: operations are facts on a work order, not
  69 + * master records that the business would want to extend with
  70 + * Tier 1 custom fields. Custom fields on the parent work order
  71 + * still flow through via [WorkOrder.ext].
  72 + */
  73 +@Entity
  74 +@Table(name = "production__work_order_operation")
  75 +class WorkOrderOperation(
  76 + workOrder: WorkOrder,
  77 + lineNo: Int,
  78 + operationCode: String,
  79 + workCenter: String,
  80 + standardMinutes: BigDecimal,
  81 + status: WorkOrderOperationStatus = WorkOrderOperationStatus.PENDING,
  82 + actualMinutes: BigDecimal? = null,
  83 + startedAt: OffsetDateTime? = null,
  84 + completedAt: OffsetDateTime? = null,
  85 +) : AuditedJpaEntity() {
  86 +
  87 + @ManyToOne
  88 + @JoinColumn(name = "work_order_id", nullable = false)
  89 + var workOrder: WorkOrder = workOrder
  90 +
  91 + @Column(name = "line_no", nullable = false)
  92 + var lineNo: Int = lineNo
  93 +
  94 + @Column(name = "operation_code", nullable = false, length = 64)
  95 + var operationCode: String = operationCode
  96 +
  97 + @Column(name = "work_center", nullable = false, length = 64)
  98 + var workCenter: String = workCenter
  99 +
  100 + @Column(name = "standard_minutes", nullable = false, precision = 10, scale = 2)
  101 + var standardMinutes: BigDecimal = standardMinutes
  102 +
  103 + @Enumerated(EnumType.STRING)
  104 + @Column(name = "status", nullable = false, length = 16)
  105 + var status: WorkOrderOperationStatus = status
  106 +
  107 + @Column(name = "actual_minutes", nullable = true, precision = 10, scale = 2)
  108 + var actualMinutes: BigDecimal? = actualMinutes
  109 +
  110 + @Column(name = "started_at", nullable = true)
  111 + var startedAt: OffsetDateTime? = startedAt
  112 +
  113 + @Column(name = "completed_at", nullable = true)
  114 + var completedAt: OffsetDateTime? = completedAt
  115 +
  116 + override fun toString(): String =
  117 + "WorkOrderOperation(id=$id, woId=${workOrder.id}, line=$lineNo, " +
  118 + "op='$operationCode', center='$workCenter', status=$status)"
  119 +}
  120 +
  121 +/**
  122 + * State machine values for [WorkOrderOperation]. See the entity
  123 + * KDoc for the allowed transitions and the rationale for each one.
  124 + */
  125 +enum class WorkOrderOperationStatus {
  126 + PENDING,
  127 + IN_PROGRESS,
  128 + COMPLETED,
  129 +}
... ...
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt
... ... @@ -17,13 +17,17 @@ import org.springframework.web.bind.annotation.ResponseStatus
17 17 import org.springframework.web.bind.annotation.RestController
18 18 import org.vibeerp.pbc.production.application.CreateWorkOrderCommand
19 19 import org.vibeerp.pbc.production.application.WorkOrderInputCommand
  20 +import org.vibeerp.pbc.production.application.WorkOrderOperationCommand
20 21 import org.vibeerp.pbc.production.application.WorkOrderService
21 22 import org.vibeerp.pbc.production.domain.WorkOrder
22 23 import org.vibeerp.pbc.production.domain.WorkOrderInput
  24 +import org.vibeerp.pbc.production.domain.WorkOrderOperation
  25 +import org.vibeerp.pbc.production.domain.WorkOrderOperationStatus
23 26 import org.vibeerp.pbc.production.domain.WorkOrderStatus
24 27 import org.vibeerp.platform.security.authz.RequirePermission
25 28 import java.math.BigDecimal
26 29 import java.time.LocalDate
  30 +import java.time.OffsetDateTime
27 31 import java.util.UUID
28 32  
29 33 /**
... ... @@ -113,6 +117,38 @@ class WorkOrderController(
113 117 quantity = request.quantity,
114 118 note = request.note,
115 119 ).toResponse(workOrderService)
  120 +
  121 + /**
  122 + * Start one routing operation — flip from PENDING to
  123 + * IN_PROGRESS. Parent work order must be IN_PROGRESS; earlier
  124 + * operations must all be COMPLETED. See
  125 + * [WorkOrderService.startOperation] for the sequential rule.
  126 + */
  127 + @PostMapping("/{id}/operations/{operationId}/start")
  128 + @RequirePermission("production.work-order.operation.start")
  129 + fun startOperation(
  130 + @PathVariable id: UUID,
  131 + @PathVariable operationId: UUID,
  132 + ): WorkOrderOperationResponse =
  133 + workOrderService.startOperation(id, operationId).toResponse()
  134 +
  135 + /**
  136 + * Mark a routing operation as COMPLETED. Requires
  137 + * `actualMinutes` — the operator-entered real runtime of the
  138 + * step. See [WorkOrderService.completeOperation].
  139 + */
  140 + @PostMapping("/{id}/operations/{operationId}/complete")
  141 + @RequirePermission("production.work-order.operation.complete")
  142 + fun completeOperation(
  143 + @PathVariable id: UUID,
  144 + @PathVariable operationId: UUID,
  145 + @RequestBody @Valid request: CompleteWorkOrderOperationRequest,
  146 + ): WorkOrderOperationResponse =
  147 + workOrderService.completeOperation(
  148 + workOrderId = id,
  149 + operationId = operationId,
  150 + actualMinutes = request.actualMinutes,
  151 + ).toResponse()
116 152 }
117 153  
118 154 // ─── DTOs ────────────────────────────────────────────────────────────
... ... @@ -130,6 +166,12 @@ data class CreateWorkOrderRequest(
130 166 */
131 167 @field:Valid val inputs: List<WorkOrderInputRequest> = emptyList(),
132 168 /**
  169 + * v3 routing operations. Empty list is legal — the work order
  170 + * completes without a sequential walk. A non-empty list gates
  171 + * complete() behind every operation being COMPLETED.
  172 + */
  173 + @field:Valid val operations: List<WorkOrderOperationRequest> = emptyList(),
  174 + /**
133 175 * Tier 1 custom-field values. Validated against declarations
134 176 * under entity name `WorkOrder`.
135 177 */
... ... @@ -142,6 +184,7 @@ data class CreateWorkOrderRequest(
142 184 dueDate = dueDate,
143 185 sourceSalesOrderCode = sourceSalesOrderCode,
144 186 inputs = inputs.map { it.toCommand() },
  187 + operations = operations.map { it.toCommand() },
145 188 ext = ext,
146 189 )
147 190 }
... ... @@ -160,6 +203,35 @@ data class WorkOrderInputRequest(
160 203 )
161 204 }
162 205  
  206 +data class WorkOrderOperationRequest(
  207 + @field:NotNull val lineNo: Int,
  208 + @field:NotBlank @field:Size(max = 64) val operationCode: String,
  209 + @field:NotBlank @field:Size(max = 64) val workCenter: String,
  210 + @field:NotNull val standardMinutes: BigDecimal,
  211 +) {
  212 + fun toCommand(): WorkOrderOperationCommand = WorkOrderOperationCommand(
  213 + lineNo = lineNo,
  214 + operationCode = operationCode,
  215 + workCenter = workCenter,
  216 + standardMinutes = standardMinutes,
  217 + )
  218 +}
  219 +
  220 +/**
  221 + * Complete-operation request body.
  222 + *
  223 + * **Single-arg Kotlin data class — same Jackson trap that bit
  224 + * [CompleteWorkOrderRequest] and the ship/receive order request
  225 + * bodies.** jackson-module-kotlin treats a one-arg data class as a
  226 + * delegate-based creator and unwraps the body, which is wrong for
  227 + * an HTTP payload that always looks like `{"actualMinutes": "..."}`.
  228 + * Fix: explicit `@JsonCreator(mode = PROPERTIES)` + `@param:JsonProperty`.
  229 + */
  230 +data class CompleteWorkOrderOperationRequest @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) constructor(
  231 + @param:JsonProperty("actualMinutes")
  232 + @field:NotNull val actualMinutes: BigDecimal,
  233 +)
  234 +
163 235 /**
164 236 * Completion request body.
165 237 *
... ... @@ -196,6 +268,7 @@ data class WorkOrderResponse(
196 268 val dueDate: LocalDate?,
197 269 val sourceSalesOrderCode: String?,
198 270 val inputs: List<WorkOrderInputResponse>,
  271 + val operations: List<WorkOrderOperationResponse>,
199 272 val ext: Map<String, Any?>,
200 273 )
201 274  
... ... @@ -207,6 +280,18 @@ data class WorkOrderInputResponse(
207 280 val sourceLocationCode: String,
208 281 )
209 282  
  283 +data class WorkOrderOperationResponse(
  284 + val id: UUID,
  285 + val lineNo: Int,
  286 + val operationCode: String,
  287 + val workCenter: String,
  288 + val standardMinutes: BigDecimal,
  289 + val status: WorkOrderOperationStatus,
  290 + val actualMinutes: BigDecimal?,
  291 + val startedAt: OffsetDateTime?,
  292 + val completedAt: OffsetDateTime?,
  293 +)
  294 +
210 295 private fun WorkOrder.toResponse(service: WorkOrderService): WorkOrderResponse =
211 296 WorkOrderResponse(
212 297 id = id,
... ... @@ -217,6 +302,7 @@ private fun WorkOrder.toResponse(service: WorkOrderService): WorkOrderResponse =
217 302 dueDate = dueDate,
218 303 sourceSalesOrderCode = sourceSalesOrderCode,
219 304 inputs = inputs.map { it.toResponse() },
  305 + operations = operations.map { it.toResponse() },
220 306 ext = service.parseExt(this),
221 307 )
222 308  
... ... @@ -228,3 +314,16 @@ private fun WorkOrderInput.toResponse(): WorkOrderInputResponse =
228 314 quantityPerUnit = quantityPerUnit,
229 315 sourceLocationCode = sourceLocationCode,
230 316 )
  317 +
  318 +private fun WorkOrderOperation.toResponse(): WorkOrderOperationResponse =
  319 + WorkOrderOperationResponse(
  320 + id = id,
  321 + lineNo = lineNo,
  322 + operationCode = operationCode,
  323 + workCenter = workCenter,
  324 + standardMinutes = standardMinutes,
  325 + status = status,
  326 + actualMinutes = actualMinutes,
  327 + startedAt = startedAt,
  328 + completedAt = completedAt,
  329 + )
... ...
pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml
... ... @@ -22,6 +22,10 @@ permissions:
22 22 description: Cancel a work order (DRAFT or IN_PROGRESS → CANCELLED)
23 23 - key: production.work-order.scrap
24 24 description: Scrap some output from a COMPLETED work order (writes a negative ADJUSTMENT, status unchanged)
  25 + - key: production.work-order.operation.start
  26 + description: Start a routing operation (PENDING → IN_PROGRESS). Parent work order must be IN_PROGRESS and the operation must come next in sequence.
  27 + - key: production.work-order.operation.complete
  28 + description: Complete a routing operation (IN_PROGRESS → COMPLETED). Records the operator-entered actual_minutes.
25 29  
26 30 customFields:
27 31 - key: production_priority
... ...
pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt
... ... @@ -6,6 +6,7 @@ import assertk.assertions.hasMessage
6 6 import assertk.assertions.hasSize
7 7 import assertk.assertions.isEqualTo
8 8 import assertk.assertions.isInstanceOf
  9 +import assertk.assertions.isNotNull
9 10 import assertk.assertions.messageContains
10 11 import io.mockk.Runs
11 12 import io.mockk.every
... ... @@ -29,6 +30,8 @@ import org.vibeerp.api.v1.ext.inventory.InventoryApi
29 30 import org.vibeerp.api.v1.ext.inventory.StockBalanceRef
30 31 import org.vibeerp.pbc.production.domain.WorkOrder
31 32 import org.vibeerp.pbc.production.domain.WorkOrderInput
  33 +import org.vibeerp.pbc.production.domain.WorkOrderOperation
  34 +import org.vibeerp.pbc.production.domain.WorkOrderOperationStatus
32 35 import org.vibeerp.pbc.production.domain.WorkOrderStatus
33 36 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
34 37 import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
... ... @@ -555,4 +558,255 @@ class WorkOrderServiceTest {
555 558 .isInstanceOf(IllegalArgumentException::class)
556 559 .messageContains("only DRAFT or IN_PROGRESS")
557 560 }
  561 +
  562 + // ─── v3 routings & operations ────────────────────────────────────
  563 +
  564 + /**
  565 + * Seeds an IN_PROGRESS work order with [ops] attached, assigning
  566 + * a fresh UUID to each so tests can reference them by operationId.
  567 + * Returns the order.
  568 + */
  569 + private fun inProgressOrderWithOps(
  570 + id: UUID = UUID.randomUUID(),
  571 + code: String = "WO-OPS",
  572 + itemCode: String = "FG-1",
  573 + qty: String = "10",
  574 + ops: List<WorkOrderOperation> = emptyList(),
  575 + ): WorkOrder {
  576 + val order = inProgressOrder(id = id, code = code, itemCode = itemCode, qty = qty)
  577 + ops.forEach {
  578 + it.id = UUID.randomUUID()
  579 + it.workOrder = order
  580 + order.operations.add(it)
  581 + }
  582 + return order
  583 + }
  584 +
  585 + private fun op(lineNo: Int, status: WorkOrderOperationStatus = WorkOrderOperationStatus.PENDING) =
  586 + WorkOrderOperation(
  587 + workOrder = WorkOrder("tmp", "FG-1", BigDecimal("1"), WorkOrderStatus.DRAFT),
  588 + lineNo = lineNo,
  589 + operationCode = "OP-$lineNo",
  590 + workCenter = "WC-$lineNo",
  591 + standardMinutes = BigDecimal("10"),
  592 + status = status,
  593 + )
  594 +
  595 + @Test
  596 + fun `create with operations attaches them all in PENDING`() {
  597 + stubItem("FG-1")
  598 +
  599 + val saved = service.create(
  600 + CreateWorkOrderCommand(
  601 + code = "WO-RT",
  602 + outputItemCode = "FG-1",
  603 + outputQuantity = BigDecimal("10"),
  604 + operations = listOf(
  605 + WorkOrderOperationCommand(1, "CUT", "CUT-01", BigDecimal("15")),
  606 + WorkOrderOperationCommand(2, "PRINT", "PRESS-A", BigDecimal("30")),
  607 + WorkOrderOperationCommand(3, "BIND", "BIND-01", BigDecimal("20")),
  608 + ),
  609 + ),
  610 + )
  611 +
  612 + assertThat(saved.operations).hasSize(3)
  613 + assertThat(saved.operations[0].operationCode).isEqualTo("CUT")
  614 + assertThat(saved.operations[0].status).isEqualTo(WorkOrderOperationStatus.PENDING)
  615 + assertThat(saved.operations[1].operationCode).isEqualTo("PRINT")
  616 + assertThat(saved.operations[2].operationCode).isEqualTo("BIND")
  617 + }
  618 +
  619 + @Test
  620 + fun `create rejects duplicate operation line numbers`() {
  621 + stubItem("FG-1")
  622 +
  623 + assertFailure {
  624 + service.create(
  625 + CreateWorkOrderCommand(
  626 + code = "WO-OPDUP",
  627 + outputItemCode = "FG-1",
  628 + outputQuantity = BigDecimal("10"),
  629 + operations = listOf(
  630 + WorkOrderOperationCommand(1, "CUT", "WC-1", BigDecimal("5")),
  631 + WorkOrderOperationCommand(1, "PRINT", "WC-2", BigDecimal("5")),
  632 + ),
  633 + ),
  634 + )
  635 + }
  636 + .isInstanceOf(IllegalArgumentException::class)
  637 + .messageContains("duplicated")
  638 + }
  639 +
  640 + @Test
  641 + fun `create rejects blank operationCode`() {
  642 + stubItem("FG-1")
  643 +
  644 + assertFailure {
  645 + service.create(
  646 + CreateWorkOrderCommand(
  647 + code = "WO-OPBLANK",
  648 + outputItemCode = "FG-1",
  649 + outputQuantity = BigDecimal("10"),
  650 + operations = listOf(
  651 + WorkOrderOperationCommand(1, "", "WC-1", BigDecimal("5")),
  652 + ),
  653 + ),
  654 + )
  655 + }
  656 + .isInstanceOf(IllegalArgumentException::class)
  657 + .messageContains("operation_code must not be blank")
  658 + }
  659 +
  660 + @Test
  661 + fun `complete is gated when any operation is not COMPLETED`() {
  662 + val id = UUID.randomUUID()
  663 + val order = inProgressOrderWithOps(
  664 + id = id,
  665 + code = "WO-GATED",
  666 + ops = listOf(
  667 + op(1, WorkOrderOperationStatus.COMPLETED),
  668 + op(2, WorkOrderOperationStatus.IN_PROGRESS),
  669 + ),
  670 + )
  671 + every { orders.findById(id) } returns Optional.of(order)
  672 +
  673 + assertFailure { service.complete(id, "WH-FG") }
  674 + .isInstanceOf(IllegalArgumentException::class)
  675 + .messageContains("routing operation")
  676 +
  677 + // No ledger rows touched when the gate refuses.
  678 + verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) }
  679 + }
  680 +
  681 + @Test
  682 + fun `complete passes through when every operation is COMPLETED`() {
  683 + val id = UUID.randomUUID()
  684 + val order = inProgressOrderWithOps(
  685 + id = id,
  686 + code = "WO-DONE-OPS",
  687 + itemCode = "FG-1",
  688 + qty = "10",
  689 + ops = listOf(
  690 + op(1, WorkOrderOperationStatus.COMPLETED),
  691 + op(2, WorkOrderOperationStatus.COMPLETED),
  692 + ),
  693 + )
  694 + every { orders.findById(id) } returns Optional.of(order)
  695 + stubInventoryCredit("FG-1", "WH-FG", BigDecimal("10"))
  696 +
  697 + val result = service.complete(id, "WH-FG")
  698 +
  699 + assertThat(result.status).isEqualTo(WorkOrderStatus.COMPLETED)
  700 + }
  701 +
  702 + @Test
  703 + fun `startOperation refuses on a DRAFT work order`() {
  704 + val id = UUID.randomUUID()
  705 + val order = draftOrder(id = id).also {
  706 + op(1).apply { this.id = UUID.randomUUID(); this.workOrder = it }.also { o -> it.operations.add(o) }
  707 + }
  708 + val opId = order.operations.first().id
  709 + every { orders.findById(id) } returns Optional.of(order)
  710 +
  711 + assertFailure { service.startOperation(id, opId) }
  712 + .isInstanceOf(IllegalArgumentException::class)
  713 + .messageContains("only IN_PROGRESS work orders can progress their operations")
  714 + }
  715 +
  716 + @Test
  717 + fun `startOperation flips PENDING to IN_PROGRESS and stamps startedAt`() {
  718 + val id = UUID.randomUUID()
  719 + val order = inProgressOrderWithOps(
  720 + id = id,
  721 + ops = listOf(op(1), op(2)),
  722 + )
  723 + val opId = order.operations.first { it.lineNo == 1 }.id
  724 + every { orders.findById(id) } returns Optional.of(order)
  725 +
  726 + val started = service.startOperation(id, opId)
  727 +
  728 + assertThat(started.status).isEqualTo(WorkOrderOperationStatus.IN_PROGRESS)
  729 + assertThat(started.startedAt).isNotNull()
  730 + }
  731 +
  732 + @Test
  733 + fun `startOperation refuses skip-ahead over a PENDING predecessor`() {
  734 + val id = UUID.randomUUID()
  735 + val order = inProgressOrderWithOps(
  736 + id = id,
  737 + ops = listOf(op(1), op(2)),
  738 + )
  739 + val opId2 = order.operations.first { it.lineNo == 2 }.id
  740 + every { orders.findById(id) } returns Optional.of(order)
  741 +
  742 + assertFailure { service.startOperation(id, opId2) }
  743 + .isInstanceOf(IllegalArgumentException::class)
  744 + .messageContains("earlier operation(s) are not yet COMPLETED")
  745 + }
  746 +
  747 + @Test
  748 + fun `startOperation is idempotent when already IN_PROGRESS`() {
  749 + val id = UUID.randomUUID()
  750 + val order = inProgressOrderWithOps(
  751 + id = id,
  752 + ops = listOf(op(1, WorkOrderOperationStatus.IN_PROGRESS)),
  753 + )
  754 + val opId = order.operations.first().id
  755 + every { orders.findById(id) } returns Optional.of(order)
  756 +
  757 + val result = service.startOperation(id, opId)
  758 +
  759 + assertThat(result.status).isEqualTo(WorkOrderOperationStatus.IN_PROGRESS)
  760 + }
  761 +
  762 + @Test
  763 + fun `completeOperation records actualMinutes and flips to COMPLETED`() {
  764 + val id = UUID.randomUUID()
  765 + val order = inProgressOrderWithOps(
  766 + id = id,
  767 + ops = listOf(op(1, WorkOrderOperationStatus.IN_PROGRESS)),
  768 + )
  769 + val opId = order.operations.first().id
  770 + every { orders.findById(id) } returns Optional.of(order)
  771 +
  772 + val done = service.completeOperation(id, opId, BigDecimal("12.5"))
  773 +
  774 + assertThat(done.status).isEqualTo(WorkOrderOperationStatus.COMPLETED)
  775 + assertThat(done.actualMinutes).isEqualTo(BigDecimal("12.5"))
  776 + assertThat(done.completedAt).isNotNull()
  777 + }
  778 +
  779 + @Test
  780 + fun `completeOperation rejects negative actualMinutes`() {
  781 + val id = UUID.randomUUID()
  782 + val order = inProgressOrderWithOps(
  783 + id = id,
  784 + ops = listOf(op(1, WorkOrderOperationStatus.IN_PROGRESS)),
  785 + )
  786 + val opId = order.operations.first().id
  787 + every { orders.findById(id) } returns Optional.of(order)
  788 +
  789 + assertFailure { service.completeOperation(id, opId, BigDecimal("-1")) }
  790 + .isInstanceOf(IllegalArgumentException::class)
  791 + .messageContains("non-negative")
  792 + }
  793 +
  794 + @Test
  795 + fun `completeOperation refuses clobbering with a different actualMinutes`() {
  796 + val id = UUID.randomUUID()
  797 + val order = inProgressOrderWithOps(
  798 + id = id,
  799 + ops = listOf(
  800 + op(1, WorkOrderOperationStatus.COMPLETED).also {
  801 + it.actualMinutes = BigDecimal("10")
  802 + },
  803 + ),
  804 + )
  805 + val opId = order.operations.first().id
  806 + every { orders.findById(id) } returns Optional.of(order)
  807 +
  808 + assertFailure { service.completeOperation(id, opId, BigDecimal("99")) }
  809 + .isInstanceOf(IllegalArgumentException::class)
  810 + .messageContains("refusing to overwrite")
  811 + }
558 812 }
... ...