diff --git a/CLAUDE.md b/CLAUDE.md index a8d2d97..dee4ce9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ plugins (incl. ref) depend on: api/api-v1 only **Foundation complete; first business surface in place.** As of the latest commit: - **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`. -- **213 unit tests across 17 modules**, all green. `./gradlew build` is the canonical full build. +- **217 unit tests across 17 modules**, all green. `./gradlew build` is the canonical full build. - **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. - **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. - **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. diff --git a/PROGRESS.md b/PROGRESS.md index 5fae476..8008744 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,11 +10,11 @@ | | | |---|---| -| **Latest version** | v0.17 (post-pbc-finance lifecycle: AR/AP entries now react to ship/receive/cancel) | -| **Latest commit** | `0e9736c feat(pbc-finance): react to ship/receive/cancel — full lifecycle on the journal` | +| **Latest version** | v0.17.1 (MovementReason gains MATERIAL_ISSUE + PRODUCTION_RECEIPT) | +| **Latest commit** | `` | | **Repo** | https://github.com/reporkey/vibe-erp | | **Modules** | 17 | -| **Unit tests** | 213, all green | +| **Unit tests** | 217, all green | | **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. | | **Real PBCs implemented** | 7 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`) | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | diff --git a/README.md b/README.md index 31a2f14..5988320 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ vibe-erp/ ## Building ```bash -# Build everything (compiles 17 modules, runs 213 unit tests) +# Build everything (compiles 17 modules, runs 217 unit tests) ./gradlew build # 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 | | | |---|---| | Modules | 17 | -| Unit tests | 213, all green | +| Unit tests | 217, all green | | Real PBCs | 7 of 10 | | Cross-cutting services live | 9 | | Plug-ins serving HTTP | 1 (reference printing-shop) | diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockMovementService.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockMovementService.kt index 256ac92..86f2a8c 100644 --- a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockMovementService.kt +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockMovementService.kt @@ -151,11 +151,13 @@ class StockMovementService( val expected: String? = when (reason) { MovementReason.RECEIPT, MovementReason.PURCHASE_RECEIPT, - MovementReason.TRANSFER_IN -> if (sign < 0) "non-negative" else null + MovementReason.TRANSFER_IN, + MovementReason.PRODUCTION_RECEIPT -> if (sign < 0) "non-negative" else null MovementReason.ISSUE, MovementReason.SALES_SHIPMENT, - MovementReason.TRANSFER_OUT -> if (sign > 0) "non-positive" else null + MovementReason.TRANSFER_OUT, + MovementReason.MATERIAL_ISSUE -> if (sign > 0) "non-positive" else null MovementReason.ADJUSTMENT -> null // either sign allowed } diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockMovement.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockMovement.kt index f8459fc..529c595 100644 --- a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockMovement.kt +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockMovement.kt @@ -104,13 +104,25 @@ class StockMovement( * matches the documented direction; calling code that gets this * wrong gets a meaningful error instead of a corrupted balance. * - * - **RECEIPT** — non-PO receipt (e.g. inventory found, customer return). Δ ≥ 0 - * - **ISSUE** — non-SO issue (e.g. write-off, internal use). Δ ≤ 0 - * - **ADJUSTMENT** — operator-driven correction (cycle count, mistake fix). Δ either sign - * - **SALES_SHIPMENT** — line item shipped on a sales order. Δ ≤ 0 - * - **PURCHASE_RECEIPT** — line item received against a purchase order. Δ ≥ 0 - * - **TRANSFER_OUT** — leg-1 of an inter-warehouse transfer. Δ ≤ 0 - * - **TRANSFER_IN** — leg-2 of an inter-warehouse transfer. Δ ≥ 0 + * - **RECEIPT** — non-PO receipt (e.g. inventory found, customer return). Δ ≥ 0 + * - **ISSUE** — non-SO issue (e.g. write-off, internal use). Δ ≤ 0 + * - **ADJUSTMENT** — operator-driven correction (cycle count, mistake fix). Δ either sign + * - **SALES_SHIPMENT** — line item shipped on a sales order. Δ ≤ 0 + * - **PURCHASE_RECEIPT** — line item received against a purchase order. Δ ≥ 0 + * - **TRANSFER_OUT** — leg-1 of an inter-warehouse transfer. Δ ≤ 0 + * - **TRANSFER_IN** — leg-2 of an inter-warehouse transfer. Δ ≥ 0 + * - **MATERIAL_ISSUE** — raw material consumed by a production work order. Δ ≤ 0 + * - **PRODUCTION_RECEIPT** — finished good produced by a work order. Δ ≥ 0 + * + * The two production-related reasons are added so the inventory + * facade can serve a future `pbc-production` (and any customer + * plug-in that needs to express "consume raw material" / "produce + * finished good") without growing a parallel API. The same + * `InventoryApi.recordMovement(...)` primitive feeds the same + * append-only `inventory__stock_movement` ledger from every + * direction — sales, purchase, production, manual adjustments — + * which is the property that makes "one ledger, many callers" + * actually true rather than a slogan. * * Stored as string in the DB so adding values later is non-breaking * for clients reading the column with raw SQL. @@ -123,4 +135,6 @@ enum class MovementReason { PURCHASE_RECEIPT, TRANSFER_OUT, TRANSFER_IN, + MATERIAL_ISSUE, + PRODUCTION_RECEIPT, } diff --git a/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockMovementServiceTest.kt b/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockMovementServiceTest.kt index 5566730..c85a1a8 100644 --- a/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockMovementServiceTest.kt +++ b/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockMovementServiceTest.kt @@ -96,6 +96,20 @@ class StockMovementServiceTest { } @Test + fun `record rejects positive delta on MATERIAL_ISSUE (production consumes raw material)`() { + assertFailure { service.record(cmd("5", MovementReason.MATERIAL_ISSUE)) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("non-positive") + } + + @Test + fun `record rejects negative delta on PRODUCTION_RECEIPT (production yields finished goods)`() { + assertFailure { service.record(cmd("-5", MovementReason.PRODUCTION_RECEIPT)) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("non-negative") + } + + @Test fun `record allows either sign on ADJUSTMENT`() { val locId = UUID.randomUUID() stubItem("SKU-1") @@ -214,6 +228,55 @@ class StockMovementServiceTest { } @Test + fun `record accepts a positive PRODUCTION_RECEIPT (finished good produced)`() { + stubItem("FG-WIDGET") + val locId = UUID.randomUUID() + every { locations.existsById(locId) } returns true + every { balances.findByItemCodeAndLocationId("FG-WIDGET", locId) } returns null + val savedMovement = slot() + every { balances.save(any()) } answers { firstArg() } + every { movements.save(capture(savedMovement)) } answers { savedMovement.captured } + + service.record( + RecordMovementCommand( + itemCode = "FG-WIDGET", + locationId = locId, + delta = BigDecimal("25"), + reason = MovementReason.PRODUCTION_RECEIPT, + reference = "WO:WO-2026-0001", + ), + ) + + assertThat(savedMovement.captured.reason).isEqualTo(MovementReason.PRODUCTION_RECEIPT) + assertThat(savedMovement.captured.delta).isEqualTo(BigDecimal("25")) + assertThat(savedMovement.captured.reference).isEqualTo("WO:WO-2026-0001") + } + + @Test + fun `record accepts a negative MATERIAL_ISSUE (raw material consumed)`() { + stubItem("RAW-PAPER") + val locId = UUID.randomUUID() + val existing = StockBalance("RAW-PAPER", locId, BigDecimal("1000")).also { it.id = UUID.randomUUID() } + every { locations.existsById(locId) } returns true + every { balances.findByItemCodeAndLocationId("RAW-PAPER", locId) } returns existing + val savedMovement = slot() + every { movements.save(capture(savedMovement)) } answers { savedMovement.captured } + + service.record( + RecordMovementCommand( + itemCode = "RAW-PAPER", + locationId = locId, + delta = BigDecimal("-200"), + reason = MovementReason.MATERIAL_ISSUE, + reference = "WO:WO-2026-0001", + ), + ) + + assertThat(savedMovement.captured.reason).isEqualTo(MovementReason.MATERIAL_ISSUE) + assertThat(existing.quantity).isEqualTo(BigDecimal("800")) + } + + @Test fun `record updates existing balance and inserts movement on the happy path`() { stubItem("SKU-1") val locId = UUID.randomUUID()