Commit 3027c1f74740fdd8bf6adf3522220db4c879b7c5

Authored by zichun
1 parent 7c44c83d

feat(workflow): REF.1 — plug-in BPMN publishes WorkOrderRequestedEvent → pbc-pro…

…duction auto-creates WorkOrder

First end-to-end cross-PBC workflow driven entirely from a customer
plug-in through api.v1 surfaces. A printing-shop BPMN kicks off a
TaskHandler that publishes a generic api.v1 event; pbc-production
reacts by creating a DRAFT WorkOrder. The plug-in has zero
compile-time coupling to pbc-production, and pbc-production has zero
knowledge the plug-in exists.

## Why an event, not a facade

Two options were on the table for "how does a plug-in ask
pbc-production to create a WorkOrder":

  (a) add a new cross-PBC facade `api.v1.ext.production.ProductionApi`
      with a `createWorkOrder(command)` method
  (b) add a generic `WorkOrderRequestedEvent` in `api.v1.event.production`
      that anyone can publish — this commit

Facade pattern (a) is what InventoryApi.recordMovement and
CatalogApi.findItemByCode use: synchronous, in-transaction,
caller-blocks-on-completion. Event pattern (b) is what
SalesOrderConfirmedEvent → SalesOrderConfirmedSubscriber uses:
asynchronous over the bus, still in-transaction (the bus uses
`Propagation.MANDATORY` with synchronous delivery so a failure
rolls everything back), but the caller doesn't need a typed result.

Option (b) wins for plug-in → pbc-production:

- Plug-in compile-time surface stays identical: plug-ins already
  import `api.v1.event.*` to publish. No new api.v1.ext package.
  Zero new plug-in dependency.
- The outbox gets the row for free — a crash between publish and
  delivery replays cleanly from `platform__event_outbox`.
- A second customer plug-in shipping a different flow that ALSO
  wants to auto-spawn work orders doesn't need a second facade, just
  publishes the same event. pbc-scheduling (future) can subscribe
  to the same channel without duplicating code.

The synchronous facade pattern stays the right tool for cross-PBC
operations the caller needs to observe (read-throughs, inventory
debits that must block the current transaction). Creating a DRAFT
work order is a fire-and-trust operation — the event shape fits.

## What landed

### api.v1 — WorkOrderRequestedEvent

New event class `org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent`
with four required fields:
  - `code`: desired work-order code (must be unique globally;
    convention is to bake the source reference into it so duplicate
    detection is trivial, e.g. `WO-FROM-PRINTINGSHOP-Q-007`)
  - `outputItemCode` + `outputQuantity`: what to produce
  - `sourceReference`: opaque free-form pointer used in logs and
    the outbox audit trail. Example values:
    `plugin:printing-shop:quote:Q-007`,
    `pbc-orders-sales:SO-2026-001:L2`

The class is a `DomainEvent` (not a `WorkOrderEvent` subclass — the
existing `WorkOrderEvent` sealed interface is for LIFECYCLE events
published BY pbc-production, not for inbound requests). `init`
validators reject blank strings and non-positive quantities so a
malformed event fails fast at publish time rather than at the
subscriber.

### pbc-production — WorkOrderRequestedSubscriber

New `@Component` in `pbc/pbc-production/.../event/WorkOrderRequestedSubscriber.kt`.
Subscribes in `@PostConstruct` via the typed-class `EventBus.subscribe`
overload (same pattern as `SalesOrderConfirmedSubscriber` + the six
pbc-finance order subscribers). The subscriber:

  1. Looks up `workOrders.findByCode(event.code)` as the idempotent
     short-circuit. If a WorkOrder with that code already exists
     (outbox replay, future async bus retry, developer re-running the
     same BPMN process), the subscriber logs at DEBUG and returns.
     **Second execution of the same BPMN produces the same outbox row
     which the subscriber then skips — the database ends up with
     exactly ONE WorkOrder regardless of how many times the process
     runs.**
  2. Calls `WorkOrderService.create(CreateWorkOrderCommand(...))` with
     the event's fields. `sourceSalesOrderCode` is null because this
     is the generic path, not the SO-driven one.

Why this is a SECOND subscriber rather than extending
`SalesOrderConfirmedSubscriber`: the two events serve different
producers. `SalesOrderConfirmedEvent` is pbc-orders-sales-specific
and requires a round-trip through `SalesOrdersApi.findByCode` to
fetch the lines; `WorkOrderRequestedEvent` carries everything the
subscriber needs inline. Collapsing them would mean the generic
path inherits the SO-flow's SO-specific lookup and short-circuit
logic that doesn't apply to it.

### reference printing-shop plug-in — CreateWorkOrderFromQuoteTaskHandler

New plug-in TaskHandler in
`reference-customer/plugin-printing-shop/.../workflow/CreateWorkOrderFromQuoteTaskHandler.kt`.
Captures the `PluginContext` via constructor — same pattern as
`PlateApprovalTaskHandler` landed in `7b2ab34d` — and from inside
`execute`:

  1. Reads `quoteCode`, `itemCode`, `quantity` off the process variables
     (`quantity` accepts Number or String since Flowable's variable
     coercion is flexible).
  2. Derives `workOrderCode = "WO-FROM-PRINTINGSHOP-$quoteCode"` and
     `sourceReference = "plugin:printing-shop:quote:$quoteCode"`.
  3. Logs via `context.logger.info(...)` — the line is tagged
     `[plugin:printing-shop]` by the framework's `Slf4jPluginLogger`.
  4. Publishes `WorkOrderRequestedEvent` via `context.eventBus.publish(...)`.
     This is the first time a plug-in TaskHandler publishes a cross-PBC
     event from inside a workflow — proves the event-bus leg of the
     handler-context pattern works end-to-end.
  5. Writes `workOrderCode` + `workOrderRequested=true` back to the
     process variables so a downstream BPMN step or the HTTP caller
     can see the derived code.

The handler is registered in `PrintingShopPlugin.start(context)`
alongside `PlateApprovalTaskHandler`:

    context.taskHandlers.register(PlateApprovalTaskHandler(context))
    context.taskHandlers.register(CreateWorkOrderFromQuoteTaskHandler(context))

Teardown via `unregisterAllByOwner("printing-shop")` still works
unchanged — the scoped registrar tracks both handlers.

### reference printing-shop plug-in — quote-to-work-order.bpmn20.xml

New BPMN file `processes/quote-to-work-order.bpmn20.xml` in the
plug-in JAR. Single synchronous service task, process definition
key `plugin-printing-shop-quote-to-work-order`, service task id
`printing_shop.quote.create_work_order` (matches the handler key).
Auto-deployed by the host's `PluginProcessDeployer` at plug-in
start — the printing-shop plug-in now ships two BPMNs bundled into
one Flowable deployment, both under category `printing-shop`.

## Smoke test (fresh DB)

```
$ docker compose down -v && docker compose up -d db
$ ./gradlew :distribution:bootRun &
...
registered TaskHandler 'printing_shop.plate.approve' owner='printing-shop' ...
registered TaskHandler 'printing_shop.quote.create_work_order' owner='printing-shop' ...
[plugin:printing-shop] registered 2 TaskHandlers: printing_shop.plate.approve, printing_shop.quote.create_work_order
PluginProcessDeployer: plug-in 'printing-shop' deployed 2 BPMN resource(s) as Flowable deploymentId='1e5c...':
  [processes/quote-to-work-order.bpmn20.xml, processes/plate-approval.bpmn20.xml]
pbc-production subscribed to WorkOrderRequestedEvent via EventBus.subscribe (typed-class overload)

# 1) seed a catalog item
$ curl -X POST /api/v1/catalog/items
       {"code":"BOOK-HARDCOVER","name":"Hardcover book","itemType":"GOOD","baseUomCode":"ea"}
  → 201 BOOK-HARDCOVER

# 2) start the plug-in's quote-to-work-order BPMN
$ curl -X POST /api/v1/workflow/process-instances
       {"processDefinitionKey":"plugin-printing-shop-quote-to-work-order",
        "variables":{"quoteCode":"Q-007","itemCode":"BOOK-HARDCOVER","quantity":500}}
  → 201 {"ended":true,
         "variables":{"quoteCode":"Q-007",
                      "itemCode":"BOOK-HARDCOVER",
                      "quantity":500,
                      "workOrderCode":"WO-FROM-PRINTINGSHOP-Q-007",
                      "workOrderRequested":true}}

Log lines observed:
  [plugin:printing-shop] quote Q-007: publishing WorkOrderRequestedEvent
     (code=WO-FROM-PRINTINGSHOP-Q-007, item=BOOK-HARDCOVER, qty=500)
  [production] WorkOrderRequestedEvent creating work order 'WO-FROM-PRINTINGSHOP-Q-007'
     for item 'BOOK-HARDCOVER' x 500 (source='plugin:printing-shop:quote:Q-007')

# 3) verify the WorkOrder now exists in pbc-production
$ curl /api/v1/production/work-orders
  → [{"id":"029c2482-...",
      "code":"WO-FROM-PRINTINGSHOP-Q-007",
      "outputItemCode":"BOOK-HARDCOVER",
      "outputQuantity":500.0,
      "status":"DRAFT",
      "sourceSalesOrderCode":null,
      "inputs":[], "ext":{}}]

# 4) run the SAME BPMN a second time — verify idempotent
$ curl -X POST /api/v1/workflow/process-instances
       {same body as above}
  → 201  (process ends, workOrderRequested=true, new event published + delivered)
$ curl /api/v1/production/work-orders
  → count=1, still only WO-FROM-PRINTINGSHOP-Q-007
```

Every single step runs through an api.v1 public surface. No framework
core code knows the printing-shop plug-in exists; no plug-in code knows
pbc-production exists. They meet on the event bus, and the outbox
guarantees the delivery.

## Tests

- 3 new tests in `pbc-production/.../WorkOrderRequestedSubscriberTest`:
  * `subscribe registers one listener for WorkOrderRequestedEvent`
  * `handle creates a work order from the event fields` — captures the
    `CreateWorkOrderCommand` and asserts every field
  * `handle short-circuits when a work order with that code already exists`
    — proves the idempotent branch
- Total framework unit tests: 278 (was 275), all green.

## What this unblocks

- **Richer multi-step BPMNs** in the plug-in that chain plate
  approval + quote → work order + production start + completion.
- **Plug-in-owned Quote entity** — the printing-shop plug-in can now
  introduce a `plugin_printingshop__quote` table via its own Liquibase
  changelog and have its HTTP endpoint create quotes that kick off the
  quote-to-work-order workflow automatically (or on operator confirm).
- **pbc-production routings/operations (v3)** — each operation becomes
  a BPMN step, potentially driven by plug-ins contributing custom
  steps via the same TaskHandler + event seam.
- **Second reference plug-in** — any new customer plug-in can publish
  `WorkOrderRequestedEvent` from its own workflows without any
  framework change.

## Non-goals (parking lot)

- The handler publishes but does not also read pbc-production state
  back. A future "wait for WO completion" BPMN step could subscribe
  to `WorkOrderCompletedEvent` inside a user-task + signal flow, but
  the engine's signal/correlation machinery isn't wired to
  plug-ins yet.
- Quote entity + HTTP + real business logic. REF.1 proves the
  cross-PBC event seam; the richer quote lifecycle is a separate
  chunk that can layer on top of this.
- Transactional rollback integration test. The synchronous bus +
  `Propagation.MANDATORY` guarantees it, but an explicit test that
  a subscriber throw rolls back both the ledger-adjacent writes and
  the Flowable process state would be worth adding with a real
  test container run.
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt
@@ -32,6 +32,71 @@ public sealed interface WorkOrderEvent : DomainEvent { @@ -32,6 +32,71 @@ public sealed interface WorkOrderEvent : DomainEvent {
32 } 32 }
33 33
34 /** 34 /**
  35 + * Inbound request to pbc-production: "please create a DRAFT work
  36 + * order for [outputItemCode] × [outputQuantity], derived from
  37 + * [sourceReference]". Published by **any** producer (core PBC or
  38 + * customer plug-in) that wants a work order created without
  39 + * importing pbc-production internals or calling a cross-PBC facade.
  40 + *
  41 + * **Why an event, not an `ext.production.ProductionApi.create(...)`
  42 + * facade:** the synchronous-facade pattern is reserved for reads +
  43 + * cross-PBC writes that must block the caller (InventoryApi.recordMovement,
  44 + * CatalogApi.findItemByCode). A work-order creation request is
  45 + * naturally asynchronous — the caller doesn't need to wait, and
  46 + * routing it through the event bus gives us:
  47 + * - Zero compile-time coupling between the producer and pbc-production
  48 + * (a plug-in doesn't depend on an `api.v1.ext.production` surface
  49 + * that doesn't exist yet).
  50 + * - The outbox for free: the row lands in `platform__event_outbox`
  51 + * inside the producer's transaction, so a crash after commit but
  52 + * before delivery replays cleanly.
  53 + * - Multiple subscribers can react: a future pbc-scheduling could
  54 + * also listen without adding a second facade.
  55 + *
  56 + * **Idempotency contract.** The subscriber MUST be idempotent on
  57 + * [code]: duplicate delivery (e.g. outbox replay) must NOT produce a
  58 + * second WorkOrder. The pbc-production subscriber uses
  59 + * `WorkOrderService.findByCode` as the short-circuit.
  60 + *
  61 + * This event is published by the reference printing-shop plug-in
  62 + * from a TaskHandler to kick off a plate → job-card → work-order
  63 + * flow (REF.1). It is NOT published by pbc-orders-sales, which uses
  64 + * the existing `SalesOrderConfirmedEvent` → `SalesOrderConfirmedSubscriber`
  65 + * path. Both paths coexist; the difference is that the SO flow
  66 + * carries its own "source sales order code" shape whereas this
  67 + * event is a generic `anything → pbc-production` channel.
  68 + *
  69 + * @property code the desired work-order code. Must be unique across
  70 + * WorkOrders. Convention: include the source system's key so
  71 + * duplicate detection is trivial (e.g.
  72 + * `WO-FROM-PRINTINGSHOP-Q-2026-001`).
  73 + * @property sourceReference opaque free-form pointer to whatever
  74 + * produced this request. Stored on the work order's audit trail
  75 + * and echoed into logs. Examples: `plugin:printing-shop:quote:Q-001`,
  76 + * `pbc-orders-sales:SO-2026-001:L2`.
  77 + */
  78 +public data class WorkOrderRequestedEvent(
  79 + public val code: String,
  80 + public val outputItemCode: String,
  81 + public val outputQuantity: BigDecimal,
  82 + public val sourceReference: String,
  83 + override val eventId: Id<DomainEvent> = Id.random(),
  84 + override val occurredAt: Instant = Instant.now(),
  85 +) : DomainEvent {
  86 + override val aggregateType: String get() = WORK_ORDER_AGGREGATE_TYPE
  87 + override val aggregateId: String get() = code
  88 +
  89 + init {
  90 + require(code.isNotBlank()) { "WorkOrderRequestedEvent.code must not be blank" }
  91 + require(outputItemCode.isNotBlank()) { "WorkOrderRequestedEvent.outputItemCode must not be blank" }
  92 + require(outputQuantity.signum() > 0) {
  93 + "WorkOrderRequestedEvent.outputQuantity must be positive (got $outputQuantity)"
  94 + }
  95 + require(sourceReference.isNotBlank()) { "WorkOrderRequestedEvent.sourceReference must not be blank" }
  96 + }
  97 +}
  98 +
  99 +/**
35 * Emitted when a new work order is created (DRAFT). The order is 100 * Emitted when a new work order is created (DRAFT). The order is
36 * scheduled to produce [outputQuantity] units of [outputItemCode] — 101 * scheduled to produce [outputQuantity] units of [outputItemCode] —
37 * no stock has moved yet. The optional [sourceSalesOrderCode] 102 * no stock has moved yet. The optional [sourceSalesOrderCode]
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriber.kt 0 → 100644
  1 +package org.vibeerp.pbc.production.event
  2 +
  3 +import jakarta.annotation.PostConstruct
  4 +import org.slf4j.LoggerFactory
  5 +import org.springframework.stereotype.Component
  6 +import org.vibeerp.api.v1.event.EventBus
  7 +import org.vibeerp.api.v1.event.EventListener
  8 +import org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent
  9 +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand
  10 +import org.vibeerp.pbc.production.application.WorkOrderService
  11 +
  12 +/**
  13 + * Reacts to [WorkOrderRequestedEvent] by creating a DRAFT work
  14 + * order. This is the second event-driven auto-spawn path in
  15 + * pbc-production; the first is [SalesOrderConfirmedSubscriber],
  16 + * which listens to `SalesOrderConfirmedEvent` and creates one
  17 + * work order per sales-order line.
  18 + *
  19 + * **Why a second subscriber instead of extending the first.** The
  20 + * two events serve different producers and carry different
  21 + * information. `SalesOrderConfirmedEvent` is a pbc-orders-sales
  22 + * lifecycle event — the subscriber has to round-trip back through
  23 + * `SalesOrdersApi.findByCode` to get the lines before it can decide
  24 + * what to spawn. `WorkOrderRequestedEvent` is a generic
  25 + * "anyone → pbc-production" channel — the event itself carries
  26 + * every field the subscriber needs, so there is no lookup.
  27 + * Collapsing the two into one subscriber would mean the generic
  28 + * path inherits the SO-flow's "skip if I already spawned anything
  29 + * for this source SO" short-circuit, which doesn't apply because
  30 + * generic callers set their own code.
  31 + *
  32 + * **Idempotent on event code.** `WorkOrderRequestedEvent.code`
  33 + * carries the pre-computed work-order code; the subscriber
  34 + * short-circuits via `WorkOrderService.findByCode` before calling
  35 + * `create`. A second delivery of the same event (outbox replay,
  36 + * future async bus retry) is a no-op.
  37 + *
  38 + * **Why `@PostConstruct` + a typed-class subscribe call** (matching
  39 + * the other PBC subscribers): the bean registers its listener BEFORE
  40 + * any HTTP traffic flows, so a process that publishes the event
  41 + * very early in boot (a plug-in's start-time initialization) still
  42 + * gets picked up.
  43 + */
  44 +@Component
  45 +class WorkOrderRequestedSubscriber(
  46 + private val eventBus: EventBus,
  47 + private val workOrders: WorkOrderService,
  48 +) {
  49 +
  50 + private val log = LoggerFactory.getLogger(WorkOrderRequestedSubscriber::class.java)
  51 +
  52 + @PostConstruct
  53 + fun subscribe() {
  54 + eventBus.subscribe(
  55 + WorkOrderRequestedEvent::class.java,
  56 + EventListener { event -> handle(event) },
  57 + )
  58 + log.info(
  59 + "pbc-production subscribed to WorkOrderRequestedEvent via EventBus.subscribe (typed-class overload)",
  60 + )
  61 + }
  62 +
  63 + /**
  64 + * Handle one inbound [WorkOrderRequestedEvent] by calling
  65 + * [WorkOrderService.create] with a command derived from the
  66 + * event. Visibility is `internal` so the unit test can drive it
  67 + * directly without going through the bus.
  68 + */
  69 + internal fun handle(event: WorkOrderRequestedEvent) {
  70 + val existing = workOrders.findByCode(event.code)
  71 + if (existing != null) {
  72 + log.debug(
  73 + "[production] WorkOrderRequestedEvent ignored — work order '{}' already exists (source='{}')",
  74 + event.code, event.sourceReference,
  75 + )
  76 + return
  77 + }
  78 + log.info(
  79 + "[production] WorkOrderRequestedEvent creating work order '{}' for item '{}' x {} (source='{}')",
  80 + event.code, event.outputItemCode, event.outputQuantity, event.sourceReference,
  81 + )
  82 + workOrders.create(
  83 + CreateWorkOrderCommand(
  84 + code = event.code,
  85 + outputItemCode = event.outputItemCode,
  86 + outputQuantity = event.outputQuantity,
  87 + // The generic path has no source SO; it carries a
  88 + // free-form sourceReference instead. That string is
  89 + // logged above and echoed into the audit trail via
  90 + // the event itself, which the outbox persists.
  91 + sourceSalesOrderCode = null,
  92 + ),
  93 + )
  94 + }
  95 +}
pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriberTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.production.event
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.isEqualTo
  5 +import io.mockk.Runs
  6 +import io.mockk.every
  7 +import io.mockk.just
  8 +import io.mockk.mockk
  9 +import io.mockk.slot
  10 +import io.mockk.verify
  11 +import org.junit.jupiter.api.Test
  12 +import org.vibeerp.api.v1.event.EventBus
  13 +import org.vibeerp.api.v1.event.EventListener
  14 +import org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent
  15 +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand
  16 +import org.vibeerp.pbc.production.application.WorkOrderService
  17 +import org.vibeerp.pbc.production.domain.WorkOrder
  18 +import org.vibeerp.pbc.production.domain.WorkOrderStatus
  19 +import java.math.BigDecimal
  20 +
  21 +class WorkOrderRequestedSubscriberTest {
  22 +
  23 + @Test
  24 + fun `subscribe registers one listener for WorkOrderRequestedEvent`() {
  25 + val eventBus = mockk<EventBus>(relaxed = true)
  26 + val workOrders = mockk<WorkOrderService>()
  27 +
  28 + WorkOrderRequestedSubscriber(eventBus, workOrders).subscribe()
  29 +
  30 + verify(exactly = 1) {
  31 + eventBus.subscribe(
  32 + WorkOrderRequestedEvent::class.java,
  33 + any<EventListener<WorkOrderRequestedEvent>>(),
  34 + )
  35 + }
  36 + }
  37 +
  38 + @Test
  39 + fun `handle creates a work order from the event fields`() {
  40 + val eventBus = mockk<EventBus>(relaxed = true)
  41 + val workOrders = mockk<WorkOrderService>()
  42 + val captured = slot<CreateWorkOrderCommand>()
  43 + every { workOrders.findByCode("WO-FROM-PRINTINGSHOP-Q-007") } returns null
  44 + every { workOrders.create(capture(captured)) } returns fakeWorkOrder("WO-FROM-PRINTINGSHOP-Q-007")
  45 +
  46 + val subscriber = WorkOrderRequestedSubscriber(eventBus, workOrders)
  47 + subscriber.handle(
  48 + WorkOrderRequestedEvent(
  49 + code = "WO-FROM-PRINTINGSHOP-Q-007",
  50 + outputItemCode = "BOOK-HARDCOVER",
  51 + outputQuantity = BigDecimal("500"),
  52 + sourceReference = "plugin:printing-shop:quote:Q-007",
  53 + ),
  54 + )
  55 +
  56 + verify(exactly = 1) { workOrders.create(any()) }
  57 + assertThat(captured.captured.code).isEqualTo("WO-FROM-PRINTINGSHOP-Q-007")
  58 + assertThat(captured.captured.outputItemCode).isEqualTo("BOOK-HARDCOVER")
  59 + assertThat(captured.captured.outputQuantity).isEqualTo(BigDecimal("500"))
  60 + assertThat(captured.captured.sourceSalesOrderCode).isEqualTo(null)
  61 + assertThat(captured.captured.inputs.size).isEqualTo(0)
  62 + }
  63 +
  64 + @Test
  65 + fun `handle short-circuits when a work order with that code already exists`() {
  66 + val eventBus = mockk<EventBus>(relaxed = true)
  67 + val workOrders = mockk<WorkOrderService>()
  68 + every { workOrders.findByCode("WO-DUP") } returns fakeWorkOrder("WO-DUP")
  69 +
  70 + val subscriber = WorkOrderRequestedSubscriber(eventBus, workOrders)
  71 + subscriber.handle(
  72 + WorkOrderRequestedEvent(
  73 + code = "WO-DUP",
  74 + outputItemCode = "X",
  75 + outputQuantity = BigDecimal("1"),
  76 + sourceReference = "replay",
  77 + ),
  78 + )
  79 +
  80 + verify(exactly = 0) { workOrders.create(any()) }
  81 + }
  82 +
  83 + private fun fakeWorkOrder(code: String): WorkOrder =
  84 + WorkOrder(
  85 + code = code,
  86 + outputItemCode = "X",
  87 + outputQuantity = BigDecimal("1"),
  88 + status = WorkOrderStatus.DRAFT,
  89 + )
  90 +}
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 @@ -6,6 +6,7 @@ import org.vibeerp.api.v1.plugin.HttpMethod
6 import org.vibeerp.api.v1.plugin.PluginContext 6 import org.vibeerp.api.v1.plugin.PluginContext
7 import org.vibeerp.api.v1.plugin.PluginRequest 7 import org.vibeerp.api.v1.plugin.PluginRequest
8 import org.vibeerp.api.v1.plugin.PluginResponse 8 import org.vibeerp.api.v1.plugin.PluginResponse
  9 +import org.vibeerp.reference.printingshop.workflow.CreateWorkOrderFromQuoteTaskHandler
9 import org.vibeerp.reference.printingshop.workflow.PlateApprovalTaskHandler 10 import org.vibeerp.reference.printingshop.workflow.PlateApprovalTaskHandler
10 import java.time.Instant 11 import java.time.Instant
11 import java.util.UUID 12 import java.util.UUID
@@ -282,8 +283,10 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP @@ -282,8 +283,10 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP
282 // the HTTP lambdas above use. This is the "handler-side plug-in 283 // the HTTP lambdas above use. This is the "handler-side plug-in
283 // context access" pattern — no new api.v1 surface required. 284 // context access" pattern — no new api.v1 surface required.
284 context.taskHandlers.register(PlateApprovalTaskHandler(context)) 285 context.taskHandlers.register(PlateApprovalTaskHandler(context))
  286 + context.taskHandlers.register(CreateWorkOrderFromQuoteTaskHandler(context))
285 context.logger.info( 287 context.logger.info(
286 - "registered 1 TaskHandler: ${PlateApprovalTaskHandler.KEY}", 288 + "registered 2 TaskHandlers: " +
  289 + "${PlateApprovalTaskHandler.KEY}, ${CreateWorkOrderFromQuoteTaskHandler.KEY}",
287 ) 290 )
288 } 291 }
289 292
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/CreateWorkOrderFromQuoteTaskHandler.kt 0 → 100644
  1 +package org.vibeerp.reference.printingshop.workflow
  2 +
  3 +import org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent
  4 +import org.vibeerp.api.v1.plugin.PluginContext
  5 +import org.vibeerp.api.v1.workflow.TaskContext
  6 +import org.vibeerp.api.v1.workflow.TaskHandler
  7 +import org.vibeerp.api.v1.workflow.WorkflowTask
  8 +import java.math.BigDecimal
  9 +
  10 +/**
  11 + * Plug-in TaskHandler that bridges a printing-shop BPMN process to
  12 + * pbc-production via the api.v1 event bus. Reads quote metadata off
  13 + * the workflow variables and publishes a [WorkOrderRequestedEvent];
  14 + * pbc-production's [WorkOrderRequestedSubscriber] reacts by creating
  15 + * a DRAFT WorkOrder without this plug-in ever knowing pbc-production
  16 + * exists.
  17 + *
  18 + * **Why this is the cleanest cross-PBC seam from a plug-in:**
  19 + * - The plug-in stays on api.v1 only. The event class lives in
  20 + * `api.v1.event.production.*`; there is no compile-time
  21 + * dependency on pbc-production internals (the plug-in linter
  22 + * would reject one).
  23 + * - Core pbc-production stays ignorant of the printing-shop plug-in.
  24 + * It just subscribes to an event type anyone can fill in. When a
  25 + * second customer plug-in ships, its quote-to-work-order flow
  26 + * publishes the same event and pbc-production reacts identically.
  27 + * - The publish + pbc-production's `WorkOrderService.create` run in
  28 + * the SAME transaction because `EventBusImpl` uses synchronous
  29 + * in-process delivery with `Propagation.MANDATORY`. A failure
  30 + * anywhere rolls the whole thing back; the event never shows up
  31 + * in `platform__event_outbox` either.
  32 + *
  33 + * **Variables it reads off the workflow:**
  34 + * - `quoteCode` (String, required) — the printing-shop quote's
  35 + * business code; used as the source-reference suffix and baked
  36 + * into the work-order code.
  37 + * - `itemCode` (String, required) — the catalog item the quote
  38 + * produces. pbc-production's subscriber validates it against
  39 + * CatalogApi before creating the work order.
  40 + * - `quantity` (Number, required) — how many units the quote
  41 + * ordered.
  42 + *
  43 + * **Variables it writes back:**
  44 + * - `workOrderCode` — the derived work-order code (so a downstream
  45 + * BPMN step can look up the WO via api.v1 or HTTP).
  46 + * - `workOrderRequested` — true, for the smoke test to assert
  47 + * against.
  48 + *
  49 + * **Idempotency** is handled by the subscriber: if the handler is
  50 + * replayed, the subscriber sees the work-order code already exists
  51 + * and short-circuits.
  52 + */
  53 +class CreateWorkOrderFromQuoteTaskHandler(
  54 + private val context: PluginContext,
  55 +) : TaskHandler {
  56 +
  57 + override fun key(): String = KEY
  58 +
  59 + override fun execute(task: WorkflowTask, ctx: TaskContext) {
  60 + val quoteCode = task.variables["quoteCode"] as? String
  61 + ?: error("CreateWorkOrderFromQuoteTaskHandler: missing 'quoteCode' variable")
  62 + val itemCode = task.variables["itemCode"] as? String
  63 + ?: error("CreateWorkOrderFromQuoteTaskHandler: missing 'itemCode' variable")
  64 + val quantity = when (val raw = task.variables["quantity"]) {
  65 + is Number -> BigDecimal(raw.toString())
  66 + is String -> BigDecimal(raw)
  67 + null -> error("CreateWorkOrderFromQuoteTaskHandler: missing 'quantity' variable")
  68 + else -> error("CreateWorkOrderFromQuoteTaskHandler: 'quantity' has unexpected type ${raw::class}")
  69 + }
  70 +
  71 + // The work-order code follows the same convention as
  72 + // SalesOrderConfirmedSubscriber's `WO-FROM-<source>` pattern
  73 + // so that two production paths produce comparable codes and
  74 + // a single audit grep picks them both up.
  75 + val workOrderCode = "WO-FROM-PRINTINGSHOP-$quoteCode"
  76 + val sourceReference = "plugin:printing-shop:quote:$quoteCode"
  77 +
  78 + context.logger.info(
  79 + "quote $quoteCode: publishing WorkOrderRequestedEvent " +
  80 + "(code=$workOrderCode, item=$itemCode, qty=$quantity)",
  81 + )
  82 +
  83 + context.eventBus.publish(
  84 + WorkOrderRequestedEvent(
  85 + code = workOrderCode,
  86 + outputItemCode = itemCode,
  87 + outputQuantity = quantity,
  88 + sourceReference = sourceReference,
  89 + ),
  90 + )
  91 +
  92 + ctx.set("workOrderCode", workOrderCode)
  93 + ctx.set("workOrderRequested", true)
  94 + }
  95 +
  96 + companion object {
  97 + const val KEY: String = "printing_shop.quote.create_work_order"
  98 + }
  99 +}
reference-customer/plugin-printing-shop/src/main/resources/processes/quote-to-work-order.bpmn20.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<!--
  3 + Reference printing-shop plug-in BPMN — quote-to-work-order (REF.1).
  4 +
  5 + A single-step process that delegates to the plug-in's
  6 + CreateWorkOrderFromQuoteTaskHandler, which publishes a
  7 + WorkOrderRequestedEvent. pbc-production's WorkOrderRequestedSubscriber
  8 + reacts to that event by creating a DRAFT WorkOrder via WorkOrderService.
  9 +
  10 + The caller passes three variables when starting the process:
  11 + quoteCode (String) the printing-shop quote's business code
  12 + itemCode (String) the catalog item the quote produces
  13 + quantity (Number) how many units
  14 +
  15 + The handler derives the work-order code from the quote code, publishes
  16 + the event, and writes back two variables:
  17 + workOrderCode — the derived WO code (for downstream steps or for the caller)
  18 + workOrderRequested — true
  19 +
  20 + The whole thing runs in ONE transaction because EventBusImpl uses
  21 + synchronous in-process delivery with Propagation.MANDATORY. If
  22 + pbc-production's subscriber throws (item not in catalog, duplicate WO
  23 + code, etc.) the workflow service-task execution rolls back.
  24 +
  25 + Auto-deployed by the host's PluginProcessDeployer at plug-in start,
  26 + under Flowable deployment category='printing-shop'. Undeployed on
  27 + plug-in stop.
  28 +-->
  29 +<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
  30 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  31 + xmlns:flowable="http://flowable.org/bpmn"
  32 + targetNamespace="http://vibeerp.org/plugin/printing-shop/bpmn">
  33 + <process id="plugin-printing-shop-quote-to-work-order"
  34 + name="Printing shop — quote to work order"
  35 + isExecutable="true">
  36 + <startEvent id="start"/>
  37 + <sequenceFlow id="flow-start-to-create-wo"
  38 + sourceRef="start"
  39 + targetRef="printing_shop.quote.create_work_order"/>
  40 + <serviceTask id="printing_shop.quote.create_work_order"
  41 + name="Publish WorkOrderRequestedEvent"
  42 + flowable:delegateExpression="${taskDispatcher}"/>
  43 + <sequenceFlow id="flow-create-wo-to-end"
  44 + sourceRef="printing_shop.quote.create_work_order"
  45 + targetRef="end"/>
  46 + <endEvent id="end"/>
  47 + </process>
  48 +</definitions>