From f63a73d5cc113d8d5c5752d3d27aea561126285b Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 8 Apr 2026 14:51:27 +0800 Subject: [PATCH] feat(pbc): P5.3 — pbc-inventory + first cross-PBC facade caller --- CLAUDE.md | 6 +++--- PROGRESS.md | 29 ++++++++++++++++------------- README.md | 8 ++++---- api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ distribution/build.gradle.kts | 1 + distribution/src/main/resources/db/changelog/master.xml | 1 + distribution/src/main/resources/db/changelog/pbc-inventory/001-inventory-init.xml | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/build.gradle.kts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/LocationService.kt | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceService.kt | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/Location.kt | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockBalance.kt | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/LocationController.kt | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockBalanceController.kt | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/LocationJpaRepository.kt | 21 +++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/StockBalanceJpaRepository.kt | 24 ++++++++++++++++++++++++ pbc/pbc-inventory/src/main/resources/META-INF/vibe-erp/metadata/inventory.yml | 41 +++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/LocationServiceTest.kt | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceServiceTest.kt | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ settings.gradle.kts | 3 +++ 21 files changed, 1216 insertions(+), 20 deletions(-) create mode 100644 api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt create mode 100644 distribution/src/main/resources/db/changelog/pbc-inventory/001-inventory-init.xml create mode 100644 pbc/pbc-inventory/build.gradle.kts create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/LocationService.kt create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceService.kt create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/Location.kt create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockBalance.kt create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/LocationController.kt create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockBalanceController.kt create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/LocationJpaRepository.kt create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/StockBalanceJpaRepository.kt create mode 100644 pbc/pbc-inventory/src/main/resources/META-INF/vibe-erp/metadata/inventory.yml create mode 100644 pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/LocationServiceTest.kt create mode 100644 pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceServiceTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 03c8c85..114e055 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only **Foundation complete; first business surface in place.** As of the latest commit: -- **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`. -- **129 unit tests across 13 modules**, all green. `./gradlew build` is the canonical full build. +- **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`. +- **139 unit tests across 14 modules**, all green. `./gradlew build` is the canonical full build. - **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. -- **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). +- **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. - **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. - **Package root** is `org.vibeerp`. - **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. diff --git a/PROGRESS.md b/PROGRESS.md index b21c91f..bc3584f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,27 +10,27 @@ | | | |---|---| -| **Latest version** | v0.9 (post-P3.4) | -| **Latest commit** | `5bffbc4 feat(metadata): P3.4 — custom field application (Tier 1 customization)` | +| **Latest version** | v0.10 (post-P5.3) | +| **Latest commit** | `feat(pbc): P5.3 — pbc-inventory + first cross-PBC facade caller` | | **Repo** | https://github.com/reporkey/vibe-erp | -| **Modules** | 13 | -| **Unit tests** | 129, all green | -| **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 | -| **Real PBCs implemented** | 3 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`) | +| **Modules** | 14 | +| **Unit tests** | 139, all green | +| **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 | +| **Real PBCs implemented** | 4 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`) | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | ## Current stage -**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. +**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. -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. +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. ## Total scope (the v1.0 cut line) 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. -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. +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. ### Phase 1 — Platform completion (foundation) @@ -80,7 +80,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **18 a |---|---|---| | P5.1 | `pbc-catalog` — items, units of measure | ✅ DONE — `69f3daa` | | P5.2 | `pbc-partners` — customers, suppliers, contacts, addresses | ✅ DONE — `3e40cae` | -| P5.3 | `pbc-inventory` — stock items, lots, locations, movements | 🔜 Pending | +| P5.3 | `pbc-inventory` — locations + stock balances (movements deferred) | ✅ DONE — `` | | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending | | P5.5 | `pbc-orders-sales` — quotes, sales orders, deliveries | 🔜 Pending | | 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 | **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. | | **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_.properties` resolves before the host's `messages_.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. | | **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. | -| **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//` → cross-PBC facade in `api.v1.ext.` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. | +| **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//` → cross-PBC facade in `api.v1.ext.` → 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. | ## What the reference plug-in proves end-to-end @@ -170,7 +170,8 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, - **Job scheduler.** No Quartz. Periodic jobs don't have a home. - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2. - **Permission checks.** `Principal` is bound; `metadata__permission` is seeded; the actual `@RequirePermission` enforcement layer is P4.3. -- **More PBCs.** Identity, catalog and partners exist. Inventory, warehousing, orders, production, quality, finance are all pending. +- **More PBCs.** Identity, catalog, partners and inventory exist. Warehousing, orders, production, quality, finance are all pending. +- **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. - **Web SPA.** No React app. The framework is API-only today. - **MCP server.** The architecture leaves room for it; the implementation is v1.1. - **Mobile.** v2. @@ -214,6 +215,8 @@ platform/platform-plugins PF4J host, linter, Liquibase runner, endpoint disp pbc/pbc-identity User entity end-to-end + auth + bootstrap admin pbc/pbc-catalog Item + Uom entities + cross-PBC CatalogApi facade pbc/pbc-partners Partner + Address + Contact entities + cross-PBC PartnersApi facade +pbc/pbc-inventory Location + StockBalance + cross-PBC InventoryApi facade + (FIRST PBC to also CONSUME another PBC's facade — CatalogApi) reference-customer/plugin-printing-shop Reference plug-in: own DB schema (plate, ink_recipe), @@ -222,7 +225,7 @@ reference-customer/plugin-printing-shop distribution Bootable Spring Boot fat-jar assembly ``` -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. +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. ## Where to look next diff --git a/README.md b/README.md index 0ab78a6..952935a 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ vibe-erp/ ## Building ```bash -# Build everything (compiles 13 modules, runs 129 unit tests) +# Build everything (compiles 14 modules, runs 139 unit tests) ./gradlew build # 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 | | | |---|---| -| Modules | 13 | -| Unit tests | 129, all green | -| Real PBCs | 3 of 10 | +| Modules | 14 | +| Unit tests | 139, all green | +| Real PBCs | 4 of 10 | | Cross-cutting services live | 8 | | Plug-ins serving HTTP | 1 (reference printing-shop) | | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt new file mode 100644 index 0000000..a38f08d --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt @@ -0,0 +1,78 @@ +package org.vibeerp.api.v1.ext.inventory + +import org.vibeerp.api.v1.core.Id +import java.math.BigDecimal + +/** + * Cross-PBC facade for the inventory bounded context. + * + * The fourth `api.v1.ext.*` package after `ext.identity`, `ext.catalog`, + * and `ext.partners`. By the time pbc-inventory ships, the framework + * has proven the cross-PBC contract works in BOTH directions: every + * earlier PBC was a *provider* (other code injects its `*Api` to + * read), and pbc-inventory is the first PBC that is also a *consumer* + * — it injects [org.vibeerp.api.v1.ext.catalog.CatalogApi] to validate + * an item code before adjusting stock. + * + * This interface is the seam for the NEXT generation of consumers: + * pbc-orders-sales will inject [InventoryApi] to check stock before + * accepting an order, pbc-orders-purchase to track committed receipts, + * and the printing-shop reference plug-in to demo "do we have enough + * paper for this job?". None of those callers will import a + * pbc-inventory type — they will see only [StockBalanceRef]. + * + * **Lookups are by codes**, not by ids. Codes are stable and + * human-readable; the inventory PBC's UUIDs are an implementation + * detail. The same rule every other `ext.*` facade follows. + * + * **What this facade does NOT expose** (deliberately): + * - The list of all balances. The facade is for "is this item at + * this location?", not for browsing inventory. Operators that + * want to browse use the REST API with their token. + * - Adjust / set / movement operations. Other PBCs SHOULD NOT + * mutate inventory directly — they emit a `StockReservation` or + * `OrderShipped` domain event and let pbc-inventory react. This + * keeps the inventory invariants in one place. + * - The location's full record. Just the code is exposed when + * needed; callers that need the location's name or type can ask + * inventory's REST API directly with their token. + */ +interface InventoryApi { + + /** + * Look up the on-hand stock balance for [itemCode] at + * [locationCode]. Returns `null` when no balance row exists for + * that combination — the framework treats "no row" as "no stock", + * NOT as "the item doesn't exist", because the absence might just + * mean nothing has ever been received into this location. + * + * Callers that need to distinguish "the item doesn't exist" from + * "the item exists but has no stock here" should call + * [org.vibeerp.api.v1.ext.catalog.CatalogApi.findItemByCode] first. + */ + fun findStockBalance(itemCode: String, locationCode: String): StockBalanceRef? + + /** + * Sum every balance row across every location for [itemCode]. + * Returns `BigDecimal.ZERO` when no rows exist (NOT `null`) — the + * facade treats "no stock anywhere" as a legitimate non-error + * state because that is most items most of the time. + */ + fun totalOnHand(itemCode: String): BigDecimal +} + +/** + * Minimal, safe-to-publish view of a stock balance row. + * + * Notably absent: the underlying `version` column (optimistic locking + * is an internal concern), audit columns (consumers don't need them), + * the `ext` JSONB (custom-field reads cross the metadata system, not + * the cross-PBC facade). Anything not here cannot be observed + * cross-PBC, by design. + */ +data class StockBalanceRef( + val id: Id, + val itemCode: String, + val locationCode: String, + val quantity: BigDecimal, +) diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts index 4b19029..7d31b7c 100644 --- a/distribution/build.gradle.kts +++ b/distribution/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(project(":pbc:pbc-identity")) implementation(project(":pbc:pbc-catalog")) implementation(project(":pbc:pbc-partners")) + implementation(project(":pbc:pbc-inventory")) implementation(libs.spring.boot.starter) implementation(libs.spring.boot.starter.web) diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml index 855b14b..2c99f3e 100644 --- a/distribution/src/main/resources/db/changelog/master.xml +++ b/distribution/src/main/resources/db/changelog/master.xml @@ -16,4 +16,5 @@ + diff --git a/distribution/src/main/resources/db/changelog/pbc-inventory/001-inventory-init.xml b/distribution/src/main/resources/db/changelog/pbc-inventory/001-inventory-init.xml new file mode 100644 index 0000000..bc318cb --- /dev/null +++ b/distribution/src/main/resources/db/changelog/pbc-inventory/001-inventory-init.xml @@ -0,0 +1,88 @@ + + + + + + + Create inventory__location table + + CREATE TABLE inventory__location ( + id uuid PRIMARY KEY, + code varchar(64) NOT NULL, + name varchar(256) NOT NULL, + type varchar(16) NOT NULL, + active boolean NOT NULL DEFAULT true, + ext jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL, + created_by varchar(128) NOT NULL, + updated_at timestamptz NOT NULL, + updated_by varchar(128) NOT NULL, + version bigint NOT NULL DEFAULT 0 + ); + CREATE UNIQUE INDEX inventory__location_code_uk ON inventory__location (code); + CREATE INDEX inventory__location_type_idx ON inventory__location (type); + CREATE INDEX inventory__location_active_idx ON inventory__location (active); + CREATE INDEX inventory__location_ext_gin ON inventory__location USING GIN (ext jsonb_path_ops); + + + DROP TABLE inventory__location; + + + + + Create inventory__stock_balance table (FK to inventory__location, no FK to catalog__item) + + CREATE TABLE inventory__stock_balance ( + id uuid PRIMARY KEY, + item_code varchar(64) NOT NULL, + location_id uuid NOT NULL REFERENCES inventory__location(id), + quantity numeric(18,4) NOT NULL, + created_at timestamptz NOT NULL, + created_by varchar(128) NOT NULL, + updated_at timestamptz NOT NULL, + updated_by varchar(128) NOT NULL, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT inventory__stock_balance_nonneg CHECK (quantity >= 0) + ); + CREATE UNIQUE INDEX inventory__stock_balance_item_loc_uk + ON inventory__stock_balance (item_code, location_id); + CREATE INDEX inventory__stock_balance_item_idx ON inventory__stock_balance (item_code); + CREATE INDEX inventory__stock_balance_loc_idx ON inventory__stock_balance (location_id); + + + DROP TABLE inventory__stock_balance; + + + + diff --git a/pbc/pbc-inventory/build.gradle.kts b/pbc/pbc-inventory/build.gradle.kts new file mode 100644 index 0000000..96e3892 --- /dev/null +++ b/pbc/pbc-inventory/build.gradle.kts @@ -0,0 +1,64 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.kotlin.jpa) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp pbc-inventory — locations and stock balances. INTERNAL Packaged Business Capability." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +allOpen { + annotation("jakarta.persistence.Entity") + annotation("jakarta.persistence.MappedSuperclass") + annotation("jakarta.persistence.Embeddable") +} + +// CRITICAL: pbc-inventory may depend on api-v1 (which exposes the +// cross-PBC CatalogApi interface), platform-persistence, +// platform-security, and platform-metadata — but NEVER on +// platform-bootstrap, NEVER on another pbc-* (NEVER on pbc-catalog +// even though we INJECT CatalogApi at runtime). The catalog +// implementation lives in pbc-catalog and is wired into the Spring +// context at runtime by the bootstrap @ComponentScan; this PBC sees +// only the interface, exactly as guardrail #9 demands. +// +// The root build.gradle.kts enforces this; if you add a `:pbc:pbc-foo` +// or `:platform:platform-bootstrap` dependency below, the build will +// refuse to load with an architectural-violation error. +dependencies { + api(project(":api:api-v1")) + implementation(project(":platform:platform-persistence")) + implementation(project(":platform:platform-security")) + implementation(project(":platform:platform-metadata")) // ExtJsonValidator (P3.4) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + + implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.data.jpa) + implementation(libs.spring.boot.starter.validation) + implementation(libs.jackson.module.kotlin) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) + testImplementation(libs.mockk) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/LocationService.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/LocationService.kt new file mode 100644 index 0000000..0276b09 --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/LocationService.kt @@ -0,0 +1,110 @@ +package org.vibeerp.pbc.inventory.application + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.pbc.inventory.domain.Location +import org.vibeerp.pbc.inventory.domain.LocationType +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator +import java.util.UUID + +/** + * Application service for location CRUD. + * + * Same shape as the catalog and partner services: thin wrapper around + * the JPA repository, code uniqueness checked in code (not just in + * the unique index, so the error message is meaningful), `ext` JSONB + * goes through the [ExtJsonValidator] before save. + * + * **Code is not updatable** — every cross-PBC reference uses the code, + * so renaming is a data-migration concern, not a routine API call. + * + * **Deactivate, never delete** — the same rule every other vibe_erp + * master-data PBC follows. Historical stock balances and (eventually) + * stock movements still resolve their location_id references. + */ +@Service +@Transactional +class LocationService( + private val locations: LocationJpaRepository, + private val extValidator: ExtJsonValidator, +) { + + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() + + @Transactional(readOnly = true) + fun list(): List = locations.findAll() + + @Transactional(readOnly = true) + fun findById(id: UUID): Location? = locations.findById(id).orElse(null) + + @Transactional(readOnly = true) + fun findByCode(code: String): Location? = locations.findByCode(code) + + fun create(command: CreateLocationCommand): Location { + require(!locations.existsByCode(command.code)) { + "location code '${command.code}' is already taken" + } + val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) + return locations.save( + Location( + code = command.code, + name = command.name, + type = command.type, + active = command.active, + ).also { + it.ext = jsonMapper.writeValueAsString(canonicalExt) + }, + ) + } + + fun update(id: UUID, command: UpdateLocationCommand): Location { + val location = locations.findById(id).orElseThrow { + NoSuchElementException("location not found: $id") + } + command.name?.let { location.name = it } + command.type?.let { location.type = it } + command.active?.let { location.active = it } + if (command.ext != null) { + val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) + location.ext = jsonMapper.writeValueAsString(canonicalExt) + } + return location + } + + fun deactivate(id: UUID) { + val location = locations.findById(id).orElseThrow { + NoSuchElementException("location not found: $id") + } + location.active = false + } + + @Suppress("UNCHECKED_CAST") + fun parseExt(location: Location): Map = try { + if (location.ext.isBlank()) emptyMap() + else jsonMapper.readValue(location.ext, Map::class.java) as Map + } catch (ex: Throwable) { + emptyMap() + } + + companion object { + const val ENTITY_NAME: String = "Location" + } +} + +data class CreateLocationCommand( + val code: String, + val name: String, + val type: LocationType, + val active: Boolean = true, + val ext: Map? = null, +) + +data class UpdateLocationCommand( + val name: String? = null, + val type: LocationType? = null, + val active: Boolean? = null, + val ext: Map? = null, +) diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceService.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceService.kt new file mode 100644 index 0000000..be9d9c4 --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceService.kt @@ -0,0 +1,103 @@ +package org.vibeerp.pbc.inventory.application + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.api.v1.ext.catalog.CatalogApi +import org.vibeerp.pbc.inventory.domain.StockBalance +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository +import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository +import java.math.BigDecimal +import java.util.UUID + +/** + * Application service for stock balance read + adjust. + * + * **The first cross-PBC facade caller in the codebase.** Until P5.3, + * every PBC was a *provider* of an `api.v1.ext.` interface + * (identity, catalog, partners). pbc-inventory is the first + * *consumer*: it injects [CatalogApi] to validate that the item code + * an adjust request quotes actually exists in the catalog and is + * active. The Spring DI container resolves the interface to the + * concrete `CatalogApiAdapter` bean from pbc-catalog at runtime — + * pbc-inventory has no compile-time dependency on pbc-catalog (the + * Gradle build refuses such a dependency, see CLAUDE.md guardrail #9). + * + * This is the proof that the modular-monolith story works in both + * directions: a new PBC can declare a cross-cutting need against any + * other PBC's `ext.*` facade without ever importing its types. + * + * **What `adjust` does** (v1): + * 1. Look up the item by code via [CatalogApi.findItemByCode]. + * Inactive or unknown items are rejected with a meaningful 400. + * 2. Look up the location by id; unknown locations are rejected. + * 3. Find the existing balance row for (item_code, location_id) or + * create one with quantity = 0. + * 4. Set the quantity to the requested value (NOT add — see below). + * + * **Why SET, not ADD, in v1.** The "delta" semantics (receipt = +X, + * issue = -X) requires a movement ledger to be auditable, and the + * ledger is deferred to a follow-up chunk. The current operation is + * deliberately the dumber one — "the balance is now N" — so the v1 + * cut is honest about not having the audit trail to back delta math. + * Once the ledger lands, this method's signature stays the same; the + * implementation grows a movement insert and the ledger view-rebuild + * runs in the same transaction. + * + * Negative quantities are rejected up front. Stock cannot go below + * zero, and the framework refuses to record it. + */ +@Service +@Transactional +class StockBalanceService( + private val balances: StockBalanceJpaRepository, + private val locations: LocationJpaRepository, + private val catalogApi: CatalogApi, +) { + + @Transactional(readOnly = true) + fun list(): List = balances.findAll() + + @Transactional(readOnly = true) + fun findByItemCode(itemCode: String): List = + balances.findByItemCode(itemCode) + + @Transactional(readOnly = true) + fun findByLocationId(locationId: UUID): List = + balances.findByLocationId(locationId) + + /** + * Set the on-hand quantity for [itemCode] at [locationId] to + * [quantity], creating the balance row if it does not yet exist. + */ + fun adjust(itemCode: String, locationId: UUID, quantity: BigDecimal): StockBalance { + require(quantity.signum() >= 0) { + "stock quantity must be non-negative (got $quantity)" + } + + // Cross-PBC validation #1: the item must exist in the catalog + // AND be active. CatalogApi returns null for inactive items — + // see the rationale on CatalogApi.findItemByCode. + catalogApi.findItemByCode(itemCode) + ?: throw IllegalArgumentException( + "item code '$itemCode' is not in the catalog (or is inactive)", + ) + + // Local validation #2: the location must exist. We don't go + // through a facade because the location lives in this PBC. + require(locations.existsById(locationId)) { + "location not found: $locationId" + } + + // Upsert the balance row. Single-instance deployments mean the + // SELECT-then-save race window is closed for practical purposes + // — vibe_erp is single-tenant per process and the @Transactional + // boundary makes the read-modify-write atomic at the DB level. + val existing = balances.findByItemCodeAndLocationId(itemCode, locationId) + return if (existing == null) { + balances.save(StockBalance(itemCode, locationId, quantity)) + } else { + existing.quantity = quantity + existing + } + } +} diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/Location.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/Location.kt new file mode 100644 index 0000000..bbed567 --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/Location.kt @@ -0,0 +1,90 @@ +package org.vibeerp.pbc.inventory.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Table +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity + +/** + * A physical or logical place where stock can sit. + * + * The pbc-inventory PBC's foundation entity. Stock balances reference + * an item × location pair, so before any stock can be tracked the + * location it sits in must exist. The model deliberately conflates + * "warehouse the company owns" and "bin within a warehouse" into one + * table with a [LocationType] discriminator: real warehouse layouts + * are recursive (warehouse → aisle → rack → bin) and modelling each + * level as its own table forces a join cascade for every stock query. + * One table with a self-reference would be even better, but YAGNI for + * v1 — most printing shops have one warehouse and a handful of named + * bins, and the discriminator handles that case cleanly without a + * recursive query. + * + * **Why `code` is the natural key** (with a UUID primary key): + * Same rationale as Item and Partner — codes are stable, human-readable, + * and survive database re-imports. Sales orders, purchase receipts, and + * production work orders quote a `WH-MAIN` location code, not a UUID. + * + * **Why VIRTUAL is in the type set:** non-physical locations like + * "in transit", "scrap", or "consigned" are real places stock can + * legitimately be. They behave like any other location in the + * arithmetic — the only difference is they have no shelf in the real + * world. Treating them as their own type lets the future warehouse- + * management UI hide them from pickers without changing the data + * model. + * + * The `ext` JSONB column is for Tier 1 customisation (P3.4) — a key + * user can add a `temperature_range` or `bonded_warehouse_licence_id` + * field via the metadata layer without patching this PBC. The + * framework's `ExtJsonValidator` enforces declared types on every + * save. + */ +@Entity +@Table(name = "inventory__location") +class Location( + code: String, + name: String, + type: LocationType, + active: Boolean = true, +) : AuditedJpaEntity() { + + @Column(name = "code", nullable = false, length = 64) + var code: String = code + + @Column(name = "name", nullable = false, length = 256) + var name: String = name + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 16) + var type: LocationType = type + + @Column(name = "active", nullable = false) + var active: Boolean = active + + @Column(name = "ext", nullable = false, columnDefinition = "jsonb") + @JdbcTypeCode(SqlTypes.JSON) + var ext: String = "{}" + + override fun toString(): String = + "Location(id=$id, code='$code', type=$type, active=$active)" +} + +/** + * The kind of place a [Location] represents. + * + * - **WAREHOUSE** — a building, the top of the storage hierarchy + * - **BIN** — a named slot inside a warehouse (shelf, rack, pallet) + * - **VIRTUAL** — non-physical (in transit, scrap, consigned, etc.) + * + * Stored as string in the DB so adding a value later is non-breaking + * for clients reading the column with raw SQL. + */ +enum class LocationType { + WAREHOUSE, + BIN, + VIRTUAL, +} diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockBalance.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockBalance.kt new file mode 100644 index 0000000..be323d5 --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockBalance.kt @@ -0,0 +1,70 @@ +package org.vibeerp.pbc.inventory.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity +import java.math.BigDecimal +import java.util.UUID + +/** + * The on-hand quantity of a single item at a single location. + * + * One row per (item_code, location_id). The combination is enforced + * by a UNIQUE INDEX in the schema, NOT by a composite primary key: + * - The primary key stays the inherited UUID `id` so the row is + * addressable from REST and from future event payloads without + * introducing a "natural-key in URL" pattern that bites the moment + * a code is renamed. + * - The unique index gives us upsert semantics at the database + * level (`INSERT ... ON CONFLICT (item_code, location_id) DO UPDATE`) + * when the application service goes through JdbcTemplate; for the + * JPA path, the service does a SELECT-then-save which is correct + * under single-instance deployments (vibe_erp is single-tenant per + * process — see CLAUDE.md guardrail #5). + * + * **Why `item_code` is a varchar and not a UUID FK to catalog__item.id:** + * pbc-inventory has NO compile-time dependency on pbc-catalog + * (guardrail #9 — PBC boundaries are sacred). The Gradle build refuses + * to load if it tries. The cross-PBC link to "is this a real item?" + * goes through `org.vibeerp.api.v1.ext.catalog.CatalogApi` at runtime, + * looked up by code. Storing the code instead of the UUID makes the + * row legible without a join, makes seed data trivial to write, and + * decouples inventory from how catalog lays out its primary keys. + * + * **No StockMovement / append-only ledger in v1.** This is the + * deliberate cut for the first inventory chunk (P5.3): we store + * current balance without a history. A subsequent chunk introduces + * `inventory__stock_movement` as an append-only event log and rebuilds + * the balance as a materialised view. The current rows will then be + * regenerated from the ledger via a Liquibase backfill. Until then, + * the only audit trail for "who changed this balance and when" is the + * `updated_by` / `updated_at` audit columns inherited from + * [AuditedJpaEntity]. + * + * **No negative balances enforced in v1.** The service refuses to + * SET a negative quantity, but does not enforce the typical "you + * cannot ISSUE more than is on hand" rule because there is no + * separate ISSUE operation yet — only the absolute SET. Issue/receipt + * deltas land with the StockMovement work above. + */ +@Entity +@Table(name = "inventory__stock_balance") +class StockBalance( + itemCode: String, + locationId: UUID, + quantity: BigDecimal, +) : AuditedJpaEntity() { + + @Column(name = "item_code", nullable = false, length = 64) + var itemCode: String = itemCode + + @Column(name = "location_id", nullable = false) + var locationId: UUID = locationId + + @Column(name = "quantity", nullable = false, precision = 18, scale = 4) + var quantity: BigDecimal = quantity + + override fun toString(): String = + "StockBalance(id=$id, itemCode='$itemCode', locationId=$locationId, qty=$quantity)" +} diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt new file mode 100644 index 0000000..448a277 --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt @@ -0,0 +1,63 @@ +package org.vibeerp.pbc.inventory.ext + +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.ext.inventory.InventoryApi +import org.vibeerp.api.v1.ext.inventory.StockBalanceRef +import org.vibeerp.pbc.inventory.domain.StockBalance +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository +import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository +import java.math.BigDecimal + +/** + * Concrete [InventoryApi] implementation. The ONLY thing other PBCs + * and plug-ins are allowed to inject for "what stock is on hand?". + * + * The fourth `*ApiAdapter` after IdentityApiAdapter, CatalogApiAdapter, + * and PartnersApiAdapter. By the time this lands the framework has a + * stable convention every future PBC clones: a runtime adapter that + * lives next to the PBC it serves, never returns its own JPA entity + * types, and never reaches across PBC boundaries. + * + * **What this adapter MUST NOT do:** + * - leak `org.vibeerp.pbc.inventory.domain.StockBalance` to callers + * - expose location records (just the code, when needed) + * - return rows where the underlying location is missing (defensive + * coding — the FK constraint should make this impossible, but the + * facade is the safety net) + * + * The location lookup happens in TWO places: + * 1. [findStockBalance] resolves the location code → id by reading + * `inventory__location.code = ?` once, then queries the balance + * by (item_code, location_id). + * 2. The result builder resolves the location id → code for the + * [StockBalanceRef] response. The double round-trip is acceptable + * for v1 because the call rate from cross-PBC consumers is low; + * a JOIN-based query lives in this file the moment a consumer + * starts hitting it on a hot path. + */ +@Component +@Transactional(readOnly = true) +class InventoryApiAdapter( + private val balances: StockBalanceJpaRepository, + private val locations: LocationJpaRepository, +) : InventoryApi { + + override fun findStockBalance(itemCode: String, locationCode: String): StockBalanceRef? { + val location = locations.findByCode(locationCode) ?: return null + val balance = balances.findByItemCodeAndLocationId(itemCode, location.id) ?: return null + return balance.toRef(locationCode) + } + + override fun totalOnHand(itemCode: String): BigDecimal = + balances.findByItemCode(itemCode) + .fold(BigDecimal.ZERO) { acc, row -> acc + row.quantity } + + private fun StockBalance.toRef(locationCode: String): StockBalanceRef = StockBalanceRef( + id = Id(this.id), + itemCode = this.itemCode, + locationCode = locationCode, + quantity = this.quantity, + ) +} diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/LocationController.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/LocationController.kt new file mode 100644 index 0000000..940f29a --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/LocationController.kt @@ -0,0 +1,119 @@ +package org.vibeerp.pbc.inventory.http + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.vibeerp.pbc.inventory.application.CreateLocationCommand +import org.vibeerp.pbc.inventory.application.LocationService +import org.vibeerp.pbc.inventory.application.UpdateLocationCommand +import org.vibeerp.pbc.inventory.domain.Location +import org.vibeerp.pbc.inventory.domain.LocationType +import java.util.UUID + +/** + * REST API for the inventory PBC's Location aggregate. + * + * Mounted at `/api/v1/inventory/locations`. Authenticated. + */ +@RestController +@RequestMapping("/api/v1/inventory/locations") +class LocationController( + private val locationService: LocationService, +) { + + @GetMapping + fun list(): List = + locationService.list().map { it.toResponse(locationService) } + + @GetMapping("/{id}") + fun get(@PathVariable id: UUID): ResponseEntity { + val location = locationService.findById(id) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(location.toResponse(locationService)) + } + + @GetMapping("/by-code/{code}") + fun getByCode(@PathVariable code: String): ResponseEntity { + val location = locationService.findByCode(code) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(location.toResponse(locationService)) + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun create(@RequestBody @Valid request: CreateLocationRequest): LocationResponse = + locationService.create( + CreateLocationCommand( + code = request.code, + name = request.name, + type = request.type, + active = request.active ?: true, + ext = request.ext, + ), + ).toResponse(locationService) + + @PatchMapping("/{id}") + fun update( + @PathVariable id: UUID, + @RequestBody @Valid request: UpdateLocationRequest, + ): LocationResponse = + locationService.update( + id, + UpdateLocationCommand( + name = request.name, + type = request.type, + active = request.active, + ext = request.ext, + ), + ).toResponse(locationService) + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deactivate(@PathVariable id: UUID) { + locationService.deactivate(id) + } +} + +// ─── DTOs ──────────────────────────────────────────────────────────── + +data class CreateLocationRequest( + @field:NotBlank @field:Size(max = 64) val code: String, + @field:NotBlank @field:Size(max = 256) val name: String, + val type: LocationType, + val active: Boolean? = true, + val ext: Map? = null, +) + +data class UpdateLocationRequest( + @field:Size(max = 256) val name: String? = null, + val type: LocationType? = null, + val active: Boolean? = null, + val ext: Map? = null, +) + +data class LocationResponse( + val id: UUID, + val code: String, + val name: String, + val type: LocationType, + val active: Boolean, + val ext: Map, +) + +private fun Location.toResponse(service: LocationService) = LocationResponse( + id = this.id, + code = this.code, + name = this.name, + type = this.type, + active = this.active, + ext = service.parseExt(this), +) diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockBalanceController.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockBalanceController.kt new file mode 100644 index 0000000..cb0090c --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockBalanceController.kt @@ -0,0 +1,82 @@ +package org.vibeerp.pbc.inventory.http + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.vibeerp.pbc.inventory.application.StockBalanceService +import org.vibeerp.pbc.inventory.domain.StockBalance +import java.math.BigDecimal +import java.util.UUID + +/** + * REST API for stock balances. + * + * Mounted at `/api/v1/inventory/balances`. Authenticated. + * + * GET supports filtering by either `itemCode` or `locationId` (or + * neither, returning everything). The list endpoint exists for + * operator UIs and the future SPA; the cross-PBC consumers should + * use [org.vibeerp.api.v1.ext.inventory.InventoryApi] from api.v1 + * instead of REST. + */ +@RestController +@RequestMapping("/api/v1/inventory/balances") +class StockBalanceController( + private val stockBalanceService: StockBalanceService, +) { + + @GetMapping + fun list( + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) locationId: UUID?, + ): List { + val rows = when { + itemCode != null -> stockBalanceService.findByItemCode(itemCode) + locationId != null -> stockBalanceService.findByLocationId(locationId) + else -> stockBalanceService.list() + } + return rows.map { it.toResponse() } + } + + /** + * Set the on-hand quantity for a single (itemCode, locationId) + * cell to the requested value, creating the row if necessary. + * Returns the resulting balance. + */ + @PostMapping("/adjust") + fun adjust(@RequestBody @Valid request: AdjustStockBalanceRequest): StockBalanceResponse = + stockBalanceService.adjust( + itemCode = request.itemCode, + locationId = request.locationId, + quantity = request.quantity, + ).toResponse() +} + +// ─── DTOs ──────────────────────────────────────────────────────────── + +data class AdjustStockBalanceRequest( + @field:NotBlank @field:Size(max = 64) val itemCode: String, + @field:NotNull val locationId: UUID, + @field:NotNull val quantity: BigDecimal, +) + +data class StockBalanceResponse( + val id: UUID, + val itemCode: String, + val locationId: UUID, + val quantity: BigDecimal, +) + +private fun StockBalance.toResponse() = StockBalanceResponse( + id = this.id, + itemCode = this.itemCode, + locationId = this.locationId, + quantity = this.quantity, +) diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/LocationJpaRepository.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/LocationJpaRepository.kt new file mode 100644 index 0000000..527502a --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/LocationJpaRepository.kt @@ -0,0 +1,21 @@ +package org.vibeerp.pbc.inventory.infrastructure + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.vibeerp.pbc.inventory.domain.Location +import java.util.UUID + +/** + * Spring Data JPA repository for [Location]. + * + * Like every other PBC's master-data repo, the predominant lookup is + * by `code` because that's what every external reference (sales + * orders, purchase receipts, work orders) carries. + */ +@Repository +interface LocationJpaRepository : JpaRepository { + + fun findByCode(code: String): Location? + + fun existsByCode(code: String): Boolean +} diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/StockBalanceJpaRepository.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/StockBalanceJpaRepository.kt new file mode 100644 index 0000000..1e303cc --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/StockBalanceJpaRepository.kt @@ -0,0 +1,24 @@ +package org.vibeerp.pbc.inventory.infrastructure + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.vibeerp.pbc.inventory.domain.StockBalance +import java.util.UUID + +/** + * Spring Data JPA repository for [StockBalance]. + * + * Lookups are by the (item_code, location_id) composite, and by + * item_code alone for "where is this item?". The DB-level UNIQUE + * INDEX on (item_code, location_id) means [findByItemCodeAndLocationId] + * is guaranteed to return at most one row. + */ +@Repository +interface StockBalanceJpaRepository : JpaRepository { + + fun findByItemCodeAndLocationId(itemCode: String, locationId: UUID): StockBalance? + + fun findByItemCode(itemCode: String): List + + fun findByLocationId(locationId: UUID): List +} diff --git a/pbc/pbc-inventory/src/main/resources/META-INF/vibe-erp/metadata/inventory.yml b/pbc/pbc-inventory/src/main/resources/META-INF/vibe-erp/metadata/inventory.yml new file mode 100644 index 0000000..ef84d94 --- /dev/null +++ b/pbc/pbc-inventory/src/main/resources/META-INF/vibe-erp/metadata/inventory.yml @@ -0,0 +1,41 @@ +# pbc-inventory metadata. +# +# Loaded at boot by MetadataLoader, tagged source='core'. + +entities: + - name: Location + pbc: inventory + table: inventory__location + description: A physical or logical place where stock can sit (warehouse, bin, virtual) + + - name: StockBalance + pbc: inventory + table: inventory__stock_balance + description: The on-hand quantity of one item at one location + +permissions: + - key: inventory.location.read + description: Read locations + - key: inventory.location.create + description: Create locations + - key: inventory.location.update + description: Update locations + - key: inventory.location.deactivate + description: Deactivate locations (preserved for historical references) + + - key: inventory.stock.read + description: Read stock balances + - key: inventory.stock.adjust + description: Set the on-hand quantity for an item × location + +menus: + - path: /inventory/locations + label: Locations + icon: warehouse + section: Inventory + order: 400 + - path: /inventory/stock + label: Stock balances + icon: layers + section: Inventory + order: 410 diff --git a/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/LocationServiceTest.kt b/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/LocationServiceTest.kt new file mode 100644 index 0000000..db1948e --- /dev/null +++ b/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/LocationServiceTest.kt @@ -0,0 +1,118 @@ +package org.vibeerp.pbc.inventory.application + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.vibeerp.pbc.inventory.domain.Location +import org.vibeerp.pbc.inventory.domain.LocationType +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator +import java.util.Optional +import java.util.UUID + +class LocationServiceTest { + + private lateinit var locations: LocationJpaRepository + private lateinit var extValidator: ExtJsonValidator + private lateinit var service: LocationService + + @BeforeEach + fun setUp() { + locations = mockk() + extValidator = mockk() + every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } + service = LocationService(locations, extValidator) + } + + @Test + fun `create rejects duplicate code`() { + every { locations.existsByCode("WH-MAIN") } returns true + + assertFailure { + service.create( + CreateLocationCommand( + code = "WH-MAIN", + name = "Main warehouse", + type = LocationType.WAREHOUSE, + ), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("location code 'WH-MAIN' is already taken") + } + + @Test + fun `create persists on the happy path`() { + val saved = slot() + every { locations.existsByCode("WH-NORTH") } returns false + every { locations.save(capture(saved)) } answers { saved.captured } + + val result = service.create( + CreateLocationCommand( + code = "WH-NORTH", + name = "North warehouse", + type = LocationType.WAREHOUSE, + ), + ) + + assertThat(result.code).isEqualTo("WH-NORTH") + assertThat(result.name).isEqualTo("North warehouse") + assertThat(result.type).isEqualTo(LocationType.WAREHOUSE) + assertThat(result.active).isEqualTo(true) + verify(exactly = 1) { locations.save(any()) } + } + + @Test + fun `update never changes code`() { + val id = UUID.randomUUID() + val existing = Location( + code = "BIN-A1", + name = "Old name", + type = LocationType.BIN, + ).also { it.id = id } + every { locations.findById(id) } returns Optional.of(existing) + + val updated = service.update( + id, + UpdateLocationCommand(name = "New name", type = LocationType.VIRTUAL), + ) + + assertThat(updated.name).isEqualTo("New name") + assertThat(updated.type).isEqualTo(LocationType.VIRTUAL) + assertThat(updated.code).isEqualTo("BIN-A1") // not touched + } + + @Test + fun `deactivate flips active without deleting`() { + val id = UUID.randomUUID() + val existing = Location( + code = "BIN-X", + name = "x", + type = LocationType.BIN, + active = true, + ).also { it.id = id } + every { locations.findById(id) } returns Optional.of(existing) + + service.deactivate(id) + + assertThat(existing.active).isFalse() + } + + @Test + fun `deactivate throws NoSuchElementException when missing`() { + val id = UUID.randomUUID() + every { locations.findById(id) } returns Optional.empty() + + assertFailure { service.deactivate(id) } + .isInstanceOf(NoSuchElementException::class) + } +} diff --git a/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceServiceTest.kt b/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceServiceTest.kt new file mode 100644 index 0000000..a845a74 --- /dev/null +++ b/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceServiceTest.kt @@ -0,0 +1,117 @@ +package org.vibeerp.pbc.inventory.application + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.ext.catalog.CatalogApi +import org.vibeerp.api.v1.ext.catalog.ItemRef +import org.vibeerp.pbc.inventory.domain.StockBalance +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository +import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository +import java.math.BigDecimal +import java.util.UUID + +class StockBalanceServiceTest { + + private lateinit var balances: StockBalanceJpaRepository + private lateinit var locations: LocationJpaRepository + private lateinit var catalogApi: CatalogApi + private lateinit var service: StockBalanceService + + @BeforeEach + fun setUp() { + balances = mockk() + locations = mockk() + catalogApi = mockk() + service = StockBalanceService(balances, locations, catalogApi) + } + + private fun stubItem(code: String) { + every { catalogApi.findItemByCode(code) } returns ItemRef( + id = Id(UUID.randomUUID()), + code = code, + name = "stub item", + itemType = "GOOD", + baseUomCode = "ea", + active = true, + ) + } + + @Test + fun `adjust rejects unknown item code via CatalogApi seam`() { + every { catalogApi.findItemByCode("FAKE") } returns null + + assertFailure { + service.adjust("FAKE", UUID.randomUUID(), BigDecimal("10")) + } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("item code 'FAKE' is not in the catalog (or is inactive)") + } + + @Test + fun `adjust rejects unknown location id`() { + stubItem("SKU-1") + val locId = UUID.randomUUID() + every { locations.existsById(locId) } returns false + + assertFailure { + service.adjust("SKU-1", locId, BigDecimal("10")) + } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("location not found: $locId") + } + + @Test + fun `adjust rejects negative quantity before catalog lookup`() { + // Negative quantity is rejected by an early require() so the + // CatalogApi mock is never invoked. + assertFailure { + service.adjust("SKU-1", UUID.randomUUID(), BigDecimal("-1")) + } + .isInstanceOf(IllegalArgumentException::class) + verify(exactly = 0) { catalogApi.findItemByCode(any()) } + } + + @Test + fun `adjust creates a new balance row when none exists`() { + stubItem("SKU-1") + val locId = UUID.randomUUID() + val saved = slot() + every { locations.existsById(locId) } returns true + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns null + every { balances.save(capture(saved)) } answers { saved.captured } + + val result = service.adjust("SKU-1", locId, BigDecimal("42.5")) + + assertThat(result.itemCode).isEqualTo("SKU-1") + assertThat(result.locationId).isEqualTo(locId) + assertThat(result.quantity).isEqualTo(BigDecimal("42.5")) + verify(exactly = 1) { balances.save(any()) } + } + + @Test + fun `adjust mutates the existing balance row when one already exists`() { + stubItem("SKU-1") + val locId = UUID.randomUUID() + val existing = StockBalance("SKU-1", locId, BigDecimal("5")).also { it.id = UUID.randomUUID() } + every { locations.existsById(locId) } returns true + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns existing + + val result = service.adjust("SKU-1", locId, BigDecimal("99")) + + assertThat(result).isEqualTo(existing) + assertThat(existing.quantity).isEqualTo(BigDecimal("99")) + // Crucially, save() is NOT called — the @Transactional method + // commit will flush the JPA-managed entity automatically. + verify(exactly = 0) { balances.save(any()) } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index fc6a2f0..f699691 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -52,6 +52,9 @@ project(":pbc:pbc-catalog").projectDir = file("pbc/pbc-catalog") include(":pbc:pbc-partners") project(":pbc:pbc-partners").projectDir = file("pbc/pbc-partners") +include(":pbc:pbc-inventory") +project(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory") + // ─── Reference customer plug-in (NOT loaded by default) ───────────── include(":reference-customer:plugin-printing-shop") project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop") -- libgit2 0.22.2