Commit b1d433e7ae91e143b7eb1ba0659e8073304efa7d
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.
Showing
9 changed files
with
430 additions
and
13 deletions
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() | ... | ... |