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 35 api(project(":api:api-v1"))
36 36 implementation(project(":platform:platform-persistence"))
37 37 implementation(project(":platform:platform-security"))
  38 + implementation(project(":platform:platform-metadata"))
38 39  
39 40 implementation(libs.kotlin.stdlib)
40 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 import org.vibeerp.pbc.catalog.domain.ItemType
7 7 import org.vibeerp.pbc.catalog.infrastructure.ItemJpaRepository
8 8 import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository
  9 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
9 10 import java.util.UUID
10 11  
11 12 /**
... ... @@ -29,6 +30,7 @@ import java.util.UUID
29 30 class ItemService(
30 31 private val items: ItemJpaRepository,
31 32 private val uoms: UomJpaRepository,
  33 + private val extValidator: ExtJsonValidator,
32 34 ) {
33 35  
34 36 @Transactional(readOnly = true)
... ... @@ -47,16 +49,16 @@ class ItemService(
47 49 require(uoms.existsByCode(command.baseUomCode)) {
48 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 64 fun update(id: UUID, command: UpdateItemCommand): Item {
... ... @@ -71,6 +73,7 @@ class ItemService(
71 73 command.description?.let { item.description = it }
72 74 command.itemType?.let { item.itemType = it }
73 75 command.active?.let { item.active = it }
  76 + extValidator.applyTo(item, command.ext)
74 77 return item
75 78 }
76 79  
... ... @@ -80,6 +83,13 @@ class ItemService(
80 83 }
81 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 95 data class CreateItemCommand(
... ... @@ -89,6 +99,7 @@ data class CreateItemCommand(
89 99 val itemType: ItemType,
90 100 val baseUomCode: String,
91 101 val active: Boolean = true,
  102 + val ext: Map<String, Any?>? = null,
92 103 )
93 104  
94 105 data class UpdateItemCommand(
... ... @@ -96,4 +107,5 @@ data class UpdateItemCommand(
96 107 val description: String? = null,
97 108 val itemType: ItemType? = null,
98 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 7 import jakarta.persistence.Table
8 8 import org.hibernate.annotations.JdbcTypeCode
9 9 import org.hibernate.type.SqlTypes
  10 +import org.vibeerp.api.v1.entity.HasExt
10 11 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
11 12  
12 13 /**
... ... @@ -42,7 +43,7 @@ class Item(
42 43 itemType: ItemType,
43 44 baseUomCode: String,
44 45 active: Boolean = true,
45   -) : AuditedJpaEntity() {
  46 +) : AuditedJpaEntity(), HasExt {
46 47  
47 48 @Column(name = "code", nullable = false, length = 64)
48 49 var code: String = code
... ... @@ -65,10 +66,17 @@ class Item(
65 66  
66 67 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
67 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 73 override fun toString(): String =
71 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 43 @GetMapping
44 44 @RequirePermission("catalog.item.read")
45 45 fun list(): List<ItemResponse> =
46   - itemService.list().map { it.toResponse() }
  46 + itemService.list().map { it.toResponse(itemService) }
47 47  
48 48 @GetMapping("/{id}")
49 49 @RequirePermission("catalog.item.read")
50 50 fun get(@PathVariable id: UUID): ResponseEntity<ItemResponse> {
51 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 55 @GetMapping("/by-code/{code}")
56 56 @RequirePermission("catalog.item.read")
57 57 fun getByCode(@PathVariable code: String): ResponseEntity<ItemResponse> {
58 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 62 @PostMapping
... ... @@ -71,8 +71,9 @@ class ItemController(
71 71 itemType = request.itemType,
72 72 baseUomCode = request.baseUomCode,
73 73 active = request.active ?: true,
  74 + ext = request.ext,
74 75 ),
75   - ).toResponse()
  76 + ).toResponse(itemService)
76 77  
77 78 @PatchMapping("/{id}")
78 79 @RequirePermission("catalog.item.update")
... ... @@ -87,8 +88,9 @@ class ItemController(
87 88 description = request.description,
88 89 itemType = request.itemType,
89 90 active = request.active,
  91 + ext = request.ext,
90 92 ),
91   - ).toResponse()
  93 + ).toResponse(itemService)
92 94  
93 95 @DeleteMapping("/{id}")
94 96 @ResponseStatus(HttpStatus.NO_CONTENT)
... ... @@ -107,6 +109,7 @@ data class CreateItemRequest(
107 109 val itemType: ItemType,
108 110 @field:NotBlank @field:Size(max = 16) val baseUomCode: String,
109 111 val active: Boolean? = true,
  112 + val ext: Map<String, Any?>? = null,
110 113 )
111 114  
112 115 data class UpdateItemRequest(
... ... @@ -114,6 +117,7 @@ data class UpdateItemRequest(
114 117 val description: String? = null,
115 118 val itemType: ItemType? = null,
116 119 val active: Boolean? = null,
  120 + val ext: Map<String, Any?>? = null,
117 121 )
118 122  
119 123 data class ItemResponse(
... ... @@ -124,9 +128,10 @@ data class ItemResponse(
124 128 val itemType: ItemType,
125 129 val baseUomCode: String,
126 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 135 id = this.id,
131 136 code = this.code,
132 137 name = this.name,
... ... @@ -134,4 +139,5 @@ private fun Item.toResponse() = ItemResponse(
134 139 itemType = this.itemType,
135 140 baseUomCode = this.baseUomCode,
136 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 5 import assertk.assertions.hasMessage
6 6 import assertk.assertions.isEqualTo
7 7 import assertk.assertions.isInstanceOf
  8 +import io.mockk.Runs
8 9 import io.mockk.every
  10 +import io.mockk.just
9 11 import io.mockk.mockk
10 12 import io.mockk.slot
11 13 import io.mockk.verify
12 14 import org.junit.jupiter.api.BeforeEach
13 15 import org.junit.jupiter.api.Test
  16 +import org.vibeerp.api.v1.entity.HasExt
14 17 import org.vibeerp.pbc.catalog.domain.Item
15 18 import org.vibeerp.pbc.catalog.domain.ItemType
16 19 import org.vibeerp.pbc.catalog.infrastructure.ItemJpaRepository
17 20 import org.vibeerp.pbc.catalog.infrastructure.UomJpaRepository
  21 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
18 22 import java.util.Optional
19 23 import java.util.UUID
20 24  
... ... @@ -22,13 +26,17 @@ class ItemServiceTest {
22 26  
23 27 private lateinit var items: ItemJpaRepository
24 28 private lateinit var uoms: UomJpaRepository
  29 + private lateinit var extValidator: ExtJsonValidator
25 30 private lateinit var service: ItemService
26 31  
27 32 @BeforeEach
28 33 fun setUp() {
29 34 items = mockk()
30 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 42 @Test
... ...
reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml
... ... @@ -81,6 +81,30 @@ customFields:
81 81 en: Customer segment
82 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 108 # ── SalesOrder: a printing shop sales rep tracks a quote number
85 109 # that originated in a pre-ERP quoting tool. Storing it as a
86 110 # free-form string on the order keeps the lineage traceable without
... ...