Commit f63a73d5cc113d8d5c5752d3d27aea561126285b

Authored by zichun
1 parent 8bf07982

feat(pbc): P5.3 — pbc-inventory + first cross-PBC facade caller

The fourth real PBC, and the first one that CONSUMES another PBC's
api.v1.ext facade. Until now every PBC was a *provider* of an
ext.<pbc> interface (identity, catalog, partners). pbc-inventory is
the first *consumer*: it injects org.vibeerp.api.v1.ext.catalog.CatalogApi
to validate item codes before adjusting stock. This proves the
cross-PBC contract works in both directions, exactly as guardrail #9
requires.

What landed
-----------
* New Gradle subproject `pbc/pbc-inventory` (14 modules total now).
* Two JPA entities, both extending `AuditedJpaEntity`:
  - `Location` — code, name, type (WAREHOUSE/BIN/VIRTUAL), active,
    ext jsonb. Single table for all location levels with a type
    discriminator (no recursive self-reference in v1; YAGNI for the
    "one warehouse, handful of bins" shape every printing shop has).
  - `StockBalance` — item_code (varchar, NOT a UUID FK), location_id
    FK, quantity numeric(18,4). The item_code is deliberately a
    string FK that references nothing because pbc-inventory has no
    compile-time link to pbc-catalog — the cross-PBC link goes
    through CatalogApi at runtime. UNIQUE INDEX on
    (item_code, location_id) is the primary integrity guarantee;
    UUID id is the addressable PK. CHECK (quantity >= 0).
* `LocationService` and `StockBalanceService` with full CRUD +
  adjust semantics. ext jsonb on Location goes through ExtJsonValidator
  (P3.4 — Tier 1 customisation).
* `StockBalanceService.adjust(itemCode, locationId, quantity)`:
  1. Reject negative quantity.
  2. **Inject CatalogApi**, call `findItemByCode(itemCode)`, reject
     if null with a meaningful 400. THIS is the cross-PBC seam test.
  3. Verify the location exists.
  4. SELECT-then-save upsert on (item_code, location_id) — single
     row per cell, mutated in place when the row exists, created
     when it doesn't. Single-instance deployment makes the
     read-modify-write race window academic.
* REST: `/api/v1/inventory/locations` (CRUD), `/api/v1/inventory/balances`
  (GET with itemCode or locationId filters, POST /adjust).
* New api.v1 facade `org.vibeerp.api.v1.ext.inventory` with
  `InventoryApi.findStockBalance(itemCode, locationCode)` +
  `totalOnHand(itemCode)` + `StockBalanceRef`. Fourth ext.* package
  after identity, catalog, partners. Sets up the next consumers
  (sales orders, purchase orders, the printing-shop plug-in's
  "do we have enough paper for this job?").
* `InventoryApiAdapter` runtime implementation in pbc-inventory.
* `inventory.yml` metadata declaring 2 entities, 6 permission keys,
  2 menu entries.

Build enforcement (the load-bearing bit)
----------------------------------------
The root build.gradle.kts STILL refuses any direct dependency from
pbc-inventory to pbc-catalog. Try adding `implementation(project(
":pbc:pbc-catalog"))` to pbc-inventory's build.gradle.kts and the
build fails at configuration time with "Architectural violation in
:pbc:pbc-inventory: depends on :pbc:pbc-catalog". The CatalogApi
interface is in api-v1; the CatalogApiAdapter implementation is in
pbc-catalog; Spring DI wires them at runtime via the bootstrap
@ComponentScan. pbc-inventory only ever sees the interface.

End-to-end smoke test
---------------------
Reset Postgres, booted the app, hit:
* POST /api/v1/inventory/locations → 201, "WH-MAIN" warehouse
* POST /api/v1/catalog/items → 201, "PAPER-A4" sheet item
* POST /api/v1/inventory/balances/adjust with itemCode=PAPER-A4 → 200,
  the cross-PBC catalog lookup succeeded
* POST .../adjust with itemCode=FAKE-ITEM → 400 with the meaningful
  message "item code 'FAKE-ITEM' is not in the catalog (or is inactive)"
  — the cross-PBC seam REJECTS unknown items as designed
* POST .../adjust with quantity=-5 → 400 "stock quantity must be
  non-negative", caught BEFORE the CatalogApi mock would be invoked
* POST .../adjust again with quantity=7500 → 200; SELECT shows ONE
  row with id unchanged and quantity = 7500 (upsert mutates, not
  duplicates)
* GET /api/v1/inventory/balances?itemCode=PAPER-A4 → the row, with
  scale-4 numeric serialised verbatim
* GET /api/v1/_meta/metadata/entities → 11 entities now (was 9 before
  Location + StockBalance landed)
* Regression: catalog uoms, identity users, partners, printing-shop
  plates with i18n (Accept-Language: zh-CN), Location custom-fields
  endpoint all still HTTP 2xx.

Build
-----
* `./gradlew build`: 14 subprojects, 139 unit tests (was 129),
  all green. The 10 new tests cover Location CRUD + the StockBalance
  adjust path with mocked CatalogApi: unknown item rejection, unknown
  location rejection, negative-quantity early reject (verifies
  CatalogApi is NOT consulted), happy-path create, and upsert
  (existing row mutated, save() not called because @Transactional
  flushes the JPA-managed entity on commit).

What was deferred
-----------------
* `inventory__stock_movement` append-only ledger. The current operation
  is "set the quantity"; receipts/issues/transfers as discrete events
  with audit trail land in a focused follow-up. The balance row will
  then be regenerated from the ledger via a Liquibase backfill.
* Negative-balance / over-issue prevention. The CHECK constraint
  blocks SET to a negative value, but there's no concept of "you
  cannot ISSUE more than is on hand" yet because there is no
  separate ISSUE operation — only absolute SET.
* Lots, batches, serial numbers, expiry dates. Plenty of printing
  shops need none of these; the ones that do can either wait for
  the lot/serial chunk later or add the columns via Tier 1 custom
  fields on Location for now.
* Cross-warehouse transfer atomicity (debit one, credit another in
  one transaction). Same — needs the ledger.
CLAUDE.md
@@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only
95 95
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 -- **13 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 -- **129 unit tests across 13 modules**, all green. `./gradlew build` is the canonical full build. 98 +- **14 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 +- **139 unit tests across 14 modules**, all green. `./gradlew build` is the canonical full build.
100 - **All 8 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), 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 8 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), 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 -- **3 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`) — they validate the recipe every future PBC clones across three different aggregate shapes (single entity, two-entity catalog, parent-with-children partners+addresses+contacts). 101 +- **4 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`) — they validate the recipe every future PBC clones across four different aggregate shapes. **pbc-inventory is the first PBC to also CONSUME another PBC's facade** (`CatalogApi`), proving the cross-PBC contract works in both directions.
102 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework. 102 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework.
103 - **Package root** is `org.vibeerp`. 103 - **Package root** is `org.vibeerp`.
104 - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot. 104 - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot.
PROGRESS.md
@@ -10,27 +10,27 @@ @@ -10,27 +10,27 @@
10 10
11 | | | 11 | | |
12 |---|---| 12 |---|---|
13 -| **Latest version** | v0.9 (post-P3.4) |  
14 -| **Latest commit** | `5bffbc4 feat(metadata): P3.4 — custom field application (Tier 1 customization)` | 13 +| **Latest version** | v0.10 (post-P5.3) |
  14 +| **Latest commit** | `feat(pbc): P5.3 — pbc-inventory + first cross-PBC facade caller` |
15 | **Repo** | https://github.com/reporkey/vibe-erp | 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 -| **Modules** | 13 |  
17 -| **Unit tests** | 129, all green |  
18 -| **End-to-end smoke runs** | All cross-cutting services + all 3 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales; Partner accepts custom-field `ext` declared in metadata |  
19 -| **Real PBCs implemented** | 3 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`) | 16 +| **Modules** | 14 |
  17 +| **Unit tests** | 139, all green |
  18 +| **End-to-end smoke runs** | All cross-cutting services + all 4 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales; Partner + Location accept custom-field `ext`; pbc-inventory rejects unknown items via the cross-PBC catalog seam |
  19 +| **Real PBCs implemented** | 4 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`) |
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) |
21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | 21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. |
22 22
23 ## Current stage 23 ## Current stage
24 24
25 -**Foundation complete; Tier 1 customization live; business surface area growing.** All eight cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader, ICU4J translator). The metadata layer now drives **custom-field validation on JSONB `ext` columns** (P3.4), the cornerstone of the "key user adds fields without code" promise: a YAML declaration is enough to start enforcing types, scales, enums, and required-checks on every save. Three real PBCs (identity, catalog, partners) validate the modular-monolith template; Partner is the first to use custom fields. The reference printing-shop plug-in remains the executable acceptance test. 25 +**Foundation complete; Tier 1 customization live; cross-PBC seam proven in both directions.** All eight cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader + custom-field validator, ICU4J translator). Four real PBCs (identity, catalog, partners, inventory) validate the modular-monolith template across four different aggregate shapes. **pbc-inventory is the first cross-PBC facade *caller*** — it injects `CatalogApi` to validate item codes before adjusting stock, proving the api.v1.ext.* contract works in both directions (every earlier PBC was a provider; this is the first consumer). The Gradle build still refuses any direct dependency between PBCs.
26 26
27 -The next phase continues **building business surface area**: more PBCs (inventory, sales orders), the workflow engine (Flowable), permission enforcement against `metadata__role_permission`, and eventually the React SPA. 27 +The next phase continues **building business surface area**: more PBCs (sales orders, purchase orders, production), the workflow engine (Flowable), permission enforcement against `metadata__role_permission`, and eventually the React SPA.
28 28
29 ## Total scope (the v1.0 cut line) 29 ## Total scope (the v1.0 cut line)
30 30
31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar. 31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar.
32 32
33 -That target breaks down into roughly 30 work units across 8 phases. About **18 are done** as of today. Below is the full list with status. 33 +That target breaks down into roughly 30 work units across 8 phases. About **19 are done** as of today. Below is the full list with status.
34 34
35 ### Phase 1 — Platform completion (foundation) 35 ### Phase 1 — Platform completion (foundation)
36 36
@@ -80,7 +80,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **18 a @@ -80,7 +80,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **18 a
80 |---|---|---| 80 |---|---|---|
81 | P5.1 | `pbc-catalog` — items, units of measure | ✅ DONE — `69f3daa` | 81 | P5.1 | `pbc-catalog` — items, units of measure | ✅ DONE — `69f3daa` |
82 | P5.2 | `pbc-partners` — customers, suppliers, contacts, addresses | ✅ DONE — `3e40cae` | 82 | P5.2 | `pbc-partners` — customers, suppliers, contacts, addresses | ✅ DONE — `3e40cae` |
83 -| P5.3 | `pbc-inventory` — stock items, lots, locations, movements | 🔜 Pending | 83 +| P5.3 | `pbc-inventory` — locations + stock balances (movements deferred) | ✅ DONE — `<this commit>` |
84 | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending | 84 | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending |
85 | P5.5 | `pbc-orders-sales` — quotes, sales orders, deliveries | 🔜 Pending | 85 | P5.5 | `pbc-orders-sales` — quotes, sales orders, deliveries | 🔜 Pending |
86 | P5.6 | `pbc-orders-purchase` — RFQs, POs, receipts | 🔜 Pending | 86 | P5.6 | `pbc-orders-purchase` — RFQs, POs, receipts | 🔜 Pending |
@@ -128,7 +128,7 @@ These are the cross-cutting platform services already wired into the running fra @@ -128,7 +128,7 @@ These are the cross-cutting platform services already wired into the running fra
128 | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. | 128 | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. |
129 | **i18n / Translator** (P1.6) | `platform-i18n` | ICU4J-backed `Translator` with named placeholders, plurals, gender, locale-aware number/date formatting. Per-plug-in instance scoped via a `(classLoader, baseName)` chain — plug-in's `META-INF/vibe-erp/i18n/messages_<locale>.properties` resolves before the host's `messages_<locale>.properties` for shared keys. `RequestLocaleProvider` reads `Accept-Language` from the active HTTP request via the servlet container, falling back to `vibeerp.i18n.defaultLocale` outside an HTTP context. JVM-default locale fallback explicitly disabled to prevent silent locale leaks. | 129 | **i18n / Translator** (P1.6) | `platform-i18n` | ICU4J-backed `Translator` with named placeholders, plurals, gender, locale-aware number/date formatting. Per-plug-in instance scoped via a `(classLoader, baseName)` chain — plug-in's `META-INF/vibe-erp/i18n/messages_<locale>.properties` resolves before the host's `messages_<locale>.properties` for shared keys. `RequestLocaleProvider` reads `Accept-Language` from the active HTTP request via the servlet container, falling back to `vibeerp.i18n.defaultLocale` outside an HTTP context. JVM-default locale fallback explicitly disabled to prevent silent locale leaks. |
130 | **Custom field application** (P3.4) | `platform-metadata.customfield` | `CustomFieldRegistry` reads `metadata__custom_field` rows into an in-memory index keyed by entity name, refreshed at boot and after every plug-in load. `ExtJsonValidator` validates the JSONB `ext` map of any entity against the declared `FieldType`s — String maxLength, Integer/Decimal numeric coercion with precision/scale enforcement, Boolean, Date/DateTime ISO-8601 parsing, Enum allowed values, UUID format. Unknown keys are rejected; required missing fields are rejected; ALL violations are returned in a single 400 so a form submitter fixes everything in one round-trip. `Partner` is the first PBC entity to wire ext through `PartnerService.create/update`; the public `GET /api/v1/_meta/metadata/custom-fields/{entityName}` endpoint serves the api.v1 runtime view of declarations to the SPA / OpenAPI / AI agent. | 130 | **Custom field application** (P3.4) | `platform-metadata.customfield` | `CustomFieldRegistry` reads `metadata__custom_field` rows into an in-memory index keyed by entity name, refreshed at boot and after every plug-in load. `ExtJsonValidator` validates the JSONB `ext` map of any entity against the declared `FieldType`s — String maxLength, Integer/Decimal numeric coercion with precision/scale enforcement, Boolean, Date/DateTime ISO-8601 parsing, Enum allowed values, UUID format. Unknown keys are rejected; required missing fields are rejected; ALL violations are returned in a single 400 so a form submitter fixes everything in one round-trip. `Partner` is the first PBC entity to wire ext through `PartnerService.create/update`; the public `GET /api/v1/_meta/metadata/custom-fields/{entityName}` endpoint serves the api.v1 runtime view of declarations to the SPA / OpenAPI / AI agent. |
131 -| **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners` | Three real PBCs prove the recipe across three aggregate shapes (single-entity user, two-entity catalog, parent-with-children partners+addresses+contacts): domain entity extending `AuditedJpaEntity` → Spring Data JPA repository → application service → REST controller under `/api/v1/<pbc>/<resource>` → cross-PBC facade in `api.v1.ext.<pbc>` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. | 131 +| **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory` | Four real PBCs prove the recipe across four aggregate shapes (single-entity user, two-entity catalog, parent-with-children partners+addresses+contacts, master-data + facts inventory locations+balances): domain entity extending `AuditedJpaEntity` → Spring Data JPA repository → application service → REST controller under `/api/v1/<pbc>/<resource>` → cross-PBC facade in `api.v1.ext.<pbc>` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. **pbc-inventory** is also the first PBC to *consume* another PBC's facade — it injects `CatalogApi` from api.v1 to validate item codes before adjusting stock, proving the cross-PBC contract works in both directions. |
132 132
133 ## What the reference plug-in proves end-to-end 133 ## What the reference plug-in proves end-to-end
134 134
@@ -170,7 +170,8 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, @@ -170,7 +170,8 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework,
170 - **Job scheduler.** No Quartz. Periodic jobs don't have a home. 170 - **Job scheduler.** No Quartz. Periodic jobs don't have a home.
171 - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2. 171 - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2.
172 - **Permission checks.** `Principal` is bound; `metadata__permission` is seeded; the actual `@RequirePermission` enforcement layer is P4.3. 172 - **Permission checks.** `Principal` is bound; `metadata__permission` is seeded; the actual `@RequirePermission` enforcement layer is P4.3.
173 -- **More PBCs.** Identity, catalog and partners exist. Inventory, warehousing, orders, production, quality, finance are all pending. 173 +- **More PBCs.** Identity, catalog, partners and inventory exist. Warehousing, orders, production, quality, finance are all pending.
  174 +- **Stock movement ledger.** Inventory has balances but not the append-only `inventory__stock_movement` history yet — the v1 cut is "set the quantity"; the audit-grade ledger lands in a follow-up.
174 - **Web SPA.** No React app. The framework is API-only today. 175 - **Web SPA.** No React app. The framework is API-only today.
175 - **MCP server.** The architecture leaves room for it; the implementation is v1.1. 176 - **MCP server.** The architecture leaves room for it; the implementation is v1.1.
176 - **Mobile.** v2. 177 - **Mobile.** v2.
@@ -214,6 +215,8 @@ platform/platform-plugins PF4J host, linter, Liquibase runner, endpoint disp @@ -214,6 +215,8 @@ platform/platform-plugins PF4J host, linter, Liquibase runner, endpoint disp
214 pbc/pbc-identity User entity end-to-end + auth + bootstrap admin 215 pbc/pbc-identity User entity end-to-end + auth + bootstrap admin
215 pbc/pbc-catalog Item + Uom entities + cross-PBC CatalogApi facade 216 pbc/pbc-catalog Item + Uom entities + cross-PBC CatalogApi facade
216 pbc/pbc-partners Partner + Address + Contact entities + cross-PBC PartnersApi facade 217 pbc/pbc-partners Partner + Address + Contact entities + cross-PBC PartnersApi facade
  218 +pbc/pbc-inventory Location + StockBalance + cross-PBC InventoryApi facade
  219 + (FIRST PBC to also CONSUME another PBC's facade — CatalogApi)
217 220
218 reference-customer/plugin-printing-shop 221 reference-customer/plugin-printing-shop
219 Reference plug-in: own DB schema (plate, ink_recipe), 222 Reference plug-in: own DB schema (plate, ink_recipe),
@@ -222,7 +225,7 @@ reference-customer/plugin-printing-shop @@ -222,7 +225,7 @@ reference-customer/plugin-printing-shop
222 distribution Bootable Spring Boot fat-jar assembly 225 distribution Bootable Spring Boot fat-jar assembly
223 ``` 226 ```
224 227
225 -13 Gradle subprojects. Architectural dependency rule (PBCs never import each other; plug-ins only see api.v1) is enforced by the root `build.gradle.kts` at configuration time. 228 +14 Gradle subprojects. Architectural dependency rule (PBCs never import each other; plug-ins only see api.v1) is enforced by the root `build.gradle.kts` at configuration time.
226 229
227 ## Where to look next 230 ## Where to look next
228 231
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 13 modules, runs 129 unit tests) 80 +# Build everything (compiles 14 modules, runs 139 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
@@ -96,9 +96,9 @@ The bootstrap admin password is printed to the application logs on first boot. A @@ -96,9 +96,9 @@ The bootstrap admin password is printed to the application logs on first boot. A
96 96
97 | | | 97 | | |
98 |---|---| 98 |---|---|
99 -| Modules | 13 |  
100 -| Unit tests | 129, all green |  
101 -| Real PBCs | 3 of 10 | 99 +| Modules | 14 |
  100 +| Unit tests | 139, all green |
  101 +| Real PBCs | 4 of 10 |
102 | Cross-cutting services live | 8 | 102 | Cross-cutting services live | 8 |
103 | Plug-ins serving HTTP | 1 (reference printing-shop) | 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | 104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt 0 → 100644
  1 +package org.vibeerp.api.v1.ext.inventory
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +import java.math.BigDecimal
  5 +
  6 +/**
  7 + * Cross-PBC facade for the inventory bounded context.
  8 + *
  9 + * The fourth `api.v1.ext.*` package after `ext.identity`, `ext.catalog`,
  10 + * and `ext.partners`. By the time pbc-inventory ships, the framework
  11 + * has proven the cross-PBC contract works in BOTH directions: every
  12 + * earlier PBC was a *provider* (other code injects its `*Api` to
  13 + * read), and pbc-inventory is the first PBC that is also a *consumer*
  14 + * — it injects [org.vibeerp.api.v1.ext.catalog.CatalogApi] to validate
  15 + * an item code before adjusting stock.
  16 + *
  17 + * This interface is the seam for the NEXT generation of consumers:
  18 + * pbc-orders-sales will inject [InventoryApi] to check stock before
  19 + * accepting an order, pbc-orders-purchase to track committed receipts,
  20 + * and the printing-shop reference plug-in to demo "do we have enough
  21 + * paper for this job?". None of those callers will import a
  22 + * pbc-inventory type — they will see only [StockBalanceRef].
  23 + *
  24 + * **Lookups are by codes**, not by ids. Codes are stable and
  25 + * human-readable; the inventory PBC's UUIDs are an implementation
  26 + * detail. The same rule every other `ext.*` facade follows.
  27 + *
  28 + * **What this facade does NOT expose** (deliberately):
  29 + * - The list of all balances. The facade is for "is this item at
  30 + * this location?", not for browsing inventory. Operators that
  31 + * want to browse use the REST API with their token.
  32 + * - Adjust / set / movement operations. Other PBCs SHOULD NOT
  33 + * mutate inventory directly — they emit a `StockReservation` or
  34 + * `OrderShipped` domain event and let pbc-inventory react. This
  35 + * keeps the inventory invariants in one place.
  36 + * - The location's full record. Just the code is exposed when
  37 + * needed; callers that need the location's name or type can ask
  38 + * inventory's REST API directly with their token.
  39 + */
  40 +interface InventoryApi {
  41 +
  42 + /**
  43 + * Look up the on-hand stock balance for [itemCode] at
  44 + * [locationCode]. Returns `null` when no balance row exists for
  45 + * that combination — the framework treats "no row" as "no stock",
  46 + * NOT as "the item doesn't exist", because the absence might just
  47 + * mean nothing has ever been received into this location.
  48 + *
  49 + * Callers that need to distinguish "the item doesn't exist" from
  50 + * "the item exists but has no stock here" should call
  51 + * [org.vibeerp.api.v1.ext.catalog.CatalogApi.findItemByCode] first.
  52 + */
  53 + fun findStockBalance(itemCode: String, locationCode: String): StockBalanceRef?
  54 +
  55 + /**
  56 + * Sum every balance row across every location for [itemCode].
  57 + * Returns `BigDecimal.ZERO` when no rows exist (NOT `null`) — the
  58 + * facade treats "no stock anywhere" as a legitimate non-error
  59 + * state because that is most items most of the time.
  60 + */
  61 + fun totalOnHand(itemCode: String): BigDecimal
  62 +}
  63 +
  64 +/**
  65 + * Minimal, safe-to-publish view of a stock balance row.
  66 + *
  67 + * Notably absent: the underlying `version` column (optimistic locking
  68 + * is an internal concern), audit columns (consumers don't need them),
  69 + * the `ext` JSONB (custom-field reads cross the metadata system, not
  70 + * the cross-PBC facade). Anything not here cannot be observed
  71 + * cross-PBC, by design.
  72 + */
  73 +data class StockBalanceRef(
  74 + val id: Id<StockBalanceRef>,
  75 + val itemCode: String,
  76 + val locationCode: String,
  77 + val quantity: BigDecimal,
  78 +)
distribution/build.gradle.kts
@@ -29,6 +29,7 @@ dependencies { @@ -29,6 +29,7 @@ dependencies {
29 implementation(project(":pbc:pbc-identity")) 29 implementation(project(":pbc:pbc-identity"))
30 implementation(project(":pbc:pbc-catalog")) 30 implementation(project(":pbc:pbc-catalog"))
31 implementation(project(":pbc:pbc-partners")) 31 implementation(project(":pbc:pbc-partners"))
  32 + implementation(project(":pbc:pbc-inventory"))
32 33
33 implementation(libs.spring.boot.starter) 34 implementation(libs.spring.boot.starter)
34 implementation(libs.spring.boot.starter.web) 35 implementation(libs.spring.boot.starter.web)
distribution/src/main/resources/db/changelog/master.xml
@@ -16,4 +16,5 @@ @@ -16,4 +16,5 @@
16 <include file="classpath:db/changelog/pbc-identity/002-identity-credential.xml"/> 16 <include file="classpath:db/changelog/pbc-identity/002-identity-credential.xml"/>
17 <include file="classpath:db/changelog/pbc-catalog/001-catalog-init.xml"/> 17 <include file="classpath:db/changelog/pbc-catalog/001-catalog-init.xml"/>
18 <include file="classpath:db/changelog/pbc-partners/001-partners-init.xml"/> 18 <include file="classpath:db/changelog/pbc-partners/001-partners-init.xml"/>
  19 + <include file="classpath:db/changelog/pbc-inventory/001-inventory-init.xml"/>
19 </databaseChangeLog> 20 </databaseChangeLog>
distribution/src/main/resources/db/changelog/pbc-inventory/001-inventory-init.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  5 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  6 +
  7 + <!--
  8 + pbc-inventory initial schema (P5.3).
  9 +
  10 + Owns: inventory__location, inventory__stock_balance.
  11 +
  12 + vibe_erp is single-tenant per instance — no tenant_id columns,
  13 + no Row-Level Security policies.
  14 +
  15 + StockBalance does NOT have a foreign key to catalog__item, by
  16 + design. The link to "is this item in the catalog?" is enforced
  17 + at the application layer through the cross-PBC api.v1 facade
  18 + (CatalogApi). A database FK across PBCs would couple their
  19 + schemas at the storage level, defeating the bounded-context
  20 + rule.
  21 +
  22 + StockBalance HAS a foreign key to inventory__location because
  23 + the relationship is intra-PBC and the framework's "PBCs own
  24 + their own tables" guarantee makes the FK safe.
  25 +
  26 + Conventions enforced for every business table in vibe_erp:
  27 + • UUID primary key
  28 + • Audit columns: created_at, created_by, updated_at, updated_by
  29 + • Optimistic-locking version column
  30 + • ext jsonb NOT NULL DEFAULT '{}' on aggregate roots only
  31 + (Location has it; StockBalance doesn't — it's a fact, not
  32 + a master record, and Tier 1 customisation belongs on the
  33 + facts that hold the data, not the cells that count it)
  34 + • GIN index on ext for fast custom-field queries
  35 + -->
  36 +
  37 + <changeSet id="inventory-init-001" author="vibe_erp">
  38 + <comment>Create inventory__location table</comment>
  39 + <sql>
  40 + CREATE TABLE inventory__location (
  41 + id uuid PRIMARY KEY,
  42 + code varchar(64) NOT NULL,
  43 + name varchar(256) NOT NULL,
  44 + type varchar(16) NOT NULL,
  45 + active boolean NOT NULL DEFAULT true,
  46 + ext jsonb NOT NULL DEFAULT '{}'::jsonb,
  47 + created_at timestamptz NOT NULL,
  48 + created_by varchar(128) NOT NULL,
  49 + updated_at timestamptz NOT NULL,
  50 + updated_by varchar(128) NOT NULL,
  51 + version bigint NOT NULL DEFAULT 0
  52 + );
  53 + CREATE UNIQUE INDEX inventory__location_code_uk ON inventory__location (code);
  54 + CREATE INDEX inventory__location_type_idx ON inventory__location (type);
  55 + CREATE INDEX inventory__location_active_idx ON inventory__location (active);
  56 + CREATE INDEX inventory__location_ext_gin ON inventory__location USING GIN (ext jsonb_path_ops);
  57 + </sql>
  58 + <rollback>
  59 + DROP TABLE inventory__location;
  60 + </rollback>
  61 + </changeSet>
  62 +
  63 + <changeSet id="inventory-init-002" author="vibe_erp">
  64 + <comment>Create inventory__stock_balance table (FK to inventory__location, no FK to catalog__item)</comment>
  65 + <sql>
  66 + CREATE TABLE inventory__stock_balance (
  67 + id uuid PRIMARY KEY,
  68 + item_code varchar(64) NOT NULL,
  69 + location_id uuid NOT NULL REFERENCES inventory__location(id),
  70 + quantity numeric(18,4) NOT NULL,
  71 + created_at timestamptz NOT NULL,
  72 + created_by varchar(128) NOT NULL,
  73 + updated_at timestamptz NOT NULL,
  74 + updated_by varchar(128) NOT NULL,
  75 + version bigint NOT NULL DEFAULT 0,
  76 + CONSTRAINT inventory__stock_balance_nonneg CHECK (quantity &gt;= 0)
  77 + );
  78 + CREATE UNIQUE INDEX inventory__stock_balance_item_loc_uk
  79 + ON inventory__stock_balance (item_code, location_id);
  80 + CREATE INDEX inventory__stock_balance_item_idx ON inventory__stock_balance (item_code);
  81 + CREATE INDEX inventory__stock_balance_loc_idx ON inventory__stock_balance (location_id);
  82 + </sql>
  83 + <rollback>
  84 + DROP TABLE inventory__stock_balance;
  85 + </rollback>
  86 + </changeSet>
  87 +
  88 +</databaseChangeLog>
pbc/pbc-inventory/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.kotlin.jpa)
  5 + alias(libs.plugins.spring.dependency.management)
  6 +}
  7 +
  8 +description = "vibe_erp pbc-inventory — locations and stock balances. INTERNAL Packaged Business Capability."
  9 +
  10 +java {
  11 + toolchain {
  12 + languageVersion.set(JavaLanguageVersion.of(21))
  13 + }
  14 +}
  15 +
  16 +kotlin {
  17 + jvmToolchain(21)
  18 + compilerOptions {
  19 + freeCompilerArgs.add("-Xjsr305=strict")
  20 + }
  21 +}
  22 +
  23 +allOpen {
  24 + annotation("jakarta.persistence.Entity")
  25 + annotation("jakarta.persistence.MappedSuperclass")
  26 + annotation("jakarta.persistence.Embeddable")
  27 +}
  28 +
  29 +// CRITICAL: pbc-inventory may depend on api-v1 (which exposes the
  30 +// cross-PBC CatalogApi interface), platform-persistence,
  31 +// platform-security, and platform-metadata — but NEVER on
  32 +// platform-bootstrap, NEVER on another pbc-* (NEVER on pbc-catalog
  33 +// even though we INJECT CatalogApi at runtime). The catalog
  34 +// implementation lives in pbc-catalog and is wired into the Spring
  35 +// context at runtime by the bootstrap @ComponentScan; this PBC sees
  36 +// only the interface, exactly as guardrail #9 demands.
  37 +//
  38 +// The root build.gradle.kts enforces this; if you add a `:pbc:pbc-foo`
  39 +// or `:platform:platform-bootstrap` dependency below, the build will
  40 +// refuse to load with an architectural-violation error.
  41 +dependencies {
  42 + api(project(":api:api-v1"))
  43 + implementation(project(":platform:platform-persistence"))
  44 + implementation(project(":platform:platform-security"))
  45 + implementation(project(":platform:platform-metadata")) // ExtJsonValidator (P3.4)
  46 +
  47 + implementation(libs.kotlin.stdlib)
  48 + implementation(libs.kotlin.reflect)
  49 +
  50 + implementation(libs.spring.boot.starter)
  51 + implementation(libs.spring.boot.starter.web)
  52 + implementation(libs.spring.boot.starter.data.jpa)
  53 + implementation(libs.spring.boot.starter.validation)
  54 + implementation(libs.jackson.module.kotlin)
  55 +
  56 + testImplementation(libs.spring.boot.starter.test)
  57 + testImplementation(libs.junit.jupiter)
  58 + testImplementation(libs.assertk)
  59 + testImplementation(libs.mockk)
  60 +}
  61 +
  62 +tasks.test {
  63 + useJUnitPlatform()
  64 +}
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/LocationService.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.application
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper
  4 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
  5 +import org.springframework.stereotype.Service
  6 +import org.springframework.transaction.annotation.Transactional
  7 +import org.vibeerp.pbc.inventory.domain.Location
  8 +import org.vibeerp.pbc.inventory.domain.LocationType
  9 +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
  10 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
  11 +import java.util.UUID
  12 +
  13 +/**
  14 + * Application service for location CRUD.
  15 + *
  16 + * Same shape as the catalog and partner services: thin wrapper around
  17 + * the JPA repository, code uniqueness checked in code (not just in
  18 + * the unique index, so the error message is meaningful), `ext` JSONB
  19 + * goes through the [ExtJsonValidator] before save.
  20 + *
  21 + * **Code is not updatable** — every cross-PBC reference uses the code,
  22 + * so renaming is a data-migration concern, not a routine API call.
  23 + *
  24 + * **Deactivate, never delete** — the same rule every other vibe_erp
  25 + * master-data PBC follows. Historical stock balances and (eventually)
  26 + * stock movements still resolve their location_id references.
  27 + */
  28 +@Service
  29 +@Transactional
  30 +class LocationService(
  31 + private val locations: LocationJpaRepository,
  32 + private val extValidator: ExtJsonValidator,
  33 +) {
  34 +
  35 + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
  36 +
  37 + @Transactional(readOnly = true)
  38 + fun list(): List<Location> = locations.findAll()
  39 +
  40 + @Transactional(readOnly = true)
  41 + fun findById(id: UUID): Location? = locations.findById(id).orElse(null)
  42 +
  43 + @Transactional(readOnly = true)
  44 + fun findByCode(code: String): Location? = locations.findByCode(code)
  45 +
  46 + fun create(command: CreateLocationCommand): Location {
  47 + require(!locations.existsByCode(command.code)) {
  48 + "location code '${command.code}' is already taken"
  49 + }
  50 + val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)
  51 + return locations.save(
  52 + Location(
  53 + code = command.code,
  54 + name = command.name,
  55 + type = command.type,
  56 + active = command.active,
  57 + ).also {
  58 + it.ext = jsonMapper.writeValueAsString(canonicalExt)
  59 + },
  60 + )
  61 + }
  62 +
  63 + fun update(id: UUID, command: UpdateLocationCommand): Location {
  64 + val location = locations.findById(id).orElseThrow {
  65 + NoSuchElementException("location not found: $id")
  66 + }
  67 + command.name?.let { location.name = it }
  68 + command.type?.let { location.type = it }
  69 + command.active?.let { location.active = it }
  70 + if (command.ext != null) {
  71 + val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)
  72 + location.ext = jsonMapper.writeValueAsString(canonicalExt)
  73 + }
  74 + return location
  75 + }
  76 +
  77 + fun deactivate(id: UUID) {
  78 + val location = locations.findById(id).orElseThrow {
  79 + NoSuchElementException("location not found: $id")
  80 + }
  81 + location.active = false
  82 + }
  83 +
  84 + @Suppress("UNCHECKED_CAST")
  85 + fun parseExt(location: Location): Map<String, Any?> = try {
  86 + if (location.ext.isBlank()) emptyMap()
  87 + else jsonMapper.readValue(location.ext, Map::class.java) as Map<String, Any?>
  88 + } catch (ex: Throwable) {
  89 + emptyMap()
  90 + }
  91 +
  92 + companion object {
  93 + const val ENTITY_NAME: String = "Location"
  94 + }
  95 +}
  96 +
  97 +data class CreateLocationCommand(
  98 + val code: String,
  99 + val name: String,
  100 + val type: LocationType,
  101 + val active: Boolean = true,
  102 + val ext: Map<String, Any?>? = null,
  103 +)
  104 +
  105 +data class UpdateLocationCommand(
  106 + val name: String? = null,
  107 + val type: LocationType? = null,
  108 + val active: Boolean? = null,
  109 + val ext: Map<String, Any?>? = null,
  110 +)
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceService.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.application
  2 +
  3 +import org.springframework.stereotype.Service
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  6 +import org.vibeerp.pbc.inventory.domain.StockBalance
  7 +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
  8 +import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository
  9 +import java.math.BigDecimal
  10 +import java.util.UUID
  11 +
  12 +/**
  13 + * Application service for stock balance read + adjust.
  14 + *
  15 + * **The first cross-PBC facade caller in the codebase.** Until P5.3,
  16 + * every PBC was a *provider* of an `api.v1.ext.<pbc>` interface
  17 + * (identity, catalog, partners). pbc-inventory is the first
  18 + * *consumer*: it injects [CatalogApi] to validate that the item code
  19 + * an adjust request quotes actually exists in the catalog and is
  20 + * active. The Spring DI container resolves the interface to the
  21 + * concrete `CatalogApiAdapter` bean from pbc-catalog at runtime —
  22 + * pbc-inventory has no compile-time dependency on pbc-catalog (the
  23 + * Gradle build refuses such a dependency, see CLAUDE.md guardrail #9).
  24 + *
  25 + * This is the proof that the modular-monolith story works in both
  26 + * directions: a new PBC can declare a cross-cutting need against any
  27 + * other PBC's `ext.*` facade without ever importing its types.
  28 + *
  29 + * **What `adjust` does** (v1):
  30 + * 1. Look up the item by code via [CatalogApi.findItemByCode].
  31 + * Inactive or unknown items are rejected with a meaningful 400.
  32 + * 2. Look up the location by id; unknown locations are rejected.
  33 + * 3. Find the existing balance row for (item_code, location_id) or
  34 + * create one with quantity = 0.
  35 + * 4. Set the quantity to the requested value (NOT add — see below).
  36 + *
  37 + * **Why SET, not ADD, in v1.** The "delta" semantics (receipt = +X,
  38 + * issue = -X) requires a movement ledger to be auditable, and the
  39 + * ledger is deferred to a follow-up chunk. The current operation is
  40 + * deliberately the dumber one — "the balance is now N" — so the v1
  41 + * cut is honest about not having the audit trail to back delta math.
  42 + * Once the ledger lands, this method's signature stays the same; the
  43 + * implementation grows a movement insert and the ledger view-rebuild
  44 + * runs in the same transaction.
  45 + *
  46 + * Negative quantities are rejected up front. Stock cannot go below
  47 + * zero, and the framework refuses to record it.
  48 + */
  49 +@Service
  50 +@Transactional
  51 +class StockBalanceService(
  52 + private val balances: StockBalanceJpaRepository,
  53 + private val locations: LocationJpaRepository,
  54 + private val catalogApi: CatalogApi,
  55 +) {
  56 +
  57 + @Transactional(readOnly = true)
  58 + fun list(): List<StockBalance> = balances.findAll()
  59 +
  60 + @Transactional(readOnly = true)
  61 + fun findByItemCode(itemCode: String): List<StockBalance> =
  62 + balances.findByItemCode(itemCode)
  63 +
  64 + @Transactional(readOnly = true)
  65 + fun findByLocationId(locationId: UUID): List<StockBalance> =
  66 + balances.findByLocationId(locationId)
  67 +
  68 + /**
  69 + * Set the on-hand quantity for [itemCode] at [locationId] to
  70 + * [quantity], creating the balance row if it does not yet exist.
  71 + */
  72 + fun adjust(itemCode: String, locationId: UUID, quantity: BigDecimal): StockBalance {
  73 + require(quantity.signum() >= 0) {
  74 + "stock quantity must be non-negative (got $quantity)"
  75 + }
  76 +
  77 + // Cross-PBC validation #1: the item must exist in the catalog
  78 + // AND be active. CatalogApi returns null for inactive items —
  79 + // see the rationale on CatalogApi.findItemByCode.
  80 + catalogApi.findItemByCode(itemCode)
  81 + ?: throw IllegalArgumentException(
  82 + "item code '$itemCode' is not in the catalog (or is inactive)",
  83 + )
  84 +
  85 + // Local validation #2: the location must exist. We don't go
  86 + // through a facade because the location lives in this PBC.
  87 + require(locations.existsById(locationId)) {
  88 + "location not found: $locationId"
  89 + }
  90 +
  91 + // Upsert the balance row. Single-instance deployments mean the
  92 + // SELECT-then-save race window is closed for practical purposes
  93 + // — vibe_erp is single-tenant per process and the @Transactional
  94 + // boundary makes the read-modify-write atomic at the DB level.
  95 + val existing = balances.findByItemCodeAndLocationId(itemCode, locationId)
  96 + return if (existing == null) {
  97 + balances.save(StockBalance(itemCode, locationId, quantity))
  98 + } else {
  99 + existing.quantity = quantity
  100 + existing
  101 + }
  102 + }
  103 +}
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/Location.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.EnumType
  6 +import jakarta.persistence.Enumerated
  7 +import jakarta.persistence.Table
  8 +import org.hibernate.annotations.JdbcTypeCode
  9 +import org.hibernate.type.SqlTypes
  10 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  11 +
  12 +/**
  13 + * A physical or logical place where stock can sit.
  14 + *
  15 + * The pbc-inventory PBC's foundation entity. Stock balances reference
  16 + * an item × location pair, so before any stock can be tracked the
  17 + * location it sits in must exist. The model deliberately conflates
  18 + * "warehouse the company owns" and "bin within a warehouse" into one
  19 + * table with a [LocationType] discriminator: real warehouse layouts
  20 + * are recursive (warehouse → aisle → rack → bin) and modelling each
  21 + * level as its own table forces a join cascade for every stock query.
  22 + * One table with a self-reference would be even better, but YAGNI for
  23 + * v1 — most printing shops have one warehouse and a handful of named
  24 + * bins, and the discriminator handles that case cleanly without a
  25 + * recursive query.
  26 + *
  27 + * **Why `code` is the natural key** (with a UUID primary key):
  28 + * Same rationale as Item and Partner — codes are stable, human-readable,
  29 + * and survive database re-imports. Sales orders, purchase receipts, and
  30 + * production work orders quote a `WH-MAIN` location code, not a UUID.
  31 + *
  32 + * **Why VIRTUAL is in the type set:** non-physical locations like
  33 + * "in transit", "scrap", or "consigned" are real places stock can
  34 + * legitimately be. They behave like any other location in the
  35 + * arithmetic — the only difference is they have no shelf in the real
  36 + * world. Treating them as their own type lets the future warehouse-
  37 + * management UI hide them from pickers without changing the data
  38 + * model.
  39 + *
  40 + * The `ext` JSONB column is for Tier 1 customisation (P3.4) — a key
  41 + * user can add a `temperature_range` or `bonded_warehouse_licence_id`
  42 + * field via the metadata layer without patching this PBC. The
  43 + * framework's `ExtJsonValidator` enforces declared types on every
  44 + * save.
  45 + */
  46 +@Entity
  47 +@Table(name = "inventory__location")
  48 +class Location(
  49 + code: String,
  50 + name: String,
  51 + type: LocationType,
  52 + active: Boolean = true,
  53 +) : AuditedJpaEntity() {
  54 +
  55 + @Column(name = "code", nullable = false, length = 64)
  56 + var code: String = code
  57 +
  58 + @Column(name = "name", nullable = false, length = 256)
  59 + var name: String = name
  60 +
  61 + @Enumerated(EnumType.STRING)
  62 + @Column(name = "type", nullable = false, length = 16)
  63 + var type: LocationType = type
  64 +
  65 + @Column(name = "active", nullable = false)
  66 + var active: Boolean = active
  67 +
  68 + @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
  69 + @JdbcTypeCode(SqlTypes.JSON)
  70 + var ext: String = "{}"
  71 +
  72 + override fun toString(): String =
  73 + "Location(id=$id, code='$code', type=$type, active=$active)"
  74 +}
  75 +
  76 +/**
  77 + * The kind of place a [Location] represents.
  78 + *
  79 + * - **WAREHOUSE** — a building, the top of the storage hierarchy
  80 + * - **BIN** — a named slot inside a warehouse (shelf, rack, pallet)
  81 + * - **VIRTUAL** — non-physical (in transit, scrap, consigned, etc.)
  82 + *
  83 + * Stored as string in the DB so adding a value later is non-breaking
  84 + * for clients reading the column with raw SQL.
  85 + */
  86 +enum class LocationType {
  87 + WAREHOUSE,
  88 + BIN,
  89 + VIRTUAL,
  90 +}
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockBalance.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.Table
  6 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  7 +import java.math.BigDecimal
  8 +import java.util.UUID
  9 +
  10 +/**
  11 + * The on-hand quantity of a single item at a single location.
  12 + *
  13 + * One row per (item_code, location_id). The combination is enforced
  14 + * by a UNIQUE INDEX in the schema, NOT by a composite primary key:
  15 + * - The primary key stays the inherited UUID `id` so the row is
  16 + * addressable from REST and from future event payloads without
  17 + * introducing a "natural-key in URL" pattern that bites the moment
  18 + * a code is renamed.
  19 + * - The unique index gives us upsert semantics at the database
  20 + * level (`INSERT ... ON CONFLICT (item_code, location_id) DO UPDATE`)
  21 + * when the application service goes through JdbcTemplate; for the
  22 + * JPA path, the service does a SELECT-then-save which is correct
  23 + * under single-instance deployments (vibe_erp is single-tenant per
  24 + * process — see CLAUDE.md guardrail #5).
  25 + *
  26 + * **Why `item_code` is a varchar and not a UUID FK to catalog__item.id:**
  27 + * pbc-inventory has NO compile-time dependency on pbc-catalog
  28 + * (guardrail #9 — PBC boundaries are sacred). The Gradle build refuses
  29 + * to load if it tries. The cross-PBC link to "is this a real item?"
  30 + * goes through `org.vibeerp.api.v1.ext.catalog.CatalogApi` at runtime,
  31 + * looked up by code. Storing the code instead of the UUID makes the
  32 + * row legible without a join, makes seed data trivial to write, and
  33 + * decouples inventory from how catalog lays out its primary keys.
  34 + *
  35 + * **No StockMovement / append-only ledger in v1.** This is the
  36 + * deliberate cut for the first inventory chunk (P5.3): we store
  37 + * current balance without a history. A subsequent chunk introduces
  38 + * `inventory__stock_movement` as an append-only event log and rebuilds
  39 + * the balance as a materialised view. The current rows will then be
  40 + * regenerated from the ledger via a Liquibase backfill. Until then,
  41 + * the only audit trail for "who changed this balance and when" is the
  42 + * `updated_by` / `updated_at` audit columns inherited from
  43 + * [AuditedJpaEntity].
  44 + *
  45 + * **No negative balances enforced in v1.** The service refuses to
  46 + * SET a negative quantity, but does not enforce the typical "you
  47 + * cannot ISSUE more than is on hand" rule because there is no
  48 + * separate ISSUE operation yet — only the absolute SET. Issue/receipt
  49 + * deltas land with the StockMovement work above.
  50 + */
  51 +@Entity
  52 +@Table(name = "inventory__stock_balance")
  53 +class StockBalance(
  54 + itemCode: String,
  55 + locationId: UUID,
  56 + quantity: BigDecimal,
  57 +) : AuditedJpaEntity() {
  58 +
  59 + @Column(name = "item_code", nullable = false, length = 64)
  60 + var itemCode: String = itemCode
  61 +
  62 + @Column(name = "location_id", nullable = false)
  63 + var locationId: UUID = locationId
  64 +
  65 + @Column(name = "quantity", nullable = false, precision = 18, scale = 4)
  66 + var quantity: BigDecimal = quantity
  67 +
  68 + override fun toString(): String =
  69 + "StockBalance(id=$id, itemCode='$itemCode', locationId=$locationId, qty=$quantity)"
  70 +}
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.ext
  2 +
  3 +import org.springframework.stereotype.Component
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.api.v1.core.Id
  6 +import org.vibeerp.api.v1.ext.inventory.InventoryApi
  7 +import org.vibeerp.api.v1.ext.inventory.StockBalanceRef
  8 +import org.vibeerp.pbc.inventory.domain.StockBalance
  9 +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
  10 +import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository
  11 +import java.math.BigDecimal
  12 +
  13 +/**
  14 + * Concrete [InventoryApi] implementation. The ONLY thing other PBCs
  15 + * and plug-ins are allowed to inject for "what stock is on hand?".
  16 + *
  17 + * The fourth `*ApiAdapter` after IdentityApiAdapter, CatalogApiAdapter,
  18 + * and PartnersApiAdapter. By the time this lands the framework has a
  19 + * stable convention every future PBC clones: a runtime adapter that
  20 + * lives next to the PBC it serves, never returns its own JPA entity
  21 + * types, and never reaches across PBC boundaries.
  22 + *
  23 + * **What this adapter MUST NOT do:**
  24 + * - leak `org.vibeerp.pbc.inventory.domain.StockBalance` to callers
  25 + * - expose location records (just the code, when needed)
  26 + * - return rows where the underlying location is missing (defensive
  27 + * coding — the FK constraint should make this impossible, but the
  28 + * facade is the safety net)
  29 + *
  30 + * The location lookup happens in TWO places:
  31 + * 1. [findStockBalance] resolves the location code → id by reading
  32 + * `inventory__location.code = ?` once, then queries the balance
  33 + * by (item_code, location_id).
  34 + * 2. The result builder resolves the location id → code for the
  35 + * [StockBalanceRef] response. The double round-trip is acceptable
  36 + * for v1 because the call rate from cross-PBC consumers is low;
  37 + * a JOIN-based query lives in this file the moment a consumer
  38 + * starts hitting it on a hot path.
  39 + */
  40 +@Component
  41 +@Transactional(readOnly = true)
  42 +class InventoryApiAdapter(
  43 + private val balances: StockBalanceJpaRepository,
  44 + private val locations: LocationJpaRepository,
  45 +) : InventoryApi {
  46 +
  47 + override fun findStockBalance(itemCode: String, locationCode: String): StockBalanceRef? {
  48 + val location = locations.findByCode(locationCode) ?: return null
  49 + val balance = balances.findByItemCodeAndLocationId(itemCode, location.id) ?: return null
  50 + return balance.toRef(locationCode)
  51 + }
  52 +
  53 + override fun totalOnHand(itemCode: String): BigDecimal =
  54 + balances.findByItemCode(itemCode)
  55 + .fold(BigDecimal.ZERO) { acc, row -> acc + row.quantity }
  56 +
  57 + private fun StockBalance.toRef(locationCode: String): StockBalanceRef = StockBalanceRef(
  58 + id = Id<StockBalanceRef>(this.id),
  59 + itemCode = this.itemCode,
  60 + locationCode = locationCode,
  61 + quantity = this.quantity,
  62 + )
  63 +}
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/LocationController.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.http
  2 +
  3 +import jakarta.validation.Valid
  4 +import jakarta.validation.constraints.NotBlank
  5 +import jakarta.validation.constraints.Size
  6 +import org.springframework.http.HttpStatus
  7 +import org.springframework.http.ResponseEntity
  8 +import org.springframework.web.bind.annotation.DeleteMapping
  9 +import org.springframework.web.bind.annotation.GetMapping
  10 +import org.springframework.web.bind.annotation.PatchMapping
  11 +import org.springframework.web.bind.annotation.PathVariable
  12 +import org.springframework.web.bind.annotation.PostMapping
  13 +import org.springframework.web.bind.annotation.RequestBody
  14 +import org.springframework.web.bind.annotation.RequestMapping
  15 +import org.springframework.web.bind.annotation.ResponseStatus
  16 +import org.springframework.web.bind.annotation.RestController
  17 +import org.vibeerp.pbc.inventory.application.CreateLocationCommand
  18 +import org.vibeerp.pbc.inventory.application.LocationService
  19 +import org.vibeerp.pbc.inventory.application.UpdateLocationCommand
  20 +import org.vibeerp.pbc.inventory.domain.Location
  21 +import org.vibeerp.pbc.inventory.domain.LocationType
  22 +import java.util.UUID
  23 +
  24 +/**
  25 + * REST API for the inventory PBC's Location aggregate.
  26 + *
  27 + * Mounted at `/api/v1/inventory/locations`. Authenticated.
  28 + */
  29 +@RestController
  30 +@RequestMapping("/api/v1/inventory/locations")
  31 +class LocationController(
  32 + private val locationService: LocationService,
  33 +) {
  34 +
  35 + @GetMapping
  36 + fun list(): List<LocationResponse> =
  37 + locationService.list().map { it.toResponse(locationService) }
  38 +
  39 + @GetMapping("/{id}")
  40 + fun get(@PathVariable id: UUID): ResponseEntity<LocationResponse> {
  41 + val location = locationService.findById(id) ?: return ResponseEntity.notFound().build()
  42 + return ResponseEntity.ok(location.toResponse(locationService))
  43 + }
  44 +
  45 + @GetMapping("/by-code/{code}")
  46 + fun getByCode(@PathVariable code: String): ResponseEntity<LocationResponse> {
  47 + val location = locationService.findByCode(code) ?: return ResponseEntity.notFound().build()
  48 + return ResponseEntity.ok(location.toResponse(locationService))
  49 + }
  50 +
  51 + @PostMapping
  52 + @ResponseStatus(HttpStatus.CREATED)
  53 + fun create(@RequestBody @Valid request: CreateLocationRequest): LocationResponse =
  54 + locationService.create(
  55 + CreateLocationCommand(
  56 + code = request.code,
  57 + name = request.name,
  58 + type = request.type,
  59 + active = request.active ?: true,
  60 + ext = request.ext,
  61 + ),
  62 + ).toResponse(locationService)
  63 +
  64 + @PatchMapping("/{id}")
  65 + fun update(
  66 + @PathVariable id: UUID,
  67 + @RequestBody @Valid request: UpdateLocationRequest,
  68 + ): LocationResponse =
  69 + locationService.update(
  70 + id,
  71 + UpdateLocationCommand(
  72 + name = request.name,
  73 + type = request.type,
  74 + active = request.active,
  75 + ext = request.ext,
  76 + ),
  77 + ).toResponse(locationService)
  78 +
  79 + @DeleteMapping("/{id}")
  80 + @ResponseStatus(HttpStatus.NO_CONTENT)
  81 + fun deactivate(@PathVariable id: UUID) {
  82 + locationService.deactivate(id)
  83 + }
  84 +}
  85 +
  86 +// ─── DTOs ────────────────────────────────────────────────────────────
  87 +
  88 +data class CreateLocationRequest(
  89 + @field:NotBlank @field:Size(max = 64) val code: String,
  90 + @field:NotBlank @field:Size(max = 256) val name: String,
  91 + val type: LocationType,
  92 + val active: Boolean? = true,
  93 + val ext: Map<String, Any?>? = null,
  94 +)
  95 +
  96 +data class UpdateLocationRequest(
  97 + @field:Size(max = 256) val name: String? = null,
  98 + val type: LocationType? = null,
  99 + val active: Boolean? = null,
  100 + val ext: Map<String, Any?>? = null,
  101 +)
  102 +
  103 +data class LocationResponse(
  104 + val id: UUID,
  105 + val code: String,
  106 + val name: String,
  107 + val type: LocationType,
  108 + val active: Boolean,
  109 + val ext: Map<String, Any?>,
  110 +)
  111 +
  112 +private fun Location.toResponse(service: LocationService) = LocationResponse(
  113 + id = this.id,
  114 + code = this.code,
  115 + name = this.name,
  116 + type = this.type,
  117 + active = this.active,
  118 + ext = service.parseExt(this),
  119 +)
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockBalanceController.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.http
  2 +
  3 +import jakarta.validation.Valid
  4 +import jakarta.validation.constraints.NotBlank
  5 +import jakarta.validation.constraints.NotNull
  6 +import jakarta.validation.constraints.Size
  7 +import org.springframework.web.bind.annotation.GetMapping
  8 +import org.springframework.web.bind.annotation.PostMapping
  9 +import org.springframework.web.bind.annotation.RequestBody
  10 +import org.springframework.web.bind.annotation.RequestMapping
  11 +import org.springframework.web.bind.annotation.RequestParam
  12 +import org.springframework.web.bind.annotation.RestController
  13 +import org.vibeerp.pbc.inventory.application.StockBalanceService
  14 +import org.vibeerp.pbc.inventory.domain.StockBalance
  15 +import java.math.BigDecimal
  16 +import java.util.UUID
  17 +
  18 +/**
  19 + * REST API for stock balances.
  20 + *
  21 + * Mounted at `/api/v1/inventory/balances`. Authenticated.
  22 + *
  23 + * GET supports filtering by either `itemCode` or `locationId` (or
  24 + * neither, returning everything). The list endpoint exists for
  25 + * operator UIs and the future SPA; the cross-PBC consumers should
  26 + * use [org.vibeerp.api.v1.ext.inventory.InventoryApi] from api.v1
  27 + * instead of REST.
  28 + */
  29 +@RestController
  30 +@RequestMapping("/api/v1/inventory/balances")
  31 +class StockBalanceController(
  32 + private val stockBalanceService: StockBalanceService,
  33 +) {
  34 +
  35 + @GetMapping
  36 + fun list(
  37 + @RequestParam(required = false) itemCode: String?,
  38 + @RequestParam(required = false) locationId: UUID?,
  39 + ): List<StockBalanceResponse> {
  40 + val rows = when {
  41 + itemCode != null -> stockBalanceService.findByItemCode(itemCode)
  42 + locationId != null -> stockBalanceService.findByLocationId(locationId)
  43 + else -> stockBalanceService.list()
  44 + }
  45 + return rows.map { it.toResponse() }
  46 + }
  47 +
  48 + /**
  49 + * Set the on-hand quantity for a single (itemCode, locationId)
  50 + * cell to the requested value, creating the row if necessary.
  51 + * Returns the resulting balance.
  52 + */
  53 + @PostMapping("/adjust")
  54 + fun adjust(@RequestBody @Valid request: AdjustStockBalanceRequest): StockBalanceResponse =
  55 + stockBalanceService.adjust(
  56 + itemCode = request.itemCode,
  57 + locationId = request.locationId,
  58 + quantity = request.quantity,
  59 + ).toResponse()
  60 +}
  61 +
  62 +// ─── DTOs ────────────────────────────────────────────────────────────
  63 +
  64 +data class AdjustStockBalanceRequest(
  65 + @field:NotBlank @field:Size(max = 64) val itemCode: String,
  66 + @field:NotNull val locationId: UUID,
  67 + @field:NotNull val quantity: BigDecimal,
  68 +)
  69 +
  70 +data class StockBalanceResponse(
  71 + val id: UUID,
  72 + val itemCode: String,
  73 + val locationId: UUID,
  74 + val quantity: BigDecimal,
  75 +)
  76 +
  77 +private fun StockBalance.toResponse() = StockBalanceResponse(
  78 + id = this.id,
  79 + itemCode = this.itemCode,
  80 + locationId = this.locationId,
  81 + quantity = this.quantity,
  82 +)
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/LocationJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.inventory.domain.Location
  6 +import java.util.UUID
  7 +
  8 +/**
  9 + * Spring Data JPA repository for [Location].
  10 + *
  11 + * Like every other PBC's master-data repo, the predominant lookup is
  12 + * by `code` because that's what every external reference (sales
  13 + * orders, purchase receipts, work orders) carries.
  14 + */
  15 +@Repository
  16 +interface LocationJpaRepository : JpaRepository<Location, UUID> {
  17 +
  18 + fun findByCode(code: String): Location?
  19 +
  20 + fun existsByCode(code: String): Boolean
  21 +}
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/StockBalanceJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.inventory.domain.StockBalance
  6 +import java.util.UUID
  7 +
  8 +/**
  9 + * Spring Data JPA repository for [StockBalance].
  10 + *
  11 + * Lookups are by the (item_code, location_id) composite, and by
  12 + * item_code alone for "where is this item?". The DB-level UNIQUE
  13 + * INDEX on (item_code, location_id) means [findByItemCodeAndLocationId]
  14 + * is guaranteed to return at most one row.
  15 + */
  16 +@Repository
  17 +interface StockBalanceJpaRepository : JpaRepository<StockBalance, UUID> {
  18 +
  19 + fun findByItemCodeAndLocationId(itemCode: String, locationId: UUID): StockBalance?
  20 +
  21 + fun findByItemCode(itemCode: String): List<StockBalance>
  22 +
  23 + fun findByLocationId(locationId: UUID): List<StockBalance>
  24 +}
pbc/pbc-inventory/src/main/resources/META-INF/vibe-erp/metadata/inventory.yml 0 → 100644
  1 +# pbc-inventory metadata.
  2 +#
  3 +# Loaded at boot by MetadataLoader, tagged source='core'.
  4 +
  5 +entities:
  6 + - name: Location
  7 + pbc: inventory
  8 + table: inventory__location
  9 + description: A physical or logical place where stock can sit (warehouse, bin, virtual)
  10 +
  11 + - name: StockBalance
  12 + pbc: inventory
  13 + table: inventory__stock_balance
  14 + description: The on-hand quantity of one item at one location
  15 +
  16 +permissions:
  17 + - key: inventory.location.read
  18 + description: Read locations
  19 + - key: inventory.location.create
  20 + description: Create locations
  21 + - key: inventory.location.update
  22 + description: Update locations
  23 + - key: inventory.location.deactivate
  24 + description: Deactivate locations (preserved for historical references)
  25 +
  26 + - key: inventory.stock.read
  27 + description: Read stock balances
  28 + - key: inventory.stock.adjust
  29 + description: Set the on-hand quantity for an item × location
  30 +
  31 +menus:
  32 + - path: /inventory/locations
  33 + label: Locations
  34 + icon: warehouse
  35 + section: Inventory
  36 + order: 400
  37 + - path: /inventory/stock
  38 + label: Stock balances
  39 + icon: layers
  40 + section: Inventory
  41 + order: 410
pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/LocationServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.hasMessage
  6 +import assertk.assertions.isEqualTo
  7 +import assertk.assertions.isFalse
  8 +import assertk.assertions.isInstanceOf
  9 +import io.mockk.every
  10 +import io.mockk.mockk
  11 +import io.mockk.slot
  12 +import io.mockk.verify
  13 +import org.junit.jupiter.api.BeforeEach
  14 +import org.junit.jupiter.api.Test
  15 +import org.vibeerp.pbc.inventory.domain.Location
  16 +import org.vibeerp.pbc.inventory.domain.LocationType
  17 +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
  18 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
  19 +import java.util.Optional
  20 +import java.util.UUID
  21 +
  22 +class LocationServiceTest {
  23 +
  24 + private lateinit var locations: LocationJpaRepository
  25 + private lateinit var extValidator: ExtJsonValidator
  26 + private lateinit var service: LocationService
  27 +
  28 + @BeforeEach
  29 + fun setUp() {
  30 + locations = mockk()
  31 + extValidator = mockk()
  32 + every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() }
  33 + service = LocationService(locations, extValidator)
  34 + }
  35 +
  36 + @Test
  37 + fun `create rejects duplicate code`() {
  38 + every { locations.existsByCode("WH-MAIN") } returns true
  39 +
  40 + assertFailure {
  41 + service.create(
  42 + CreateLocationCommand(
  43 + code = "WH-MAIN",
  44 + name = "Main warehouse",
  45 + type = LocationType.WAREHOUSE,
  46 + ),
  47 + )
  48 + }
  49 + .isInstanceOf(IllegalArgumentException::class)
  50 + .hasMessage("location code 'WH-MAIN' is already taken")
  51 + }
  52 +
  53 + @Test
  54 + fun `create persists on the happy path`() {
  55 + val saved = slot<Location>()
  56 + every { locations.existsByCode("WH-NORTH") } returns false
  57 + every { locations.save(capture(saved)) } answers { saved.captured }
  58 +
  59 + val result = service.create(
  60 + CreateLocationCommand(
  61 + code = "WH-NORTH",
  62 + name = "North warehouse",
  63 + type = LocationType.WAREHOUSE,
  64 + ),
  65 + )
  66 +
  67 + assertThat(result.code).isEqualTo("WH-NORTH")
  68 + assertThat(result.name).isEqualTo("North warehouse")
  69 + assertThat(result.type).isEqualTo(LocationType.WAREHOUSE)
  70 + assertThat(result.active).isEqualTo(true)
  71 + verify(exactly = 1) { locations.save(any()) }
  72 + }
  73 +
  74 + @Test
  75 + fun `update never changes code`() {
  76 + val id = UUID.randomUUID()
  77 + val existing = Location(
  78 + code = "BIN-A1",
  79 + name = "Old name",
  80 + type = LocationType.BIN,
  81 + ).also { it.id = id }
  82 + every { locations.findById(id) } returns Optional.of(existing)
  83 +
  84 + val updated = service.update(
  85 + id,
  86 + UpdateLocationCommand(name = "New name", type = LocationType.VIRTUAL),
  87 + )
  88 +
  89 + assertThat(updated.name).isEqualTo("New name")
  90 + assertThat(updated.type).isEqualTo(LocationType.VIRTUAL)
  91 + assertThat(updated.code).isEqualTo("BIN-A1") // not touched
  92 + }
  93 +
  94 + @Test
  95 + fun `deactivate flips active without deleting`() {
  96 + val id = UUID.randomUUID()
  97 + val existing = Location(
  98 + code = "BIN-X",
  99 + name = "x",
  100 + type = LocationType.BIN,
  101 + active = true,
  102 + ).also { it.id = id }
  103 + every { locations.findById(id) } returns Optional.of(existing)
  104 +
  105 + service.deactivate(id)
  106 +
  107 + assertThat(existing.active).isFalse()
  108 + }
  109 +
  110 + @Test
  111 + fun `deactivate throws NoSuchElementException when missing`() {
  112 + val id = UUID.randomUUID()
  113 + every { locations.findById(id) } returns Optional.empty()
  114 +
  115 + assertFailure { service.deactivate(id) }
  116 + .isInstanceOf(NoSuchElementException::class)
  117 + }
  118 +}
pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.hasMessage
  6 +import assertk.assertions.isEqualTo
  7 +import assertk.assertions.isInstanceOf
  8 +import io.mockk.every
  9 +import io.mockk.mockk
  10 +import io.mockk.slot
  11 +import io.mockk.verify
  12 +import org.junit.jupiter.api.BeforeEach
  13 +import org.junit.jupiter.api.Test
  14 +import org.vibeerp.api.v1.core.Id
  15 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  16 +import org.vibeerp.api.v1.ext.catalog.ItemRef
  17 +import org.vibeerp.pbc.inventory.domain.StockBalance
  18 +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
  19 +import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository
  20 +import java.math.BigDecimal
  21 +import java.util.UUID
  22 +
  23 +class StockBalanceServiceTest {
  24 +
  25 + private lateinit var balances: StockBalanceJpaRepository
  26 + private lateinit var locations: LocationJpaRepository
  27 + private lateinit var catalogApi: CatalogApi
  28 + private lateinit var service: StockBalanceService
  29 +
  30 + @BeforeEach
  31 + fun setUp() {
  32 + balances = mockk()
  33 + locations = mockk()
  34 + catalogApi = mockk()
  35 + service = StockBalanceService(balances, locations, catalogApi)
  36 + }
  37 +
  38 + private fun stubItem(code: String) {
  39 + every { catalogApi.findItemByCode(code) } returns ItemRef(
  40 + id = Id(UUID.randomUUID()),
  41 + code = code,
  42 + name = "stub item",
  43 + itemType = "GOOD",
  44 + baseUomCode = "ea",
  45 + active = true,
  46 + )
  47 + }
  48 +
  49 + @Test
  50 + fun `adjust rejects unknown item code via CatalogApi seam`() {
  51 + every { catalogApi.findItemByCode("FAKE") } returns null
  52 +
  53 + assertFailure {
  54 + service.adjust("FAKE", UUID.randomUUID(), BigDecimal("10"))
  55 + }
  56 + .isInstanceOf(IllegalArgumentException::class)
  57 + .hasMessage("item code 'FAKE' is not in the catalog (or is inactive)")
  58 + }
  59 +
  60 + @Test
  61 + fun `adjust rejects unknown location id`() {
  62 + stubItem("SKU-1")
  63 + val locId = UUID.randomUUID()
  64 + every { locations.existsById(locId) } returns false
  65 +
  66 + assertFailure {
  67 + service.adjust("SKU-1", locId, BigDecimal("10"))
  68 + }
  69 + .isInstanceOf(IllegalArgumentException::class)
  70 + .hasMessage("location not found: $locId")
  71 + }
  72 +
  73 + @Test
  74 + fun `adjust rejects negative quantity before catalog lookup`() {
  75 + // Negative quantity is rejected by an early require() so the
  76 + // CatalogApi mock is never invoked.
  77 + assertFailure {
  78 + service.adjust("SKU-1", UUID.randomUUID(), BigDecimal("-1"))
  79 + }
  80 + .isInstanceOf(IllegalArgumentException::class)
  81 + verify(exactly = 0) { catalogApi.findItemByCode(any()) }
  82 + }
  83 +
  84 + @Test
  85 + fun `adjust creates a new balance row when none exists`() {
  86 + stubItem("SKU-1")
  87 + val locId = UUID.randomUUID()
  88 + val saved = slot<StockBalance>()
  89 + every { locations.existsById(locId) } returns true
  90 + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns null
  91 + every { balances.save(capture(saved)) } answers { saved.captured }
  92 +
  93 + val result = service.adjust("SKU-1", locId, BigDecimal("42.5"))
  94 +
  95 + assertThat(result.itemCode).isEqualTo("SKU-1")
  96 + assertThat(result.locationId).isEqualTo(locId)
  97 + assertThat(result.quantity).isEqualTo(BigDecimal("42.5"))
  98 + verify(exactly = 1) { balances.save(any()) }
  99 + }
  100 +
  101 + @Test
  102 + fun `adjust mutates the existing balance row when one already exists`() {
  103 + stubItem("SKU-1")
  104 + val locId = UUID.randomUUID()
  105 + val existing = StockBalance("SKU-1", locId, BigDecimal("5")).also { it.id = UUID.randomUUID() }
  106 + every { locations.existsById(locId) } returns true
  107 + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns existing
  108 +
  109 + val result = service.adjust("SKU-1", locId, BigDecimal("99"))
  110 +
  111 + assertThat(result).isEqualTo(existing)
  112 + assertThat(existing.quantity).isEqualTo(BigDecimal("99"))
  113 + // Crucially, save() is NOT called — the @Transactional method
  114 + // commit will flush the JPA-managed entity automatically.
  115 + verify(exactly = 0) { balances.save(any()) }
  116 + }
  117 +}
settings.gradle.kts
@@ -52,6 +52,9 @@ project(&quot;:pbc:pbc-catalog&quot;).projectDir = file(&quot;pbc/pbc-catalog&quot;) @@ -52,6 +52,9 @@ project(&quot;:pbc:pbc-catalog&quot;).projectDir = file(&quot;pbc/pbc-catalog&quot;)
52 include(":pbc:pbc-partners") 52 include(":pbc:pbc-partners")
53 project(":pbc:pbc-partners").projectDir = file("pbc/pbc-partners") 53 project(":pbc:pbc-partners").projectDir = file("pbc/pbc-partners")
54 54
  55 +include(":pbc:pbc-inventory")
  56 +project(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory")
  57 +
55 // ─── Reference customer plug-in (NOT loaded by default) ───────────── 58 // ─── Reference customer plug-in (NOT loaded by default) ─────────────
56 include(":reference-customer:plugin-printing-shop") 59 include(":reference-customer:plugin-printing-shop")
57 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop") 60 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop")