Commit 35ad8a8d68416bd9ac4e57935224ef790edc3d0c

Authored by zichun
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.
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 }
... ...