-
…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. -
Closes the last known gap from the HasExt refactor (commit 986f02ce): pbc-production's WorkOrder had an `ext` column but no validator was wired, so an operator could write arbitrary JSON without any schema enforcement. This fixes that and adds the first Tier 1 custom fields for WorkOrder. Code changes: - WorkOrder implements HasExt; ext becomes `override var ext`, ENTITY_NAME moves onto the entity companion. - WorkOrderService injects ExtJsonValidator, calls applyTo() in create() before saving (null-safe so the SalesOrderConfirmedSubscriber's auto-spawn path still works — verified by smoke test). - CreateWorkOrderCommand + CreateWorkOrderRequest gain an `ext` field that flows through to the validator. - WorkOrderResponse gains an `ext: Map<String, Any?>` field; the response mapper signature changes to `toResponse(service)` to reach the validator via a convenience parseExt delegate on the service (same pattern as the other four PBCs). - pbc-production Gradle build adds `implementation(project(":platform:platform-metadata"))`. Metadata (production.yml): - Permission keys extended to match the v2 state machine: production.work-order.start (was missing) and production.work-order.scrap (was missing). The existing .read / .create / .complete / .cancel keys stay. - Two custom fields declared: * production_priority (enum: low, normal, high, urgent) * production_routing_notes (string, maxLength 1024) Both are optional and non-PII; an operator can now add priority and routing notes to a work order through the public API without any code change, which is the whole point of Tier 1 customization. Unit tests: WorkOrderServiceTest constructor updated to pass the new extValidator dependency and stub applyTo/parseExt as no-ops. No behavioral test changes — ext validation is covered by ExtJsonValidatorTest and the platform-wide smoke tests. Smoke verified end-to-end against real Postgres: - GET /_meta/metadata/custom-fields/WorkOrder now returns both declarations with correct enum sets and maxLength. - POST /work-orders with valid ext {production_priority:"high", production_routing_notes:"Rush for customer demo"} → 201, canonical form persisted, round-trips via GET. - POST with invalid enum value → 400 "value 'emergency' is not in allowed set [low, normal, high, urgent]". - POST with unknown ext key → 400 "ext contains undeclared key(s) for 'WorkOrder': [unknown_field]". - Auto-spawn from confirmed SO → DRAFT work order with empty ext `{}`, confirming the applyTo(null) null-safe path. Five of the eight PBCs now participate in the HasExt pattern: Partner, Location, SalesOrder, PurchaseOrder, WorkOrder. The remaining three (Item, Uom, JournalEntry) either have their own custom-field story in separate entities or are derived state. 246 unit tests, all green. 18 Gradle subprojects.
-
Grows pbc-production from the minimal v1 (DRAFT → COMPLETED in one step, single output, no BOM) into a real v2 production PBC: 1. IN_PROGRESS state between DRAFT and COMPLETED so "started but not finished" work orders are observable on a dashboard. WorkOrderService.start(id) performs the transition and publishes a new WorkOrderStartedEvent. cancel() now accepts DRAFT OR IN_PROGRESS (v2 writes nothing to the ledger at start() so there is nothing to undo on cancel). 2. Bill of materials via a new WorkOrderInput child entity — @OneToMany with cascade + orphanRemoval, same shape as SalesOrderLine. Each line carries (lineNo, itemCode, quantityPerUnit, sourceLocationCode). complete() now iterates the inputs in lineNo order and writes one MATERIAL_ISSUE ledger row per line (delta = -(quantityPerUnit × outputQuantity)) BEFORE writing the PRODUCTION_RECEIPT for the output. All in one transaction — a failure anywhere rolls back every prior ledger row AND the status flip. Empty inputs list is legal (the v1 auto-spawn-from-SO path still works unchanged, writing only the PRODUCTION_RECEIPT). 3. Scrap flow for COMPLETED work orders via a new scrap(id, scrapLocationCode, quantity, note) service method. Writes a negative ADJUSTMENT ledger row tagged WO:<code>:SCRAP and publishes a new WorkOrderScrappedEvent. Chose ADJUSTMENT over adding a new SCRAP movement reason to keep the enum stable — the reference-string suffix is the disambiguator. The work order itself STAYS COMPLETED; scrap is a correction on top of a terminal state, not a state change. complete() now requires IN_PROGRESS (not DRAFT); existing callers must start() first. api.v1 grows two events (WorkOrderStartedEvent, WorkOrderScrappedEvent) alongside the three that already existed. Since this is additive within a major version, the api.v1 semver contract holds — existing subscribers continue to compile. Liquibase: 002-production-v2.xml widens the status CHECK and creates production__work_order_input with (work_order_id FK, line_no, item_code, quantity_per_unit, source_location_code) plus a unique (work_order_id, line_no) constraint, a CHECK quantity_per_unit > 0, and the audit columns. ON DELETE CASCADE from the parent. Unit tests: WorkOrderServiceTest grows from 8 to 18 cases — covers start happy path, start rejection, complete-on-DRAFT rejection, empty-BOM complete, BOM-with-two-lines complete (verifies both MATERIAL_ISSUE deltas AND the PRODUCTION_RECEIPT all fire with the right references), scrap happy path, scrap on non-COMPLETED rejection, scrap with non-positive quantity rejection, cancel-from-IN_PROGRESS, and BOM validation rejects (unknown item, duplicate line_no). Smoke verified end-to-end against real Postgres: - Created WO-SMOKE with 2-line BOM (2 paper + 0.5 ink per brochure, output 100). - Started (DRAFT → IN_PROGRESS, no ledger rows). - Completed: paper balance 500→300 (MATERIAL_ISSUE -200), ink 200→150 (MATERIAL_ISSUE -50), FG-BROCHURE 0→100 (PRODUCTION_RECEIPT +100). All 3 rows tagged WO:WO-SMOKE. - Scrapped 7 units: FG-BROCHURE 100→93, ADJUSTMENT -7 tagged WO:WO-SMOKE:SCRAP, work order stayed COMPLETED. - Auto-spawn: SO-42 confirm still creates WO-FROM-SO-42-L1 as a DRAFT with empty BOM; starting + completing it writes only the PRODUCTION_RECEIPT (zero MATERIAL_ISSUE rows), proving the empty-BOM path is backwards-compatible. - Negative paths: complete-on-DRAFT 400s, scrap-on-DRAFT 400s, double-start 400s, cancel-from-IN_PROGRESS 200. 240 unit tests, 18 Gradle subprojects.
-
The framework's eighth PBC and the first one that's NOT order- or master-data-shaped. Work orders are about *making things*, which is the reason the printing-shop reference customer exists in the first place. With this PBC in place the framework can express the full buy-sell-make loop end-to-end. What landed (new module pbc/pbc-production/) - WorkOrder entity (production__work_order): code, output_item_code, output_quantity, status (DRAFT|COMPLETED| CANCELLED), due_date (display-only), source_sales_order_code (nullable — work orders can be either auto-spawned from a confirmed SO or created manually), ext. - WorkOrderJpaRepository with existsBySourceSalesOrderCode / findBySourceSalesOrderCode for the auto-spawn dedup. - WorkOrderService.create / complete / cancel: • create validates the output item via CatalogApi (same seam SalesOrderService and PurchaseOrderService use), rejects non-positive quantities, publishes WorkOrderCreatedEvent. • complete(outputLocationCode) credits finished goods to the named location via InventoryApi.recordMovement with reason=PRODUCTION_RECEIPT (added in commit c52d0d59) and reference="WO:<order_code>", then flips status to COMPLETED, then publishes WorkOrderCompletedEvent — all in the same @Transactional method. • cancel only allowed from DRAFT (no un-producing finished goods); publishes WorkOrderCancelledEvent. - SalesOrderConfirmedSubscriber (@PostConstruct → EventBus.subscribe(SalesOrderConfirmedEvent::class.java, ...)): walks the confirmed sales order's lines via SalesOrdersApi (NOT by importing pbc-orders-sales) and calls WorkOrderService.create for each line. Coded as one bean with one subscription — matches pbc-finance's one-bean-per-subject pattern. • Idempotent on source sales order code — if any work order already exists for the SO, the whole spawn is a no-op. • Tolerant of a missing SO (defensive against a future async bus that could deliver the confirm event after the SO has vanished). • The WO code convention: WO-FROM-<so_code>-L<lineno>, e.g. WO-FROM-SO-2026-0001-L1. - REST controller /api/v1/production/work-orders: list, get, by-code, create, complete, cancel — each annotated with @RequirePermission. Four permission keys declared in the production.yml metadata: read / create / complete / cancel. - CompleteWorkOrderRequest: single-arg DTO uses the @JsonCreator(mode=PROPERTIES) + @param:JsonProperty trick that already bit ShipSalesOrderRequest and ReceivePurchaseOrderRequest; cross-referenced in the KDoc so the third instance doesn't need re-discovery. - distribution/.../pbc-production/001-production-init.xml: CREATE TABLE with CHECK on status + CHECK on qty>0 + GIN on ext + the usual indexes. NEITHER output_item_code NOR source_sales_order_code is a foreign key (cross-PBC reference policy — guardrail #9). - settings.gradle.kts + distribution/build.gradle.kts: registers the new module and adds it to the distribution dependency list. - master.xml: includes the new changelog in dependency order, after pbc-finance. New api.v1 surface: org.vibeerp.api.v1.event.production.* - WorkOrderCreatedEvent, WorkOrderCompletedEvent, WorkOrderCancelledEvent — sealed under WorkOrderEvent, aggregateType="production.WorkOrder". Same pattern as the order events, so any future consumer (finance revenue recognition, warehouse put-away dashboard, a customer plug-in that needs to react to "work finished") subscribes through the public typed-class overload with no dependency on pbc-production. Unit tests (13 new, 217 → 230 total) - WorkOrderServiceTest (9 tests): create dedup, positive quantity check, catalog seam, happy-path create with event assertion, complete rejects non-DRAFT, complete happy path with InventoryApi.recordMovement assertion + event assertion, cancel from DRAFT, cancel rejects COMPLETED. - SalesOrderConfirmedSubscriberTest (5 tests): subscription registration count, spawns N work orders for N SO lines with correct code convention, idempotent when WOs already exist, no-op on missing SO, and a listener-routing test that captures the EventListener instance and verifies it forwards to the right service method. End-to-end smoke verified against real Postgres - Fresh DB, fresh boot. Both OrderEventSubscribers (pbc-finance) and SalesOrderConfirmedSubscriber (pbc-production) log their subscription registration before the first HTTP call. - Seeded two items (BROCHURE-A, BROCHURE-B), a customer, and a finished-goods location (WH-FG). - Created a 2-line sales order (SO-WO-1), confirmed it. → Produced ONE orders_sales.SalesOrder outbox row. → Produced ONE AR POSTED finance__journal_entry for 1000 USD (500 × 1 + 250 × 2 — the pbc-finance consumer still works). → Produced TWO draft work orders auto-spawned from the SO lines: WO-FROM-SO-WO-1-L1 (BROCHURE-A × 500) and WO-FROM-SO-WO-1-L2 (BROCHURE-B × 250), both with source_sales_order_code=SO-WO-1. - Completed WO1 to WH-FG: → Produced a PRODUCTION_RECEIPT ledger row for BROCHURE-A delta=500 reference="WO:WO-FROM-SO-WO-1-L1". → inventory__stock_balance now has BROCHURE-A = 500 at WH-FG. → Flipped status to COMPLETED. - Cancelled WO2 → CANCELLED. - Created a manual WO-MANUAL-1 with no source SO → succeeds; demonstrates the "operator creates a WO to build inventory ahead of demand" path. - platform__event_outbox ends with 6 rows all DISPATCHED: orders_sales.SalesOrder SO-WO-1 production.WorkOrder WO-FROM-SO-WO-1-L1 (created) production.WorkOrder WO-FROM-SO-WO-1-L2 (created) production.WorkOrder WO-FROM-SO-WO-1-L1 (completed) production.WorkOrder WO-FROM-SO-WO-1-L2 (cancelled) production.WorkOrder WO-MANUAL-1 (created) Why this chunk was the right next move - pbc-finance was a PASSIVE consumer — it only wrote derived reporting state. pbc-production is the first ACTIVE consumer: it creates new aggregates with their own state machines and their own cross-PBC writes in reaction to another PBC's events. This is a meaningfully harder test of the event-driven integration story and it passes end-to-end. - "One ledger, three callers" is now real: sales shipments, purchase receipts, AND production receipts all feed the same inventory__stock_movement ledger through the same InventoryApi.recordMovement facade. The facade has proven stable under three very different callers. - The framework now expresses the basic ERP trinity: buy (purchase orders), sell (sales orders), make (work orders). That's the shape every real manufacturing customer needs, and it's done without any PBC importing another. What's deliberately NOT in v1 - No bill of materials. complete() only credits finished goods; it does NOT issue raw materials. A shop floor that needs to consume 4 sheets of paper to produce 1 brochure does it manually via POST /api/v1/inventory/movements with reason= MATERIAL_ISSUE (added in commit c52d0d59). A proper BOM lands as WorkOrderInput lines in a future chunk. - No IN_PROGRESS state. complete() goes DRAFT → COMPLETED in one step. A real shop floor needs "started but not finished" visibility; that's the next iteration. - No routings, operations, machine assignments, or due-date enforcement. due_date is display-only. - No "scrap defective output" flow for a COMPLETED work order. cancel refuses from COMPLETED; the fix requires a new MovementReason and a new event, not a special-case method on the service.