Commit 986f02ce64aaa7eb5db436ef8023643f9a27bd2d

Authored by zichun
1 parent 9106d9ac

refactor(metadata): HasExt marker + applyTo/parseExt helpers on ExtJsonValidator

Removes the ext-handling copy/paste that had grown across four PBCs
(partners, inventory, orders-sales, orders-purchase). Every service
that wrote the JSONB `ext` column was manually doing the same
four-step sequence: validate, null-check, serialize with a local
ObjectMapper, assign to the entity. And every response mapper was
doing the inverse: check-if-blank, parse, cast, swallow errors.

Net: ~15 lines saved per PBC, one place to change the ext contract
later (e.g. PII redaction, audit tagging, field-level events), and
a stable plug-in opt-in mechanism — any plug-in entity that
implements `HasExt` automatically participates.

New api.v1 surface:

  interface HasExt {
      val extEntityName: String     // key into metadata__custom_field
      var ext: String               // the serialized JSONB column
  }

Lives in `org.vibeerp.api.v1.entity` so plug-ins can opt their own
entities into the same validation path. Zero Spring/Jackson
dependencies — api.v1 stays clean.

Extended `ExtJsonValidator` (platform-metadata) with two helpers:

  fun applyTo(entity: HasExt, ext: Map<String, Any?>?)
      — null-safe; validates; writes canonical JSON to entity.ext.
        Replaces the validate + writeValueAsString + assign triplet
        in every service's create() and update().

  fun parseExt(entity: HasExt): Map<String, Any?>
      — returns empty map on blank/corrupt column; response
        mappers never 500 on bad data. Replaces the four identical
        parseExt local functions.

ExtJsonValidator now takes an ObjectMapper via constructor
injection (Spring Boot's auto-configured bean).

Entities that now implement HasExt (override val extEntityName;
override var ext; companion object const val ENTITY_NAME):
  - Partner (`partners.Partner` → "Partner")
  - Location (`inventory.Location` → "Location")
  - SalesOrder (`orders_sales.SalesOrder` → "SalesOrder")
  - PurchaseOrder (`orders_purchase.PurchaseOrder` → "PurchaseOrder")

Deliberately NOT converted this chunk:
  - WorkOrder (pbc-production) — its ext column has no declared
    fields yet; a follow-up that adds declarations AND the
    HasExt implementation is cleaner than splitting the two.
  - JournalEntry (pbc-finance) — derived state, no ext column.

Services lose:
  - The `jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()`
    field (four copies eliminated)
  - The `parseExt(entity): Map` helper function (four copies)
  - The `companion object { const val ENTITY_NAME = ... }` constant
    (moved onto the entity where it belongs)
  - The `val canonicalExt = extValidator.validate(...)` +
    `.also { it.ext = jsonMapper.writeValueAsString(canonicalExt) }`
    create pattern (replaced with one applyTo call)
  - The `if (command.ext != null) { ... }` update pattern
    (applyTo is null-safe)

Unit tests: 6 new cases on ExtJsonValidatorTest cover applyTo and
parseExt (null-safe path, happy path, failure path, blank column,
round-trip, malformed JSON). Existing service tests just swap the
mock setup from stubbing `validate` to stubbing `applyTo` and
`parseExt` with no-ops.

Smoke verified end-to-end against real Postgres:
  - POST /partners with valid ext (partners_credit_limit,
    partners_industry) → 201, canonical form persisted.
  - GET /partners/by-code/X → 200, ext round-trips.
  - POST with invalid enum value → 400 "value 'x' is not in
    allowed set [printing, publishing, packaging, other]".
  - POST with undeclared key → 400 "ext contains undeclared
    key(s) for 'Partner': [rogue_field]".
  - PATCH with new ext → 200, ext updated.
  - PATCH WITHOUT ext field → 200, prior ext preserved (null-safe
    applyTo).
  - POST /orders/sales-orders with no ext → 201, the create path
    via the shared helper still works.

246 unit tests (+6 over 240), 18 Gradle subprojects.
CLAUDE.md
... ... @@ -96,7 +96,7 @@ plugins (incl. ref) depend on: api/api-v1 only
96 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97  
98 98 - **18 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.
99   -- **240 unit tests across 18 modules**, all green. `./gradlew build` is the canonical full build.
  99 +- **246 unit tests across 18 modules**, all green. `./gradlew build` is the canonical full build.
100 100 - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge.
101 101 - **8 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`). **The full buy-sell-make loop works**: a purchase order receives stock via `PURCHASE_RECEIPT`, a sales order ships stock via `SALES_SHIPMENT`, and a work order produces stock via `PRODUCTION_RECEIPT`. All three PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade.
102 102 - **Event-driven cross-PBC integration is live in BOTH directions and the consumer reacts to the full lifecycle.** Six typed events under `org.vibeerp.api.v1.event.orders.*` are published from `SalesOrderService` and `PurchaseOrderService` inside the same `@Transactional` method as their state changes. **pbc-finance** subscribes to ALL SIX of them via the api.v1 `EventBus.subscribe(eventType, listener)` typed-class overload: confirm events POST AR/AP rows; ship/receive events SETTLE them; cancel events REVERSE them. Each transition is idempotent under at-least-once delivery — re-applying the same destination status is a no-op. Cancel-from-DRAFT is a clean no-op because no `*ConfirmedEvent` was ever published. pbc-finance has no source dependency on pbc-orders-*; it reaches them only through events.
... ...
PROGRESS.md
... ... @@ -10,11 +10,11 @@
10 10  
11 11 | | |
12 12 |---|---|
13   -| **Latest version** | v0.19.0 (pbc-production v2 — IN_PROGRESS + BOM + scrap) |
14   -| **Latest commit** | `75a75ba feat(production): P5.7 v2 — IN_PROGRESS state + BOM auto-issue + scrap flow` |
  13 +| **Latest version** | v0.19.1 (HasExt marker interface + ExtJsonValidator helpers) |
  14 +| **Latest commit** | `TBD refactor(metadata): HasExt marker + applyTo/parseExt helpers on ExtJsonValidator` |
15 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 16 | **Modules** | 18 |
17   -| **Unit tests** | 240, all green |
  17 +| **Unit tests** | 246, all green |
18 18 | **End-to-end smoke runs** | An SO confirmed with 2 lines auto-spawns 2 draft work orders via `SalesOrderConfirmedSubscriber`; completing one credits the finished-good stock via `PRODUCTION_RECEIPT`, cancelling the other flips its status, and a manual WO can still be created with no source SO. All in one run: 6 outbox rows DISPATCHED across `orders_sales.SalesOrder` and `production.WorkOrder` topics; pbc-finance still writes its AR row for the underlying SO; the `inventory__stock_movement` ledger carries the production receipt tagged `WO:<code>`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). |
19 19 | **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) |
20 20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) |
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/HasExt.kt 0 → 100644
  1 +package org.vibeerp.api.v1.entity
  2 +
  3 +/**
  4 + * Marker interface for every entity that participates in Tier 1
  5 + * customization — i.e. any entity with a JSONB `ext` column that
  6 + * must be validated against [CustomField] declarations on save and
  7 + * decoded for response DTOs on read.
  8 + *
  9 + * **Why this is in api.v1, not platform.metadata:** plug-ins define
  10 + * their own entities and need to opt into the same validation and
  11 + * serialization path as the framework's core PBCs. Keeping the
  12 + * marker in api.v1 means a plug-in's `Plate` or `InkRecipe` can
  13 + * implement `HasExt` and automatically get:
  14 + * - validation against its `metadata__custom_field` declarations,
  15 + * - canonical JSON serialization into the column,
  16 + * - safe parse-back into a `Map<String, Any?>` for response DTOs,
  17 + * all through the same `ExtJsonValidator.applyTo(...)` /
  18 + * `.parseExt(...)` helpers that the core PBCs use. No plug-in code
  19 + * should need to reach into `platform.metadata.internal.*` for ext
  20 + * handling.
  21 + *
  22 + * **Interface surface is deliberately tiny** — two members.
  23 + *
  24 + * - [extEntityName] is the stable string key used to look up
  25 + * declared fields in the custom field registry. The convention is
  26 + * `<pbc>.<Entity>` (e.g. `"partners.Partner"`, `"inventory.Location"`).
  27 + * Plug-ins are expected to namespace theirs under the plug-in id
  28 + * (e.g. `"printing-shop.Plate"`).
  29 + * - [ext] is the serialized JSONB column. Entities should back this
  30 + * with a `@Column(columnDefinition = "jsonb") var ext: String =
  31 + * "{}"`. The `ExtJsonValidator.applyTo(...)` helper writes the
  32 + * canonical form here; `ExtJsonValidator.parseExt(...)` reads it
  33 + * back.
  34 + *
  35 + * **Why `ext` is a `String`, not a `Map`:** Hibernate writes the JSON
  36 + * column as a string (or its `@JdbcTypeCode(SqlTypes.JSON)` wrapper
  37 + * — effectively the same thing). Exposing it as a Map here would
  38 + * force plug-ins to depend on Jackson, which `api.v1` explicitly
  39 + * does NOT. Keeping it a String means the only Jackson touchpoint
  40 + * lives inside `ExtJsonValidator`, which is already in
  41 + * `platform.metadata`.
  42 + *
  43 + * **Does NOT extend [Entity].** Not every Entity has custom fields;
  44 + * not every HasExt needs an Id (though in practice they always do).
  45 + * Keeping the two marker interfaces orthogonal means a plug-in can
  46 + * choose which behaviors it opts into without inheritance bloat.
  47 + */
  48 +public interface HasExt {
  49 + /**
  50 + * The stable entity-name key under which this entity's custom
  51 + * fields are declared in `metadata__custom_field`. Convention:
  52 + * `<pbc>.<Entity>` for core; `<plugin-id>.<Entity>` for plug-ins.
  53 + */
  54 + public val extEntityName: String
  55 +
  56 + /**
  57 + * The serialized JSONB column. Managed by
  58 + * `ExtJsonValidator.applyTo(...)` on save and decoded by
  59 + * `ExtJsonValidator.parseExt(...)` on read. Should default to
  60 + * `"{}"` on the entity class so an un-customized row still
  61 + * round-trips through JSON cleanly.
  62 + */
  63 + public var ext: String
  64 +}
... ...
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/LocationService.kt
1 1 package org.vibeerp.pbc.inventory.application
2 2  
3   -import com.fasterxml.jackson.databind.ObjectMapper
4   -import com.fasterxml.jackson.module.kotlin.registerKotlinModule
5 3 import org.springframework.stereotype.Service
6 4 import org.springframework.transaction.annotation.Transactional
7 5 import org.vibeerp.pbc.inventory.domain.Location
... ... @@ -32,8 +30,6 @@ class LocationService(
32 30 private val extValidator: ExtJsonValidator,
33 31 ) {
34 32  
35   - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
36   -
37 33 @Transactional(readOnly = true)
38 34 fun list(): List<Location> = locations.findAll()
39 35  
... ... @@ -47,17 +43,14 @@ class LocationService(
47 43 require(!locations.existsByCode(command.code)) {
48 44 "location code '${command.code}' is already taken"
49 45 }
50   - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)
51   - return locations.save(
52   - Location(
53   - code = command.code,
54   - name = command.name,
55   - type = command.type,
56   - active = command.active,
57   - ).also {
58   - it.ext = jsonMapper.writeValueAsString(canonicalExt)
59   - },
  46 + val location = Location(
  47 + code = command.code,
  48 + name = command.name,
  49 + type = command.type,
  50 + active = command.active,
60 51 )
  52 + extValidator.applyTo(location, command.ext)
  53 + return locations.save(location)
61 54 }
62 55  
63 56 fun update(id: UUID, command: UpdateLocationCommand): Location {
... ... @@ -67,10 +60,7 @@ class LocationService(
67 60 command.name?.let { location.name = it }
68 61 command.type?.let { location.type = it }
69 62 command.active?.let { location.active = it }
70   - if (command.ext != null) {
71   - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)
72   - location.ext = jsonMapper.writeValueAsString(canonicalExt)
73   - }
  63 + extValidator.applyTo(location, command.ext)
74 64 return location
75 65 }
76 66  
... ... @@ -81,17 +71,14 @@ class LocationService(
81 71 location.active = false
82 72 }
83 73  
84   - @Suppress("UNCHECKED_CAST")
85   - fun parseExt(location: Location): Map<String, Any?> = try {
86   - if (location.ext.isBlank()) emptyMap()
87   - else jsonMapper.readValue(location.ext, Map::class.java) as Map<String, Any?>
88   - } catch (ex: Throwable) {
89   - emptyMap()
90   - }
91   -
92   - companion object {
93   - const val ENTITY_NAME: String = "Location"
94   - }
  74 + /**
  75 + * Convenience passthrough for response mappers — delegates to
  76 + * [ExtJsonValidator.parseExt]. Kept here as a thin wrapper so
  77 + * the controller's mapper doesn't have to inject the validator
  78 + * directly.
  79 + */
  80 + fun parseExt(location: Location): Map<String, Any?> =
  81 + extValidator.parseExt(location)
95 82 }
96 83  
97 84 data class CreateLocationCommand(
... ...
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/Location.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 /**
... ... @@ -50,7 +51,7 @@ class Location(
50 51 name: String,
51 52 type: LocationType,
52 53 active: Boolean = true,
53   -) : AuditedJpaEntity() {
  54 +) : AuditedJpaEntity(), HasExt {
54 55  
55 56 @Column(name = "code", nullable = false, length = 64)
56 57 var code: String = code
... ... @@ -67,10 +68,17 @@ class Location(
67 68  
68 69 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
69 70 @JdbcTypeCode(SqlTypes.JSON)
70   - var ext: String = "{}"
  71 + override var ext: String = "{}"
  72 +
  73 + override val extEntityName: String get() = ENTITY_NAME
71 74  
72 75 override fun toString(): String =
73 76 "Location(id=$id, code='$code', type=$type, active=$active)"
  77 +
  78 + companion object {
  79 + /** Key under which Location's custom fields are declared in `metadata__custom_field`. */
  80 + const val ENTITY_NAME: String = "Location"
  81 + }
74 82 }
75 83  
76 84 /**
... ...
pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/LocationServiceTest.kt
... ... @@ -6,12 +6,15 @@ import assertk.assertions.hasMessage
6 6 import assertk.assertions.isEqualTo
7 7 import assertk.assertions.isFalse
8 8 import assertk.assertions.isInstanceOf
  9 +import io.mockk.Runs
9 10 import io.mockk.every
  11 +import io.mockk.just
10 12 import io.mockk.mockk
11 13 import io.mockk.slot
12 14 import io.mockk.verify
13 15 import org.junit.jupiter.api.BeforeEach
14 16 import org.junit.jupiter.api.Test
  17 +import org.vibeerp.api.v1.entity.HasExt
15 18 import org.vibeerp.pbc.inventory.domain.Location
16 19 import org.vibeerp.pbc.inventory.domain.LocationType
17 20 import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
... ... @@ -29,7 +32,11 @@ class LocationServiceTest {
29 32 fun setUp() {
30 33 locations = mockk()
31 34 extValidator = mockk()
32   - every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() }
  35 + // applyTo is a void method with side effects on the entity;
  36 + // for unit tests we treat it as a no-op. Tests that exercise
  37 + // the ext path directly can stub concrete behavior.
  38 + every { extValidator.applyTo(any<HasExt>(), any()) } just Runs
  39 + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap()
33 40 service = LocationService(locations, extValidator)
34 41 }
35 42  
... ...
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt
1 1 package org.vibeerp.pbc.orders.purchase.application
2 2  
3   -import com.fasterxml.jackson.databind.ObjectMapper
4   -import com.fasterxml.jackson.module.kotlin.registerKotlinModule
5 3 import org.springframework.stereotype.Service
6 4 import org.springframework.transaction.annotation.Transactional
7 5 import org.vibeerp.api.v1.event.EventBus
... ... @@ -55,8 +53,6 @@ class PurchaseOrderService(
55 53 private val eventBus: EventBus,
56 54 ) {
57 55  
58   - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
59   -
60 56 @Transactional(readOnly = true)
61 57 fun list(): List<PurchaseOrder> = orders.findAll()
62 58  
... ... @@ -112,8 +108,6 @@ class PurchaseOrderService(
112 108 acc + (line.quantity * line.unitPrice)
113 109 }
114 110  
115   - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)
116   -
117 111 val order = PurchaseOrder(
118 112 code = command.code,
119 113 partnerCode = command.partnerCode,
... ... @@ -122,9 +116,8 @@ class PurchaseOrderService(
122 116 expectedDate = command.expectedDate,
123 117 currencyCode = command.currencyCode,
124 118 totalAmount = total,
125   - ).also {
126   - it.ext = jsonMapper.writeValueAsString(canonicalExt)
127   - }
  119 + )
  120 + extValidator.applyTo(order, command.ext)
128 121 for (line in command.lines) {
129 122 order.lines += PurchaseOrderLine(
130 123 purchaseOrder = order,
... ... @@ -160,9 +153,8 @@ class PurchaseOrderService(
160 153 command.expectedDate?.let { order.expectedDate = it }
161 154 command.currencyCode?.let { order.currencyCode = it }
162 155  
163   - if (command.ext != null) {
164   - order.ext = jsonMapper.writeValueAsString(extValidator.validate(ENTITY_NAME, command.ext))
165   - }
  156 + // applyTo() is null-safe — a null command.ext is a no-op.
  157 + extValidator.applyTo(order, command.ext)
166 158  
167 159 if (command.lines != null) {
168 160 require(command.lines.isNotEmpty()) {
... ... @@ -309,17 +301,13 @@ class PurchaseOrderService(
309 301 return order
310 302 }
311 303  
312   - @Suppress("UNCHECKED_CAST")
313   - fun parseExt(order: PurchaseOrder): Map<String, Any?> = try {
314   - if (order.ext.isBlank()) emptyMap()
315   - else jsonMapper.readValue(order.ext, Map::class.java) as Map<String, Any?>
316   - } catch (ex: Throwable) {
317   - emptyMap()
318   - }
319   -
320   - companion object {
321   - const val ENTITY_NAME: String = "PurchaseOrder"
322   - }
  304 + /**
  305 + * Convenience passthrough for response mappers — delegates to
  306 + * [ExtJsonValidator.parseExt]. Returns an empty map on an empty
  307 + * or unparseable column so response rendering never 500s.
  308 + */
  309 + fun parseExt(order: PurchaseOrder): Map<String, Any?> =
  310 + extValidator.parseExt(order)
323 311 }
324 312  
325 313 data class CreatePurchaseOrderCommand(
... ...
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrder.kt
... ... @@ -11,6 +11,7 @@ import jakarta.persistence.OrderBy
11 11 import jakarta.persistence.Table
12 12 import org.hibernate.annotations.JdbcTypeCode
13 13 import org.hibernate.type.SqlTypes
  14 +import org.vibeerp.api.v1.entity.HasExt
14 15 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
15 16 import java.math.BigDecimal
16 17 import java.time.LocalDate
... ... @@ -70,7 +71,7 @@ class PurchaseOrder(
70 71 expectedDate: LocalDate? = null,
71 72 currencyCode: String,
72 73 totalAmount: BigDecimal = BigDecimal.ZERO,
73   -) : AuditedJpaEntity() {
  74 +) : AuditedJpaEntity(), HasExt {
74 75  
75 76 @Column(name = "code", nullable = false, length = 64)
76 77 var code: String = code
... ... @@ -96,7 +97,9 @@ class PurchaseOrder(
96 97  
97 98 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
98 99 @JdbcTypeCode(SqlTypes.JSON)
99   - var ext: String = "{}"
  100 + override var ext: String = "{}"
  101 +
  102 + override val extEntityName: String get() = ENTITY_NAME
100 103  
101 104 @OneToMany(
102 105 mappedBy = "purchaseOrder",
... ... @@ -109,6 +112,11 @@ class PurchaseOrder(
109 112  
110 113 override fun toString(): String =
111 114 "PurchaseOrder(id=$id, code='$code', supplier='$partnerCode', status=$status, total=$totalAmount $currencyCode)"
  115 +
  116 + companion object {
  117 + /** Key under which PurchaseOrder's custom fields are declared in `metadata__custom_field`. */
  118 + const val ENTITY_NAME: String = "PurchaseOrder"
  119 + }
112 120 }
113 121  
114 122 /**
... ...
pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt
... ... @@ -16,6 +16,7 @@ import io.mockk.verify
16 16 import org.junit.jupiter.api.BeforeEach
17 17 import org.junit.jupiter.api.Test
18 18 import org.vibeerp.api.v1.core.Id
  19 +import org.vibeerp.api.v1.entity.HasExt
19 20 import org.vibeerp.api.v1.event.EventBus
20 21 import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent
21 22 import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent
... ... @@ -54,7 +55,8 @@ class PurchaseOrderServiceTest {
54 55 inventoryApi = mockk()
55 56 extValidator = mockk()
56 57 eventBus = mockk()
57   - every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() }
  58 + every { extValidator.applyTo(any<HasExt>(), any()) } just runs
  59 + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap()
58 60 every { orders.existsByCode(any()) } returns false
59 61 every { orders.save(any<PurchaseOrder>()) } answers { firstArg() }
60 62 every { eventBus.publish(any()) } just runs
... ...
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt
1 1 package org.vibeerp.pbc.orders.sales.application
2 2  
3   -import com.fasterxml.jackson.databind.ObjectMapper
4   -import com.fasterxml.jackson.module.kotlin.registerKotlinModule
5 3 import org.springframework.stereotype.Service
6 4 import org.springframework.transaction.annotation.Transactional
7 5 import org.vibeerp.api.v1.event.EventBus
... ... @@ -78,8 +76,6 @@ class SalesOrderService(
78 76 private val eventBus: EventBus,
79 77 ) {
80 78  
81   - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
82   -
83 79 @Transactional(readOnly = true)
84 80 fun list(): List<SalesOrder> = orders.findAll()
85 81  
... ... @@ -138,8 +134,6 @@ class SalesOrderService(
138 134 acc + (line.quantity * line.unitPrice)
139 135 }
140 136  
141   - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)
142   -
143 137 val order = SalesOrder(
144 138 code = command.code,
145 139 partnerCode = command.partnerCode,
... ... @@ -147,9 +141,8 @@ class SalesOrderService(
147 141 orderDate = command.orderDate,
148 142 currencyCode = command.currencyCode,
149 143 totalAmount = total,
150   - ).also {
151   - it.ext = jsonMapper.writeValueAsString(canonicalExt)
152   - }
  144 + )
  145 + extValidator.applyTo(order, command.ext)
153 146 for (line in command.lines) {
154 147 order.lines += SalesOrderLine(
155 148 salesOrder = order,
... ... @@ -184,9 +177,8 @@ class SalesOrderService(
184 177 command.orderDate?.let { order.orderDate = it }
185 178 command.currencyCode?.let { order.currencyCode = it }
186 179  
187   - if (command.ext != null) {
188   - order.ext = jsonMapper.writeValueAsString(extValidator.validate(ENTITY_NAME, command.ext))
189   - }
  180 + // applyTo() is null-safe — a null command.ext is a no-op.
  181 + extValidator.applyTo(order, command.ext)
190 182  
191 183 if (command.lines != null) {
192 184 require(command.lines.isNotEmpty()) {
... ... @@ -347,17 +339,13 @@ class SalesOrderService(
347 339 return order
348 340 }
349 341  
350   - @Suppress("UNCHECKED_CAST")
351   - fun parseExt(order: SalesOrder): Map<String, Any?> = try {
352   - if (order.ext.isBlank()) emptyMap()
353   - else jsonMapper.readValue(order.ext, Map::class.java) as Map<String, Any?>
354   - } catch (ex: Throwable) {
355   - emptyMap()
356   - }
357   -
358   - companion object {
359   - const val ENTITY_NAME: String = "SalesOrder"
360   - }
  342 + /**
  343 + * Convenience passthrough for response mappers — delegates to
  344 + * [ExtJsonValidator.parseExt]. Returns an empty map on an empty
  345 + * or unparseable column so response rendering never 500s.
  346 + */
  347 + fun parseExt(order: SalesOrder): Map<String, Any?> =
  348 + extValidator.parseExt(order)
361 349 }
362 350  
363 351 data class CreateSalesOrderCommand(
... ...
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt
... ... @@ -12,6 +12,7 @@ import jakarta.persistence.OrderBy
12 12 import jakarta.persistence.Table
13 13 import org.hibernate.annotations.JdbcTypeCode
14 14 import org.hibernate.type.SqlTypes
  15 +import org.vibeerp.api.v1.entity.HasExt
15 16 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
16 17 import java.math.BigDecimal
17 18 import java.time.LocalDate
... ... @@ -76,7 +77,7 @@ class SalesOrder(
76 77 orderDate: LocalDate,
77 78 currencyCode: String,
78 79 totalAmount: BigDecimal = BigDecimal.ZERO,
79   -) : AuditedJpaEntity() {
  80 +) : AuditedJpaEntity(), HasExt {
80 81  
81 82 @Column(name = "code", nullable = false, length = 64)
82 83 var code: String = code
... ... @@ -99,7 +100,9 @@ class SalesOrder(
99 100  
100 101 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
101 102 @JdbcTypeCode(SqlTypes.JSON)
102   - var ext: String = "{}"
  103 + override var ext: String = "{}"
  104 +
  105 + override val extEntityName: String get() = ENTITY_NAME
103 106  
104 107 /**
105 108 * The order's line items, eagerly loaded. Cascade ALL because
... ... @@ -119,6 +122,11 @@ class SalesOrder(
119 122  
120 123 override fun toString(): String =
121 124 "SalesOrder(id=$id, code='$code', partner='$partnerCode', status=$status, total=$totalAmount $currencyCode)"
  125 +
  126 + companion object {
  127 + /** Key under which SalesOrder's custom fields are declared in `metadata__custom_field`. */
  128 + const val ENTITY_NAME: String = "SalesOrder"
  129 + }
122 130 }
123 131  
124 132 /**
... ...
pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt
... ... @@ -17,6 +17,7 @@ import io.mockk.verify
17 17 import org.junit.jupiter.api.BeforeEach
18 18 import org.junit.jupiter.api.Test
19 19 import org.vibeerp.api.v1.core.Id
  20 +import org.vibeerp.api.v1.entity.HasExt
20 21 import org.vibeerp.api.v1.event.EventBus
21 22 import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent
22 23 import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent
... ... @@ -54,7 +55,10 @@ class SalesOrderServiceTest {
54 55 inventoryApi = mockk()
55 56 extValidator = mockk()
56 57 eventBus = mockk()
57   - every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() }
  58 + // applyTo is a void with side effects; default to a no-op so
  59 + // tests that don't touch ext don't need to set expectations.
  60 + every { extValidator.applyTo(any<HasExt>(), any()) } just runs
  61 + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap()
58 62 every { orders.existsByCode(any()) } returns false
59 63 every { orders.save(any<SalesOrder>()) } answers { firstArg() }
60 64 // The bus is fire-and-forget from the service's perspective.
... ...
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/PartnerService.kt
1 1 package org.vibeerp.pbc.partners.application
2 2  
3   -import com.fasterxml.jackson.databind.ObjectMapper
4   -import com.fasterxml.jackson.module.kotlin.registerKotlinModule
5 3 import org.springframework.stereotype.Service
6 4 import org.springframework.transaction.annotation.Transactional
7 5 import org.vibeerp.pbc.partners.domain.Partner
... ... @@ -50,8 +48,6 @@ class PartnerService(
50 48 private val extValidator: ExtJsonValidator,
51 49 ) {
52 50  
53   - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
54   -
55 51 @Transactional(readOnly = true)
56 52 fun list(): List<Partner> = partners.findAll()
57 53  
... ... @@ -65,24 +61,22 @@ class PartnerService(
65 61 require(!partners.existsByCode(command.code)) {
66 62 "partner code '${command.code}' is already taken"
67 63 }
68   - // Validate the ext JSONB before save (P3.4 — Tier 1 customization).
69   - // Throws IllegalArgumentException with all violations on failure;
70   - // GlobalExceptionHandler maps it to 400 Bad Request.
71   - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)
72   - return partners.save(
73   - Partner(
74   - code = command.code,
75   - name = command.name,
76   - type = command.type,
77   - taxId = command.taxId,
78   - website = command.website,
79   - email = command.email,
80   - phone = command.phone,
81   - active = command.active,
82   - ).also {
83   - it.ext = jsonMapper.writeValueAsString(canonicalExt)
84   - },
  64 + // P3.4 — Tier 1 customization. applyTo() validates, canonicalises,
  65 + // and writes to partner.ext. Throws IllegalArgumentException with
  66 + // all violations joined if validation fails; GlobalExceptionHandler
  67 + // maps that to 400 Bad Request.
  68 + val partner = Partner(
  69 + code = command.code,
  70 + name = command.name,
  71 + type = command.type,
  72 + taxId = command.taxId,
  73 + website = command.website,
  74 + email = command.email,
  75 + phone = command.phone,
  76 + active = command.active,
85 77 )
  78 + extValidator.applyTo(partner, command.ext)
  79 + return partners.save(partner)
86 80 }
87 81  
88 82 fun update(id: UUID, command: UpdatePartnerCommand): Partner {
... ... @@ -102,37 +96,18 @@ class PartnerService(
102 96 // updates would force callers to merge with the existing JSON
103 97 // before sending, which is the kind of foot-gun the framework
104 98 // should not normalise. PATCH is for top-level columns only.
105   - if (command.ext != null) {
106   - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)
107   - partner.ext = jsonMapper.writeValueAsString(canonicalExt)
108   - }
  99 + // applyTo() is null-safe — a null command.ext is a no-op.
  100 + extValidator.applyTo(partner, command.ext)
109 101 return partner
110 102 }
111 103  
112 104 /**
113   - * Parse the entity's `ext` JSON string back into a Map for the
114   - * REST response. Returns an empty map when the column is empty
115   - * or unparseable — the latter shouldn't happen because every
116   - * write goes through [extValidator], but cleaning up bad rows
117   - * from old data isn't this method's responsibility.
  105 + * Convenience passthrough for response mappers — delegates to
  106 + * [ExtJsonValidator.parseExt]. Returns an empty map on an empty
  107 + * or unparseable column so response rendering never 500s.
118 108 */
119   - @Suppress("UNCHECKED_CAST")
120   - fun parseExt(partner: Partner): Map<String, Any?> = try {
121   - if (partner.ext.isBlank()) emptyMap()
122   - else jsonMapper.readValue(partner.ext, Map::class.java) as Map<String, Any?>
123   - } catch (ex: Throwable) {
124   - emptyMap()
125   - }
126   -
127   - companion object {
128   - /**
129   - * The entity name the partner aggregate is registered under in
130   - * `metadata__entity` (and therefore the key the [ExtJsonValidator]
131   - * looks up). Kept as a constant so the controller and tests
132   - * can reference the same string without hard-coding it twice.
133   - */
134   - const val ENTITY_NAME: String = "Partner"
135   - }
  109 + fun parseExt(partner: Partner): Map<String, Any?> =
  110 + extValidator.parseExt(partner)
136 111  
137 112 /**
138 113 * Deactivate the partner and all its contacts. Addresses are left
... ...
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/domain/Partner.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 /**
... ... @@ -58,7 +59,7 @@ class Partner(
58 59 email: String? = null,
59 60 phone: String? = null,
60 61 active: Boolean = true,
61   -) : AuditedJpaEntity() {
  62 +) : AuditedJpaEntity(), HasExt {
62 63  
63 64 @Column(name = "code", nullable = false, length = 64)
64 65 var code: String = code
... ... @@ -87,10 +88,17 @@ class Partner(
87 88  
88 89 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
89 90 @JdbcTypeCode(SqlTypes.JSON)
90   - var ext: String = "{}"
  91 + override var ext: String = "{}"
  92 +
  93 + override val extEntityName: String get() = ENTITY_NAME
91 94  
92 95 override fun toString(): String =
93 96 "Partner(id=$id, code='$code', type=$type, active=$active)"
  97 +
  98 + companion object {
  99 + /** Key under which Partner's custom fields are declared in `metadata__custom_field`. */
  100 + const val ENTITY_NAME: String = "Partner"
  101 + }
94 102 }
95 103  
96 104 /**
... ...
pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/PartnerServiceTest.kt
... ... @@ -6,12 +6,15 @@ import assertk.assertions.hasMessage
6 6 import assertk.assertions.isEqualTo
7 7 import assertk.assertions.isFalse
8 8 import assertk.assertions.isInstanceOf
  9 +import io.mockk.Runs
9 10 import io.mockk.every
  11 +import io.mockk.just
10 12 import io.mockk.mockk
11 13 import io.mockk.slot
12 14 import io.mockk.verify
13 15 import org.junit.jupiter.api.BeforeEach
14 16 import org.junit.jupiter.api.Test
  17 +import org.vibeerp.api.v1.entity.HasExt
15 18 import org.vibeerp.pbc.partners.domain.Contact
16 19 import org.vibeerp.pbc.partners.domain.Partner
17 20 import org.vibeerp.pbc.partners.domain.PartnerType
... ... @@ -36,9 +39,10 @@ class PartnerServiceTest {
36 39 addresses = mockk()
37 40 contacts = mockk()
38 41 extValidator = mockk()
39   - // Default: validator returns the input map unchanged. Tests
40   - // that exercise ext directly override this expectation.
41   - every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() }
  42 + // Default: validator treats applyTo as a no-op. Tests that
  43 + // exercise ext directly override this expectation.
  44 + every { extValidator.applyTo(any<HasExt>(), any()) } just Runs
  45 + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap()
42 46 service = PartnerService(partners, addresses, contacts, extValidator)
43 47 }
44 48  
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidator.kt
1 1 package org.vibeerp.platform.metadata.customfield
2 2  
  3 +import com.fasterxml.jackson.databind.ObjectMapper
3 4 import org.springframework.stereotype.Component
4 5 import org.vibeerp.api.v1.entity.CustomField
5 6 import org.vibeerp.api.v1.entity.FieldType
  7 +import org.vibeerp.api.v1.entity.HasExt
6 8 import java.math.BigDecimal
7 9 import java.time.LocalDate
8 10 import java.time.OffsetDateTime
... ... @@ -53,9 +55,53 @@ import java.util.UUID
53 55 @Component
54 56 class ExtJsonValidator(
55 57 private val registry: CustomFieldRegistry,
  58 + private val jsonMapper: ObjectMapper,
56 59 ) {
57 60  
58 61 /**
  62 + * Validate [ext], serialize the canonical form, and write it to
  63 + * [entity]'s ext column. Null [ext] is a no-op (the column keeps
  64 + * its prior value, which means PATCH-style "don't touch ext"
  65 + * works out of the box).
  66 + *
  67 + * This is the single entry point every core PBC service and
  68 + * every plug-in uses to stage an ext update. It replaces the
  69 + * four-line sequence (validate / writeValueAsString / assign to
  70 + * column) that used to live in every service — removing a lot
  71 + * of boilerplate and giving the framework one place to change
  72 + * the ext-save contract later (e.g. PII redaction, audit
  73 + * tagging, eventing on field-level changes).
  74 + *
  75 + * Throws [IllegalArgumentException] with all violations joined
  76 + * by `; ` if validation fails. The framework's
  77 + * `GlobalExceptionHandler` maps that to 400 Bad Request.
  78 + */
  79 + fun applyTo(entity: HasExt, ext: Map<String, Any?>?) {
  80 + if (ext == null) return
  81 + val canonical = validate(entity.extEntityName, ext)
  82 + entity.ext = jsonMapper.writeValueAsString(canonical)
  83 + }
  84 +
  85 + /**
  86 + * Parse an entity's serialized `ext` column back into a
  87 + * `Map<String, Any?>` for response DTOs. Returns an empty map
  88 + * for any parse failure — response mappers should NOT fail a
  89 + * successful read because of a bad serialized ext value; if the
  90 + * column is corrupt, the fix is a save, not a 500 on read.
  91 + *
  92 + * This replaces the `parseExt(...)` helper that was duplicated
  93 + * verbatim in every core PBC service.
  94 + */
  95 + @Suppress("UNCHECKED_CAST")
  96 + fun parseExt(entity: HasExt): Map<String, Any?> =
  97 + try {
  98 + if (entity.ext.isBlank()) emptyMap()
  99 + else jsonMapper.readValue(entity.ext, Map::class.java) as Map<String, Any?>
  100 + } catch (ex: Throwable) {
  101 + emptyMap()
  102 + }
  103 +
  104 + /**
59 105 * Validate (and canonicalise) the [ext] map for [entityName].
60 106 *
61 107 * @return a fresh map containing only the declared fields, with
... ...
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidatorTest.kt
... ... @@ -9,12 +9,15 @@ import assertk.assertions.isEmpty
9 9 import assertk.assertions.isEqualTo
10 10 import assertk.assertions.isInstanceOf
11 11 import assertk.assertions.messageContains
  12 +import com.fasterxml.jackson.databind.ObjectMapper
  13 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
12 14 import io.mockk.every
13 15 import io.mockk.mockk
14 16 import org.junit.jupiter.api.BeforeEach
15 17 import org.junit.jupiter.api.Test
16 18 import org.vibeerp.api.v1.entity.CustomField
17 19 import org.vibeerp.api.v1.entity.FieldType
  20 +import org.vibeerp.api.v1.entity.HasExt
18 21  
19 22 class ExtJsonValidatorTest {
20 23  
... ... @@ -24,7 +27,7 @@ class ExtJsonValidatorTest {
24 27 @BeforeEach
25 28 fun setUp() {
26 29 registry = mockk()
27   - validator = ExtJsonValidator(registry)
  30 + validator = ExtJsonValidator(registry, ObjectMapper().registerKotlinModule())
28 31 }
29 32  
30 33 private fun stub(vararg fields: CustomField) {
... ... @@ -170,6 +173,72 @@ class ExtJsonValidatorTest {
170 173 assertThat(canonical.keys).hasSize(0)
171 174 }
172 175  
  176 + // ─── applyTo + parseExt (HasExt convenience helpers) ─────────────
  177 +
  178 + /**
  179 + * Minimal [HasExt] fixture. Services will mix this marker into
  180 + * their JPA entities; the test just needs something concrete.
  181 + */
  182 + private class FakeEntity(
  183 + override val extEntityName: String = ENTITY,
  184 + override var ext: String = "{}",
  185 + ) : HasExt
  186 +
  187 + @Test
  188 + fun `applyTo with null ext is a no-op`() {
  189 + val fake = FakeEntity()
  190 + validator.applyTo(fake, null)
  191 + assertThat(fake.ext).isEqualTo("{}")
  192 + }
  193 +
  194 + @Test
  195 + fun `applyTo validates and writes canonical JSON to entity ext`() {
  196 + stub(
  197 + CustomField("label", ENTITY, FieldType.String(maxLength = 16)),
  198 + CustomField("count", ENTITY, FieldType.Integer),
  199 + )
  200 + val fake = FakeEntity()
  201 +
  202 + validator.applyTo(fake, mapOf("label" to "ok", "count" to 3))
  203 +
  204 + // Canonical form — label first (declared order), count as Long.
  205 + assertThat(fake.ext).isEqualTo("""{"label":"ok","count":3}""")
  206 + }
  207 +
  208 + @Test
  209 + fun `applyTo surfaces validation failures as IllegalArgumentException`() {
  210 + stub(CustomField("known", ENTITY, FieldType.Integer))
  211 + val fake = FakeEntity()
  212 +
  213 + assertFailure { validator.applyTo(fake, mapOf("rogue" to "x")) }
  214 + .isInstanceOf(IllegalArgumentException::class)
  215 + .messageContains("undeclared")
  216 + // Ext was NOT touched on failure.
  217 + assertThat(fake.ext).isEqualTo("{}")
  218 + }
  219 +
  220 + @Test
  221 + fun `parseExt returns empty map for blank ext column`() {
  222 + val fake = FakeEntity(ext = "")
  223 + assertThat(validator.parseExt(fake)).isEmpty()
  224 + }
  225 +
  226 + @Test
  227 + fun `parseExt round-trips a previously-canonicalised ext value`() {
  228 + val fake = FakeEntity(ext = """{"label":"round-trip","count":42}""")
  229 + val parsed = validator.parseExt(fake)
  230 + assertThat(parsed.keys).contains("label")
  231 + assertThat(parsed.keys).contains("count")
  232 + assertThat(parsed["label"]).isEqualTo("round-trip")
  233 + }
  234 +
  235 + @Test
  236 + fun `parseExt swallows malformed JSON and returns empty map`() {
  237 + val fake = FakeEntity(ext = "this is not json")
  238 + // Response mappers should NOT 500 on a corrupt row.
  239 + assertThat(validator.parseExt(fake)).isEmpty()
  240 + }
  241 +
173 242 companion object {
174 243 const val ENTITY: String = "TestEntity"
175 244 }
... ...