diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/catalog/CatalogApi.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/catalog/CatalogApi.kt new file mode 100644 index 0000000..3d19949 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/catalog/CatalogApi.kt @@ -0,0 +1,74 @@ +package org.vibeerp.api.v1.ext.catalog + +import org.vibeerp.api.v1.core.Id + +/** + * Cross-PBC facade for the catalog bounded context. + * + * The second `api.v1.ext.*` package after `ext.identity`. Together they + * are the proof that PBCs talk to each other through `api.v1`, not + * directly. Guardrail #9 ("PBC boundaries are sacred") is enforced by + * the Gradle build, which forbids `pbc-orders-sales` from declaring + * `pbc-catalog` as a dependency. The only way other code can ask + * "what is item X" or "what UoM is this code" is by injecting + * [CatalogApi] — an interface declared here, in api.v1, and implemented + * by `pbc-catalog` at runtime. + * + * If [CatalogApi] grows a method that leaks catalog-internal types, the + * abstraction has failed and the method must be reshaped. The whole + * point of this package is to expose intent ([ItemRef], [UomRef]) + * without exposing the storage model (`Item`, `Uom` JPA entities). + * + * Lookups are by **code**, not by id. Codes are stable, human-readable, + * and the natural key plug-ins use to refer to catalog rows from their + * own metadata files. Adding id-based lookups later is non-breaking; + * removing the code-based ones would be. + */ +interface CatalogApi { + + /** + * Look up an item by its unique catalog code (e.g. "SKU-12345", + * "INK-CMYK-CYAN", "PRESS-OPER-HOUR"). Returns `null` when no such + * item exists or when the item is inactive — the framework treats + * inactive items as "do not use" for downstream callers. + */ + fun findItemByCode(code: String): ItemRef? + + /** + * Look up a unit of measure by its code (e.g. "kg", "m", "sheet"). + * Returns `null` when no such unit is registered. + */ + fun findUomByCode(code: String): UomRef? +} + +/** + * Minimal, safe-to-publish view of a catalog item. + * + * Notably absent: pricing, vendor relationships, anything from the + * future `catalog__item_attribute` table. Anything that does not appear + * here cannot be observed cross-PBC, and that is intentional — adding + * a field is a deliberate api.v1 change with a major-version cost. + */ +data class ItemRef( + val id: Id, + val code: String, + val name: String, + val itemType: String, // GOOD | SERVICE | DIGITAL — string for plug-in compatibility + val baseUomCode: String, // references a UomRef.code + val active: Boolean, +) + +/** + * Minimal, safe-to-publish view of a unit of measure. + * + * The `dimension` field is a free-form string ("mass", "length", …) + * because the closed set of dimensions has not been finalised in v0.4. + * Plug-ins that care about the closed set should defer that check until + * the framework promotes it to a typed enum. + */ +data class UomRef( + val id: Id, + val code: String, + val name: String, + val dimension: String, +) diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts index 133aba5..685ab04 100644 --- a/distribution/build.gradle.kts +++ b/distribution/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(project(":platform:platform-plugins")) implementation(project(":platform:platform-security")) implementation(project(":pbc:pbc-identity")) + implementation(project(":pbc:pbc-catalog")) implementation(libs.spring.boot.starter) implementation(libs.spring.boot.starter.web) diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml index 8d8261d..01a124d 100644 --- a/distribution/src/main/resources/db/changelog/master.xml +++ b/distribution/src/main/resources/db/changelog/master.xml @@ -13,4 +13,5 @@ + diff --git a/distribution/src/main/resources/db/changelog/pbc-catalog/001-catalog-init.xml b/distribution/src/main/resources/db/changelog/pbc-catalog/001-catalog-init.xml new file mode 100644 index 0000000..9c001b6 --- /dev/null +++ b/distribution/src/main/resources/db/changelog/pbc-catalog/001-catalog-init.xml @@ -0,0 +1,104 @@ + + + + + + + Create catalog__uom table + + CREATE TABLE catalog__uom ( + id uuid PRIMARY KEY, + code varchar(16) NOT NULL, + name varchar(128) NOT NULL, + dimension varchar(32) NOT NULL, + ext jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL, + created_by varchar(128) NOT NULL, + updated_at timestamptz NOT NULL, + updated_by varchar(128) NOT NULL, + version bigint NOT NULL DEFAULT 0 + ); + CREATE UNIQUE INDEX catalog__uom_code_uk ON catalog__uom (code); + CREATE INDEX catalog__uom_ext_gin ON catalog__uom USING GIN (ext jsonb_path_ops); + + + DROP TABLE catalog__uom; + + + + + Create catalog__item table (FK to catalog__uom by code) + + CREATE TABLE catalog__item ( + id uuid PRIMARY KEY, + code varchar(64) NOT NULL, + name varchar(256) NOT NULL, + description text, + item_type varchar(32) NOT NULL, + base_uom_code varchar(16) NOT NULL REFERENCES catalog__uom(code), + active boolean NOT NULL DEFAULT true, + ext jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL, + created_by varchar(128) NOT NULL, + updated_at timestamptz NOT NULL, + updated_by varchar(128) NOT NULL, + version bigint NOT NULL DEFAULT 0 + ); + CREATE UNIQUE INDEX catalog__item_code_uk ON catalog__item (code); + CREATE INDEX catalog__item_ext_gin ON catalog__item USING GIN (ext jsonb_path_ops); + CREATE INDEX catalog__item_active_idx ON catalog__item (active); + + + DROP TABLE catalog__item; + + + + + + Seed canonical UoMs + + INSERT INTO catalog__uom (id, code, name, dimension, created_at, created_by, updated_at, updated_by) VALUES + (gen_random_uuid(), 'kg', 'Kilogram', 'mass', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'g', 'Gram', 'mass', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 't', 'Tonne', 'mass', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'm', 'Metre', 'length', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'cm', 'Centimetre','length', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'mm', 'Millimetre','length', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'km', 'Kilometre', 'length', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'm2', 'Square metre','area', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'l', 'Litre', 'volume', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'ml', 'Millilitre','volume', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'ea', 'Each', 'count', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'sheet', 'Sheet', 'count', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'pack', 'Pack', 'count', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'h', 'Hour', 'time', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), 'min', 'Minute', 'time', now(), '__seed__', now(), '__seed__'); + + + DELETE FROM catalog__uom WHERE created_by = '__seed__'; + + + + diff --git a/pbc/pbc-catalog/build.gradle.kts b/pbc/pbc-catalog/build.gradle.kts new file mode 100644 index 0000000..d1d180a --- /dev/null +++ b/pbc/pbc-catalog/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.kotlin.jpa) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp pbc-catalog — items, units of measure, attributes. INTERNAL Packaged Business Capability." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +allOpen { + annotation("jakarta.persistence.Entity") + annotation("jakarta.persistence.MappedSuperclass") + annotation("jakarta.persistence.Embeddable") +} + +// CRITICAL: pbc-catalog may depend on api-v1, platform-persistence and +// platform-security but NEVER on platform-bootstrap, NEVER on another +// pbc-*. The root build.gradle.kts enforces this; if you add a +// `:pbc:pbc-foo` or `:platform:platform-bootstrap` dependency below, +// the build will refuse to load with an architectural-violation error. +dependencies { + api(project(":api:api-v1")) + implementation(project(":platform:platform-persistence")) + implementation(project(":platform:platform-security")) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + + implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.data.jpa) + implementation(libs.spring.boot.starter.validation) + implementation(libs.jackson.module.kotlin) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) + testImplementation(libs.mockk) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/application/ItemService.kt b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/application/ItemService.kt new file mode 100644 index 0000000..c739b0f --- /dev/null +++ b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/application/ItemService.kt @@ -0,0 +1,99 @@ +package org.vibeerp.pbc.catalog.application + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.pbc.catalog.domain.Item +import org.vibeerp.pbc.catalog.domain.ItemType +import org.vibeerp.pbc.catalog.infrastructure.ItemJpaRepository +import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository +import java.util.UUID + +/** + * Application service for item CRUD. + * + * Item is the first PBC entity that has a foreign key to another row in + * the same PBC (Item → Uom by code). The validation of "the referenced + * UoM actually exists" lives here in application code, not as a database + * constraint, even though there IS a database FK on `base_uom_code` — + * the DB constraint is the second wall, this check produces a much + * better error message. + * + * Cross-PBC references (which Item will eventually grow: vendor from + * pbc-partners, default warehouse from pbc-inventory, …) go through + * `api.v1.ext.` facades, NOT direct dependencies. This is the rule + * the Gradle build enforces and the discipline the framework lives or + * dies by. + */ +@Service +@Transactional +class ItemService( + private val items: ItemJpaRepository, + private val uoms: UomJpaRepository, +) { + + @Transactional(readOnly = true) + fun list(): List = items.findAll() + + @Transactional(readOnly = true) + fun findById(id: UUID): Item? = items.findById(id).orElse(null) + + @Transactional(readOnly = true) + fun findByCode(code: String): Item? = items.findByCode(code) + + fun create(command: CreateItemCommand): Item { + require(!items.existsByCode(command.code)) { + "item code '${command.code}' is already taken" + } + require(uoms.existsByCode(command.baseUomCode)) { + "base UoM '${command.baseUomCode}' is not in the catalog" + } + return items.save( + Item( + code = command.code, + name = command.name, + description = command.description, + itemType = command.itemType, + baseUomCode = command.baseUomCode, + active = command.active, + ), + ) + } + + fun update(id: UUID, command: UpdateItemCommand): Item { + val item = items.findById(id).orElseThrow { + NoSuchElementException("item not found: $id") + } + // `code` and `baseUomCode` are deliberately not updatable here: + // the first because every external reference uses code, the + // second because changing units of measure on an existing item + // is a data-migration operation, not an edit. + command.name?.let { item.name = it } + command.description?.let { item.description = it } + command.itemType?.let { item.itemType = it } + command.active?.let { item.active = it } + return item + } + + fun deactivate(id: UUID) { + val item = items.findById(id).orElseThrow { + NoSuchElementException("item not found: $id") + } + item.active = false + } +} + +data class CreateItemCommand( + val code: String, + val name: String, + val description: String?, + val itemType: ItemType, + val baseUomCode: String, + val active: Boolean = true, +) + +data class UpdateItemCommand( + val name: String? = null, + val description: String? = null, + val itemType: ItemType? = null, + val active: Boolean? = null, +) diff --git a/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/application/UomService.kt b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/application/UomService.kt new file mode 100644 index 0000000..98b8834 --- /dev/null +++ b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/application/UomService.kt @@ -0,0 +1,76 @@ +package org.vibeerp.pbc.catalog.application + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.pbc.catalog.domain.Uom +import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository +import java.util.UUID + +/** + * Application service for unit-of-measure CRUD. + * + * Mirrors the shape of `pbc-identity`'s `UserService` so that all PBCs + * follow the same recipe and a future code generator (or human reader) + * can navigate them by analogy. Boundaries: + * + * • Controllers MUST go through this service. They never inject the + * repository directly. + * • The service is the only place that holds a transaction boundary. + * • Validation that involves cross-row state (e.g. "code is unique") + * lives here, not in the entity. Validation that involves a single + * field (length, format) lives on the controller's request DTO via + * `jakarta.validation` annotations. + */ +@Service +@Transactional +class UomService( + private val uoms: UomJpaRepository, +) { + + @Transactional(readOnly = true) + fun list(): List = uoms.findAll() + + @Transactional(readOnly = true) + fun findById(id: UUID): Uom? = uoms.findById(id).orElse(null) + + @Transactional(readOnly = true) + fun findByCode(code: String): Uom? = uoms.findByCode(code) + + fun create(command: CreateUomCommand): Uom { + require(!uoms.existsByCode(command.code)) { + "uom code '${command.code}' is already taken" + } + return uoms.save( + Uom( + code = command.code, + name = command.name, + dimension = command.dimension, + ), + ) + } + + fun update(id: UUID, command: UpdateUomCommand): Uom { + val uom = uoms.findById(id).orElseThrow { + NoSuchElementException("uom not found: $id") + } + // `code` is intentionally NOT updatable: changing a code would + // invalidate every Item row that references it (FK is by code, + // not by id) and every saved metadata row that mentions it. + // Operators who really need a different code create a new UoM + // and migrate items. + command.name?.let { uom.name = it } + command.dimension?.let { uom.dimension = it } + return uom + } +} + +data class CreateUomCommand( + val code: String, + val name: String, + val dimension: String, +) + +data class UpdateUomCommand( + val name: String? = null, + val dimension: String? = null, +) diff --git a/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/domain/Item.kt b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/domain/Item.kt new file mode 100644 index 0000000..a366435 --- /dev/null +++ b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/domain/Item.kt @@ -0,0 +1,88 @@ +package org.vibeerp.pbc.catalog.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Table +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity + +/** + * The canonical Item entity for the catalog PBC. + * + * An "item" is anything a printing shop (or any vibe_erp customer) might + * buy, sell, store, produce, or invoice for: a physical good, a service, + * a digital asset. Item is the foundation that orders, inventory, and + * production all reference; it lands first because everything else + * depends on it being there. + * + * **Why `base_uom_code` is a varchar FK and not a UUID:** + * UoM codes are stable, human-readable, and how every other PBC and every + * plug-in addresses units. Modeling the FK as `varchar` makes seed data + * trivial (you can write `base_uom_code = 'kg'` in a YAML), keeps the + * Hibernate-generated SQL readable, and lets you eyeball the database + * directly. The cost is that renaming a UoM code is forbidden — and that + * is intentional (see `UomService.update`). + * + * **Why `ext` JSONB instead of a lot of typed columns:** + * Most printing shops will want extra fields on Item — colour, weight, + * supplier SKU, stock-keeping rules — that vary per customer. The metadata + * store + JSONB pattern lets a key user add those fields through the UI + * without a migration. The handful of fields above are the ones EVERY + * customer needs. + */ +@Entity +@Table(name = "catalog__item") +class Item( + code: String, + name: String, + description: String?, + itemType: ItemType, + baseUomCode: String, + active: Boolean = true, +) : AuditedJpaEntity() { + + @Column(name = "code", nullable = false, length = 64) + var code: String = code + + @Column(name = "name", nullable = false, length = 256) + var name: String = name + + @Column(name = "description", nullable = true) + var description: String? = description + + @Enumerated(EnumType.STRING) + @Column(name = "item_type", nullable = false, length = 32) + var itemType: ItemType = itemType + + @Column(name = "base_uom_code", nullable = false, length = 16) + var baseUomCode: String = baseUomCode + + @Column(name = "active", nullable = false) + var active: Boolean = active + + @Column(name = "ext", nullable = false, columnDefinition = "jsonb") + @JdbcTypeCode(SqlTypes.JSON) + var ext: String = "{}" + + override fun toString(): String = + "Item(id=$id, code='$code', type=$itemType, baseUom='$baseUomCode')" +} + +/** + * The kind of thing an [Item] represents. + * + * Stored as a string in the DB so adding values later is non-breaking + * for clients reading the column with raw SQL. Plug-ins that need a + * narrower notion of "type" should add their own discriminator field via + * the metadata custom-field mechanism, not by extending this enum — + * extending the enum would force every plug-in's compile classpath to + * carry the new value. + */ +enum class ItemType { + GOOD, // physical thing you can hold and ship + SERVICE, // labour, time, intangible work + DIGITAL, // file delivered electronically +} diff --git a/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/domain/Uom.kt b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/domain/Uom.kt new file mode 100644 index 0000000..a577722 --- /dev/null +++ b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/domain/Uom.kt @@ -0,0 +1,55 @@ +package org.vibeerp.pbc.catalog.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity + +/** + * Unit of measure as stored in `catalog__uom`. + * + * Why a separate entity from [org.vibeerp.api.v1.core.UnitOfMeasure]: + * - The api.v1 type is the **wire form** — a value class wrapping just + * `code`. It carries no display name and no dimension because plug-ins + * that pass quantities around don't need them and shouldn't depend on + * the catalog implementation. + * - This [Uom] type is the **storage form** — the row in the catalog + * PBC's database table, with name, dimension, and `ext` for custom + * fields. PBC code that lives in `pbc-catalog` uses this directly. + * + * Conversions between units (e.g. 1000 g = 1 kg) are NOT modeled here in + * v0.4. They land later when a real customer needs them; until then, + * arithmetic is restricted to "same code only" by api.v1's [Quantity] + * type. + */ +@Entity +@Table(name = "catalog__uom") +class Uom( + code: String, + name: String, + dimension: String, +) : AuditedJpaEntity() { + + @Column(name = "code", nullable = false, length = 16) + var code: String = code + + @Column(name = "name", nullable = false, length = 128) + var name: String = name + + /** + * Logical dimension this unit measures: `mass`, `length`, `area`, + * `volume`, `count`, `time`, `currency`. Used by future logic that + * wants to refuse "add 5 kg to 5 m" at the catalog layer rather than + * waiting for the [Quantity] arithmetic check. + */ + @Column(name = "dimension", nullable = false, length = 32) + var dimension: String = dimension + + @Column(name = "ext", nullable = false, columnDefinition = "jsonb") + @JdbcTypeCode(SqlTypes.JSON) + var ext: String = "{}" + + override fun toString(): String = "Uom(id=$id, code='$code', dimension='$dimension')" +} diff --git a/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/ext/CatalogApiAdapter.kt b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/ext/CatalogApiAdapter.kt new file mode 100644 index 0000000..5d112b1 --- /dev/null +++ b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/ext/CatalogApiAdapter.kt @@ -0,0 +1,62 @@ +package org.vibeerp.pbc.catalog.ext + +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.ext.catalog.CatalogApi +import org.vibeerp.api.v1.ext.catalog.ItemRef +import org.vibeerp.api.v1.ext.catalog.UomRef +import org.vibeerp.pbc.catalog.domain.Item +import org.vibeerp.pbc.catalog.domain.Uom +import org.vibeerp.pbc.catalog.infrastructure.ItemJpaRepository +import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository + +/** + * Concrete [CatalogApi] implementation. The ONLY thing other PBCs and + * plug-ins are allowed to inject for "give me a catalog reference". + * + * Why this file exists: see the rationale on [CatalogApi]. The Gradle + * build forbids cross-PBC dependencies; this adapter is the runtime + * fulfillment of the api.v1 contract that lets the rest of the world + * ask catalog questions without importing catalog types. + * + * **What this adapter MUST NOT do:** + * • leak `org.vibeerp.pbc.catalog.domain.Item` or `Uom` to callers + * • return inactive items (the contract says inactive → null) + * • bypass the active filter on findItemByCode + * + * If a caller needs more fields, the answer is to grow `ItemRef`/`UomRef` + * deliberately (an api.v1 change with a major-version cost), not to + * hand them the entity. + */ +@Component +@Transactional(readOnly = true) +class CatalogApiAdapter( + private val items: ItemJpaRepository, + private val uoms: UomJpaRepository, +) : CatalogApi { + + override fun findItemByCode(code: String): ItemRef? = + items.findByCode(code) + ?.takeIf { it.active } + ?.toRef() + + override fun findUomByCode(code: String): UomRef? = + uoms.findByCode(code)?.toRef() + + private fun Item.toRef(): ItemRef = ItemRef( + id = Id(this.id), + code = this.code, + name = this.name, + itemType = this.itemType.name, + baseUomCode = this.baseUomCode, + active = this.active, + ) + + private fun Uom.toRef(): UomRef = UomRef( + id = Id(this.id), + code = this.code, + name = this.name, + dimension = this.dimension, + ) +} diff --git a/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/http/ItemController.kt b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/http/ItemController.kt new file mode 100644 index 0000000..224116c --- /dev/null +++ b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/http/ItemController.kt @@ -0,0 +1,130 @@ +package org.vibeerp.pbc.catalog.http + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.vibeerp.pbc.catalog.application.CreateItemCommand +import org.vibeerp.pbc.catalog.application.ItemService +import org.vibeerp.pbc.catalog.application.UpdateItemCommand +import org.vibeerp.pbc.catalog.domain.Item +import org.vibeerp.pbc.catalog.domain.ItemType +import java.util.UUID + +/** + * REST API for the catalog PBC's Item aggregate. + * + * Mounted at `/api/v1/catalog/items`. Authenticated; the framework's + * Spring Security filter chain rejects anonymous requests with 401. + * + * The DTO layer (request/response) is intentionally separate from the + * JPA entity. This is the rule that lets the entity's storage shape + * evolve (rename a column, denormalize a field, drop something) without + * breaking API consumers — including the OpenAPI spec, the React SPA, + * AI agents using the MCP server, and third-party integrations. + */ +@RestController +@RequestMapping("/api/v1/catalog/items") +class ItemController( + private val itemService: ItemService, +) { + + @GetMapping + fun list(): List = + itemService.list().map { it.toResponse() } + + @GetMapping("/{id}") + fun get(@PathVariable id: UUID): ResponseEntity { + val item = itemService.findById(id) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(item.toResponse()) + } + + @GetMapping("/by-code/{code}") + fun getByCode(@PathVariable code: String): ResponseEntity { + val item = itemService.findByCode(code) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(item.toResponse()) + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun create(@RequestBody @Valid request: CreateItemRequest): ItemResponse = + itemService.create( + CreateItemCommand( + code = request.code, + name = request.name, + description = request.description, + itemType = request.itemType, + baseUomCode = request.baseUomCode, + active = request.active ?: true, + ), + ).toResponse() + + @PatchMapping("/{id}") + fun update( + @PathVariable id: UUID, + @RequestBody @Valid request: UpdateItemRequest, + ): ItemResponse = + itemService.update( + id, + UpdateItemCommand( + name = request.name, + description = request.description, + itemType = request.itemType, + active = request.active, + ), + ).toResponse() + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deactivate(@PathVariable id: UUID) { + itemService.deactivate(id) + } +} + +// ─── DTOs ──────────────────────────────────────────────────────────── + +data class CreateItemRequest( + @field:NotBlank @field:Size(max = 64) val code: String, + @field:NotBlank @field:Size(max = 256) val name: String, + val description: String?, + val itemType: ItemType, + @field:NotBlank @field:Size(max = 16) val baseUomCode: String, + val active: Boolean? = true, +) + +data class UpdateItemRequest( + @field:Size(max = 256) val name: String? = null, + val description: String? = null, + val itemType: ItemType? = null, + val active: Boolean? = null, +) + +data class ItemResponse( + val id: UUID, + val code: String, + val name: String, + val description: String?, + val itemType: ItemType, + val baseUomCode: String, + val active: Boolean, +) + +private fun Item.toResponse() = ItemResponse( + id = this.id, + code = this.code, + name = this.name, + description = this.description, + itemType = this.itemType, + baseUomCode = this.baseUomCode, + active = this.active, +) diff --git a/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/http/UomController.kt b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/http/UomController.kt new file mode 100644 index 0000000..27b10cc --- /dev/null +++ b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/http/UomController.kt @@ -0,0 +1,103 @@ +package org.vibeerp.pbc.catalog.http + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.vibeerp.pbc.catalog.application.CreateUomCommand +import org.vibeerp.pbc.catalog.application.UomService +import org.vibeerp.pbc.catalog.application.UpdateUomCommand +import org.vibeerp.pbc.catalog.domain.Uom +import java.util.UUID + +/** + * REST API for the catalog PBC's UoM aggregate. + * + * Mounted at `/api/v1/catalog/uoms` following the convention + * `/api/v1//` so each PBC namespaces naturally and + * plug-in endpoints (which mount under `/api/v1/plugins/...`) cannot + * collide with core endpoints. + * + * Authentication is required (Spring Security's filter chain rejects + * anonymous requests with 401). The principal is bound to the audit + * listener so created_by/updated_by carry the real user id. + */ +@RestController +@RequestMapping("/api/v1/catalog/uoms") +class UomController( + private val uomService: UomService, +) { + + @GetMapping + fun list(): List = + uomService.list().map { it.toResponse() } + + @GetMapping("/{id}") + fun get(@PathVariable id: UUID): ResponseEntity { + val uom = uomService.findById(id) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(uom.toResponse()) + } + + @GetMapping("/by-code/{code}") + fun getByCode(@PathVariable code: String): ResponseEntity { + val uom = uomService.findByCode(code) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(uom.toResponse()) + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun create(@RequestBody @Valid request: CreateUomRequest): UomResponse = + uomService.create( + CreateUomCommand( + code = request.code, + name = request.name, + dimension = request.dimension, + ), + ).toResponse() + + @PatchMapping("/{id}") + fun update( + @PathVariable id: UUID, + @RequestBody @Valid request: UpdateUomRequest, + ): UomResponse = + uomService.update( + id, + UpdateUomCommand(name = request.name, dimension = request.dimension), + ).toResponse() +} + +// ─── DTOs ──────────────────────────────────────────────────────────── + +data class CreateUomRequest( + @field:NotBlank @field:Size(max = 16) val code: String, + @field:NotBlank @field:Size(max = 128) val name: String, + @field:NotBlank @field:Size(max = 32) val dimension: String, +) + +data class UpdateUomRequest( + @field:Size(max = 128) val name: String? = null, + @field:Size(max = 32) val dimension: String? = null, +) + +data class UomResponse( + val id: UUID, + val code: String, + val name: String, + val dimension: String, +) + +private fun Uom.toResponse() = UomResponse( + id = this.id, + code = this.code, + name = this.name, + dimension = this.dimension, +) diff --git a/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/infrastructure/ItemJpaRepository.kt b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/infrastructure/ItemJpaRepository.kt new file mode 100644 index 0000000..5f49e70 --- /dev/null +++ b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/infrastructure/ItemJpaRepository.kt @@ -0,0 +1,22 @@ +package org.vibeerp.pbc.catalog.infrastructure + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.vibeerp.pbc.catalog.domain.Item +import java.util.UUID + +/** + * Spring Data JPA repository for [Item]. + * + * Like [UomJpaRepository], lookups are predominantly by `code` because + * item codes are stable, human-meaningful, and the natural key every + * other PBC and every plug-in uses to reference an item ("SKU-12345" + * is more meaningful than a UUID). + */ +@Repository +interface ItemJpaRepository : JpaRepository { + + fun findByCode(code: String): Item? + + fun existsByCode(code: String): Boolean +} diff --git a/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/infrastructure/UomJpaRepository.kt b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/infrastructure/UomJpaRepository.kt new file mode 100644 index 0000000..28f4788 --- /dev/null +++ b/pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/infrastructure/UomJpaRepository.kt @@ -0,0 +1,23 @@ +package org.vibeerp.pbc.catalog.infrastructure + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.vibeerp.pbc.catalog.domain.Uom +import java.util.UUID + +/** + * Spring Data JPA repository for [Uom]. + * + * Lookups are predominantly **by code** because UoM codes are stable and + * human-meaningful (e.g. "kg", "m", "ea") and that is how every other + * PBC and every plug-in references units. The unique constraint on + * `catalog__uom.code` lives in the Liquibase changeset and is the source + * of truth for "no two UoMs share a code". + */ +@Repository +interface UomJpaRepository : JpaRepository { + + fun findByCode(code: String): Uom? + + fun existsByCode(code: String): Boolean +} diff --git a/pbc/pbc-catalog/src/test/kotlin/org/vibeerp/pbc/catalog/application/ItemServiceTest.kt b/pbc/pbc-catalog/src/test/kotlin/org/vibeerp/pbc/catalog/application/ItemServiceTest.kt new file mode 100644 index 0000000..01b258f --- /dev/null +++ b/pbc/pbc-catalog/src/test/kotlin/org/vibeerp/pbc/catalog/application/ItemServiceTest.kt @@ -0,0 +1,147 @@ +package org.vibeerp.pbc.catalog.application + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.vibeerp.pbc.catalog.domain.Item +import org.vibeerp.pbc.catalog.domain.ItemType +import org.vibeerp.pbc.catalog.infrastructure.ItemJpaRepository +import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository +import java.util.Optional +import java.util.UUID + +class ItemServiceTest { + + private lateinit var items: ItemJpaRepository + private lateinit var uoms: UomJpaRepository + private lateinit var service: ItemService + + @BeforeEach + fun setUp() { + items = mockk() + uoms = mockk() + service = ItemService(items, uoms) + } + + @Test + fun `create rejects duplicate code`() { + every { items.existsByCode("SKU-1") } returns true + + assertFailure { + service.create( + CreateItemCommand( + code = "SKU-1", + name = "x", + description = null, + itemType = ItemType.GOOD, + baseUomCode = "kg", + ), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("item code 'SKU-1' is already taken") + } + + @Test + fun `create rejects unknown base UoM`() { + every { items.existsByCode("SKU-2") } returns false + every { uoms.existsByCode("foo") } returns false + + assertFailure { + service.create( + CreateItemCommand( + code = "SKU-2", + name = "x", + description = null, + itemType = ItemType.GOOD, + baseUomCode = "foo", + ), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("base UoM 'foo' is not in the catalog") + } + + @Test + fun `create persists on the happy path`() { + val saved = slot() + every { items.existsByCode("SKU-3") } returns false + every { uoms.existsByCode("kg") } returns true + every { items.save(capture(saved)) } answers { saved.captured } + + val result = service.create( + CreateItemCommand( + code = "SKU-3", + name = "Heavy thing", + description = "really heavy", + itemType = ItemType.GOOD, + baseUomCode = "kg", + ), + ) + + assertThat(result.code).isEqualTo("SKU-3") + assertThat(result.name).isEqualTo("Heavy thing") + assertThat(result.itemType).isEqualTo(ItemType.GOOD) + assertThat(result.baseUomCode).isEqualTo("kg") + assertThat(result.active).isEqualTo(true) + verify(exactly = 1) { items.save(any()) } + } + + @Test + fun `update never changes code or baseUomCode`() { + val id = UUID.randomUUID() + val existing = Item( + code = "SKU-orig", + name = "Old name", + description = null, + itemType = ItemType.GOOD, + baseUomCode = "kg", + ).also { it.id = id } + every { items.findById(id) } returns Optional.of(existing) + + val updated = service.update( + id, + UpdateItemCommand(name = "New name", itemType = ItemType.SERVICE), + ) + + assertThat(updated.name).isEqualTo("New name") + assertThat(updated.itemType).isEqualTo(ItemType.SERVICE) + assertThat(updated.code).isEqualTo("SKU-orig") // not touched + assertThat(updated.baseUomCode).isEqualTo("kg") // not touched + } + + @Test + fun `deactivate flips active without deleting`() { + val id = UUID.randomUUID() + val existing = Item( + code = "SKU-x", + name = "x", + description = null, + itemType = ItemType.GOOD, + baseUomCode = "kg", + active = true, + ).also { it.id = id } + every { items.findById(id) } returns Optional.of(existing) + + service.deactivate(id) + + assertThat(existing.active).isEqualTo(false) + } + + @Test + fun `deactivate throws NoSuchElementException when missing`() { + val id = UUID.randomUUID() + every { items.findById(id) } returns Optional.empty() + + assertFailure { service.deactivate(id) } + .isInstanceOf(NoSuchElementException::class) + } +} diff --git a/pbc/pbc-catalog/src/test/kotlin/org/vibeerp/pbc/catalog/application/UomServiceTest.kt b/pbc/pbc-catalog/src/test/kotlin/org/vibeerp/pbc/catalog/application/UomServiceTest.kt new file mode 100644 index 0000000..2b2ecba --- /dev/null +++ b/pbc/pbc-catalog/src/test/kotlin/org/vibeerp/pbc/catalog/application/UomServiceTest.kt @@ -0,0 +1,87 @@ +package org.vibeerp.pbc.catalog.application + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.vibeerp.pbc.catalog.domain.Uom +import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository +import java.util.Optional +import java.util.UUID + +class UomServiceTest { + + private lateinit var uoms: UomJpaRepository + private lateinit var service: UomService + + @BeforeEach + fun setUp() { + uoms = mockk() + service = UomService(uoms) + } + + @Test + fun `create rejects duplicate code`() { + every { uoms.existsByCode("kg") } returns true + + assertFailure { + service.create(CreateUomCommand(code = "kg", name = "Kilogram", dimension = "mass")) + } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("uom code 'kg' is already taken") + } + + @Test + fun `create persists when code is free`() { + val saved = slot() + every { uoms.existsByCode("sheet") } returns false + every { uoms.save(capture(saved)) } answers { saved.captured } + + val result = service.create( + CreateUomCommand(code = "sheet", name = "Sheet", dimension = "count"), + ) + + assertThat(result.code).isEqualTo("sheet") + assertThat(result.name).isEqualTo("Sheet") + assertThat(result.dimension).isEqualTo("count") + verify(exactly = 1) { uoms.save(any()) } + } + + @Test + fun `findByCode returns null when missing`() { + every { uoms.findByCode("ghost") } returns null + assertThat(service.findByCode("ghost")).isEqualTo(null) + } + + @Test + fun `update mutates only the supplied fields and never touches code`() { + val id = UUID.randomUUID() + val existing = Uom(code = "kg", name = "Kilo", dimension = "mass").also { it.id = id } + every { uoms.findById(id) } returns Optional.of(existing) + + val updated = service.update( + id = id, + command = UpdateUomCommand(name = "Kilogram"), + ) + + assertThat(updated.name).isEqualTo("Kilogram") + assertThat(updated.code).isEqualTo("kg") // untouched + assertThat(updated.dimension).isEqualTo("mass") // untouched + } + + @Test + fun `update throws NoSuchElementException when missing`() { + val id = UUID.randomUUID() + every { uoms.findById(id) } returns Optional.empty() + + assertFailure { service.update(id, UpdateUomCommand(name = "x")) } + .isInstanceOf(NoSuchElementException::class) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1ef8bde..6fcee16 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,9 @@ project(":platform:platform-security").projectDir = file("platform/platform-secu include(":pbc:pbc-identity") project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") +include(":pbc:pbc-catalog") +project(":pbc:pbc-catalog").projectDir = file("pbc/pbc-catalog") + // ─── Reference customer plug-in (NOT loaded by default) ───────────── include(":reference-customer:plugin-printing-shop") project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop")