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,6 +25,7 @@ | ||
| 25 | <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/> | 25 | <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/> |
| 26 | <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/> | 26 | <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/> |
| 27 | <include file="classpath:db/changelog/pbc-production/002-production-v2.xml"/> | 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 | <include file="classpath:db/changelog/pbc-quality/001-quality-init.xml"/> | 29 | <include file="classpath:db/changelog/pbc-quality/001-quality-init.xml"/> |
| 29 | <include file="classpath:db/changelog/pbc-quality/002-quality-quarantine-locations.xml"/> | 30 | <include file="classpath:db/changelog/pbc-quality/002-quality-quarantine-locations.xml"/> |
| 30 | </databaseChangeLog> | 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,11 +13,14 @@ import org.vibeerp.api.v1.ext.catalog.CatalogApi | ||
| 13 | import org.vibeerp.api.v1.ext.inventory.InventoryApi | 13 | import org.vibeerp.api.v1.ext.inventory.InventoryApi |
| 14 | import org.vibeerp.pbc.production.domain.WorkOrder | 14 | import org.vibeerp.pbc.production.domain.WorkOrder |
| 15 | import org.vibeerp.pbc.production.domain.WorkOrderInput | 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 | import org.vibeerp.pbc.production.domain.WorkOrderStatus | 18 | import org.vibeerp.pbc.production.domain.WorkOrderStatus |
| 17 | import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository | 19 | import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository |
| 18 | import org.vibeerp.platform.metadata.customfield.ExtJsonValidator | 20 | import org.vibeerp.platform.metadata.customfield.ExtJsonValidator |
| 19 | import java.math.BigDecimal | 21 | import java.math.BigDecimal |
| 20 | import java.time.LocalDate | 22 | import java.time.LocalDate |
| 23 | +import java.time.OffsetDateTime | ||
| 21 | import java.util.UUID | 24 | import java.util.UUID |
| 22 | 25 | ||
| 23 | /** | 26 | /** |
| @@ -137,6 +140,29 @@ class WorkOrderService( | @@ -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 | val order = WorkOrder( | 166 | val order = WorkOrder( |
| 141 | code = command.code, | 167 | code = command.code, |
| 142 | outputItemCode = command.outputItemCode, | 168 | outputItemCode = command.outputItemCode, |
| @@ -162,6 +188,22 @@ class WorkOrderService( | @@ -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 | val saved = orders.save(order) | 207 | val saved = orders.save(order) |
| 166 | 208 | ||
| 167 | eventBus.publish( | 209 | eventBus.publish( |
| @@ -242,6 +284,23 @@ class WorkOrderService( | @@ -242,6 +284,23 @@ class WorkOrderService( | ||
| 242 | "only IN_PROGRESS can be completed" | 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 | // Issue every BOM input FIRST. Doing the materials before the | 304 | // Issue every BOM input FIRST. Doing the materials before the |
| 246 | // output means a bad BOM (missing item, insufficient stock) | 305 | // output means a bad BOM (missing item, insufficient stock) |
| 247 | // fails the call before any PRODUCTION_RECEIPT is written — | 306 | // fails the call before any PRODUCTION_RECEIPT is written — |
| @@ -342,6 +401,125 @@ class WorkOrderService( | @@ -342,6 +401,125 @@ class WorkOrderService( | ||
| 342 | return order | 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 | fun cancel(id: UUID): WorkOrder { | 523 | fun cancel(id: UUID): WorkOrder { |
| 346 | val order = orders.findById(id).orElseThrow { | 524 | val order = orders.findById(id).orElseThrow { |
| 347 | NoSuchElementException("work order not found: $id") | 525 | NoSuchElementException("work order not found: $id") |
| @@ -385,6 +563,14 @@ data class CreateWorkOrderCommand( | @@ -385,6 +563,14 @@ data class CreateWorkOrderCommand( | ||
| 385 | */ | 563 | */ |
| 386 | val inputs: List<WorkOrderInputCommand> = emptyList(), | 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 | * Tier 1 custom-field values. Validated against declarations | 574 | * Tier 1 custom-field values. Validated against declarations |
| 389 | * under entity name `WorkOrder` in `metadata__custom_field` via | 575 | * under entity name `WorkOrder` in `metadata__custom_field` via |
| 390 | * [ExtJsonValidator.applyTo]. Null is legal and produces an | 576 | * [ExtJsonValidator.applyTo]. Null is legal and produces an |
| @@ -403,3 +589,19 @@ data class WorkOrderInputCommand( | @@ -403,3 +589,19 @@ data class WorkOrderInputCommand( | ||
| 403 | val quantityPerUnit: BigDecimal, | 589 | val quantityPerUnit: BigDecimal, |
| 404 | val sourceLocationCode: String, | 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,8 +151,30 @@ class WorkOrder( | ||
| 151 | @OrderBy("lineNo ASC") | 151 | @OrderBy("lineNo ASC") |
| 152 | var inputs: MutableList<WorkOrderInput> = mutableListOf() | 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 | override fun toString(): String = | 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 | companion object { | 179 | companion object { |
| 158 | /** Key under which WorkOrder's custom fields are declared in `metadata__custom_field`. */ | 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,13 +17,17 @@ import org.springframework.web.bind.annotation.ResponseStatus | ||
| 17 | import org.springframework.web.bind.annotation.RestController | 17 | import org.springframework.web.bind.annotation.RestController |
| 18 | import org.vibeerp.pbc.production.application.CreateWorkOrderCommand | 18 | import org.vibeerp.pbc.production.application.CreateWorkOrderCommand |
| 19 | import org.vibeerp.pbc.production.application.WorkOrderInputCommand | 19 | import org.vibeerp.pbc.production.application.WorkOrderInputCommand |
| 20 | +import org.vibeerp.pbc.production.application.WorkOrderOperationCommand | ||
| 20 | import org.vibeerp.pbc.production.application.WorkOrderService | 21 | import org.vibeerp.pbc.production.application.WorkOrderService |
| 21 | import org.vibeerp.pbc.production.domain.WorkOrder | 22 | import org.vibeerp.pbc.production.domain.WorkOrder |
| 22 | import org.vibeerp.pbc.production.domain.WorkOrderInput | 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 | import org.vibeerp.pbc.production.domain.WorkOrderStatus | 26 | import org.vibeerp.pbc.production.domain.WorkOrderStatus |
| 24 | import org.vibeerp.platform.security.authz.RequirePermission | 27 | import org.vibeerp.platform.security.authz.RequirePermission |
| 25 | import java.math.BigDecimal | 28 | import java.math.BigDecimal |
| 26 | import java.time.LocalDate | 29 | import java.time.LocalDate |
| 30 | +import java.time.OffsetDateTime | ||
| 27 | import java.util.UUID | 31 | import java.util.UUID |
| 28 | 32 | ||
| 29 | /** | 33 | /** |
| @@ -113,6 +117,38 @@ class WorkOrderController( | @@ -113,6 +117,38 @@ class WorkOrderController( | ||
| 113 | quantity = request.quantity, | 117 | quantity = request.quantity, |
| 114 | note = request.note, | 118 | note = request.note, |
| 115 | ).toResponse(workOrderService) | 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 | // ─── DTOs ──────────────────────────────────────────────────────────── | 154 | // ─── DTOs ──────────────────────────────────────────────────────────── |
| @@ -130,6 +166,12 @@ data class CreateWorkOrderRequest( | @@ -130,6 +166,12 @@ data class CreateWorkOrderRequest( | ||
| 130 | */ | 166 | */ |
| 131 | @field:Valid val inputs: List<WorkOrderInputRequest> = emptyList(), | 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 | * Tier 1 custom-field values. Validated against declarations | 175 | * Tier 1 custom-field values. Validated against declarations |
| 134 | * under entity name `WorkOrder`. | 176 | * under entity name `WorkOrder`. |
| 135 | */ | 177 | */ |
| @@ -142,6 +184,7 @@ data class CreateWorkOrderRequest( | @@ -142,6 +184,7 @@ data class CreateWorkOrderRequest( | ||
| 142 | dueDate = dueDate, | 184 | dueDate = dueDate, |
| 143 | sourceSalesOrderCode = sourceSalesOrderCode, | 185 | sourceSalesOrderCode = sourceSalesOrderCode, |
| 144 | inputs = inputs.map { it.toCommand() }, | 186 | inputs = inputs.map { it.toCommand() }, |
| 187 | + operations = operations.map { it.toCommand() }, | ||
| 145 | ext = ext, | 188 | ext = ext, |
| 146 | ) | 189 | ) |
| 147 | } | 190 | } |
| @@ -160,6 +203,35 @@ data class WorkOrderInputRequest( | @@ -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 | * Completion request body. | 236 | * Completion request body. |
| 165 | * | 237 | * |
| @@ -196,6 +268,7 @@ data class WorkOrderResponse( | @@ -196,6 +268,7 @@ data class WorkOrderResponse( | ||
| 196 | val dueDate: LocalDate?, | 268 | val dueDate: LocalDate?, |
| 197 | val sourceSalesOrderCode: String?, | 269 | val sourceSalesOrderCode: String?, |
| 198 | val inputs: List<WorkOrderInputResponse>, | 270 | val inputs: List<WorkOrderInputResponse>, |
| 271 | + val operations: List<WorkOrderOperationResponse>, | ||
| 199 | val ext: Map<String, Any?>, | 272 | val ext: Map<String, Any?>, |
| 200 | ) | 273 | ) |
| 201 | 274 | ||
| @@ -207,6 +280,18 @@ data class WorkOrderInputResponse( | @@ -207,6 +280,18 @@ data class WorkOrderInputResponse( | ||
| 207 | val sourceLocationCode: String, | 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 | private fun WorkOrder.toResponse(service: WorkOrderService): WorkOrderResponse = | 295 | private fun WorkOrder.toResponse(service: WorkOrderService): WorkOrderResponse = |
| 211 | WorkOrderResponse( | 296 | WorkOrderResponse( |
| 212 | id = id, | 297 | id = id, |
| @@ -217,6 +302,7 @@ private fun WorkOrder.toResponse(service: WorkOrderService): WorkOrderResponse = | @@ -217,6 +302,7 @@ private fun WorkOrder.toResponse(service: WorkOrderService): WorkOrderResponse = | ||
| 217 | dueDate = dueDate, | 302 | dueDate = dueDate, |
| 218 | sourceSalesOrderCode = sourceSalesOrderCode, | 303 | sourceSalesOrderCode = sourceSalesOrderCode, |
| 219 | inputs = inputs.map { it.toResponse() }, | 304 | inputs = inputs.map { it.toResponse() }, |
| 305 | + operations = operations.map { it.toResponse() }, | ||
| 220 | ext = service.parseExt(this), | 306 | ext = service.parseExt(this), |
| 221 | ) | 307 | ) |
| 222 | 308 | ||
| @@ -228,3 +314,16 @@ private fun WorkOrderInput.toResponse(): WorkOrderInputResponse = | @@ -228,3 +314,16 @@ private fun WorkOrderInput.toResponse(): WorkOrderInputResponse = | ||
| 228 | quantityPerUnit = quantityPerUnit, | 314 | quantityPerUnit = quantityPerUnit, |
| 229 | sourceLocationCode = sourceLocationCode, | 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,6 +22,10 @@ permissions: | ||
| 22 | description: Cancel a work order (DRAFT or IN_PROGRESS → CANCELLED) | 22 | description: Cancel a work order (DRAFT or IN_PROGRESS → CANCELLED) |
| 23 | - key: production.work-order.scrap | 23 | - key: production.work-order.scrap |
| 24 | description: Scrap some output from a COMPLETED work order (writes a negative ADJUSTMENT, status unchanged) | 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 | customFields: | 30 | customFields: |
| 27 | - key: production_priority | 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 +6,7 @@ import assertk.assertions.hasMessage | ||
| 6 | import assertk.assertions.hasSize | 6 | import assertk.assertions.hasSize |
| 7 | import assertk.assertions.isEqualTo | 7 | import assertk.assertions.isEqualTo |
| 8 | import assertk.assertions.isInstanceOf | 8 | import assertk.assertions.isInstanceOf |
| 9 | +import assertk.assertions.isNotNull | ||
| 9 | import assertk.assertions.messageContains | 10 | import assertk.assertions.messageContains |
| 10 | import io.mockk.Runs | 11 | import io.mockk.Runs |
| 11 | import io.mockk.every | 12 | import io.mockk.every |
| @@ -29,6 +30,8 @@ import org.vibeerp.api.v1.ext.inventory.InventoryApi | @@ -29,6 +30,8 @@ import org.vibeerp.api.v1.ext.inventory.InventoryApi | ||
| 29 | import org.vibeerp.api.v1.ext.inventory.StockBalanceRef | 30 | import org.vibeerp.api.v1.ext.inventory.StockBalanceRef |
| 30 | import org.vibeerp.pbc.production.domain.WorkOrder | 31 | import org.vibeerp.pbc.production.domain.WorkOrder |
| 31 | import org.vibeerp.pbc.production.domain.WorkOrderInput | 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 | import org.vibeerp.pbc.production.domain.WorkOrderStatus | 35 | import org.vibeerp.pbc.production.domain.WorkOrderStatus |
| 33 | import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository | 36 | import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository |
| 34 | import org.vibeerp.platform.metadata.customfield.ExtJsonValidator | 37 | import org.vibeerp.platform.metadata.customfield.ExtJsonValidator |
| @@ -555,4 +558,255 @@ class WorkOrderServiceTest { | @@ -555,4 +558,255 @@ class WorkOrderServiceTest { | ||
| 555 | .isInstanceOf(IllegalArgumentException::class) | 558 | .isInstanceOf(IllegalArgumentException::class) |
| 556 | .messageContains("only DRAFT or IN_PROGRESS") | 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