Commit 8b95af948724f31ac564e40f88ccaa1a2ee41b53

Authored by zichun
1 parent 8ce94781

feat(production): wire WorkOrder into HasExt pattern + declare custom fields

Closes the last known gap from the HasExt refactor (commit 986f02ce):
pbc-production's WorkOrder had an `ext` column but no validator was
wired, so an operator could write arbitrary JSON without any
schema enforcement. This fixes that and adds the first Tier 1
custom fields for WorkOrder.

Code changes:
  - WorkOrder implements HasExt; ext becomes `override var ext`,
    ENTITY_NAME moves onto the entity companion.
  - WorkOrderService injects ExtJsonValidator, calls applyTo() in
    create() before saving (null-safe so the
    SalesOrderConfirmedSubscriber's auto-spawn path still works —
    verified by smoke test).
  - CreateWorkOrderCommand + CreateWorkOrderRequest gain an `ext`
    field that flows through to the validator.
  - WorkOrderResponse gains an `ext: Map<String, Any?>` field; the
    response mapper signature changes to `toResponse(service)` to
    reach the validator via a convenience parseExt delegate on the
    service (same pattern as the other four PBCs).
  - pbc-production Gradle build adds `implementation(project(":platform:platform-metadata"))`.

Metadata (production.yml):
  - Permission keys extended to match the v2 state machine:
    production.work-order.start (was missing) and
    production.work-order.scrap (was missing). The existing
    .read / .create / .complete / .cancel keys stay.
  - Two custom fields declared:
      * production_priority (enum: low, normal, high, urgent)
      * production_routing_notes (string, maxLength 1024)
    Both are optional and non-PII; an operator can now add
    priority and routing notes to a work order through the public
    API without any code change, which is the whole point of
    Tier 1 customization.

Unit tests: WorkOrderServiceTest constructor updated to pass the
new extValidator dependency and stub applyTo/parseExt as no-ops.
No behavioral test changes — ext validation is covered by
ExtJsonValidatorTest and the platform-wide smoke tests.

Smoke verified end-to-end against real Postgres:
  - GET /_meta/metadata/custom-fields/WorkOrder now returns both
    declarations with correct enum sets and maxLength.
  - POST /work-orders with valid ext {production_priority:"high",
    production_routing_notes:"Rush for customer demo"} → 201,
    canonical form persisted, round-trips via GET.
  - POST with invalid enum value → 400 "value 'emergency' is not
    in allowed set [low, normal, high, urgent]".
  - POST with unknown ext key → 400 "ext contains undeclared
    key(s) for 'WorkOrder': [unknown_field]".
  - Auto-spawn from confirmed SO → DRAFT work order with empty
    ext `{}`, confirming the applyTo(null) null-safe path.

Five of the eight PBCs now participate in the HasExt pattern:
Partner, Location, SalesOrder, PurchaseOrder, WorkOrder. The
remaining three (Item, Uom, JournalEntry) either have their own
custom-field story in separate entities or are derived state.

246 unit tests, all green. 18 Gradle subprojects.
pbc/pbc-production/build.gradle.kts
... ... @@ -36,6 +36,7 @@ dependencies {
36 36 api(project(":api:api-v1"))
37 37 implementation(project(":platform:platform-persistence"))
38 38 implementation(project(":platform:platform-security"))
  39 + implementation(project(":platform:platform-metadata"))
39 40  
40 41 implementation(libs.kotlin.stdlib)
41 42 implementation(libs.kotlin.reflect)
... ...
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt
... ... @@ -15,6 +15,7 @@ import org.vibeerp.pbc.production.domain.WorkOrder
15 15 import org.vibeerp.pbc.production.domain.WorkOrderInput
16 16 import org.vibeerp.pbc.production.domain.WorkOrderStatus
17 17 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
  18 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
18 19 import java.math.BigDecimal
19 20 import java.time.LocalDate
20 21 import java.util.UUID
... ... @@ -70,6 +71,7 @@ class WorkOrderService(
70 71 private val catalogApi: CatalogApi,
71 72 private val inventoryApi: InventoryApi,
72 73 private val eventBus: EventBus,
  74 + private val extValidator: ExtJsonValidator,
73 75 ) {
74 76  
75 77 private val log = LoggerFactory.getLogger(WorkOrderService::class.java)
... ... @@ -87,6 +89,14 @@ class WorkOrderService(
87 89 fun findBySourceSalesOrderCode(salesOrderCode: String): List<WorkOrder> =
88 90 orders.findBySourceSalesOrderCode(salesOrderCode)
89 91  
  92 + /**
  93 + * Convenience passthrough for response mappers — delegates to
  94 + * [ExtJsonValidator.parseExt]. Returns an empty map on an empty
  95 + * or unparseable column so response rendering never 500s.
  96 + */
  97 + fun parseExt(order: WorkOrder): Map<String, Any?> =
  98 + extValidator.parseExt(order)
  99 +
90 100 fun create(command: CreateWorkOrderCommand): WorkOrder {
91 101 require(!orders.existsByCode(command.code)) {
92 102 "work order code '${command.code}' is already taken"
... ... @@ -135,6 +145,10 @@ class WorkOrderService(
135 145 dueDate = command.dueDate,
136 146 sourceSalesOrderCode = command.sourceSalesOrderCode,
137 147 )
  148 + // Validate and apply any Tier 1 custom-field values. applyTo
  149 + // is null-safe — the SalesOrderConfirmedSubscriber's auto-spawn
  150 + // path passes null and gets the default empty ext.
  151 + extValidator.applyTo(order, command.ext)
138 152 // Attach BOM children BEFORE the first save so Hibernate
139 153 // cascades the whole graph in one commit.
140 154 for (input in command.inputs) {
... ... @@ -370,6 +384,14 @@ data class CreateWorkOrderCommand(
370 384 * complete() writes only the PRODUCTION_RECEIPT).
371 385 */
372 386 val inputs: List<WorkOrderInputCommand> = emptyList(),
  387 + /**
  388 + * Tier 1 custom-field values. Validated against declarations
  389 + * under entity name `WorkOrder` in `metadata__custom_field` via
  390 + * [ExtJsonValidator.applyTo]. Null is legal and produces an
  391 + * empty `{}` column; the SalesOrderConfirmedSubscriber's
  392 + * auto-spawn path uses null.
  393 + */
  394 + val ext: Map<String, Any?>? = null,
373 395 )
374 396  
375 397 /**
... ...
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.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
... ... @@ -101,7 +102,7 @@ class WorkOrder(
101 102 status: WorkOrderStatus = WorkOrderStatus.DRAFT,
102 103 dueDate: LocalDate? = null,
103 104 sourceSalesOrderCode: String? = null,
104   -) : AuditedJpaEntity() {
  105 +) : AuditedJpaEntity(), HasExt {
105 106  
106 107 @Column(name = "code", nullable = false, length = 64)
107 108 var code: String = code
... ... @@ -124,7 +125,9 @@ class WorkOrder(
124 125  
125 126 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
126 127 @JdbcTypeCode(SqlTypes.JSON)
127   - var ext: String = "{}"
  128 + override var ext: String = "{}"
  129 +
  130 + override val extEntityName: String get() = ENTITY_NAME
128 131  
129 132 /**
130 133 * The BOM — zero or more raw material lines consumed per unit of
... ... @@ -150,6 +153,11 @@ class WorkOrder(
150 153  
151 154 override fun toString(): String =
152 155 "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', status=$status, inputs=${inputs.size})"
  156 +
  157 + companion object {
  158 + /** Key under which WorkOrder's custom fields are declared in `metadata__custom_field`. */
  159 + const val ENTITY_NAME: String = "WorkOrder"
  160 + }
153 161 }
154 162  
155 163 /**
... ...
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt
... ... @@ -42,27 +42,27 @@ class WorkOrderController(
42 42 @GetMapping
43 43 @RequirePermission("production.work-order.read")
44 44 fun list(): List<WorkOrderResponse> =
45   - workOrderService.list().map { it.toResponse() }
  45 + workOrderService.list().map { it.toResponse(workOrderService) }
46 46  
47 47 @GetMapping("/{id}")
48 48 @RequirePermission("production.work-order.read")
49 49 fun get(@PathVariable id: UUID): ResponseEntity<WorkOrderResponse> {
50 50 val order = workOrderService.findById(id) ?: return ResponseEntity.notFound().build()
51   - return ResponseEntity.ok(order.toResponse())
  51 + return ResponseEntity.ok(order.toResponse(workOrderService))
52 52 }
53 53  
54 54 @GetMapping("/by-code/{code}")
55 55 @RequirePermission("production.work-order.read")
56 56 fun getByCode(@PathVariable code: String): ResponseEntity<WorkOrderResponse> {
57 57 val order = workOrderService.findByCode(code) ?: return ResponseEntity.notFound().build()
58   - return ResponseEntity.ok(order.toResponse())
  58 + return ResponseEntity.ok(order.toResponse(workOrderService))
59 59 }
60 60  
61 61 @PostMapping
62 62 @ResponseStatus(HttpStatus.CREATED)
63 63 @RequirePermission("production.work-order.create")
64 64 fun create(@RequestBody @Valid request: CreateWorkOrderRequest): WorkOrderResponse =
65   - workOrderService.create(request.toCommand()).toResponse()
  65 + workOrderService.create(request.toCommand()).toResponse(workOrderService)
66 66  
67 67 /**
68 68 * Start a DRAFT work order — flip to IN_PROGRESS. v2 state
... ... @@ -74,7 +74,7 @@ class WorkOrderController(
74 74 @PostMapping("/{id}/start")
75 75 @RequirePermission("production.work-order.start")
76 76 fun start(@PathVariable id: UUID): WorkOrderResponse =
77   - workOrderService.start(id).toResponse()
  77 + workOrderService.start(id).toResponse(workOrderService)
78 78  
79 79 /**
80 80 * Mark an IN_PROGRESS work order as COMPLETED. Atomically:
... ... @@ -89,12 +89,12 @@ class WorkOrderController(
89 89 @PathVariable id: UUID,
90 90 @RequestBody @Valid request: CompleteWorkOrderRequest,
91 91 ): WorkOrderResponse =
92   - workOrderService.complete(id, request.outputLocationCode).toResponse()
  92 + workOrderService.complete(id, request.outputLocationCode).toResponse(workOrderService)
93 93  
94 94 @PostMapping("/{id}/cancel")
95 95 @RequirePermission("production.work-order.cancel")
96 96 fun cancel(@PathVariable id: UUID): WorkOrderResponse =
97   - workOrderService.cancel(id).toResponse()
  97 + workOrderService.cancel(id).toResponse(workOrderService)
98 98  
99 99 /**
100 100 * Scrap some of a COMPLETED work order's output. Writes a
... ... @@ -112,7 +112,7 @@ class WorkOrderController(
112 112 scrapLocationCode = request.scrapLocationCode,
113 113 quantity = request.quantity,
114 114 note = request.note,
115   - ).toResponse()
  115 + ).toResponse(workOrderService)
116 116 }
117 117  
118 118 // ─── DTOs ────────────────────────────────────────────────────────────
... ... @@ -129,6 +129,11 @@ data class CreateWorkOrderRequest(
129 129 * complete().
130 130 */
131 131 @field:Valid val inputs: List<WorkOrderInputRequest> = emptyList(),
  132 + /**
  133 + * Tier 1 custom-field values. Validated against declarations
  134 + * under entity name `WorkOrder`.
  135 + */
  136 + val ext: Map<String, Any?>? = null,
132 137 ) {
133 138 fun toCommand(): CreateWorkOrderCommand = CreateWorkOrderCommand(
134 139 code = code,
... ... @@ -137,6 +142,7 @@ data class CreateWorkOrderRequest(
137 142 dueDate = dueDate,
138 143 sourceSalesOrderCode = sourceSalesOrderCode,
139 144 inputs = inputs.map { it.toCommand() },
  145 + ext = ext,
140 146 )
141 147 }
142 148  
... ... @@ -190,6 +196,7 @@ data class WorkOrderResponse(
190 196 val dueDate: LocalDate?,
191 197 val sourceSalesOrderCode: String?,
192 198 val inputs: List<WorkOrderInputResponse>,
  199 + val ext: Map<String, Any?>,
193 200 )
194 201  
195 202 data class WorkOrderInputResponse(
... ... @@ -200,7 +207,7 @@ data class WorkOrderInputResponse(
200 207 val sourceLocationCode: String,
201 208 )
202 209  
203   -private fun WorkOrder.toResponse(): WorkOrderResponse =
  210 +private fun WorkOrder.toResponse(service: WorkOrderService): WorkOrderResponse =
204 211 WorkOrderResponse(
205 212 id = id,
206 213 code = code,
... ... @@ -210,6 +217,7 @@ private fun WorkOrder.toResponse(): WorkOrderResponse =
210 217 dueDate = dueDate,
211 218 sourceSalesOrderCode = sourceSalesOrderCode,
212 219 inputs = inputs.map { it.toResponse() },
  220 + ext = service.parseExt(this),
213 221 )
214 222  
215 223 private fun WorkOrderInput.toResponse(): WorkOrderInputResponse =
... ...
pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml
... ... @@ -14,10 +14,36 @@ permissions:
14 14 description: Read work orders
15 15 - key: production.work-order.create
16 16 description: Create draft work orders
  17 + - key: production.work-order.start
  18 + description: Start a work order (DRAFT → IN_PROGRESS)
17 19 - key: production.work-order.complete
18   - description: Mark a draft work order completed (DRAFT → COMPLETED, credits inventory atomically)
  20 + description: Complete a work order (IN_PROGRESS → COMPLETED; issues BOM materials and credits finished goods atomically)
19 21 - key: production.work-order.cancel
20   - description: Cancel a draft work order (DRAFT → CANCELLED)
  22 + description: Cancel a work order (DRAFT or IN_PROGRESS → CANCELLED)
  23 + - key: production.work-order.scrap
  24 + description: Scrap some output from a COMPLETED work order (writes a negative ADJUSTMENT, status unchanged)
  25 +
  26 +customFields:
  27 + - key: production_priority
  28 + targetEntity: WorkOrder
  29 + type:
  30 + kind: enum
  31 + allowedValues: [low, normal, high, urgent]
  32 + required: false
  33 + pii: false
  34 + labelTranslations:
  35 + en: Priority
  36 + zh-CN: 优先级
  37 + - key: production_routing_notes
  38 + targetEntity: WorkOrder
  39 + type:
  40 + kind: string
  41 + maxLength: 1024
  42 + required: false
  43 + pii: false
  44 + labelTranslations:
  45 + en: Routing notes
  46 + zh-CN: 工艺说明
21 47  
22 48 menus:
23 49 - path: /production/work-orders
... ...
pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.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.production.WorkOrderCancelledEvent
21 22 import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent
... ... @@ -30,6 +31,7 @@ import org.vibeerp.pbc.production.domain.WorkOrder
30 31 import org.vibeerp.pbc.production.domain.WorkOrderInput
31 32 import org.vibeerp.pbc.production.domain.WorkOrderStatus
32 33 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
  34 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
33 35 import java.math.BigDecimal
34 36 import java.util.Optional
35 37 import java.util.UUID
... ... @@ -40,6 +42,7 @@ class WorkOrderServiceTest {
40 42 private lateinit var catalogApi: CatalogApi
41 43 private lateinit var inventoryApi: InventoryApi
42 44 private lateinit var eventBus: EventBus
  45 + private lateinit var extValidator: ExtJsonValidator
43 46 private lateinit var service: WorkOrderService
44 47  
45 48 @BeforeEach
... ... @@ -48,10 +51,13 @@ class WorkOrderServiceTest {
48 51 catalogApi = mockk()
49 52 inventoryApi = mockk()
50 53 eventBus = mockk()
  54 + extValidator = mockk()
51 55 every { orders.existsByCode(any()) } returns false
52 56 every { orders.save(any<WorkOrder>()) } answers { firstArg() }
53 57 every { eventBus.publish(any()) } just Runs
54   - service = WorkOrderService(orders, catalogApi, inventoryApi, eventBus)
  58 + every { extValidator.applyTo(any<HasExt>(), any()) } just Runs
  59 + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap()
  60 + service = WorkOrderService(orders, catalogApi, inventoryApi, eventBus, extValidator)
55 61 }
56 62  
57 63 private fun stubItem(code: String) {
... ...