Commit 3027c1f74740fdd8bf6adf3522220db4c879b7c5
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.
Showing
6 changed files
with
401 additions
and
1 deletions
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt
| ... | ... | @@ -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 | 100 | * Emitted when a new work order is created (DRAFT). The order is |
| 36 | 101 | * scheduled to produce [outputQuantity] units of [outputItemCode] — |
| 37 | 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 | import org.vibeerp.api.v1.plugin.PluginContext |
| 7 | 7 | import org.vibeerp.api.v1.plugin.PluginRequest |
| 8 | 8 | import org.vibeerp.api.v1.plugin.PluginResponse |
| 9 | +import org.vibeerp.reference.printingshop.workflow.CreateWorkOrderFromQuoteTaskHandler | |
| 9 | 10 | import org.vibeerp.reference.printingshop.workflow.PlateApprovalTaskHandler |
| 10 | 11 | import java.time.Instant |
| 11 | 12 | import java.util.UUID |
| ... | ... | @@ -282,8 +283,10 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP |
| 282 | 283 | // the HTTP lambdas above use. This is the "handler-side plug-in |
| 283 | 284 | // context access" pattern — no new api.v1 surface required. |
| 284 | 285 | context.taskHandlers.register(PlateApprovalTaskHandler(context)) |
| 286 | + context.taskHandlers.register(CreateWorkOrderFromQuoteTaskHandler(context)) | |
| 285 | 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> | ... | ... |