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 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97  
98 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 100 - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge.
101 101 - **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 102 - **Event-driven cross-PBC integration is live in BOTH directions and the consumer reacts to the full lifecycle.** Six typed events under `org.vibeerp.api.v1.event.orders.*` are published from `SalesOrderService` and `PurchaseOrderService` inside the same `@Transactional` method as their state changes. **pbc-finance** subscribes to ALL SIX of them via the api.v1 `EventBus.subscribe(eventType, listener)` typed-class overload: confirm events POST AR/AP rows; ship/receive events SETTLE them; cancel events REVERSE them. Each transition is idempotent under at-least-once delivery — re-applying the same destination status is a no-op. Cancel-from-DRAFT is a clean no-op because no `*ConfirmedEvent` was ever published. pbc-finance has no source dependency on pbc-orders-*; it reaches them only through events.
... ...
PROGRESS.md
... ... @@ -10,11 +10,11 @@
10 10  
11 11 | | |
12 12 |---|---|
13   -| **Latest version** | v0.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 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 16 | **Modules** | 17 |
17   -| **Unit tests** | 213, all green |
  17 +| **Unit tests** | 217, all green |
18 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 19 | **Real PBCs implemented** | 7 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`) |
20 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 77 ## Building
78 78  
79 79 ```bash
80   -# Build everything (compiles 17 modules, runs 213 unit tests)
  80 +# Build everything (compiles 17 modules, runs 217 unit tests)
81 81 ./gradlew build
82 82  
83 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 97 | | |
98 98 |---|---|
99 99 | Modules | 17 |
100   -| Unit tests | 213, all green |
  100 +| Unit tests | 217, all green |
101 101 | Real PBCs | 7 of 10 |
102 102 | Cross-cutting services live | 9 |
103 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 151 val expected: String? = when (reason) {
152 152 MovementReason.RECEIPT,
153 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 157 MovementReason.ISSUE,
157 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 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 104 * matches the documented direction; calling code that gets this
105 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 127 * Stored as string in the DB so adding values later is non-breaking
116 128 * for clients reading the column with raw SQL.
... ... @@ -123,4 +135,6 @@ enum class MovementReason {
123 135 PURCHASE_RECEIPT,
124 136 TRANSFER_OUT,
125 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 96 }
97 97  
98 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 113 fun `record allows either sign on ADJUSTMENT`() {
100 114 val locId = UUID.randomUUID()
101 115 stubItem("SKU-1")
... ... @@ -214,6 +228,55 @@ class StockMovementServiceTest {
214 228 }
215 229  
216 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 280 fun `record updates existing balance and inserts movement on the happy path`() {
218 281 stubItem("SKU-1")
219 282 val locId = UUID.randomUUID()
... ...