Commit 8b95af948724f31ac564e40f88ccaa1a2ee41b53
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.
Showing
6 changed files
with
85 additions
and
14 deletions
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) { | ... | ... |