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,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() |