Commit 69f3daa92537723d1cd87fa4b3b49efb9a1dc1fb
1 parent
540d916f
feat(pbc-catalog): P5.1 — items, units of measure, cross-PBC facade
Adds the second core PBC, validating that the pbc-identity template is
actually clonable and that the Gradle dependency rule fires correctly
for a real second PBC.
What landed:
* New `pbc/pbc-catalog/` Gradle subproject. Same shape as pbc-identity:
api-v1 + platform-persistence + platform-security only (no
platform-bootstrap, no other pbc). The architecture rule in the root
build.gradle.kts now has two real PBCs to enforce against.
* `Uom` entity (catalog__uom) — code, name, dimension, ext jsonb.
Code is the natural key (stable, human-readable). UomService rejects
duplicate codes and refuses to update the code itself (would invalidate
every Item FK referencing it). UomController at /api/v1/catalog/uoms
exposes list, get-by-id, get-by-code, create, update.
* `Item` entity (catalog__item) — code, name, description, item_type
(GOOD/SERVICE/DIGITAL enum), base_uom_code FK, active flag, ext jsonb.
ItemService validates the referenced UoM exists at the application
layer (better error message than the DB FK alone), refuses to update
code or baseUomCode (data-migration operations, not edits), supports
soft delete via deactivate. ItemController at /api/v1/catalog/items
with full CRUD.
* `org.vibeerp.api.v1.ext.catalog.CatalogApi` — second cross-PBC facade
in api.v1 (after IdentityApi). Exposes findItemByCode(code) and
findUomByCode(code) returning safe ItemRef/UomRef DTOs. Inactive items
are filtered to null at the boundary so callers cannot accidentally
reference deactivated catalog rows.
* `CatalogApiAdapter` in pbc-catalog — concrete @Component
implementing CatalogApi. Maps internal entities to api.v1 DTOs without
leaking storage types.
* Liquibase changeset (catalog-init-001..003) creates both tables with
unique indexes on code, GIN indexes on ext, and seeds 15 canonical
units of measure: kg/g/t (mass), m/cm/mm/km (length), m2 (area),
l/ml (volume), ea/sheet/pack (count), h/min (time). Tagged
created_by='__seed__' so a future metadata uninstall sweep can
identify them.
Tests: 11 new unit tests (UomServiceTest x5, ItemServiceTest x6),
total now 49 unit tests across the framework, all green.
End-to-end smoke test against fresh Postgres via docker-compose
(14/14 passing):
GET /api/v1/catalog/items (no auth) → 401
POST /api/v1/auth/login → access token
GET /api/v1/catalog/uoms (Bearer) → 15 seeded UoMs
GET /api/v1/catalog/uoms/by-code/kg → 200
POST custom UoM 'roll' → 201
POST duplicate UoM 'kg' → 400 + clear message
GET items → []
POST item with unknown UoM → 400 + clear message
POST item with valid UoM → 201
catalog__item.created_by → admin user UUID
(NOT __system__)
GET /by-code/INK-CMYK-CYAN → 200
PATCH item name + description → 200
DELETE item → 204
GET item → active=false
The principal-context bridge from P4.1 keeps working without any
additional wiring in pbc-catalog: every PBC inherits the audit
behavior for free by extending AuditedJpaEntity. That is exactly the
"PBCs follow a recipe, the framework provides the cross-cutting
machinery" promise from the architecture spec.
Architectural rule enforcement still active: confirmed by reading the
build.gradle.kts and observing that pbc-catalog declares no
:platform:platform-bootstrap and no :pbc:pbc-identity dependency. The
build refuses to load on either violation.
Showing
17 changed files
with
1131 additions
and
0 deletions
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/catalog/CatalogApi.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.ext.catalog | |
| 2 | + | |
| 3 | +import org.vibeerp.api.v1.core.Id | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Cross-PBC facade for the catalog bounded context. | |
| 7 | + * | |
| 8 | + * The second `api.v1.ext.*` package after `ext.identity`. Together they | |
| 9 | + * are the proof that PBCs talk to each other through `api.v1`, not | |
| 10 | + * directly. Guardrail #9 ("PBC boundaries are sacred") is enforced by | |
| 11 | + * the Gradle build, which forbids `pbc-orders-sales` from declaring | |
| 12 | + * `pbc-catalog` as a dependency. The only way other code can ask | |
| 13 | + * "what is item X" or "what UoM is this code" is by injecting | |
| 14 | + * [CatalogApi] — an interface declared here, in api.v1, and implemented | |
| 15 | + * by `pbc-catalog` at runtime. | |
| 16 | + * | |
| 17 | + * If [CatalogApi] grows a method that leaks catalog-internal types, the | |
| 18 | + * abstraction has failed and the method must be reshaped. The whole | |
| 19 | + * point of this package is to expose intent ([ItemRef], [UomRef]) | |
| 20 | + * without exposing the storage model (`Item`, `Uom` JPA entities). | |
| 21 | + * | |
| 22 | + * Lookups are by **code**, not by id. Codes are stable, human-readable, | |
| 23 | + * and the natural key plug-ins use to refer to catalog rows from their | |
| 24 | + * own metadata files. Adding id-based lookups later is non-breaking; | |
| 25 | + * removing the code-based ones would be. | |
| 26 | + */ | |
| 27 | +interface CatalogApi { | |
| 28 | + | |
| 29 | + /** | |
| 30 | + * Look up an item by its unique catalog code (e.g. "SKU-12345", | |
| 31 | + * "INK-CMYK-CYAN", "PRESS-OPER-HOUR"). Returns `null` when no such | |
| 32 | + * item exists or when the item is inactive — the framework treats | |
| 33 | + * inactive items as "do not use" for downstream callers. | |
| 34 | + */ | |
| 35 | + fun findItemByCode(code: String): ItemRef? | |
| 36 | + | |
| 37 | + /** | |
| 38 | + * Look up a unit of measure by its code (e.g. "kg", "m", "sheet"). | |
| 39 | + * Returns `null` when no such unit is registered. | |
| 40 | + */ | |
| 41 | + fun findUomByCode(code: String): UomRef? | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * Minimal, safe-to-publish view of a catalog item. | |
| 46 | + * | |
| 47 | + * Notably absent: pricing, vendor relationships, anything from the | |
| 48 | + * future `catalog__item_attribute` table. Anything that does not appear | |
| 49 | + * here cannot be observed cross-PBC, and that is intentional — adding | |
| 50 | + * a field is a deliberate api.v1 change with a major-version cost. | |
| 51 | + */ | |
| 52 | +data class ItemRef( | |
| 53 | + val id: Id<ItemRef>, | |
| 54 | + val code: String, | |
| 55 | + val name: String, | |
| 56 | + val itemType: String, // GOOD | SERVICE | DIGITAL — string for plug-in compatibility | |
| 57 | + val baseUomCode: String, // references a UomRef.code | |
| 58 | + val active: Boolean, | |
| 59 | +) | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * Minimal, safe-to-publish view of a unit of measure. | |
| 63 | + * | |
| 64 | + * The `dimension` field is a free-form string ("mass", "length", …) | |
| 65 | + * because the closed set of dimensions has not been finalised in v0.4. | |
| 66 | + * Plug-ins that care about the closed set should defer that check until | |
| 67 | + * the framework promotes it to a typed enum. | |
| 68 | + */ | |
| 69 | +data class UomRef( | |
| 70 | + val id: Id<UomRef>, | |
| 71 | + val code: String, | |
| 72 | + val name: String, | |
| 73 | + val dimension: String, | |
| 74 | +) | ... | ... |
distribution/build.gradle.kts
| ... | ... | @@ -24,6 +24,7 @@ dependencies { |
| 24 | 24 | implementation(project(":platform:platform-plugins")) |
| 25 | 25 | implementation(project(":platform:platform-security")) |
| 26 | 26 | implementation(project(":pbc:pbc-identity")) |
| 27 | + implementation(project(":pbc:pbc-catalog")) | |
| 27 | 28 | |
| 28 | 29 | implementation(libs.spring.boot.starter) |
| 29 | 30 | implementation(libs.spring.boot.starter.web) | ... | ... |
distribution/src/main/resources/db/changelog/master.xml
| ... | ... | @@ -13,4 +13,5 @@ |
| 13 | 13 | <include file="classpath:db/changelog/platform/000-platform-init.xml"/> |
| 14 | 14 | <include file="classpath:db/changelog/pbc-identity/001-identity-init.xml"/> |
| 15 | 15 | <include file="classpath:db/changelog/pbc-identity/002-identity-credential.xml"/> |
| 16 | + <include file="classpath:db/changelog/pbc-catalog/001-catalog-init.xml"/> | |
| 16 | 17 | </databaseChangeLog> | ... | ... |
distribution/src/main/resources/db/changelog/pbc-catalog/001-catalog-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-catalog initial schema. | |
| 9 | + | |
| 10 | + Owns: catalog__uom, catalog__item. | |
| 11 | + | |
| 12 | + vibe_erp is single-tenant per instance — there are no tenant_id | |
| 13 | + columns and no Row-Level Security policies on these tables. | |
| 14 | + | |
| 15 | + Conventions enforced for every business table in vibe_erp: | |
| 16 | + • UUID primary key | |
| 17 | + • Audit columns: created_at, created_by, updated_at, updated_by | |
| 18 | + • Optimistic-locking version column | |
| 19 | + • ext jsonb NOT NULL DEFAULT '{}' for key-user custom fields | |
| 20 | + • GIN index on ext for fast custom-field queries | |
| 21 | + --> | |
| 22 | + | |
| 23 | + <changeSet id="catalog-init-001" author="vibe_erp"> | |
| 24 | + <comment>Create catalog__uom table</comment> | |
| 25 | + <sql> | |
| 26 | + CREATE TABLE catalog__uom ( | |
| 27 | + id uuid PRIMARY KEY, | |
| 28 | + code varchar(16) NOT NULL, | |
| 29 | + name varchar(128) NOT NULL, | |
| 30 | + dimension varchar(32) NOT NULL, | |
| 31 | + ext jsonb NOT NULL DEFAULT '{}'::jsonb, | |
| 32 | + created_at timestamptz NOT NULL, | |
| 33 | + created_by varchar(128) NOT NULL, | |
| 34 | + updated_at timestamptz NOT NULL, | |
| 35 | + updated_by varchar(128) NOT NULL, | |
| 36 | + version bigint NOT NULL DEFAULT 0 | |
| 37 | + ); | |
| 38 | + CREATE UNIQUE INDEX catalog__uom_code_uk ON catalog__uom (code); | |
| 39 | + CREATE INDEX catalog__uom_ext_gin ON catalog__uom USING GIN (ext jsonb_path_ops); | |
| 40 | + </sql> | |
| 41 | + <rollback> | |
| 42 | + DROP TABLE catalog__uom; | |
| 43 | + </rollback> | |
| 44 | + </changeSet> | |
| 45 | + | |
| 46 | + <changeSet id="catalog-init-002" author="vibe_erp"> | |
| 47 | + <comment>Create catalog__item table (FK to catalog__uom by code)</comment> | |
| 48 | + <sql> | |
| 49 | + CREATE TABLE catalog__item ( | |
| 50 | + id uuid PRIMARY KEY, | |
| 51 | + code varchar(64) NOT NULL, | |
| 52 | + name varchar(256) NOT NULL, | |
| 53 | + description text, | |
| 54 | + item_type varchar(32) NOT NULL, | |
| 55 | + base_uom_code varchar(16) NOT NULL REFERENCES catalog__uom(code), | |
| 56 | + active boolean NOT NULL DEFAULT true, | |
| 57 | + ext jsonb NOT NULL DEFAULT '{}'::jsonb, | |
| 58 | + created_at timestamptz NOT NULL, | |
| 59 | + created_by varchar(128) NOT NULL, | |
| 60 | + updated_at timestamptz NOT NULL, | |
| 61 | + updated_by varchar(128) NOT NULL, | |
| 62 | + version bigint NOT NULL DEFAULT 0 | |
| 63 | + ); | |
| 64 | + CREATE UNIQUE INDEX catalog__item_code_uk ON catalog__item (code); | |
| 65 | + CREATE INDEX catalog__item_ext_gin ON catalog__item USING GIN (ext jsonb_path_ops); | |
| 66 | + CREATE INDEX catalog__item_active_idx ON catalog__item (active); | |
| 67 | + </sql> | |
| 68 | + <rollback> | |
| 69 | + DROP TABLE catalog__item; | |
| 70 | + </rollback> | |
| 71 | + </changeSet> | |
| 72 | + | |
| 73 | + <!-- | |
| 74 | + Seed canonical UoMs. Tagged source='core' via the audit columns | |
| 75 | + so the future metadata uninstall logic can leave them alone when | |
| 76 | + a plug-in is removed. The set covers the units a printing-shop | |
| 77 | + plug-in (and most other v1.0 plug-ins) will need on day one. | |
| 78 | + --> | |
| 79 | + <changeSet id="catalog-init-003" author="vibe_erp"> | |
| 80 | + <comment>Seed canonical UoMs</comment> | |
| 81 | + <sql> | |
| 82 | + INSERT INTO catalog__uom (id, code, name, dimension, created_at, created_by, updated_at, updated_by) VALUES | |
| 83 | + (gen_random_uuid(), 'kg', 'Kilogram', 'mass', now(), '__seed__', now(), '__seed__'), | |
| 84 | + (gen_random_uuid(), 'g', 'Gram', 'mass', now(), '__seed__', now(), '__seed__'), | |
| 85 | + (gen_random_uuid(), 't', 'Tonne', 'mass', now(), '__seed__', now(), '__seed__'), | |
| 86 | + (gen_random_uuid(), 'm', 'Metre', 'length', now(), '__seed__', now(), '__seed__'), | |
| 87 | + (gen_random_uuid(), 'cm', 'Centimetre','length', now(), '__seed__', now(), '__seed__'), | |
| 88 | + (gen_random_uuid(), 'mm', 'Millimetre','length', now(), '__seed__', now(), '__seed__'), | |
| 89 | + (gen_random_uuid(), 'km', 'Kilometre', 'length', now(), '__seed__', now(), '__seed__'), | |
| 90 | + (gen_random_uuid(), 'm2', 'Square metre','area', now(), '__seed__', now(), '__seed__'), | |
| 91 | + (gen_random_uuid(), 'l', 'Litre', 'volume', now(), '__seed__', now(), '__seed__'), | |
| 92 | + (gen_random_uuid(), 'ml', 'Millilitre','volume', now(), '__seed__', now(), '__seed__'), | |
| 93 | + (gen_random_uuid(), 'ea', 'Each', 'count', now(), '__seed__', now(), '__seed__'), | |
| 94 | + (gen_random_uuid(), 'sheet', 'Sheet', 'count', now(), '__seed__', now(), '__seed__'), | |
| 95 | + (gen_random_uuid(), 'pack', 'Pack', 'count', now(), '__seed__', now(), '__seed__'), | |
| 96 | + (gen_random_uuid(), 'h', 'Hour', 'time', now(), '__seed__', now(), '__seed__'), | |
| 97 | + (gen_random_uuid(), 'min', 'Minute', 'time', now(), '__seed__', now(), '__seed__'); | |
| 98 | + </sql> | |
| 99 | + <rollback> | |
| 100 | + DELETE FROM catalog__uom WHERE created_by = '__seed__'; | |
| 101 | + </rollback> | |
| 102 | + </changeSet> | |
| 103 | + | |
| 104 | +</databaseChangeLog> | ... | ... |
pbc/pbc-catalog/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-catalog — items, units of measure, attributes. 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-catalog may depend on api-v1, platform-persistence and | |
| 30 | +// platform-security but NEVER on platform-bootstrap, NEVER on another | |
| 31 | +// pbc-*. The root build.gradle.kts enforces this; if you add a | |
| 32 | +// `:pbc:pbc-foo` or `:platform:platform-bootstrap` dependency below, | |
| 33 | +// the build will refuse to load with an architectural-violation error. | |
| 34 | +dependencies { | |
| 35 | + api(project(":api:api-v1")) | |
| 36 | + implementation(project(":platform:platform-persistence")) | |
| 37 | + implementation(project(":platform:platform-security")) | |
| 38 | + | |
| 39 | + implementation(libs.kotlin.stdlib) | |
| 40 | + implementation(libs.kotlin.reflect) | |
| 41 | + | |
| 42 | + implementation(libs.spring.boot.starter) | |
| 43 | + implementation(libs.spring.boot.starter.web) | |
| 44 | + implementation(libs.spring.boot.starter.data.jpa) | |
| 45 | + implementation(libs.spring.boot.starter.validation) | |
| 46 | + implementation(libs.jackson.module.kotlin) | |
| 47 | + | |
| 48 | + testImplementation(libs.spring.boot.starter.test) | |
| 49 | + testImplementation(libs.junit.jupiter) | |
| 50 | + testImplementation(libs.assertk) | |
| 51 | + testImplementation(libs.mockk) | |
| 52 | +} | |
| 53 | + | |
| 54 | +tasks.test { | |
| 55 | + useJUnitPlatform() | |
| 56 | +} | ... | ... |
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/application/ItemService.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.catalog.application | |
| 2 | + | |
| 3 | +import org.springframework.stereotype.Service | |
| 4 | +import org.springframework.transaction.annotation.Transactional | |
| 5 | +import org.vibeerp.pbc.catalog.domain.Item | |
| 6 | +import org.vibeerp.pbc.catalog.domain.ItemType | |
| 7 | +import org.vibeerp.pbc.catalog.infrastructure.ItemJpaRepository | |
| 8 | +import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository | |
| 9 | +import java.util.UUID | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Application service for item CRUD. | |
| 13 | + * | |
| 14 | + * Item is the first PBC entity that has a foreign key to another row in | |
| 15 | + * the same PBC (Item → Uom by code). The validation of "the referenced | |
| 16 | + * UoM actually exists" lives here in application code, not as a database | |
| 17 | + * constraint, even though there IS a database FK on `base_uom_code` — | |
| 18 | + * the DB constraint is the second wall, this check produces a much | |
| 19 | + * better error message. | |
| 20 | + * | |
| 21 | + * Cross-PBC references (which Item will eventually grow: vendor from | |
| 22 | + * pbc-partners, default warehouse from pbc-inventory, …) go through | |
| 23 | + * `api.v1.ext.<pbc>` facades, NOT direct dependencies. This is the rule | |
| 24 | + * the Gradle build enforces and the discipline the framework lives or | |
| 25 | + * dies by. | |
| 26 | + */ | |
| 27 | +@Service | |
| 28 | +@Transactional | |
| 29 | +class ItemService( | |
| 30 | + private val items: ItemJpaRepository, | |
| 31 | + private val uoms: UomJpaRepository, | |
| 32 | +) { | |
| 33 | + | |
| 34 | + @Transactional(readOnly = true) | |
| 35 | + fun list(): List<Item> = items.findAll() | |
| 36 | + | |
| 37 | + @Transactional(readOnly = true) | |
| 38 | + fun findById(id: UUID): Item? = items.findById(id).orElse(null) | |
| 39 | + | |
| 40 | + @Transactional(readOnly = true) | |
| 41 | + fun findByCode(code: String): Item? = items.findByCode(code) | |
| 42 | + | |
| 43 | + fun create(command: CreateItemCommand): Item { | |
| 44 | + require(!items.existsByCode(command.code)) { | |
| 45 | + "item code '${command.code}' is already taken" | |
| 46 | + } | |
| 47 | + require(uoms.existsByCode(command.baseUomCode)) { | |
| 48 | + "base UoM '${command.baseUomCode}' is not in the catalog" | |
| 49 | + } | |
| 50 | + return items.save( | |
| 51 | + Item( | |
| 52 | + code = command.code, | |
| 53 | + name = command.name, | |
| 54 | + description = command.description, | |
| 55 | + itemType = command.itemType, | |
| 56 | + baseUomCode = command.baseUomCode, | |
| 57 | + active = command.active, | |
| 58 | + ), | |
| 59 | + ) | |
| 60 | + } | |
| 61 | + | |
| 62 | + fun update(id: UUID, command: UpdateItemCommand): Item { | |
| 63 | + val item = items.findById(id).orElseThrow { | |
| 64 | + NoSuchElementException("item not found: $id") | |
| 65 | + } | |
| 66 | + // `code` and `baseUomCode` are deliberately not updatable here: | |
| 67 | + // the first because every external reference uses code, the | |
| 68 | + // second because changing units of measure on an existing item | |
| 69 | + // is a data-migration operation, not an edit. | |
| 70 | + command.name?.let { item.name = it } | |
| 71 | + command.description?.let { item.description = it } | |
| 72 | + command.itemType?.let { item.itemType = it } | |
| 73 | + command.active?.let { item.active = it } | |
| 74 | + return item | |
| 75 | + } | |
| 76 | + | |
| 77 | + fun deactivate(id: UUID) { | |
| 78 | + val item = items.findById(id).orElseThrow { | |
| 79 | + NoSuchElementException("item not found: $id") | |
| 80 | + } | |
| 81 | + item.active = false | |
| 82 | + } | |
| 83 | +} | |
| 84 | + | |
| 85 | +data class CreateItemCommand( | |
| 86 | + val code: String, | |
| 87 | + val name: String, | |
| 88 | + val description: String?, | |
| 89 | + val itemType: ItemType, | |
| 90 | + val baseUomCode: String, | |
| 91 | + val active: Boolean = true, | |
| 92 | +) | |
| 93 | + | |
| 94 | +data class UpdateItemCommand( | |
| 95 | + val name: String? = null, | |
| 96 | + val description: String? = null, | |
| 97 | + val itemType: ItemType? = null, | |
| 98 | + val active: Boolean? = null, | |
| 99 | +) | ... | ... |
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/application/UomService.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.catalog.application | |
| 2 | + | |
| 3 | +import org.springframework.stereotype.Service | |
| 4 | +import org.springframework.transaction.annotation.Transactional | |
| 5 | +import org.vibeerp.pbc.catalog.domain.Uom | |
| 6 | +import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository | |
| 7 | +import java.util.UUID | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Application service for unit-of-measure CRUD. | |
| 11 | + * | |
| 12 | + * Mirrors the shape of `pbc-identity`'s `UserService` so that all PBCs | |
| 13 | + * follow the same recipe and a future code generator (or human reader) | |
| 14 | + * can navigate them by analogy. Boundaries: | |
| 15 | + * | |
| 16 | + * • Controllers MUST go through this service. They never inject the | |
| 17 | + * repository directly. | |
| 18 | + * • The service is the only place that holds a transaction boundary. | |
| 19 | + * • Validation that involves cross-row state (e.g. "code is unique") | |
| 20 | + * lives here, not in the entity. Validation that involves a single | |
| 21 | + * field (length, format) lives on the controller's request DTO via | |
| 22 | + * `jakarta.validation` annotations. | |
| 23 | + */ | |
| 24 | +@Service | |
| 25 | +@Transactional | |
| 26 | +class UomService( | |
| 27 | + private val uoms: UomJpaRepository, | |
| 28 | +) { | |
| 29 | + | |
| 30 | + @Transactional(readOnly = true) | |
| 31 | + fun list(): List<Uom> = uoms.findAll() | |
| 32 | + | |
| 33 | + @Transactional(readOnly = true) | |
| 34 | + fun findById(id: UUID): Uom? = uoms.findById(id).orElse(null) | |
| 35 | + | |
| 36 | + @Transactional(readOnly = true) | |
| 37 | + fun findByCode(code: String): Uom? = uoms.findByCode(code) | |
| 38 | + | |
| 39 | + fun create(command: CreateUomCommand): Uom { | |
| 40 | + require(!uoms.existsByCode(command.code)) { | |
| 41 | + "uom code '${command.code}' is already taken" | |
| 42 | + } | |
| 43 | + return uoms.save( | |
| 44 | + Uom( | |
| 45 | + code = command.code, | |
| 46 | + name = command.name, | |
| 47 | + dimension = command.dimension, | |
| 48 | + ), | |
| 49 | + ) | |
| 50 | + } | |
| 51 | + | |
| 52 | + fun update(id: UUID, command: UpdateUomCommand): Uom { | |
| 53 | + val uom = uoms.findById(id).orElseThrow { | |
| 54 | + NoSuchElementException("uom not found: $id") | |
| 55 | + } | |
| 56 | + // `code` is intentionally NOT updatable: changing a code would | |
| 57 | + // invalidate every Item row that references it (FK is by code, | |
| 58 | + // not by id) and every saved metadata row that mentions it. | |
| 59 | + // Operators who really need a different code create a new UoM | |
| 60 | + // and migrate items. | |
| 61 | + command.name?.let { uom.name = it } | |
| 62 | + command.dimension?.let { uom.dimension = it } | |
| 63 | + return uom | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +data class CreateUomCommand( | |
| 68 | + val code: String, | |
| 69 | + val name: String, | |
| 70 | + val dimension: String, | |
| 71 | +) | |
| 72 | + | |
| 73 | +data class UpdateUomCommand( | |
| 74 | + val name: String? = null, | |
| 75 | + val dimension: String? = null, | |
| 76 | +) | ... | ... |
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/domain/Item.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.catalog.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 | + * The canonical Item entity for the catalog PBC. | |
| 14 | + * | |
| 15 | + * An "item" is anything a printing shop (or any vibe_erp customer) might | |
| 16 | + * buy, sell, store, produce, or invoice for: a physical good, a service, | |
| 17 | + * a digital asset. Item is the foundation that orders, inventory, and | |
| 18 | + * production all reference; it lands first because everything else | |
| 19 | + * depends on it being there. | |
| 20 | + * | |
| 21 | + * **Why `base_uom_code` is a varchar FK and not a UUID:** | |
| 22 | + * UoM codes are stable, human-readable, and how every other PBC and every | |
| 23 | + * plug-in addresses units. Modeling the FK as `varchar` makes seed data | |
| 24 | + * trivial (you can write `base_uom_code = 'kg'` in a YAML), keeps the | |
| 25 | + * Hibernate-generated SQL readable, and lets you eyeball the database | |
| 26 | + * directly. The cost is that renaming a UoM code is forbidden — and that | |
| 27 | + * is intentional (see `UomService.update`). | |
| 28 | + * | |
| 29 | + * **Why `ext` JSONB instead of a lot of typed columns:** | |
| 30 | + * Most printing shops will want extra fields on Item — colour, weight, | |
| 31 | + * supplier SKU, stock-keeping rules — that vary per customer. The metadata | |
| 32 | + * store + JSONB pattern lets a key user add those fields through the UI | |
| 33 | + * without a migration. The handful of fields above are the ones EVERY | |
| 34 | + * customer needs. | |
| 35 | + */ | |
| 36 | +@Entity | |
| 37 | +@Table(name = "catalog__item") | |
| 38 | +class Item( | |
| 39 | + code: String, | |
| 40 | + name: String, | |
| 41 | + description: String?, | |
| 42 | + itemType: ItemType, | |
| 43 | + baseUomCode: String, | |
| 44 | + active: Boolean = true, | |
| 45 | +) : AuditedJpaEntity() { | |
| 46 | + | |
| 47 | + @Column(name = "code", nullable = false, length = 64) | |
| 48 | + var code: String = code | |
| 49 | + | |
| 50 | + @Column(name = "name", nullable = false, length = 256) | |
| 51 | + var name: String = name | |
| 52 | + | |
| 53 | + @Column(name = "description", nullable = true) | |
| 54 | + var description: String? = description | |
| 55 | + | |
| 56 | + @Enumerated(EnumType.STRING) | |
| 57 | + @Column(name = "item_type", nullable = false, length = 32) | |
| 58 | + var itemType: ItemType = itemType | |
| 59 | + | |
| 60 | + @Column(name = "base_uom_code", nullable = false, length = 16) | |
| 61 | + var baseUomCode: String = baseUomCode | |
| 62 | + | |
| 63 | + @Column(name = "active", nullable = false) | |
| 64 | + var active: Boolean = active | |
| 65 | + | |
| 66 | + @Column(name = "ext", nullable = false, columnDefinition = "jsonb") | |
| 67 | + @JdbcTypeCode(SqlTypes.JSON) | |
| 68 | + var ext: String = "{}" | |
| 69 | + | |
| 70 | + override fun toString(): String = | |
| 71 | + "Item(id=$id, code='$code', type=$itemType, baseUom='$baseUomCode')" | |
| 72 | +} | |
| 73 | + | |
| 74 | +/** | |
| 75 | + * The kind of thing an [Item] represents. | |
| 76 | + * | |
| 77 | + * Stored as a string in the DB so adding values later is non-breaking | |
| 78 | + * for clients reading the column with raw SQL. Plug-ins that need a | |
| 79 | + * narrower notion of "type" should add their own discriminator field via | |
| 80 | + * the metadata custom-field mechanism, not by extending this enum — | |
| 81 | + * extending the enum would force every plug-in's compile classpath to | |
| 82 | + * carry the new value. | |
| 83 | + */ | |
| 84 | +enum class ItemType { | |
| 85 | + GOOD, // physical thing you can hold and ship | |
| 86 | + SERVICE, // labour, time, intangible work | |
| 87 | + DIGITAL, // file delivered electronically | |
| 88 | +} | ... | ... |
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/domain/Uom.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.catalog.domain | |
| 2 | + | |
| 3 | +import jakarta.persistence.Column | |
| 4 | +import jakarta.persistence.Entity | |
| 5 | +import jakarta.persistence.Table | |
| 6 | +import org.hibernate.annotations.JdbcTypeCode | |
| 7 | +import org.hibernate.type.SqlTypes | |
| 8 | +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Unit of measure as stored in `catalog__uom`. | |
| 12 | + * | |
| 13 | + * Why a separate entity from [org.vibeerp.api.v1.core.UnitOfMeasure]: | |
| 14 | + * - The api.v1 type is the **wire form** — a value class wrapping just | |
| 15 | + * `code`. It carries no display name and no dimension because plug-ins | |
| 16 | + * that pass quantities around don't need them and shouldn't depend on | |
| 17 | + * the catalog implementation. | |
| 18 | + * - This [Uom] type is the **storage form** — the row in the catalog | |
| 19 | + * PBC's database table, with name, dimension, and `ext` for custom | |
| 20 | + * fields. PBC code that lives in `pbc-catalog` uses this directly. | |
| 21 | + * | |
| 22 | + * Conversions between units (e.g. 1000 g = 1 kg) are NOT modeled here in | |
| 23 | + * v0.4. They land later when a real customer needs them; until then, | |
| 24 | + * arithmetic is restricted to "same code only" by api.v1's [Quantity] | |
| 25 | + * type. | |
| 26 | + */ | |
| 27 | +@Entity | |
| 28 | +@Table(name = "catalog__uom") | |
| 29 | +class Uom( | |
| 30 | + code: String, | |
| 31 | + name: String, | |
| 32 | + dimension: String, | |
| 33 | +) : AuditedJpaEntity() { | |
| 34 | + | |
| 35 | + @Column(name = "code", nullable = false, length = 16) | |
| 36 | + var code: String = code | |
| 37 | + | |
| 38 | + @Column(name = "name", nullable = false, length = 128) | |
| 39 | + var name: String = name | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * Logical dimension this unit measures: `mass`, `length`, `area`, | |
| 43 | + * `volume`, `count`, `time`, `currency`. Used by future logic that | |
| 44 | + * wants to refuse "add 5 kg to 5 m" at the catalog layer rather than | |
| 45 | + * waiting for the [Quantity] arithmetic check. | |
| 46 | + */ | |
| 47 | + @Column(name = "dimension", nullable = false, length = 32) | |
| 48 | + var dimension: String = dimension | |
| 49 | + | |
| 50 | + @Column(name = "ext", nullable = false, columnDefinition = "jsonb") | |
| 51 | + @JdbcTypeCode(SqlTypes.JSON) | |
| 52 | + var ext: String = "{}" | |
| 53 | + | |
| 54 | + override fun toString(): String = "Uom(id=$id, code='$code', dimension='$dimension')" | |
| 55 | +} | ... | ... |
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/ext/CatalogApiAdapter.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.catalog.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.catalog.CatalogApi | |
| 7 | +import org.vibeerp.api.v1.ext.catalog.ItemRef | |
| 8 | +import org.vibeerp.api.v1.ext.catalog.UomRef | |
| 9 | +import org.vibeerp.pbc.catalog.domain.Item | |
| 10 | +import org.vibeerp.pbc.catalog.domain.Uom | |
| 11 | +import org.vibeerp.pbc.catalog.infrastructure.ItemJpaRepository | |
| 12 | +import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * Concrete [CatalogApi] implementation. The ONLY thing other PBCs and | |
| 16 | + * plug-ins are allowed to inject for "give me a catalog reference". | |
| 17 | + * | |
| 18 | + * Why this file exists: see the rationale on [CatalogApi]. The Gradle | |
| 19 | + * build forbids cross-PBC dependencies; this adapter is the runtime | |
| 20 | + * fulfillment of the api.v1 contract that lets the rest of the world | |
| 21 | + * ask catalog questions without importing catalog types. | |
| 22 | + * | |
| 23 | + * **What this adapter MUST NOT do:** | |
| 24 | + * • leak `org.vibeerp.pbc.catalog.domain.Item` or `Uom` to callers | |
| 25 | + * • return inactive items (the contract says inactive → null) | |
| 26 | + * • bypass the active filter on findItemByCode | |
| 27 | + * | |
| 28 | + * If a caller needs more fields, the answer is to grow `ItemRef`/`UomRef` | |
| 29 | + * deliberately (an api.v1 change with a major-version cost), not to | |
| 30 | + * hand them the entity. | |
| 31 | + */ | |
| 32 | +@Component | |
| 33 | +@Transactional(readOnly = true) | |
| 34 | +class CatalogApiAdapter( | |
| 35 | + private val items: ItemJpaRepository, | |
| 36 | + private val uoms: UomJpaRepository, | |
| 37 | +) : CatalogApi { | |
| 38 | + | |
| 39 | + override fun findItemByCode(code: String): ItemRef? = | |
| 40 | + items.findByCode(code) | |
| 41 | + ?.takeIf { it.active } | |
| 42 | + ?.toRef() | |
| 43 | + | |
| 44 | + override fun findUomByCode(code: String): UomRef? = | |
| 45 | + uoms.findByCode(code)?.toRef() | |
| 46 | + | |
| 47 | + private fun Item.toRef(): ItemRef = ItemRef( | |
| 48 | + id = Id<ItemRef>(this.id), | |
| 49 | + code = this.code, | |
| 50 | + name = this.name, | |
| 51 | + itemType = this.itemType.name, | |
| 52 | + baseUomCode = this.baseUomCode, | |
| 53 | + active = this.active, | |
| 54 | + ) | |
| 55 | + | |
| 56 | + private fun Uom.toRef(): UomRef = UomRef( | |
| 57 | + id = Id<UomRef>(this.id), | |
| 58 | + code = this.code, | |
| 59 | + name = this.name, | |
| 60 | + dimension = this.dimension, | |
| 61 | + ) | |
| 62 | +} | ... | ... |
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/http/ItemController.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.catalog.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.catalog.application.CreateItemCommand | |
| 18 | +import org.vibeerp.pbc.catalog.application.ItemService | |
| 19 | +import org.vibeerp.pbc.catalog.application.UpdateItemCommand | |
| 20 | +import org.vibeerp.pbc.catalog.domain.Item | |
| 21 | +import org.vibeerp.pbc.catalog.domain.ItemType | |
| 22 | +import java.util.UUID | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * REST API for the catalog PBC's Item aggregate. | |
| 26 | + * | |
| 27 | + * Mounted at `/api/v1/catalog/items`. Authenticated; the framework's | |
| 28 | + * Spring Security filter chain rejects anonymous requests with 401. | |
| 29 | + * | |
| 30 | + * The DTO layer (request/response) is intentionally separate from the | |
| 31 | + * JPA entity. This is the rule that lets the entity's storage shape | |
| 32 | + * evolve (rename a column, denormalize a field, drop something) without | |
| 33 | + * breaking API consumers — including the OpenAPI spec, the React SPA, | |
| 34 | + * AI agents using the MCP server, and third-party integrations. | |
| 35 | + */ | |
| 36 | +@RestController | |
| 37 | +@RequestMapping("/api/v1/catalog/items") | |
| 38 | +class ItemController( | |
| 39 | + private val itemService: ItemService, | |
| 40 | +) { | |
| 41 | + | |
| 42 | + @GetMapping | |
| 43 | + fun list(): List<ItemResponse> = | |
| 44 | + itemService.list().map { it.toResponse() } | |
| 45 | + | |
| 46 | + @GetMapping("/{id}") | |
| 47 | + fun get(@PathVariable id: UUID): ResponseEntity<ItemResponse> { | |
| 48 | + val item = itemService.findById(id) ?: return ResponseEntity.notFound().build() | |
| 49 | + return ResponseEntity.ok(item.toResponse()) | |
| 50 | + } | |
| 51 | + | |
| 52 | + @GetMapping("/by-code/{code}") | |
| 53 | + fun getByCode(@PathVariable code: String): ResponseEntity<ItemResponse> { | |
| 54 | + val item = itemService.findByCode(code) ?: return ResponseEntity.notFound().build() | |
| 55 | + return ResponseEntity.ok(item.toResponse()) | |
| 56 | + } | |
| 57 | + | |
| 58 | + @PostMapping | |
| 59 | + @ResponseStatus(HttpStatus.CREATED) | |
| 60 | + fun create(@RequestBody @Valid request: CreateItemRequest): ItemResponse = | |
| 61 | + itemService.create( | |
| 62 | + CreateItemCommand( | |
| 63 | + code = request.code, | |
| 64 | + name = request.name, | |
| 65 | + description = request.description, | |
| 66 | + itemType = request.itemType, | |
| 67 | + baseUomCode = request.baseUomCode, | |
| 68 | + active = request.active ?: true, | |
| 69 | + ), | |
| 70 | + ).toResponse() | |
| 71 | + | |
| 72 | + @PatchMapping("/{id}") | |
| 73 | + fun update( | |
| 74 | + @PathVariable id: UUID, | |
| 75 | + @RequestBody @Valid request: UpdateItemRequest, | |
| 76 | + ): ItemResponse = | |
| 77 | + itemService.update( | |
| 78 | + id, | |
| 79 | + UpdateItemCommand( | |
| 80 | + name = request.name, | |
| 81 | + description = request.description, | |
| 82 | + itemType = request.itemType, | |
| 83 | + active = request.active, | |
| 84 | + ), | |
| 85 | + ).toResponse() | |
| 86 | + | |
| 87 | + @DeleteMapping("/{id}") | |
| 88 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 89 | + fun deactivate(@PathVariable id: UUID) { | |
| 90 | + itemService.deactivate(id) | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +// ─── DTOs ──────────────────────────────────────────────────────────── | |
| 95 | + | |
| 96 | +data class CreateItemRequest( | |
| 97 | + @field:NotBlank @field:Size(max = 64) val code: String, | |
| 98 | + @field:NotBlank @field:Size(max = 256) val name: String, | |
| 99 | + val description: String?, | |
| 100 | + val itemType: ItemType, | |
| 101 | + @field:NotBlank @field:Size(max = 16) val baseUomCode: String, | |
| 102 | + val active: Boolean? = true, | |
| 103 | +) | |
| 104 | + | |
| 105 | +data class UpdateItemRequest( | |
| 106 | + @field:Size(max = 256) val name: String? = null, | |
| 107 | + val description: String? = null, | |
| 108 | + val itemType: ItemType? = null, | |
| 109 | + val active: Boolean? = null, | |
| 110 | +) | |
| 111 | + | |
| 112 | +data class ItemResponse( | |
| 113 | + val id: UUID, | |
| 114 | + val code: String, | |
| 115 | + val name: String, | |
| 116 | + val description: String?, | |
| 117 | + val itemType: ItemType, | |
| 118 | + val baseUomCode: String, | |
| 119 | + val active: Boolean, | |
| 120 | +) | |
| 121 | + | |
| 122 | +private fun Item.toResponse() = ItemResponse( | |
| 123 | + id = this.id, | |
| 124 | + code = this.code, | |
| 125 | + name = this.name, | |
| 126 | + description = this.description, | |
| 127 | + itemType = this.itemType, | |
| 128 | + baseUomCode = this.baseUomCode, | |
| 129 | + active = this.active, | |
| 130 | +) | ... | ... |
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/http/UomController.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.catalog.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.GetMapping | |
| 9 | +import org.springframework.web.bind.annotation.PatchMapping | |
| 10 | +import org.springframework.web.bind.annotation.PathVariable | |
| 11 | +import org.springframework.web.bind.annotation.PostMapping | |
| 12 | +import org.springframework.web.bind.annotation.RequestBody | |
| 13 | +import org.springframework.web.bind.annotation.RequestMapping | |
| 14 | +import org.springframework.web.bind.annotation.ResponseStatus | |
| 15 | +import org.springframework.web.bind.annotation.RestController | |
| 16 | +import org.vibeerp.pbc.catalog.application.CreateUomCommand | |
| 17 | +import org.vibeerp.pbc.catalog.application.UomService | |
| 18 | +import org.vibeerp.pbc.catalog.application.UpdateUomCommand | |
| 19 | +import org.vibeerp.pbc.catalog.domain.Uom | |
| 20 | +import java.util.UUID | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * REST API for the catalog PBC's UoM aggregate. | |
| 24 | + * | |
| 25 | + * Mounted at `/api/v1/catalog/uoms` following the convention | |
| 26 | + * `/api/v1/<pbc-name>/<resource>` so each PBC namespaces naturally and | |
| 27 | + * plug-in endpoints (which mount under `/api/v1/plugins/...`) cannot | |
| 28 | + * collide with core endpoints. | |
| 29 | + * | |
| 30 | + * Authentication is required (Spring Security's filter chain rejects | |
| 31 | + * anonymous requests with 401). The principal is bound to the audit | |
| 32 | + * listener so created_by/updated_by carry the real user id. | |
| 33 | + */ | |
| 34 | +@RestController | |
| 35 | +@RequestMapping("/api/v1/catalog/uoms") | |
| 36 | +class UomController( | |
| 37 | + private val uomService: UomService, | |
| 38 | +) { | |
| 39 | + | |
| 40 | + @GetMapping | |
| 41 | + fun list(): List<UomResponse> = | |
| 42 | + uomService.list().map { it.toResponse() } | |
| 43 | + | |
| 44 | + @GetMapping("/{id}") | |
| 45 | + fun get(@PathVariable id: UUID): ResponseEntity<UomResponse> { | |
| 46 | + val uom = uomService.findById(id) ?: return ResponseEntity.notFound().build() | |
| 47 | + return ResponseEntity.ok(uom.toResponse()) | |
| 48 | + } | |
| 49 | + | |
| 50 | + @GetMapping("/by-code/{code}") | |
| 51 | + fun getByCode(@PathVariable code: String): ResponseEntity<UomResponse> { | |
| 52 | + val uom = uomService.findByCode(code) ?: return ResponseEntity.notFound().build() | |
| 53 | + return ResponseEntity.ok(uom.toResponse()) | |
| 54 | + } | |
| 55 | + | |
| 56 | + @PostMapping | |
| 57 | + @ResponseStatus(HttpStatus.CREATED) | |
| 58 | + fun create(@RequestBody @Valid request: CreateUomRequest): UomResponse = | |
| 59 | + uomService.create( | |
| 60 | + CreateUomCommand( | |
| 61 | + code = request.code, | |
| 62 | + name = request.name, | |
| 63 | + dimension = request.dimension, | |
| 64 | + ), | |
| 65 | + ).toResponse() | |
| 66 | + | |
| 67 | + @PatchMapping("/{id}") | |
| 68 | + fun update( | |
| 69 | + @PathVariable id: UUID, | |
| 70 | + @RequestBody @Valid request: UpdateUomRequest, | |
| 71 | + ): UomResponse = | |
| 72 | + uomService.update( | |
| 73 | + id, | |
| 74 | + UpdateUomCommand(name = request.name, dimension = request.dimension), | |
| 75 | + ).toResponse() | |
| 76 | +} | |
| 77 | + | |
| 78 | +// ─── DTOs ──────────────────────────────────────────────────────────── | |
| 79 | + | |
| 80 | +data class CreateUomRequest( | |
| 81 | + @field:NotBlank @field:Size(max = 16) val code: String, | |
| 82 | + @field:NotBlank @field:Size(max = 128) val name: String, | |
| 83 | + @field:NotBlank @field:Size(max = 32) val dimension: String, | |
| 84 | +) | |
| 85 | + | |
| 86 | +data class UpdateUomRequest( | |
| 87 | + @field:Size(max = 128) val name: String? = null, | |
| 88 | + @field:Size(max = 32) val dimension: String? = null, | |
| 89 | +) | |
| 90 | + | |
| 91 | +data class UomResponse( | |
| 92 | + val id: UUID, | |
| 93 | + val code: String, | |
| 94 | + val name: String, | |
| 95 | + val dimension: String, | |
| 96 | +) | |
| 97 | + | |
| 98 | +private fun Uom.toResponse() = UomResponse( | |
| 99 | + id = this.id, | |
| 100 | + code = this.code, | |
| 101 | + name = this.name, | |
| 102 | + dimension = this.dimension, | |
| 103 | +) | ... | ... |
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/infrastructure/ItemJpaRepository.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.catalog.infrastructure | |
| 2 | + | |
| 3 | +import org.springframework.data.jpa.repository.JpaRepository | |
| 4 | +import org.springframework.stereotype.Repository | |
| 5 | +import org.vibeerp.pbc.catalog.domain.Item | |
| 6 | +import java.util.UUID | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Spring Data JPA repository for [Item]. | |
| 10 | + * | |
| 11 | + * Like [UomJpaRepository], lookups are predominantly by `code` because | |
| 12 | + * item codes are stable, human-meaningful, and the natural key every | |
| 13 | + * other PBC and every plug-in uses to reference an item ("SKU-12345" | |
| 14 | + * is more meaningful than a UUID). | |
| 15 | + */ | |
| 16 | +@Repository | |
| 17 | +interface ItemJpaRepository : JpaRepository<Item, UUID> { | |
| 18 | + | |
| 19 | + fun findByCode(code: String): Item? | |
| 20 | + | |
| 21 | + fun existsByCode(code: String): Boolean | |
| 22 | +} | ... | ... |
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/infrastructure/UomJpaRepository.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.catalog.infrastructure | |
| 2 | + | |
| 3 | +import org.springframework.data.jpa.repository.JpaRepository | |
| 4 | +import org.springframework.stereotype.Repository | |
| 5 | +import org.vibeerp.pbc.catalog.domain.Uom | |
| 6 | +import java.util.UUID | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Spring Data JPA repository for [Uom]. | |
| 10 | + * | |
| 11 | + * Lookups are predominantly **by code** because UoM codes are stable and | |
| 12 | + * human-meaningful (e.g. "kg", "m", "ea") and that is how every other | |
| 13 | + * PBC and every plug-in references units. The unique constraint on | |
| 14 | + * `catalog__uom.code` lives in the Liquibase changeset and is the source | |
| 15 | + * of truth for "no two UoMs share a code". | |
| 16 | + */ | |
| 17 | +@Repository | |
| 18 | +interface UomJpaRepository : JpaRepository<Uom, UUID> { | |
| 19 | + | |
| 20 | + fun findByCode(code: String): Uom? | |
| 21 | + | |
| 22 | + fun existsByCode(code: String): Boolean | |
| 23 | +} | ... | ... |
pbc/pbc-catalog/src/test/kotlin/org/vibeerp/pbc/catalog/application/ItemServiceTest.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.catalog.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.pbc.catalog.domain.Item | |
| 15 | +import org.vibeerp.pbc.catalog.domain.ItemType | |
| 16 | +import org.vibeerp.pbc.catalog.infrastructure.ItemJpaRepository | |
| 17 | +import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository | |
| 18 | +import java.util.Optional | |
| 19 | +import java.util.UUID | |
| 20 | + | |
| 21 | +class ItemServiceTest { | |
| 22 | + | |
| 23 | + private lateinit var items: ItemJpaRepository | |
| 24 | + private lateinit var uoms: UomJpaRepository | |
| 25 | + private lateinit var service: ItemService | |
| 26 | + | |
| 27 | + @BeforeEach | |
| 28 | + fun setUp() { | |
| 29 | + items = mockk() | |
| 30 | + uoms = mockk() | |
| 31 | + service = ItemService(items, uoms) | |
| 32 | + } | |
| 33 | + | |
| 34 | + @Test | |
| 35 | + fun `create rejects duplicate code`() { | |
| 36 | + every { items.existsByCode("SKU-1") } returns true | |
| 37 | + | |
| 38 | + assertFailure { | |
| 39 | + service.create( | |
| 40 | + CreateItemCommand( | |
| 41 | + code = "SKU-1", | |
| 42 | + name = "x", | |
| 43 | + description = null, | |
| 44 | + itemType = ItemType.GOOD, | |
| 45 | + baseUomCode = "kg", | |
| 46 | + ), | |
| 47 | + ) | |
| 48 | + } | |
| 49 | + .isInstanceOf(IllegalArgumentException::class) | |
| 50 | + .hasMessage("item code 'SKU-1' is already taken") | |
| 51 | + } | |
| 52 | + | |
| 53 | + @Test | |
| 54 | + fun `create rejects unknown base UoM`() { | |
| 55 | + every { items.existsByCode("SKU-2") } returns false | |
| 56 | + every { uoms.existsByCode("foo") } returns false | |
| 57 | + | |
| 58 | + assertFailure { | |
| 59 | + service.create( | |
| 60 | + CreateItemCommand( | |
| 61 | + code = "SKU-2", | |
| 62 | + name = "x", | |
| 63 | + description = null, | |
| 64 | + itemType = ItemType.GOOD, | |
| 65 | + baseUomCode = "foo", | |
| 66 | + ), | |
| 67 | + ) | |
| 68 | + } | |
| 69 | + .isInstanceOf(IllegalArgumentException::class) | |
| 70 | + .hasMessage("base UoM 'foo' is not in the catalog") | |
| 71 | + } | |
| 72 | + | |
| 73 | + @Test | |
| 74 | + fun `create persists on the happy path`() { | |
| 75 | + val saved = slot<Item>() | |
| 76 | + every { items.existsByCode("SKU-3") } returns false | |
| 77 | + every { uoms.existsByCode("kg") } returns true | |
| 78 | + every { items.save(capture(saved)) } answers { saved.captured } | |
| 79 | + | |
| 80 | + val result = service.create( | |
| 81 | + CreateItemCommand( | |
| 82 | + code = "SKU-3", | |
| 83 | + name = "Heavy thing", | |
| 84 | + description = "really heavy", | |
| 85 | + itemType = ItemType.GOOD, | |
| 86 | + baseUomCode = "kg", | |
| 87 | + ), | |
| 88 | + ) | |
| 89 | + | |
| 90 | + assertThat(result.code).isEqualTo("SKU-3") | |
| 91 | + assertThat(result.name).isEqualTo("Heavy thing") | |
| 92 | + assertThat(result.itemType).isEqualTo(ItemType.GOOD) | |
| 93 | + assertThat(result.baseUomCode).isEqualTo("kg") | |
| 94 | + assertThat(result.active).isEqualTo(true) | |
| 95 | + verify(exactly = 1) { items.save(any()) } | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + fun `update never changes code or baseUomCode`() { | |
| 100 | + val id = UUID.randomUUID() | |
| 101 | + val existing = Item( | |
| 102 | + code = "SKU-orig", | |
| 103 | + name = "Old name", | |
| 104 | + description = null, | |
| 105 | + itemType = ItemType.GOOD, | |
| 106 | + baseUomCode = "kg", | |
| 107 | + ).also { it.id = id } | |
| 108 | + every { items.findById(id) } returns Optional.of(existing) | |
| 109 | + | |
| 110 | + val updated = service.update( | |
| 111 | + id, | |
| 112 | + UpdateItemCommand(name = "New name", itemType = ItemType.SERVICE), | |
| 113 | + ) | |
| 114 | + | |
| 115 | + assertThat(updated.name).isEqualTo("New name") | |
| 116 | + assertThat(updated.itemType).isEqualTo(ItemType.SERVICE) | |
| 117 | + assertThat(updated.code).isEqualTo("SKU-orig") // not touched | |
| 118 | + assertThat(updated.baseUomCode).isEqualTo("kg") // not touched | |
| 119 | + } | |
| 120 | + | |
| 121 | + @Test | |
| 122 | + fun `deactivate flips active without deleting`() { | |
| 123 | + val id = UUID.randomUUID() | |
| 124 | + val existing = Item( | |
| 125 | + code = "SKU-x", | |
| 126 | + name = "x", | |
| 127 | + description = null, | |
| 128 | + itemType = ItemType.GOOD, | |
| 129 | + baseUomCode = "kg", | |
| 130 | + active = true, | |
| 131 | + ).also { it.id = id } | |
| 132 | + every { items.findById(id) } returns Optional.of(existing) | |
| 133 | + | |
| 134 | + service.deactivate(id) | |
| 135 | + | |
| 136 | + assertThat(existing.active).isEqualTo(false) | |
| 137 | + } | |
| 138 | + | |
| 139 | + @Test | |
| 140 | + fun `deactivate throws NoSuchElementException when missing`() { | |
| 141 | + val id = UUID.randomUUID() | |
| 142 | + every { items.findById(id) } returns Optional.empty() | |
| 143 | + | |
| 144 | + assertFailure { service.deactivate(id) } | |
| 145 | + .isInstanceOf(NoSuchElementException::class) | |
| 146 | + } | |
| 147 | +} | ... | ... |
pbc/pbc-catalog/src/test/kotlin/org/vibeerp/pbc/catalog/application/UomServiceTest.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.catalog.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.pbc.catalog.domain.Uom | |
| 15 | +import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository | |
| 16 | +import java.util.Optional | |
| 17 | +import java.util.UUID | |
| 18 | + | |
| 19 | +class UomServiceTest { | |
| 20 | + | |
| 21 | + private lateinit var uoms: UomJpaRepository | |
| 22 | + private lateinit var service: UomService | |
| 23 | + | |
| 24 | + @BeforeEach | |
| 25 | + fun setUp() { | |
| 26 | + uoms = mockk() | |
| 27 | + service = UomService(uoms) | |
| 28 | + } | |
| 29 | + | |
| 30 | + @Test | |
| 31 | + fun `create rejects duplicate code`() { | |
| 32 | + every { uoms.existsByCode("kg") } returns true | |
| 33 | + | |
| 34 | + assertFailure { | |
| 35 | + service.create(CreateUomCommand(code = "kg", name = "Kilogram", dimension = "mass")) | |
| 36 | + } | |
| 37 | + .isInstanceOf(IllegalArgumentException::class) | |
| 38 | + .hasMessage("uom code 'kg' is already taken") | |
| 39 | + } | |
| 40 | + | |
| 41 | + @Test | |
| 42 | + fun `create persists when code is free`() { | |
| 43 | + val saved = slot<Uom>() | |
| 44 | + every { uoms.existsByCode("sheet") } returns false | |
| 45 | + every { uoms.save(capture(saved)) } answers { saved.captured } | |
| 46 | + | |
| 47 | + val result = service.create( | |
| 48 | + CreateUomCommand(code = "sheet", name = "Sheet", dimension = "count"), | |
| 49 | + ) | |
| 50 | + | |
| 51 | + assertThat(result.code).isEqualTo("sheet") | |
| 52 | + assertThat(result.name).isEqualTo("Sheet") | |
| 53 | + assertThat(result.dimension).isEqualTo("count") | |
| 54 | + verify(exactly = 1) { uoms.save(any()) } | |
| 55 | + } | |
| 56 | + | |
| 57 | + @Test | |
| 58 | + fun `findByCode returns null when missing`() { | |
| 59 | + every { uoms.findByCode("ghost") } returns null | |
| 60 | + assertThat(service.findByCode("ghost")).isEqualTo(null) | |
| 61 | + } | |
| 62 | + | |
| 63 | + @Test | |
| 64 | + fun `update mutates only the supplied fields and never touches code`() { | |
| 65 | + val id = UUID.randomUUID() | |
| 66 | + val existing = Uom(code = "kg", name = "Kilo", dimension = "mass").also { it.id = id } | |
| 67 | + every { uoms.findById(id) } returns Optional.of(existing) | |
| 68 | + | |
| 69 | + val updated = service.update( | |
| 70 | + id = id, | |
| 71 | + command = UpdateUomCommand(name = "Kilogram"), | |
| 72 | + ) | |
| 73 | + | |
| 74 | + assertThat(updated.name).isEqualTo("Kilogram") | |
| 75 | + assertThat(updated.code).isEqualTo("kg") // untouched | |
| 76 | + assertThat(updated.dimension).isEqualTo("mass") // untouched | |
| 77 | + } | |
| 78 | + | |
| 79 | + @Test | |
| 80 | + fun `update throws NoSuchElementException when missing`() { | |
| 81 | + val id = UUID.randomUUID() | |
| 82 | + every { uoms.findById(id) } returns Optional.empty() | |
| 83 | + | |
| 84 | + assertFailure { service.update(id, UpdateUomCommand(name = "x")) } | |
| 85 | + .isInstanceOf(NoSuchElementException::class) | |
| 86 | + } | |
| 87 | +} | ... | ... |
settings.gradle.kts
| ... | ... | @@ -37,6 +37,9 @@ project(":platform:platform-security").projectDir = file("platform/platform-secu |
| 37 | 37 | include(":pbc:pbc-identity") |
| 38 | 38 | project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") |
| 39 | 39 | |
| 40 | +include(":pbc:pbc-catalog") | |
| 41 | +project(":pbc:pbc-catalog").projectDir = file("pbc/pbc-catalog") | |
| 42 | + | |
| 40 | 43 | // ─── Reference customer plug-in (NOT loaded by default) ───────────── |
| 41 | 44 | include(":reference-customer:plugin-printing-shop") |
| 42 | 45 | project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop") | ... | ... |