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.