From b1d433e7ae91e143b7eb1ba0659e8073304efa7d Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 8 Apr 2026 19:57:02 +0800 Subject: [PATCH] feat(events): wire SalesOrderService + PurchaseOrderService onto the event bus --- CLAUDE.md | 3 ++- PROGRESS.md | 16 ++++++++-------- README.md | 4 ++-- api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/orders/PurchaseOrderEvents.kt | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/orders/SalesOrderEvents.kt | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt | 34 ++++++++++++++++++++++++++++++++++ pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt | 38 ++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 9 files changed, 430 insertions(+), 13 deletions(-) create mode 100644 api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/orders/PurchaseOrderEvents.kt create mode 100644 api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/orders/SalesOrderEvents.kt diff --git a/CLAUDE.md b/CLAUDE.md index b6f6d12..d6411f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,9 +96,10 @@ plugins (incl. ref) depend on: api/api-v1 only **Foundation complete; first business surface in place.** As of the latest commit: - **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`. -- **186 unit tests across 16 modules**, all green. `./gradlew build` is the canonical full build. +- **192 unit tests across 16 modules**, all green. `./gradlew build` is the canonical full build. - **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. - **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. +- **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. - **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. - **Package root** is `org.vibeerp`. - **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. diff --git a/PROGRESS.md b/PROGRESS.md index acc8cec..015bb33 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,27 +10,27 @@ | | | |---|---| -| **Latest version** | v0.14 (post-P5.6) | -| **Latest commit** | `2861656 feat(pbc): P5.6 — pbc-orders-purchase + receive-driven inventory increase` | +| **Latest version** | v0.15 (post-event-driven cross-PBC integration) | +| **Latest commit** | `` | | **Repo** | https://github.com/reporkey/vibe-erp | | **Modules** | 16 | -| **Unit tests** | 186, all green | -| **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. | +| **Unit tests** | 192, all green | +| **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. | | **Real PBCs implemented** | 6 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`) | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | ## Current stage -**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:`, a sales order ships stock via `SALES_SHIPMENT` ledger rows tagged `SO:`. 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. +**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. -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. +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. ## Total scope (the v1.0 cut line) 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. -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. +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. ### Phase 1 — Platform completion (foundation) @@ -125,7 +125,7 @@ These are the cross-cutting platform services already wired into the running fra | **Plug-in HTTP** (P1.3) | `platform-plugins` | Plug-ins call `context.endpoints.register(method, path, handler)` to mount lambdas under `/api/v1/plugins//`. Path templates with `{var}` extraction via Spring's `AntPathMatcher`. Plug-in code never imports Spring MVC types. | | **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. | | **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. | -| **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. | +| **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. | | **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. | | **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_.properties` resolves before the host's `messages_.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. | | **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. | diff --git a/README.md b/README.md index 45674a2..4fff8f5 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ vibe-erp/ ## Building ```bash -# Build everything (compiles 16 modules, runs 186 unit tests) +# Build everything (compiles 16 modules, runs 192 unit tests) ./gradlew build # 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 | | | |---|---| | Modules | 16 | -| Unit tests | 186, all green | +| Unit tests | 192, all green | | Real PBCs | 6 of 10 | | Cross-cutting services live | 9 | | Plug-ins serving HTTP | 1 (reference printing-shop) | diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/orders/PurchaseOrderEvents.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/orders/PurchaseOrderEvents.kt new file mode 100644 index 0000000..183be70 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/orders/PurchaseOrderEvents.kt @@ -0,0 +1,79 @@ +package org.vibeerp.api.v1.event.orders + +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.event.DomainEvent +import java.time.Instant + +/** + * Domain events emitted by the purchase order PBC. + * + * The buying-side mirror of [SalesOrderEvent]. Same shape — order + * code + partner code + state-transition fields — with the partner + * playing the SUPPLIER role and the stock direction inverted. + * + * **`aggregateType` convention:** `orders_purchase.PurchaseOrder`. + * Distinct from `orders_sales.SalesOrder` so a finance consumer can + * subscribe to one or both directions without filtering. + */ +public sealed interface PurchaseOrderEvent : DomainEvent { + public val orderCode: String + public val partnerCode: String +} + +/** + * Emitted when a DRAFT purchase order is confirmed (sent to the supplier). + * + * Listeners react by, for example: opening a payable on the supplier + * account, expecting an inbound shipment in the warehouse dashboard, + * or alerting production that ordered raw materials are on the way. + * No stock has moved yet — that's [PurchaseOrderReceivedEvent]. + */ +public data class PurchaseOrderConfirmedEvent( + override val orderCode: String, + override val partnerCode: String, + val currencyCode: String, + val totalAmount: java.math.BigDecimal, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : PurchaseOrderEvent { + override val aggregateType: String get() = PURCHASE_AGGREGATE_TYPE + override val aggregateId: String get() = orderCode +} + +/** + * Emitted when a CONFIRMED purchase order is received. + * + * The companion `PURCHASE_RECEIPT` ledger rows are guaranteed to + * exist by the time this fires (same transaction). `receivingLocationCode` + * is included for warehouse / put-away listeners that need to know + * where the goods landed without re-reading the order. + */ +public data class PurchaseOrderReceivedEvent( + override val orderCode: String, + override val partnerCode: String, + val receivingLocationCode: String, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : PurchaseOrderEvent { + override val aggregateType: String get() = PURCHASE_AGGREGATE_TYPE + override val aggregateId: String get() = orderCode +} + +/** + * Emitted when a purchase order is cancelled (from DRAFT or CONFIRMED). + * + * Cancellation from RECEIVED is forbidden — that path is + * "return-to-supplier", a separate flow. + */ +public data class PurchaseOrderCancelledEvent( + override val orderCode: String, + override val partnerCode: String, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : PurchaseOrderEvent { + override val aggregateType: String get() = PURCHASE_AGGREGATE_TYPE + override val aggregateId: String get() = orderCode +} + +/** Topic string for wildcard / topic-based subscriptions to purchase order events. */ +public const val PURCHASE_AGGREGATE_TYPE: String = "orders_purchase.PurchaseOrder" diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/orders/SalesOrderEvents.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/orders/SalesOrderEvents.kt new file mode 100644 index 0000000..311613e --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/orders/SalesOrderEvents.kt @@ -0,0 +1,102 @@ +package org.vibeerp.api.v1.event.orders + +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.event.DomainEvent +import java.time.Instant + +/** + * Domain events emitted by the sales order PBC. + * + * **Why these live in `api.v1` and not inside pbc-orders-sales:** + * - Other PBCs (production, finance) and customer plug-ins must be + * able to subscribe to "the customer's order shipped" without + * importing pbc-orders-sales' internal classes. The Gradle build + * refuses cross-PBC dependencies (CLAUDE.md guardrail #9), so the + * only place a subscriber can see these classes is `api.v1`. + * - The class objects are part of the framework's stable contract. + * Renaming a field here is a major-version bump (CLAUDE.md + * guardrail #10). + * + * **`aggregateType` convention:** `orders_sales.SalesOrder`. Picked + * to match the `.` pattern documented on + * [DomainEvent.aggregateType]. The `_` separator is intentional — + * Postgres outbox indexes treat `.` as part of the value, and the + * underscore reads more naturally for the directory-style "everything + * about sales orders" subscription. + * + * **What each event carries:** the order's business code (the human + * key, stable across the system) and the partner code (so a finance + * subscriber can post the receivable without re-reading the order), + * plus whatever state-transition fields the consumer cannot derive + * from the order code alone. Currency and total are included on + * confirm/ship because finance and reporting consumers want them + * without the round-trip back to pbc-orders-sales. + */ +public sealed interface SalesOrderEvent : DomainEvent { + public val orderCode: String + public val partnerCode: String +} + +/** + * Emitted when a DRAFT sales order is confirmed. + * + * Listeners react by, for example: scheduling production, putting + * the order on a credit hold queue, or posting an "expected revenue" + * row to a reporting cube. The order is NOT yet shipped — no stock + * has moved. + */ +public data class SalesOrderConfirmedEvent( + override val orderCode: String, + override val partnerCode: String, + val currencyCode: String, + val totalAmount: java.math.BigDecimal, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : SalesOrderEvent { + override val aggregateType: String get() = AGGREGATE_TYPE + override val aggregateId: String get() = orderCode +} + +/** + * Emitted when a CONFIRMED sales order is shipped. + * + * The companion stock-movement rows have already been written by + * the time this event is published — the publish happens inside + * the SAME transaction as the ledger writes, so a subscriber that + * reads `inventory__stock_movement` immediately on receipt is + * guaranteed to see the corresponding rows. + * + * `shippingLocationCode` is included so a downstream "warehouse + * dashboard" subscriber can show the dispatch without re-reading + * the order. + */ +public data class SalesOrderShippedEvent( + override val orderCode: String, + override val partnerCode: String, + val shippingLocationCode: String, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : SalesOrderEvent { + override val aggregateType: String get() = AGGREGATE_TYPE + override val aggregateId: String get() = orderCode +} + +/** + * Emitted when a sales order is cancelled (from DRAFT or CONFIRMED). + * + * Cancellation from SHIPPED is forbidden by the service — that path + * is "issue a return", which will be a different event when the + * returns flow lands. + */ +public data class SalesOrderCancelledEvent( + override val orderCode: String, + override val partnerCode: String, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : SalesOrderEvent { + override val aggregateType: String get() = AGGREGATE_TYPE + override val aggregateId: String get() = orderCode +} + +/** Topic string for wildcard / topic-based subscriptions to sales order events. */ +public const val AGGREGATE_TYPE: String = "orders_sales.SalesOrder" diff --git a/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt index 2aa7097..01a7274 100644 --- a/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt +++ b/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 import com.fasterxml.jackson.module.kotlin.registerKotlinModule import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent +import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.PurchaseOrderReceivedEvent import org.vibeerp.api.v1.ext.catalog.CatalogApi import org.vibeerp.api.v1.ext.inventory.InventoryApi import org.vibeerp.api.v1.ext.partners.PartnersApi @@ -48,6 +52,7 @@ class PurchaseOrderService( private val catalogApi: CatalogApi, private val inventoryApi: InventoryApi, private val extValidator: ExtJsonValidator, + private val eventBus: EventBus, ) { private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() @@ -206,6 +211,15 @@ class PurchaseOrderService( "cannot confirm purchase order ${order.code} in status ${order.status}; only DRAFT can be confirmed" } order.status = PurchaseOrderStatus.CONFIRMED + + eventBus.publish( + PurchaseOrderConfirmedEvent( + orderCode = order.code, + partnerCode = order.partnerCode, + currencyCode = order.currencyCode, + totalAmount = order.totalAmount, + ), + ) return order } @@ -224,6 +238,13 @@ class PurchaseOrderService( "issue a return-to-supplier flow instead" } order.status = PurchaseOrderStatus.CANCELLED + + eventBus.publish( + PurchaseOrderCancelledEvent( + orderCode = order.code, + partnerCode = order.partnerCode, + ), + ) return order } @@ -272,6 +293,19 @@ class PurchaseOrderService( } order.status = PurchaseOrderStatus.RECEIVED + + // Mirror of SalesOrderService.ship's publish: same transaction + // as the ledger writes above and the status change. A + // subscriber reading inventory__stock_movement on receipt is + // guaranteed to see every PURCHASE_RECEIPT row tagged + // PO:. + eventBus.publish( + PurchaseOrderReceivedEvent( + orderCode = order.code, + partnerCode = order.partnerCode, + receivingLocationCode = receivingLocationCode, + ), + ) return order } diff --git a/pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt b/pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt index d126d52..409df05 100644 --- a/pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt +++ b/pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt @@ -8,12 +8,18 @@ import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf import assertk.assertions.messageContains import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.slot import io.mockk.verify import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent +import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.PurchaseOrderReceivedEvent import org.vibeerp.api.v1.ext.catalog.CatalogApi import org.vibeerp.api.v1.ext.catalog.ItemRef import org.vibeerp.api.v1.ext.inventory.InventoryApi @@ -37,6 +43,7 @@ class PurchaseOrderServiceTest { private lateinit var catalogApi: CatalogApi private lateinit var inventoryApi: InventoryApi private lateinit var extValidator: ExtJsonValidator + private lateinit var eventBus: EventBus private lateinit var service: PurchaseOrderService @BeforeEach @@ -46,10 +53,12 @@ class PurchaseOrderServiceTest { catalogApi = mockk() inventoryApi = mockk() extValidator = mockk() + eventBus = mockk() every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } every { orders.existsByCode(any()) } returns false every { orders.save(any()) } answers { firstArg() } - service = PurchaseOrderService(orders, partnersApi, catalogApi, inventoryApi, extValidator) + every { eventBus.publish(any()) } just runs + service = PurchaseOrderService(orders, partnersApi, catalogApi, inventoryApi, extValidator, eventBus) } private fun stubSupplier(code: String, type: String = "SUPPLIER") { @@ -310,4 +319,76 @@ class PurchaseOrderServiceTest { assertThat(cancelled.status).isEqualTo(PurchaseOrderStatus.CANCELLED) } + + // ─── event bus integration ─────────────────────────────────────── + + @Test + fun `confirm publishes PurchaseOrderConfirmedEvent with header fields`() { + val id = UUID.randomUUID() + val order = PurchaseOrder( + code = "PO-42", + partnerCode = "SUP-1", + status = PurchaseOrderStatus.DRAFT, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + totalAmount = BigDecimal("550.00"), + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(order) + + service.confirm(id) + + verify(exactly = 1) { + eventBus.publish( + match { + it.orderCode == "PO-42" && + it.partnerCode == "SUP-1" && + it.currencyCode == "USD" && + it.totalAmount == BigDecimal("550.00") + }, + ) + } + } + + @Test + fun `receive publishes PurchaseOrderReceivedEvent with location`() { + val id = UUID.randomUUID() + val po = confirmedPo(id, lines = listOf("PAPER-A4" to "5000")) + every { orders.findById(id) } returns Optional.of(po) + stubInventoryCredit("PAPER-A4", "WH-MAIN", BigDecimal("5000")) + + service.receive(id, "WH-MAIN") + + verify(exactly = 1) { + eventBus.publish( + match { + it.orderCode == "PO-1" && + it.partnerCode == "SUP-1" && + it.receivingLocationCode == "WH-MAIN" + }, + ) + } + } + + @Test + fun `cancel publishes PurchaseOrderCancelledEvent`() { + val id = UUID.randomUUID() + val po = PurchaseOrder( + code = "PO-7", + partnerCode = "SUP-1", + status = PurchaseOrderStatus.CONFIRMED, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(po) + + service.cancel(id) + + verify(exactly = 1) { + eventBus.publish( + match { + it.orderCode == "PO-7" && it.partnerCode == "SUP-1" + }, + ) + } + } } diff --git a/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt index 73ad3d6..0a55ba4 100644 --- a/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt +++ b/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 import com.fasterxml.jackson.module.kotlin.registerKotlinModule import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent import org.vibeerp.api.v1.ext.catalog.CatalogApi import org.vibeerp.api.v1.ext.inventory.InventoryApi import org.vibeerp.api.v1.ext.partners.PartnersApi @@ -71,6 +75,7 @@ class SalesOrderService( private val catalogApi: CatalogApi, private val inventoryApi: InventoryApi, private val extValidator: ExtJsonValidator, + private val eventBus: EventBus, ) { private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() @@ -232,6 +237,19 @@ class SalesOrderService( "cannot confirm sales order ${order.code} in status ${order.status}; only DRAFT can be confirmed" } order.status = SalesOrderStatus.CONFIRMED + + // Publish the event INSIDE the same transaction as the + // status change. EventBus is wired with Propagation.MANDATORY, + // so this would throw if it ran outside one — which never + // happens here because the class is @Transactional. + eventBus.publish( + SalesOrderConfirmedEvent( + orderCode = order.code, + partnerCode = order.partnerCode, + currencyCode = order.currencyCode, + totalAmount = order.totalAmount, + ), + ) return order } @@ -250,6 +268,13 @@ class SalesOrderService( "issue a return / refund flow instead" } order.status = SalesOrderStatus.CANCELLED + + eventBus.publish( + SalesOrderCancelledEvent( + orderCode = order.code, + partnerCode = order.partnerCode, + ), + ) return order } @@ -306,6 +331,19 @@ class SalesOrderService( } order.status = SalesOrderStatus.SHIPPED + + // Publish AFTER all the inventory writes succeed but BEFORE + // returning. The publish is part of the same transaction as + // both the status change AND every recordMovement call above, + // so a subscriber that reads the ledger on receipt is + // guaranteed to see all the matching SALES_SHIPMENT rows. + eventBus.publish( + SalesOrderShippedEvent( + orderCode = order.code, + partnerCode = order.partnerCode, + shippingLocationCode = shippingLocationCode, + ), + ) return order } diff --git a/pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt b/pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt index 44f74a2..a32f523 100644 --- a/pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt +++ b/pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt @@ -10,11 +10,17 @@ import assertk.assertions.isInstanceOf import assertk.assertions.messageContains import io.mockk.every import io.mockk.mockk +import io.mockk.runs +import io.mockk.just import io.mockk.slot import io.mockk.verify import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent +import org.vibeerp.api.v1.event.orders.SalesOrderShippedEvent import org.vibeerp.api.v1.ext.catalog.CatalogApi import org.vibeerp.api.v1.ext.catalog.ItemRef import org.vibeerp.api.v1.ext.inventory.InventoryApi @@ -37,6 +43,7 @@ class SalesOrderServiceTest { private lateinit var catalogApi: CatalogApi private lateinit var inventoryApi: InventoryApi private lateinit var extValidator: ExtJsonValidator + private lateinit var eventBus: EventBus private lateinit var service: SalesOrderService @BeforeEach @@ -46,10 +53,13 @@ class SalesOrderServiceTest { catalogApi = mockk() inventoryApi = mockk() extValidator = mockk() + eventBus = mockk() every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } every { orders.existsByCode(any()) } returns false every { orders.save(any()) } answers { firstArg() } - service = SalesOrderService(orders, partnersApi, catalogApi, inventoryApi, extValidator) + // The bus is fire-and-forget from the service's perspective. + every { eventBus.publish(any()) } just runs + service = SalesOrderService(orders, partnersApi, catalogApi, inventoryApi, extValidator, eventBus) } private fun stubCustomer(code: String, type: String = "CUSTOMER") { @@ -397,6 +407,78 @@ class SalesOrderServiceTest { } } + // ─── event bus integration ─────────────────────────────────────── + + @Test + fun `confirm publishes SalesOrderConfirmedEvent with header fields`() { + val id = UUID.randomUUID() + val order = SalesOrder( + code = "SO-42", + partnerCode = "CUST-1", + status = SalesOrderStatus.DRAFT, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + totalAmount = BigDecimal("123.45"), + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(order) + + service.confirm(id) + + verify(exactly = 1) { + eventBus.publish( + match { + it.orderCode == "SO-42" && + it.partnerCode == "CUST-1" && + it.currencyCode == "USD" && + it.totalAmount == BigDecimal("123.45") + }, + ) + } + } + + @Test + fun `ship publishes SalesOrderShippedEvent with shipping location`() { + val id = UUID.randomUUID() + val order = confirmedOrder(id, lines = listOf("PAPER-A4" to "10")) + every { orders.findById(id) } returns Optional.of(order) + stubInventoryDebit("PAPER-A4", "WH-MAIN", BigDecimal("-10")) + + service.ship(id, "WH-MAIN") + + verify(exactly = 1) { + eventBus.publish( + match { + it.orderCode == "SO-1" && + it.partnerCode == "CUST-1" && + it.shippingLocationCode == "WH-MAIN" + }, + ) + } + } + + @Test + fun `cancel publishes SalesOrderCancelledEvent`() { + val id = UUID.randomUUID() + val order = SalesOrder( + code = "SO-7", + partnerCode = "CUST-1", + status = SalesOrderStatus.CONFIRMED, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(order) + + service.cancel(id) + + verify(exactly = 1) { + eventBus.publish( + match { + it.orderCode == "SO-7" && it.partnerCode == "CUST-1" + }, + ) + } + } + @Test fun `cancel rejects a SHIPPED order`() { val id = UUID.randomUUID() -- libgit2 0.22.2