Commit f63a73d5cc113d8d5c5752d3d27aea561126285b
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.
Showing
21 changed files
with
1216 additions
and
20 deletions
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 >= 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(":pbc:pbc-catalog").projectDir = file("pbc/pbc-catalog") | @@ -52,6 +52,9 @@ project(":pbc:pbc-catalog").projectDir = file("pbc/pbc-catalog") | ||
| 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") |