…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.