Commit 75a75baa9687e20987de86c6c22ef3bc7f6a2a3a
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.
Showing
10 changed files
with
952 additions
and
87 deletions
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 > 0), | |
| 68 | + CONSTRAINT production__work_order_input_line_no_pos | |
| 69 | + CHECK (line_no > 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 | } | ... | ... |