Commit 75a75baa9687e20987de86c6c22ef3bc7f6a2a3a

Authored by zichun
1 parent abe2c6a0

feat(production): P5.7 v2 — IN_PROGRESS state + BOM auto-issue + scrap flow

Grows pbc-production from the minimal v1 (DRAFT → COMPLETED in one
step, single output, no BOM) into a real v2 production PBC:

  1. IN_PROGRESS state between DRAFT and COMPLETED so "started but
     not finished" work orders are observable on a dashboard.
     WorkOrderService.start(id) performs the transition and publishes
     a new WorkOrderStartedEvent. cancel() now accepts DRAFT OR
     IN_PROGRESS (v2 writes nothing to the ledger at start() so there
     is nothing to undo on cancel).

  2. Bill of materials via a new WorkOrderInput child entity —
     @OneToMany with cascade + orphanRemoval, same shape as
     SalesOrderLine. Each line carries (lineNo, itemCode,
     quantityPerUnit, sourceLocationCode). complete() now iterates
     the inputs in lineNo order and writes one MATERIAL_ISSUE
     ledger row per line (delta = -(quantityPerUnit × outputQuantity))
     BEFORE writing the PRODUCTION_RECEIPT for the output. All in
     one transaction — a failure anywhere rolls back every prior
     ledger row AND the status flip. Empty inputs list is legal
     (the v1 auto-spawn-from-SO path still works unchanged,
     writing only the PRODUCTION_RECEIPT).

  3. Scrap flow for COMPLETED work orders via a new scrap(id,
     scrapLocationCode, quantity, note) service method. Writes a
     negative ADJUSTMENT ledger row tagged WO:<code>:SCRAP and
     publishes a new WorkOrderScrappedEvent. Chose ADJUSTMENT over
     adding a new SCRAP movement reason to keep the enum stable —
     the reference-string suffix is the disambiguator. The work
     order itself STAYS COMPLETED; scrap is a correction on top of
     a terminal state, not a state change.

  complete() now requires IN_PROGRESS (not DRAFT); existing callers
  must start() first.

  api.v1 grows two events (WorkOrderStartedEvent,
  WorkOrderScrappedEvent) alongside the three that already existed.
  Since this is additive within a major version, the api.v1 semver
  contract holds — existing subscribers continue to compile.

  Liquibase: 002-production-v2.xml widens the status CHECK and
  creates production__work_order_input with (work_order_id FK,
  line_no, item_code, quantity_per_unit, source_location_code) plus
  a unique (work_order_id, line_no) constraint, a CHECK
  quantity_per_unit > 0, and the audit columns. ON DELETE CASCADE
  from the parent.

  Unit tests: WorkOrderServiceTest grows from 8 to 18 cases —
  covers start happy path, start rejection, complete-on-DRAFT
  rejection, empty-BOM complete, BOM-with-two-lines complete
  (verifies both MATERIAL_ISSUE deltas AND the PRODUCTION_RECEIPT
  all fire with the right references), scrap happy path, scrap on
  non-COMPLETED rejection, scrap with non-positive quantity
  rejection, cancel-from-IN_PROGRESS, and BOM validation rejects
  (unknown item, duplicate line_no).

Smoke verified end-to-end against real Postgres:
  - Created WO-SMOKE with 2-line BOM (2 paper + 0.5 ink per
    brochure, output 100).
  - Started (DRAFT → IN_PROGRESS, no ledger rows).
  - Completed: paper balance 500→300 (MATERIAL_ISSUE -200),
    ink 200→150 (MATERIAL_ISSUE -50), FG-BROCHURE 0→100
    (PRODUCTION_RECEIPT +100). All 3 rows tagged WO:WO-SMOKE.
  - Scrapped 7 units: FG-BROCHURE 100→93, ADJUSTMENT -7 tagged
    WO:WO-SMOKE:SCRAP, work order stayed COMPLETED.
  - Auto-spawn: SO-42 confirm still creates WO-FROM-SO-42-L1 as a
    DRAFT with empty BOM; starting + completing it writes only the
    PRODUCTION_RECEIPT (zero MATERIAL_ISSUE rows), proving the
    empty-BOM path is backwards-compatible.
  - Negative paths: complete-on-DRAFT 400s, scrap-on-DRAFT 400s,
    double-start 400s, cancel-from-IN_PROGRESS 200.

240 unit tests, 18 Gradle subprojects.
CLAUDE.md
@@ -96,7 +96,7 @@ plugins (incl. ref) depend on: api/api-v1 only @@ -96,7 +96,7 @@ plugins (incl. ref) depend on: api/api-v1 only
96 **Foundation complete; first business surface in place.** As of the latest commit: 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97
98 - **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`. 98 - **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`.
99 -- **230 unit tests across 18 modules**, all green. `./gradlew build` is the canonical full build. 99 +- **240 unit tests across 18 modules**, all green. `./gradlew build` is the canonical full build.
100 - **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. 100 - **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.
101 - **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. 101 - **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.
102 - **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. 102 - **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.
PROGRESS.md
@@ -10,11 +10,11 @@ @@ -10,11 +10,11 @@
10 10
11 | | | 11 | | |
12 |---|---| 12 |---|---|
13 -| **Latest version** | v0.18.2 (full P4.3 rollout across all PBC controllers) |  
14 -| **Latest commit** | `da386cc feat(security): annotate inventory + orders list/get/create/update endpoints` | 13 +| **Latest version** | v0.19.0 (pbc-production v2 — IN_PROGRESS + BOM + scrap) |
  14 +| **Latest commit** | `TBD feat(production): v2 state machine + BOM + scrap flow` |
15 | **Repo** | https://github.com/reporkey/vibe-erp | 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 | **Modules** | 18 | 16 | **Modules** | 18 |
17 -| **Unit tests** | 230, all green | 17 +| **Unit tests** | 240, all green |
18 | **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:<code>`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). | 18 | **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:<code>`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). |
19 | **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) | 19 | **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) |
20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | 20 | **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 @@ -84,7 +84,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **22 a
84 | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending | 84 | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending |
85 | P5.5 | `pbc-orders-sales` — sales orders + lines (deliveries deferred) | ✅ DONE — `a8eb2a6` | 85 | P5.5 | `pbc-orders-sales` — sales orders + lines (deliveries deferred) | ✅ DONE — `a8eb2a6` |
86 | P5.6 | `pbc-orders-purchase` — POs + receive flow (RFQs deferred) | ✅ DONE — `2861656` | 86 | P5.6 | `pbc-orders-purchase` — POs + receive flow (RFQs deferred) | ✅ DONE — `2861656` |
87 -| 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 | 87 +| 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. |
88 | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending | 88 | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending |
89 | P5.9 | `pbc-finance` — GL, journal entries, AR/AP minimal | ✅ Partial — minimal AR/AP journal entries driven by events; full GL pending | 89 | P5.9 | `pbc-finance` — GL, journal entries, AR/AP minimal | ✅ Partial — minimal AR/AP journal entries driven by events; full GL pending |
90 90
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt
@@ -52,14 +52,39 @@ public data class WorkOrderCreatedEvent( @@ -52,14 +52,39 @@ public data class WorkOrderCreatedEvent(
52 } 52 }
53 53
54 /** 54 /**
55 - * Emitted when a DRAFT work order is completed (terminal happy path). 55 + * Emitted when a DRAFT work order is started — the operator picked
  56 + * it up and production has physically begun. The v2 state machine
  57 + * adds IN_PROGRESS between DRAFT and COMPLETED so "started but not
  58 + * finished" work is observable on a dashboard and addressable by
  59 + * downstream PBCs (capacity, scheduling, shop-floor displays).
  60 + *
  61 + * **v2 design note:** raw materials are NOT consumed on `start()`;
  62 + * they are consumed atomically at `complete()` time. A future v3
  63 + * may split that into "pick materials now, receive output later"
  64 + * but v2 keeps the material-issue and the production-receipt in
  65 + * the same transaction so the ledger stays symmetrical.
  66 + */
  67 +public data class WorkOrderStartedEvent(
  68 + override val orderCode: String,
  69 + override val outputItemCode: String,
  70 + override val outputQuantity: BigDecimal,
  71 + override val eventId: Id<DomainEvent> = Id.random(),
  72 + override val occurredAt: Instant = Instant.now(),
  73 +) : WorkOrderEvent {
  74 + override val aggregateType: String get() = WORK_ORDER_AGGREGATE_TYPE
  75 + override val aggregateId: String get() = orderCode
  76 +}
  77 +
  78 +/**
  79 + * Emitted when an IN_PROGRESS work order is completed (terminal happy path).
56 * 80 *
57 * The companion `PRODUCTION_RECEIPT` ledger row has already been 81 * The companion `PRODUCTION_RECEIPT` ledger row has already been
58 * written by the time this event fires — the publish runs inside 82 * written by the time this event fires — the publish runs inside
59 * the same `@Transactional` method as the inventory write and the 83 * the same `@Transactional` method as the inventory write and the
60 * status flip, so a subscriber that reads `inventory__stock_movement` 84 * status flip, so a subscriber that reads `inventory__stock_movement`
61 * on receipt is guaranteed to see the matching row tagged 85 * on receipt is guaranteed to see the matching row tagged
62 - * `WO:<order_code>`. 86 + * `WO:<order_code>`. The `MATERIAL_ISSUE` rows for any BOM inputs
  87 + * have also already been written in the same transaction.
63 * 88 *
64 * `outputLocationCode` is included so warehouse and dispatch 89 * `outputLocationCode` is included so warehouse and dispatch
65 * subscribers can act on "what just landed where" without 90 * subscribers can act on "what just landed where" without
@@ -78,11 +103,14 @@ public data class WorkOrderCompletedEvent( @@ -78,11 +103,14 @@ public data class WorkOrderCompletedEvent(
78 } 103 }
79 104
80 /** 105 /**
81 - * Emitted when a work order is cancelled from DRAFT. 106 + * Emitted when a work order is cancelled. v2 allows cancel from
  107 + * either DRAFT or IN_PROGRESS — nothing has been written to the
  108 + * ledger yet at those states (v2 only touches inventory at
  109 + * `complete()`) so cancel is a clean status flip.
82 * 110 *
83 * The framework refuses to cancel a COMPLETED work order — that 111 * The framework refuses to cancel a COMPLETED work order — that
84 * would imply un-producing finished goods, which is "scrap them", 112 * would imply un-producing finished goods, which is "scrap them",
85 - * a separate flow that lands later as its own event. 113 + * a separate [WorkOrderScrappedEvent] flow.
86 */ 114 */
87 public data class WorkOrderCancelledEvent( 115 public data class WorkOrderCancelledEvent(
88 override val orderCode: String, 116 override val orderCode: String,
@@ -95,5 +123,41 @@ public data class WorkOrderCancelledEvent( @@ -95,5 +123,41 @@ public data class WorkOrderCancelledEvent(
95 override val aggregateId: String get() = orderCode 123 override val aggregateId: String get() = orderCode
96 } 124 }
97 125
  126 +/**
  127 + * Emitted when a COMPLETED work order has some of its output
  128 + * scrapped (e.g. QC rejected a batch).
  129 + *
  130 + * **Why scrap is post-completion, not a cancel path:** the finished
  131 + * goods ARE on the shelves — they were counted, inspected, and then
  132 + * found defective. Treating that as "uncomplete the work order"
  133 + * would break the ledger's append-only discipline. Instead the
  134 + * [org.vibeerp.pbc.production.application.WorkOrderService.scrap]
  135 + * call writes a negative `ADJUSTMENT` ledger row referencing
  136 + * `WO:<order_code>:SCRAP` so the audit trail is "this is stock that
  137 + * came off THIS work order and was later thrown away". The work
  138 + * order itself stays COMPLETED; scrap is a correction on top.
  139 + *
  140 + * [scrappedQuantity] is the amount destroyed (positive number;
  141 + * the ledger row is negative because it debits stock). [scrapLocationCode]
  142 + * is where the destroyed stock came from — usually the same
  143 + * location the `PRODUCTION_RECEIPT` credited at `complete()` time,
  144 + * but the operator names it explicitly in case the finished goods
  145 + * have been moved since. [note] is a free-form reason captured from
  146 + * the scrap form.
  147 + */
  148 +public data class WorkOrderScrappedEvent(
  149 + override val orderCode: String,
  150 + override val outputItemCode: String,
  151 + override val outputQuantity: BigDecimal,
  152 + public val scrappedQuantity: BigDecimal,
  153 + public val scrapLocationCode: String,
  154 + public val note: String?,
  155 + override val eventId: Id<DomainEvent> = Id.random(),
  156 + override val occurredAt: Instant = Instant.now(),
  157 +) : WorkOrderEvent {
  158 + override val aggregateType: String get() = WORK_ORDER_AGGREGATE_TYPE
  159 + override val aggregateId: String get() = orderCode
  160 +}
  161 +
98 /** Topic string for wildcard / topic-based subscriptions to work order events. */ 162 /** Topic string for wildcard / topic-based subscriptions to work order events. */
99 public const val WORK_ORDER_AGGREGATE_TYPE: String = "production.WorkOrder" 163 public const val WORK_ORDER_AGGREGATE_TYPE: String = "production.WorkOrder"
distribution/src/main/resources/db/changelog/master.xml
@@ -23,4 +23,5 @@ @@ -23,4 +23,5 @@
23 <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/> 23 <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/>
24 <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/> 24 <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/>
25 <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/> 25 <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/>
  26 + <include file="classpath:db/changelog/pbc-production/002-production-v2.xml"/>
26 </databaseChangeLog> 27 </databaseChangeLog>
distribution/src/main/resources/db/changelog/pbc-production/002-production-v2.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 v2 schema additions.
  9 +
  10 + Two things:
  11 +
  12 + 1) Widen the status CHECK constraint to admit IN_PROGRESS.
  13 + v1 locked DRAFT / COMPLETED / CANCELLED only; v2 adds an
  14 + IN_PROGRESS state between DRAFT and COMPLETED so
  15 + started-but-not-finished work is observable.
  16 +
  17 + 2) Create production__work_order_input — the BOM child table.
  18 + Each row is one raw material consumed per unit of output.
  19 + complete() iterates these lines and writes one
  20 + MATERIAL_ISSUE ledger row per line before writing the
  21 + PRODUCTION_RECEIPT, all in one transaction. An empty list
  22 + is legal (complete() just writes the PRODUCTION_RECEIPT
  23 + and nothing else), which lets the SalesOrderConfirmedSubscriber
  24 + keep auto-spawning orders without knowing the BOM.
  25 +
  26 + NEITHER source_location_code NOR item_code is a foreign key —
  27 + same cross-PBC rationale as the parent table. The application
  28 + enforces existence through CatalogApi / LocationJpaRepository
  29 + at create time.
  30 + -->
  31 +
  32 + <changeSet id="production-v2-001-status-in-progress" author="vibe_erp">
  33 + <comment>Widen production__work_order.status CHECK to allow IN_PROGRESS</comment>
  34 + <sql>
  35 + ALTER TABLE production__work_order
  36 + DROP CONSTRAINT production__work_order_status_check;
  37 + ALTER TABLE production__work_order
  38 + ADD CONSTRAINT production__work_order_status_check
  39 + CHECK (status IN ('DRAFT', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'));
  40 + </sql>
  41 + <rollback>
  42 + ALTER TABLE production__work_order
  43 + DROP CONSTRAINT production__work_order_status_check;
  44 + ALTER TABLE production__work_order
  45 + ADD CONSTRAINT production__work_order_status_check
  46 + CHECK (status IN ('DRAFT', 'COMPLETED', 'CANCELLED'));
  47 + </rollback>
  48 + </changeSet>
  49 +
  50 + <changeSet id="production-v2-002-work-order-input" author="vibe_erp">
  51 + <comment>Create production__work_order_input (BOM child table)</comment>
  52 + <sql>
  53 + CREATE TABLE production__work_order_input (
  54 + id uuid PRIMARY KEY,
  55 + work_order_id uuid NOT NULL
  56 + REFERENCES production__work_order(id) ON DELETE CASCADE,
  57 + line_no integer NOT NULL,
  58 + item_code varchar(64) NOT NULL,
  59 + quantity_per_unit numeric(18,4) NOT NULL,
  60 + source_location_code varchar(64) NOT NULL,
  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_input_qty_pos
  67 + CHECK (quantity_per_unit &gt; 0),
  68 + CONSTRAINT production__work_order_input_line_no_pos
  69 + CHECK (line_no &gt; 0),
  70 + CONSTRAINT production__work_order_input_line_uk
  71 + UNIQUE (work_order_id, line_no)
  72 + );
  73 + CREATE INDEX production__work_order_input_wo_idx
  74 + ON production__work_order_input (work_order_id);
  75 + CREATE INDEX production__work_order_input_item_idx
  76 + ON production__work_order_input (item_code);
  77 + </sql>
  78 + <rollback>
  79 + DROP TABLE production__work_order_input;
  80 + </rollback>
  81 + </changeSet>
  82 +
  83 +</databaseChangeLog>
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt
@@ -7,9 +7,12 @@ import org.vibeerp.api.v1.event.EventBus @@ -7,9 +7,12 @@ import org.vibeerp.api.v1.event.EventBus
7 import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent 7 import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent
8 import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent 8 import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent
9 import org.vibeerp.api.v1.event.production.WorkOrderCreatedEvent 9 import org.vibeerp.api.v1.event.production.WorkOrderCreatedEvent
  10 +import org.vibeerp.api.v1.event.production.WorkOrderScrappedEvent
  11 +import org.vibeerp.api.v1.event.production.WorkOrderStartedEvent
10 import org.vibeerp.api.v1.ext.catalog.CatalogApi 12 import org.vibeerp.api.v1.ext.catalog.CatalogApi
11 import org.vibeerp.api.v1.ext.inventory.InventoryApi 13 import org.vibeerp.api.v1.ext.inventory.InventoryApi
12 import org.vibeerp.pbc.production.domain.WorkOrder 14 import org.vibeerp.pbc.production.domain.WorkOrder
  15 +import org.vibeerp.pbc.production.domain.WorkOrderInput
13 import org.vibeerp.pbc.production.domain.WorkOrderStatus 16 import org.vibeerp.pbc.production.domain.WorkOrderStatus
14 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository 17 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
15 import java.math.BigDecimal 18 import java.math.BigDecimal
@@ -21,13 +24,16 @@ import java.util.UUID @@ -21,13 +24,16 @@ import java.util.UUID
21 * 24 *
22 * **Cross-PBC seams** (all read through `api.v1.ext.*` interfaces, 25 * **Cross-PBC seams** (all read through `api.v1.ext.*` interfaces,
23 * never through pbc-* internals): 26 * never through pbc-* internals):
24 - * - [CatalogApi] — validates that the output item exists and is  
25 - * active before a work order can be created.  
26 - * - [InventoryApi] — credits the finished good to the receiving  
27 - * location when [complete] is called, with reason  
28 - * `PRODUCTION_RECEIPT` (added in commit `c52d0d5`) and a  
29 - * `WO:<order_code>` reference. Same primitive that pbc-orders-sales  
30 - * and pbc-orders-purchase call — one ledger, three callers. 27 + * - [CatalogApi] — validates that the output item AND every BOM
  28 + * input item exists and is active before a work order can be
  29 + * created.
  30 + * - [InventoryApi] — on [complete] writes one `MATERIAL_ISSUE`
  31 + * ledger row per BOM input AND a `PRODUCTION_RECEIPT` ledger row
  32 + * for the output, all in the same transaction. On [scrap] writes
  33 + * a negative `ADJUSTMENT` ledger row. Same primitive that
  34 + * pbc-orders-sales and pbc-orders-purchase call — one ledger,
  35 + * now four call-sites (sales ship, purchase receive, work order
  36 + * complete for both sides, work order scrap).
31 * 37 *
32 * **Event publishing.** Each state-changing method publishes a 38 * **Event publishing.** Each state-changing method publishes a
33 * typed event from `api.v1.event.production.*` inside the same 39 * typed event from `api.v1.event.production.*` inside the same
@@ -36,17 +42,26 @@ import java.util.UUID @@ -36,17 +42,26 @@ import java.util.UUID
36 * `Propagation.MANDATORY` so a publish outside a transaction would 42 * `Propagation.MANDATORY` so a publish outside a transaction would
37 * throw — every method here is transactional, so the contract is 43 * throw — every method here is transactional, so the contract is
38 * always met. A failure on any line rolls back the status change 44 * always met. A failure on any line rolls back the status change
39 - * AND the would-have-been outbox row. 45 + * AND every ledger row AND the would-have-been outbox row.
40 * 46 *
41 - * **State machine** (enforced by [complete] and [cancel]):  
42 - * - DRAFT → COMPLETED (complete) 47 + * **v2 state machine** (enforced by [start], [complete], [cancel]):
  48 + * - DRAFT → IN_PROGRESS (start)
  49 + * - IN_PROGRESS → COMPLETED (complete — issues BOM materials, credits finished goods)
43 * - DRAFT → CANCELLED (cancel) 50 * - DRAFT → CANCELLED (cancel)
  51 + * - IN_PROGRESS → CANCELLED (cancel — nothing to undo, v2 only writes to the ledger at complete())
  52 + * - COMPLETED is terminal but admits post-completion scrap via [scrap]
44 * - Anything else throws. 53 * - Anything else throws.
45 * 54 *
46 - * The minimal v1 has no `update` method — work orders are  
47 - * effectively immutable from creation until completion. A future  
48 - * "update output quantity before start" gesture lands as its own  
49 - * verb when the operations team needs it. 55 + * **Why complete() is atomic across ALL inputs AND the output.**
  56 + * A failure halfway through the BOM loop (item missing, location
  57 + * missing, insufficient stock) rolls back every prior MATERIAL_ISSUE
  58 + * of the same complete() call AND the status flip. There is no
  59 + * half-finished work order with some materials issued but the
  60 + * output not credited — it's an all-or-nothing transition.
  61 + *
  62 + * The v2 service still has no `update` method — work orders are
  63 + * immutable from creation until start(). A future chunk may grow a
  64 + * "edit BOM before start" gesture if the operations team needs it.
50 */ 65 */
51 @Service 66 @Service
52 @Transactional 67 @Transactional
@@ -87,6 +102,31 @@ class WorkOrderService( @@ -87,6 +102,31 @@ class WorkOrderService(
87 "output item code '${command.outputItemCode}' is not in the catalog (or is inactive)", 102 "output item code '${command.outputItemCode}' is not in the catalog (or is inactive)",
88 ) 103 )
89 104
  105 + // v2: validate every BOM input line up-front. Catching a bad
  106 + // item or a duplicate line_no HERE instead of at complete()
  107 + // means the operator fixes the mistake while still editing
  108 + // the work order, not when they try to finish a production
  109 + // run. Line numbers must be unique and positive; v2 does not
  110 + // sort them, it trusts the caller's ordering.
  111 + val seenLineNos = HashSet<Int>(command.inputs.size)
  112 + for (input in command.inputs) {
  113 + require(input.lineNo > 0) {
  114 + "work order input line_no must be positive (got ${input.lineNo})"
  115 + }
  116 + require(seenLineNos.add(input.lineNo)) {
  117 + "work order input line_no ${input.lineNo} is duplicated"
  118 + }
  119 + require(input.quantityPerUnit.signum() > 0) {
  120 + "work order input line ${input.lineNo} quantity_per_unit must be positive " +
  121 + "(got ${input.quantityPerUnit})"
  122 + }
  123 + catalogApi.findItemByCode(input.itemCode)
  124 + ?: throw IllegalArgumentException(
  125 + "work order input line ${input.lineNo}: item code '${input.itemCode}' " +
  126 + "is not in the catalog (or is inactive)",
  127 + )
  128 + }
  129 +
90 val order = WorkOrder( 130 val order = WorkOrder(
91 code = command.code, 131 code = command.code,
92 outputItemCode = command.outputItemCode, 132 outputItemCode = command.outputItemCode,
@@ -95,6 +135,19 @@ class WorkOrderService( @@ -95,6 +135,19 @@ class WorkOrderService(
95 dueDate = command.dueDate, 135 dueDate = command.dueDate,
96 sourceSalesOrderCode = command.sourceSalesOrderCode, 136 sourceSalesOrderCode = command.sourceSalesOrderCode,
97 ) 137 )
  138 + // Attach BOM children BEFORE the first save so Hibernate
  139 + // cascades the whole graph in one commit.
  140 + for (input in command.inputs) {
  141 + order.inputs.add(
  142 + WorkOrderInput(
  143 + workOrder = order,
  144 + lineNo = input.lineNo,
  145 + itemCode = input.itemCode,
  146 + quantityPerUnit = input.quantityPerUnit,
  147 + sourceLocationCode = input.sourceLocationCode,
  148 + ),
  149 + )
  150 + }
98 val saved = orders.save(order) 151 val saved = orders.save(order)
99 152
100 eventBus.publish( 153 eventBus.publish(
@@ -109,29 +162,86 @@ class WorkOrderService( @@ -109,29 +162,86 @@ class WorkOrderService(
109 } 162 }
110 163
111 /** 164 /**
112 - * Mark a DRAFT work order as COMPLETED, crediting [outputLocationCode]  
113 - * with the output quantity in the same transaction. 165 + * Start a DRAFT work order — the operator has physically begun
  166 + * production. v2 writes NOTHING to the inventory ledger on
  167 + * start(); materials are consumed atomically at [complete] time.
  168 + * start() exists to make "started but not finished" work
  169 + * observable on a shop-floor dashboard.
  170 + */
  171 + fun start(id: UUID): WorkOrder {
  172 + val order = orders.findById(id).orElseThrow {
  173 + NoSuchElementException("work order not found: $id")
  174 + }
  175 + require(order.status == WorkOrderStatus.DRAFT) {
  176 + "cannot start work order ${order.code} in status ${order.status}; " +
  177 + "only DRAFT can be started"
  178 + }
  179 + order.status = WorkOrderStatus.IN_PROGRESS
  180 +
  181 + eventBus.publish(
  182 + WorkOrderStartedEvent(
  183 + orderCode = order.code,
  184 + outputItemCode = order.outputItemCode,
  185 + outputQuantity = order.outputQuantity,
  186 + ),
  187 + )
  188 + return order
  189 + }
  190 +
  191 + /**
  192 + * Mark an IN_PROGRESS work order as COMPLETED, issuing every BOM
  193 + * input AND crediting [outputLocationCode] with the output
  194 + * quantity — all in the same transaction.
114 * 195 *
115 - * **Cross-PBC WRITE** through the same `InventoryApi.recordMovement` 196 + * **Cross-PBC WRITES** through the same `InventoryApi.recordMovement`
116 * facade that pbc-orders-purchase uses for receipt and pbc-orders-sales 197 * facade that pbc-orders-purchase uses for receipt and pbc-orders-sales
117 - * uses for shipment. The reason is `PRODUCTION_RECEIPT` and the  
118 - * reference is `WO:<order_code>` so a ledger reader can attribute  
119 - * the row to this work order. 198 + * uses for shipment:
  199 + * 1. For each BOM line, a NEGATIVE `MATERIAL_ISSUE` movement
  200 + * debiting `quantityPerUnit × outputQuantity` from the line's
  201 + * `sourceLocationCode`.
  202 + * 2. A positive `PRODUCTION_RECEIPT` movement crediting
  203 + * `outputQuantity` to [outputLocationCode].
  204 + *
  205 + * Both sets of rows reference `WO:<order_code>` so a ledger
  206 + * reader can attribute the row to this work order. The inputs
  207 + * are processed in `lineNo` order for determinism (the BOM
  208 + * collection is @OrderBy("lineNo ASC")).
120 * 209 *
121 - * The whole operation runs in ONE transaction. A failure on the  
122 - * inventory write — bad item, bad location, would push balance  
123 - * negative (impossible for a positive delta but the framework  
124 - * checks it anyway) — rolls back BOTH the ledger row AND the  
125 - * status change. There is no half-completed work order with  
126 - * a partial inventory write. 210 + * The whole operation runs in ONE transaction. A failure anywhere
  211 + * in the loop — bad item, bad location, balance would go
  212 + * negative — rolls back BOTH every already-written ledger row
  213 + * AND the status flip. There is no half-completed work order
  214 + * with some materials issued but the output not credited.
  215 + *
  216 + * **Empty BOM is legal.** An auto-spawned work order (from
  217 + * SalesOrderConfirmedSubscriber) has no inputs at v2 and will
  218 + * just write the PRODUCTION_RECEIPT on complete() without any
  219 + * MATERIAL_ISSUE rows. That's the same behavior as v1 and is
  220 + * why v2 is backwards-compatible at the ledger level.
127 */ 221 */
128 fun complete(id: UUID, outputLocationCode: String): WorkOrder { 222 fun complete(id: UUID, outputLocationCode: String): WorkOrder {
129 val order = orders.findById(id).orElseThrow { 223 val order = orders.findById(id).orElseThrow {
130 NoSuchElementException("work order not found: $id") 224 NoSuchElementException("work order not found: $id")
131 } 225 }
132 - require(order.status == WorkOrderStatus.DRAFT) { 226 + require(order.status == WorkOrderStatus.IN_PROGRESS) {
133 "cannot complete work order ${order.code} in status ${order.status}; " + 227 "cannot complete work order ${order.code} in status ${order.status}; " +
134 - "only DRAFT can be completed" 228 + "only IN_PROGRESS can be completed"
  229 + }
  230 +
  231 + // Issue every BOM input FIRST. Doing the materials before the
  232 + // output means a bad BOM (missing item, insufficient stock)
  233 + // fails the call before any PRODUCTION_RECEIPT is written —
  234 + // keeping the transaction's failure mode "nothing happened"
  235 + // rather than "output credited but material debits missing".
  236 + for (input in order.inputs) {
  237 + val totalIssued = input.quantityPerUnit.multiply(order.outputQuantity)
  238 + inventoryApi.recordMovement(
  239 + itemCode = input.itemCode,
  240 + locationCode = input.sourceLocationCode,
  241 + delta = totalIssued.negate(),
  242 + reason = "MATERIAL_ISSUE",
  243 + reference = "WO:${order.code}",
  244 + )
135 } 245 }
136 246
137 // Credit the finished good to the receiving location. 247 // Credit the finished good to the receiving location.
@@ -156,17 +266,84 @@ class WorkOrderService( @@ -156,17 +266,84 @@ class WorkOrderService(
156 return order 266 return order
157 } 267 }
158 268
  269 + /**
  270 + * Scrap some or all of a COMPLETED work order's finished-goods
  271 + * output. Writes a negative `ADJUSTMENT` ledger row tagged
  272 + * `WO:<code>:SCRAP` (the `:SCRAP` suffix is the convention for
  273 + * "this is a post-completion correction on a work order").
  274 + *
  275 + * **Why ADJUSTMENT rather than a new SCRAP enum value:**
  276 + * ADJUSTMENT already admits either sign and is the documented
  277 + * "operator-driven correction" reason. Adding SCRAP as a new
  278 + * enum value would be non-breaking (the column is varchar) but
  279 + * would mean two reasons fighting for the same semantics. The
  280 + * reference-string convention does the disambiguation without
  281 + * growing the enum.
  282 + *
  283 + * **Work order stays COMPLETED.** Scrap is a correction ON TOP
  284 + * of a terminal state, not a state change. The operator can call
  285 + * scrap multiple times if defects are discovered in batches —
  286 + * v2 trusts the operator and doesn't track accumulated scrap.
  287 + *
  288 + * Publishes [WorkOrderScrappedEvent] in the same transaction as
  289 + * the ledger write. Both the ledger row and the event carry the
  290 + * scrapped quantity + location + optional note so a downstream
  291 + * quality PBC can react.
  292 + */
  293 + fun scrap(
  294 + id: UUID,
  295 + scrapLocationCode: String,
  296 + quantity: BigDecimal,
  297 + note: String?,
  298 + ): WorkOrder {
  299 + val order = orders.findById(id).orElseThrow {
  300 + NoSuchElementException("work order not found: $id")
  301 + }
  302 + require(order.status == WorkOrderStatus.COMPLETED) {
  303 + "cannot scrap work order ${order.code} in status ${order.status}; " +
  304 + "only COMPLETED work orders can be scrapped"
  305 + }
  306 + require(quantity.signum() > 0) {
  307 + "scrap quantity must be positive (got $quantity)"
  308 + }
  309 +
  310 + inventoryApi.recordMovement(
  311 + itemCode = order.outputItemCode,
  312 + locationCode = scrapLocationCode,
  313 + delta = quantity.negate(),
  314 + reason = "ADJUSTMENT",
  315 + reference = "WO:${order.code}:SCRAP",
  316 + )
  317 +
  318 + eventBus.publish(
  319 + WorkOrderScrappedEvent(
  320 + orderCode = order.code,
  321 + outputItemCode = order.outputItemCode,
  322 + outputQuantity = order.outputQuantity,
  323 + scrappedQuantity = quantity,
  324 + scrapLocationCode = scrapLocationCode,
  325 + note = note,
  326 + ),
  327 + )
  328 + return order
  329 + }
  330 +
159 fun cancel(id: UUID): WorkOrder { 331 fun cancel(id: UUID): WorkOrder {
160 val order = orders.findById(id).orElseThrow { 332 val order = orders.findById(id).orElseThrow {
161 NoSuchElementException("work order not found: $id") 333 NoSuchElementException("work order not found: $id")
162 } 334 }
163 // COMPLETED is terminal — once finished goods exist on the 335 // COMPLETED is terminal — once finished goods exist on the
164 // shelves the framework will NOT let you "uncomplete" them. 336 // shelves the framework will NOT let you "uncomplete" them.
165 - // A real shop floor needs a "scrap defective output" flow,  
166 - // but that's its own event for a future chunk.  
167 - require(order.status == WorkOrderStatus.DRAFT) { 337 + // Use [scrap] for post-completion corrections. v2 allows
  338 + // cancel from either DRAFT or IN_PROGRESS because v2 writes
  339 + // nothing to the ledger at start() time, so there is never
  340 + // anything to un-do on a cancel.
  341 + require(
  342 + order.status == WorkOrderStatus.DRAFT ||
  343 + order.status == WorkOrderStatus.IN_PROGRESS,
  344 + ) {
168 "cannot cancel work order ${order.code} in status ${order.status}; " + 345 "cannot cancel work order ${order.code} in status ${order.status}; " +
169 - "only DRAFT work orders can be cancelled" 346 + "only DRAFT or IN_PROGRESS work orders can be cancelled"
170 } 347 }
171 order.status = WorkOrderStatus.CANCELLED 348 order.status = WorkOrderStatus.CANCELLED
172 349
@@ -187,4 +364,20 @@ data class CreateWorkOrderCommand( @@ -187,4 +364,20 @@ data class CreateWorkOrderCommand(
187 val outputQuantity: BigDecimal, 364 val outputQuantity: BigDecimal,
188 val dueDate: LocalDate? = null, 365 val dueDate: LocalDate? = null,
189 val sourceSalesOrderCode: String? = null, 366 val sourceSalesOrderCode: String? = null,
  367 + /**
  368 + * BOM lines — the raw materials that must be consumed per unit
  369 + * of output. Empty list is legal (produces the v1 behavior:
  370 + * complete() writes only the PRODUCTION_RECEIPT).
  371 + */
  372 + val inputs: List<WorkOrderInputCommand> = emptyList(),
  373 +)
  374 +
  375 +/**
  376 + * One BOM line on a work order create command.
  377 + */
  378 +data class WorkOrderInputCommand(
  379 + val lineNo: Int,
  380 + val itemCode: String,
  381 + val quantityPerUnit: BigDecimal,
  382 + val sourceLocationCode: String,
190 ) 383 )
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt
1 package org.vibeerp.pbc.production.domain 1 package org.vibeerp.pbc.production.domain
2 2
  3 +import jakarta.persistence.CascadeType
3 import jakarta.persistence.Column 4 import jakarta.persistence.Column
4 import jakarta.persistence.Entity 5 import jakarta.persistence.Entity
5 import jakarta.persistence.EnumType 6 import jakarta.persistence.EnumType
6 import jakarta.persistence.Enumerated 7 import jakarta.persistence.Enumerated
  8 +import jakarta.persistence.FetchType
  9 +import jakarta.persistence.OneToMany
  10 +import jakarta.persistence.OrderBy
7 import jakarta.persistence.Table 11 import jakarta.persistence.Table
8 import org.hibernate.annotations.JdbcTypeCode 12 import org.hibernate.annotations.JdbcTypeCode
9 import org.hibernate.type.SqlTypes 13 import org.hibernate.type.SqlTypes
@@ -24,39 +28,54 @@ import java.time.LocalDate @@ -24,39 +28,54 @@ import java.time.LocalDate
24 * customer ordered 1000 brochures, the floor produced 1000 brochures, 28 * customer ordered 1000 brochures, the floor produced 1000 brochures,
25 * stock now contains 1000 brochures". 29 * stock now contains 1000 brochures".
26 * 30 *
27 - * **What v1 deliberately does NOT model:**  
28 - * - **No bill of materials.** A real BOM would list every raw  
29 - * material consumed per unit of output. The minimal v1 only  
30 - * tracks the output side (production_receipt) — material issues  
31 - * happen as separate manual movements via the ledger. The next  
32 - * chunk after this one will probably add a `WorkOrderInput`  
33 - * child entity and have `complete()` issue raw materials too. 31 + * **v2 vs v1.** v1 shipped the minimal spine (DRAFT → COMPLETED in
  32 + * one step, single-output, no BOM). v2 adds:
  33 + * - An **IN_PROGRESS** state so "started but not finished" work is
  34 + * observable on a shop-floor dashboard.
  35 + * - A **bill of materials** via the [inputs] child collection —
  36 + * each [WorkOrderInput] row names a raw material, a per-unit
  37 + * consumption quantity, and a source location. On
  38 + * `complete(outputLocationCode)` the service iterates the inputs
  39 + * and writes one `MATERIAL_ISSUE` ledger row per line BEFORE
  40 + * writing the `PRODUCTION_RECEIPT` — all in the same transaction.
  41 + * - A **scrap** verb for COMPLETED work orders that writes a
  42 + * negative `ADJUSTMENT` ledger row tagged `WO:<code>:SCRAP`
  43 + * without changing the order's terminal status.
  44 + *
  45 + * **What v2 still deliberately does NOT model:**
34 * - **No routings or operations.** A real shop floor would model 46 * - **No routings or operations.** A real shop floor would model
35 * each step of production (cut, print, fold, bind, pack) with 47 * each step of production (cut, print, fold, bind, pack) with
36 - * its own duration and machine assignment. v1 collapses the 48 + * its own duration and machine assignment. v2 collapses the
37 * whole journey into a single complete() call. 49 * whole journey into a single complete() call.
38 - * - **No IN_PROGRESS state.** v1 has DRAFT → COMPLETED in one  
39 - * step; v2 will add IN_PROGRESS so a started-but-not-finished  
40 - * order is observable on a dashboard.  
41 * - **No scheduling, no capacity planning, no due-date enforcement.** 50 * - **No scheduling, no capacity planning, no due-date enforcement.**
42 * A `dueDate` field exists for display only; nothing in the 51 * A `dueDate` field exists for display only; nothing in the
43 * framework refuses a late completion. 52 * framework refuses a late completion.
  53 + * - **No BOM on auto-spawned orders.** The
  54 + * [SalesOrderConfirmedSubscriber] spawns DRAFT work orders with
  55 + * an empty [inputs] list because a sales-order line does not
  56 + * carry BOM data. An operator wanting material enforcement
  57 + * creates the work order manually with its inputs; a future v3
  58 + * may grow the catalog item master with a default BOM template
  59 + * that the subscriber can copy onto auto-spawned orders.
44 * 60 *
45 - * **State machine:**  
46 - * - **DRAFT** — created but not yet completed. Lines may still  
47 - * change (in this v1 there are no lines, just  
48 - * the output item + quantity).  
49 - * - **COMPLETED** — terminal happy path. The  
50 - * `complete(outputLocationCode)` call has written  
51 - * a `PRODUCTION_RECEIPT` ledger row crediting the  
52 - * output item at the named location, and the  
53 - * `WorkOrderCompletedEvent` has been published in  
54 - * the same transaction.  
55 - * - **CANCELLED** — terminal. Reachable only from DRAFT — you  
56 - * cannot "uncomplete" finished goods. A real shop  
57 - * will eventually need a "scrap" flow for  
58 - * completed-but-defective output, but that's a  
59 - * different event for a future chunk. 61 + * **State machine (v2):**
  62 + * - **DRAFT** — created but not yet started. Inputs may still
  63 + * be edited (service-side update is a future
  64 + * chunk; v2 freezes inputs at create time).
  65 + * - **IN_PROGRESS** — operator has started production. No ledger
  66 + * rows written yet; this state just makes
  67 + * started work observable.
  68 + * - **COMPLETED** — terminal happy path. `complete(outputLocationCode)`
  69 + * has written one `MATERIAL_ISSUE` per BOM
  70 + * input AND the `PRODUCTION_RECEIPT` crediting
  71 + * the output item, all in the same transaction.
  72 + * `WorkOrderCompletedEvent` has been published.
  73 + * Post-completion scrap is still allowed via
  74 + * `scrap()` without leaving the state.
  75 + * - **CANCELLED** — terminal. Reachable from DRAFT or IN_PROGRESS.
  76 + * The framework will NOT let you cancel from
  77 + * COMPLETED — "un-producing finished goods"
  78 + * doesn't exist; scrap them instead.
60 * 79 *
61 * **Why `output_item_code` and `output_quantity` are columns rather 80 * **Why `output_item_code` and `output_quantity` are columns rather
62 * than a one-line `WorkOrderLine` child entity:** v1 produces 81 * than a one-line `WorkOrderLine` child entity:** v1 produces
@@ -107,16 +126,47 @@ class WorkOrder( @@ -107,16 +126,47 @@ class WorkOrder(
107 @JdbcTypeCode(SqlTypes.JSON) 126 @JdbcTypeCode(SqlTypes.JSON)
108 var ext: String = "{}" 127 var ext: String = "{}"
109 128
  129 + /**
  130 + * The BOM — zero or more raw material lines consumed per unit of
  131 + * output. An empty list is a legal v2 state and means "this work
  132 + * order has no tracked material inputs"; `complete()` will just
  133 + * write the `PRODUCTION_RECEIPT` without any `MATERIAL_ISSUE`
  134 + * rows. Eagerly fetched because every read of a work order
  135 + * header is followed in practice by a read of its inputs (the
  136 + * shop-floor view shows them, the complete() call iterates them).
  137 + *
  138 + * `orphanRemoval = true` means dropping a line from the list
  139 + * deletes the child row; `cascade = ALL` means a save on the
  140 + * parent saves the children. Same convention as [SalesOrder.lines].
  141 + */
  142 + @OneToMany(
  143 + mappedBy = "workOrder",
  144 + cascade = [CascadeType.ALL],
  145 + orphanRemoval = true,
  146 + fetch = FetchType.EAGER,
  147 + )
  148 + @OrderBy("lineNo ASC")
  149 + var inputs: MutableList<WorkOrderInput> = mutableListOf()
  150 +
110 override fun toString(): String = 151 override fun toString(): String =
111 - "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', status=$status)" 152 + "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', status=$status, inputs=${inputs.size})"
112 } 153 }
113 154
114 /** 155 /**
115 * State machine values for [WorkOrder]. See the entity KDoc for the 156 * State machine values for [WorkOrder]. See the entity KDoc for the
116 * allowed transitions and the rationale for each one. 157 * allowed transitions and the rationale for each one.
  158 + *
  159 + * **v2 state graph:**
  160 + * - DRAFT → IN_PROGRESS (start)
  161 + * - DRAFT → CANCELLED (cancel)
  162 + * - IN_PROGRESS → COMPLETED (complete — issues BOM materials, credits finished goods)
  163 + * - IN_PROGRESS → CANCELLED (cancel — nothing to undo, v2 only writes to the ledger at complete())
  164 + * - COMPLETED (terminal; scrap is a post-completion correction, not a state change)
  165 + * - CANCELLED (terminal)
117 */ 166 */
118 enum class WorkOrderStatus { 167 enum class WorkOrderStatus {
119 DRAFT, 168 DRAFT,
  169 + IN_PROGRESS,
120 COMPLETED, 170 COMPLETED,
121 CANCELLED, 171 CANCELLED,
122 } 172 }
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrderInput.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.JoinColumn
  6 +import jakarta.persistence.ManyToOne
  7 +import jakarta.persistence.Table
  8 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  9 +import java.math.BigDecimal
  10 +
  11 +/**
  12 + * One BOM line of a [WorkOrder] — a raw material that must be
  13 + * consumed to produce the work order's output.
  14 + *
  15 + * **v2 shape: per-unit consumption.** [quantityPerUnit] is the
  16 + * amount of [itemCode] consumed per ONE unit of the parent's
  17 + * [WorkOrder.outputQuantity]. The total material issued on
  18 + * `complete()` is `quantityPerUnit * outputQuantity`. Per-unit
  19 + * was chosen over "absolute total" because it survives edits to
  20 + * the parent's output quantity without having to re-calculate
  21 + * every BOM line, which is the common ergonomic on every shop
  22 + * floor. A 100-brochure run scaled up to 200 automatically needs
  23 + * twice the paper.
  24 + *
  25 + * **Why `item_code` is a varchar, not a UUID FK:** same cross-PBC
  26 + * rule the rest of pbc-production follows. The raw material is a
  27 + * catalog item owned by pbc-catalog, and the link is enforced at
  28 + * the application layer through `CatalogApi.findItemByCode` at
  29 + * create time. A DB-level FK would couple pbc-production's schema
  30 + * with pbc-catalog, which CLAUDE.md guardrail #9 refuses.
  31 + *
  32 + * **Why `source_location_code` lives on each LINE, not on the
  33 + * work-order header:** different raw materials can come from
  34 + * different warehouses (paper from the paper store, ink from the
  35 + * ink store, bindings from the finished-components room). Holding
  36 + * the source on the line keeps the BOM honest and the smoke-test
  37 + * ledger readable.
  38 + *
  39 + * **No `ext` JSONB on the line** — same rationale as
  40 + * [org.vibeerp.pbc.orders.sales.domain.SalesOrderLine]: lines are
  41 + * facts, not master records.
  42 + */
  43 +@Entity
  44 +@Table(name = "production__work_order_input")
  45 +class WorkOrderInput(
  46 + workOrder: WorkOrder,
  47 + lineNo: Int,
  48 + itemCode: String,
  49 + quantityPerUnit: BigDecimal,
  50 + sourceLocationCode: String,
  51 +) : AuditedJpaEntity() {
  52 +
  53 + @ManyToOne
  54 + @JoinColumn(name = "work_order_id", nullable = false)
  55 + var workOrder: WorkOrder = workOrder
  56 +
  57 + @Column(name = "line_no", nullable = false)
  58 + var lineNo: Int = lineNo
  59 +
  60 + @Column(name = "item_code", nullable = false, length = 64)
  61 + var itemCode: String = itemCode
  62 +
  63 + @Column(name = "quantity_per_unit", nullable = false, precision = 18, scale = 4)
  64 + var quantityPerUnit: BigDecimal = quantityPerUnit
  65 +
  66 + @Column(name = "source_location_code", nullable = false, length = 64)
  67 + var sourceLocationCode: String = sourceLocationCode
  68 +
  69 + /**
  70 + * The total quantity that must be issued from [sourceLocationCode]
  71 + * when the parent work order is completed: `quantityPerUnit ×
  72 + * outputQuantity`. Computed at read time from the two stored
  73 + * inputs so the storage cannot drift.
  74 + */
  75 + fun totalQuantity(): BigDecimal =
  76 + quantityPerUnit.multiply(workOrder.outputQuantity)
  77 +
  78 + override fun toString(): String =
  79 + "WorkOrderInput(id=$id, woId=${workOrder.id}, line=$lineNo, " +
  80 + "item='$itemCode', qtyPerUnit=$quantityPerUnit, srcLoc='$sourceLocationCode')"
  81 +}
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt
@@ -16,8 +16,10 @@ import org.springframework.web.bind.annotation.RequestMapping @@ -16,8 +16,10 @@ import org.springframework.web.bind.annotation.RequestMapping
16 import org.springframework.web.bind.annotation.ResponseStatus 16 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.WorkOrderService 20 import org.vibeerp.pbc.production.application.WorkOrderService
20 import org.vibeerp.pbc.production.domain.WorkOrder 21 import org.vibeerp.pbc.production.domain.WorkOrder
  22 +import org.vibeerp.pbc.production.domain.WorkOrderInput
21 import org.vibeerp.pbc.production.domain.WorkOrderStatus 23 import org.vibeerp.pbc.production.domain.WorkOrderStatus
22 import org.vibeerp.platform.security.authz.RequirePermission 24 import org.vibeerp.platform.security.authz.RequirePermission
23 import java.math.BigDecimal 25 import java.math.BigDecimal
@@ -63,10 +65,23 @@ class WorkOrderController( @@ -63,10 +65,23 @@ class WorkOrderController(
63 workOrderService.create(request.toCommand()).toResponse() 65 workOrderService.create(request.toCommand()).toResponse()
64 66
65 /** 67 /**
66 - * Mark a DRAFT work order as COMPLETED. Atomically credits the  
67 - * output quantity to the named location via  
68 - * `InventoryApi.recordMovement(PRODUCTION_RECEIPT)`. See  
69 - * [WorkOrderService.complete] for the full rationale. 68 + * Start a DRAFT work order — flip to IN_PROGRESS. v2 state
  69 + * machine: DRAFT → IN_PROGRESS. Nothing is written to the
  70 + * inventory ledger at start() time; materials and output are
  71 + * all consumed at `complete()` in one transaction. See
  72 + * [WorkOrderService.start].
  73 + */
  74 + @PostMapping("/{id}/start")
  75 + @RequirePermission("production.work-order.start")
  76 + fun start(@PathVariable id: UUID): WorkOrderResponse =
  77 + workOrderService.start(id).toResponse()
  78 +
  79 + /**
  80 + * Mark an IN_PROGRESS work order as COMPLETED. Atomically:
  81 + * - issues every BOM input as `MATERIAL_ISSUE`
  82 + * - credits the output as `PRODUCTION_RECEIPT`
  83 + * in a single transaction. See [WorkOrderService.complete] for
  84 + * the full rationale.
70 */ 85 */
71 @PostMapping("/{id}/complete") 86 @PostMapping("/{id}/complete")
72 @RequirePermission("production.work-order.complete") 87 @RequirePermission("production.work-order.complete")
@@ -80,6 +95,24 @@ class WorkOrderController( @@ -80,6 +95,24 @@ class WorkOrderController(
80 @RequirePermission("production.work-order.cancel") 95 @RequirePermission("production.work-order.cancel")
81 fun cancel(@PathVariable id: UUID): WorkOrderResponse = 96 fun cancel(@PathVariable id: UUID): WorkOrderResponse =
82 workOrderService.cancel(id).toResponse() 97 workOrderService.cancel(id).toResponse()
  98 +
  99 + /**
  100 + * Scrap some of a COMPLETED work order's output. Writes a
  101 + * negative `ADJUSTMENT` ledger row; the work order itself stays
  102 + * COMPLETED. See [WorkOrderService.scrap] for the full rationale.
  103 + */
  104 + @PostMapping("/{id}/scrap")
  105 + @RequirePermission("production.work-order.scrap")
  106 + fun scrap(
  107 + @PathVariable id: UUID,
  108 + @RequestBody @Valid request: ScrapWorkOrderRequest,
  109 + ): WorkOrderResponse =
  110 + workOrderService.scrap(
  111 + id = id,
  112 + scrapLocationCode = request.scrapLocationCode,
  113 + quantity = request.quantity,
  114 + note = request.note,
  115 + ).toResponse()
83 } 116 }
84 117
85 // ─── DTOs ──────────────────────────────────────────────────────────── 118 // ─── DTOs ────────────────────────────────────────────────────────────
@@ -90,6 +123,12 @@ data class CreateWorkOrderRequest( @@ -90,6 +123,12 @@ data class CreateWorkOrderRequest(
90 @field:NotNull val outputQuantity: BigDecimal, 123 @field:NotNull val outputQuantity: BigDecimal,
91 val dueDate: LocalDate? = null, 124 val dueDate: LocalDate? = null,
92 @field:Size(max = 64) val sourceSalesOrderCode: String? = null, 125 @field:Size(max = 64) val sourceSalesOrderCode: String? = null,
  126 + /**
  127 + * v2 BOM lines. Empty list is legal — the work order will just
  128 + * credit finished goods without issuing any materials on
  129 + * complete().
  130 + */
  131 + @field:Valid val inputs: List<WorkOrderInputRequest> = emptyList(),
93 ) { 132 ) {
94 fun toCommand(): CreateWorkOrderCommand = CreateWorkOrderCommand( 133 fun toCommand(): CreateWorkOrderCommand = CreateWorkOrderCommand(
95 code = code, 134 code = code,
@@ -97,6 +136,21 @@ data class CreateWorkOrderRequest( @@ -97,6 +136,21 @@ data class CreateWorkOrderRequest(
97 outputQuantity = outputQuantity, 136 outputQuantity = outputQuantity,
98 dueDate = dueDate, 137 dueDate = dueDate,
99 sourceSalesOrderCode = sourceSalesOrderCode, 138 sourceSalesOrderCode = sourceSalesOrderCode,
  139 + inputs = inputs.map { it.toCommand() },
  140 + )
  141 +}
  142 +
  143 +data class WorkOrderInputRequest(
  144 + @field:NotNull val lineNo: Int,
  145 + @field:NotBlank @field:Size(max = 64) val itemCode: String,
  146 + @field:NotNull val quantityPerUnit: BigDecimal,
  147 + @field:NotBlank @field:Size(max = 64) val sourceLocationCode: String,
  148 +) {
  149 + fun toCommand(): WorkOrderInputCommand = WorkOrderInputCommand(
  150 + lineNo = lineNo,
  151 + itemCode = itemCode,
  152 + quantityPerUnit = quantityPerUnit,
  153 + sourceLocationCode = sourceLocationCode,
100 ) 154 )
101 } 155 }
102 156
@@ -116,6 +170,17 @@ data class CompleteWorkOrderRequest @JsonCreator(mode = JsonCreator.Mode.PROPERT @@ -116,6 +170,17 @@ data class CompleteWorkOrderRequest @JsonCreator(mode = JsonCreator.Mode.PROPERT
116 @field:NotBlank @field:Size(max = 64) val outputLocationCode: String, 170 @field:NotBlank @field:Size(max = 64) val outputLocationCode: String,
117 ) 171 )
118 172
  173 +/**
  174 + * Scrap request body. Multi-arg data class — no Jackson single-arg
  175 + * trap here, but the two-place body `{"scrapLocationCode", "quantity",
  176 + * "note"}` is explicit for clarity.
  177 + */
  178 +data class ScrapWorkOrderRequest(
  179 + @field:NotBlank @field:Size(max = 64) val scrapLocationCode: String,
  180 + @field:NotNull val quantity: BigDecimal,
  181 + @field:Size(max = 512) val note: String? = null,
  182 +)
  183 +
119 data class WorkOrderResponse( 184 data class WorkOrderResponse(
120 val id: UUID, 185 val id: UUID,
121 val code: String, 186 val code: String,
@@ -124,6 +189,15 @@ data class WorkOrderResponse( @@ -124,6 +189,15 @@ data class WorkOrderResponse(
124 val status: WorkOrderStatus, 189 val status: WorkOrderStatus,
125 val dueDate: LocalDate?, 190 val dueDate: LocalDate?,
126 val sourceSalesOrderCode: String?, 191 val sourceSalesOrderCode: String?,
  192 + val inputs: List<WorkOrderInputResponse>,
  193 +)
  194 +
  195 +data class WorkOrderInputResponse(
  196 + val id: UUID,
  197 + val lineNo: Int,
  198 + val itemCode: String,
  199 + val quantityPerUnit: BigDecimal,
  200 + val sourceLocationCode: String,
127 ) 201 )
128 202
129 private fun WorkOrder.toResponse(): WorkOrderResponse = 203 private fun WorkOrder.toResponse(): WorkOrderResponse =
@@ -135,4 +209,14 @@ private fun WorkOrder.toResponse(): WorkOrderResponse = @@ -135,4 +209,14 @@ private fun WorkOrder.toResponse(): WorkOrderResponse =
135 status = status, 209 status = status,
136 dueDate = dueDate, 210 dueDate = dueDate,
137 sourceSalesOrderCode = sourceSalesOrderCode, 211 sourceSalesOrderCode = sourceSalesOrderCode,
  212 + inputs = inputs.map { it.toResponse() },
  213 + )
  214 +
  215 +private fun WorkOrderInput.toResponse(): WorkOrderInputResponse =
  216 + WorkOrderInputResponse(
  217 + id = id,
  218 + lineNo = lineNo,
  219 + itemCode = itemCode,
  220 + quantityPerUnit = quantityPerUnit,
  221 + sourceLocationCode = sourceLocationCode,
138 ) 222 )
pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt
@@ -3,6 +3,7 @@ package org.vibeerp.pbc.production.application @@ -3,6 +3,7 @@ package org.vibeerp.pbc.production.application
3 import assertk.assertFailure 3 import assertk.assertFailure
4 import assertk.assertThat 4 import assertk.assertThat
5 import assertk.assertions.hasMessage 5 import assertk.assertions.hasMessage
  6 +import assertk.assertions.hasSize
6 import assertk.assertions.isEqualTo 7 import assertk.assertions.isEqualTo
7 import assertk.assertions.isInstanceOf 8 import assertk.assertions.isInstanceOf
8 import assertk.assertions.messageContains 9 import assertk.assertions.messageContains
@@ -19,11 +20,14 @@ import org.vibeerp.api.v1.event.EventBus @@ -19,11 +20,14 @@ import org.vibeerp.api.v1.event.EventBus
19 import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent 20 import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent
20 import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent 21 import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent
21 import org.vibeerp.api.v1.event.production.WorkOrderCreatedEvent 22 import org.vibeerp.api.v1.event.production.WorkOrderCreatedEvent
  23 +import org.vibeerp.api.v1.event.production.WorkOrderScrappedEvent
  24 +import org.vibeerp.api.v1.event.production.WorkOrderStartedEvent
22 import org.vibeerp.api.v1.ext.catalog.CatalogApi 25 import org.vibeerp.api.v1.ext.catalog.CatalogApi
23 import org.vibeerp.api.v1.ext.catalog.ItemRef 26 import org.vibeerp.api.v1.ext.catalog.ItemRef
24 import org.vibeerp.api.v1.ext.inventory.InventoryApi 27 import org.vibeerp.api.v1.ext.inventory.InventoryApi
25 import org.vibeerp.api.v1.ext.inventory.StockBalanceRef 28 import org.vibeerp.api.v1.ext.inventory.StockBalanceRef
26 import org.vibeerp.pbc.production.domain.WorkOrder 29 import org.vibeerp.pbc.production.domain.WorkOrder
  30 +import org.vibeerp.pbc.production.domain.WorkOrderInput
27 import org.vibeerp.pbc.production.domain.WorkOrderStatus 31 import org.vibeerp.pbc.production.domain.WorkOrderStatus
28 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository 32 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
29 import java.math.BigDecimal 33 import java.math.BigDecimal
@@ -78,6 +82,48 @@ class WorkOrderServiceTest { @@ -78,6 +82,48 @@ class WorkOrderServiceTest {
78 ) 82 )
79 } 83 }
80 84
  85 + private fun stubInventoryIssue(
  86 + itemCode: String,
  87 + locationCode: String,
  88 + expectedDelta: BigDecimal,
  89 + ) {
  90 + every {
  91 + inventoryApi.recordMovement(
  92 + itemCode = itemCode,
  93 + locationCode = locationCode,
  94 + delta = expectedDelta,
  95 + reason = "MATERIAL_ISSUE",
  96 + reference = any(),
  97 + )
  98 + } returns StockBalanceRef(
  99 + id = Id(UUID.randomUUID()),
  100 + itemCode = itemCode,
  101 + locationCode = locationCode,
  102 + quantity = BigDecimal("0"),
  103 + )
  104 + }
  105 +
  106 + private fun stubInventoryAdjustment(
  107 + itemCode: String,
  108 + locationCode: String,
  109 + expectedDelta: BigDecimal,
  110 + ) {
  111 + every {
  112 + inventoryApi.recordMovement(
  113 + itemCode = itemCode,
  114 + locationCode = locationCode,
  115 + delta = expectedDelta,
  116 + reason = "ADJUSTMENT",
  117 + reference = any(),
  118 + )
  119 + } returns StockBalanceRef(
  120 + id = Id(UUID.randomUUID()),
  121 + itemCode = itemCode,
  122 + locationCode = locationCode,
  123 + quantity = BigDecimal("0"),
  124 + )
  125 + }
  126 +
81 // ─── create ────────────────────────────────────────────────────── 127 // ─── create ──────────────────────────────────────────────────────
82 128
83 @Test 129 @Test
@@ -133,6 +179,7 @@ class WorkOrderServiceTest { @@ -133,6 +179,7 @@ class WorkOrderServiceTest {
133 assertThat(saved.status).isEqualTo(WorkOrderStatus.DRAFT) 179 assertThat(saved.status).isEqualTo(WorkOrderStatus.DRAFT)
134 assertThat(saved.outputQuantity).isEqualTo(BigDecimal("25")) 180 assertThat(saved.outputQuantity).isEqualTo(BigDecimal("25"))
135 assertThat(saved.sourceSalesOrderCode).isEqualTo("SO-42") 181 assertThat(saved.sourceSalesOrderCode).isEqualTo("SO-42")
  182 + assertThat(saved.inputs).hasSize(0)
136 verify(exactly = 1) { 183 verify(exactly = 1) {
137 eventBus.publish( 184 eventBus.publish(
138 match<WorkOrderCreatedEvent> { 185 match<WorkOrderCreatedEvent> {
@@ -145,7 +192,91 @@ class WorkOrderServiceTest { @@ -145,7 +192,91 @@ class WorkOrderServiceTest {
145 } 192 }
146 } 193 }
147 194
148 - // ─── complete ──────────────────────────────────────────────────── 195 + @Test
  196 + fun `create with BOM inputs validates each item and saves the lines`() {
  197 + stubItem("FG-1")
  198 + stubItem("RAW-PAPER")
  199 + stubItem("RAW-INK")
  200 +
  201 + val saved = service.create(
  202 + CreateWorkOrderCommand(
  203 + code = "WO-BOM",
  204 + outputItemCode = "FG-1",
  205 + outputQuantity = BigDecimal("100"),
  206 + inputs = listOf(
  207 + WorkOrderInputCommand(
  208 + lineNo = 1,
  209 + itemCode = "RAW-PAPER",
  210 + quantityPerUnit = BigDecimal("2"),
  211 + sourceLocationCode = "WH-RAW",
  212 + ),
  213 + WorkOrderInputCommand(
  214 + lineNo = 2,
  215 + itemCode = "RAW-INK",
  216 + quantityPerUnit = BigDecimal("0.5"),
  217 + sourceLocationCode = "WH-RAW",
  218 + ),
  219 + ),
  220 + ),
  221 + )
  222 +
  223 + assertThat(saved.inputs).hasSize(2)
  224 + assertThat(saved.inputs[0].itemCode).isEqualTo("RAW-PAPER")
  225 + assertThat(saved.inputs[0].quantityPerUnit).isEqualTo(BigDecimal("2"))
  226 + assertThat(saved.inputs[1].itemCode).isEqualTo("RAW-INK")
  227 + verify(exactly = 1) { catalogApi.findItemByCode("RAW-PAPER") }
  228 + verify(exactly = 1) { catalogApi.findItemByCode("RAW-INK") }
  229 + }
  230 +
  231 + @Test
  232 + fun `create rejects a BOM line with unknown item`() {
  233 + stubItem("FG-1")
  234 + every { catalogApi.findItemByCode("GHOST") } returns null
  235 +
  236 + assertFailure {
  237 + service.create(
  238 + CreateWorkOrderCommand(
  239 + code = "WO-BAD-BOM",
  240 + outputItemCode = "FG-1",
  241 + outputQuantity = BigDecimal("10"),
  242 + inputs = listOf(
  243 + WorkOrderInputCommand(
  244 + lineNo = 1,
  245 + itemCode = "GHOST",
  246 + quantityPerUnit = BigDecimal("1"),
  247 + sourceLocationCode = "WH-RAW",
  248 + ),
  249 + ),
  250 + ),
  251 + )
  252 + }
  253 + .isInstanceOf(IllegalArgumentException::class)
  254 + .messageContains("'GHOST'")
  255 + }
  256 +
  257 + @Test
  258 + fun `create rejects duplicate BOM line numbers`() {
  259 + stubItem("FG-1")
  260 + stubItem("RAW-A")
  261 +
  262 + assertFailure {
  263 + service.create(
  264 + CreateWorkOrderCommand(
  265 + code = "WO-DUP-LINE",
  266 + outputItemCode = "FG-1",
  267 + outputQuantity = BigDecimal("10"),
  268 + inputs = listOf(
  269 + WorkOrderInputCommand(1, "RAW-A", BigDecimal("1"), "WH-RAW"),
  270 + WorkOrderInputCommand(1, "RAW-A", BigDecimal("2"), "WH-RAW"),
  271 + ),
  272 + ),
  273 + )
  274 + }
  275 + .isInstanceOf(IllegalArgumentException::class)
  276 + .messageContains("duplicated")
  277 + }
  278 +
  279 + // ─── start ───────────────────────────────────────────────────────
149 280
150 private fun draftOrder( 281 private fun draftOrder(
151 id: UUID = UUID.randomUUID(), 282 id: UUID = UUID.randomUUID(),
@@ -159,27 +290,64 @@ class WorkOrderServiceTest { @@ -159,27 +290,64 @@ class WorkOrderServiceTest {
159 status = WorkOrderStatus.DRAFT, 290 status = WorkOrderStatus.DRAFT,
160 ).also { it.id = id } 291 ).also { it.id = id }
161 292
  293 + private fun inProgressOrder(
  294 + id: UUID = UUID.randomUUID(),
  295 + code: String = "WO-1",
  296 + itemCode: String = "FG-1",
  297 + qty: String = "100",
  298 + ): WorkOrder = WorkOrder(
  299 + code = code,
  300 + outputItemCode = itemCode,
  301 + outputQuantity = BigDecimal(qty),
  302 + status = WorkOrderStatus.IN_PROGRESS,
  303 + ).also { it.id = id }
  304 +
162 @Test 305 @Test
163 - fun `complete rejects a non-DRAFT work order`() { 306 + fun `start flips a DRAFT order to IN_PROGRESS and publishes`() {
164 val id = UUID.randomUUID() 307 val id = UUID.randomUUID()
165 - val done = WorkOrder(  
166 - code = "WO-1",  
167 - outputItemCode = "FG-1",  
168 - outputQuantity = BigDecimal("5"),  
169 - status = WorkOrderStatus.COMPLETED,  
170 - ).also { it.id = id }  
171 - every { orders.findById(id) } returns Optional.of(done) 308 + val order = draftOrder(id = id, code = "WO-START")
  309 + every { orders.findById(id) } returns Optional.of(order)
  310 +
  311 + val result = service.start(id)
  312 +
  313 + assertThat(result.status).isEqualTo(WorkOrderStatus.IN_PROGRESS)
  314 + verify(exactly = 1) {
  315 + eventBus.publish(
  316 + match<WorkOrderStartedEvent> { it.orderCode == "WO-START" },
  317 + )
  318 + }
  319 + verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) }
  320 + }
  321 +
  322 + @Test
  323 + fun `start rejects a non-DRAFT order`() {
  324 + val id = UUID.randomUUID()
  325 + val order = inProgressOrder(id = id, code = "WO-ALREADY")
  326 + every { orders.findById(id) } returns Optional.of(order)
  327 +
  328 + assertFailure { service.start(id) }
  329 + .isInstanceOf(IllegalArgumentException::class)
  330 + .messageContains("only DRAFT can be started")
  331 + }
  332 +
  333 + // ─── complete ────────────────────────────────────────────────────
  334 +
  335 + @Test
  336 + fun `complete rejects a work order that is not IN_PROGRESS`() {
  337 + val id = UUID.randomUUID()
  338 + val draft = draftOrder(id = id, code = "WO-DR")
  339 + every { orders.findById(id) } returns Optional.of(draft)
172 340
173 assertFailure { service.complete(id, "WH-FG") } 341 assertFailure { service.complete(id, "WH-FG") }
174 .isInstanceOf(IllegalArgumentException::class) 342 .isInstanceOf(IllegalArgumentException::class)
175 - .messageContains("only DRAFT can be completed") 343 + .messageContains("only IN_PROGRESS can be completed")
176 verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) } 344 verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) }
177 } 345 }
178 346
179 @Test 347 @Test
180 - fun `complete credits inventory and publishes WorkOrderCompletedEvent`() { 348 + fun `complete on an empty-BOM order credits only the finished good`() {
181 val id = UUID.randomUUID() 349 val id = UUID.randomUUID()
182 - val order = draftOrder(id = id, code = "WO-9", itemCode = "FG-WIDGET", qty = "25") 350 + val order = inProgressOrder(id = id, code = "WO-9", itemCode = "FG-WIDGET", qty = "25")
183 every { orders.findById(id) } returns Optional.of(order) 351 every { orders.findById(id) } returns Optional.of(order)
184 stubInventoryCredit("FG-WIDGET", "WH-FG", BigDecimal("25")) 352 stubInventoryCredit("FG-WIDGET", "WH-FG", BigDecimal("25"))
185 353
@@ -199,14 +367,139 @@ class WorkOrderServiceTest { @@ -199,14 +367,139 @@ class WorkOrderServiceTest {
199 eventBus.publish( 367 eventBus.publish(
200 match<WorkOrderCompletedEvent> { 368 match<WorkOrderCompletedEvent> {
201 it.orderCode == "WO-9" && 369 it.orderCode == "WO-9" &&
202 - it.outputItemCode == "FG-WIDGET" &&  
203 - it.outputQuantity == BigDecimal("25") &&  
204 it.outputLocationCode == "WH-FG" 370 it.outputLocationCode == "WH-FG"
205 }, 371 },
206 ) 372 )
207 } 373 }
208 } 374 }
209 375
  376 + @Test
  377 + fun `complete with BOM issues every input BEFORE crediting the output`() {
  378 + val id = UUID.randomUUID()
  379 + val order = inProgressOrder(id = id, code = "WO-BOM", itemCode = "FG-1", qty = "100")
  380 + order.inputs.add(
  381 + WorkOrderInput(
  382 + workOrder = order,
  383 + lineNo = 1,
  384 + itemCode = "RAW-PAPER",
  385 + quantityPerUnit = BigDecimal("2"),
  386 + sourceLocationCode = "WH-RAW",
  387 + ),
  388 + )
  389 + order.inputs.add(
  390 + WorkOrderInput(
  391 + workOrder = order,
  392 + lineNo = 2,
  393 + itemCode = "RAW-INK",
  394 + quantityPerUnit = BigDecimal("0.5"),
  395 + sourceLocationCode = "WH-RAW",
  396 + ),
  397 + )
  398 + every { orders.findById(id) } returns Optional.of(order)
  399 + // paper: 2 * 100 = 200 (scale 0) issued → delta = -200
  400 + stubInventoryIssue("RAW-PAPER", "WH-RAW", BigDecimal("-200"))
  401 + // ink: 0.5 * 100 = 50.0 (scale 1) issued → delta = -50.0
  402 + stubInventoryIssue("RAW-INK", "WH-RAW", BigDecimal("-50.0"))
  403 + stubInventoryCredit("FG-1", "WH-FG", BigDecimal("100"))
  404 +
  405 + val result = service.complete(id, "WH-FG")
  406 +
  407 + assertThat(result.status).isEqualTo(WorkOrderStatus.COMPLETED)
  408 + verify(exactly = 1) {
  409 + inventoryApi.recordMovement(
  410 + itemCode = "RAW-PAPER",
  411 + locationCode = "WH-RAW",
  412 + delta = BigDecimal("-200"),
  413 + reason = "MATERIAL_ISSUE",
  414 + reference = "WO:WO-BOM",
  415 + )
  416 + }
  417 + verify(exactly = 1) {
  418 + inventoryApi.recordMovement(
  419 + itemCode = "RAW-INK",
  420 + locationCode = "WH-RAW",
  421 + delta = BigDecimal("-50.0"),
  422 + reason = "MATERIAL_ISSUE",
  423 + reference = "WO:WO-BOM",
  424 + )
  425 + }
  426 + verify(exactly = 1) {
  427 + inventoryApi.recordMovement(
  428 + itemCode = "FG-1",
  429 + locationCode = "WH-FG",
  430 + delta = BigDecimal("100"),
  431 + reason = "PRODUCTION_RECEIPT",
  432 + reference = "WO:WO-BOM",
  433 + )
  434 + }
  435 + }
  436 +
  437 + // ─── scrap ───────────────────────────────────────────────────────
  438 +
  439 + @Test
  440 + fun `scrap rejects a non-COMPLETED order`() {
  441 + val id = UUID.randomUUID()
  442 + val order = inProgressOrder(id = id, code = "WO-NOT-DONE")
  443 + every { orders.findById(id) } returns Optional.of(order)
  444 +
  445 + assertFailure { service.scrap(id, "WH-FG", BigDecimal("5"), null) }
  446 + .isInstanceOf(IllegalArgumentException::class)
  447 + .messageContains("only COMPLETED work orders can be scrapped")
  448 + }
  449 +
  450 + @Test
  451 + fun `scrap rejects a non-positive quantity`() {
  452 + val id = UUID.randomUUID()
  453 + val order = WorkOrder(
  454 + code = "WO-Z",
  455 + outputItemCode = "FG-1",
  456 + outputQuantity = BigDecimal("10"),
  457 + status = WorkOrderStatus.COMPLETED,
  458 + ).also { it.id = id }
  459 + every { orders.findById(id) } returns Optional.of(order)
  460 +
  461 + assertFailure { service.scrap(id, "WH-FG", BigDecimal.ZERO, null) }
  462 + .isInstanceOf(IllegalArgumentException::class)
  463 + .messageContains("must be positive")
  464 + }
  465 +
  466 + @Test
  467 + fun `scrap writes a negative ADJUSTMENT and publishes WorkOrderScrappedEvent`() {
  468 + val id = UUID.randomUUID()
  469 + val order = WorkOrder(
  470 + code = "WO-S",
  471 + outputItemCode = "FG-1",
  472 + outputQuantity = BigDecimal("100"),
  473 + status = WorkOrderStatus.COMPLETED,
  474 + ).also { it.id = id }
  475 + every { orders.findById(id) } returns Optional.of(order)
  476 + stubInventoryAdjustment("FG-1", "WH-FG", BigDecimal("-7"))
  477 +
  478 + val result = service.scrap(id, "WH-FG", BigDecimal("7"), "QC reject")
  479 +
  480 + // Scrap does not change the status
  481 + assertThat(result.status).isEqualTo(WorkOrderStatus.COMPLETED)
  482 + verify(exactly = 1) {
  483 + inventoryApi.recordMovement(
  484 + itemCode = "FG-1",
  485 + locationCode = "WH-FG",
  486 + delta = BigDecimal("-7"),
  487 + reason = "ADJUSTMENT",
  488 + reference = "WO:WO-S:SCRAP",
  489 + )
  490 + }
  491 + verify(exactly = 1) {
  492 + eventBus.publish(
  493 + match<WorkOrderScrappedEvent> {
  494 + it.orderCode == "WO-S" &&
  495 + it.scrappedQuantity == BigDecimal("7") &&
  496 + it.scrapLocationCode == "WH-FG" &&
  497 + it.note == "QC reject"
  498 + },
  499 + )
  500 + }
  501 + }
  502 +
210 // ─── cancel ────────────────────────────────────────────────────── 503 // ─── cancel ──────────────────────────────────────────────────────
211 504
212 @Test 505 @Test
@@ -226,6 +519,22 @@ class WorkOrderServiceTest { @@ -226,6 +519,22 @@ class WorkOrderServiceTest {
226 } 519 }
227 520
228 @Test 521 @Test
  522 + fun `cancel also accepts an IN_PROGRESS order (v2)`() {
  523 + val id = UUID.randomUUID()
  524 + val order = inProgressOrder(id = id, code = "WO-IP-CANCEL")
  525 + every { orders.findById(id) } returns Optional.of(order)
  526 +
  527 + val result = service.cancel(id)
  528 +
  529 + assertThat(result.status).isEqualTo(WorkOrderStatus.CANCELLED)
  530 + verify(exactly = 1) {
  531 + eventBus.publish(
  532 + match<WorkOrderCancelledEvent> { it.orderCode == "WO-IP-CANCEL" },
  533 + )
  534 + }
  535 + }
  536 +
  537 + @Test
229 fun `cancel rejects a COMPLETED order (no un-producing finished goods)`() { 538 fun `cancel rejects a COMPLETED order (no un-producing finished goods)`() {
230 val id = UUID.randomUUID() 539 val id = UUID.randomUUID()
231 val done = WorkOrder( 540 val done = WorkOrder(
@@ -238,6 +547,6 @@ class WorkOrderServiceTest { @@ -238,6 +547,6 @@ class WorkOrderServiceTest {
238 547
239 assertFailure { service.cancel(id) } 548 assertFailure { service.cancel(id) }
240 .isInstanceOf(IllegalArgumentException::class) 549 .isInstanceOf(IllegalArgumentException::class)
241 - .messageContains("only DRAFT work orders can be cancelled") 550 + .messageContains("only DRAFT or IN_PROGRESS")
242 } 551 }
243 } 552 }