Commit b4edf64b1810ad4e8aa9530a6a34d38e29a82b62

Authored by zichun
1 parent c52d0d59

feat(pbc): pbc-production (P5.7 minimal) — work orders auto-spawned from SO confirms

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.
CLAUDE.md
... ... @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only
95 95  
96 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97  
98   -- **17 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.
99   -- **217 unit tests across 17 modules**, all green. `./gradlew build` is the canonical full build.
  98 +- **18 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.
  99 +- **230 unit tests across 18 modules**, all green. `./gradlew build` is the canonical full build.
100 100 - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge.
101   -- **7 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`). **The full buy-and-sell loop works**: a purchase order receives stock via `PURCHASE_RECEIPT` ledger rows, a sales order ships stock via `SALES_SHIPMENT` ledger rows. Both PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade.
  101 +- **8 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`). **The full buy-sell-make loop works**: a purchase order receives stock via `PURCHASE_RECEIPT`, a sales order ships stock via `SALES_SHIPMENT`, and a work order produces stock via `PRODUCTION_RECEIPT`. All three PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade.
102 102 - **Event-driven cross-PBC integration is live in BOTH directions and the consumer reacts to the full lifecycle.** Six typed events under `org.vibeerp.api.v1.event.orders.*` are published from `SalesOrderService` and `PurchaseOrderService` inside the same `@Transactional` method as their state changes. **pbc-finance** subscribes to ALL SIX of them via the api.v1 `EventBus.subscribe(eventType, listener)` typed-class overload: confirm events POST AR/AP rows; ship/receive events SETTLE them; cancel events REVERSE them. Each transition is idempotent under at-least-once delivery — re-applying the same destination status is a no-op. Cancel-from-DRAFT is a clean no-op because no `*ConfirmedEvent` was ever published. pbc-finance has no source dependency on pbc-orders-*; it reaches them only through events.
103 103 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework.
104 104 - **Package root** is `org.vibeerp`.
... ...
PROGRESS.md
... ... @@ -10,13 +10,13 @@
10 10  
11 11 | | |
12 12 |---|---|
13   -| **Latest version** | v0.17.1 (MovementReason gains MATERIAL_ISSUE + PRODUCTION_RECEIPT) |
14   -| **Latest commit** | `ed66823 feat(inventory): add MATERIAL_ISSUE + PRODUCTION_RECEIPT movement reasons` |
  13 +| **Latest version** | v0.18 (pbc-production — work orders auto-spawned from SO confirms) |
  14 +| **Latest commit** | `<pin after push>` |
15 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16   -| **Modules** | 17 |
17   -| **Unit tests** | 217, all green |
18   -| **End-to-end smoke runs** | pbc-finance now reacts to all six order events. Smoke verified: PO confirm → AP POSTED → receive → AP SETTLED. SO confirm → AR POSTED → ship → AR SETTLED. Confirm-then-cancel of either PO or SO flips the row to REVERSED. Cancel-from-DRAFT writes no row (no `*ConfirmedEvent` was ever published). All lifecycle transitions are idempotent: a duplicate settle/reverse delivery is a clean no-op, and a settle never overwrites a reversal (or vice versa). Status filter on `GET /api/v1/finance/journal-entries?status=POSTED|SETTLED|REVERSED` returns the right partition. |
19   -| **Real PBCs implemented** | 7 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`) |
  16 +| **Modules** | 18 |
  17 +| **Unit tests** | 230, all green |
  18 +| **End-to-end smoke runs** | An SO confirmed with 2 lines auto-spawns 2 draft work orders via `SalesOrderConfirmedSubscriber`; completing one credits the finished-good stock via `PRODUCTION_RECEIPT`, cancelling the other flips its status, and a manual WO can still be created with no source SO. All in one run: 6 outbox rows DISPATCHED across `orders_sales.SalesOrder` and `production.WorkOrder` topics; pbc-finance still writes its AR row for the underlying SO; the `inventory__stock_movement` ledger carries the production receipt tagged `WO:<code>`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). |
  19 +| **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) |
20 20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) |
21 21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. |
22 22  
... ... @@ -84,7 +84,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **22 a
84 84 | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending |
85 85 | P5.5 | `pbc-orders-sales` — sales orders + lines (deliveries deferred) | ✅ DONE — `a8eb2a6` |
86 86 | P5.6 | `pbc-orders-purchase` — POs + receive flow (RFQs deferred) | ✅ DONE — `2861656` |
87   -| P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending |
  87 +| P5.7 | `pbc-production` — work orders, routings, operations | ✅ Partial — minimal single-output work orders + auto-spawn from SO confirm; BOM / routings / operations / IN_PROGRESS pending |
88 88 | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending |
89 89 | P5.9 | `pbc-finance` — GL, journal entries, AR/AP minimal | ✅ Partial — minimal AR/AP journal entries driven by events; full GL pending |
90 90  
... ...
README.md
... ... @@ -77,7 +77,7 @@ vibe-erp/
77 77 ## Building
78 78  
79 79 ```bash
80   -# Build everything (compiles 17 modules, runs 217 unit tests)
  80 +# Build everything (compiles 18 modules, runs 230 unit tests)
81 81 ./gradlew build
82 82  
83 83 # Bring up Postgres + the reference plug-in JAR
... ... @@ -96,9 +96,9 @@ The bootstrap admin password is printed to the application logs on first boot. A
96 96  
97 97 | | |
98 98 |---|---|
99   -| Modules | 17 |
100   -| Unit tests | 217, all green |
101   -| Real PBCs | 7 of 10 |
  99 +| Modules | 18 |
  100 +| Unit tests | 230, all green |
  101 +| Real PBCs | 8 of 10 |
102 102 | Cross-cutting services live | 9 |
103 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
104 104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending |
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt 0 → 100644
  1 +package org.vibeerp.api.v1.event.production
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +import org.vibeerp.api.v1.event.DomainEvent
  5 +import java.math.BigDecimal
  6 +import java.time.Instant
  7 +
  8 +/**
  9 + * Domain events emitted by the production PBC.
  10 + *
  11 + * Same shape as the order events in `api.v1.event.orders.*`. The
  12 + * events live in api.v1, NOT inside pbc-production, so other PBCs
  13 + * (warehousing, finance, quality) and customer plug-ins can subscribe
  14 + * without importing pbc-production internals — which the Gradle
  15 + * build refuses anyway (CLAUDE.md guardrail #9).
  16 + *
  17 + * **`aggregateType` convention:** `production.WorkOrder`. Matches the
  18 + * `<pbc>.<aggregate>` convention documented on
  19 + * [DomainEvent.aggregateType].
  20 + *
  21 + * **What each event carries:** the work order's business code (the
  22 + * stable human key), the output item code, and the quantity. Fields
  23 + * are picked so a downstream subscriber can do something useful
  24 + * without having to round-trip back to pbc-production through a
  25 + * facade — the same "events carry what consumers need" principle
  26 + * that drove the order events' shape.
  27 + */
  28 +public sealed interface WorkOrderEvent : DomainEvent {
  29 + public val orderCode: String
  30 + public val outputItemCode: String
  31 + public val outputQuantity: BigDecimal
  32 +}
  33 +
  34 +/**
  35 + * Emitted when a new work order is created (DRAFT). The order is
  36 + * scheduled to produce [outputQuantity] units of [outputItemCode] —
  37 + * no stock has moved yet. The optional [sourceSalesOrderCode]
  38 + * carries the SO that triggered the auto-creation, when one exists,
  39 + * so a downstream "production progress" board can show the linkage
  40 + * without re-querying.
  41 + */
  42 +public data class WorkOrderCreatedEvent(
  43 + override val orderCode: String,
  44 + override val outputItemCode: String,
  45 + override val outputQuantity: BigDecimal,
  46 + public val sourceSalesOrderCode: String?,
  47 + override val eventId: Id<DomainEvent> = Id.random(),
  48 + override val occurredAt: Instant = Instant.now(),
  49 +) : WorkOrderEvent {
  50 + override val aggregateType: String get() = WORK_ORDER_AGGREGATE_TYPE
  51 + override val aggregateId: String get() = orderCode
  52 +}
  53 +
  54 +/**
  55 + * Emitted when a DRAFT work order is completed (terminal happy path).
  56 + *
  57 + * The companion `PRODUCTION_RECEIPT` ledger row has already been
  58 + * written by the time this event fires — the publish runs inside
  59 + * the same `@Transactional` method as the inventory write and the
  60 + * status flip, so a subscriber that reads `inventory__stock_movement`
  61 + * on receipt is guaranteed to see the matching row tagged
  62 + * `WO:<order_code>`.
  63 + *
  64 + * `outputLocationCode` is included so warehouse and dispatch
  65 + * subscribers can act on "what just landed where" without
  66 + * re-reading the order.
  67 + */
  68 +public data class WorkOrderCompletedEvent(
  69 + override val orderCode: String,
  70 + override val outputItemCode: String,
  71 + override val outputQuantity: BigDecimal,
  72 + public val outputLocationCode: String,
  73 + override val eventId: Id<DomainEvent> = Id.random(),
  74 + override val occurredAt: Instant = Instant.now(),
  75 +) : WorkOrderEvent {
  76 + override val aggregateType: String get() = WORK_ORDER_AGGREGATE_TYPE
  77 + override val aggregateId: String get() = orderCode
  78 +}
  79 +
  80 +/**
  81 + * Emitted when a work order is cancelled from DRAFT.
  82 + *
  83 + * The framework refuses to cancel a COMPLETED work order — that
  84 + * would imply un-producing finished goods, which is "scrap them",
  85 + * a separate flow that lands later as its own event.
  86 + */
  87 +public data class WorkOrderCancelledEvent(
  88 + override val orderCode: String,
  89 + override val outputItemCode: String,
  90 + override val outputQuantity: BigDecimal,
  91 + override val eventId: Id<DomainEvent> = Id.random(),
  92 + override val occurredAt: Instant = Instant.now(),
  93 +) : WorkOrderEvent {
  94 + override val aggregateType: String get() = WORK_ORDER_AGGREGATE_TYPE
  95 + override val aggregateId: String get() = orderCode
  96 +}
  97 +
  98 +/** Topic string for wildcard / topic-based subscriptions to work order events. */
  99 +public const val WORK_ORDER_AGGREGATE_TYPE: String = "production.WorkOrder"
... ...
distribution/build.gradle.kts
... ... @@ -33,6 +33,7 @@ dependencies {
33 33 implementation(project(":pbc:pbc-orders-sales"))
34 34 implementation(project(":pbc:pbc-orders-purchase"))
35 35 implementation(project(":pbc:pbc-finance"))
  36 + implementation(project(":pbc:pbc-production"))
36 37  
37 38 implementation(libs.spring.boot.starter)
38 39 implementation(libs.spring.boot.starter.web)
... ...
distribution/src/main/resources/db/changelog/master.xml
... ... @@ -22,4 +22,5 @@
22 22 <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/>
23 23 <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/>
24 24 <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/>
  25 + <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/>
25 26 </databaseChangeLog>
... ...
distribution/src/main/resources/db/changelog/pbc-production/001-production-init.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  5 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  6 +
  7 + <!--
  8 + pbc-production initial schema (P5.7, minimal v1).
  9 +
  10 + Owns: production__work_order.
  11 +
  12 + Single-output work orders only — no BOM lines, no operations,
  13 + no routings, no scheduling. The minimal v1 has just enough to
  14 + prove that "a confirmed sales order auto-spawns a work order
  15 + and completing it credits finished goods to inventory".
  16 +
  17 + NEITHER `output_item_code` NOR `source_sales_order_code` is a
  18 + foreign key. They are cross-PBC references; a database FK
  19 + across PBCs would couple pbc-production's schema with
  20 + pbc-catalog and pbc-orders-sales at the storage level,
  21 + defeating the bounded-context rule (CLAUDE.md guardrail #9).
  22 + The application enforces existence via CatalogApi and the
  23 + SalesOrderConfirmedSubscriber's idempotent dedup.
  24 + -->
  25 +
  26 + <changeSet id="production-init-001" author="vibe_erp">
  27 + <comment>Create production__work_order table</comment>
  28 + <sql>
  29 + CREATE TABLE production__work_order (
  30 + id uuid PRIMARY KEY,
  31 + code varchar(64) NOT NULL,
  32 + output_item_code varchar(64) NOT NULL,
  33 + output_quantity numeric(18,4) NOT NULL,
  34 + status varchar(16) NOT NULL,
  35 + due_date date,
  36 + source_sales_order_code varchar(64),
  37 + ext jsonb NOT NULL DEFAULT '{}'::jsonb,
  38 + created_at timestamptz NOT NULL,
  39 + created_by varchar(128) NOT NULL,
  40 + updated_at timestamptz NOT NULL,
  41 + updated_by varchar(128) NOT NULL,
  42 + version bigint NOT NULL DEFAULT 0,
  43 + CONSTRAINT production__work_order_status_check
  44 + CHECK (status IN ('DRAFT', 'COMPLETED', 'CANCELLED')),
  45 + CONSTRAINT production__work_order_qty_pos
  46 + CHECK (output_quantity &gt; 0)
  47 + );
  48 + CREATE UNIQUE INDEX production__work_order_code_uk
  49 + ON production__work_order (code);
  50 + CREATE INDEX production__work_order_status_idx
  51 + ON production__work_order (status);
  52 + CREATE INDEX production__work_order_output_item_idx
  53 + ON production__work_order (output_item_code);
  54 + CREATE INDEX production__work_order_source_so_idx
  55 + ON production__work_order (source_sales_order_code);
  56 + CREATE INDEX production__work_order_due_date_idx
  57 + ON production__work_order (due_date);
  58 + CREATE INDEX production__work_order_ext_gin
  59 + ON production__work_order USING GIN (ext jsonb_path_ops);
  60 + </sql>
  61 + <rollback>
  62 + DROP TABLE production__work_order;
  63 + </rollback>
  64 + </changeSet>
  65 +
  66 +</databaseChangeLog>
... ...
pbc/pbc-production/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.kotlin.jpa)
  5 + alias(libs.plugins.spring.dependency.management)
  6 +}
  7 +
  8 +description = "vibe_erp pbc-production — minimal work order PBC. Reacts to SalesOrderConfirmedEvent to auto-spawn drafts; complete() credits finished goods through InventoryApi.recordMovement(PRODUCTION_RECEIPT). INTERNAL Packaged Business Capability."
  9 +
  10 +java {
  11 + toolchain {
  12 + languageVersion.set(JavaLanguageVersion.of(21))
  13 + }
  14 +}
  15 +
  16 +kotlin {
  17 + jvmToolchain(21)
  18 + compilerOptions {
  19 + freeCompilerArgs.add("-Xjsr305=strict")
  20 + }
  21 +}
  22 +
  23 +allOpen {
  24 + annotation("jakarta.persistence.Entity")
  25 + annotation("jakarta.persistence.MappedSuperclass")
  26 + annotation("jakarta.persistence.Embeddable")
  27 +}
  28 +
  29 +// Eighth PBC. Same dependency rule as the others — api/api-v1 +
  30 +// platform/* only, NEVER another pbc-*. Cross-PBC reads happen via
  31 +// CatalogApi (validate the output item exists) and SalesOrdersApi
  32 +// (look up the source SO when reacting to SalesOrderConfirmedEvent).
  33 +// Cross-PBC writes happen via InventoryApi.recordMovement with the
  34 +// new PRODUCTION_RECEIPT reason added in commit c52d0d5.
  35 +dependencies {
  36 + api(project(":api:api-v1"))
  37 + implementation(project(":platform:platform-persistence"))
  38 + implementation(project(":platform:platform-security"))
  39 +
  40 + implementation(libs.kotlin.stdlib)
  41 + implementation(libs.kotlin.reflect)
  42 +
  43 + implementation(libs.spring.boot.starter)
  44 + implementation(libs.spring.boot.starter.web)
  45 + implementation(libs.spring.boot.starter.data.jpa)
  46 + implementation(libs.spring.boot.starter.validation)
  47 + implementation(libs.jackson.module.kotlin)
  48 +
  49 + testImplementation(libs.spring.boot.starter.test)
  50 + testImplementation(libs.junit.jupiter)
  51 + testImplementation(libs.assertk)
  52 + testImplementation(libs.mockk)
  53 +}
  54 +
  55 +tasks.test {
  56 + useJUnitPlatform()
  57 +}
... ...
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt 0 → 100644
  1 +package org.vibeerp.pbc.production.application
  2 +
  3 +import org.slf4j.LoggerFactory
  4 +import org.springframework.stereotype.Service
  5 +import org.springframework.transaction.annotation.Transactional
  6 +import org.vibeerp.api.v1.event.EventBus
  7 +import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent
  8 +import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent
  9 +import org.vibeerp.api.v1.event.production.WorkOrderCreatedEvent
  10 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  11 +import org.vibeerp.api.v1.ext.inventory.InventoryApi
  12 +import org.vibeerp.pbc.production.domain.WorkOrder
  13 +import org.vibeerp.pbc.production.domain.WorkOrderStatus
  14 +import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
  15 +import java.math.BigDecimal
  16 +import java.time.LocalDate
  17 +import java.util.UUID
  18 +
  19 +/**
  20 + * Application service for [WorkOrder] CRUD and state transitions.
  21 + *
  22 + * **Cross-PBC seams** (all read through `api.v1.ext.*` interfaces,
  23 + * never through pbc-* internals):
  24 + * - [CatalogApi] — validates that the output item exists and is
  25 + * active before a work order can be created.
  26 + * - [InventoryApi] — credits the finished good to the receiving
  27 + * location when [complete] is called, with reason
  28 + * `PRODUCTION_RECEIPT` (added in commit `c52d0d5`) and a
  29 + * `WO:<order_code>` reference. Same primitive that pbc-orders-sales
  30 + * and pbc-orders-purchase call — one ledger, three callers.
  31 + *
  32 + * **Event publishing.** Each state-changing method publishes a
  33 + * typed event from `api.v1.event.production.*` inside the same
  34 + * `@Transactional` method as the JPA mutation and the (when
  35 + * applicable) ledger write. EventBusImpl uses
  36 + * `Propagation.MANDATORY` so a publish outside a transaction would
  37 + * throw — every method here is transactional, so the contract is
  38 + * always met. A failure on any line rolls back the status change
  39 + * AND the would-have-been outbox row.
  40 + *
  41 + * **State machine** (enforced by [complete] and [cancel]):
  42 + * - DRAFT → COMPLETED (complete)
  43 + * - DRAFT → CANCELLED (cancel)
  44 + * - Anything else throws.
  45 + *
  46 + * The minimal v1 has no `update` method — work orders are
  47 + * effectively immutable from creation until completion. A future
  48 + * "update output quantity before start" gesture lands as its own
  49 + * verb when the operations team needs it.
  50 + */
  51 +@Service
  52 +@Transactional
  53 +class WorkOrderService(
  54 + private val orders: WorkOrderJpaRepository,
  55 + private val catalogApi: CatalogApi,
  56 + private val inventoryApi: InventoryApi,
  57 + private val eventBus: EventBus,
  58 +) {
  59 +
  60 + private val log = LoggerFactory.getLogger(WorkOrderService::class.java)
  61 +
  62 + @Transactional(readOnly = true)
  63 + fun list(): List<WorkOrder> = orders.findAll()
  64 +
  65 + @Transactional(readOnly = true)
  66 + fun findById(id: UUID): WorkOrder? = orders.findById(id).orElse(null)
  67 +
  68 + @Transactional(readOnly = true)
  69 + fun findByCode(code: String): WorkOrder? = orders.findByCode(code)
  70 +
  71 + @Transactional(readOnly = true)
  72 + fun findBySourceSalesOrderCode(salesOrderCode: String): List<WorkOrder> =
  73 + orders.findBySourceSalesOrderCode(salesOrderCode)
  74 +
  75 + fun create(command: CreateWorkOrderCommand): WorkOrder {
  76 + require(!orders.existsByCode(command.code)) {
  77 + "work order code '${command.code}' is already taken"
  78 + }
  79 + require(command.outputQuantity.signum() > 0) {
  80 + "output quantity must be positive (got ${command.outputQuantity})"
  81 + }
  82 + // Cross-PBC validation: the output item must exist in the
  83 + // catalog and be active. Identical seam to the one
  84 + // SalesOrderService and PurchaseOrderService use.
  85 + catalogApi.findItemByCode(command.outputItemCode)
  86 + ?: throw IllegalArgumentException(
  87 + "output item code '${command.outputItemCode}' is not in the catalog (or is inactive)",
  88 + )
  89 +
  90 + val order = WorkOrder(
  91 + code = command.code,
  92 + outputItemCode = command.outputItemCode,
  93 + outputQuantity = command.outputQuantity,
  94 + status = WorkOrderStatus.DRAFT,
  95 + dueDate = command.dueDate,
  96 + sourceSalesOrderCode = command.sourceSalesOrderCode,
  97 + )
  98 + val saved = orders.save(order)
  99 +
  100 + eventBus.publish(
  101 + WorkOrderCreatedEvent(
  102 + orderCode = saved.code,
  103 + outputItemCode = saved.outputItemCode,
  104 + outputQuantity = saved.outputQuantity,
  105 + sourceSalesOrderCode = saved.sourceSalesOrderCode,
  106 + ),
  107 + )
  108 + return saved
  109 + }
  110 +
  111 + /**
  112 + * Mark a DRAFT work order as COMPLETED, crediting [outputLocationCode]
  113 + * with the output quantity in the same transaction.
  114 + *
  115 + * **Cross-PBC WRITE** through the same `InventoryApi.recordMovement`
  116 + * facade that pbc-orders-purchase uses for receipt and pbc-orders-sales
  117 + * uses for shipment. The reason is `PRODUCTION_RECEIPT` and the
  118 + * reference is `WO:<order_code>` so a ledger reader can attribute
  119 + * the row to this work order.
  120 + *
  121 + * The whole operation runs in ONE transaction. A failure on the
  122 + * inventory write — bad item, bad location, would push balance
  123 + * negative (impossible for a positive delta but the framework
  124 + * checks it anyway) — rolls back BOTH the ledger row AND the
  125 + * status change. There is no half-completed work order with
  126 + * a partial inventory write.
  127 + */
  128 + fun complete(id: UUID, outputLocationCode: String): WorkOrder {
  129 + val order = orders.findById(id).orElseThrow {
  130 + NoSuchElementException("work order not found: $id")
  131 + }
  132 + require(order.status == WorkOrderStatus.DRAFT) {
  133 + "cannot complete work order ${order.code} in status ${order.status}; " +
  134 + "only DRAFT can be completed"
  135 + }
  136 +
  137 + // Credit the finished good to the receiving location.
  138 + inventoryApi.recordMovement(
  139 + itemCode = order.outputItemCode,
  140 + locationCode = outputLocationCode,
  141 + delta = order.outputQuantity,
  142 + reason = "PRODUCTION_RECEIPT",
  143 + reference = "WO:${order.code}",
  144 + )
  145 +
  146 + order.status = WorkOrderStatus.COMPLETED
  147 +
  148 + eventBus.publish(
  149 + WorkOrderCompletedEvent(
  150 + orderCode = order.code,
  151 + outputItemCode = order.outputItemCode,
  152 + outputQuantity = order.outputQuantity,
  153 + outputLocationCode = outputLocationCode,
  154 + ),
  155 + )
  156 + return order
  157 + }
  158 +
  159 + fun cancel(id: UUID): WorkOrder {
  160 + val order = orders.findById(id).orElseThrow {
  161 + NoSuchElementException("work order not found: $id")
  162 + }
  163 + // COMPLETED is terminal — once finished goods exist on the
  164 + // shelves the framework will NOT let you "uncomplete" them.
  165 + // A real shop floor needs a "scrap defective output" flow,
  166 + // but that's its own event for a future chunk.
  167 + require(order.status == WorkOrderStatus.DRAFT) {
  168 + "cannot cancel work order ${order.code} in status ${order.status}; " +
  169 + "only DRAFT work orders can be cancelled"
  170 + }
  171 + order.status = WorkOrderStatus.CANCELLED
  172 +
  173 + eventBus.publish(
  174 + WorkOrderCancelledEvent(
  175 + orderCode = order.code,
  176 + outputItemCode = order.outputItemCode,
  177 + outputQuantity = order.outputQuantity,
  178 + ),
  179 + )
  180 + return order
  181 + }
  182 +}
  183 +
  184 +data class CreateWorkOrderCommand(
  185 + val code: String,
  186 + val outputItemCode: String,
  187 + val outputQuantity: BigDecimal,
  188 + val dueDate: LocalDate? = null,
  189 + val sourceSalesOrderCode: String? = null,
  190 +)
... ...
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt 0 → 100644
  1 +package org.vibeerp.pbc.production.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.EnumType
  6 +import jakarta.persistence.Enumerated
  7 +import jakarta.persistence.Table
  8 +import org.hibernate.annotations.JdbcTypeCode
  9 +import org.hibernate.type.SqlTypes
  10 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  11 +import java.math.BigDecimal
  12 +import java.time.LocalDate
  13 +
  14 +/**
  15 + * A work order: an instruction to manufacture a specific quantity of
  16 + * a specific finished-good item.
  17 + *
  18 + * **Why this PBC exists.** pbc-production is the framework's eighth
  19 + * PBC and the first one that's NOT order-shaped or master-data-shaped.
  20 + * Sales and purchase orders are about money moving in and out;
  21 + * inventory is about counts. Work orders are about *making things* —
  22 + * which is the actual reason the printing-shop reference customer
  23 + * exists. With this PBC in place the framework can express "the
  24 + * customer ordered 1000 brochures, the floor produced 1000 brochures,
  25 + * stock now contains 1000 brochures".
  26 + *
  27 + * **What v1 deliberately does NOT model:**
  28 + * - **No bill of materials.** A real BOM would list every raw
  29 + * material consumed per unit of output. The minimal v1 only
  30 + * tracks the output side (production_receipt) — material issues
  31 + * happen as separate manual movements via the ledger. The next
  32 + * chunk after this one will probably add a `WorkOrderInput`
  33 + * child entity and have `complete()` issue raw materials too.
  34 + * - **No routings or operations.** A real shop floor would model
  35 + * each step of production (cut, print, fold, bind, pack) with
  36 + * its own duration and machine assignment. v1 collapses the
  37 + * whole journey into a single complete() call.
  38 + * - **No IN_PROGRESS state.** v1 has DRAFT → COMPLETED in one
  39 + * step; v2 will add IN_PROGRESS so a started-but-not-finished
  40 + * order is observable on a dashboard.
  41 + * - **No scheduling, no capacity planning, no due-date enforcement.**
  42 + * A `dueDate` field exists for display only; nothing in the
  43 + * framework refuses a late completion.
  44 + *
  45 + * **State machine:**
  46 + * - **DRAFT** — created but not yet completed. Lines may still
  47 + * change (in this v1 there are no lines, just
  48 + * the output item + quantity).
  49 + * - **COMPLETED** — terminal happy path. The
  50 + * `complete(outputLocationCode)` call has written
  51 + * a `PRODUCTION_RECEIPT` ledger row crediting the
  52 + * output item at the named location, and the
  53 + * `WorkOrderCompletedEvent` has been published in
  54 + * the same transaction.
  55 + * - **CANCELLED** — terminal. Reachable only from DRAFT — you
  56 + * cannot "uncomplete" finished goods. A real shop
  57 + * will eventually need a "scrap" flow for
  58 + * completed-but-defective output, but that's a
  59 + * different event for a future chunk.
  60 + *
  61 + * **Why `output_item_code` and `output_quantity` are columns rather
  62 + * than a one-line `WorkOrderLine` child entity:** v1 produces
  63 + * exactly one finished-good item per work order. A multi-output
  64 + * model (a single press run printing 4 different brochures
  65 + * simultaneously) is real but uncommon enough that flattening it
  66 + * into one row keeps the v1 schema and the v1 unit tests trivially
  67 + * verifiable. When the multi-output case lands, this column moves
  68 + * into a child table and the parent table loses these two fields.
  69 + *
  70 + * **`source_sales_order_code` is nullable** because work orders can
  71 + * be created for two reasons: (a) the [SalesOrderConfirmedSubscriber]
  72 + * auto-spawns one when a sales order is confirmed (filled in), or
  73 + * (b) an operator manually creates one to build inventory ahead of
  74 + * demand (null). Both are first-class.
  75 + */
  76 +@Entity
  77 +@Table(name = "production__work_order")
  78 +class WorkOrder(
  79 + code: String,
  80 + outputItemCode: String,
  81 + outputQuantity: BigDecimal,
  82 + status: WorkOrderStatus = WorkOrderStatus.DRAFT,
  83 + dueDate: LocalDate? = null,
  84 + sourceSalesOrderCode: String? = null,
  85 +) : AuditedJpaEntity() {
  86 +
  87 + @Column(name = "code", nullable = false, length = 64)
  88 + var code: String = code
  89 +
  90 + @Column(name = "output_item_code", nullable = false, length = 64)
  91 + var outputItemCode: String = outputItemCode
  92 +
  93 + @Column(name = "output_quantity", nullable = false, precision = 18, scale = 4)
  94 + var outputQuantity: BigDecimal = outputQuantity
  95 +
  96 + @Enumerated(EnumType.STRING)
  97 + @Column(name = "status", nullable = false, length = 16)
  98 + var status: WorkOrderStatus = status
  99 +
  100 + @Column(name = "due_date", nullable = true)
  101 + var dueDate: LocalDate? = dueDate
  102 +
  103 + @Column(name = "source_sales_order_code", nullable = true, length = 64)
  104 + var sourceSalesOrderCode: String? = sourceSalesOrderCode
  105 +
  106 + @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
  107 + @JdbcTypeCode(SqlTypes.JSON)
  108 + var ext: String = "{}"
  109 +
  110 + override fun toString(): String =
  111 + "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', status=$status)"
  112 +}
  113 +
  114 +/**
  115 + * State machine values for [WorkOrder]. See the entity KDoc for the
  116 + * allowed transitions and the rationale for each one.
  117 + */
  118 +enum class WorkOrderStatus {
  119 + DRAFT,
  120 + COMPLETED,
  121 + CANCELLED,
  122 +}
... ...
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/SalesOrderConfirmedSubscriber.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.orders.SalesOrderConfirmedEvent
  9 +import org.vibeerp.api.v1.ext.orders.SalesOrdersApi
  10 +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand
  11 +import org.vibeerp.pbc.production.application.WorkOrderService
  12 +
  13 +/**
  14 + * Reacts to `SalesOrderConfirmedEvent` by auto-spawning a draft
  15 + * [org.vibeerp.pbc.production.domain.WorkOrder] for every line of
  16 + * the confirmed sales order.
  17 + *
  18 + * **Why this is interesting** — pbc-finance is a *passive* consumer
  19 + * (it just records derived state), but pbc-production reacts to a
  20 + * cross-PBC event by *creating new business state* in its own
  21 + * aggregate. The created work orders are first-class objects with
  22 + * their own state machine, their own events, and their own write
  23 + * paths to other PBCs (the [InventoryApi.recordMovement] crediting
  24 + * `PRODUCTION_RECEIPT` on completion). This is the strongest test
  25 + * yet of the event-driven cross-PBC integration story.
  26 + *
  27 + * **Why we look the order back up via [SalesOrdersApi]** instead of
  28 + * carrying line data on the event itself: the v1 [SalesOrderConfirmedEvent]
  29 + * carries only header fields (orderCode, partnerCode, currency,
  30 + * total). Adding `lines` to the event would couple every consumer to
  31 + * a richer event payload and would force a major-version bump on
  32 + * api.v1 if the line shape ever changes. The cross-PBC facade lookup
  33 + * is the cleanest way to keep events small and stable while still
  34 + * giving consumers structured access to whatever they need.
  35 + *
  36 + * **Idempotent.** [SalesOrderConfirmedEvent] could be delivered more
  37 + * than once (outbox replay, future Kafka retry). The subscriber
  38 + * checks `existsBySourceSalesOrderCode` and returns early if any
  39 + * work orders already exist for that SO — the dedup contract is
  40 + * "one work order set per source sales order, ever". This is
  41 + * coarser than pbc-finance's per-event-id dedup because there is
  42 + * no natural one-to-one mapping (one SO with N lines produces N
  43 + * work orders) and "did we already process this SO" is the right
  44 + * question to ask.
  45 + *
  46 + * **One subscription, one bean.** Single `@PostConstruct` registers
  47 + * a listener via the typed-class `EventBus.subscribe` overload. Same
  48 + * pattern pbc-finance uses; same pattern any plug-in would use.
  49 + */
  50 +@Component
  51 +class SalesOrderConfirmedSubscriber(
  52 + private val eventBus: EventBus,
  53 + private val salesOrdersApi: SalesOrdersApi,
  54 + private val workOrders: WorkOrderService,
  55 +) {
  56 +
  57 + private val log = LoggerFactory.getLogger(SalesOrderConfirmedSubscriber::class.java)
  58 +
  59 + @PostConstruct
  60 + fun subscribe() {
  61 + eventBus.subscribe(
  62 + SalesOrderConfirmedEvent::class.java,
  63 + EventListener { event -> spawnWorkOrders(event) },
  64 + )
  65 + log.info(
  66 + "pbc-production subscribed to SalesOrderConfirmedEvent " +
  67 + "via EventBus.subscribe (typed-class overload)",
  68 + )
  69 + }
  70 +
  71 + /**
  72 + * For each line of the confirmed sales order, create one draft
  73 + * work order. Idempotent on (source_sales_order_code) — if any
  74 + * work order already exists for the SO code, the call is a
  75 + * complete no-op.
  76 + */
  77 + internal fun spawnWorkOrders(event: SalesOrderConfirmedEvent) {
  78 + if (workOrders.findBySourceSalesOrderCode(event.orderCode).isNotEmpty()) {
  79 + log.debug(
  80 + "[production] SalesOrderConfirmedEvent for {} already has work orders; skipping auto-spawn",
  81 + event.orderCode,
  82 + )
  83 + return
  84 + }
  85 + val so = salesOrdersApi.findByCode(event.orderCode)
  86 + if (so == null) {
  87 + // Sales order vanished between confirm and event delivery
  88 + // (impossible under the current synchronous bus, defensive
  89 + // for a future async bus). Log and move on.
  90 + log.warn(
  91 + "[production] SalesOrderConfirmedEvent for {} but SalesOrdersApi.findByCode returned null; skipping",
  92 + event.orderCode,
  93 + )
  94 + return
  95 + }
  96 + log.info(
  97 + "[production] auto-spawning {} work order(s) for sales order {}",
  98 + so.lines.size, so.code,
  99 + )
  100 + for (line in so.lines) {
  101 + workOrders.create(
  102 + CreateWorkOrderCommand(
  103 + // The work order code is derived from the SO
  104 + // code + line number to keep it unique and
  105 + // human-readable. e.g. SO-2026-0001 line 2 →
  106 + // WO-FROM-SO-2026-0001-L2.
  107 + code = "WO-FROM-${so.code}-L${line.lineNo}",
  108 + outputItemCode = line.itemCode,
  109 + outputQuantity = line.quantity,
  110 + sourceSalesOrderCode = so.code,
  111 + ),
  112 + )
  113 + }
  114 + }
  115 +}
... ...
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt 0 → 100644
  1 +package org.vibeerp.pbc.production.http
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonCreator
  4 +import com.fasterxml.jackson.annotation.JsonProperty
  5 +import jakarta.validation.Valid
  6 +import jakarta.validation.constraints.NotBlank
  7 +import jakarta.validation.constraints.NotNull
  8 +import jakarta.validation.constraints.Size
  9 +import org.springframework.http.HttpStatus
  10 +import org.springframework.http.ResponseEntity
  11 +import org.springframework.web.bind.annotation.GetMapping
  12 +import org.springframework.web.bind.annotation.PathVariable
  13 +import org.springframework.web.bind.annotation.PostMapping
  14 +import org.springframework.web.bind.annotation.RequestBody
  15 +import org.springframework.web.bind.annotation.RequestMapping
  16 +import org.springframework.web.bind.annotation.ResponseStatus
  17 +import org.springframework.web.bind.annotation.RestController
  18 +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand
  19 +import org.vibeerp.pbc.production.application.WorkOrderService
  20 +import org.vibeerp.pbc.production.domain.WorkOrder
  21 +import org.vibeerp.pbc.production.domain.WorkOrderStatus
  22 +import org.vibeerp.platform.security.authz.RequirePermission
  23 +import java.math.BigDecimal
  24 +import java.time.LocalDate
  25 +import java.util.UUID
  26 +
  27 +/**
  28 + * REST API for the work orders PBC.
  29 + *
  30 + * Mounted at `/api/v1/production/work-orders`. State transitions
  31 + * use dedicated `/complete` and `/cancel` endpoints — same shape
  32 + * as the order PBCs.
  33 + */
  34 +@RestController
  35 +@RequestMapping("/api/v1/production/work-orders")
  36 +class WorkOrderController(
  37 + private val workOrderService: WorkOrderService,
  38 +) {
  39 +
  40 + @GetMapping
  41 + @RequirePermission("production.work-order.read")
  42 + fun list(): List<WorkOrderResponse> =
  43 + workOrderService.list().map { it.toResponse() }
  44 +
  45 + @GetMapping("/{id}")
  46 + @RequirePermission("production.work-order.read")
  47 + fun get(@PathVariable id: UUID): ResponseEntity<WorkOrderResponse> {
  48 + val order = workOrderService.findById(id) ?: return ResponseEntity.notFound().build()
  49 + return ResponseEntity.ok(order.toResponse())
  50 + }
  51 +
  52 + @GetMapping("/by-code/{code}")
  53 + @RequirePermission("production.work-order.read")
  54 + fun getByCode(@PathVariable code: String): ResponseEntity<WorkOrderResponse> {
  55 + val order = workOrderService.findByCode(code) ?: return ResponseEntity.notFound().build()
  56 + return ResponseEntity.ok(order.toResponse())
  57 + }
  58 +
  59 + @PostMapping
  60 + @ResponseStatus(HttpStatus.CREATED)
  61 + @RequirePermission("production.work-order.create")
  62 + fun create(@RequestBody @Valid request: CreateWorkOrderRequest): WorkOrderResponse =
  63 + workOrderService.create(request.toCommand()).toResponse()
  64 +
  65 + /**
  66 + * Mark a DRAFT work order as COMPLETED. Atomically credits the
  67 + * output quantity to the named location via
  68 + * `InventoryApi.recordMovement(PRODUCTION_RECEIPT)`. See
  69 + * [WorkOrderService.complete] for the full rationale.
  70 + */
  71 + @PostMapping("/{id}/complete")
  72 + @RequirePermission("production.work-order.complete")
  73 + fun complete(
  74 + @PathVariable id: UUID,
  75 + @RequestBody @Valid request: CompleteWorkOrderRequest,
  76 + ): WorkOrderResponse =
  77 + workOrderService.complete(id, request.outputLocationCode).toResponse()
  78 +
  79 + @PostMapping("/{id}/cancel")
  80 + @RequirePermission("production.work-order.cancel")
  81 + fun cancel(@PathVariable id: UUID): WorkOrderResponse =
  82 + workOrderService.cancel(id).toResponse()
  83 +}
  84 +
  85 +// ─── DTOs ────────────────────────────────────────────────────────────
  86 +
  87 +data class CreateWorkOrderRequest(
  88 + @field:NotBlank @field:Size(max = 64) val code: String,
  89 + @field:NotBlank @field:Size(max = 64) val outputItemCode: String,
  90 + @field:NotNull val outputQuantity: BigDecimal,
  91 + val dueDate: LocalDate? = null,
  92 + @field:Size(max = 64) val sourceSalesOrderCode: String? = null,
  93 +) {
  94 + fun toCommand(): CreateWorkOrderCommand = CreateWorkOrderCommand(
  95 + code = code,
  96 + outputItemCode = outputItemCode,
  97 + outputQuantity = outputQuantity,
  98 + dueDate = dueDate,
  99 + sourceSalesOrderCode = sourceSalesOrderCode,
  100 + )
  101 +}
  102 +
  103 +/**
  104 + * Completion request body.
  105 + *
  106 + * **Single-arg Kotlin data class — same Jackson trap that bit
  107 + * `ShipSalesOrderRequest` and `ReceivePurchaseOrderRequest`.**
  108 + * jackson-module-kotlin treats a one-arg data class as a
  109 + * delegate-based creator and unwraps the body, which is wrong for
  110 + * an HTTP payload that always looks like
  111 + * `{"outputLocationCode": "..."}`. Fix: explicit
  112 + * `@JsonCreator(mode = PROPERTIES)` + `@param:JsonProperty`.
  113 + */
  114 +data class CompleteWorkOrderRequest @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) constructor(
  115 + @param:JsonProperty("outputLocationCode")
  116 + @field:NotBlank @field:Size(max = 64) val outputLocationCode: String,
  117 +)
  118 +
  119 +data class WorkOrderResponse(
  120 + val id: UUID,
  121 + val code: String,
  122 + val outputItemCode: String,
  123 + val outputQuantity: BigDecimal,
  124 + val status: WorkOrderStatus,
  125 + val dueDate: LocalDate?,
  126 + val sourceSalesOrderCode: String?,
  127 +)
  128 +
  129 +private fun WorkOrder.toResponse(): WorkOrderResponse =
  130 + WorkOrderResponse(
  131 + id = id,
  132 + code = code,
  133 + outputItemCode = outputItemCode,
  134 + outputQuantity = outputQuantity,
  135 + status = status,
  136 + dueDate = dueDate,
  137 + sourceSalesOrderCode = sourceSalesOrderCode,
  138 + )
... ...
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/infrastructure/WorkOrderJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.production.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.production.domain.WorkOrder
  6 +import org.vibeerp.pbc.production.domain.WorkOrderStatus
  7 +import java.util.UUID
  8 +
  9 +/**
  10 + * Spring Data JPA repository for [WorkOrder].
  11 + *
  12 + * The lookup methods support both the controller (list/find by code)
  13 + * and the [SalesOrderConfirmedSubscriber] (find existing work orders
  14 + * for a given source sales order code, used for the idempotency
  15 + * check on auto-creation).
  16 + */
  17 +@Repository
  18 +interface WorkOrderJpaRepository : JpaRepository<WorkOrder, UUID> {
  19 + fun existsByCode(code: String): Boolean
  20 + fun findByCode(code: String): WorkOrder?
  21 + fun findByStatus(status: WorkOrderStatus): List<WorkOrder>
  22 + fun findBySourceSalesOrderCode(sourceSalesOrderCode: String): List<WorkOrder>
  23 + fun existsBySourceSalesOrderCode(sourceSalesOrderCode: String): Boolean
  24 +}
... ...
pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml 0 → 100644
  1 +# pbc-production metadata.
  2 +#
  3 +# Loaded at boot by MetadataLoader, tagged source='core'. Minimal
  4 +# v1: one entity (WorkOrder), four permissions, one menu.
  5 +
  6 +entities:
  7 + - name: WorkOrder
  8 + pbc: production
  9 + table: production__work_order
  10 + description: A single-output work order — one finished good item × quantity, with optional source sales order linkage
  11 +
  12 +permissions:
  13 + - key: production.work-order.read
  14 + description: Read work orders
  15 + - key: production.work-order.create
  16 + description: Create draft work orders
  17 + - key: production.work-order.complete
  18 + description: Mark a draft work order completed (DRAFT → COMPLETED, credits inventory atomically)
  19 + - key: production.work-order.cancel
  20 + description: Cancel a draft work order (DRAFT → CANCELLED)
  21 +
  22 +menus:
  23 + - path: /production/work-orders
  24 + label: Work orders
  25 + icon: factory
  26 + section: Production
  27 + order: 800
... ...
pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.production.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.hasMessage
  6 +import assertk.assertions.isEqualTo
  7 +import assertk.assertions.isInstanceOf
  8 +import assertk.assertions.messageContains
  9 +import io.mockk.Runs
  10 +import io.mockk.every
  11 +import io.mockk.just
  12 +import io.mockk.mockk
  13 +import io.mockk.slot
  14 +import io.mockk.verify
  15 +import org.junit.jupiter.api.BeforeEach
  16 +import org.junit.jupiter.api.Test
  17 +import org.vibeerp.api.v1.core.Id
  18 +import org.vibeerp.api.v1.event.EventBus
  19 +import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent
  20 +import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent
  21 +import org.vibeerp.api.v1.event.production.WorkOrderCreatedEvent
  22 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  23 +import org.vibeerp.api.v1.ext.catalog.ItemRef
  24 +import org.vibeerp.api.v1.ext.inventory.InventoryApi
  25 +import org.vibeerp.api.v1.ext.inventory.StockBalanceRef
  26 +import org.vibeerp.pbc.production.domain.WorkOrder
  27 +import org.vibeerp.pbc.production.domain.WorkOrderStatus
  28 +import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
  29 +import java.math.BigDecimal
  30 +import java.util.Optional
  31 +import java.util.UUID
  32 +
  33 +class WorkOrderServiceTest {
  34 +
  35 + private lateinit var orders: WorkOrderJpaRepository
  36 + private lateinit var catalogApi: CatalogApi
  37 + private lateinit var inventoryApi: InventoryApi
  38 + private lateinit var eventBus: EventBus
  39 + private lateinit var service: WorkOrderService
  40 +
  41 + @BeforeEach
  42 + fun setUp() {
  43 + orders = mockk()
  44 + catalogApi = mockk()
  45 + inventoryApi = mockk()
  46 + eventBus = mockk()
  47 + every { orders.existsByCode(any()) } returns false
  48 + every { orders.save(any<WorkOrder>()) } answers { firstArg() }
  49 + every { eventBus.publish(any()) } just Runs
  50 + service = WorkOrderService(orders, catalogApi, inventoryApi, eventBus)
  51 + }
  52 +
  53 + private fun stubItem(code: String) {
  54 + every { catalogApi.findItemByCode(code) } returns ItemRef(
  55 + id = Id(UUID.randomUUID()),
  56 + code = code,
  57 + name = "Stub item",
  58 + itemType = "GOOD",
  59 + baseUomCode = "ea",
  60 + active = true,
  61 + )
  62 + }
  63 +
  64 + private fun stubInventoryCredit(itemCode: String, locationCode: String, expectedDelta: BigDecimal) {
  65 + every {
  66 + inventoryApi.recordMovement(
  67 + itemCode = itemCode,
  68 + locationCode = locationCode,
  69 + delta = expectedDelta,
  70 + reason = "PRODUCTION_RECEIPT",
  71 + reference = any(),
  72 + )
  73 + } returns StockBalanceRef(
  74 + id = Id(UUID.randomUUID()),
  75 + itemCode = itemCode,
  76 + locationCode = locationCode,
  77 + quantity = BigDecimal("100"),
  78 + )
  79 + }
  80 +
  81 + // ─── create ──────────────────────────────────────────────────────
  82 +
  83 + @Test
  84 + fun `create rejects a duplicate code`() {
  85 + every { orders.existsByCode("WO-DUP") } returns true
  86 +
  87 + assertFailure {
  88 + service.create(
  89 + CreateWorkOrderCommand(code = "WO-DUP", outputItemCode = "FG-1", outputQuantity = BigDecimal("10")),
  90 + )
  91 + }
  92 + .isInstanceOf(IllegalArgumentException::class)
  93 + .hasMessage("work order code 'WO-DUP' is already taken")
  94 + }
  95 +
  96 + @Test
  97 + fun `create rejects non-positive output quantity`() {
  98 + assertFailure {
  99 + service.create(
  100 + CreateWorkOrderCommand(code = "WO-1", outputItemCode = "FG-1", outputQuantity = BigDecimal("0")),
  101 + )
  102 + }
  103 + .isInstanceOf(IllegalArgumentException::class)
  104 + .messageContains("must be positive")
  105 + }
  106 +
  107 + @Test
  108 + fun `create rejects an unknown catalog item via the CatalogApi seam`() {
  109 + every { catalogApi.findItemByCode("FAKE") } returns null
  110 +
  111 + assertFailure {
  112 + service.create(
  113 + CreateWorkOrderCommand(code = "WO-1", outputItemCode = "FAKE", outputQuantity = BigDecimal("10")),
  114 + )
  115 + }
  116 + .isInstanceOf(IllegalArgumentException::class)
  117 + .messageContains("not in the catalog")
  118 + }
  119 +
  120 + @Test
  121 + fun `create saves a DRAFT work order and publishes WorkOrderCreatedEvent`() {
  122 + stubItem("FG-1")
  123 +
  124 + val saved = service.create(
  125 + CreateWorkOrderCommand(
  126 + code = "WO-1",
  127 + outputItemCode = "FG-1",
  128 + outputQuantity = BigDecimal("25"),
  129 + sourceSalesOrderCode = "SO-42",
  130 + ),
  131 + )
  132 +
  133 + assertThat(saved.status).isEqualTo(WorkOrderStatus.DRAFT)
  134 + assertThat(saved.outputQuantity).isEqualTo(BigDecimal("25"))
  135 + assertThat(saved.sourceSalesOrderCode).isEqualTo("SO-42")
  136 + verify(exactly = 1) {
  137 + eventBus.publish(
  138 + match<WorkOrderCreatedEvent> {
  139 + it.orderCode == "WO-1" &&
  140 + it.outputItemCode == "FG-1" &&
  141 + it.outputQuantity == BigDecimal("25") &&
  142 + it.sourceSalesOrderCode == "SO-42"
  143 + },
  144 + )
  145 + }
  146 + }
  147 +
  148 + // ─── complete ────────────────────────────────────────────────────
  149 +
  150 + private fun draftOrder(
  151 + id: UUID = UUID.randomUUID(),
  152 + code: String = "WO-1",
  153 + itemCode: String = "FG-1",
  154 + qty: String = "100",
  155 + ): WorkOrder = WorkOrder(
  156 + code = code,
  157 + outputItemCode = itemCode,
  158 + outputQuantity = BigDecimal(qty),
  159 + status = WorkOrderStatus.DRAFT,
  160 + ).also { it.id = id }
  161 +
  162 + @Test
  163 + fun `complete rejects a non-DRAFT work order`() {
  164 + val id = UUID.randomUUID()
  165 + val done = WorkOrder(
  166 + code = "WO-1",
  167 + outputItemCode = "FG-1",
  168 + outputQuantity = BigDecimal("5"),
  169 + status = WorkOrderStatus.COMPLETED,
  170 + ).also { it.id = id }
  171 + every { orders.findById(id) } returns Optional.of(done)
  172 +
  173 + assertFailure { service.complete(id, "WH-FG") }
  174 + .isInstanceOf(IllegalArgumentException::class)
  175 + .messageContains("only DRAFT can be completed")
  176 + verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) }
  177 + }
  178 +
  179 + @Test
  180 + fun `complete credits inventory and publishes WorkOrderCompletedEvent`() {
  181 + val id = UUID.randomUUID()
  182 + val order = draftOrder(id = id, code = "WO-9", itemCode = "FG-WIDGET", qty = "25")
  183 + every { orders.findById(id) } returns Optional.of(order)
  184 + stubInventoryCredit("FG-WIDGET", "WH-FG", BigDecimal("25"))
  185 +
  186 + val result = service.complete(id, "WH-FG")
  187 +
  188 + assertThat(result.status).isEqualTo(WorkOrderStatus.COMPLETED)
  189 + verify(exactly = 1) {
  190 + inventoryApi.recordMovement(
  191 + itemCode = "FG-WIDGET",
  192 + locationCode = "WH-FG",
  193 + delta = BigDecimal("25"),
  194 + reason = "PRODUCTION_RECEIPT",
  195 + reference = "WO:WO-9",
  196 + )
  197 + }
  198 + verify(exactly = 1) {
  199 + eventBus.publish(
  200 + match<WorkOrderCompletedEvent> {
  201 + it.orderCode == "WO-9" &&
  202 + it.outputItemCode == "FG-WIDGET" &&
  203 + it.outputQuantity == BigDecimal("25") &&
  204 + it.outputLocationCode == "WH-FG"
  205 + },
  206 + )
  207 + }
  208 + }
  209 +
  210 + // ─── cancel ──────────────────────────────────────────────────────
  211 +
  212 + @Test
  213 + fun `cancel flips a DRAFT order to CANCELLED and publishes`() {
  214 + val id = UUID.randomUUID()
  215 + val order = draftOrder(id = id, code = "WO-C")
  216 + every { orders.findById(id) } returns Optional.of(order)
  217 +
  218 + val result = service.cancel(id)
  219 +
  220 + assertThat(result.status).isEqualTo(WorkOrderStatus.CANCELLED)
  221 + verify(exactly = 1) {
  222 + eventBus.publish(
  223 + match<WorkOrderCancelledEvent> { it.orderCode == "WO-C" },
  224 + )
  225 + }
  226 + }
  227 +
  228 + @Test
  229 + fun `cancel rejects a COMPLETED order (no un-producing finished goods)`() {
  230 + val id = UUID.randomUUID()
  231 + val done = WorkOrder(
  232 + code = "WO-DONE",
  233 + outputItemCode = "FG-1",
  234 + outputQuantity = BigDecimal("5"),
  235 + status = WorkOrderStatus.COMPLETED,
  236 + ).also { it.id = id }
  237 + every { orders.findById(id) } returns Optional.of(done)
  238 +
  239 + assertFailure { service.cancel(id) }
  240 + .isInstanceOf(IllegalArgumentException::class)
  241 + .messageContains("only DRAFT work orders can be cancelled")
  242 + }
  243 +}
... ...
pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/SalesOrderConfirmedSubscriberTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.production.event
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.hasSize
  5 +import assertk.assertions.isEqualTo
  6 +import io.mockk.Runs
  7 +import io.mockk.every
  8 +import io.mockk.just
  9 +import io.mockk.mockk
  10 +import io.mockk.slot
  11 +import io.mockk.verify
  12 +import org.junit.jupiter.api.Test
  13 +import org.vibeerp.api.v1.core.Id
  14 +import org.vibeerp.api.v1.event.EventBus
  15 +import org.vibeerp.api.v1.event.EventListener
  16 +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent
  17 +import org.vibeerp.api.v1.ext.orders.SalesOrderLineRef
  18 +import org.vibeerp.api.v1.ext.orders.SalesOrderRef
  19 +import org.vibeerp.api.v1.ext.orders.SalesOrdersApi
  20 +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand
  21 +import org.vibeerp.pbc.production.application.WorkOrderService
  22 +import java.math.BigDecimal
  23 +import java.time.LocalDate
  24 +
  25 +class SalesOrderConfirmedSubscriberTest {
  26 +
  27 + private fun salesOrder(code: String, vararg lines: Pair<String, String>): SalesOrderRef =
  28 + SalesOrderRef(
  29 + id = Id(java.util.UUID.randomUUID()),
  30 + code = code,
  31 + partnerCode = "CUST-1",
  32 + status = "CONFIRMED",
  33 + orderDate = LocalDate.of(2026, 4, 8),
  34 + currencyCode = "USD",
  35 + totalAmount = BigDecimal("0"),
  36 + lines = lines.mapIndexed { i, (item, qty) ->
  37 + SalesOrderLineRef(
  38 + lineNo = i + 1,
  39 + itemCode = item,
  40 + quantity = BigDecimal(qty),
  41 + unitPrice = BigDecimal("1.00"),
  42 + currencyCode = "USD",
  43 + )
  44 + },
  45 + )
  46 +
  47 + @Test
  48 + fun `subscribe registers one listener for SalesOrderConfirmedEvent`() {
  49 + val eventBus = mockk<EventBus>(relaxed = true)
  50 + val salesOrdersApi = mockk<SalesOrdersApi>()
  51 + val workOrders = mockk<WorkOrderService>()
  52 +
  53 + SalesOrderConfirmedSubscriber(eventBus, salesOrdersApi, workOrders).subscribe()
  54 +
  55 + verify(exactly = 1) {
  56 + eventBus.subscribe(SalesOrderConfirmedEvent::class.java, any<EventListener<SalesOrderConfirmedEvent>>())
  57 + }
  58 + }
  59 +
  60 + @Test
  61 + fun `spawnWorkOrders creates one draft work order per sales order line`() {
  62 + val eventBus = mockk<EventBus>(relaxed = true)
  63 + val salesOrdersApi = mockk<SalesOrdersApi>()
  64 + val workOrders = mockk<WorkOrderService>()
  65 + val subscriber = SalesOrderConfirmedSubscriber(eventBus, salesOrdersApi, workOrders)
  66 +
  67 + every { workOrders.findBySourceSalesOrderCode("SO-42") } returns emptyList()
  68 + every { salesOrdersApi.findByCode("SO-42") } returns salesOrder(
  69 + "SO-42",
  70 + "BROCHURE-A" to "500",
  71 + "BROCHURE-B" to "250",
  72 + )
  73 + val captured = mutableListOf<CreateWorkOrderCommand>()
  74 + every { workOrders.create(capture(captured)) } answers { mockk(relaxed = true) }
  75 +
  76 + subscriber.spawnWorkOrders(
  77 + SalesOrderConfirmedEvent(
  78 + orderCode = "SO-42",
  79 + partnerCode = "CUST-1",
  80 + currencyCode = "USD",
  81 + totalAmount = BigDecimal("0"),
  82 + ),
  83 + )
  84 +
  85 + assertThat(captured).hasSize(2)
  86 + with(captured[0]) {
  87 + assertThat(code).isEqualTo("WO-FROM-SO-42-L1")
  88 + assertThat(outputItemCode).isEqualTo("BROCHURE-A")
  89 + assertThat(outputQuantity).isEqualTo(BigDecimal("500"))
  90 + assertThat(sourceSalesOrderCode).isEqualTo("SO-42")
  91 + }
  92 + with(captured[1]) {
  93 + assertThat(code).isEqualTo("WO-FROM-SO-42-L2")
  94 + assertThat(outputItemCode).isEqualTo("BROCHURE-B")
  95 + assertThat(outputQuantity).isEqualTo(BigDecimal("250"))
  96 + assertThat(sourceSalesOrderCode).isEqualTo("SO-42")
  97 + }
  98 + }
  99 +
  100 + @Test
  101 + fun `spawnWorkOrders is idempotent when work orders already exist for the SO`() {
  102 + val eventBus = mockk<EventBus>(relaxed = true)
  103 + val salesOrdersApi = mockk<SalesOrdersApi>()
  104 + val workOrders = mockk<WorkOrderService>()
  105 +
  106 + every { workOrders.findBySourceSalesOrderCode("SO-DUP") } returns listOf(mockk())
  107 +
  108 + SalesOrderConfirmedSubscriber(eventBus, salesOrdersApi, workOrders).spawnWorkOrders(
  109 + SalesOrderConfirmedEvent(
  110 + orderCode = "SO-DUP",
  111 + partnerCode = "CUST-1",
  112 + currencyCode = "USD",
  113 + totalAmount = BigDecimal("0"),
  114 + ),
  115 + )
  116 +
  117 + verify(exactly = 0) { salesOrdersApi.findByCode(any()) }
  118 + verify(exactly = 0) { workOrders.create(any()) }
  119 + }
  120 +
  121 + @Test
  122 + fun `spawnWorkOrders is a no-op when the SO has vanished by the time the event arrives`() {
  123 + val eventBus = mockk<EventBus>(relaxed = true)
  124 + val salesOrdersApi = mockk<SalesOrdersApi>()
  125 + val workOrders = mockk<WorkOrderService>()
  126 + every { workOrders.findBySourceSalesOrderCode("SO-GONE") } returns emptyList()
  127 + every { salesOrdersApi.findByCode("SO-GONE") } returns null
  128 +
  129 + SalesOrderConfirmedSubscriber(eventBus, salesOrdersApi, workOrders).spawnWorkOrders(
  130 + SalesOrderConfirmedEvent(
  131 + orderCode = "SO-GONE",
  132 + partnerCode = "CUST-1",
  133 + currencyCode = "USD",
  134 + totalAmount = BigDecimal("0"),
  135 + ),
  136 + )
  137 +
  138 + verify(exactly = 0) { workOrders.create(any()) }
  139 + }
  140 +
  141 + @Test
  142 + fun `the registered listener forwards the event to spawnWorkOrders`() {
  143 + val eventBus = mockk<EventBus>(relaxed = true)
  144 + val salesOrdersApi = mockk<SalesOrdersApi>()
  145 + val workOrders = mockk<WorkOrderService>(relaxed = true)
  146 + val captured = slot<EventListener<SalesOrderConfirmedEvent>>()
  147 + every {
  148 + eventBus.subscribe(SalesOrderConfirmedEvent::class.java, capture(captured))
  149 + } answers { mockk(relaxed = true) }
  150 +
  151 + SalesOrderConfirmedSubscriber(eventBus, salesOrdersApi, workOrders).subscribe()
  152 +
  153 + // The listener should call into spawnWorkOrders on the same
  154 + // bean; we can't easily capture the method call inside the
  155 + // bean itself, so we verify the downstream effect: it checks
  156 + // the sales-order lookup.
  157 + every { workOrders.findBySourceSalesOrderCode("SO-ROUTE") } returns emptyList()
  158 + every { salesOrdersApi.findByCode("SO-ROUTE") } returns null
  159 +
  160 + captured.captured.handle(
  161 + SalesOrderConfirmedEvent(
  162 + orderCode = "SO-ROUTE",
  163 + partnerCode = "CUST-1",
  164 + currencyCode = "USD",
  165 + totalAmount = BigDecimal("0"),
  166 + ),
  167 + )
  168 +
  169 + verify(exactly = 1) { workOrders.findBySourceSalesOrderCode("SO-ROUTE") }
  170 + }
  171 +}
... ...
settings.gradle.kts
... ... @@ -64,6 +64,9 @@ project(&quot;:pbc:pbc-orders-purchase&quot;).projectDir = file(&quot;pbc/pbc-orders-purchase&quot;)
64 64 include(":pbc:pbc-finance")
65 65 project(":pbc:pbc-finance").projectDir = file("pbc/pbc-finance")
66 66  
  67 +include(":pbc:pbc-production")
  68 +project(":pbc:pbc-production").projectDir = file("pbc/pbc-production")
  69 +
67 70 // ─── Reference customer plug-in (NOT loaded by default) ─────────────
68 71 include(":reference-customer:plugin-printing-shop")
69 72 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop")
... ...