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,6 +36,7 @@ dependencies {
36 api(project(":api:api-v1")) 36 api(project(":api:api-v1"))
37 implementation(project(":platform:platform-persistence")) 37 implementation(project(":platform:platform-persistence"))
38 implementation(project(":platform:platform-security")) 38 implementation(project(":platform:platform-security"))
  39 + implementation(project(":platform:platform-metadata"))
39 40
40 implementation(libs.kotlin.stdlib) 41 implementation(libs.kotlin.stdlib)
41 implementation(libs.kotlin.reflect) 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,6 +15,7 @@ import org.vibeerp.pbc.production.domain.WorkOrder
15 import org.vibeerp.pbc.production.domain.WorkOrderInput 15 import org.vibeerp.pbc.production.domain.WorkOrderInput
16 import org.vibeerp.pbc.production.domain.WorkOrderStatus 16 import org.vibeerp.pbc.production.domain.WorkOrderStatus
17 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository 17 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
  18 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
18 import java.math.BigDecimal 19 import java.math.BigDecimal
19 import java.time.LocalDate 20 import java.time.LocalDate
20 import java.util.UUID 21 import java.util.UUID
@@ -70,6 +71,7 @@ class WorkOrderService( @@ -70,6 +71,7 @@ class WorkOrderService(
70 private val catalogApi: CatalogApi, 71 private val catalogApi: CatalogApi,
71 private val inventoryApi: InventoryApi, 72 private val inventoryApi: InventoryApi,
72 private val eventBus: EventBus, 73 private val eventBus: EventBus,
  74 + private val extValidator: ExtJsonValidator,
73 ) { 75 ) {
74 76
75 private val log = LoggerFactory.getLogger(WorkOrderService::class.java) 77 private val log = LoggerFactory.getLogger(WorkOrderService::class.java)
@@ -87,6 +89,14 @@ class WorkOrderService( @@ -87,6 +89,14 @@ class WorkOrderService(
87 fun findBySourceSalesOrderCode(salesOrderCode: String): List<WorkOrder> = 89 fun findBySourceSalesOrderCode(salesOrderCode: String): List<WorkOrder> =
88 orders.findBySourceSalesOrderCode(salesOrderCode) 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 fun create(command: CreateWorkOrderCommand): WorkOrder { 100 fun create(command: CreateWorkOrderCommand): WorkOrder {
91 require(!orders.existsByCode(command.code)) { 101 require(!orders.existsByCode(command.code)) {
92 "work order code '${command.code}' is already taken" 102 "work order code '${command.code}' is already taken"
@@ -135,6 +145,10 @@ class WorkOrderService( @@ -135,6 +145,10 @@ class WorkOrderService(
135 dueDate = command.dueDate, 145 dueDate = command.dueDate,
136 sourceSalesOrderCode = command.sourceSalesOrderCode, 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 // Attach BOM children BEFORE the first save so Hibernate 152 // Attach BOM children BEFORE the first save so Hibernate
139 // cascades the whole graph in one commit. 153 // cascades the whole graph in one commit.
140 for (input in command.inputs) { 154 for (input in command.inputs) {
@@ -370,6 +384,14 @@ data class CreateWorkOrderCommand( @@ -370,6 +384,14 @@ data class CreateWorkOrderCommand(
370 * complete() writes only the PRODUCTION_RECEIPT). 384 * complete() writes only the PRODUCTION_RECEIPT).
371 */ 385 */
372 val inputs: List<WorkOrderInputCommand> = emptyList(), 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,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
@@ -101,7 +102,7 @@ class WorkOrder( @@ -101,7 +102,7 @@ class WorkOrder(
101 status: WorkOrderStatus = WorkOrderStatus.DRAFT, 102 status: WorkOrderStatus = WorkOrderStatus.DRAFT,
102 dueDate: LocalDate? = null, 103 dueDate: LocalDate? = null,
103 sourceSalesOrderCode: String? = null, 104 sourceSalesOrderCode: String? = null,
104 -) : AuditedJpaEntity() { 105 +) : AuditedJpaEntity(), HasExt {
105 106
106 @Column(name = "code", nullable = false, length = 64) 107 @Column(name = "code", nullable = false, length = 64)
107 var code: String = code 108 var code: String = code
@@ -124,7 +125,9 @@ class WorkOrder( @@ -124,7 +125,9 @@ class WorkOrder(
124 125
125 @Column(name = "ext", nullable = false, columnDefinition = "jsonb") 126 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
126 @JdbcTypeCode(SqlTypes.JSON) 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 * The BOM — zero or more raw material lines consumed per unit of 133 * The BOM — zero or more raw material lines consumed per unit of
@@ -150,6 +153,11 @@ class WorkOrder( @@ -150,6 +153,11 @@ class WorkOrder(
150 153
151 override fun toString(): String = 154 override fun toString(): String =
152 "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', status=$status, inputs=${inputs.size})" 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,27 +42,27 @@ class WorkOrderController(
42 @GetMapping 42 @GetMapping
43 @RequirePermission("production.work-order.read") 43 @RequirePermission("production.work-order.read")
44 fun list(): List<WorkOrderResponse> = 44 fun list(): List<WorkOrderResponse> =
45 - workOrderService.list().map { it.toResponse() } 45 + workOrderService.list().map { it.toResponse(workOrderService) }
46 46
47 @GetMapping("/{id}") 47 @GetMapping("/{id}")
48 @RequirePermission("production.work-order.read") 48 @RequirePermission("production.work-order.read")
49 fun get(@PathVariable id: UUID): ResponseEntity<WorkOrderResponse> { 49 fun get(@PathVariable id: UUID): ResponseEntity<WorkOrderResponse> {
50 val order = workOrderService.findById(id) ?: return ResponseEntity.notFound().build() 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 @GetMapping("/by-code/{code}") 54 @GetMapping("/by-code/{code}")
55 @RequirePermission("production.work-order.read") 55 @RequirePermission("production.work-order.read")
56 fun getByCode(@PathVariable code: String): ResponseEntity<WorkOrderResponse> { 56 fun getByCode(@PathVariable code: String): ResponseEntity<WorkOrderResponse> {
57 val order = workOrderService.findByCode(code) ?: return ResponseEntity.notFound().build() 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 @PostMapping 61 @PostMapping
62 @ResponseStatus(HttpStatus.CREATED) 62 @ResponseStatus(HttpStatus.CREATED)
63 @RequirePermission("production.work-order.create") 63 @RequirePermission("production.work-order.create")
64 fun create(@RequestBody @Valid request: CreateWorkOrderRequest): WorkOrderResponse = 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 * Start a DRAFT work order — flip to IN_PROGRESS. v2 state 68 * Start a DRAFT work order — flip to IN_PROGRESS. v2 state
@@ -74,7 +74,7 @@ class WorkOrderController( @@ -74,7 +74,7 @@ class WorkOrderController(
74 @PostMapping("/{id}/start") 74 @PostMapping("/{id}/start")
75 @RequirePermission("production.work-order.start") 75 @RequirePermission("production.work-order.start")
76 fun start(@PathVariable id: UUID): WorkOrderResponse = 76 fun start(@PathVariable id: UUID): WorkOrderResponse =
77 - workOrderService.start(id).toResponse() 77 + workOrderService.start(id).toResponse(workOrderService)
78 78
79 /** 79 /**
80 * Mark an IN_PROGRESS work order as COMPLETED. Atomically: 80 * Mark an IN_PROGRESS work order as COMPLETED. Atomically:
@@ -89,12 +89,12 @@ class WorkOrderController( @@ -89,12 +89,12 @@ class WorkOrderController(
89 @PathVariable id: UUID, 89 @PathVariable id: UUID,
90 @RequestBody @Valid request: CompleteWorkOrderRequest, 90 @RequestBody @Valid request: CompleteWorkOrderRequest,
91 ): WorkOrderResponse = 91 ): WorkOrderResponse =
92 - workOrderService.complete(id, request.outputLocationCode).toResponse() 92 + workOrderService.complete(id, request.outputLocationCode).toResponse(workOrderService)
93 93
94 @PostMapping("/{id}/cancel") 94 @PostMapping("/{id}/cancel")
95 @RequirePermission("production.work-order.cancel") 95 @RequirePermission("production.work-order.cancel")
96 fun cancel(@PathVariable id: UUID): WorkOrderResponse = 96 fun cancel(@PathVariable id: UUID): WorkOrderResponse =
97 - workOrderService.cancel(id).toResponse() 97 + workOrderService.cancel(id).toResponse(workOrderService)
98 98
99 /** 99 /**
100 * Scrap some of a COMPLETED work order's output. Writes a 100 * Scrap some of a COMPLETED work order's output. Writes a
@@ -112,7 +112,7 @@ class WorkOrderController( @@ -112,7 +112,7 @@ class WorkOrderController(
112 scrapLocationCode = request.scrapLocationCode, 112 scrapLocationCode = request.scrapLocationCode,
113 quantity = request.quantity, 113 quantity = request.quantity,
114 note = request.note, 114 note = request.note,
115 - ).toResponse() 115 + ).toResponse(workOrderService)
116 } 116 }
117 117
118 // ─── DTOs ──────────────────────────────────────────────────────────── 118 // ─── DTOs ────────────────────────────────────────────────────────────
@@ -129,6 +129,11 @@ data class CreateWorkOrderRequest( @@ -129,6 +129,11 @@ data class CreateWorkOrderRequest(
129 * complete(). 129 * complete().
130 */ 130 */
131 @field:Valid val inputs: List<WorkOrderInputRequest> = emptyList(), 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 fun toCommand(): CreateWorkOrderCommand = CreateWorkOrderCommand( 138 fun toCommand(): CreateWorkOrderCommand = CreateWorkOrderCommand(
134 code = code, 139 code = code,
@@ -137,6 +142,7 @@ data class CreateWorkOrderRequest( @@ -137,6 +142,7 @@ data class CreateWorkOrderRequest(
137 dueDate = dueDate, 142 dueDate = dueDate,
138 sourceSalesOrderCode = sourceSalesOrderCode, 143 sourceSalesOrderCode = sourceSalesOrderCode,
139 inputs = inputs.map { it.toCommand() }, 144 inputs = inputs.map { it.toCommand() },
  145 + ext = ext,
140 ) 146 )
141 } 147 }
142 148
@@ -190,6 +196,7 @@ data class WorkOrderResponse( @@ -190,6 +196,7 @@ data class WorkOrderResponse(
190 val dueDate: LocalDate?, 196 val dueDate: LocalDate?,
191 val sourceSalesOrderCode: String?, 197 val sourceSalesOrderCode: String?,
192 val inputs: List<WorkOrderInputResponse>, 198 val inputs: List<WorkOrderInputResponse>,
  199 + val ext: Map<String, Any?>,
193 ) 200 )
194 201
195 data class WorkOrderInputResponse( 202 data class WorkOrderInputResponse(
@@ -200,7 +207,7 @@ data class WorkOrderInputResponse( @@ -200,7 +207,7 @@ data class WorkOrderInputResponse(
200 val sourceLocationCode: String, 207 val sourceLocationCode: String,
201 ) 208 )
202 209
203 -private fun WorkOrder.toResponse(): WorkOrderResponse = 210 +private fun WorkOrder.toResponse(service: WorkOrderService): WorkOrderResponse =
204 WorkOrderResponse( 211 WorkOrderResponse(
205 id = id, 212 id = id,
206 code = code, 213 code = code,
@@ -210,6 +217,7 @@ private fun WorkOrder.toResponse(): WorkOrderResponse = @@ -210,6 +217,7 @@ private fun WorkOrder.toResponse(): WorkOrderResponse =
210 dueDate = dueDate, 217 dueDate = dueDate,
211 sourceSalesOrderCode = sourceSalesOrderCode, 218 sourceSalesOrderCode = sourceSalesOrderCode,
212 inputs = inputs.map { it.toResponse() }, 219 inputs = inputs.map { it.toResponse() },
  220 + ext = service.parseExt(this),
213 ) 221 )
214 222
215 private fun WorkOrderInput.toResponse(): WorkOrderInputResponse = 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,10 +14,36 @@ permissions:
14 description: Read work orders 14 description: Read work orders
15 - key: production.work-order.create 15 - key: production.work-order.create
16 description: Create draft work orders 16 description: Create draft work orders
  17 + - key: production.work-order.start
  18 + description: Start a work order (DRAFT → IN_PROGRESS)
17 - key: production.work-order.complete 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 - key: production.work-order.cancel 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 menus: 48 menus:
23 - path: /production/work-orders 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,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.production.WorkOrderCancelledEvent 21 import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent
21 import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent 22 import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent
@@ -30,6 +31,7 @@ import org.vibeerp.pbc.production.domain.WorkOrder @@ -30,6 +31,7 @@ import org.vibeerp.pbc.production.domain.WorkOrder
30 import org.vibeerp.pbc.production.domain.WorkOrderInput 31 import org.vibeerp.pbc.production.domain.WorkOrderInput
31 import org.vibeerp.pbc.production.domain.WorkOrderStatus 32 import org.vibeerp.pbc.production.domain.WorkOrderStatus
32 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository 33 import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
  34 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
33 import java.math.BigDecimal 35 import java.math.BigDecimal
34 import java.util.Optional 36 import java.util.Optional
35 import java.util.UUID 37 import java.util.UUID
@@ -40,6 +42,7 @@ class WorkOrderServiceTest { @@ -40,6 +42,7 @@ class WorkOrderServiceTest {
40 private lateinit var catalogApi: CatalogApi 42 private lateinit var catalogApi: CatalogApi
41 private lateinit var inventoryApi: InventoryApi 43 private lateinit var inventoryApi: InventoryApi
42 private lateinit var eventBus: EventBus 44 private lateinit var eventBus: EventBus
  45 + private lateinit var extValidator: ExtJsonValidator
43 private lateinit var service: WorkOrderService 46 private lateinit var service: WorkOrderService
44 47
45 @BeforeEach 48 @BeforeEach
@@ -48,10 +51,13 @@ class WorkOrderServiceTest { @@ -48,10 +51,13 @@ class WorkOrderServiceTest {
48 catalogApi = mockk() 51 catalogApi = mockk()
49 inventoryApi = mockk() 52 inventoryApi = mockk()
50 eventBus = mockk() 53 eventBus = mockk()
  54 + extValidator = mockk()
51 every { orders.existsByCode(any()) } returns false 55 every { orders.existsByCode(any()) } returns false
52 every { orders.save(any<WorkOrder>()) } answers { firstArg() } 56 every { orders.save(any<WorkOrder>()) } answers { firstArg() }
53 every { eventBus.publish(any()) } just Runs 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 private fun stubItem(code: String) { 63 private fun stubItem(code: String) {