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 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97  
98 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 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 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 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 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 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 16 | **Modules** | 18 |
17   -| **Unit tests** | 230, all green |
  17 +| **Unit tests** | 240, all green |
18 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 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 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 84 | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending |
85 85 | P5.5 | `pbc-orders-sales` — sales orders + lines (deliveries deferred) | ✅ DONE — `a8eb2a6` |
86 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 88 | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending |
89 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 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 81 * The companion `PRODUCTION_RECEIPT` ledger row has already been
58 82 * written by the time this event fires — the publish runs inside
59 83 * the same `@Transactional` method as the inventory write and the
60 84 * status flip, so a subscriber that reads `inventory__stock_movement`
61 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 89 * `outputLocationCode` is included so warehouse and dispatch
65 90 * subscribers can act on "what just landed where" without
... ... @@ -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 111 * The framework refuses to cancel a COMPLETED work order — that
84 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 115 public data class WorkOrderCancelledEvent(
88 116 override val orderCode: String,
... ... @@ -95,5 +123,41 @@ public data class WorkOrderCancelledEvent(
95 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 162 /** Topic string for wildcard / topic-based subscriptions to work order events. */
99 163 public const val WORK_ORDER_AGGREGATE_TYPE: String = "production.WorkOrder"
... ...
distribution/src/main/resources/db/changelog/master.xml
... ... @@ -23,4 +23,5 @@
23 23 <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/>
24 24 <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/>
25 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 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 7 import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent
8 8 import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent
9 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 12 import org.vibeerp.api.v1.ext.catalog.CatalogApi
11 13 import org.vibeerp.api.v1.ext.inventory.InventoryApi
12 14 import org.vibeerp.pbc.production.domain.WorkOrder
  15 +import org.vibeerp.pbc.production.domain.WorkOrderInput
13 16 import org.vibeerp.pbc.production.domain.WorkOrderStatus
14 17 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
15 18 import java.math.BigDecimal
... ... @@ -21,13 +24,16 @@ import java.util.UUID
21 24 *
22 25 * **Cross-PBC seams** (all read through `api.v1.ext.*` interfaces,
23 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 38 * **Event publishing.** Each state-changing method publishes a
33 39 * typed event from `api.v1.event.production.*` inside the same
... ... @@ -36,17 +42,26 @@ import java.util.UUID
36 42 * `Propagation.MANDATORY` so a publish outside a transaction would
37 43 * throw — every method here is transactional, so the contract is
38 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 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 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 66 @Service
52 67 @Transactional
... ... @@ -87,6 +102,31 @@ class WorkOrderService(
87 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 130 val order = WorkOrder(
91 131 code = command.code,
92 132 outputItemCode = command.outputItemCode,
... ... @@ -95,6 +135,19 @@ class WorkOrderService(
95 135 dueDate = command.dueDate,
96 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 151 val saved = orders.save(order)
99 152  
100 153 eventBus.publish(
... ... @@ -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 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 222 fun complete(id: UUID, outputLocationCode: String): WorkOrder {
129 223 val order = orders.findById(id).orElseThrow {
130 224 NoSuchElementException("work order not found: $id")
131 225 }
132   - require(order.status == WorkOrderStatus.DRAFT) {
  226 + require(order.status == WorkOrderStatus.IN_PROGRESS) {
133 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 247 // Credit the finished good to the receiving location.
... ... @@ -156,17 +266,84 @@ class WorkOrderService(
156 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 331 fun cancel(id: UUID): WorkOrder {
160 332 val order = orders.findById(id).orElseThrow {
161 333 NoSuchElementException("work order not found: $id")
162 334 }
163 335 // COMPLETED is terminal — once finished goods exist on the
164 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 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 348 order.status = WorkOrderStatus.CANCELLED
172 349  
... ... @@ -187,4 +364,20 @@ data class CreateWorkOrderCommand(
187 364 val outputQuantity: BigDecimal,
188 365 val dueDate: LocalDate? = null,
189 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 1 package org.vibeerp.pbc.production.domain
2 2  
  3 +import jakarta.persistence.CascadeType
3 4 import jakarta.persistence.Column
4 5 import jakarta.persistence.Entity
5 6 import jakarta.persistence.EnumType
6 7 import jakarta.persistence.Enumerated
  8 +import jakarta.persistence.FetchType
  9 +import jakarta.persistence.OneToMany
  10 +import jakarta.persistence.OrderBy
7 11 import jakarta.persistence.Table
8 12 import org.hibernate.annotations.JdbcTypeCode
9 13 import org.hibernate.type.SqlTypes
... ... @@ -24,39 +28,54 @@ import java.time.LocalDate
24 28 * customer ordered 1000 brochures, the floor produced 1000 brochures,
25 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 46 * - **No routings or operations.** A real shop floor would model
35 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 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 50 * - **No scheduling, no capacity planning, no due-date enforcement.**
42 51 * A `dueDate` field exists for display only; nothing in the
43 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 80 * **Why `output_item_code` and `output_quantity` are columns rather
62 81 * than a one-line `WorkOrderLine` child entity:** v1 produces
... ... @@ -107,16 +126,47 @@ class WorkOrder(
107 126 @JdbcTypeCode(SqlTypes.JSON)
108 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 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 156 * State machine values for [WorkOrder]. See the entity KDoc for the
116 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 167 enum class WorkOrderStatus {
119 168 DRAFT,
  169 + IN_PROGRESS,
120 170 COMPLETED,
121 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 16 import org.springframework.web.bind.annotation.ResponseStatus
17 17 import org.springframework.web.bind.annotation.RestController
18 18 import org.vibeerp.pbc.production.application.CreateWorkOrderCommand
  19 +import org.vibeerp.pbc.production.application.WorkOrderInputCommand
19 20 import org.vibeerp.pbc.production.application.WorkOrderService
20 21 import org.vibeerp.pbc.production.domain.WorkOrder
  22 +import org.vibeerp.pbc.production.domain.WorkOrderInput
21 23 import org.vibeerp.pbc.production.domain.WorkOrderStatus
22 24 import org.vibeerp.platform.security.authz.RequirePermission
23 25 import java.math.BigDecimal
... ... @@ -63,10 +65,23 @@ class WorkOrderController(
63 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 86 @PostMapping("/{id}/complete")
72 87 @RequirePermission("production.work-order.complete")
... ... @@ -80,6 +95,24 @@ class WorkOrderController(
80 95 @RequirePermission("production.work-order.cancel")
81 96 fun cancel(@PathVariable id: UUID): WorkOrderResponse =
82 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 118 // ─── DTOs ────────────────────────────────────────────────────────────
... ... @@ -90,6 +123,12 @@ data class CreateWorkOrderRequest(
90 123 @field:NotNull val outputQuantity: BigDecimal,
91 124 val dueDate: LocalDate? = null,
92 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 133 fun toCommand(): CreateWorkOrderCommand = CreateWorkOrderCommand(
95 134 code = code,
... ... @@ -97,6 +136,21 @@ data class CreateWorkOrderRequest(
97 136 outputQuantity = outputQuantity,
98 137 dueDate = dueDate,
99 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 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 184 data class WorkOrderResponse(
120 185 val id: UUID,
121 186 val code: String,
... ... @@ -124,6 +189,15 @@ data class WorkOrderResponse(
124 189 val status: WorkOrderStatus,
125 190 val dueDate: LocalDate?,
126 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 203 private fun WorkOrder.toResponse(): WorkOrderResponse =
... ... @@ -135,4 +209,14 @@ private fun WorkOrder.toResponse(): WorkOrderResponse =
135 209 status = status,
136 210 dueDate = dueDate,
137 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 3 import assertk.assertFailure
4 4 import assertk.assertThat
5 5 import assertk.assertions.hasMessage
  6 +import assertk.assertions.hasSize
6 7 import assertk.assertions.isEqualTo
7 8 import assertk.assertions.isInstanceOf
8 9 import assertk.assertions.messageContains
... ... @@ -19,11 +20,14 @@ import org.vibeerp.api.v1.event.EventBus
19 20 import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent
20 21 import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent
21 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 25 import org.vibeerp.api.v1.ext.catalog.CatalogApi
23 26 import org.vibeerp.api.v1.ext.catalog.ItemRef
24 27 import org.vibeerp.api.v1.ext.inventory.InventoryApi
25 28 import org.vibeerp.api.v1.ext.inventory.StockBalanceRef
26 29 import org.vibeerp.pbc.production.domain.WorkOrder
  30 +import org.vibeerp.pbc.production.domain.WorkOrderInput
27 31 import org.vibeerp.pbc.production.domain.WorkOrderStatus
28 32 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
29 33 import java.math.BigDecimal
... ... @@ -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 127 // ─── create ──────────────────────────────────────────────────────
82 128  
83 129 @Test
... ... @@ -133,6 +179,7 @@ class WorkOrderServiceTest {
133 179 assertThat(saved.status).isEqualTo(WorkOrderStatus.DRAFT)
134 180 assertThat(saved.outputQuantity).isEqualTo(BigDecimal("25"))
135 181 assertThat(saved.sourceSalesOrderCode).isEqualTo("SO-42")
  182 + assertThat(saved.inputs).hasSize(0)
136 183 verify(exactly = 1) {
137 184 eventBus.publish(
138 185 match<WorkOrderCreatedEvent> {
... ... @@ -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 281 private fun draftOrder(
151 282 id: UUID = UUID.randomUUID(),
... ... @@ -159,27 +290,64 @@ class WorkOrderServiceTest {
159 290 status = WorkOrderStatus.DRAFT,
160 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 305 @Test
163   - fun `complete rejects a non-DRAFT work order`() {
  306 + fun `start flips a DRAFT order to IN_PROGRESS and publishes`() {
164 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 341 assertFailure { service.complete(id, "WH-FG") }
174 342 .isInstanceOf(IllegalArgumentException::class)
175   - .messageContains("only DRAFT can be completed")
  343 + .messageContains("only IN_PROGRESS can be completed")
176 344 verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) }
177 345 }
178 346  
179 347 @Test
180   - fun `complete credits inventory and publishes WorkOrderCompletedEvent`() {
  348 + fun `complete on an empty-BOM order credits only the finished good`() {
181 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 351 every { orders.findById(id) } returns Optional.of(order)
184 352 stubInventoryCredit("FG-WIDGET", "WH-FG", BigDecimal("25"))
185 353  
... ... @@ -199,14 +367,139 @@ class WorkOrderServiceTest {
199 367 eventBus.publish(
200 368 match<WorkOrderCompletedEvent> {
201 369 it.orderCode == "WO-9" &&
202   - it.outputItemCode == "FG-WIDGET" &&
203   - it.outputQuantity == BigDecimal("25") &&
204 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 503 // ─── cancel ──────────────────────────────────────────────────────
211 504  
212 505 @Test
... ... @@ -226,6 +519,22 @@ class WorkOrderServiceTest {
226 519 }
227 520  
228 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 538 fun `cancel rejects a COMPLETED order (no un-producing finished goods)`() {
230 539 val id = UUID.randomUUID()
231 540 val done = WorkOrder(
... ... @@ -238,6 +547,6 @@ class WorkOrderServiceTest {
238 547  
239 548 assertFailure { service.cancel(id) }
240 549 .isInstanceOf(IllegalArgumentException::class)
241   - .messageContains("only DRAFT work orders can be cancelled")
  550 + .messageContains("only DRAFT or IN_PROGRESS")
242 551 }
243 552 }
... ...