Commit 98d7032b56b688b3e7903a98ca508b87da34253d
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.
Showing
6 changed files
with
77 additions
and
18 deletions
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 | ... | ... |