From 3027c1f74740fdd8bf6adf3522220db4c879b7c5 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 9 Apr 2026 13:25:01 +0800 Subject: [PATCH] feat(workflow): REF.1 — plug-in BPMN publishes WorkOrderRequestedEvent → pbc-production auto-creates WorkOrder --- api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriber.kt | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriberTest.kt | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt | 5 ++++- reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/CreateWorkOrderFromQuoteTaskHandler.kt | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ reference-customer/plugin-printing-shop/src/main/resources/processes/quote-to-work-order.bpmn20.xml | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriber.kt create mode 100644 pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriberTest.kt create mode 100644 reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/CreateWorkOrderFromQuoteTaskHandler.kt create mode 100644 reference-customer/plugin-printing-shop/src/main/resources/processes/quote-to-work-order.bpmn20.xml diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt index ee37526..689a453 100644 --- a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt @@ -32,6 +32,71 @@ public sealed interface WorkOrderEvent : DomainEvent { } /** + * Inbound request to pbc-production: "please create a DRAFT work + * order for [outputItemCode] × [outputQuantity], derived from + * [sourceReference]". Published by **any** producer (core PBC or + * customer plug-in) that wants a work order created without + * importing pbc-production internals or calling a cross-PBC facade. + * + * **Why an event, not an `ext.production.ProductionApi.create(...)` + * facade:** the synchronous-facade pattern is reserved for reads + + * cross-PBC writes that must block the caller (InventoryApi.recordMovement, + * CatalogApi.findItemByCode). A work-order creation request is + * naturally asynchronous — the caller doesn't need to wait, and + * routing it through the event bus gives us: + * - Zero compile-time coupling between the producer and pbc-production + * (a plug-in doesn't depend on an `api.v1.ext.production` surface + * that doesn't exist yet). + * - The outbox for free: the row lands in `platform__event_outbox` + * inside the producer's transaction, so a crash after commit but + * before delivery replays cleanly. + * - Multiple subscribers can react: a future pbc-scheduling could + * also listen without adding a second facade. + * + * **Idempotency contract.** The subscriber MUST be idempotent on + * [code]: duplicate delivery (e.g. outbox replay) must NOT produce a + * second WorkOrder. The pbc-production subscriber uses + * `WorkOrderService.findByCode` as the short-circuit. + * + * This event is published by the reference printing-shop plug-in + * from a TaskHandler to kick off a plate → job-card → work-order + * flow (REF.1). It is NOT published by pbc-orders-sales, which uses + * the existing `SalesOrderConfirmedEvent` → `SalesOrderConfirmedSubscriber` + * path. Both paths coexist; the difference is that the SO flow + * carries its own "source sales order code" shape whereas this + * event is a generic `anything → pbc-production` channel. + * + * @property code the desired work-order code. Must be unique across + * WorkOrders. Convention: include the source system's key so + * duplicate detection is trivial (e.g. + * `WO-FROM-PRINTINGSHOP-Q-2026-001`). + * @property sourceReference opaque free-form pointer to whatever + * produced this request. Stored on the work order's audit trail + * and echoed into logs. Examples: `plugin:printing-shop:quote:Q-001`, + * `pbc-orders-sales:SO-2026-001:L2`. + */ +public data class WorkOrderRequestedEvent( + public val code: String, + public val outputItemCode: String, + public val outputQuantity: BigDecimal, + public val sourceReference: String, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : DomainEvent { + override val aggregateType: String get() = WORK_ORDER_AGGREGATE_TYPE + override val aggregateId: String get() = code + + init { + require(code.isNotBlank()) { "WorkOrderRequestedEvent.code must not be blank" } + require(outputItemCode.isNotBlank()) { "WorkOrderRequestedEvent.outputItemCode must not be blank" } + require(outputQuantity.signum() > 0) { + "WorkOrderRequestedEvent.outputQuantity must be positive (got $outputQuantity)" + } + require(sourceReference.isNotBlank()) { "WorkOrderRequestedEvent.sourceReference must not be blank" } + } +} + +/** * Emitted when a new work order is created (DRAFT). The order is * scheduled to produce [outputQuantity] units of [outputItemCode] — * no stock has moved yet. The optional [sourceSalesOrderCode] diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriber.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriber.kt new file mode 100644 index 0000000..014f4af --- /dev/null +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriber.kt @@ -0,0 +1,95 @@ +package org.vibeerp.pbc.production.event + +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.EventListener +import org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand +import org.vibeerp.pbc.production.application.WorkOrderService + +/** + * Reacts to [WorkOrderRequestedEvent] by creating a DRAFT work + * order. This is the second event-driven auto-spawn path in + * pbc-production; the first is [SalesOrderConfirmedSubscriber], + * which listens to `SalesOrderConfirmedEvent` and creates one + * work order per sales-order line. + * + * **Why a second subscriber instead of extending the first.** The + * two events serve different producers and carry different + * information. `SalesOrderConfirmedEvent` is a pbc-orders-sales + * lifecycle event — the subscriber has to round-trip back through + * `SalesOrdersApi.findByCode` to get the lines before it can decide + * what to spawn. `WorkOrderRequestedEvent` is a generic + * "anyone → pbc-production" channel — the event itself carries + * every field the subscriber needs, so there is no lookup. + * Collapsing the two into one subscriber would mean the generic + * path inherits the SO-flow's "skip if I already spawned anything + * for this source SO" short-circuit, which doesn't apply because + * generic callers set their own code. + * + * **Idempotent on event code.** `WorkOrderRequestedEvent.code` + * carries the pre-computed work-order code; the subscriber + * short-circuits via `WorkOrderService.findByCode` before calling + * `create`. A second delivery of the same event (outbox replay, + * future async bus retry) is a no-op. + * + * **Why `@PostConstruct` + a typed-class subscribe call** (matching + * the other PBC subscribers): the bean registers its listener BEFORE + * any HTTP traffic flows, so a process that publishes the event + * very early in boot (a plug-in's start-time initialization) still + * gets picked up. + */ +@Component +class WorkOrderRequestedSubscriber( + private val eventBus: EventBus, + private val workOrders: WorkOrderService, +) { + + private val log = LoggerFactory.getLogger(WorkOrderRequestedSubscriber::class.java) + + @PostConstruct + fun subscribe() { + eventBus.subscribe( + WorkOrderRequestedEvent::class.java, + EventListener { event -> handle(event) }, + ) + log.info( + "pbc-production subscribed to WorkOrderRequestedEvent via EventBus.subscribe (typed-class overload)", + ) + } + + /** + * Handle one inbound [WorkOrderRequestedEvent] by calling + * [WorkOrderService.create] with a command derived from the + * event. Visibility is `internal` so the unit test can drive it + * directly without going through the bus. + */ + internal fun handle(event: WorkOrderRequestedEvent) { + val existing = workOrders.findByCode(event.code) + if (existing != null) { + log.debug( + "[production] WorkOrderRequestedEvent ignored — work order '{}' already exists (source='{}')", + event.code, event.sourceReference, + ) + return + } + log.info( + "[production] WorkOrderRequestedEvent creating work order '{}' for item '{}' x {} (source='{}')", + event.code, event.outputItemCode, event.outputQuantity, event.sourceReference, + ) + workOrders.create( + CreateWorkOrderCommand( + code = event.code, + outputItemCode = event.outputItemCode, + outputQuantity = event.outputQuantity, + // The generic path has no source SO; it carries a + // free-form sourceReference instead. That string is + // logged above and echoed into the audit trail via + // the event itself, which the outbox persists. + sourceSalesOrderCode = null, + ), + ) + } +} diff --git a/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriberTest.kt b/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriberTest.kt new file mode 100644 index 0000000..55a61df --- /dev/null +++ b/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriberTest.kt @@ -0,0 +1,90 @@ +package org.vibeerp.pbc.production.event + +import assertk.assertThat +import assertk.assertions.isEqualTo +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.EventListener +import org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand +import org.vibeerp.pbc.production.application.WorkOrderService +import org.vibeerp.pbc.production.domain.WorkOrder +import org.vibeerp.pbc.production.domain.WorkOrderStatus +import java.math.BigDecimal + +class WorkOrderRequestedSubscriberTest { + + @Test + fun `subscribe registers one listener for WorkOrderRequestedEvent`() { + val eventBus = mockk(relaxed = true) + val workOrders = mockk() + + WorkOrderRequestedSubscriber(eventBus, workOrders).subscribe() + + verify(exactly = 1) { + eventBus.subscribe( + WorkOrderRequestedEvent::class.java, + any>(), + ) + } + } + + @Test + fun `handle creates a work order from the event fields`() { + val eventBus = mockk(relaxed = true) + val workOrders = mockk() + val captured = slot() + every { workOrders.findByCode("WO-FROM-PRINTINGSHOP-Q-007") } returns null + every { workOrders.create(capture(captured)) } returns fakeWorkOrder("WO-FROM-PRINTINGSHOP-Q-007") + + val subscriber = WorkOrderRequestedSubscriber(eventBus, workOrders) + subscriber.handle( + WorkOrderRequestedEvent( + code = "WO-FROM-PRINTINGSHOP-Q-007", + outputItemCode = "BOOK-HARDCOVER", + outputQuantity = BigDecimal("500"), + sourceReference = "plugin:printing-shop:quote:Q-007", + ), + ) + + verify(exactly = 1) { workOrders.create(any()) } + assertThat(captured.captured.code).isEqualTo("WO-FROM-PRINTINGSHOP-Q-007") + assertThat(captured.captured.outputItemCode).isEqualTo("BOOK-HARDCOVER") + assertThat(captured.captured.outputQuantity).isEqualTo(BigDecimal("500")) + assertThat(captured.captured.sourceSalesOrderCode).isEqualTo(null) + assertThat(captured.captured.inputs.size).isEqualTo(0) + } + + @Test + fun `handle short-circuits when a work order with that code already exists`() { + val eventBus = mockk(relaxed = true) + val workOrders = mockk() + every { workOrders.findByCode("WO-DUP") } returns fakeWorkOrder("WO-DUP") + + val subscriber = WorkOrderRequestedSubscriber(eventBus, workOrders) + subscriber.handle( + WorkOrderRequestedEvent( + code = "WO-DUP", + outputItemCode = "X", + outputQuantity = BigDecimal("1"), + sourceReference = "replay", + ), + ) + + verify(exactly = 0) { workOrders.create(any()) } + } + + private fun fakeWorkOrder(code: String): WorkOrder = + WorkOrder( + code = code, + outputItemCode = "X", + outputQuantity = BigDecimal("1"), + status = WorkOrderStatus.DRAFT, + ) +} diff --git a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt index 021226e..cbd2abd 100644 --- a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt +++ b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt @@ -6,6 +6,7 @@ import org.vibeerp.api.v1.plugin.HttpMethod import org.vibeerp.api.v1.plugin.PluginContext import org.vibeerp.api.v1.plugin.PluginRequest import org.vibeerp.api.v1.plugin.PluginResponse +import org.vibeerp.reference.printingshop.workflow.CreateWorkOrderFromQuoteTaskHandler import org.vibeerp.reference.printingshop.workflow.PlateApprovalTaskHandler import java.time.Instant import java.util.UUID @@ -282,8 +283,10 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP // the HTTP lambdas above use. This is the "handler-side plug-in // context access" pattern — no new api.v1 surface required. context.taskHandlers.register(PlateApprovalTaskHandler(context)) + context.taskHandlers.register(CreateWorkOrderFromQuoteTaskHandler(context)) context.logger.info( - "registered 1 TaskHandler: ${PlateApprovalTaskHandler.KEY}", + "registered 2 TaskHandlers: " + + "${PlateApprovalTaskHandler.KEY}, ${CreateWorkOrderFromQuoteTaskHandler.KEY}", ) } diff --git a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/CreateWorkOrderFromQuoteTaskHandler.kt b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/CreateWorkOrderFromQuoteTaskHandler.kt new file mode 100644 index 0000000..2ac6f44 --- /dev/null +++ b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/CreateWorkOrderFromQuoteTaskHandler.kt @@ -0,0 +1,99 @@ +package org.vibeerp.reference.printingshop.workflow + +import org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent +import org.vibeerp.api.v1.plugin.PluginContext +import org.vibeerp.api.v1.workflow.TaskContext +import org.vibeerp.api.v1.workflow.TaskHandler +import org.vibeerp.api.v1.workflow.WorkflowTask +import java.math.BigDecimal + +/** + * Plug-in TaskHandler that bridges a printing-shop BPMN process to + * pbc-production via the api.v1 event bus. Reads quote metadata off + * the workflow variables and publishes a [WorkOrderRequestedEvent]; + * pbc-production's [WorkOrderRequestedSubscriber] reacts by creating + * a DRAFT WorkOrder without this plug-in ever knowing pbc-production + * exists. + * + * **Why this is the cleanest cross-PBC seam from a plug-in:** + * - The plug-in stays on api.v1 only. The event class lives in + * `api.v1.event.production.*`; there is no compile-time + * dependency on pbc-production internals (the plug-in linter + * would reject one). + * - Core pbc-production stays ignorant of the printing-shop plug-in. + * It just subscribes to an event type anyone can fill in. When a + * second customer plug-in ships, its quote-to-work-order flow + * publishes the same event and pbc-production reacts identically. + * - The publish + pbc-production's `WorkOrderService.create` run in + * the SAME transaction because `EventBusImpl` uses synchronous + * in-process delivery with `Propagation.MANDATORY`. A failure + * anywhere rolls the whole thing back; the event never shows up + * in `platform__event_outbox` either. + * + * **Variables it reads off the workflow:** + * - `quoteCode` (String, required) — the printing-shop quote's + * business code; used as the source-reference suffix and baked + * into the work-order code. + * - `itemCode` (String, required) — the catalog item the quote + * produces. pbc-production's subscriber validates it against + * CatalogApi before creating the work order. + * - `quantity` (Number, required) — how many units the quote + * ordered. + * + * **Variables it writes back:** + * - `workOrderCode` — the derived work-order code (so a downstream + * BPMN step can look up the WO via api.v1 or HTTP). + * - `workOrderRequested` — true, for the smoke test to assert + * against. + * + * **Idempotency** is handled by the subscriber: if the handler is + * replayed, the subscriber sees the work-order code already exists + * and short-circuits. + */ +class CreateWorkOrderFromQuoteTaskHandler( + private val context: PluginContext, +) : TaskHandler { + + override fun key(): String = KEY + + override fun execute(task: WorkflowTask, ctx: TaskContext) { + val quoteCode = task.variables["quoteCode"] as? String + ?: error("CreateWorkOrderFromQuoteTaskHandler: missing 'quoteCode' variable") + val itemCode = task.variables["itemCode"] as? String + ?: error("CreateWorkOrderFromQuoteTaskHandler: missing 'itemCode' variable") + val quantity = when (val raw = task.variables["quantity"]) { + is Number -> BigDecimal(raw.toString()) + is String -> BigDecimal(raw) + null -> error("CreateWorkOrderFromQuoteTaskHandler: missing 'quantity' variable") + else -> error("CreateWorkOrderFromQuoteTaskHandler: 'quantity' has unexpected type ${raw::class}") + } + + // The work-order code follows the same convention as + // SalesOrderConfirmedSubscriber's `WO-FROM-` pattern + // so that two production paths produce comparable codes and + // a single audit grep picks them both up. + val workOrderCode = "WO-FROM-PRINTINGSHOP-$quoteCode" + val sourceReference = "plugin:printing-shop:quote:$quoteCode" + + context.logger.info( + "quote $quoteCode: publishing WorkOrderRequestedEvent " + + "(code=$workOrderCode, item=$itemCode, qty=$quantity)", + ) + + context.eventBus.publish( + WorkOrderRequestedEvent( + code = workOrderCode, + outputItemCode = itemCode, + outputQuantity = quantity, + sourceReference = sourceReference, + ), + ) + + ctx.set("workOrderCode", workOrderCode) + ctx.set("workOrderRequested", true) + } + + companion object { + const val KEY: String = "printing_shop.quote.create_work_order" + } +} diff --git a/reference-customer/plugin-printing-shop/src/main/resources/processes/quote-to-work-order.bpmn20.xml b/reference-customer/plugin-printing-shop/src/main/resources/processes/quote-to-work-order.bpmn20.xml new file mode 100644 index 0000000..975c91a --- /dev/null +++ b/reference-customer/plugin-printing-shop/src/main/resources/processes/quote-to-work-order.bpmn20.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + -- libgit2 0.22.2