diff --git a/CLAUDE.md b/CLAUDE.md index 79238fb..2c44f03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ plugins (incl. ref) depend on: api/api-v1 only **Foundation complete; first business surface in place.** As of the latest commit: - **18 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`. -- **230 unit tests across 18 modules**, all green. `./gradlew build` is the canonical full build. +- **240 unit tests across 18 modules**, all green. `./gradlew build` is the canonical full build. - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge. - **8 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`). **The full buy-sell-make loop works**: a purchase order receives stock via `PURCHASE_RECEIPT`, a sales order ships stock via `SALES_SHIPMENT`, and a work order produces stock via `PRODUCTION_RECEIPT`. All three PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade. - **Event-driven cross-PBC integration is live in BOTH directions and the consumer reacts to the full lifecycle.** Six typed events under `org.vibeerp.api.v1.event.orders.*` are published from `SalesOrderService` and `PurchaseOrderService` inside the same `@Transactional` method as their state changes. **pbc-finance** subscribes to ALL SIX of them via the api.v1 `EventBus.subscribe(eventType, listener)` typed-class overload: confirm events POST AR/AP rows; ship/receive events SETTLE them; cancel events REVERSE them. Each transition is idempotent under at-least-once delivery — re-applying the same destination status is a no-op. Cancel-from-DRAFT is a clean no-op because no `*ConfirmedEvent` was ever published. pbc-finance has no source dependency on pbc-orders-*; it reaches them only through events. diff --git a/PROGRESS.md b/PROGRESS.md index dfcec02..4134d6c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,11 +10,11 @@ | | | |---|---| -| **Latest version** | v0.18.2 (full P4.3 rollout across all PBC controllers) | -| **Latest commit** | `da386cc feat(security): annotate inventory + orders list/get/create/update endpoints` | +| **Latest version** | v0.19.0 (pbc-production v2 — IN_PROGRESS + BOM + scrap) | +| **Latest commit** | `TBD feat(production): v2 state machine + BOM + scrap flow` | | **Repo** | https://github.com/reporkey/vibe-erp | | **Modules** | 18 | -| **Unit tests** | 230, all green | +| **Unit tests** | 240, all green | | **End-to-end smoke runs** | An SO confirmed with 2 lines auto-spawns 2 draft work orders via `SalesOrderConfirmedSubscriber`; completing one credits the finished-good stock via `PRODUCTION_RECEIPT`, cancelling the other flips its status, and a manual WO can still be created with no source SO. All in one run: 6 outbox rows DISPATCHED across `orders_sales.SalesOrder` and `production.WorkOrder` topics; pbc-finance still writes its AR row for the underlying SO; the `inventory__stock_movement` ledger carries the production receipt tagged `WO:`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). | | **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | @@ -84,7 +84,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **22 a | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending | | P5.5 | `pbc-orders-sales` — sales orders + lines (deliveries deferred) | ✅ DONE — `a8eb2a6` | | P5.6 | `pbc-orders-purchase` — POs + receive flow (RFQs deferred) | ✅ DONE — `2861656` | -| P5.7 | `pbc-production` — work orders, routings, operations | ✅ Partial — minimal single-output work orders + auto-spawn from SO confirm; BOM / routings / operations / IN_PROGRESS pending | +| P5.7 | `pbc-production` — work orders, routings, operations | ✅ Partial — v2 state machine (DRAFT → IN_PROGRESS → COMPLETED), `WorkOrderInput` BOM child entity with per-unit consumption auto-issuing `MATERIAL_ISSUE` at complete time, scrap verb writing negative `ADJUSTMENT`. Auto-spawn from SO confirm still works with empty BOM. Routings / operations / scheduling still pending. | | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending | | P5.9 | `pbc-finance` — GL, journal entries, AR/AP minimal | ✅ Partial — minimal AR/AP journal entries driven by events; full GL pending | diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt index b997f58..ee37526 100644 --- a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt @@ -52,14 +52,39 @@ public data class WorkOrderCreatedEvent( } /** - * Emitted when a DRAFT work order is completed (terminal happy path). + * Emitted when a DRAFT work order is started — the operator picked + * it up and production has physically begun. The v2 state machine + * adds IN_PROGRESS between DRAFT and COMPLETED so "started but not + * finished" work is observable on a dashboard and addressable by + * downstream PBCs (capacity, scheduling, shop-floor displays). + * + * **v2 design note:** raw materials are NOT consumed on `start()`; + * they are consumed atomically at `complete()` time. A future v3 + * may split that into "pick materials now, receive output later" + * but v2 keeps the material-issue and the production-receipt in + * the same transaction so the ledger stays symmetrical. + */ +public data class WorkOrderStartedEvent( + override val orderCode: String, + override val outputItemCode: String, + override val outputQuantity: BigDecimal, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : WorkOrderEvent { + override val aggregateType: String get() = WORK_ORDER_AGGREGATE_TYPE + override val aggregateId: String get() = orderCode +} + +/** + * Emitted when an IN_PROGRESS work order is completed (terminal happy path). * * The companion `PRODUCTION_RECEIPT` ledger row has already been * written by the time this event fires — the publish runs inside * the same `@Transactional` method as the inventory write and the * status flip, so a subscriber that reads `inventory__stock_movement` * on receipt is guaranteed to see the matching row tagged - * `WO:`. + * `WO:`. The `MATERIAL_ISSUE` rows for any BOM inputs + * have also already been written in the same transaction. * * `outputLocationCode` is included so warehouse and dispatch * subscribers can act on "what just landed where" without @@ -78,11 +103,14 @@ public data class WorkOrderCompletedEvent( } /** - * Emitted when a work order is cancelled from DRAFT. + * Emitted when a work order is cancelled. v2 allows cancel from + * either DRAFT or IN_PROGRESS — nothing has been written to the + * ledger yet at those states (v2 only touches inventory at + * `complete()`) so cancel is a clean status flip. * * The framework refuses to cancel a COMPLETED work order — that * would imply un-producing finished goods, which is "scrap them", - * a separate flow that lands later as its own event. + * a separate [WorkOrderScrappedEvent] flow. */ public data class WorkOrderCancelledEvent( override val orderCode: String, @@ -95,5 +123,41 @@ public data class WorkOrderCancelledEvent( override val aggregateId: String get() = orderCode } +/** + * Emitted when a COMPLETED work order has some of its output + * scrapped (e.g. QC rejected a batch). + * + * **Why scrap is post-completion, not a cancel path:** the finished + * goods ARE on the shelves — they were counted, inspected, and then + * found defective. Treating that as "uncomplete the work order" + * would break the ledger's append-only discipline. Instead the + * [org.vibeerp.pbc.production.application.WorkOrderService.scrap] + * call writes a negative `ADJUSTMENT` ledger row referencing + * `WO::SCRAP` so the audit trail is "this is stock that + * came off THIS work order and was later thrown away". The work + * order itself stays COMPLETED; scrap is a correction on top. + * + * [scrappedQuantity] is the amount destroyed (positive number; + * the ledger row is negative because it debits stock). [scrapLocationCode] + * is where the destroyed stock came from — usually the same + * location the `PRODUCTION_RECEIPT` credited at `complete()` time, + * but the operator names it explicitly in case the finished goods + * have been moved since. [note] is a free-form reason captured from + * the scrap form. + */ +public data class WorkOrderScrappedEvent( + override val orderCode: String, + override val outputItemCode: String, + override val outputQuantity: BigDecimal, + public val scrappedQuantity: BigDecimal, + public val scrapLocationCode: String, + public val note: String?, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : WorkOrderEvent { + override val aggregateType: String get() = WORK_ORDER_AGGREGATE_TYPE + override val aggregateId: String get() = orderCode +} + /** Topic string for wildcard / topic-based subscriptions to work order events. */ public const val WORK_ORDER_AGGREGATE_TYPE: String = "production.WorkOrder" diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml index b3baee0..8580a90 100644 --- a/distribution/src/main/resources/db/changelog/master.xml +++ b/distribution/src/main/resources/db/changelog/master.xml @@ -23,4 +23,5 @@ + diff --git a/distribution/src/main/resources/db/changelog/pbc-production/002-production-v2.xml b/distribution/src/main/resources/db/changelog/pbc-production/002-production-v2.xml new file mode 100644 index 0000000..fb8bd23 --- /dev/null +++ b/distribution/src/main/resources/db/changelog/pbc-production/002-production-v2.xml @@ -0,0 +1,83 @@ + + + + + + + Widen production__work_order.status CHECK to allow IN_PROGRESS + + ALTER TABLE production__work_order + DROP CONSTRAINT production__work_order_status_check; + ALTER TABLE production__work_order + ADD CONSTRAINT production__work_order_status_check + CHECK (status IN ('DRAFT', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED')); + + + ALTER TABLE production__work_order + DROP CONSTRAINT production__work_order_status_check; + ALTER TABLE production__work_order + ADD CONSTRAINT production__work_order_status_check + CHECK (status IN ('DRAFT', 'COMPLETED', 'CANCELLED')); + + + + + Create production__work_order_input (BOM child table) + + CREATE TABLE production__work_order_input ( + id uuid PRIMARY KEY, + work_order_id uuid NOT NULL + REFERENCES production__work_order(id) ON DELETE CASCADE, + line_no integer NOT NULL, + item_code varchar(64) NOT NULL, + quantity_per_unit numeric(18,4) NOT NULL, + source_location_code varchar(64) NOT NULL, + created_at timestamptz NOT NULL, + created_by varchar(128) NOT NULL, + updated_at timestamptz NOT NULL, + updated_by varchar(128) NOT NULL, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT production__work_order_input_qty_pos + CHECK (quantity_per_unit > 0), + CONSTRAINT production__work_order_input_line_no_pos + CHECK (line_no > 0), + CONSTRAINT production__work_order_input_line_uk + UNIQUE (work_order_id, line_no) + ); + CREATE INDEX production__work_order_input_wo_idx + ON production__work_order_input (work_order_id); + CREATE INDEX production__work_order_input_item_idx + ON production__work_order_input (item_code); + + + DROP TABLE production__work_order_input; + + + + diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt index 6b9b003..45d6178 100644 --- a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt @@ -7,9 +7,12 @@ import org.vibeerp.api.v1.event.EventBus import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent import org.vibeerp.api.v1.event.production.WorkOrderCreatedEvent +import org.vibeerp.api.v1.event.production.WorkOrderScrappedEvent +import org.vibeerp.api.v1.event.production.WorkOrderStartedEvent import org.vibeerp.api.v1.ext.catalog.CatalogApi import org.vibeerp.api.v1.ext.inventory.InventoryApi import org.vibeerp.pbc.production.domain.WorkOrder +import org.vibeerp.pbc.production.domain.WorkOrderInput import org.vibeerp.pbc.production.domain.WorkOrderStatus import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository import java.math.BigDecimal @@ -21,13 +24,16 @@ import java.util.UUID * * **Cross-PBC seams** (all read through `api.v1.ext.*` interfaces, * never through pbc-* internals): - * - [CatalogApi] — validates that the output item exists and is - * active before a work order can be created. - * - [InventoryApi] — credits the finished good to the receiving - * location when [complete] is called, with reason - * `PRODUCTION_RECEIPT` (added in commit `c52d0d5`) and a - * `WO:` reference. Same primitive that pbc-orders-sales - * and pbc-orders-purchase call — one ledger, three callers. + * - [CatalogApi] — validates that the output item AND every BOM + * input item exists and is active before a work order can be + * created. + * - [InventoryApi] — on [complete] writes one `MATERIAL_ISSUE` + * ledger row per BOM input AND a `PRODUCTION_RECEIPT` ledger row + * for the output, all in the same transaction. On [scrap] writes + * a negative `ADJUSTMENT` ledger row. Same primitive that + * pbc-orders-sales and pbc-orders-purchase call — one ledger, + * now four call-sites (sales ship, purchase receive, work order + * complete for both sides, work order scrap). * * **Event publishing.** Each state-changing method publishes a * typed event from `api.v1.event.production.*` inside the same @@ -36,17 +42,26 @@ import java.util.UUID * `Propagation.MANDATORY` so a publish outside a transaction would * throw — every method here is transactional, so the contract is * always met. A failure on any line rolls back the status change - * AND the would-have-been outbox row. + * AND every ledger row AND the would-have-been outbox row. * - * **State machine** (enforced by [complete] and [cancel]): - * - DRAFT → COMPLETED (complete) + * **v2 state machine** (enforced by [start], [complete], [cancel]): + * - DRAFT → IN_PROGRESS (start) + * - IN_PROGRESS → COMPLETED (complete — issues BOM materials, credits finished goods) * - DRAFT → CANCELLED (cancel) + * - IN_PROGRESS → CANCELLED (cancel — nothing to undo, v2 only writes to the ledger at complete()) + * - COMPLETED is terminal but admits post-completion scrap via [scrap] * - Anything else throws. * - * The minimal v1 has no `update` method — work orders are - * effectively immutable from creation until completion. A future - * "update output quantity before start" gesture lands as its own - * verb when the operations team needs it. + * **Why complete() is atomic across ALL inputs AND the output.** + * A failure halfway through the BOM loop (item missing, location + * missing, insufficient stock) rolls back every prior MATERIAL_ISSUE + * of the same complete() call AND the status flip. There is no + * half-finished work order with some materials issued but the + * output not credited — it's an all-or-nothing transition. + * + * The v2 service still has no `update` method — work orders are + * immutable from creation until start(). A future chunk may grow a + * "edit BOM before start" gesture if the operations team needs it. */ @Service @Transactional @@ -87,6 +102,31 @@ class WorkOrderService( "output item code '${command.outputItemCode}' is not in the catalog (or is inactive)", ) + // v2: validate every BOM input line up-front. Catching a bad + // item or a duplicate line_no HERE instead of at complete() + // means the operator fixes the mistake while still editing + // the work order, not when they try to finish a production + // run. Line numbers must be unique and positive; v2 does not + // sort them, it trusts the caller's ordering. + val seenLineNos = HashSet(command.inputs.size) + for (input in command.inputs) { + require(input.lineNo > 0) { + "work order input line_no must be positive (got ${input.lineNo})" + } + require(seenLineNos.add(input.lineNo)) { + "work order input line_no ${input.lineNo} is duplicated" + } + require(input.quantityPerUnit.signum() > 0) { + "work order input line ${input.lineNo} quantity_per_unit must be positive " + + "(got ${input.quantityPerUnit})" + } + catalogApi.findItemByCode(input.itemCode) + ?: throw IllegalArgumentException( + "work order input line ${input.lineNo}: item code '${input.itemCode}' " + + "is not in the catalog (or is inactive)", + ) + } + val order = WorkOrder( code = command.code, outputItemCode = command.outputItemCode, @@ -95,6 +135,19 @@ class WorkOrderService( dueDate = command.dueDate, sourceSalesOrderCode = command.sourceSalesOrderCode, ) + // Attach BOM children BEFORE the first save so Hibernate + // cascades the whole graph in one commit. + for (input in command.inputs) { + order.inputs.add( + WorkOrderInput( + workOrder = order, + lineNo = input.lineNo, + itemCode = input.itemCode, + quantityPerUnit = input.quantityPerUnit, + sourceLocationCode = input.sourceLocationCode, + ), + ) + } val saved = orders.save(order) eventBus.publish( @@ -109,29 +162,86 @@ class WorkOrderService( } /** - * Mark a DRAFT work order as COMPLETED, crediting [outputLocationCode] - * with the output quantity in the same transaction. + * Start a DRAFT work order — the operator has physically begun + * production. v2 writes NOTHING to the inventory ledger on + * start(); materials are consumed atomically at [complete] time. + * start() exists to make "started but not finished" work + * observable on a shop-floor dashboard. + */ + fun start(id: UUID): WorkOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("work order not found: $id") + } + require(order.status == WorkOrderStatus.DRAFT) { + "cannot start work order ${order.code} in status ${order.status}; " + + "only DRAFT can be started" + } + order.status = WorkOrderStatus.IN_PROGRESS + + eventBus.publish( + WorkOrderStartedEvent( + orderCode = order.code, + outputItemCode = order.outputItemCode, + outputQuantity = order.outputQuantity, + ), + ) + return order + } + + /** + * Mark an IN_PROGRESS work order as COMPLETED, issuing every BOM + * input AND crediting [outputLocationCode] with the output + * quantity — all in the same transaction. * - * **Cross-PBC WRITE** through the same `InventoryApi.recordMovement` + * **Cross-PBC WRITES** through the same `InventoryApi.recordMovement` * facade that pbc-orders-purchase uses for receipt and pbc-orders-sales - * uses for shipment. The reason is `PRODUCTION_RECEIPT` and the - * reference is `WO:` so a ledger reader can attribute - * the row to this work order. + * uses for shipment: + * 1. For each BOM line, a NEGATIVE `MATERIAL_ISSUE` movement + * debiting `quantityPerUnit × outputQuantity` from the line's + * `sourceLocationCode`. + * 2. A positive `PRODUCTION_RECEIPT` movement crediting + * `outputQuantity` to [outputLocationCode]. + * + * Both sets of rows reference `WO:` so a ledger + * reader can attribute the row to this work order. The inputs + * are processed in `lineNo` order for determinism (the BOM + * collection is @OrderBy("lineNo ASC")). * - * The whole operation runs in ONE transaction. A failure on the - * inventory write — bad item, bad location, would push balance - * negative (impossible for a positive delta but the framework - * checks it anyway) — rolls back BOTH the ledger row AND the - * status change. There is no half-completed work order with - * a partial inventory write. + * The whole operation runs in ONE transaction. A failure anywhere + * in the loop — bad item, bad location, balance would go + * negative — rolls back BOTH every already-written ledger row + * AND the status flip. There is no half-completed work order + * with some materials issued but the output not credited. + * + * **Empty BOM is legal.** An auto-spawned work order (from + * SalesOrderConfirmedSubscriber) has no inputs at v2 and will + * just write the PRODUCTION_RECEIPT on complete() without any + * MATERIAL_ISSUE rows. That's the same behavior as v1 and is + * why v2 is backwards-compatible at the ledger level. */ fun complete(id: UUID, outputLocationCode: String): WorkOrder { val order = orders.findById(id).orElseThrow { NoSuchElementException("work order not found: $id") } - require(order.status == WorkOrderStatus.DRAFT) { + require(order.status == WorkOrderStatus.IN_PROGRESS) { "cannot complete work order ${order.code} in status ${order.status}; " + - "only DRAFT can be completed" + "only IN_PROGRESS can be completed" + } + + // Issue every BOM input FIRST. Doing the materials before the + // output means a bad BOM (missing item, insufficient stock) + // fails the call before any PRODUCTION_RECEIPT is written — + // keeping the transaction's failure mode "nothing happened" + // rather than "output credited but material debits missing". + for (input in order.inputs) { + val totalIssued = input.quantityPerUnit.multiply(order.outputQuantity) + inventoryApi.recordMovement( + itemCode = input.itemCode, + locationCode = input.sourceLocationCode, + delta = totalIssued.negate(), + reason = "MATERIAL_ISSUE", + reference = "WO:${order.code}", + ) } // Credit the finished good to the receiving location. @@ -156,17 +266,84 @@ class WorkOrderService( return order } + /** + * Scrap some or all of a COMPLETED work order's finished-goods + * output. Writes a negative `ADJUSTMENT` ledger row tagged + * `WO::SCRAP` (the `:SCRAP` suffix is the convention for + * "this is a post-completion correction on a work order"). + * + * **Why ADJUSTMENT rather than a new SCRAP enum value:** + * ADJUSTMENT already admits either sign and is the documented + * "operator-driven correction" reason. Adding SCRAP as a new + * enum value would be non-breaking (the column is varchar) but + * would mean two reasons fighting for the same semantics. The + * reference-string convention does the disambiguation without + * growing the enum. + * + * **Work order stays COMPLETED.** Scrap is a correction ON TOP + * of a terminal state, not a state change. The operator can call + * scrap multiple times if defects are discovered in batches — + * v2 trusts the operator and doesn't track accumulated scrap. + * + * Publishes [WorkOrderScrappedEvent] in the same transaction as + * the ledger write. Both the ledger row and the event carry the + * scrapped quantity + location + optional note so a downstream + * quality PBC can react. + */ + fun scrap( + id: UUID, + scrapLocationCode: String, + quantity: BigDecimal, + note: String?, + ): WorkOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("work order not found: $id") + } + require(order.status == WorkOrderStatus.COMPLETED) { + "cannot scrap work order ${order.code} in status ${order.status}; " + + "only COMPLETED work orders can be scrapped" + } + require(quantity.signum() > 0) { + "scrap quantity must be positive (got $quantity)" + } + + inventoryApi.recordMovement( + itemCode = order.outputItemCode, + locationCode = scrapLocationCode, + delta = quantity.negate(), + reason = "ADJUSTMENT", + reference = "WO:${order.code}:SCRAP", + ) + + eventBus.publish( + WorkOrderScrappedEvent( + orderCode = order.code, + outputItemCode = order.outputItemCode, + outputQuantity = order.outputQuantity, + scrappedQuantity = quantity, + scrapLocationCode = scrapLocationCode, + note = note, + ), + ) + return order + } + fun cancel(id: UUID): WorkOrder { val order = orders.findById(id).orElseThrow { NoSuchElementException("work order not found: $id") } // COMPLETED is terminal — once finished goods exist on the // shelves the framework will NOT let you "uncomplete" them. - // A real shop floor needs a "scrap defective output" flow, - // but that's its own event for a future chunk. - require(order.status == WorkOrderStatus.DRAFT) { + // Use [scrap] for post-completion corrections. v2 allows + // cancel from either DRAFT or IN_PROGRESS because v2 writes + // nothing to the ledger at start() time, so there is never + // anything to un-do on a cancel. + require( + order.status == WorkOrderStatus.DRAFT || + order.status == WorkOrderStatus.IN_PROGRESS, + ) { "cannot cancel work order ${order.code} in status ${order.status}; " + - "only DRAFT work orders can be cancelled" + "only DRAFT or IN_PROGRESS work orders can be cancelled" } order.status = WorkOrderStatus.CANCELLED @@ -187,4 +364,20 @@ data class CreateWorkOrderCommand( val outputQuantity: BigDecimal, val dueDate: LocalDate? = null, val sourceSalesOrderCode: String? = null, + /** + * BOM lines — the raw materials that must be consumed per unit + * of output. Empty list is legal (produces the v1 behavior: + * complete() writes only the PRODUCTION_RECEIPT). + */ + val inputs: List = emptyList(), +) + +/** + * One BOM line on a work order create command. + */ +data class WorkOrderInputCommand( + val lineNo: Int, + val itemCode: String, + val quantityPerUnit: BigDecimal, + val sourceLocationCode: String, ) diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt index 40b5294..bde5339 100644 --- a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt @@ -1,9 +1,13 @@ package org.vibeerp.pbc.production.domain +import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.OneToMany +import jakarta.persistence.OrderBy import jakarta.persistence.Table import org.hibernate.annotations.JdbcTypeCode import org.hibernate.type.SqlTypes @@ -24,39 +28,54 @@ import java.time.LocalDate * customer ordered 1000 brochures, the floor produced 1000 brochures, * stock now contains 1000 brochures". * - * **What v1 deliberately does NOT model:** - * - **No bill of materials.** A real BOM would list every raw - * material consumed per unit of output. The minimal v1 only - * tracks the output side (production_receipt) — material issues - * happen as separate manual movements via the ledger. The next - * chunk after this one will probably add a `WorkOrderInput` - * child entity and have `complete()` issue raw materials too. + * **v2 vs v1.** v1 shipped the minimal spine (DRAFT → COMPLETED in + * one step, single-output, no BOM). v2 adds: + * - An **IN_PROGRESS** state so "started but not finished" work is + * observable on a shop-floor dashboard. + * - A **bill of materials** via the [inputs] child collection — + * each [WorkOrderInput] row names a raw material, a per-unit + * consumption quantity, and a source location. On + * `complete(outputLocationCode)` the service iterates the inputs + * and writes one `MATERIAL_ISSUE` ledger row per line BEFORE + * writing the `PRODUCTION_RECEIPT` — all in the same transaction. + * - A **scrap** verb for COMPLETED work orders that writes a + * negative `ADJUSTMENT` ledger row tagged `WO::SCRAP` + * without changing the order's terminal status. + * + * **What v2 still deliberately does NOT model:** * - **No routings or operations.** A real shop floor would model * each step of production (cut, print, fold, bind, pack) with - * its own duration and machine assignment. v1 collapses the + * its own duration and machine assignment. v2 collapses the * whole journey into a single complete() call. - * - **No IN_PROGRESS state.** v1 has DRAFT → COMPLETED in one - * step; v2 will add IN_PROGRESS so a started-but-not-finished - * order is observable on a dashboard. * - **No scheduling, no capacity planning, no due-date enforcement.** * A `dueDate` field exists for display only; nothing in the * framework refuses a late completion. + * - **No BOM on auto-spawned orders.** The + * [SalesOrderConfirmedSubscriber] spawns DRAFT work orders with + * an empty [inputs] list because a sales-order line does not + * carry BOM data. An operator wanting material enforcement + * creates the work order manually with its inputs; a future v3 + * may grow the catalog item master with a default BOM template + * that the subscriber can copy onto auto-spawned orders. * - * **State machine:** - * - **DRAFT** — created but not yet completed. Lines may still - * change (in this v1 there are no lines, just - * the output item + quantity). - * - **COMPLETED** — terminal happy path. The - * `complete(outputLocationCode)` call has written - * a `PRODUCTION_RECEIPT` ledger row crediting the - * output item at the named location, and the - * `WorkOrderCompletedEvent` has been published in - * the same transaction. - * - **CANCELLED** — terminal. Reachable only from DRAFT — you - * cannot "uncomplete" finished goods. A real shop - * will eventually need a "scrap" flow for - * completed-but-defective output, but that's a - * different event for a future chunk. + * **State machine (v2):** + * - **DRAFT** — created but not yet started. Inputs may still + * be edited (service-side update is a future + * chunk; v2 freezes inputs at create time). + * - **IN_PROGRESS** — operator has started production. No ledger + * rows written yet; this state just makes + * started work observable. + * - **COMPLETED** — terminal happy path. `complete(outputLocationCode)` + * has written one `MATERIAL_ISSUE` per BOM + * input AND the `PRODUCTION_RECEIPT` crediting + * the output item, all in the same transaction. + * `WorkOrderCompletedEvent` has been published. + * Post-completion scrap is still allowed via + * `scrap()` without leaving the state. + * - **CANCELLED** — terminal. Reachable from DRAFT or IN_PROGRESS. + * The framework will NOT let you cancel from + * COMPLETED — "un-producing finished goods" + * doesn't exist; scrap them instead. * * **Why `output_item_code` and `output_quantity` are columns rather * than a one-line `WorkOrderLine` child entity:** v1 produces @@ -107,16 +126,47 @@ class WorkOrder( @JdbcTypeCode(SqlTypes.JSON) var ext: String = "{}" + /** + * The BOM — zero or more raw material lines consumed per unit of + * output. An empty list is a legal v2 state and means "this work + * order has no tracked material inputs"; `complete()` will just + * write the `PRODUCTION_RECEIPT` without any `MATERIAL_ISSUE` + * rows. Eagerly fetched because every read of a work order + * header is followed in practice by a read of its inputs (the + * shop-floor view shows them, the complete() call iterates them). + * + * `orphanRemoval = true` means dropping a line from the list + * deletes the child row; `cascade = ALL` means a save on the + * parent saves the children. Same convention as [SalesOrder.lines]. + */ + @OneToMany( + mappedBy = "workOrder", + cascade = [CascadeType.ALL], + orphanRemoval = true, + fetch = FetchType.EAGER, + ) + @OrderBy("lineNo ASC") + var inputs: MutableList = mutableListOf() + override fun toString(): String = - "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', status=$status)" + "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', status=$status, inputs=${inputs.size})" } /** * State machine values for [WorkOrder]. See the entity KDoc for the * allowed transitions and the rationale for each one. + * + * **v2 state graph:** + * - DRAFT → IN_PROGRESS (start) + * - DRAFT → CANCELLED (cancel) + * - IN_PROGRESS → COMPLETED (complete — issues BOM materials, credits finished goods) + * - IN_PROGRESS → CANCELLED (cancel — nothing to undo, v2 only writes to the ledger at complete()) + * - COMPLETED (terminal; scrap is a post-completion correction, not a state change) + * - CANCELLED (terminal) */ enum class WorkOrderStatus { DRAFT, + IN_PROGRESS, COMPLETED, CANCELLED, } diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrderInput.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrderInput.kt new file mode 100644 index 0000000..cd364e2 --- /dev/null +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrderInput.kt @@ -0,0 +1,81 @@ +package org.vibeerp.pbc.production.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity +import java.math.BigDecimal + +/** + * One BOM line of a [WorkOrder] — a raw material that must be + * consumed to produce the work order's output. + * + * **v2 shape: per-unit consumption.** [quantityPerUnit] is the + * amount of [itemCode] consumed per ONE unit of the parent's + * [WorkOrder.outputQuantity]. The total material issued on + * `complete()` is `quantityPerUnit * outputQuantity`. Per-unit + * was chosen over "absolute total" because it survives edits to + * the parent's output quantity without having to re-calculate + * every BOM line, which is the common ergonomic on every shop + * floor. A 100-brochure run scaled up to 200 automatically needs + * twice the paper. + * + * **Why `item_code` is a varchar, not a UUID FK:** same cross-PBC + * rule the rest of pbc-production follows. The raw material is a + * catalog item owned by pbc-catalog, and the link is enforced at + * the application layer through `CatalogApi.findItemByCode` at + * create time. A DB-level FK would couple pbc-production's schema + * with pbc-catalog, which CLAUDE.md guardrail #9 refuses. + * + * **Why `source_location_code` lives on each LINE, not on the + * work-order header:** different raw materials can come from + * different warehouses (paper from the paper store, ink from the + * ink store, bindings from the finished-components room). Holding + * the source on the line keeps the BOM honest and the smoke-test + * ledger readable. + * + * **No `ext` JSONB on the line** — same rationale as + * [org.vibeerp.pbc.orders.sales.domain.SalesOrderLine]: lines are + * facts, not master records. + */ +@Entity +@Table(name = "production__work_order_input") +class WorkOrderInput( + workOrder: WorkOrder, + lineNo: Int, + itemCode: String, + quantityPerUnit: BigDecimal, + sourceLocationCode: String, +) : AuditedJpaEntity() { + + @ManyToOne + @JoinColumn(name = "work_order_id", nullable = false) + var workOrder: WorkOrder = workOrder + + @Column(name = "line_no", nullable = false) + var lineNo: Int = lineNo + + @Column(name = "item_code", nullable = false, length = 64) + var itemCode: String = itemCode + + @Column(name = "quantity_per_unit", nullable = false, precision = 18, scale = 4) + var quantityPerUnit: BigDecimal = quantityPerUnit + + @Column(name = "source_location_code", nullable = false, length = 64) + var sourceLocationCode: String = sourceLocationCode + + /** + * The total quantity that must be issued from [sourceLocationCode] + * when the parent work order is completed: `quantityPerUnit × + * outputQuantity`. Computed at read time from the two stored + * inputs so the storage cannot drift. + */ + fun totalQuantity(): BigDecimal = + quantityPerUnit.multiply(workOrder.outputQuantity) + + override fun toString(): String = + "WorkOrderInput(id=$id, woId=${workOrder.id}, line=$lineNo, " + + "item='$itemCode', qtyPerUnit=$quantityPerUnit, srcLoc='$sourceLocationCode')" +} diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt index 7b2384e..f021577 100644 --- a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt @@ -16,8 +16,10 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.vibeerp.pbc.production.application.CreateWorkOrderCommand +import org.vibeerp.pbc.production.application.WorkOrderInputCommand import org.vibeerp.pbc.production.application.WorkOrderService import org.vibeerp.pbc.production.domain.WorkOrder +import org.vibeerp.pbc.production.domain.WorkOrderInput import org.vibeerp.pbc.production.domain.WorkOrderStatus import org.vibeerp.platform.security.authz.RequirePermission import java.math.BigDecimal @@ -63,10 +65,23 @@ class WorkOrderController( workOrderService.create(request.toCommand()).toResponse() /** - * Mark a DRAFT work order as COMPLETED. Atomically credits the - * output quantity to the named location via - * `InventoryApi.recordMovement(PRODUCTION_RECEIPT)`. See - * [WorkOrderService.complete] for the full rationale. + * Start a DRAFT work order — flip to IN_PROGRESS. v2 state + * machine: DRAFT → IN_PROGRESS. Nothing is written to the + * inventory ledger at start() time; materials and output are + * all consumed at `complete()` in one transaction. See + * [WorkOrderService.start]. + */ + @PostMapping("/{id}/start") + @RequirePermission("production.work-order.start") + fun start(@PathVariable id: UUID): WorkOrderResponse = + workOrderService.start(id).toResponse() + + /** + * Mark an IN_PROGRESS work order as COMPLETED. Atomically: + * - issues every BOM input as `MATERIAL_ISSUE` + * - credits the output as `PRODUCTION_RECEIPT` + * in a single transaction. See [WorkOrderService.complete] for + * the full rationale. */ @PostMapping("/{id}/complete") @RequirePermission("production.work-order.complete") @@ -80,6 +95,24 @@ class WorkOrderController( @RequirePermission("production.work-order.cancel") fun cancel(@PathVariable id: UUID): WorkOrderResponse = workOrderService.cancel(id).toResponse() + + /** + * Scrap some of a COMPLETED work order's output. Writes a + * negative `ADJUSTMENT` ledger row; the work order itself stays + * COMPLETED. See [WorkOrderService.scrap] for the full rationale. + */ + @PostMapping("/{id}/scrap") + @RequirePermission("production.work-order.scrap") + fun scrap( + @PathVariable id: UUID, + @RequestBody @Valid request: ScrapWorkOrderRequest, + ): WorkOrderResponse = + workOrderService.scrap( + id = id, + scrapLocationCode = request.scrapLocationCode, + quantity = request.quantity, + note = request.note, + ).toResponse() } // ─── DTOs ──────────────────────────────────────────────────────────── @@ -90,6 +123,12 @@ data class CreateWorkOrderRequest( @field:NotNull val outputQuantity: BigDecimal, val dueDate: LocalDate? = null, @field:Size(max = 64) val sourceSalesOrderCode: String? = null, + /** + * v2 BOM lines. Empty list is legal — the work order will just + * credit finished goods without issuing any materials on + * complete(). + */ + @field:Valid val inputs: List = emptyList(), ) { fun toCommand(): CreateWorkOrderCommand = CreateWorkOrderCommand( code = code, @@ -97,6 +136,21 @@ data class CreateWorkOrderRequest( outputQuantity = outputQuantity, dueDate = dueDate, sourceSalesOrderCode = sourceSalesOrderCode, + inputs = inputs.map { it.toCommand() }, + ) +} + +data class WorkOrderInputRequest( + @field:NotNull val lineNo: Int, + @field:NotBlank @field:Size(max = 64) val itemCode: String, + @field:NotNull val quantityPerUnit: BigDecimal, + @field:NotBlank @field:Size(max = 64) val sourceLocationCode: String, +) { + fun toCommand(): WorkOrderInputCommand = WorkOrderInputCommand( + lineNo = lineNo, + itemCode = itemCode, + quantityPerUnit = quantityPerUnit, + sourceLocationCode = sourceLocationCode, ) } @@ -116,6 +170,17 @@ data class CompleteWorkOrderRequest @JsonCreator(mode = JsonCreator.Mode.PROPERT @field:NotBlank @field:Size(max = 64) val outputLocationCode: String, ) +/** + * Scrap request body. Multi-arg data class — no Jackson single-arg + * trap here, but the two-place body `{"scrapLocationCode", "quantity", + * "note"}` is explicit for clarity. + */ +data class ScrapWorkOrderRequest( + @field:NotBlank @field:Size(max = 64) val scrapLocationCode: String, + @field:NotNull val quantity: BigDecimal, + @field:Size(max = 512) val note: String? = null, +) + data class WorkOrderResponse( val id: UUID, val code: String, @@ -124,6 +189,15 @@ data class WorkOrderResponse( val status: WorkOrderStatus, val dueDate: LocalDate?, val sourceSalesOrderCode: String?, + val inputs: List, +) + +data class WorkOrderInputResponse( + val id: UUID, + val lineNo: Int, + val itemCode: String, + val quantityPerUnit: BigDecimal, + val sourceLocationCode: String, ) private fun WorkOrder.toResponse(): WorkOrderResponse = @@ -135,4 +209,14 @@ private fun WorkOrder.toResponse(): WorkOrderResponse = status = status, dueDate = dueDate, sourceSalesOrderCode = sourceSalesOrderCode, + inputs = inputs.map { it.toResponse() }, + ) + +private fun WorkOrderInput.toResponse(): WorkOrderInputResponse = + WorkOrderInputResponse( + id = id, + lineNo = lineNo, + itemCode = itemCode, + quantityPerUnit = quantityPerUnit, + sourceLocationCode = sourceLocationCode, ) diff --git a/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt b/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt index d6ed0b3..a5fe102 100644 --- a/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt +++ b/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt @@ -3,6 +3,7 @@ package org.vibeerp.pbc.production.application import assertk.assertFailure import assertk.assertThat import assertk.assertions.hasMessage +import assertk.assertions.hasSize import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf import assertk.assertions.messageContains @@ -19,11 +20,14 @@ import org.vibeerp.api.v1.event.EventBus import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent import org.vibeerp.api.v1.event.production.WorkOrderCreatedEvent +import org.vibeerp.api.v1.event.production.WorkOrderScrappedEvent +import org.vibeerp.api.v1.event.production.WorkOrderStartedEvent import org.vibeerp.api.v1.ext.catalog.CatalogApi import org.vibeerp.api.v1.ext.catalog.ItemRef import org.vibeerp.api.v1.ext.inventory.InventoryApi import org.vibeerp.api.v1.ext.inventory.StockBalanceRef import org.vibeerp.pbc.production.domain.WorkOrder +import org.vibeerp.pbc.production.domain.WorkOrderInput import org.vibeerp.pbc.production.domain.WorkOrderStatus import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository import java.math.BigDecimal @@ -78,6 +82,48 @@ class WorkOrderServiceTest { ) } + private fun stubInventoryIssue( + itemCode: String, + locationCode: String, + expectedDelta: BigDecimal, + ) { + every { + inventoryApi.recordMovement( + itemCode = itemCode, + locationCode = locationCode, + delta = expectedDelta, + reason = "MATERIAL_ISSUE", + reference = any(), + ) + } returns StockBalanceRef( + id = Id(UUID.randomUUID()), + itemCode = itemCode, + locationCode = locationCode, + quantity = BigDecimal("0"), + ) + } + + private fun stubInventoryAdjustment( + itemCode: String, + locationCode: String, + expectedDelta: BigDecimal, + ) { + every { + inventoryApi.recordMovement( + itemCode = itemCode, + locationCode = locationCode, + delta = expectedDelta, + reason = "ADJUSTMENT", + reference = any(), + ) + } returns StockBalanceRef( + id = Id(UUID.randomUUID()), + itemCode = itemCode, + locationCode = locationCode, + quantity = BigDecimal("0"), + ) + } + // ─── create ────────────────────────────────────────────────────── @Test @@ -133,6 +179,7 @@ class WorkOrderServiceTest { assertThat(saved.status).isEqualTo(WorkOrderStatus.DRAFT) assertThat(saved.outputQuantity).isEqualTo(BigDecimal("25")) assertThat(saved.sourceSalesOrderCode).isEqualTo("SO-42") + assertThat(saved.inputs).hasSize(0) verify(exactly = 1) { eventBus.publish( match { @@ -145,7 +192,91 @@ class WorkOrderServiceTest { } } - // ─── complete ──────────────────────────────────────────────────── + @Test + fun `create with BOM inputs validates each item and saves the lines`() { + stubItem("FG-1") + stubItem("RAW-PAPER") + stubItem("RAW-INK") + + val saved = service.create( + CreateWorkOrderCommand( + code = "WO-BOM", + outputItemCode = "FG-1", + outputQuantity = BigDecimal("100"), + inputs = listOf( + WorkOrderInputCommand( + lineNo = 1, + itemCode = "RAW-PAPER", + quantityPerUnit = BigDecimal("2"), + sourceLocationCode = "WH-RAW", + ), + WorkOrderInputCommand( + lineNo = 2, + itemCode = "RAW-INK", + quantityPerUnit = BigDecimal("0.5"), + sourceLocationCode = "WH-RAW", + ), + ), + ), + ) + + assertThat(saved.inputs).hasSize(2) + assertThat(saved.inputs[0].itemCode).isEqualTo("RAW-PAPER") + assertThat(saved.inputs[0].quantityPerUnit).isEqualTo(BigDecimal("2")) + assertThat(saved.inputs[1].itemCode).isEqualTo("RAW-INK") + verify(exactly = 1) { catalogApi.findItemByCode("RAW-PAPER") } + verify(exactly = 1) { catalogApi.findItemByCode("RAW-INK") } + } + + @Test + fun `create rejects a BOM line with unknown item`() { + stubItem("FG-1") + every { catalogApi.findItemByCode("GHOST") } returns null + + assertFailure { + service.create( + CreateWorkOrderCommand( + code = "WO-BAD-BOM", + outputItemCode = "FG-1", + outputQuantity = BigDecimal("10"), + inputs = listOf( + WorkOrderInputCommand( + lineNo = 1, + itemCode = "GHOST", + quantityPerUnit = BigDecimal("1"), + sourceLocationCode = "WH-RAW", + ), + ), + ), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("'GHOST'") + } + + @Test + fun `create rejects duplicate BOM line numbers`() { + stubItem("FG-1") + stubItem("RAW-A") + + assertFailure { + service.create( + CreateWorkOrderCommand( + code = "WO-DUP-LINE", + outputItemCode = "FG-1", + outputQuantity = BigDecimal("10"), + inputs = listOf( + WorkOrderInputCommand(1, "RAW-A", BigDecimal("1"), "WH-RAW"), + WorkOrderInputCommand(1, "RAW-A", BigDecimal("2"), "WH-RAW"), + ), + ), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("duplicated") + } + + // ─── start ─────────────────────────────────────────────────────── private fun draftOrder( id: UUID = UUID.randomUUID(), @@ -159,27 +290,64 @@ class WorkOrderServiceTest { status = WorkOrderStatus.DRAFT, ).also { it.id = id } + private fun inProgressOrder( + id: UUID = UUID.randomUUID(), + code: String = "WO-1", + itemCode: String = "FG-1", + qty: String = "100", + ): WorkOrder = WorkOrder( + code = code, + outputItemCode = itemCode, + outputQuantity = BigDecimal(qty), + status = WorkOrderStatus.IN_PROGRESS, + ).also { it.id = id } + @Test - fun `complete rejects a non-DRAFT work order`() { + fun `start flips a DRAFT order to IN_PROGRESS and publishes`() { val id = UUID.randomUUID() - val done = WorkOrder( - code = "WO-1", - outputItemCode = "FG-1", - outputQuantity = BigDecimal("5"), - status = WorkOrderStatus.COMPLETED, - ).also { it.id = id } - every { orders.findById(id) } returns Optional.of(done) + val order = draftOrder(id = id, code = "WO-START") + every { orders.findById(id) } returns Optional.of(order) + + val result = service.start(id) + + assertThat(result.status).isEqualTo(WorkOrderStatus.IN_PROGRESS) + verify(exactly = 1) { + eventBus.publish( + match { it.orderCode == "WO-START" }, + ) + } + verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) } + } + + @Test + fun `start rejects a non-DRAFT order`() { + val id = UUID.randomUUID() + val order = inProgressOrder(id = id, code = "WO-ALREADY") + every { orders.findById(id) } returns Optional.of(order) + + assertFailure { service.start(id) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("only DRAFT can be started") + } + + // ─── complete ──────────────────────────────────────────────────── + + @Test + fun `complete rejects a work order that is not IN_PROGRESS`() { + val id = UUID.randomUUID() + val draft = draftOrder(id = id, code = "WO-DR") + every { orders.findById(id) } returns Optional.of(draft) assertFailure { service.complete(id, "WH-FG") } .isInstanceOf(IllegalArgumentException::class) - .messageContains("only DRAFT can be completed") + .messageContains("only IN_PROGRESS can be completed") verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) } } @Test - fun `complete credits inventory and publishes WorkOrderCompletedEvent`() { + fun `complete on an empty-BOM order credits only the finished good`() { val id = UUID.randomUUID() - val order = draftOrder(id = id, code = "WO-9", itemCode = "FG-WIDGET", qty = "25") + val order = inProgressOrder(id = id, code = "WO-9", itemCode = "FG-WIDGET", qty = "25") every { orders.findById(id) } returns Optional.of(order) stubInventoryCredit("FG-WIDGET", "WH-FG", BigDecimal("25")) @@ -199,14 +367,139 @@ class WorkOrderServiceTest { eventBus.publish( match { it.orderCode == "WO-9" && - it.outputItemCode == "FG-WIDGET" && - it.outputQuantity == BigDecimal("25") && it.outputLocationCode == "WH-FG" }, ) } } + @Test + fun `complete with BOM issues every input BEFORE crediting the output`() { + val id = UUID.randomUUID() + val order = inProgressOrder(id = id, code = "WO-BOM", itemCode = "FG-1", qty = "100") + order.inputs.add( + WorkOrderInput( + workOrder = order, + lineNo = 1, + itemCode = "RAW-PAPER", + quantityPerUnit = BigDecimal("2"), + sourceLocationCode = "WH-RAW", + ), + ) + order.inputs.add( + WorkOrderInput( + workOrder = order, + lineNo = 2, + itemCode = "RAW-INK", + quantityPerUnit = BigDecimal("0.5"), + sourceLocationCode = "WH-RAW", + ), + ) + every { orders.findById(id) } returns Optional.of(order) + // paper: 2 * 100 = 200 (scale 0) issued → delta = -200 + stubInventoryIssue("RAW-PAPER", "WH-RAW", BigDecimal("-200")) + // ink: 0.5 * 100 = 50.0 (scale 1) issued → delta = -50.0 + stubInventoryIssue("RAW-INK", "WH-RAW", BigDecimal("-50.0")) + stubInventoryCredit("FG-1", "WH-FG", BigDecimal("100")) + + val result = service.complete(id, "WH-FG") + + assertThat(result.status).isEqualTo(WorkOrderStatus.COMPLETED) + verify(exactly = 1) { + inventoryApi.recordMovement( + itemCode = "RAW-PAPER", + locationCode = "WH-RAW", + delta = BigDecimal("-200"), + reason = "MATERIAL_ISSUE", + reference = "WO:WO-BOM", + ) + } + verify(exactly = 1) { + inventoryApi.recordMovement( + itemCode = "RAW-INK", + locationCode = "WH-RAW", + delta = BigDecimal("-50.0"), + reason = "MATERIAL_ISSUE", + reference = "WO:WO-BOM", + ) + } + verify(exactly = 1) { + inventoryApi.recordMovement( + itemCode = "FG-1", + locationCode = "WH-FG", + delta = BigDecimal("100"), + reason = "PRODUCTION_RECEIPT", + reference = "WO:WO-BOM", + ) + } + } + + // ─── scrap ─────────────────────────────────────────────────────── + + @Test + fun `scrap rejects a non-COMPLETED order`() { + val id = UUID.randomUUID() + val order = inProgressOrder(id = id, code = "WO-NOT-DONE") + every { orders.findById(id) } returns Optional.of(order) + + assertFailure { service.scrap(id, "WH-FG", BigDecimal("5"), null) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("only COMPLETED work orders can be scrapped") + } + + @Test + fun `scrap rejects a non-positive quantity`() { + val id = UUID.randomUUID() + val order = WorkOrder( + code = "WO-Z", + outputItemCode = "FG-1", + outputQuantity = BigDecimal("10"), + status = WorkOrderStatus.COMPLETED, + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(order) + + assertFailure { service.scrap(id, "WH-FG", BigDecimal.ZERO, null) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("must be positive") + } + + @Test + fun `scrap writes a negative ADJUSTMENT and publishes WorkOrderScrappedEvent`() { + val id = UUID.randomUUID() + val order = WorkOrder( + code = "WO-S", + outputItemCode = "FG-1", + outputQuantity = BigDecimal("100"), + status = WorkOrderStatus.COMPLETED, + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(order) + stubInventoryAdjustment("FG-1", "WH-FG", BigDecimal("-7")) + + val result = service.scrap(id, "WH-FG", BigDecimal("7"), "QC reject") + + // Scrap does not change the status + assertThat(result.status).isEqualTo(WorkOrderStatus.COMPLETED) + verify(exactly = 1) { + inventoryApi.recordMovement( + itemCode = "FG-1", + locationCode = "WH-FG", + delta = BigDecimal("-7"), + reason = "ADJUSTMENT", + reference = "WO:WO-S:SCRAP", + ) + } + verify(exactly = 1) { + eventBus.publish( + match { + it.orderCode == "WO-S" && + it.scrappedQuantity == BigDecimal("7") && + it.scrapLocationCode == "WH-FG" && + it.note == "QC reject" + }, + ) + } + } + // ─── cancel ────────────────────────────────────────────────────── @Test @@ -226,6 +519,22 @@ class WorkOrderServiceTest { } @Test + fun `cancel also accepts an IN_PROGRESS order (v2)`() { + val id = UUID.randomUUID() + val order = inProgressOrder(id = id, code = "WO-IP-CANCEL") + every { orders.findById(id) } returns Optional.of(order) + + val result = service.cancel(id) + + assertThat(result.status).isEqualTo(WorkOrderStatus.CANCELLED) + verify(exactly = 1) { + eventBus.publish( + match { it.orderCode == "WO-IP-CANCEL" }, + ) + } + } + + @Test fun `cancel rejects a COMPLETED order (no un-producing finished goods)`() { val id = UUID.randomUUID() val done = WorkOrder( @@ -238,6 +547,6 @@ class WorkOrderServiceTest { assertFailure { service.cancel(id) } .isInstanceOf(IllegalArgumentException::class) - .messageContains("only DRAFT work orders can be cancelled") + .messageContains("only DRAFT or IN_PROGRESS") } }