From 8b95af948724f31ac564e40f88ccaa1a2ee41b53 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 9 Apr 2026 10:19:13 +0800 Subject: [PATCH] feat(production): wire WorkOrder into HasExt pattern + declare custom fields --- pbc/pbc-production/build.gradle.kts | 1 + pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt | 22 ++++++++++++++++++++++ pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt | 12 ++++++++++-- pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt | 26 +++++++++++++++++--------- pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml | 30 ++++++++++++++++++++++++++++-- pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt | 8 +++++++- 6 files changed, 85 insertions(+), 14 deletions(-) diff --git a/pbc/pbc-production/build.gradle.kts b/pbc/pbc-production/build.gradle.kts index 3e2d6a5..1b5cc3a 100644 --- a/pbc/pbc-production/build.gradle.kts +++ b/pbc/pbc-production/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { api(project(":api:api-v1")) implementation(project(":platform:platform-persistence")) implementation(project(":platform:platform-security")) + implementation(project(":platform:platform-metadata")) implementation(libs.kotlin.stdlib) implementation(libs.kotlin.reflect) diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt index 45d6178..bae3d86 100644 --- a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt @@ -15,6 +15,7 @@ import org.vibeerp.pbc.production.domain.WorkOrder import org.vibeerp.pbc.production.domain.WorkOrderInput import org.vibeerp.pbc.production.domain.WorkOrderStatus import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator import java.math.BigDecimal import java.time.LocalDate import java.util.UUID @@ -70,6 +71,7 @@ class WorkOrderService( private val catalogApi: CatalogApi, private val inventoryApi: InventoryApi, private val eventBus: EventBus, + private val extValidator: ExtJsonValidator, ) { private val log = LoggerFactory.getLogger(WorkOrderService::class.java) @@ -87,6 +89,14 @@ class WorkOrderService( fun findBySourceSalesOrderCode(salesOrderCode: String): List = orders.findBySourceSalesOrderCode(salesOrderCode) + /** + * Convenience passthrough for response mappers — delegates to + * [ExtJsonValidator.parseExt]. Returns an empty map on an empty + * or unparseable column so response rendering never 500s. + */ + fun parseExt(order: WorkOrder): Map = + extValidator.parseExt(order) + fun create(command: CreateWorkOrderCommand): WorkOrder { require(!orders.existsByCode(command.code)) { "work order code '${command.code}' is already taken" @@ -135,6 +145,10 @@ class WorkOrderService( dueDate = command.dueDate, sourceSalesOrderCode = command.sourceSalesOrderCode, ) + // Validate and apply any Tier 1 custom-field values. applyTo + // is null-safe — the SalesOrderConfirmedSubscriber's auto-spawn + // path passes null and gets the default empty ext. + extValidator.applyTo(order, command.ext) // Attach BOM children BEFORE the first save so Hibernate // cascades the whole graph in one commit. for (input in command.inputs) { @@ -370,6 +384,14 @@ data class CreateWorkOrderCommand( * complete() writes only the PRODUCTION_RECEIPT). */ val inputs: List = emptyList(), + /** + * Tier 1 custom-field values. Validated against declarations + * under entity name `WorkOrder` in `metadata__custom_field` via + * [ExtJsonValidator.applyTo]. Null is legal and produces an + * empty `{}` column; the SalesOrderConfirmedSubscriber's + * auto-spawn path uses null. + */ + val ext: Map? = null, ) /** diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt index bde5339..358dc35 100644 --- a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt @@ -11,6 +11,7 @@ import jakarta.persistence.OrderBy import jakarta.persistence.Table import org.hibernate.annotations.JdbcTypeCode import org.hibernate.type.SqlTypes +import org.vibeerp.api.v1.entity.HasExt import org.vibeerp.platform.persistence.audit.AuditedJpaEntity import java.math.BigDecimal import java.time.LocalDate @@ -101,7 +102,7 @@ class WorkOrder( status: WorkOrderStatus = WorkOrderStatus.DRAFT, dueDate: LocalDate? = null, sourceSalesOrderCode: String? = null, -) : AuditedJpaEntity() { +) : AuditedJpaEntity(), HasExt { @Column(name = "code", nullable = false, length = 64) var code: String = code @@ -124,7 +125,9 @@ class WorkOrder( @Column(name = "ext", nullable = false, columnDefinition = "jsonb") @JdbcTypeCode(SqlTypes.JSON) - var ext: String = "{}" + override var ext: String = "{}" + + override val extEntityName: String get() = ENTITY_NAME /** * The BOM — zero or more raw material lines consumed per unit of @@ -150,6 +153,11 @@ class WorkOrder( override fun toString(): String = "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', status=$status, inputs=${inputs.size})" + + companion object { + /** Key under which WorkOrder's custom fields are declared in `metadata__custom_field`. */ + const val ENTITY_NAME: String = "WorkOrder" + } } /** diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt index f021577..cff7634 100644 --- a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt @@ -42,27 +42,27 @@ class WorkOrderController( @GetMapping @RequirePermission("production.work-order.read") fun list(): List = - workOrderService.list().map { it.toResponse() } + workOrderService.list().map { it.toResponse(workOrderService) } @GetMapping("/{id}") @RequirePermission("production.work-order.read") fun get(@PathVariable id: UUID): ResponseEntity { val order = workOrderService.findById(id) ?: return ResponseEntity.notFound().build() - return ResponseEntity.ok(order.toResponse()) + return ResponseEntity.ok(order.toResponse(workOrderService)) } @GetMapping("/by-code/{code}") @RequirePermission("production.work-order.read") fun getByCode(@PathVariable code: String): ResponseEntity { val order = workOrderService.findByCode(code) ?: return ResponseEntity.notFound().build() - return ResponseEntity.ok(order.toResponse()) + return ResponseEntity.ok(order.toResponse(workOrderService)) } @PostMapping @ResponseStatus(HttpStatus.CREATED) @RequirePermission("production.work-order.create") fun create(@RequestBody @Valid request: CreateWorkOrderRequest): WorkOrderResponse = - workOrderService.create(request.toCommand()).toResponse() + workOrderService.create(request.toCommand()).toResponse(workOrderService) /** * Start a DRAFT work order — flip to IN_PROGRESS. v2 state @@ -74,7 +74,7 @@ class WorkOrderController( @PostMapping("/{id}/start") @RequirePermission("production.work-order.start") fun start(@PathVariable id: UUID): WorkOrderResponse = - workOrderService.start(id).toResponse() + workOrderService.start(id).toResponse(workOrderService) /** * Mark an IN_PROGRESS work order as COMPLETED. Atomically: @@ -89,12 +89,12 @@ class WorkOrderController( @PathVariable id: UUID, @RequestBody @Valid request: CompleteWorkOrderRequest, ): WorkOrderResponse = - workOrderService.complete(id, request.outputLocationCode).toResponse() + workOrderService.complete(id, request.outputLocationCode).toResponse(workOrderService) @PostMapping("/{id}/cancel") @RequirePermission("production.work-order.cancel") fun cancel(@PathVariable id: UUID): WorkOrderResponse = - workOrderService.cancel(id).toResponse() + workOrderService.cancel(id).toResponse(workOrderService) /** * Scrap some of a COMPLETED work order's output. Writes a @@ -112,7 +112,7 @@ class WorkOrderController( scrapLocationCode = request.scrapLocationCode, quantity = request.quantity, note = request.note, - ).toResponse() + ).toResponse(workOrderService) } // ─── DTOs ──────────────────────────────────────────────────────────── @@ -129,6 +129,11 @@ data class CreateWorkOrderRequest( * complete(). */ @field:Valid val inputs: List = emptyList(), + /** + * Tier 1 custom-field values. Validated against declarations + * under entity name `WorkOrder`. + */ + val ext: Map? = null, ) { fun toCommand(): CreateWorkOrderCommand = CreateWorkOrderCommand( code = code, @@ -137,6 +142,7 @@ data class CreateWorkOrderRequest( dueDate = dueDate, sourceSalesOrderCode = sourceSalesOrderCode, inputs = inputs.map { it.toCommand() }, + ext = ext, ) } @@ -190,6 +196,7 @@ data class WorkOrderResponse( val dueDate: LocalDate?, val sourceSalesOrderCode: String?, val inputs: List, + val ext: Map, ) data class WorkOrderInputResponse( @@ -200,7 +207,7 @@ data class WorkOrderInputResponse( val sourceLocationCode: String, ) -private fun WorkOrder.toResponse(): WorkOrderResponse = +private fun WorkOrder.toResponse(service: WorkOrderService): WorkOrderResponse = WorkOrderResponse( id = id, code = code, @@ -210,6 +217,7 @@ private fun WorkOrder.toResponse(): WorkOrderResponse = dueDate = dueDate, sourceSalesOrderCode = sourceSalesOrderCode, inputs = inputs.map { it.toResponse() }, + ext = service.parseExt(this), ) private fun WorkOrderInput.toResponse(): WorkOrderInputResponse = diff --git a/pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml b/pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml index 2fc7607..5d71295 100644 --- a/pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml +++ b/pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml @@ -14,10 +14,36 @@ permissions: description: Read work orders - key: production.work-order.create description: Create draft work orders + - key: production.work-order.start + description: Start a work order (DRAFT → IN_PROGRESS) - key: production.work-order.complete - description: Mark a draft work order completed (DRAFT → COMPLETED, credits inventory atomically) + description: Complete a work order (IN_PROGRESS → COMPLETED; issues BOM materials and credits finished goods atomically) - key: production.work-order.cancel - description: Cancel a draft work order (DRAFT → CANCELLED) + description: Cancel a work order (DRAFT or IN_PROGRESS → CANCELLED) + - key: production.work-order.scrap + description: Scrap some output from a COMPLETED work order (writes a negative ADJUSTMENT, status unchanged) + +customFields: + - key: production_priority + targetEntity: WorkOrder + type: + kind: enum + allowedValues: [low, normal, high, urgent] + required: false + pii: false + labelTranslations: + en: Priority + zh-CN: 优先级 + - key: production_routing_notes + targetEntity: WorkOrder + type: + kind: string + maxLength: 1024 + required: false + pii: false + labelTranslations: + en: Routing notes + zh-CN: 工艺说明 menus: - path: /production/work-orders diff --git a/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt b/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt index a5fe102..6fc7ce7 100644 --- a/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt +++ b/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt @@ -16,6 +16,7 @@ import io.mockk.verify import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.entity.HasExt import org.vibeerp.api.v1.event.EventBus import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent @@ -30,6 +31,7 @@ import org.vibeerp.pbc.production.domain.WorkOrder import org.vibeerp.pbc.production.domain.WorkOrderInput import org.vibeerp.pbc.production.domain.WorkOrderStatus import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator import java.math.BigDecimal import java.util.Optional import java.util.UUID @@ -40,6 +42,7 @@ class WorkOrderServiceTest { private lateinit var catalogApi: CatalogApi private lateinit var inventoryApi: InventoryApi private lateinit var eventBus: EventBus + private lateinit var extValidator: ExtJsonValidator private lateinit var service: WorkOrderService @BeforeEach @@ -48,10 +51,13 @@ class WorkOrderServiceTest { catalogApi = mockk() inventoryApi = mockk() eventBus = mockk() + extValidator = mockk() every { orders.existsByCode(any()) } returns false every { orders.save(any()) } answers { firstArg() } every { eventBus.publish(any()) } just Runs - service = WorkOrderService(orders, catalogApi, inventoryApi, eventBus) + every { extValidator.applyTo(any(), any()) } just Runs + every { extValidator.parseExt(any()) } returns emptyMap() + service = WorkOrderService(orders, catalogApi, inventoryApi, eventBus, extValidator) } private fun stubItem(code: String) { -- libgit2 0.22.2