Commit ed668237cb54456097a01d2f2a59a72acb30821a

Authored by zichun
1 parent 39a7d861

feat(inventory): add MATERIAL_ISSUE + PRODUCTION_RECEIPT movement reasons

Extends pbc-inventory's MovementReason enum with the two reasons a
production-style PBC needs to record stock movements through the
existing InventoryApi.recordMovement facade. No new endpoint, no
new database column — just two new enum values, two new sign-
validation rules, and four new tests.

Why this lands BEFORE pbc-production
  - It's the smallest self-contained change that unblocks any future
    production-related code (the framework's planned pbc-production,
    a customer plug-in's manufacturing module, or even an ad-hoc
    operator script). Each of those callers can now record
    "consume raw material" / "produce finished good" through the
    same primitive that already serves sales shipments and purchase
    receipts.
  - It validates the "one ledger, many callers" property the
    architecture spec promised. Adding a new movement reason takes
    zero schema changes (the column is varchar) and zero plug-in
    changes (the api.v1 facade takes the reason as a string and
    delegates to MovementReason.valueOf inside the adapter). The
    enum lives entirely inside pbc-inventory.

What changed
  - StockMovement.kt: enum gains MATERIAL_ISSUE (Δ ≤ 0) and
    PRODUCTION_RECEIPT (Δ ≥ 0), with KDoc explaining why each one
    was added and how they fit the "one primitive for every direction"
    story.
  - StockMovementService.validateSign: PRODUCTION_RECEIPT joins the
    must-be-non-negative bucket alongside RECEIPT, PURCHASE_RECEIPT,
    and TRANSFER_IN; MATERIAL_ISSUE joins the must-be-non-positive
    bucket alongside ISSUE, SALES_SHIPMENT, and TRANSFER_OUT.
  - 4 new unit tests:
      • record rejects positive delta on MATERIAL_ISSUE
      • record rejects negative delta on PRODUCTION_RECEIPT
      • record accepts a positive PRODUCTION_RECEIPT (happy path,
        new balance row at the receiving location)
      • record accepts a negative MATERIAL_ISSUE (decrements an
        existing balance from 1000 → 800)
  - Total tests: 213 → 217.

Smoke test against real Postgres
  - Booted on a fresh DB; no schema migration needed because the
    `reason` column is varchar(32), already wide enough.
  - Seeded an item RAW-PAPER, an item FG-WIDGET, and a location
    WH-PROD via the existing endpoints.
  - POST /api/v1/inventory/movements with reason=RECEIPT for 1000
    raw paper → balance row at 1000.
  - POST /api/v1/inventory/movements with reason=MATERIAL_ISSUE
    delta=-200 reference="WO:WO-EVT-1" → balance becomes 800,
    ledger row written.
  - POST /api/v1/inventory/movements with reason=PRODUCTION_RECEIPT
    delta=50 reference="WO:WO-EVT-1" → balance row at 50 for
    FG-WIDGET, ledger row written.
  - Negative test: POST PRODUCTION_RECEIPT with delta=-1 →
    400 Bad Request "movement reason PRODUCTION_RECEIPT requires
    a non-negative delta (got -1)" — the new sign rule fires.
  - Final ledger has 3 rows (RECEIPT, MATERIAL_ISSUE,
    PRODUCTION_RECEIPT); final balance has FG-WIDGET=50 and
    RAW-PAPER=800 — the math is correct.

What's deliberately NOT in this chunk
  - No pbc-production yet. That's the next chunk; this is just
    the foundation that lets it (or any other production-ish
    caller) write to the ledger correctly without needing changes
    to api.v1 or pbc-inventory ever again.
  - No new return-path reasons (RETURN_FROM_CUSTOMER,
    RETURN_TO_SUPPLIER) — those land when the returns flow does.
  - No reference convention for "WO:" — that's documented in the
    KDoc on `reference`, not enforced anywhere. The v0.16/v0.17
    convention "<source>:<code>" continues unchanged.
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 - **17 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 - **17 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 -- **213 unit tests across 17 modules**, all green. `./gradlew build` is the canonical full build. 99 +- **217 unit tests across 17 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 - **7 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`). **The full buy-and-sell loop works**: a purchase order receives stock via `PURCHASE_RECEIPT` ledger rows, a sales order ships stock via `SALES_SHIPMENT` ledger rows. Both PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade. 101 - **7 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`). **The full buy-and-sell loop works**: a purchase order receives stock via `PURCHASE_RECEIPT` ledger rows, a sales order ships stock via `SALES_SHIPMENT` ledger rows. Both 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.17 (post-pbc-finance lifecycle: AR/AP entries now react to ship/receive/cancel) |  
14 -| **Latest commit** | `0e9736c feat(pbc-finance): react to ship/receive/cancel — full lifecycle on the journal` | 13 +| **Latest version** | v0.17.1 (MovementReason gains MATERIAL_ISSUE + PRODUCTION_RECEIPT) |
  14 +| **Latest commit** | `<pin after push>` |
15 | **Repo** | https://github.com/reporkey/vibe-erp | 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 | **Modules** | 17 | 16 | **Modules** | 17 |
17 -| **Unit tests** | 213, all green | 17 +| **Unit tests** | 217, all green |
18 | **End-to-end smoke runs** | pbc-finance now reacts to all six order events. Smoke verified: PO confirm → AP POSTED → receive → AP SETTLED. SO confirm → AR POSTED → ship → AR SETTLED. Confirm-then-cancel of either PO or SO flips the row to REVERSED. Cancel-from-DRAFT writes no row (no `*ConfirmedEvent` was ever published). All lifecycle transitions are idempotent: a duplicate settle/reverse delivery is a clean no-op, and a settle never overwrites a reversal (or vice versa). Status filter on `GET /api/v1/finance/journal-entries?status=POSTED|SETTLED|REVERSED` returns the right partition. | 18 | **End-to-end smoke runs** | pbc-finance now reacts to all six order events. Smoke verified: PO confirm → AP POSTED → receive → AP SETTLED. SO confirm → AR POSTED → ship → AR SETTLED. Confirm-then-cancel of either PO or SO flips the row to REVERSED. Cancel-from-DRAFT writes no row (no `*ConfirmedEvent` was ever published). All lifecycle transitions are idempotent: a duplicate settle/reverse delivery is a clean no-op, and a settle never overwrites a reversal (or vice versa). Status filter on `GET /api/v1/finance/journal-entries?status=POSTED|SETTLED|REVERSED` returns the right partition. |
19 | **Real PBCs implemented** | 7 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`) | 19 | **Real PBCs implemented** | 7 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`) |
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) |
README.md
@@ -77,7 +77,7 @@ vibe-erp/ @@ -77,7 +77,7 @@ vibe-erp/
77 ## Building 77 ## Building
78 78
79 ```bash 79 ```bash
80 -# Build everything (compiles 17 modules, runs 213 unit tests) 80 +# Build everything (compiles 17 modules, runs 217 unit tests)
81 ./gradlew build 81 ./gradlew build
82 82
83 # Bring up Postgres + the reference plug-in JAR 83 # Bring up Postgres + the reference plug-in JAR
@@ -97,7 +97,7 @@ The bootstrap admin password is printed to the application logs on first boot. A @@ -97,7 +97,7 @@ The bootstrap admin password is printed to the application logs on first boot. A
97 | | | 97 | | |
98 |---|---| 98 |---|---|
99 | Modules | 17 | 99 | Modules | 17 |
100 -| Unit tests | 213, all green | 100 +| Unit tests | 217, all green |
101 | Real PBCs | 7 of 10 | 101 | Real PBCs | 7 of 10 |
102 | Cross-cutting services live | 9 | 102 | Cross-cutting services live | 9 |
103 | Plug-ins serving HTTP | 1 (reference printing-shop) | 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockMovementService.kt
@@ -151,11 +151,13 @@ class StockMovementService( @@ -151,11 +151,13 @@ class StockMovementService(
151 val expected: String? = when (reason) { 151 val expected: String? = when (reason) {
152 MovementReason.RECEIPT, 152 MovementReason.RECEIPT,
153 MovementReason.PURCHASE_RECEIPT, 153 MovementReason.PURCHASE_RECEIPT,
154 - MovementReason.TRANSFER_IN -> if (sign < 0) "non-negative" else null 154 + MovementReason.TRANSFER_IN,
  155 + MovementReason.PRODUCTION_RECEIPT -> if (sign < 0) "non-negative" else null
155 156
156 MovementReason.ISSUE, 157 MovementReason.ISSUE,
157 MovementReason.SALES_SHIPMENT, 158 MovementReason.SALES_SHIPMENT,
158 - MovementReason.TRANSFER_OUT -> if (sign > 0) "non-positive" else null 159 + MovementReason.TRANSFER_OUT,
  160 + MovementReason.MATERIAL_ISSUE -> if (sign > 0) "non-positive" else null
159 161
160 MovementReason.ADJUSTMENT -> null // either sign allowed 162 MovementReason.ADJUSTMENT -> null // either sign allowed
161 } 163 }
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockMovement.kt
@@ -104,13 +104,25 @@ class StockMovement( @@ -104,13 +104,25 @@ class StockMovement(
104 * matches the documented direction; calling code that gets this 104 * matches the documented direction; calling code that gets this
105 * wrong gets a meaningful error instead of a corrupted balance. 105 * wrong gets a meaningful error instead of a corrupted balance.
106 * 106 *
107 - * - **RECEIPT** — non-PO receipt (e.g. inventory found, customer return). Δ ≥ 0  
108 - * - **ISSUE** — non-SO issue (e.g. write-off, internal use). Δ ≤ 0  
109 - * - **ADJUSTMENT** — operator-driven correction (cycle count, mistake fix). Δ either sign  
110 - * - **SALES_SHIPMENT** — line item shipped on a sales order. Δ ≤ 0  
111 - * - **PURCHASE_RECEIPT** — line item received against a purchase order. Δ ≥ 0  
112 - * - **TRANSFER_OUT** — leg-1 of an inter-warehouse transfer. Δ ≤ 0  
113 - * - **TRANSFER_IN** — leg-2 of an inter-warehouse transfer. Δ ≥ 0 107 + * - **RECEIPT** — non-PO receipt (e.g. inventory found, customer return). Δ ≥ 0
  108 + * - **ISSUE** — non-SO issue (e.g. write-off, internal use). Δ ≤ 0
  109 + * - **ADJUSTMENT** — operator-driven correction (cycle count, mistake fix). Δ either sign
  110 + * - **SALES_SHIPMENT** — line item shipped on a sales order. Δ ≤ 0
  111 + * - **PURCHASE_RECEIPT** — line item received against a purchase order. Δ ≥ 0
  112 + * - **TRANSFER_OUT** — leg-1 of an inter-warehouse transfer. Δ ≤ 0
  113 + * - **TRANSFER_IN** — leg-2 of an inter-warehouse transfer. Δ ≥ 0
  114 + * - **MATERIAL_ISSUE** — raw material consumed by a production work order. Δ ≤ 0
  115 + * - **PRODUCTION_RECEIPT** — finished good produced by a work order. Δ ≥ 0
  116 + *
  117 + * The two production-related reasons are added so the inventory
  118 + * facade can serve a future `pbc-production` (and any customer
  119 + * plug-in that needs to express "consume raw material" / "produce
  120 + * finished good") without growing a parallel API. The same
  121 + * `InventoryApi.recordMovement(...)` primitive feeds the same
  122 + * append-only `inventory__stock_movement` ledger from every
  123 + * direction — sales, purchase, production, manual adjustments —
  124 + * which is the property that makes "one ledger, many callers"
  125 + * actually true rather than a slogan.
114 * 126 *
115 * Stored as string in the DB so adding values later is non-breaking 127 * Stored as string in the DB so adding values later is non-breaking
116 * for clients reading the column with raw SQL. 128 * for clients reading the column with raw SQL.
@@ -123,4 +135,6 @@ enum class MovementReason { @@ -123,4 +135,6 @@ enum class MovementReason {
123 PURCHASE_RECEIPT, 135 PURCHASE_RECEIPT,
124 TRANSFER_OUT, 136 TRANSFER_OUT,
125 TRANSFER_IN, 137 TRANSFER_IN,
  138 + MATERIAL_ISSUE,
  139 + PRODUCTION_RECEIPT,
126 } 140 }
pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockMovementServiceTest.kt
@@ -96,6 +96,20 @@ class StockMovementServiceTest { @@ -96,6 +96,20 @@ class StockMovementServiceTest {
96 } 96 }
97 97
98 @Test 98 @Test
  99 + fun `record rejects positive delta on MATERIAL_ISSUE (production consumes raw material)`() {
  100 + assertFailure { service.record(cmd("5", MovementReason.MATERIAL_ISSUE)) }
  101 + .isInstanceOf(IllegalArgumentException::class)
  102 + .messageContains("non-positive")
  103 + }
  104 +
  105 + @Test
  106 + fun `record rejects negative delta on PRODUCTION_RECEIPT (production yields finished goods)`() {
  107 + assertFailure { service.record(cmd("-5", MovementReason.PRODUCTION_RECEIPT)) }
  108 + .isInstanceOf(IllegalArgumentException::class)
  109 + .messageContains("non-negative")
  110 + }
  111 +
  112 + @Test
99 fun `record allows either sign on ADJUSTMENT`() { 113 fun `record allows either sign on ADJUSTMENT`() {
100 val locId = UUID.randomUUID() 114 val locId = UUID.randomUUID()
101 stubItem("SKU-1") 115 stubItem("SKU-1")
@@ -214,6 +228,55 @@ class StockMovementServiceTest { @@ -214,6 +228,55 @@ class StockMovementServiceTest {
214 } 228 }
215 229
216 @Test 230 @Test
  231 + fun `record accepts a positive PRODUCTION_RECEIPT (finished good produced)`() {
  232 + stubItem("FG-WIDGET")
  233 + val locId = UUID.randomUUID()
  234 + every { locations.existsById(locId) } returns true
  235 + every { balances.findByItemCodeAndLocationId("FG-WIDGET", locId) } returns null
  236 + val savedMovement = slot<StockMovement>()
  237 + every { balances.save(any<StockBalance>()) } answers { firstArg() }
  238 + every { movements.save(capture(savedMovement)) } answers { savedMovement.captured }
  239 +
  240 + service.record(
  241 + RecordMovementCommand(
  242 + itemCode = "FG-WIDGET",
  243 + locationId = locId,
  244 + delta = BigDecimal("25"),
  245 + reason = MovementReason.PRODUCTION_RECEIPT,
  246 + reference = "WO:WO-2026-0001",
  247 + ),
  248 + )
  249 +
  250 + assertThat(savedMovement.captured.reason).isEqualTo(MovementReason.PRODUCTION_RECEIPT)
  251 + assertThat(savedMovement.captured.delta).isEqualTo(BigDecimal("25"))
  252 + assertThat(savedMovement.captured.reference).isEqualTo("WO:WO-2026-0001")
  253 + }
  254 +
  255 + @Test
  256 + fun `record accepts a negative MATERIAL_ISSUE (raw material consumed)`() {
  257 + stubItem("RAW-PAPER")
  258 + val locId = UUID.randomUUID()
  259 + val existing = StockBalance("RAW-PAPER", locId, BigDecimal("1000")).also { it.id = UUID.randomUUID() }
  260 + every { locations.existsById(locId) } returns true
  261 + every { balances.findByItemCodeAndLocationId("RAW-PAPER", locId) } returns existing
  262 + val savedMovement = slot<StockMovement>()
  263 + every { movements.save(capture(savedMovement)) } answers { savedMovement.captured }
  264 +
  265 + service.record(
  266 + RecordMovementCommand(
  267 + itemCode = "RAW-PAPER",
  268 + locationId = locId,
  269 + delta = BigDecimal("-200"),
  270 + reason = MovementReason.MATERIAL_ISSUE,
  271 + reference = "WO:WO-2026-0001",
  272 + ),
  273 + )
  274 +
  275 + assertThat(savedMovement.captured.reason).isEqualTo(MovementReason.MATERIAL_ISSUE)
  276 + assertThat(existing.quantity).isEqualTo(BigDecimal("800"))
  277 + }
  278 +
  279 + @Test
217 fun `record updates existing balance and inserts movement on the happy path`() { 280 fun `record updates existing balance and inserts movement on the happy path`() {
218 stubItem("SKU-1") 281 stubItem("SKU-1")
219 val locId = UUID.randomUUID() 282 val locId = UUID.randomUUID()