You need to sign in before continuing.
Commit ed668237cb54456097a01d2f2a59a72acb30821a
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.
Showing
6 changed files
with
94 additions
and
15 deletions
CLAUDE.md
| ... | ... | @@ -96,7 +96,7 @@ plugins (incl. ref) depend on: api/api-v1 only |
| 96 | 96 | **Foundation complete; first business surface in place.** As of the latest commit: |
| 97 | 97 | |
| 98 | 98 | - **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() | ... | ... |