Commit 986f02ce64aaa7eb5db436ef8023643f9a27bd2d
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.
Showing
17 changed files
with
307 additions
and
141 deletions
CLAUDE.md
| @@ -96,7 +96,7 @@ plugins (incl. ref) depend on: api/api-v1 only | @@ -96,7 +96,7 @@ plugins (incl. ref) depend on: api/api-v1 only | ||
| 96 | **Foundation complete; first business surface in place.** As of the latest commit: | 96 | **Foundation complete; first business surface in place.** As of the latest commit: |
| 97 | 97 | ||
| 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`. | 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 | - **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. | 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 | - **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. | 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 | - **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. | 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,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 | | **Repo** | https://github.com/reporkey/vibe-erp | | 15 | | **Repo** | https://github.com/reporkey/vibe-erp | |
| 16 | | **Modules** | 18 | | 16 | | **Modules** | 18 | |
| 17 | -| **Unit tests** | 240, all green | | 17 | +| **Unit tests** | 246, all green | |
| 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). | | 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 | | **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) | | 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 | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | | 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 | package org.vibeerp.pbc.inventory.application | 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 | import org.springframework.stereotype.Service | 3 | import org.springframework.stereotype.Service |
| 6 | import org.springframework.transaction.annotation.Transactional | 4 | import org.springframework.transaction.annotation.Transactional |
| 7 | import org.vibeerp.pbc.inventory.domain.Location | 5 | import org.vibeerp.pbc.inventory.domain.Location |
| @@ -32,8 +30,6 @@ class LocationService( | @@ -32,8 +30,6 @@ class LocationService( | ||
| 32 | private val extValidator: ExtJsonValidator, | 30 | private val extValidator: ExtJsonValidator, |
| 33 | ) { | 31 | ) { |
| 34 | 32 | ||
| 35 | - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() | ||
| 36 | - | ||
| 37 | @Transactional(readOnly = true) | 33 | @Transactional(readOnly = true) |
| 38 | fun list(): List<Location> = locations.findAll() | 34 | fun list(): List<Location> = locations.findAll() |
| 39 | 35 | ||
| @@ -47,17 +43,14 @@ class LocationService( | @@ -47,17 +43,14 @@ class LocationService( | ||
| 47 | require(!locations.existsByCode(command.code)) { | 43 | require(!locations.existsByCode(command.code)) { |
| 48 | "location code '${command.code}' is already taken" | 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 | fun update(id: UUID, command: UpdateLocationCommand): Location { | 56 | fun update(id: UUID, command: UpdateLocationCommand): Location { |
| @@ -67,10 +60,7 @@ class LocationService( | @@ -67,10 +60,7 @@ class LocationService( | ||
| 67 | command.name?.let { location.name = it } | 60 | command.name?.let { location.name = it } |
| 68 | command.type?.let { location.type = it } | 61 | command.type?.let { location.type = it } |
| 69 | command.active?.let { location.active = it } | 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 | return location | 64 | return location |
| 75 | } | 65 | } |
| 76 | 66 | ||
| @@ -81,17 +71,14 @@ class LocationService( | @@ -81,17 +71,14 @@ class LocationService( | ||
| 81 | location.active = false | 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 | data class CreateLocationCommand( | 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,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 | /** |
| @@ -50,7 +51,7 @@ class Location( | @@ -50,7 +51,7 @@ class Location( | ||
| 50 | name: String, | 51 | name: String, |
| 51 | type: LocationType, | 52 | type: LocationType, |
| 52 | active: Boolean = true, | 53 | active: Boolean = true, |
| 53 | -) : AuditedJpaEntity() { | 54 | +) : AuditedJpaEntity(), HasExt { |
| 54 | 55 | ||
| 55 | @Column(name = "code", nullable = false, length = 64) | 56 | @Column(name = "code", nullable = false, length = 64) |
| 56 | var code: String = code | 57 | var code: String = code |
| @@ -67,10 +68,17 @@ class Location( | @@ -67,10 +68,17 @@ class Location( | ||
| 67 | 68 | ||
| 68 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") | 69 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") |
| 69 | @JdbcTypeCode(SqlTypes.JSON) | 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 | override fun toString(): String = | 75 | override fun toString(): String = |
| 73 | "Location(id=$id, code='$code', type=$type, active=$active)" | 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,12 +6,15 @@ import assertk.assertions.hasMessage | ||
| 6 | import assertk.assertions.isEqualTo | 6 | import assertk.assertions.isEqualTo |
| 7 | import assertk.assertions.isFalse | 7 | import assertk.assertions.isFalse |
| 8 | import assertk.assertions.isInstanceOf | 8 | import assertk.assertions.isInstanceOf |
| 9 | +import io.mockk.Runs | ||
| 9 | import io.mockk.every | 10 | import io.mockk.every |
| 11 | +import io.mockk.just | ||
| 10 | import io.mockk.mockk | 12 | import io.mockk.mockk |
| 11 | import io.mockk.slot | 13 | import io.mockk.slot |
| 12 | import io.mockk.verify | 14 | import io.mockk.verify |
| 13 | import org.junit.jupiter.api.BeforeEach | 15 | import org.junit.jupiter.api.BeforeEach |
| 14 | import org.junit.jupiter.api.Test | 16 | import org.junit.jupiter.api.Test |
| 17 | +import org.vibeerp.api.v1.entity.HasExt | ||
| 15 | import org.vibeerp.pbc.inventory.domain.Location | 18 | import org.vibeerp.pbc.inventory.domain.Location |
| 16 | import org.vibeerp.pbc.inventory.domain.LocationType | 19 | import org.vibeerp.pbc.inventory.domain.LocationType |
| 17 | import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository | 20 | import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository |
| @@ -29,7 +32,11 @@ class LocationServiceTest { | @@ -29,7 +32,11 @@ class LocationServiceTest { | ||
| 29 | fun setUp() { | 32 | fun setUp() { |
| 30 | locations = mockk() | 33 | locations = mockk() |
| 31 | extValidator = mockk() | 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 | service = LocationService(locations, extValidator) | 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 | package org.vibeerp.pbc.orders.purchase.application | 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 | import org.springframework.stereotype.Service | 3 | import org.springframework.stereotype.Service |
| 6 | import org.springframework.transaction.annotation.Transactional | 4 | import org.springframework.transaction.annotation.Transactional |
| 7 | import org.vibeerp.api.v1.event.EventBus | 5 | import org.vibeerp.api.v1.event.EventBus |
| @@ -55,8 +53,6 @@ class PurchaseOrderService( | @@ -55,8 +53,6 @@ class PurchaseOrderService( | ||
| 55 | private val eventBus: EventBus, | 53 | private val eventBus: EventBus, |
| 56 | ) { | 54 | ) { |
| 57 | 55 | ||
| 58 | - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() | ||
| 59 | - | ||
| 60 | @Transactional(readOnly = true) | 56 | @Transactional(readOnly = true) |
| 61 | fun list(): List<PurchaseOrder> = orders.findAll() | 57 | fun list(): List<PurchaseOrder> = orders.findAll() |
| 62 | 58 | ||
| @@ -112,8 +108,6 @@ class PurchaseOrderService( | @@ -112,8 +108,6 @@ class PurchaseOrderService( | ||
| 112 | acc + (line.quantity * line.unitPrice) | 108 | acc + (line.quantity * line.unitPrice) |
| 113 | } | 109 | } |
| 114 | 110 | ||
| 115 | - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) | ||
| 116 | - | ||
| 117 | val order = PurchaseOrder( | 111 | val order = PurchaseOrder( |
| 118 | code = command.code, | 112 | code = command.code, |
| 119 | partnerCode = command.partnerCode, | 113 | partnerCode = command.partnerCode, |
| @@ -122,9 +116,8 @@ class PurchaseOrderService( | @@ -122,9 +116,8 @@ class PurchaseOrderService( | ||
| 122 | expectedDate = command.expectedDate, | 116 | expectedDate = command.expectedDate, |
| 123 | currencyCode = command.currencyCode, | 117 | currencyCode = command.currencyCode, |
| 124 | totalAmount = total, | 118 | totalAmount = total, |
| 125 | - ).also { | ||
| 126 | - it.ext = jsonMapper.writeValueAsString(canonicalExt) | ||
| 127 | - } | 119 | + ) |
| 120 | + extValidator.applyTo(order, command.ext) | ||
| 128 | for (line in command.lines) { | 121 | for (line in command.lines) { |
| 129 | order.lines += PurchaseOrderLine( | 122 | order.lines += PurchaseOrderLine( |
| 130 | purchaseOrder = order, | 123 | purchaseOrder = order, |
| @@ -160,9 +153,8 @@ class PurchaseOrderService( | @@ -160,9 +153,8 @@ class PurchaseOrderService( | ||
| 160 | command.expectedDate?.let { order.expectedDate = it } | 153 | command.expectedDate?.let { order.expectedDate = it } |
| 161 | command.currencyCode?.let { order.currencyCode = it } | 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 | if (command.lines != null) { | 159 | if (command.lines != null) { |
| 168 | require(command.lines.isNotEmpty()) { | 160 | require(command.lines.isNotEmpty()) { |
| @@ -309,17 +301,13 @@ class PurchaseOrderService( | @@ -309,17 +301,13 @@ class PurchaseOrderService( | ||
| 309 | return order | 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 | data class CreatePurchaseOrderCommand( | 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,6 +11,7 @@ import jakarta.persistence.OrderBy | ||
| 11 | import jakarta.persistence.Table | 11 | import jakarta.persistence.Table |
| 12 | import org.hibernate.annotations.JdbcTypeCode | 12 | import org.hibernate.annotations.JdbcTypeCode |
| 13 | import org.hibernate.type.SqlTypes | 13 | import org.hibernate.type.SqlTypes |
| 14 | +import org.vibeerp.api.v1.entity.HasExt | ||
| 14 | import org.vibeerp.platform.persistence.audit.AuditedJpaEntity | 15 | import org.vibeerp.platform.persistence.audit.AuditedJpaEntity |
| 15 | import java.math.BigDecimal | 16 | import java.math.BigDecimal |
| 16 | import java.time.LocalDate | 17 | import java.time.LocalDate |
| @@ -70,7 +71,7 @@ class PurchaseOrder( | @@ -70,7 +71,7 @@ class PurchaseOrder( | ||
| 70 | expectedDate: LocalDate? = null, | 71 | expectedDate: LocalDate? = null, |
| 71 | currencyCode: String, | 72 | currencyCode: String, |
| 72 | totalAmount: BigDecimal = BigDecimal.ZERO, | 73 | totalAmount: BigDecimal = BigDecimal.ZERO, |
| 73 | -) : AuditedJpaEntity() { | 74 | +) : AuditedJpaEntity(), HasExt { |
| 74 | 75 | ||
| 75 | @Column(name = "code", nullable = false, length = 64) | 76 | @Column(name = "code", nullable = false, length = 64) |
| 76 | var code: String = code | 77 | var code: String = code |
| @@ -96,7 +97,9 @@ class PurchaseOrder( | @@ -96,7 +97,9 @@ class PurchaseOrder( | ||
| 96 | 97 | ||
| 97 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") | 98 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") |
| 98 | @JdbcTypeCode(SqlTypes.JSON) | 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 | @OneToMany( | 104 | @OneToMany( |
| 102 | mappedBy = "purchaseOrder", | 105 | mappedBy = "purchaseOrder", |
| @@ -109,6 +112,11 @@ class PurchaseOrder( | @@ -109,6 +112,11 @@ class PurchaseOrder( | ||
| 109 | 112 | ||
| 110 | override fun toString(): String = | 113 | override fun toString(): String = |
| 111 | "PurchaseOrder(id=$id, code='$code', supplier='$partnerCode', status=$status, total=$totalAmount $currencyCode)" | 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,6 +16,7 @@ import io.mockk.verify | ||
| 16 | import org.junit.jupiter.api.BeforeEach | 16 | import org.junit.jupiter.api.BeforeEach |
| 17 | import org.junit.jupiter.api.Test | 17 | import org.junit.jupiter.api.Test |
| 18 | import org.vibeerp.api.v1.core.Id | 18 | import org.vibeerp.api.v1.core.Id |
| 19 | +import org.vibeerp.api.v1.entity.HasExt | ||
| 19 | import org.vibeerp.api.v1.event.EventBus | 20 | import org.vibeerp.api.v1.event.EventBus |
| 20 | import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent | 21 | import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent |
| 21 | import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent | 22 | import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent |
| @@ -54,7 +55,8 @@ class PurchaseOrderServiceTest { | @@ -54,7 +55,8 @@ class PurchaseOrderServiceTest { | ||
| 54 | inventoryApi = mockk() | 55 | inventoryApi = mockk() |
| 55 | extValidator = mockk() | 56 | extValidator = mockk() |
| 56 | eventBus = mockk() | 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 | every { orders.existsByCode(any()) } returns false | 60 | every { orders.existsByCode(any()) } returns false |
| 59 | every { orders.save(any<PurchaseOrder>()) } answers { firstArg() } | 61 | every { orders.save(any<PurchaseOrder>()) } answers { firstArg() } |
| 60 | every { eventBus.publish(any()) } just runs | 62 | every { eventBus.publish(any()) } just runs |
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt
| 1 | package org.vibeerp.pbc.orders.sales.application | 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 | import org.springframework.stereotype.Service | 3 | import org.springframework.stereotype.Service |
| 6 | import org.springframework.transaction.annotation.Transactional | 4 | import org.springframework.transaction.annotation.Transactional |
| 7 | import org.vibeerp.api.v1.event.EventBus | 5 | import org.vibeerp.api.v1.event.EventBus |
| @@ -78,8 +76,6 @@ class SalesOrderService( | @@ -78,8 +76,6 @@ class SalesOrderService( | ||
| 78 | private val eventBus: EventBus, | 76 | private val eventBus: EventBus, |
| 79 | ) { | 77 | ) { |
| 80 | 78 | ||
| 81 | - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() | ||
| 82 | - | ||
| 83 | @Transactional(readOnly = true) | 79 | @Transactional(readOnly = true) |
| 84 | fun list(): List<SalesOrder> = orders.findAll() | 80 | fun list(): List<SalesOrder> = orders.findAll() |
| 85 | 81 | ||
| @@ -138,8 +134,6 @@ class SalesOrderService( | @@ -138,8 +134,6 @@ class SalesOrderService( | ||
| 138 | acc + (line.quantity * line.unitPrice) | 134 | acc + (line.quantity * line.unitPrice) |
| 139 | } | 135 | } |
| 140 | 136 | ||
| 141 | - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) | ||
| 142 | - | ||
| 143 | val order = SalesOrder( | 137 | val order = SalesOrder( |
| 144 | code = command.code, | 138 | code = command.code, |
| 145 | partnerCode = command.partnerCode, | 139 | partnerCode = command.partnerCode, |
| @@ -147,9 +141,8 @@ class SalesOrderService( | @@ -147,9 +141,8 @@ class SalesOrderService( | ||
| 147 | orderDate = command.orderDate, | 141 | orderDate = command.orderDate, |
| 148 | currencyCode = command.currencyCode, | 142 | currencyCode = command.currencyCode, |
| 149 | totalAmount = total, | 143 | totalAmount = total, |
| 150 | - ).also { | ||
| 151 | - it.ext = jsonMapper.writeValueAsString(canonicalExt) | ||
| 152 | - } | 144 | + ) |
| 145 | + extValidator.applyTo(order, command.ext) | ||
| 153 | for (line in command.lines) { | 146 | for (line in command.lines) { |
| 154 | order.lines += SalesOrderLine( | 147 | order.lines += SalesOrderLine( |
| 155 | salesOrder = order, | 148 | salesOrder = order, |
| @@ -184,9 +177,8 @@ class SalesOrderService( | @@ -184,9 +177,8 @@ class SalesOrderService( | ||
| 184 | command.orderDate?.let { order.orderDate = it } | 177 | command.orderDate?.let { order.orderDate = it } |
| 185 | command.currencyCode?.let { order.currencyCode = it } | 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 | if (command.lines != null) { | 183 | if (command.lines != null) { |
| 192 | require(command.lines.isNotEmpty()) { | 184 | require(command.lines.isNotEmpty()) { |
| @@ -347,17 +339,13 @@ class SalesOrderService( | @@ -347,17 +339,13 @@ class SalesOrderService( | ||
| 347 | return order | 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 | data class CreateSalesOrderCommand( | 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,6 +12,7 @@ import jakarta.persistence.OrderBy | ||
| 12 | import jakarta.persistence.Table | 12 | import jakarta.persistence.Table |
| 13 | import org.hibernate.annotations.JdbcTypeCode | 13 | import org.hibernate.annotations.JdbcTypeCode |
| 14 | import org.hibernate.type.SqlTypes | 14 | import org.hibernate.type.SqlTypes |
| 15 | +import org.vibeerp.api.v1.entity.HasExt | ||
| 15 | import org.vibeerp.platform.persistence.audit.AuditedJpaEntity | 16 | import org.vibeerp.platform.persistence.audit.AuditedJpaEntity |
| 16 | import java.math.BigDecimal | 17 | import java.math.BigDecimal |
| 17 | import java.time.LocalDate | 18 | import java.time.LocalDate |
| @@ -76,7 +77,7 @@ class SalesOrder( | @@ -76,7 +77,7 @@ class SalesOrder( | ||
| 76 | orderDate: LocalDate, | 77 | orderDate: LocalDate, |
| 77 | currencyCode: String, | 78 | currencyCode: String, |
| 78 | totalAmount: BigDecimal = BigDecimal.ZERO, | 79 | totalAmount: BigDecimal = BigDecimal.ZERO, |
| 79 | -) : AuditedJpaEntity() { | 80 | +) : AuditedJpaEntity(), HasExt { |
| 80 | 81 | ||
| 81 | @Column(name = "code", nullable = false, length = 64) | 82 | @Column(name = "code", nullable = false, length = 64) |
| 82 | var code: String = code | 83 | var code: String = code |
| @@ -99,7 +100,9 @@ class SalesOrder( | @@ -99,7 +100,9 @@ class SalesOrder( | ||
| 99 | 100 | ||
| 100 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") | 101 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") |
| 101 | @JdbcTypeCode(SqlTypes.JSON) | 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 | * The order's line items, eagerly loaded. Cascade ALL because | 108 | * The order's line items, eagerly loaded. Cascade ALL because |
| @@ -119,6 +122,11 @@ class SalesOrder( | @@ -119,6 +122,11 @@ class SalesOrder( | ||
| 119 | 122 | ||
| 120 | override fun toString(): String = | 123 | override fun toString(): String = |
| 121 | "SalesOrder(id=$id, code='$code', partner='$partnerCode', status=$status, total=$totalAmount $currencyCode)" | 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,6 +17,7 @@ import io.mockk.verify | ||
| 17 | import org.junit.jupiter.api.BeforeEach | 17 | import org.junit.jupiter.api.BeforeEach |
| 18 | import org.junit.jupiter.api.Test | 18 | import org.junit.jupiter.api.Test |
| 19 | import org.vibeerp.api.v1.core.Id | 19 | import org.vibeerp.api.v1.core.Id |
| 20 | +import org.vibeerp.api.v1.entity.HasExt | ||
| 20 | import org.vibeerp.api.v1.event.EventBus | 21 | import org.vibeerp.api.v1.event.EventBus |
| 21 | import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent | 22 | import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent |
| 22 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent | 23 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent |
| @@ -54,7 +55,10 @@ class SalesOrderServiceTest { | @@ -54,7 +55,10 @@ class SalesOrderServiceTest { | ||
| 54 | inventoryApi = mockk() | 55 | inventoryApi = mockk() |
| 55 | extValidator = mockk() | 56 | extValidator = mockk() |
| 56 | eventBus = mockk() | 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 | every { orders.existsByCode(any()) } returns false | 62 | every { orders.existsByCode(any()) } returns false |
| 59 | every { orders.save(any<SalesOrder>()) } answers { firstArg() } | 63 | every { orders.save(any<SalesOrder>()) } answers { firstArg() } |
| 60 | // The bus is fire-and-forget from the service's perspective. | 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 | package org.vibeerp.pbc.partners.application | 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 | import org.springframework.stereotype.Service | 3 | import org.springframework.stereotype.Service |
| 6 | import org.springframework.transaction.annotation.Transactional | 4 | import org.springframework.transaction.annotation.Transactional |
| 7 | import org.vibeerp.pbc.partners.domain.Partner | 5 | import org.vibeerp.pbc.partners.domain.Partner |
| @@ -50,8 +48,6 @@ class PartnerService( | @@ -50,8 +48,6 @@ class PartnerService( | ||
| 50 | private val extValidator: ExtJsonValidator, | 48 | private val extValidator: ExtJsonValidator, |
| 51 | ) { | 49 | ) { |
| 52 | 50 | ||
| 53 | - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() | ||
| 54 | - | ||
| 55 | @Transactional(readOnly = true) | 51 | @Transactional(readOnly = true) |
| 56 | fun list(): List<Partner> = partners.findAll() | 52 | fun list(): List<Partner> = partners.findAll() |
| 57 | 53 | ||
| @@ -65,24 +61,22 @@ class PartnerService( | @@ -65,24 +61,22 @@ class PartnerService( | ||
| 65 | require(!partners.existsByCode(command.code)) { | 61 | require(!partners.existsByCode(command.code)) { |
| 66 | "partner code '${command.code}' is already taken" | 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 | fun update(id: UUID, command: UpdatePartnerCommand): Partner { | 82 | fun update(id: UUID, command: UpdatePartnerCommand): Partner { |
| @@ -102,37 +96,18 @@ class PartnerService( | @@ -102,37 +96,18 @@ class PartnerService( | ||
| 102 | // updates would force callers to merge with the existing JSON | 96 | // updates would force callers to merge with the existing JSON |
| 103 | // before sending, which is the kind of foot-gun the framework | 97 | // before sending, which is the kind of foot-gun the framework |
| 104 | // should not normalise. PATCH is for top-level columns only. | 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 | return partner | 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 | * Deactivate the partner and all its contacts. Addresses are left | 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,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 | /** |
| @@ -58,7 +59,7 @@ class Partner( | @@ -58,7 +59,7 @@ class Partner( | ||
| 58 | email: String? = null, | 59 | email: String? = null, |
| 59 | phone: String? = null, | 60 | phone: String? = null, |
| 60 | active: Boolean = true, | 61 | active: Boolean = true, |
| 61 | -) : AuditedJpaEntity() { | 62 | +) : AuditedJpaEntity(), HasExt { |
| 62 | 63 | ||
| 63 | @Column(name = "code", nullable = false, length = 64) | 64 | @Column(name = "code", nullable = false, length = 64) |
| 64 | var code: String = code | 65 | var code: String = code |
| @@ -87,10 +88,17 @@ class Partner( | @@ -87,10 +88,17 @@ class Partner( | ||
| 87 | 88 | ||
| 88 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") | 89 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") |
| 89 | @JdbcTypeCode(SqlTypes.JSON) | 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 | override fun toString(): String = | 95 | override fun toString(): String = |
| 93 | "Partner(id=$id, code='$code', type=$type, active=$active)" | 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,12 +6,15 @@ import assertk.assertions.hasMessage | ||
| 6 | import assertk.assertions.isEqualTo | 6 | import assertk.assertions.isEqualTo |
| 7 | import assertk.assertions.isFalse | 7 | import assertk.assertions.isFalse |
| 8 | import assertk.assertions.isInstanceOf | 8 | import assertk.assertions.isInstanceOf |
| 9 | +import io.mockk.Runs | ||
| 9 | import io.mockk.every | 10 | import io.mockk.every |
| 11 | +import io.mockk.just | ||
| 10 | import io.mockk.mockk | 12 | import io.mockk.mockk |
| 11 | import io.mockk.slot | 13 | import io.mockk.slot |
| 12 | import io.mockk.verify | 14 | import io.mockk.verify |
| 13 | import org.junit.jupiter.api.BeforeEach | 15 | import org.junit.jupiter.api.BeforeEach |
| 14 | import org.junit.jupiter.api.Test | 16 | import org.junit.jupiter.api.Test |
| 17 | +import org.vibeerp.api.v1.entity.HasExt | ||
| 15 | import org.vibeerp.pbc.partners.domain.Contact | 18 | import org.vibeerp.pbc.partners.domain.Contact |
| 16 | import org.vibeerp.pbc.partners.domain.Partner | 19 | import org.vibeerp.pbc.partners.domain.Partner |
| 17 | import org.vibeerp.pbc.partners.domain.PartnerType | 20 | import org.vibeerp.pbc.partners.domain.PartnerType |
| @@ -36,9 +39,10 @@ class PartnerServiceTest { | @@ -36,9 +39,10 @@ class PartnerServiceTest { | ||
| 36 | addresses = mockk() | 39 | addresses = mockk() |
| 37 | contacts = mockk() | 40 | contacts = mockk() |
| 38 | extValidator = mockk() | 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 | service = PartnerService(partners, addresses, contacts, extValidator) | 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 | package org.vibeerp.platform.metadata.customfield | 1 | package org.vibeerp.platform.metadata.customfield |
| 2 | 2 | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper | ||
| 3 | import org.springframework.stereotype.Component | 4 | import org.springframework.stereotype.Component |
| 4 | import org.vibeerp.api.v1.entity.CustomField | 5 | import org.vibeerp.api.v1.entity.CustomField |
| 5 | import org.vibeerp.api.v1.entity.FieldType | 6 | import org.vibeerp.api.v1.entity.FieldType |
| 7 | +import org.vibeerp.api.v1.entity.HasExt | ||
| 6 | import java.math.BigDecimal | 8 | import java.math.BigDecimal |
| 7 | import java.time.LocalDate | 9 | import java.time.LocalDate |
| 8 | import java.time.OffsetDateTime | 10 | import java.time.OffsetDateTime |
| @@ -53,9 +55,53 @@ import java.util.UUID | @@ -53,9 +55,53 @@ import java.util.UUID | ||
| 53 | @Component | 55 | @Component |
| 54 | class ExtJsonValidator( | 56 | class ExtJsonValidator( |
| 55 | private val registry: CustomFieldRegistry, | 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 | * Validate (and canonicalise) the [ext] map for [entityName]. | 105 | * Validate (and canonicalise) the [ext] map for [entityName]. |
| 60 | * | 106 | * |
| 61 | * @return a fresh map containing only the declared fields, with | 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,12 +9,15 @@ import assertk.assertions.isEmpty | ||
| 9 | import assertk.assertions.isEqualTo | 9 | import assertk.assertions.isEqualTo |
| 10 | import assertk.assertions.isInstanceOf | 10 | import assertk.assertions.isInstanceOf |
| 11 | import assertk.assertions.messageContains | 11 | import assertk.assertions.messageContains |
| 12 | +import com.fasterxml.jackson.databind.ObjectMapper | ||
| 13 | +import com.fasterxml.jackson.module.kotlin.registerKotlinModule | ||
| 12 | import io.mockk.every | 14 | import io.mockk.every |
| 13 | import io.mockk.mockk | 15 | import io.mockk.mockk |
| 14 | import org.junit.jupiter.api.BeforeEach | 16 | import org.junit.jupiter.api.BeforeEach |
| 15 | import org.junit.jupiter.api.Test | 17 | import org.junit.jupiter.api.Test |
| 16 | import org.vibeerp.api.v1.entity.CustomField | 18 | import org.vibeerp.api.v1.entity.CustomField |
| 17 | import org.vibeerp.api.v1.entity.FieldType | 19 | import org.vibeerp.api.v1.entity.FieldType |
| 20 | +import org.vibeerp.api.v1.entity.HasExt | ||
| 18 | 21 | ||
| 19 | class ExtJsonValidatorTest { | 22 | class ExtJsonValidatorTest { |
| 20 | 23 | ||
| @@ -24,7 +27,7 @@ class ExtJsonValidatorTest { | @@ -24,7 +27,7 @@ class ExtJsonValidatorTest { | ||
| 24 | @BeforeEach | 27 | @BeforeEach |
| 25 | fun setUp() { | 28 | fun setUp() { |
| 26 | registry = mockk() | 29 | registry = mockk() |
| 27 | - validator = ExtJsonValidator(registry) | 30 | + validator = ExtJsonValidator(registry, ObjectMapper().registerKotlinModule()) |
| 28 | } | 31 | } |
| 29 | 32 | ||
| 30 | private fun stub(vararg fields: CustomField) { | 33 | private fun stub(vararg fields: CustomField) { |
| @@ -170,6 +173,72 @@ class ExtJsonValidatorTest { | @@ -170,6 +173,72 @@ class ExtJsonValidatorTest { | ||
| 170 | assertThat(canonical.keys).hasSize(0) | 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 | companion object { | 242 | companion object { |
| 174 | const val ENTITY: String = "TestEntity" | 243 | const val ENTITY: String = "TestEntity" |
| 175 | } | 244 | } |
-
mentioned in commit 8b95af94