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.