Commit 98d7032b56b688b3e7903a98ca508b87da34253d

Authored by zichun
1 parent bdb84752

feat(catalog): wire Item into HasExt pattern + restore plug-in Item fields

Completes the HasExt rollout across every core entity with an ext
column. Item was the last one that carried an ext JSONB column
without any validation wired — a plug-in could declare custom fields
for Item but nothing would enforce them on save. This fixes that and
restores two printing-shop-specific Item fields to the reference
plug-in that were temporarily dropped from the previous Tier 1
customization chunk (commit 16c59310) precisely because Item wasn't
wired.

Code changes:
  - Item implements HasExt; `ext` becomes `override var ext`, a
    companion constant holds the entity name "Item".
  - ItemService injects ExtJsonValidator, calls applyTo() in both
    create() and update() (create + update symmetry like partners
    and locations). parseExt passthrough added for response mappers.
  - CreateItemCommand, UpdateItemCommand, CreateItemRequest,
    UpdateItemRequest gain a nullable ext field.
  - ItemResponse now carries the parsed ext map, same shape as
    PartnerResponse / LocationResponse / SalesOrderResponse.
  - pbc-catalog build.gradle adds
    `implementation(project(":platform:platform-metadata"))`.
  - ItemServiceTest constructor updated to pass the new validator
    dependency with no-op stubs.

Plug-in YAML (printing-shop.yml):
  - Re-added `printing_shop_color_count` (integer) and
    `printing_shop_paper_gsm` (integer) custom fields targeting Item.
    These were originally in the commit 16c59310 draft but removed
    because Item wasn't wired. Now that Item is wired, they're back
    and actually enforced.

Smoke verified end-to-end against real Postgres with the plug-in
staged:
  - GET /_meta/metadata/custom-fields/Item returns 2 plug-in fields.
  - POST /catalog/items with `{printing_shop_color_count: 4,
    printing_shop_paper_gsm: 170}` → 201, canonical form persisted.
  - GET roundtrip preserves both integer values.
  - POST with `printing_shop_color_count: "not-a-number"` → 400
    "ext.printing_shop_color_count: not a valid integer: 'not-a-number'".
  - POST with `rogue_key` → 400 "ext contains undeclared key(s)
    for 'Item': [rogue_key]".

Six of eight PBCs now participate in HasExt:
  Partner, Location, SalesOrder, PurchaseOrder, WorkOrder, Item.
The remaining two are pbc-identity (User has no ext column by
design — identity is a security concern, not a customization one)
and pbc-finance (JournalEntry is derived state from events, no
customization surface).

Five core entities carry Tier 1 custom fields as of this commit:
  Partner     (2 core + 1 plug-in)
  Item        (0 core + 2 plug-in)
  SalesOrder  (0 core + 1 plug-in)
  WorkOrder   (2 core + 1 plug-in)
  Location    (0 core + 0 plug-in — wired but no declarations yet)

246 unit tests, all green. 18 Gradle subprojects.
pbc/pbc-catalog/build.gradle.kts
@@ -35,6 +35,7 @@ dependencies { @@ -35,6 +35,7 @@ dependencies {
35 api(project(":api:api-v1")) 35 api(project(":api:api-v1"))
36 implementation(project(":platform:platform-persistence")) 36 implementation(project(":platform:platform-persistence"))
37 implementation(project(":platform:platform-security")) 37 implementation(project(":platform:platform-security"))
  38 + implementation(project(":platform:platform-metadata"))
38 39
39 implementation(libs.kotlin.stdlib) 40 implementation(libs.kotlin.stdlib)
40 implementation(libs.kotlin.reflect) 41 implementation(libs.kotlin.reflect)
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/application/ItemService.kt
@@ -6,6 +6,7 @@ import org.vibeerp.pbc.catalog.domain.Item @@ -6,6 +6,7 @@ import org.vibeerp.pbc.catalog.domain.Item
6 import org.vibeerp.pbc.catalog.domain.ItemType 6 import org.vibeerp.pbc.catalog.domain.ItemType
7 import org.vibeerp.pbc.catalog.infrastructure.ItemJpaRepository 7 import org.vibeerp.pbc.catalog.infrastructure.ItemJpaRepository
8 import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository 8 import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository
  9 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
9 import java.util.UUID 10 import java.util.UUID
10 11
11 /** 12 /**
@@ -29,6 +30,7 @@ import java.util.UUID @@ -29,6 +30,7 @@ import java.util.UUID
29 class ItemService( 30 class ItemService(
30 private val items: ItemJpaRepository, 31 private val items: ItemJpaRepository,
31 private val uoms: UomJpaRepository, 32 private val uoms: UomJpaRepository,
  33 + private val extValidator: ExtJsonValidator,
32 ) { 34 ) {
33 35
34 @Transactional(readOnly = true) 36 @Transactional(readOnly = true)
@@ -47,16 +49,16 @@ class ItemService( @@ -47,16 +49,16 @@ class ItemService(
47 require(uoms.existsByCode(command.baseUomCode)) { 49 require(uoms.existsByCode(command.baseUomCode)) {
48 "base UoM '${command.baseUomCode}' is not in the catalog" 50 "base UoM '${command.baseUomCode}' is not in the catalog"
49 } 51 }
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 - ), 52 + val item = Item(
  53 + code = command.code,
  54 + name = command.name,
  55 + description = command.description,
  56 + itemType = command.itemType,
  57 + baseUomCode = command.baseUomCode,
  58 + active = command.active,
59 ) 59 )
  60 + extValidator.applyTo(item, command.ext)
  61 + return items.save(item)
60 } 62 }
61 63
62 fun update(id: UUID, command: UpdateItemCommand): Item { 64 fun update(id: UUID, command: UpdateItemCommand): Item {
@@ -71,6 +73,7 @@ class ItemService( @@ -71,6 +73,7 @@ class ItemService(
71 command.description?.let { item.description = it } 73 command.description?.let { item.description = it }
72 command.itemType?.let { item.itemType = it } 74 command.itemType?.let { item.itemType = it }
73 command.active?.let { item.active = it } 75 command.active?.let { item.active = it }
  76 + extValidator.applyTo(item, command.ext)
74 return item 77 return item
75 } 78 }
76 79
@@ -80,6 +83,13 @@ class ItemService( @@ -80,6 +83,13 @@ class ItemService(
80 } 83 }
81 item.active = false 84 item.active = false
82 } 85 }
  86 +
  87 + /**
  88 + * Convenience passthrough for response mappers — delegates to
  89 + * [ExtJsonValidator.parseExt].
  90 + */
  91 + fun parseExt(item: Item): Map<String, Any?> =
  92 + extValidator.parseExt(item)
83 } 93 }
84 94
85 data class CreateItemCommand( 95 data class CreateItemCommand(
@@ -89,6 +99,7 @@ data class CreateItemCommand( @@ -89,6 +99,7 @@ data class CreateItemCommand(
89 val itemType: ItemType, 99 val itemType: ItemType,
90 val baseUomCode: String, 100 val baseUomCode: String,
91 val active: Boolean = true, 101 val active: Boolean = true,
  102 + val ext: Map<String, Any?>? = null,
92 ) 103 )
93 104
94 data class UpdateItemCommand( 105 data class UpdateItemCommand(
@@ -96,4 +107,5 @@ data class UpdateItemCommand( @@ -96,4 +107,5 @@ data class UpdateItemCommand(
96 val description: String? = null, 107 val description: String? = null,
97 val itemType: ItemType? = null, 108 val itemType: ItemType? = null,
98 val active: Boolean? = null, 109 val active: Boolean? = null,
  110 + val ext: Map<String, Any?>? = null,
99 ) 111 )
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/domain/Item.kt
@@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated
7 import jakarta.persistence.Table 7 import jakarta.persistence.Table
8 import org.hibernate.annotations.JdbcTypeCode 8 import org.hibernate.annotations.JdbcTypeCode
9 import org.hibernate.type.SqlTypes 9 import org.hibernate.type.SqlTypes
  10 +import org.vibeerp.api.v1.entity.HasExt
10 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity 11 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
11 12
12 /** 13 /**
@@ -42,7 +43,7 @@ class Item( @@ -42,7 +43,7 @@ class Item(
42 itemType: ItemType, 43 itemType: ItemType,
43 baseUomCode: String, 44 baseUomCode: String,
44 active: Boolean = true, 45 active: Boolean = true,
45 -) : AuditedJpaEntity() { 46 +) : AuditedJpaEntity(), HasExt {
46 47
47 @Column(name = "code", nullable = false, length = 64) 48 @Column(name = "code", nullable = false, length = 64)
48 var code: String = code 49 var code: String = code
@@ -65,10 +66,17 @@ class Item( @@ -65,10 +66,17 @@ class Item(
65 66
66 @Column(name = "ext", nullable = false, columnDefinition = "jsonb") 67 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
67 @JdbcTypeCode(SqlTypes.JSON) 68 @JdbcTypeCode(SqlTypes.JSON)
68 - var ext: String = "{}" 69 + override var ext: String = "{}"
  70 +
  71 + override val extEntityName: String get() = ENTITY_NAME
69 72
70 override fun toString(): String = 73 override fun toString(): String =
71 "Item(id=$id, code='$code', type=$itemType, baseUom='$baseUomCode')" 74 "Item(id=$id, code='$code', type=$itemType, baseUom='$baseUomCode')"
  75 +
  76 + companion object {
  77 + /** Key under which Item's custom fields are declared in `metadata__custom_field`. */
  78 + const val ENTITY_NAME: String = "Item"
  79 + }
72 } 80 }
73 81
74 /** 82 /**
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/http/ItemController.kt
@@ -43,20 +43,20 @@ class ItemController( @@ -43,20 +43,20 @@ class ItemController(
43 @GetMapping 43 @GetMapping
44 @RequirePermission("catalog.item.read") 44 @RequirePermission("catalog.item.read")
45 fun list(): List<ItemResponse> = 45 fun list(): List<ItemResponse> =
46 - itemService.list().map { it.toResponse() } 46 + itemService.list().map { it.toResponse(itemService) }
47 47
48 @GetMapping("/{id}") 48 @GetMapping("/{id}")
49 @RequirePermission("catalog.item.read") 49 @RequirePermission("catalog.item.read")
50 fun get(@PathVariable id: UUID): ResponseEntity<ItemResponse> { 50 fun get(@PathVariable id: UUID): ResponseEntity<ItemResponse> {
51 val item = itemService.findById(id) ?: return ResponseEntity.notFound().build() 51 val item = itemService.findById(id) ?: return ResponseEntity.notFound().build()
52 - return ResponseEntity.ok(item.toResponse()) 52 + return ResponseEntity.ok(item.toResponse(itemService))
53 } 53 }
54 54
55 @GetMapping("/by-code/{code}") 55 @GetMapping("/by-code/{code}")
56 @RequirePermission("catalog.item.read") 56 @RequirePermission("catalog.item.read")
57 fun getByCode(@PathVariable code: String): ResponseEntity<ItemResponse> { 57 fun getByCode(@PathVariable code: String): ResponseEntity<ItemResponse> {
58 val item = itemService.findByCode(code) ?: return ResponseEntity.notFound().build() 58 val item = itemService.findByCode(code) ?: return ResponseEntity.notFound().build()
59 - return ResponseEntity.ok(item.toResponse()) 59 + return ResponseEntity.ok(item.toResponse(itemService))
60 } 60 }
61 61
62 @PostMapping 62 @PostMapping
@@ -71,8 +71,9 @@ class ItemController( @@ -71,8 +71,9 @@ class ItemController(
71 itemType = request.itemType, 71 itemType = request.itemType,
72 baseUomCode = request.baseUomCode, 72 baseUomCode = request.baseUomCode,
73 active = request.active ?: true, 73 active = request.active ?: true,
  74 + ext = request.ext,
74 ), 75 ),
75 - ).toResponse() 76 + ).toResponse(itemService)
76 77
77 @PatchMapping("/{id}") 78 @PatchMapping("/{id}")
78 @RequirePermission("catalog.item.update") 79 @RequirePermission("catalog.item.update")
@@ -87,8 +88,9 @@ class ItemController( @@ -87,8 +88,9 @@ class ItemController(
87 description = request.description, 88 description = request.description,
88 itemType = request.itemType, 89 itemType = request.itemType,
89 active = request.active, 90 active = request.active,
  91 + ext = request.ext,
90 ), 92 ),
91 - ).toResponse() 93 + ).toResponse(itemService)
92 94
93 @DeleteMapping("/{id}") 95 @DeleteMapping("/{id}")
94 @ResponseStatus(HttpStatus.NO_CONTENT) 96 @ResponseStatus(HttpStatus.NO_CONTENT)
@@ -107,6 +109,7 @@ data class CreateItemRequest( @@ -107,6 +109,7 @@ data class CreateItemRequest(
107 val itemType: ItemType, 109 val itemType: ItemType,
108 @field:NotBlank @field:Size(max = 16) val baseUomCode: String, 110 @field:NotBlank @field:Size(max = 16) val baseUomCode: String,
109 val active: Boolean? = true, 111 val active: Boolean? = true,
  112 + val ext: Map<String, Any?>? = null,
110 ) 113 )
111 114
112 data class UpdateItemRequest( 115 data class UpdateItemRequest(
@@ -114,6 +117,7 @@ data class UpdateItemRequest( @@ -114,6 +117,7 @@ data class UpdateItemRequest(
114 val description: String? = null, 117 val description: String? = null,
115 val itemType: ItemType? = null, 118 val itemType: ItemType? = null,
116 val active: Boolean? = null, 119 val active: Boolean? = null,
  120 + val ext: Map<String, Any?>? = null,
117 ) 121 )
118 122
119 data class ItemResponse( 123 data class ItemResponse(
@@ -124,9 +128,10 @@ data class ItemResponse( @@ -124,9 +128,10 @@ data class ItemResponse(
124 val itemType: ItemType, 128 val itemType: ItemType,
125 val baseUomCode: String, 129 val baseUomCode: String,
126 val active: Boolean, 130 val active: Boolean,
  131 + val ext: Map<String, Any?>,
127 ) 132 )
128 133
129 -private fun Item.toResponse() = ItemResponse( 134 +private fun Item.toResponse(service: ItemService) = ItemResponse(
130 id = this.id, 135 id = this.id,
131 code = this.code, 136 code = this.code,
132 name = this.name, 137 name = this.name,
@@ -134,4 +139,5 @@ private fun Item.toResponse() = ItemResponse( @@ -134,4 +139,5 @@ private fun Item.toResponse() = ItemResponse(
134 itemType = this.itemType, 139 itemType = this.itemType,
135 baseUomCode = this.baseUomCode, 140 baseUomCode = this.baseUomCode,
136 active = this.active, 141 active = this.active,
  142 + ext = service.parseExt(this),
137 ) 143 )
pbc/pbc-catalog/src/test/kotlin/org/vibeerp/pbc/catalog/application/ItemServiceTest.kt
@@ -5,16 +5,20 @@ import assertk.assertThat @@ -5,16 +5,20 @@ import assertk.assertThat
5 import assertk.assertions.hasMessage 5 import assertk.assertions.hasMessage
6 import assertk.assertions.isEqualTo 6 import assertk.assertions.isEqualTo
7 import assertk.assertions.isInstanceOf 7 import assertk.assertions.isInstanceOf
  8 +import io.mockk.Runs
8 import io.mockk.every 9 import io.mockk.every
  10 +import io.mockk.just
9 import io.mockk.mockk 11 import io.mockk.mockk
10 import io.mockk.slot 12 import io.mockk.slot
11 import io.mockk.verify 13 import io.mockk.verify
12 import org.junit.jupiter.api.BeforeEach 14 import org.junit.jupiter.api.BeforeEach
13 import org.junit.jupiter.api.Test 15 import org.junit.jupiter.api.Test
  16 +import org.vibeerp.api.v1.entity.HasExt
14 import org.vibeerp.pbc.catalog.domain.Item 17 import org.vibeerp.pbc.catalog.domain.Item
15 import org.vibeerp.pbc.catalog.domain.ItemType 18 import org.vibeerp.pbc.catalog.domain.ItemType
16 import org.vibeerp.pbc.catalog.infrastructure.ItemJpaRepository 19 import org.vibeerp.pbc.catalog.infrastructure.ItemJpaRepository
17 import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository 20 import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository
  21 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
18 import java.util.Optional 22 import java.util.Optional
19 import java.util.UUID 23 import java.util.UUID
20 24
@@ -22,13 +26,17 @@ class ItemServiceTest { @@ -22,13 +26,17 @@ class ItemServiceTest {
22 26
23 private lateinit var items: ItemJpaRepository 27 private lateinit var items: ItemJpaRepository
24 private lateinit var uoms: UomJpaRepository 28 private lateinit var uoms: UomJpaRepository
  29 + private lateinit var extValidator: ExtJsonValidator
25 private lateinit var service: ItemService 30 private lateinit var service: ItemService
26 31
27 @BeforeEach 32 @BeforeEach
28 fun setUp() { 33 fun setUp() {
29 items = mockk() 34 items = mockk()
30 uoms = mockk() 35 uoms = mockk()
31 - service = ItemService(items, uoms) 36 + extValidator = mockk()
  37 + every { extValidator.applyTo(any<HasExt>(), any()) } just Runs
  38 + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap()
  39 + service = ItemService(items, uoms, extValidator)
32 } 40 }
33 41
34 @Test 42 @Test
reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml
@@ -81,6 +81,30 @@ customFields: @@ -81,6 +81,30 @@ customFields:
81 en: Customer segment 81 en: Customer segment
82 zh-CN: 客户细分 82 zh-CN: 客户细分
83 83
  84 + # ── Item: every printing item has a color count (1-color black-only
  85 + # job, 4-color CMYK, 5-color CMYK + spot, etc.) and a paper weight
  86 + # (grams per square metre). Neither belongs in the generic catalog
  87 + # core — they're printing-specific.
  88 + - key: printing_shop_color_count
  89 + targetEntity: Item
  90 + type:
  91 + kind: integer
  92 + required: false
  93 + pii: false
  94 + labelTranslations:
  95 + en: Color count
  96 + zh-CN: 颜色数量
  97 +
  98 + - key: printing_shop_paper_gsm
  99 + targetEntity: Item
  100 + type:
  101 + kind: integer
  102 + required: false
  103 + pii: false
  104 + labelTranslations:
  105 + en: Paper weight (gsm)
  106 + zh-CN: 纸张克重
  107 +
84 # ── SalesOrder: a printing shop sales rep tracks a quote number 108 # ── SalesOrder: a printing shop sales rep tracks a quote number
85 # that originated in a pre-ERP quoting tool. Storing it as a 109 # that originated in a pre-ERP quoting tool. Storing it as a
86 # free-form string on the order keeps the lineage traceable without 110 # free-form string on the order keeps the lineage traceable without