Commit 69f3daa92537723d1cd87fa4b3b49efb9a1dc1fb

Authored by vibe_erp
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.
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,6 +24,7 @@ dependencies {
24 implementation(project(":platform:platform-plugins")) 24 implementation(project(":platform:platform-plugins"))
25 implementation(project(":platform:platform-security")) 25 implementation(project(":platform:platform-security"))
26 implementation(project(":pbc:pbc-identity")) 26 implementation(project(":pbc:pbc-identity"))
  27 + implementation(project(":pbc:pbc-catalog"))
27 28
28 implementation(libs.spring.boot.starter) 29 implementation(libs.spring.boot.starter)
29 implementation(libs.spring.boot.starter.web) 30 implementation(libs.spring.boot.starter.web)
distribution/src/main/resources/db/changelog/master.xml
@@ -13,4 +13,5 @@ @@ -13,4 +13,5 @@
13 <include file="classpath:db/changelog/platform/000-platform-init.xml"/> 13 <include file="classpath:db/changelog/platform/000-platform-init.xml"/>
14 <include file="classpath:db/changelog/pbc-identity/001-identity-init.xml"/> 14 <include file="classpath:db/changelog/pbc-identity/001-identity-init.xml"/>
15 <include file="classpath:db/changelog/pbc-identity/002-identity-credential.xml"/> 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 </databaseChangeLog> 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(&quot;:platform:platform-security&quot;).projectDir = file(&quot;platform/platform-secu @@ -37,6 +37,9 @@ project(&quot;:platform:platform-security&quot;).projectDir = file(&quot;platform/platform-secu
37 include(":pbc:pbc-identity") 37 include(":pbc:pbc-identity")
38 project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") 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 // ─── Reference customer plug-in (NOT loaded by default) ───────────── 43 // ─── Reference customer plug-in (NOT loaded by default) ─────────────
41 include(":reference-customer:plugin-printing-shop") 44 include(":reference-customer:plugin-printing-shop")
42 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop") 45 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop")