Commit 35ad8a8d68416bd9ac4e57935224ef790edc3d0c
1 parent
f4407913
feat(api-v1+production+ref-plugin): thread v3 routings through event-driven WO spawn
Extends WorkOrderRequestedEvent with an optional routing so a
producer — core PBC or customer plug-in — can attach shop-floor
operations to a requested work order without importing any
pbc-production internals. The reference printing-shop plug-in's
quote-to-work-order BPMN now ships a 4-step default routing
(CUT → PRINT → FOLD → BIND) end-to-end through the public api.v1
surface.
**api.v1 surface additions (additive, defaulted).**
- New public data class `RoutingOperationSpec(lineNo, operationCode,
workCenter, standardMinutes)` in
`api.v1.event.production.WorkOrderEvents` with init-block
invariants matching pbc-production v3's internal validation
(positive lineNo, non-blank operationCode + workCenter,
non-negative standardMinutes).
- `WorkOrderRequestedEvent` gains an `operations: List<RoutingOperationSpec>`
field, defaulted to `emptyList()`. Existing callers compile
without changes; the event's init block now also validates
that every operation has a unique lineNo. Convention matches
the other v1 events that already carry defaulted `eventId` and
`occurredAt` — additive within a major version.
**pbc-production subscriber wiring.**
- `WorkOrderRequestedSubscriber.handle` now maps
`event.operations` → `WorkOrderOperationCommand` 1:1 and passes
them to `CreateWorkOrderCommand`. Empty list keeps the v2
behavior exactly (auto-spawned orders from the SO path still
get no routing and walk DRAFT → IN_PROGRESS → COMPLETED without
any gate); a non-empty list feeds the new v3 WorkOrderOperation
children and forces a sequential walk on the shop floor. The
log line now includes `ops=<size>` so operators can see at a
glance whether a WO came with a routing.
**Reference plug-in.**
- `CreateWorkOrderFromQuoteTaskHandler` now attaches
`DEFAULT_PRINTING_SHOP_ROUTING`: a 4-step sequence modeled on
the reference business doc's brochure production flow. Each
step gets its own work center (PRINTING-CUT-01,
PRINTING-PRESS-A, PRINTING-FOLD-01, PRINTING-BIND-01) so a
future shop-floor dashboard can show which station is running
which job. Standard times are round-number placeholders
(15/30/10/20 minutes) — a real customer tunes them from
historical data. Deliberately hard-coded in v1: a real shop
with a dozen different flows would either ship a richer plug-in
that picks routing per item type, or wait for a future Tier 1
"routing template" metadata entity. v1 just proves the
event-driven seam carries v3 operations end-to-end.
**Why this is the right shape.**
- Zero new compile-time coupling. The plug-in imports only
`api.v1.event.production.RoutingOperationSpec`; the plug-in
linter would refuse any reach into `pbc.production.*`.
- Core pbc-production stays ignorant of the plug-in: the
subscriber doesn't know where the event came from.
- The same `WorkOrderRequestedEvent` path now works for ANY
producer — the next customer plug-in that spawns routed work
orders gets zero core changes.
**Tests.** New `WorkOrderRequestedSubscriberTest.handle passes
event operations through as WorkOrderOperationCommand` asserts
the 1:1 mapping of RoutingOperationSpec → WorkOrderOperationCommand.
The existing test gains one assertion that an empty `operations`
list on the event produces an empty `operations` list on the
command (backwards-compat lock-in).
**Smoke-tested end-to-end against real Postgres:**
1. POST /api/v1/workflow/process-instances with processDefinitionKey
`plugin-printing-shop-quote-to-work-order` and variables
`{quoteCode: "Q-ROUTING-001", itemCode: "FG-BROCHURE", quantity: 250}`
2. BPMN runs through CreateWorkOrderFromQuoteTaskHandler,
publishes WorkOrderRequestedEvent with 4 operations
3. pbc-production subscriber creates WO `WO-FROM-PRINTINGSHOP-Q-ROUTING-001`
4. GET /api/v1/production/work-orders/by-code/... returns the WO
with status=DRAFT and 4 operations (CUT/PRINT/FOLD/BIND) all
PENDING, each with its own work_center and standard_minutes.
This is the framework's first business flow where a customer
plug-in provides a routing to a core PBC end-to-end through
api.v1 alone. Closes the loop between the v3 routings feature
(commit fa867189) and the executable acceptance test in the
reference plug-in.
24 modules, 350 unit tests (+1), all green.
Showing
4 changed files
with
161 additions
and
3 deletions
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt
| ... | ... | @@ -80,6 +80,17 @@ public data class WorkOrderRequestedEvent( |
| 80 | 80 | public val outputItemCode: String, |
| 81 | 81 | public val outputQuantity: BigDecimal, |
| 82 | 82 | public val sourceReference: String, |
| 83 | + /** | |
| 84 | + * Optional routing — zero or more shop-floor operations to | |
| 85 | + * attach to the created work order (pbc-production v3). Empty | |
| 86 | + * list is the default and matches v2 behavior exactly (no | |
| 87 | + * sequential walk, no gate on complete). A non-empty list | |
| 88 | + * feeds WorkOrderOperation children on the created aggregate. | |
| 89 | + * | |
| 90 | + * Additive to the existing fields — defaulted so producers | |
| 91 | + * that don't yet speak routings compile without changes. | |
| 92 | + */ | |
| 93 | + public val operations: List<RoutingOperationSpec> = emptyList(), | |
| 83 | 94 | override val eventId: Id<DomainEvent> = Id.random(), |
| 84 | 95 | override val occurredAt: Instant = Instant.now(), |
| 85 | 96 | ) : DomainEvent { |
| ... | ... | @@ -93,6 +104,48 @@ public data class WorkOrderRequestedEvent( |
| 93 | 104 | "WorkOrderRequestedEvent.outputQuantity must be positive (got $outputQuantity)" |
| 94 | 105 | } |
| 95 | 106 | require(sourceReference.isNotBlank()) { "WorkOrderRequestedEvent.sourceReference must not be blank" } |
| 107 | + val seenLineNos = HashSet<Int>(operations.size) | |
| 108 | + for (op in operations) { | |
| 109 | + require(seenLineNos.add(op.lineNo)) { | |
| 110 | + "WorkOrderRequestedEvent.operations has duplicate lineNo ${op.lineNo}" | |
| 111 | + } | |
| 112 | + } | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +/** | |
| 117 | + * One routing step on a [WorkOrderRequestedEvent]. Mirrors the | |
| 118 | + * shape of pbc-production v3's internal `WorkOrderOperationCommand` | |
| 119 | + * but lives in api.v1 so producers — core PBCs AND customer | |
| 120 | + * plug-ins — can populate it without importing pbc-production. | |
| 121 | + * | |
| 122 | + * @property lineNo 1-based sequence number. Must be unique within | |
| 123 | + * the event's operations list; determines the sequential walk | |
| 124 | + * order (op N must complete before op N+1 can start). | |
| 125 | + * @property operationCode short label for the step (e.g. "CUT", | |
| 126 | + * "PRINT", "BIND"). Free-form; no enumeration. | |
| 127 | + * @property workCenter free-form label for the resource running | |
| 128 | + * this step (e.g. "PRESS-A"). Will eventually become a FK to a | |
| 129 | + * future `pbc-equipment` PBC; kept as a string for now to avoid | |
| 130 | + * pre-emptively coupling PBC schemas. | |
| 131 | + * @property standardMinutes planned runtime of this step per WORK | |
| 132 | + * ORDER (not per unit of output). A 100-brochure cut step | |
| 133 | + * supposed to take 30 minutes passes `BigDecimal("30")`. Must | |
| 134 | + * be non-negative; zero is legal for instantaneous steps. | |
| 135 | + */ | |
| 136 | +public data class RoutingOperationSpec( | |
| 137 | + public val lineNo: Int, | |
| 138 | + public val operationCode: String, | |
| 139 | + public val workCenter: String, | |
| 140 | + public val standardMinutes: BigDecimal, | |
| 141 | +) { | |
| 142 | + init { | |
| 143 | + require(lineNo > 0) { "RoutingOperationSpec.lineNo must be positive (got $lineNo)" } | |
| 144 | + require(operationCode.isNotBlank()) { "RoutingOperationSpec.operationCode must not be blank" } | |
| 145 | + require(workCenter.isNotBlank()) { "RoutingOperationSpec.workCenter must not be blank" } | |
| 146 | + require(standardMinutes.signum() >= 0) { | |
| 147 | + "RoutingOperationSpec.standardMinutes must be non-negative (got $standardMinutes)" | |
| 148 | + } | |
| 96 | 149 | } |
| 97 | 150 | } |
| 98 | 151 | ... | ... |
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriber.kt
| ... | ... | @@ -7,6 +7,7 @@ import org.vibeerp.api.v1.event.EventBus |
| 7 | 7 | import org.vibeerp.api.v1.event.EventListener |
| 8 | 8 | import org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent |
| 9 | 9 | import org.vibeerp.pbc.production.application.CreateWorkOrderCommand |
| 10 | +import org.vibeerp.pbc.production.application.WorkOrderOperationCommand | |
| 10 | 11 | import org.vibeerp.pbc.production.application.WorkOrderService |
| 11 | 12 | |
| 12 | 13 | /** |
| ... | ... | @@ -76,8 +77,9 @@ class WorkOrderRequestedSubscriber( |
| 76 | 77 | return |
| 77 | 78 | } |
| 78 | 79 | log.info( |
| 79 | - "[production] WorkOrderRequestedEvent creating work order '{}' for item '{}' x {} (source='{}')", | |
| 80 | - event.code, event.outputItemCode, event.outputQuantity, event.sourceReference, | |
| 80 | + "[production] WorkOrderRequestedEvent creating work order '{}' for item '{}' x {} " + | |
| 81 | + "with {} routing operation(s) (source='{}')", | |
| 82 | + event.code, event.outputItemCode, event.outputQuantity, event.operations.size, event.sourceReference, | |
| 81 | 83 | ) |
| 82 | 84 | workOrders.create( |
| 83 | 85 | CreateWorkOrderCommand( |
| ... | ... | @@ -89,6 +91,20 @@ class WorkOrderRequestedSubscriber( |
| 89 | 91 | // logged above and echoed into the audit trail via |
| 90 | 92 | // the event itself, which the outbox persists. |
| 91 | 93 | sourceSalesOrderCode = null, |
| 94 | + // v3: pass through any routing operations the | |
| 95 | + // producer attached to the event. Empty list keeps | |
| 96 | + // the v2 behavior exactly (no sequential walk, no | |
| 97 | + // gate on complete). The conversion is 1:1 — | |
| 98 | + // RoutingOperationSpec is the api.v1 mirror of the | |
| 99 | + // internal WorkOrderOperationCommand. | |
| 100 | + operations = event.operations.map { | |
| 101 | + WorkOrderOperationCommand( | |
| 102 | + lineNo = it.lineNo, | |
| 103 | + operationCode = it.operationCode, | |
| 104 | + workCenter = it.workCenter, | |
| 105 | + standardMinutes = it.standardMinutes, | |
| 106 | + ) | |
| 107 | + }, | |
| 92 | 108 | ), |
| 93 | 109 | ) |
| 94 | 110 | } | ... | ... |
pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriberTest.kt
| ... | ... | @@ -11,6 +11,7 @@ import io.mockk.verify |
| 11 | 11 | import org.junit.jupiter.api.Test |
| 12 | 12 | import org.vibeerp.api.v1.event.EventBus |
| 13 | 13 | import org.vibeerp.api.v1.event.EventListener |
| 14 | +import org.vibeerp.api.v1.event.production.RoutingOperationSpec | |
| 14 | 15 | import org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent |
| 15 | 16 | import org.vibeerp.pbc.production.application.CreateWorkOrderCommand |
| 16 | 17 | import org.vibeerp.pbc.production.application.WorkOrderService |
| ... | ... | @@ -59,6 +60,41 @@ class WorkOrderRequestedSubscriberTest { |
| 59 | 60 | assertThat(captured.captured.outputQuantity).isEqualTo(BigDecimal("500")) |
| 60 | 61 | assertThat(captured.captured.sourceSalesOrderCode).isEqualTo(null) |
| 61 | 62 | assertThat(captured.captured.inputs.size).isEqualTo(0) |
| 63 | + // v3 backwards compat: empty operations on the event → | |
| 64 | + // empty operations on the command. | |
| 65 | + assertThat(captured.captured.operations.size).isEqualTo(0) | |
| 66 | + } | |
| 67 | + | |
| 68 | + @Test | |
| 69 | + fun `handle passes event operations through as WorkOrderOperationCommand`() { | |
| 70 | + val eventBus = mockk<EventBus>(relaxed = true) | |
| 71 | + val workOrders = mockk<WorkOrderService>() | |
| 72 | + val captured = slot<CreateWorkOrderCommand>() | |
| 73 | + every { workOrders.findByCode("WO-WITH-ROUTING") } returns null | |
| 74 | + every { workOrders.create(capture(captured)) } returns fakeWorkOrder("WO-WITH-ROUTING") | |
| 75 | + | |
| 76 | + val subscriber = WorkOrderRequestedSubscriber(eventBus, workOrders) | |
| 77 | + subscriber.handle( | |
| 78 | + WorkOrderRequestedEvent( | |
| 79 | + code = "WO-WITH-ROUTING", | |
| 80 | + outputItemCode = "BOOK", | |
| 81 | + outputQuantity = BigDecimal("10"), | |
| 82 | + sourceReference = "test", | |
| 83 | + operations = listOf( | |
| 84 | + RoutingOperationSpec(1, "CUT", "WC-A", BigDecimal("5")), | |
| 85 | + RoutingOperationSpec(2, "PRINT", "WC-B", BigDecimal("15")), | |
| 86 | + ), | |
| 87 | + ), | |
| 88 | + ) | |
| 89 | + | |
| 90 | + val ops = captured.captured.operations | |
| 91 | + assertThat(ops.size).isEqualTo(2) | |
| 92 | + assertThat(ops[0].lineNo).isEqualTo(1) | |
| 93 | + assertThat(ops[0].operationCode).isEqualTo("CUT") | |
| 94 | + assertThat(ops[0].workCenter).isEqualTo("WC-A") | |
| 95 | + assertThat(ops[0].standardMinutes).isEqualTo(BigDecimal("5")) | |
| 96 | + assertThat(ops[1].lineNo).isEqualTo(2) | |
| 97 | + assertThat(ops[1].operationCode).isEqualTo("PRINT") | |
| 62 | 98 | } |
| 63 | 99 | |
| 64 | 100 | @Test | ... | ... |
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/CreateWorkOrderFromQuoteTaskHandler.kt
| 1 | 1 | package org.vibeerp.reference.printingshop.workflow |
| 2 | 2 | |
| 3 | +import org.vibeerp.api.v1.event.production.RoutingOperationSpec | |
| 3 | 4 | import org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent |
| 4 | 5 | import org.vibeerp.api.v1.plugin.PluginContext |
| 5 | 6 | import org.vibeerp.api.v1.workflow.TaskContext |
| ... | ... | @@ -75,9 +76,25 @@ class CreateWorkOrderFromQuoteTaskHandler( |
| 75 | 76 | val workOrderCode = "WO-FROM-PRINTINGSHOP-$quoteCode" |
| 76 | 77 | val sourceReference = "plugin:printing-shop:quote:$quoteCode" |
| 77 | 78 | |
| 79 | + // v3: attach a default printing-shop routing so the created | |
| 80 | + // work order already has a shop-floor sequence to walk. | |
| 81 | + // Four steps modeled on the reference business doc's | |
| 82 | + // brochure production flow — cut the stock, print on the | |
| 83 | + // press, fold the sheets, bind the spine. Each gets a | |
| 84 | + // plausible standard time and a distinct work center so | |
| 85 | + // the operator dashboard can show which station is busy. | |
| 86 | + // | |
| 87 | + // This is deliberately hard-coded in v1: a real printing | |
| 88 | + // shop with a dozen different flows would either (a) ship | |
| 89 | + // a richer plug-in that picks a routing per item type, or | |
| 90 | + // (b) wait for the future Tier 1 "routing template" | |
| 91 | + // metadata entity. v1 just proves the event-driven seam | |
| 92 | + // carries v3 operations end-to-end. | |
| 93 | + val routing = DEFAULT_PRINTING_SHOP_ROUTING | |
| 94 | + | |
| 78 | 95 | context.logger.info( |
| 79 | 96 | "quote $quoteCode: publishing WorkOrderRequestedEvent " + |
| 80 | - "(code=$workOrderCode, item=$itemCode, qty=$quantity)", | |
| 97 | + "(code=$workOrderCode, item=$itemCode, qty=$quantity, ops=${routing.size})", | |
| 81 | 98 | ) |
| 82 | 99 | |
| 83 | 100 | context.eventBus.publish( |
| ... | ... | @@ -86,6 +103,7 @@ class CreateWorkOrderFromQuoteTaskHandler( |
| 86 | 103 | outputItemCode = itemCode, |
| 87 | 104 | outputQuantity = quantity, |
| 88 | 105 | sourceReference = sourceReference, |
| 106 | + operations = routing, | |
| 89 | 107 | ), |
| 90 | 108 | ) |
| 91 | 109 | |
| ... | ... | @@ -95,5 +113,40 @@ class CreateWorkOrderFromQuoteTaskHandler( |
| 95 | 113 | |
| 96 | 114 | companion object { |
| 97 | 115 | const val KEY: String = "printing_shop.quote.create_work_order" |
| 116 | + | |
| 117 | + /** | |
| 118 | + * The default 4-step printing-shop routing attached to | |
| 119 | + * every WO spawned from a quote. Each step names its own | |
| 120 | + * work center so a future shop-floor dashboard can show | |
| 121 | + * "which station is running which job" without any extra | |
| 122 | + * coupling. Standard times are round-number placeholders; | |
| 123 | + * a real customer would tune them from historical data. | |
| 124 | + */ | |
| 125 | + internal val DEFAULT_PRINTING_SHOP_ROUTING: List<RoutingOperationSpec> = listOf( | |
| 126 | + RoutingOperationSpec( | |
| 127 | + lineNo = 1, | |
| 128 | + operationCode = "CUT", | |
| 129 | + workCenter = "PRINTING-CUT-01", | |
| 130 | + standardMinutes = BigDecimal("15"), | |
| 131 | + ), | |
| 132 | + RoutingOperationSpec( | |
| 133 | + lineNo = 2, | |
| 134 | + operationCode = "PRINT", | |
| 135 | + workCenter = "PRINTING-PRESS-A", | |
| 136 | + standardMinutes = BigDecimal("30"), | |
| 137 | + ), | |
| 138 | + RoutingOperationSpec( | |
| 139 | + lineNo = 3, | |
| 140 | + operationCode = "FOLD", | |
| 141 | + workCenter = "PRINTING-FOLD-01", | |
| 142 | + standardMinutes = BigDecimal("10"), | |
| 143 | + ), | |
| 144 | + RoutingOperationSpec( | |
| 145 | + lineNo = 4, | |
| 146 | + operationCode = "BIND", | |
| 147 | + workCenter = "PRINTING-BIND-01", | |
| 148 | + standardMinutes = BigDecimal("20"), | |
| 149 | + ), | |
| 150 | + ) | |
| 98 | 151 | } |
| 99 | 152 | } | ... | ... |