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