Commit fa867189d1d1fe4f400966fd7725dbced25ecc21
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.
Showing
8 changed files
with
801 additions
and
1 deletions
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 > 0), | |
| 70 | + CONSTRAINT production__work_order_operation_std_min_pos | |
| 71 | + CHECK (standard_minutes >= 0), | |
| 72 | + CONSTRAINT production__work_order_operation_act_min_pos | |
| 73 | + CHECK (actual_minutes IS NULL OR actual_minutes >= 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 | } | ... | ... |
-
mentioned in commit 35ad8a8d