Commit b1d433e7ae91e143b7eb1ba0659e8073304efa7d

Authored by zichun
1 parent e7301754

feat(events): wire SalesOrderService + PurchaseOrderService onto the event bus

The event bus and transactional outbox have existed since P1.7 but no
real PBC business logic was publishing through them. This change closes
that loop end-to-end:

api.v1.event.orders (new public surface)
  - SalesOrderConfirmedEvent / SalesOrderShippedEvent /
    SalesOrderCancelledEvent — sealed under SalesOrderEvent,
    aggregateType = "orders_sales.SalesOrder"
  - PurchaseOrderConfirmedEvent / PurchaseOrderReceivedEvent /
    PurchaseOrderCancelledEvent — sealed under PurchaseOrderEvent,
    aggregateType = "orders_purchase.PurchaseOrder"
  - Events live in api.v1 (not inside the PBCs) so other PBCs and
    customer plug-ins can subscribe without importing the producing
    PBC — that would violate guardrail #9.

pbc-orders-sales / pbc-orders-purchase
  - SalesOrderService and PurchaseOrderService now inject EventBus
    and publish a typed event from each state-changing method
    (confirm, ship/receive, cancel). The publish runs INSIDE the
    same @Transactional method as the JPA mutation and the
    InventoryApi.recordMovement ledger writes — EventBusImpl uses
    Propagation.MANDATORY, so a publish outside a transaction
    fails loudly. A failure in any line rolls back the status
    change AND every ledger row AND the would-have-been outbox row.
  - 6 new unit tests (3 per service) mockk the EventBus and verify
    each transition publishes exactly one matching event with the
    expected fields. Total tests: 186 → 192.

End-to-end smoke verified against real Postgres
  - Created supplier, customer, item PAPER-A4, location WH-MAIN.
  - Drove a PO and an SO through the full state machine plus a
    cancel of each. 6 events fired:
      orders_purchase.PurchaseOrder × 3 (confirm + receive + cancel)
      orders_sales.SalesOrder       × 3 (confirm + ship + cancel)
  - The wildcard EventAuditLogSubscriber logged each one at INFO
    level to /tmp/vibe-erp-boot.log with the [event-audit] tag.
  - platform__event_outbox shows 6 rows, all flipped from PENDING
    to DISPATCHED by the OutboxPoller within seconds.
  - The publish-inside-the-ledger-transaction guarantee means a
    subscriber that reads inventory__stock_movement on event
    receipt is guaranteed to see the matching SALES_SHIPMENT or
    PURCHASE_RECEIPT rows. This is what the architecture spec
    section 9 promised and now delivers.

Why this is the right shape
  - Other PBCs (production, finance) and customer plug-ins can now
    react to "an order was confirmed/shipped/received/cancelled"
    without ever importing pbc-orders-* internals. The event class
    objects live in api.v1, the only stable contract surface.
  - The aggregateType strings ("orders_sales.SalesOrder",
    "orders_purchase.PurchaseOrder") match the <pbc>.<aggregate>
    convention documented on DomainEvent.aggregateType, so a
    cross-classloader subscriber can use the topic-string subscribe
    overload without holding the concrete Class<E>.
  - The bus's outbox row is the durability anchor for the future
    Kafka/NATS bridge: switching from in-process delivery to
    cross-process delivery will require zero changes to either
    PBC's publish call.
CLAUDE.md
... ... @@ -96,9 +96,10 @@ plugins (incl. ref) depend on: api/api-v1 only
96 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97  
98 98 - **16 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   -- **186 unit tests across 16 modules**, all green. `./gradlew build` is the canonical full build.
  99 +- **192 unit tests across 16 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 101 - **6 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`). **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.
  102 +- **Event-driven cross-PBC integration is live.** Six typed events under `org.vibeerp.api.v1.event.orders.*` (`SalesOrderConfirmed/Shipped/Cancelled`, `PurchaseOrderConfirmed/Received/Cancelled`) are published from `SalesOrderService` and `PurchaseOrderService` inside the same `@Transactional` method as their state changes. The wildcard `EventAuditLogSubscriber` logs every one and `platform__event_outbox` rows are persisted + dispatched by the `OutboxPoller`. This is the first end-to-end use of the event bus from real PBC business logic.
102 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.
103 104 - **Package root** is `org.vibeerp`.
104 105 - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot.
... ...
PROGRESS.md
... ... @@ -10,27 +10,27 @@
10 10  
11 11 | | |
12 12 |---|---|
13   -| **Latest version** | v0.14 (post-P5.6) |
14   -| **Latest commit** | `2861656 feat(pbc): P5.6 — pbc-orders-purchase + receive-driven inventory increase` |
  13 +| **Latest version** | v0.15 (post-event-driven cross-PBC integration) |
  14 +| **Latest commit** | `<pin after push>` |
15 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 16 | **Modules** | 16 |
17   -| **Unit tests** | 186, all green |
18   -| **End-to-end smoke runs** | The full buy-and-sell loop works: create supplier + customer + item + location, place a PO for 5000, confirm, **receive** (stock goes from 0 to 5000 via `PURCHASE_RECEIPT` ledger row tagged `PO:PO-2026-0001`), then place a SO for 50, confirm, **ship** (stock drops to 4950 via `SALES_SHIPMENT` tagged `SO:SO-2026-0001`). Both PBCs feed the same ledger. |
  17 +| **Unit tests** | 192, all green |
  18 +| **End-to-end smoke runs** | The full buy-and-sell loop works AND every state transition publishes a domain event end-to-end: PO confirm/receive/cancel emit `orders_purchase.PurchaseOrder` events, SO confirm/ship/cancel emit `orders_sales.SalesOrder` events. The wildcard `EventAuditLogSubscriber` logs each one and `platform__event_outbox` rows are persisted in the same transaction as the state change and dispatched by the `OutboxPoller`. Smoke verified: 6 events fired, 6 outbox rows DISPATCHED. |
19 19 | **Real PBCs implemented** | 6 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`) |
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  
23 23 ## Current stage
24 24  
25   -**Foundation complete; Tier 1 customization live; authorization enforced; full buy-and-sell loop closed.** All eight cross-cutting platform services are live plus the authorization layer. **The framework now has six PBCs and both ends of the inventory loop work**: a purchase order receives stock via `PURCHASE_RECEIPT` ledger rows tagged `PO:<code>`, a sales order ships stock via `SALES_SHIPMENT` ledger rows tagged `SO:<code>`. Both PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade — the cross-PBC contract supports BOTH directions and BOTH consumers identically. The end-to-end demo "buy 5000 sheets, ship 50, see balance = 4950 with both ledger rows tagged correctly" runs in one smoke test.
  25 +**Foundation complete; Tier 1 customization live; authorization enforced; full buy-and-sell loop closed; event-driven cross-PBC integration live.** All eight cross-cutting platform services are live plus the authorization layer. **The framework now has six PBCs, both ends of the inventory loop work, and every state transition emits a typed domain event** through the `EventBus` + transactional outbox. The 6 new events live in `api.v1.event.orders` (`SalesOrderConfirmed/Shipped/Cancelled`, `PurchaseOrderConfirmed/Received/Cancelled`) so any future PBC, plug-in, or subscriber can react without importing pbc-orders-sales or pbc-orders-purchase. Each publish runs inside the same `@Transactional` method as the state change and the ledger writes — a rollback on any line rolls the publish back too.
26 26  
27   -The next phase continues **building business surface area**: pbc-production (the framework's first non-order/non-master-data PBC), the workflow engine (Flowable), event-driven cross-PBC integration (the event bus has been wired since P1.7 but no flow uses it yet), and eventually the React SPA.
  27 +The next phase continues **building business surface area**: pbc-production (the framework's first non-order/non-master-data PBC), the workflow engine (Flowable), and eventually the React SPA.
28 28  
29 29 ## Total scope (the v1.0 cut line)
30 30  
31 31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar.
32 32  
33   -That target breaks down into roughly 30 work units across 8 phases. About **22 are done** as of today. Below is the full list with status.
  33 +That target breaks down into roughly 30 work units across 8 phases. About **22 are done** as of today, plus the event-driven cross-PBC integration follow-up that completes the P1.7 story. Below is the full list with status.
34 34  
35 35 ### Phase 1 — Platform completion (foundation)
36 36  
... ... @@ -125,7 +125,7 @@ These are the cross-cutting platform services already wired into the running fra
125 125 | **Plug-in HTTP** (P1.3) | `platform-plugins` | Plug-ins call `context.endpoints.register(method, path, handler)` to mount lambdas under `/api/v1/plugins/<plugin-id>/<path>`. Path templates with `{var}` extraction via Spring's `AntPathMatcher`. Plug-in code never imports Spring MVC types. |
126 126 | **Plug-in linter** (P1.2) | `platform-plugins` | At plug-in load time (before any plug-in code runs), ASM-walks every `.class` entry for references to `org.vibeerp.platform.*` or `org.vibeerp.pbc.*`. Forbidden references unload the plug-in with a per-class violation report. |
127 127 | **Plug-in DB schemas** (P1.4) | `platform-plugins` | Each plug-in ships its own `META-INF/vibe-erp/db/changelog.xml`. The host's `PluginLiquibaseRunner` applies it against the shared host datasource at plug-in start. Plug-ins query their own tables via the api.v1 `PluginJdbc` typed-SQL surface — no Spring or Hibernate types ever leak. |
128   -| **Event bus + outbox** (P1.7) | `platform-events` | Synchronous in-process delivery PLUS a transactional outbox row in the same DB transaction. `Propagation.MANDATORY` so the bus refuses to publish outside an active transaction (no publish-and-rollback leaks). `OutboxPoller` flips PENDING → DISPATCHED every 5s. Wildcard `**` topic for the audit subscriber; topic-string and class-based subscribe. |
  128 +| **Event bus + outbox** (P1.7) | `platform-events` | Synchronous in-process delivery PLUS a transactional outbox row in the same DB transaction. `Propagation.MANDATORY` so the bus refuses to publish outside an active transaction (no publish-and-rollback leaks). `OutboxPoller` flips PENDING → DISPATCHED every 5s. Wildcard `**` topic for the audit subscriber; topic-string and class-based subscribe. **Now exercised end-to-end:** `SalesOrderService.confirm/ship/cancel` and `PurchaseOrderService.confirm/receive/cancel` each publish a typed event from `api.v1.event.orders.*` inside the same `@Transactional` method as their state change and ledger writes. Smoke test confirms the wildcard `EventAuditLogSubscriber` logs every one and `platform__event_outbox` rows are persisted + dispatched. |
129 129 | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. |
130 130 | **i18n / Translator** (P1.6) | `platform-i18n` | ICU4J-backed `Translator` with named placeholders, plurals, gender, locale-aware number/date formatting. Per-plug-in instance scoped via a `(classLoader, baseName)` chain — plug-in's `META-INF/vibe-erp/i18n/messages_<locale>.properties` resolves before the host's `messages_<locale>.properties` for shared keys. `RequestLocaleProvider` reads `Accept-Language` from the active HTTP request via the servlet container, falling back to `vibeerp.i18n.defaultLocale` outside an HTTP context. JVM-default locale fallback explicitly disabled to prevent silent locale leaks. |
131 131 | **Custom field application** (P3.4) | `platform-metadata.customfield` | `CustomFieldRegistry` reads `metadata__custom_field` rows into an in-memory index keyed by entity name, refreshed at boot and after every plug-in load. `ExtJsonValidator` validates the JSONB `ext` map of any entity against the declared `FieldType`s — String maxLength, Integer/Decimal numeric coercion with precision/scale enforcement, Boolean, Date/DateTime ISO-8601 parsing, Enum allowed values, UUID format. Unknown keys are rejected; required missing fields are rejected; ALL violations are returned in a single 400 so a form submitter fixes everything in one round-trip. `Partner` is the first PBC entity to wire ext through `PartnerService.create/update`; the public `GET /api/v1/_meta/metadata/custom-fields/{entityName}` endpoint serves the api.v1 runtime view of declarations to the SPA / OpenAPI / AI agent. |
... ...
README.md
... ... @@ -77,7 +77,7 @@ vibe-erp/
77 77 ## Building
78 78  
79 79 ```bash
80   -# Build everything (compiles 16 modules, runs 186 unit tests)
  80 +# Build everything (compiles 16 modules, runs 192 unit tests)
81 81 ./gradlew build
82 82  
83 83 # Bring up Postgres + the reference plug-in JAR
... ... @@ -97,7 +97,7 @@ The bootstrap admin password is printed to the application logs on first boot. A
97 97 | | |
98 98 |---|---|
99 99 | Modules | 16 |
100   -| Unit tests | 186, all green |
  100 +| Unit tests | 192, all green |
101 101 | Real PBCs | 6 of 10 |
102 102 | Cross-cutting services live | 9 |
103 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/orders/PurchaseOrderEvents.kt 0 → 100644
  1 +package org.vibeerp.api.v1.event.orders
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +import org.vibeerp.api.v1.event.DomainEvent
  5 +import java.time.Instant
  6 +
  7 +/**
  8 + * Domain events emitted by the purchase order PBC.
  9 + *
  10 + * The buying-side mirror of [SalesOrderEvent]. Same shape — order
  11 + * code + partner code + state-transition fields — with the partner
  12 + * playing the SUPPLIER role and the stock direction inverted.
  13 + *
  14 + * **`aggregateType` convention:** `orders_purchase.PurchaseOrder`.
  15 + * Distinct from `orders_sales.SalesOrder` so a finance consumer can
  16 + * subscribe to one or both directions without filtering.
  17 + */
  18 +public sealed interface PurchaseOrderEvent : DomainEvent {
  19 + public val orderCode: String
  20 + public val partnerCode: String
  21 +}
  22 +
  23 +/**
  24 + * Emitted when a DRAFT purchase order is confirmed (sent to the supplier).
  25 + *
  26 + * Listeners react by, for example: opening a payable on the supplier
  27 + * account, expecting an inbound shipment in the warehouse dashboard,
  28 + * or alerting production that ordered raw materials are on the way.
  29 + * No stock has moved yet — that's [PurchaseOrderReceivedEvent].
  30 + */
  31 +public data class PurchaseOrderConfirmedEvent(
  32 + override val orderCode: String,
  33 + override val partnerCode: String,
  34 + val currencyCode: String,
  35 + val totalAmount: java.math.BigDecimal,
  36 + override val eventId: Id<DomainEvent> = Id.random(),
  37 + override val occurredAt: Instant = Instant.now(),
  38 +) : PurchaseOrderEvent {
  39 + override val aggregateType: String get() = PURCHASE_AGGREGATE_TYPE
  40 + override val aggregateId: String get() = orderCode
  41 +}
  42 +
  43 +/**
  44 + * Emitted when a CONFIRMED purchase order is received.
  45 + *
  46 + * The companion `PURCHASE_RECEIPT` ledger rows are guaranteed to
  47 + * exist by the time this fires (same transaction). `receivingLocationCode`
  48 + * is included for warehouse / put-away listeners that need to know
  49 + * where the goods landed without re-reading the order.
  50 + */
  51 +public data class PurchaseOrderReceivedEvent(
  52 + override val orderCode: String,
  53 + override val partnerCode: String,
  54 + val receivingLocationCode: String,
  55 + override val eventId: Id<DomainEvent> = Id.random(),
  56 + override val occurredAt: Instant = Instant.now(),
  57 +) : PurchaseOrderEvent {
  58 + override val aggregateType: String get() = PURCHASE_AGGREGATE_TYPE
  59 + override val aggregateId: String get() = orderCode
  60 +}
  61 +
  62 +/**
  63 + * Emitted when a purchase order is cancelled (from DRAFT or CONFIRMED).
  64 + *
  65 + * Cancellation from RECEIVED is forbidden — that path is
  66 + * "return-to-supplier", a separate flow.
  67 + */
  68 +public data class PurchaseOrderCancelledEvent(
  69 + override val orderCode: String,
  70 + override val partnerCode: String,
  71 + override val eventId: Id<DomainEvent> = Id.random(),
  72 + override val occurredAt: Instant = Instant.now(),
  73 +) : PurchaseOrderEvent {
  74 + override val aggregateType: String get() = PURCHASE_AGGREGATE_TYPE
  75 + override val aggregateId: String get() = orderCode
  76 +}
  77 +
  78 +/** Topic string for wildcard / topic-based subscriptions to purchase order events. */
  79 +public const val PURCHASE_AGGREGATE_TYPE: String = "orders_purchase.PurchaseOrder"
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/orders/SalesOrderEvents.kt 0 → 100644
  1 +package org.vibeerp.api.v1.event.orders
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +import org.vibeerp.api.v1.event.DomainEvent
  5 +import java.time.Instant
  6 +
  7 +/**
  8 + * Domain events emitted by the sales order PBC.
  9 + *
  10 + * **Why these live in `api.v1` and not inside pbc-orders-sales:**
  11 + * - Other PBCs (production, finance) and customer plug-ins must be
  12 + * able to subscribe to "the customer's order shipped" without
  13 + * importing pbc-orders-sales' internal classes. The Gradle build
  14 + * refuses cross-PBC dependencies (CLAUDE.md guardrail #9), so the
  15 + * only place a subscriber can see these classes is `api.v1`.
  16 + * - The class objects are part of the framework's stable contract.
  17 + * Renaming a field here is a major-version bump (CLAUDE.md
  18 + * guardrail #10).
  19 + *
  20 + * **`aggregateType` convention:** `orders_sales.SalesOrder`. Picked
  21 + * to match the `<pbc>.<aggregate>` pattern documented on
  22 + * [DomainEvent.aggregateType]. The `_` separator is intentional —
  23 + * Postgres outbox indexes treat `.` as part of the value, and the
  24 + * underscore reads more naturally for the directory-style "everything
  25 + * about sales orders" subscription.
  26 + *
  27 + * **What each event carries:** the order's business code (the human
  28 + * key, stable across the system) and the partner code (so a finance
  29 + * subscriber can post the receivable without re-reading the order),
  30 + * plus whatever state-transition fields the consumer cannot derive
  31 + * from the order code alone. Currency and total are included on
  32 + * confirm/ship because finance and reporting consumers want them
  33 + * without the round-trip back to pbc-orders-sales.
  34 + */
  35 +public sealed interface SalesOrderEvent : DomainEvent {
  36 + public val orderCode: String
  37 + public val partnerCode: String
  38 +}
  39 +
  40 +/**
  41 + * Emitted when a DRAFT sales order is confirmed.
  42 + *
  43 + * Listeners react by, for example: scheduling production, putting
  44 + * the order on a credit hold queue, or posting an "expected revenue"
  45 + * row to a reporting cube. The order is NOT yet shipped — no stock
  46 + * has moved.
  47 + */
  48 +public data class SalesOrderConfirmedEvent(
  49 + override val orderCode: String,
  50 + override val partnerCode: String,
  51 + val currencyCode: String,
  52 + val totalAmount: java.math.BigDecimal,
  53 + override val eventId: Id<DomainEvent> = Id.random(),
  54 + override val occurredAt: Instant = Instant.now(),
  55 +) : SalesOrderEvent {
  56 + override val aggregateType: String get() = AGGREGATE_TYPE
  57 + override val aggregateId: String get() = orderCode
  58 +}
  59 +
  60 +/**
  61 + * Emitted when a CONFIRMED sales order is shipped.
  62 + *
  63 + * The companion stock-movement rows have already been written by
  64 + * the time this event is published — the publish happens inside
  65 + * the SAME transaction as the ledger writes, so a subscriber that
  66 + * reads `inventory__stock_movement` immediately on receipt is
  67 + * guaranteed to see the corresponding rows.
  68 + *
  69 + * `shippingLocationCode` is included so a downstream "warehouse
  70 + * dashboard" subscriber can show the dispatch without re-reading
  71 + * the order.
  72 + */
  73 +public data class SalesOrderShippedEvent(
  74 + override val orderCode: String,
  75 + override val partnerCode: String,
  76 + val shippingLocationCode: String,
  77 + override val eventId: Id<DomainEvent> = Id.random(),
  78 + override val occurredAt: Instant = Instant.now(),
  79 +) : SalesOrderEvent {
  80 + override val aggregateType: String get() = AGGREGATE_TYPE
  81 + override val aggregateId: String get() = orderCode
  82 +}
  83 +
  84 +/**
  85 + * Emitted when a sales order is cancelled (from DRAFT or CONFIRMED).
  86 + *
  87 + * Cancellation from SHIPPED is forbidden by the service — that path
  88 + * is "issue a return", which will be a different event when the
  89 + * returns flow lands.
  90 + */
  91 +public data class SalesOrderCancelledEvent(
  92 + override val orderCode: String,
  93 + override val partnerCode: String,
  94 + override val eventId: Id<DomainEvent> = Id.random(),
  95 + override val occurredAt: Instant = Instant.now(),
  96 +) : SalesOrderEvent {
  97 + override val aggregateType: String get() = AGGREGATE_TYPE
  98 + override val aggregateId: String get() = orderCode
  99 +}
  100 +
  101 +/** Topic string for wildcard / topic-based subscriptions to sales order events. */
  102 +public const val AGGREGATE_TYPE: String = "orders_sales.SalesOrder"
... ...
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt
... ... @@ -4,6 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper
4 4 import com.fasterxml.jackson.module.kotlin.registerKotlinModule
5 5 import org.springframework.stereotype.Service
6 6 import org.springframework.transaction.annotation.Transactional
  7 +import org.vibeerp.api.v1.event.EventBus
  8 +import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent
  9 +import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent
  10 +import org.vibeerp.api.v1.event.orders.PurchaseOrderReceivedEvent
7 11 import org.vibeerp.api.v1.ext.catalog.CatalogApi
8 12 import org.vibeerp.api.v1.ext.inventory.InventoryApi
9 13 import org.vibeerp.api.v1.ext.partners.PartnersApi
... ... @@ -48,6 +52,7 @@ class PurchaseOrderService(
48 52 private val catalogApi: CatalogApi,
49 53 private val inventoryApi: InventoryApi,
50 54 private val extValidator: ExtJsonValidator,
  55 + private val eventBus: EventBus,
51 56 ) {
52 57  
53 58 private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
... ... @@ -206,6 +211,15 @@ class PurchaseOrderService(
206 211 "cannot confirm purchase order ${order.code} in status ${order.status}; only DRAFT can be confirmed"
207 212 }
208 213 order.status = PurchaseOrderStatus.CONFIRMED
  214 +
  215 + eventBus.publish(
  216 + PurchaseOrderConfirmedEvent(
  217 + orderCode = order.code,
  218 + partnerCode = order.partnerCode,
  219 + currencyCode = order.currencyCode,
  220 + totalAmount = order.totalAmount,
  221 + ),
  222 + )
209 223 return order
210 224 }
211 225  
... ... @@ -224,6 +238,13 @@ class PurchaseOrderService(
224 238 "issue a return-to-supplier flow instead"
225 239 }
226 240 order.status = PurchaseOrderStatus.CANCELLED
  241 +
  242 + eventBus.publish(
  243 + PurchaseOrderCancelledEvent(
  244 + orderCode = order.code,
  245 + partnerCode = order.partnerCode,
  246 + ),
  247 + )
227 248 return order
228 249 }
229 250  
... ... @@ -272,6 +293,19 @@ class PurchaseOrderService(
272 293 }
273 294  
274 295 order.status = PurchaseOrderStatus.RECEIVED
  296 +
  297 + // Mirror of SalesOrderService.ship's publish: same transaction
  298 + // as the ledger writes above and the status change. A
  299 + // subscriber reading inventory__stock_movement on receipt is
  300 + // guaranteed to see every PURCHASE_RECEIPT row tagged
  301 + // PO:<order_code>.
  302 + eventBus.publish(
  303 + PurchaseOrderReceivedEvent(
  304 + orderCode = order.code,
  305 + partnerCode = order.partnerCode,
  306 + receivingLocationCode = receivingLocationCode,
  307 + ),
  308 + )
275 309 return order
276 310 }
277 311  
... ...
pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt
... ... @@ -8,12 +8,18 @@ import assertk.assertions.isEqualTo
8 8 import assertk.assertions.isInstanceOf
9 9 import assertk.assertions.messageContains
10 10 import io.mockk.every
  11 +import io.mockk.just
11 12 import io.mockk.mockk
  13 +import io.mockk.runs
12 14 import io.mockk.slot
13 15 import io.mockk.verify
14 16 import org.junit.jupiter.api.BeforeEach
15 17 import org.junit.jupiter.api.Test
16 18 import org.vibeerp.api.v1.core.Id
  19 +import org.vibeerp.api.v1.event.EventBus
  20 +import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent
  21 +import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent
  22 +import org.vibeerp.api.v1.event.orders.PurchaseOrderReceivedEvent
17 23 import org.vibeerp.api.v1.ext.catalog.CatalogApi
18 24 import org.vibeerp.api.v1.ext.catalog.ItemRef
19 25 import org.vibeerp.api.v1.ext.inventory.InventoryApi
... ... @@ -37,6 +43,7 @@ class PurchaseOrderServiceTest {
37 43 private lateinit var catalogApi: CatalogApi
38 44 private lateinit var inventoryApi: InventoryApi
39 45 private lateinit var extValidator: ExtJsonValidator
  46 + private lateinit var eventBus: EventBus
40 47 private lateinit var service: PurchaseOrderService
41 48  
42 49 @BeforeEach
... ... @@ -46,10 +53,12 @@ class PurchaseOrderServiceTest {
46 53 catalogApi = mockk()
47 54 inventoryApi = mockk()
48 55 extValidator = mockk()
  56 + eventBus = mockk()
49 57 every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() }
50 58 every { orders.existsByCode(any()) } returns false
51 59 every { orders.save(any<PurchaseOrder>()) } answers { firstArg() }
52   - service = PurchaseOrderService(orders, partnersApi, catalogApi, inventoryApi, extValidator)
  60 + every { eventBus.publish(any()) } just runs
  61 + service = PurchaseOrderService(orders, partnersApi, catalogApi, inventoryApi, extValidator, eventBus)
53 62 }
54 63  
55 64 private fun stubSupplier(code: String, type: String = "SUPPLIER") {
... ... @@ -310,4 +319,76 @@ class PurchaseOrderServiceTest {
310 319  
311 320 assertThat(cancelled.status).isEqualTo(PurchaseOrderStatus.CANCELLED)
312 321 }
  322 +
  323 + // ─── event bus integration ───────────────────────────────────────
  324 +
  325 + @Test
  326 + fun `confirm publishes PurchaseOrderConfirmedEvent with header fields`() {
  327 + val id = UUID.randomUUID()
  328 + val order = PurchaseOrder(
  329 + code = "PO-42",
  330 + partnerCode = "SUP-1",
  331 + status = PurchaseOrderStatus.DRAFT,
  332 + orderDate = LocalDate.of(2026, 4, 8),
  333 + currencyCode = "USD",
  334 + totalAmount = BigDecimal("550.00"),
  335 + ).also { it.id = id }
  336 + every { orders.findById(id) } returns Optional.of(order)
  337 +
  338 + service.confirm(id)
  339 +
  340 + verify(exactly = 1) {
  341 + eventBus.publish(
  342 + match<PurchaseOrderConfirmedEvent> {
  343 + it.orderCode == "PO-42" &&
  344 + it.partnerCode == "SUP-1" &&
  345 + it.currencyCode == "USD" &&
  346 + it.totalAmount == BigDecimal("550.00")
  347 + },
  348 + )
  349 + }
  350 + }
  351 +
  352 + @Test
  353 + fun `receive publishes PurchaseOrderReceivedEvent with location`() {
  354 + val id = UUID.randomUUID()
  355 + val po = confirmedPo(id, lines = listOf("PAPER-A4" to "5000"))
  356 + every { orders.findById(id) } returns Optional.of(po)
  357 + stubInventoryCredit("PAPER-A4", "WH-MAIN", BigDecimal("5000"))
  358 +
  359 + service.receive(id, "WH-MAIN")
  360 +
  361 + verify(exactly = 1) {
  362 + eventBus.publish(
  363 + match<PurchaseOrderReceivedEvent> {
  364 + it.orderCode == "PO-1" &&
  365 + it.partnerCode == "SUP-1" &&
  366 + it.receivingLocationCode == "WH-MAIN"
  367 + },
  368 + )
  369 + }
  370 + }
  371 +
  372 + @Test
  373 + fun `cancel publishes PurchaseOrderCancelledEvent`() {
  374 + val id = UUID.randomUUID()
  375 + val po = PurchaseOrder(
  376 + code = "PO-7",
  377 + partnerCode = "SUP-1",
  378 + status = PurchaseOrderStatus.CONFIRMED,
  379 + orderDate = LocalDate.of(2026, 4, 8),
  380 + currencyCode = "USD",
  381 + ).also { it.id = id }
  382 + every { orders.findById(id) } returns Optional.of(po)
  383 +
  384 + service.cancel(id)
  385 +
  386 + verify(exactly = 1) {
  387 + eventBus.publish(
  388 + match<PurchaseOrderCancelledEvent> {
  389 + it.orderCode == "PO-7" && it.partnerCode == "SUP-1"
  390 + },
  391 + )
  392 + }
  393 + }
313 394 }
... ...
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt
... ... @@ -4,6 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper
4 4 import com.fasterxml.jackson.module.kotlin.registerKotlinModule
5 5 import org.springframework.stereotype.Service
6 6 import org.springframework.transaction.annotation.Transactional
  7 +import org.vibeerp.api.v1.event.EventBus
  8 +import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent
  9 +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent
  10 +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent
7 11 import org.vibeerp.api.v1.ext.catalog.CatalogApi
8 12 import org.vibeerp.api.v1.ext.inventory.InventoryApi
9 13 import org.vibeerp.api.v1.ext.partners.PartnersApi
... ... @@ -71,6 +75,7 @@ class SalesOrderService(
71 75 private val catalogApi: CatalogApi,
72 76 private val inventoryApi: InventoryApi,
73 77 private val extValidator: ExtJsonValidator,
  78 + private val eventBus: EventBus,
74 79 ) {
75 80  
76 81 private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
... ... @@ -232,6 +237,19 @@ class SalesOrderService(
232 237 "cannot confirm sales order ${order.code} in status ${order.status}; only DRAFT can be confirmed"
233 238 }
234 239 order.status = SalesOrderStatus.CONFIRMED
  240 +
  241 + // Publish the event INSIDE the same transaction as the
  242 + // status change. EventBus is wired with Propagation.MANDATORY,
  243 + // so this would throw if it ran outside one — which never
  244 + // happens here because the class is @Transactional.
  245 + eventBus.publish(
  246 + SalesOrderConfirmedEvent(
  247 + orderCode = order.code,
  248 + partnerCode = order.partnerCode,
  249 + currencyCode = order.currencyCode,
  250 + totalAmount = order.totalAmount,
  251 + ),
  252 + )
235 253 return order
236 254 }
237 255  
... ... @@ -250,6 +268,13 @@ class SalesOrderService(
250 268 "issue a return / refund flow instead"
251 269 }
252 270 order.status = SalesOrderStatus.CANCELLED
  271 +
  272 + eventBus.publish(
  273 + SalesOrderCancelledEvent(
  274 + orderCode = order.code,
  275 + partnerCode = order.partnerCode,
  276 + ),
  277 + )
253 278 return order
254 279 }
255 280  
... ... @@ -306,6 +331,19 @@ class SalesOrderService(
306 331 }
307 332  
308 333 order.status = SalesOrderStatus.SHIPPED
  334 +
  335 + // Publish AFTER all the inventory writes succeed but BEFORE
  336 + // returning. The publish is part of the same transaction as
  337 + // both the status change AND every recordMovement call above,
  338 + // so a subscriber that reads the ledger on receipt is
  339 + // guaranteed to see all the matching SALES_SHIPMENT rows.
  340 + eventBus.publish(
  341 + SalesOrderShippedEvent(
  342 + orderCode = order.code,
  343 + partnerCode = order.partnerCode,
  344 + shippingLocationCode = shippingLocationCode,
  345 + ),
  346 + )
309 347 return order
310 348 }
311 349  
... ...
pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt
... ... @@ -10,11 +10,17 @@ import assertk.assertions.isInstanceOf
10 10 import assertk.assertions.messageContains
11 11 import io.mockk.every
12 12 import io.mockk.mockk
  13 +import io.mockk.runs
  14 +import io.mockk.just
13 15 import io.mockk.slot
14 16 import io.mockk.verify
15 17 import org.junit.jupiter.api.BeforeEach
16 18 import org.junit.jupiter.api.Test
17 19 import org.vibeerp.api.v1.core.Id
  20 +import org.vibeerp.api.v1.event.EventBus
  21 +import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent
  22 +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent
  23 +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent
18 24 import org.vibeerp.api.v1.ext.catalog.CatalogApi
19 25 import org.vibeerp.api.v1.ext.catalog.ItemRef
20 26 import org.vibeerp.api.v1.ext.inventory.InventoryApi
... ... @@ -37,6 +43,7 @@ class SalesOrderServiceTest {
37 43 private lateinit var catalogApi: CatalogApi
38 44 private lateinit var inventoryApi: InventoryApi
39 45 private lateinit var extValidator: ExtJsonValidator
  46 + private lateinit var eventBus: EventBus
40 47 private lateinit var service: SalesOrderService
41 48  
42 49 @BeforeEach
... ... @@ -46,10 +53,13 @@ class SalesOrderServiceTest {
46 53 catalogApi = mockk()
47 54 inventoryApi = mockk()
48 55 extValidator = mockk()
  56 + eventBus = mockk()
49 57 every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() }
50 58 every { orders.existsByCode(any()) } returns false
51 59 every { orders.save(any<SalesOrder>()) } answers { firstArg() }
52   - service = SalesOrderService(orders, partnersApi, catalogApi, inventoryApi, extValidator)
  60 + // The bus is fire-and-forget from the service's perspective.
  61 + every { eventBus.publish(any()) } just runs
  62 + service = SalesOrderService(orders, partnersApi, catalogApi, inventoryApi, extValidator, eventBus)
53 63 }
54 64  
55 65 private fun stubCustomer(code: String, type: String = "CUSTOMER") {
... ... @@ -397,6 +407,78 @@ class SalesOrderServiceTest {
397 407 }
398 408 }
399 409  
  410 + // ─── event bus integration ───────────────────────────────────────
  411 +
  412 + @Test
  413 + fun `confirm publishes SalesOrderConfirmedEvent with header fields`() {
  414 + val id = UUID.randomUUID()
  415 + val order = SalesOrder(
  416 + code = "SO-42",
  417 + partnerCode = "CUST-1",
  418 + status = SalesOrderStatus.DRAFT,
  419 + orderDate = LocalDate.of(2026, 4, 8),
  420 + currencyCode = "USD",
  421 + totalAmount = BigDecimal("123.45"),
  422 + ).also { it.id = id }
  423 + every { orders.findById(id) } returns Optional.of(order)
  424 +
  425 + service.confirm(id)
  426 +
  427 + verify(exactly = 1) {
  428 + eventBus.publish(
  429 + match<SalesOrderConfirmedEvent> {
  430 + it.orderCode == "SO-42" &&
  431 + it.partnerCode == "CUST-1" &&
  432 + it.currencyCode == "USD" &&
  433 + it.totalAmount == BigDecimal("123.45")
  434 + },
  435 + )
  436 + }
  437 + }
  438 +
  439 + @Test
  440 + fun `ship publishes SalesOrderShippedEvent with shipping location`() {
  441 + val id = UUID.randomUUID()
  442 + val order = confirmedOrder(id, lines = listOf("PAPER-A4" to "10"))
  443 + every { orders.findById(id) } returns Optional.of(order)
  444 + stubInventoryDebit("PAPER-A4", "WH-MAIN", BigDecimal("-10"))
  445 +
  446 + service.ship(id, "WH-MAIN")
  447 +
  448 + verify(exactly = 1) {
  449 + eventBus.publish(
  450 + match<SalesOrderShippedEvent> {
  451 + it.orderCode == "SO-1" &&
  452 + it.partnerCode == "CUST-1" &&
  453 + it.shippingLocationCode == "WH-MAIN"
  454 + },
  455 + )
  456 + }
  457 + }
  458 +
  459 + @Test
  460 + fun `cancel publishes SalesOrderCancelledEvent`() {
  461 + val id = UUID.randomUUID()
  462 + val order = SalesOrder(
  463 + code = "SO-7",
  464 + partnerCode = "CUST-1",
  465 + status = SalesOrderStatus.CONFIRMED,
  466 + orderDate = LocalDate.of(2026, 4, 8),
  467 + currencyCode = "USD",
  468 + ).also { it.id = id }
  469 + every { orders.findById(id) } returns Optional.of(order)
  470 +
  471 + service.cancel(id)
  472 +
  473 + verify(exactly = 1) {
  474 + eventBus.publish(
  475 + match<SalesOrderCancelledEvent> {
  476 + it.orderCode == "SO-7" && it.partnerCode == "CUST-1"
  477 + },
  478 + )
  479 + }
  480 + }
  481 +
400 482 @Test
401 483 fun `cancel rejects a SHIPPED order`() {
402 484 val id = UUID.randomUUID()
... ...