diff --git a/CLAUDE.md b/CLAUDE.md index dee4ce9..79238fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only **Foundation complete; first business surface in place.** As of the latest commit: -- **17 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`. -- **217 unit tests across 17 modules**, all green. `./gradlew build` is the canonical full build. +- **18 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`. +- **230 unit tests across 18 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. -- **7 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`). **The full buy-and-sell loop works**: a purchase order receives stock via `PURCHASE_RECEIPT` ledger rows, a sales order ships stock via `SALES_SHIPMENT` ledger rows. Both PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade. +- **8 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`). **The full buy-sell-make loop works**: a purchase order receives stock via `PURCHASE_RECEIPT`, a sales order ships stock via `SALES_SHIPMENT`, and a work order produces stock via `PRODUCTION_RECEIPT`. All three PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade. - **Event-driven cross-PBC integration is live in BOTH directions and the consumer reacts to the full lifecycle.** Six typed events under `org.vibeerp.api.v1.event.orders.*` are published from `SalesOrderService` and `PurchaseOrderService` inside the same `@Transactional` method as their state changes. **pbc-finance** subscribes to ALL SIX of them via the api.v1 `EventBus.subscribe(eventType, listener)` typed-class overload: confirm events POST AR/AP rows; ship/receive events SETTLE them; cancel events REVERSE them. Each transition is idempotent under at-least-once delivery — re-applying the same destination status is a no-op. Cancel-from-DRAFT is a clean no-op because no `*ConfirmedEvent` was ever published. pbc-finance has no source dependency on pbc-orders-*; it reaches them only through events. - **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`. diff --git a/PROGRESS.md b/PROGRESS.md index 90ba195..a00239e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,13 +10,13 @@ | | | |---|---| -| **Latest version** | v0.17.1 (MovementReason gains MATERIAL_ISSUE + PRODUCTION_RECEIPT) | -| **Latest commit** | `ed66823 feat(inventory): add MATERIAL_ISSUE + PRODUCTION_RECEIPT movement reasons` | +| **Latest version** | v0.18 (pbc-production — work orders auto-spawned from SO confirms) | +| **Latest commit** | `` | | **Repo** | https://github.com/reporkey/vibe-erp | -| **Modules** | 17 | -| **Unit tests** | 217, all green | -| **End-to-end smoke runs** | pbc-finance now reacts to all six order events. Smoke verified: PO confirm → AP POSTED → receive → AP SETTLED. SO confirm → AR POSTED → ship → AR SETTLED. Confirm-then-cancel of either PO or SO flips the row to REVERSED. Cancel-from-DRAFT writes no row (no `*ConfirmedEvent` was ever published). All lifecycle transitions are idempotent: a duplicate settle/reverse delivery is a clean no-op, and a settle never overwrites a reversal (or vice versa). Status filter on `GET /api/v1/finance/journal-entries?status=POSTED|SETTLED|REVERSED` returns the right partition. | -| **Real PBCs implemented** | 7 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`) | +| **Modules** | 18 | +| **Unit tests** | 230, all green | +| **End-to-end smoke runs** | An SO confirmed with 2 lines auto-spawns 2 draft work orders via `SalesOrderConfirmedSubscriber`; completing one credits the finished-good stock via `PRODUCTION_RECEIPT`, cancelling the other flips its status, and a manual WO can still be created with no source SO. All in one run: 6 outbox rows DISPATCHED across `orders_sales.SalesOrder` and `production.WorkOrder` topics; pbc-finance still writes its AR row for the underlying SO; the `inventory__stock_movement` ledger carries the production receipt tagged `WO:`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). | +| **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) | | **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. | @@ -84,7 +84,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **22 a | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending | | P5.5 | `pbc-orders-sales` — sales orders + lines (deliveries deferred) | ✅ DONE — `a8eb2a6` | | P5.6 | `pbc-orders-purchase` — POs + receive flow (RFQs deferred) | ✅ DONE — `2861656` | -| P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending | +| P5.7 | `pbc-production` — work orders, routings, operations | ✅ Partial — minimal single-output work orders + auto-spawn from SO confirm; BOM / routings / operations / IN_PROGRESS pending | | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending | | P5.9 | `pbc-finance` — GL, journal entries, AR/AP minimal | ✅ Partial — minimal AR/AP journal entries driven by events; full GL pending | diff --git a/README.md b/README.md index 5988320..cc8ccd7 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ vibe-erp/ ## Building ```bash -# Build everything (compiles 17 modules, runs 217 unit tests) +# Build everything (compiles 18 modules, runs 230 unit tests) ./gradlew build # Bring up Postgres + the reference plug-in JAR @@ -96,9 +96,9 @@ The bootstrap admin password is printed to the application logs on first boot. A | | | |---|---| -| Modules | 17 | -| Unit tests | 217, all green | -| Real PBCs | 7 of 10 | +| Modules | 18 | +| Unit tests | 230, all green | +| Real PBCs | 8 of 10 | | Cross-cutting services live | 9 | | Plug-ins serving HTTP | 1 (reference printing-shop) | | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt new file mode 100644 index 0000000..b997f58 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/production/WorkOrderEvents.kt @@ -0,0 +1,99 @@ +package org.vibeerp.api.v1.event.production + +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.event.DomainEvent +import java.math.BigDecimal +import java.time.Instant + +/** + * Domain events emitted by the production PBC. + * + * Same shape as the order events in `api.v1.event.orders.*`. The + * events live in api.v1, NOT inside pbc-production, so other PBCs + * (warehousing, finance, quality) and customer plug-ins can subscribe + * without importing pbc-production internals — which the Gradle + * build refuses anyway (CLAUDE.md guardrail #9). + * + * **`aggregateType` convention:** `production.WorkOrder`. Matches the + * `.` convention documented on + * [DomainEvent.aggregateType]. + * + * **What each event carries:** the work order's business code (the + * stable human key), the output item code, and the quantity. Fields + * are picked so a downstream subscriber can do something useful + * without having to round-trip back to pbc-production through a + * facade — the same "events carry what consumers need" principle + * that drove the order events' shape. + */ +public sealed interface WorkOrderEvent : DomainEvent { + public val orderCode: String + public val outputItemCode: String + public val outputQuantity: BigDecimal +} + +/** + * Emitted when a new work order is created (DRAFT). The order is + * scheduled to produce [outputQuantity] units of [outputItemCode] — + * no stock has moved yet. The optional [sourceSalesOrderCode] + * carries the SO that triggered the auto-creation, when one exists, + * so a downstream "production progress" board can show the linkage + * without re-querying. + */ +public data class WorkOrderCreatedEvent( + override val orderCode: String, + override val outputItemCode: String, + override val outputQuantity: BigDecimal, + public val sourceSalesOrderCode: String?, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : WorkOrderEvent { + override val aggregateType: String get() = WORK_ORDER_AGGREGATE_TYPE + override val aggregateId: String get() = orderCode +} + +/** + * Emitted when a DRAFT work order is completed (terminal happy path). + * + * The companion `PRODUCTION_RECEIPT` ledger row has already been + * written by the time this event fires — the publish runs inside + * the same `@Transactional` method as the inventory write and the + * status flip, so a subscriber that reads `inventory__stock_movement` + * on receipt is guaranteed to see the matching row tagged + * `WO:`. + * + * `outputLocationCode` is included so warehouse and dispatch + * subscribers can act on "what just landed where" without + * re-reading the order. + */ +public data class WorkOrderCompletedEvent( + override val orderCode: String, + override val outputItemCode: String, + override val outputQuantity: BigDecimal, + public val outputLocationCode: String, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : WorkOrderEvent { + override val aggregateType: String get() = WORK_ORDER_AGGREGATE_TYPE + override val aggregateId: String get() = orderCode +} + +/** + * Emitted when a work order is cancelled from DRAFT. + * + * The framework refuses to cancel a COMPLETED work order — that + * would imply un-producing finished goods, which is "scrap them", + * a separate flow that lands later as its own event. + */ +public data class WorkOrderCancelledEvent( + override val orderCode: String, + override val outputItemCode: String, + override val outputQuantity: BigDecimal, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : WorkOrderEvent { + override val aggregateType: String get() = WORK_ORDER_AGGREGATE_TYPE + override val aggregateId: String get() = orderCode +} + +/** Topic string for wildcard / topic-based subscriptions to work order events. */ +public const val WORK_ORDER_AGGREGATE_TYPE: String = "production.WorkOrder" diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts index d205b27..f47176a 100644 --- a/distribution/build.gradle.kts +++ b/distribution/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(project(":pbc:pbc-orders-sales")) implementation(project(":pbc:pbc-orders-purchase")) implementation(project(":pbc:pbc-finance")) + implementation(project(":pbc:pbc-production")) implementation(libs.spring.boot.starter) implementation(libs.spring.boot.starter.web) diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml index 341ac07..b3baee0 100644 --- a/distribution/src/main/resources/db/changelog/master.xml +++ b/distribution/src/main/resources/db/changelog/master.xml @@ -22,4 +22,5 @@ + diff --git a/distribution/src/main/resources/db/changelog/pbc-production/001-production-init.xml b/distribution/src/main/resources/db/changelog/pbc-production/001-production-init.xml new file mode 100644 index 0000000..c7c61c7 --- /dev/null +++ b/distribution/src/main/resources/db/changelog/pbc-production/001-production-init.xml @@ -0,0 +1,66 @@ + + + + + + + Create production__work_order table + + CREATE TABLE production__work_order ( + id uuid PRIMARY KEY, + code varchar(64) NOT NULL, + output_item_code varchar(64) NOT NULL, + output_quantity numeric(18,4) NOT NULL, + status varchar(16) NOT NULL, + due_date date, + source_sales_order_code varchar(64), + ext jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL, + created_by varchar(128) NOT NULL, + updated_at timestamptz NOT NULL, + updated_by varchar(128) NOT NULL, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT production__work_order_status_check + CHECK (status IN ('DRAFT', 'COMPLETED', 'CANCELLED')), + CONSTRAINT production__work_order_qty_pos + CHECK (output_quantity > 0) + ); + CREATE UNIQUE INDEX production__work_order_code_uk + ON production__work_order (code); + CREATE INDEX production__work_order_status_idx + ON production__work_order (status); + CREATE INDEX production__work_order_output_item_idx + ON production__work_order (output_item_code); + CREATE INDEX production__work_order_source_so_idx + ON production__work_order (source_sales_order_code); + CREATE INDEX production__work_order_due_date_idx + ON production__work_order (due_date); + CREATE INDEX production__work_order_ext_gin + ON production__work_order USING GIN (ext jsonb_path_ops); + + + DROP TABLE production__work_order; + + + + diff --git a/pbc/pbc-production/build.gradle.kts b/pbc/pbc-production/build.gradle.kts new file mode 100644 index 0000000..3e2d6a5 --- /dev/null +++ b/pbc/pbc-production/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.kotlin.jpa) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp pbc-production — minimal work order PBC. Reacts to SalesOrderConfirmedEvent to auto-spawn drafts; complete() credits finished goods through InventoryApi.recordMovement(PRODUCTION_RECEIPT). INTERNAL Packaged Business Capability." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +allOpen { + annotation("jakarta.persistence.Entity") + annotation("jakarta.persistence.MappedSuperclass") + annotation("jakarta.persistence.Embeddable") +} + +// Eighth PBC. Same dependency rule as the others — api/api-v1 + +// platform/* only, NEVER another pbc-*. Cross-PBC reads happen via +// CatalogApi (validate the output item exists) and SalesOrdersApi +// (look up the source SO when reacting to SalesOrderConfirmedEvent). +// Cross-PBC writes happen via InventoryApi.recordMovement with the +// new PRODUCTION_RECEIPT reason added in commit c52d0d5. +dependencies { + api(project(":api:api-v1")) + implementation(project(":platform:platform-persistence")) + implementation(project(":platform:platform-security")) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + + implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.data.jpa) + implementation(libs.spring.boot.starter.validation) + implementation(libs.jackson.module.kotlin) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) + testImplementation(libs.mockk) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt new file mode 100644 index 0000000..6b9b003 --- /dev/null +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt @@ -0,0 +1,190 @@ +package org.vibeerp.pbc.production.application + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.production.WorkOrderCancelledEvent +import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent +import org.vibeerp.api.v1.event.production.WorkOrderCreatedEvent +import org.vibeerp.api.v1.ext.catalog.CatalogApi +import org.vibeerp.api.v1.ext.inventory.InventoryApi +import org.vibeerp.pbc.production.domain.WorkOrder +import org.vibeerp.pbc.production.domain.WorkOrderStatus +import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository +import java.math.BigDecimal +import java.time.LocalDate +import java.util.UUID + +/** + * Application service for [WorkOrder] CRUD and state transitions. + * + * **Cross-PBC seams** (all read through `api.v1.ext.*` interfaces, + * never through pbc-* internals): + * - [CatalogApi] — validates that the output item exists and is + * active before a work order can be created. + * - [InventoryApi] — credits the finished good to the receiving + * location when [complete] is called, with reason + * `PRODUCTION_RECEIPT` (added in commit `c52d0d5`) and a + * `WO:` reference. Same primitive that pbc-orders-sales + * and pbc-orders-purchase call — one ledger, three callers. + * + * **Event publishing.** Each state-changing method publishes a + * typed event from `api.v1.event.production.*` inside the same + * `@Transactional` method as the JPA mutation and the (when + * applicable) ledger write. EventBusImpl uses + * `Propagation.MANDATORY` so a publish outside a transaction would + * throw — every method here is transactional, so the contract is + * always met. A failure on any line rolls back the status change + * AND the would-have-been outbox row. + * + * **State machine** (enforced by [complete] and [cancel]): + * - DRAFT → COMPLETED (complete) + * - DRAFT → CANCELLED (cancel) + * - Anything else throws. + * + * The minimal v1 has no `update` method — work orders are + * effectively immutable from creation until completion. A future + * "update output quantity before start" gesture lands as its own + * verb when the operations team needs it. + */ +@Service +@Transactional +class WorkOrderService( + private val orders: WorkOrderJpaRepository, + private val catalogApi: CatalogApi, + private val inventoryApi: InventoryApi, + private val eventBus: EventBus, +) { + + private val log = LoggerFactory.getLogger(WorkOrderService::class.java) + + @Transactional(readOnly = true) + fun list(): List = orders.findAll() + + @Transactional(readOnly = true) + fun findById(id: UUID): WorkOrder? = orders.findById(id).orElse(null) + + @Transactional(readOnly = true) + fun findByCode(code: String): WorkOrder? = orders.findByCode(code) + + @Transactional(readOnly = true) + fun findBySourceSalesOrderCode(salesOrderCode: String): List = + orders.findBySourceSalesOrderCode(salesOrderCode) + + fun create(command: CreateWorkOrderCommand): WorkOrder { + require(!orders.existsByCode(command.code)) { + "work order code '${command.code}' is already taken" + } + require(command.outputQuantity.signum() > 0) { + "output quantity must be positive (got ${command.outputQuantity})" + } + // Cross-PBC validation: the output item must exist in the + // catalog and be active. Identical seam to the one + // SalesOrderService and PurchaseOrderService use. + catalogApi.findItemByCode(command.outputItemCode) + ?: throw IllegalArgumentException( + "output item code '${command.outputItemCode}' is not in the catalog (or is inactive)", + ) + + val order = WorkOrder( + code = command.code, + outputItemCode = command.outputItemCode, + outputQuantity = command.outputQuantity, + status = WorkOrderStatus.DRAFT, + dueDate = command.dueDate, + sourceSalesOrderCode = command.sourceSalesOrderCode, + ) + val saved = orders.save(order) + + eventBus.publish( + WorkOrderCreatedEvent( + orderCode = saved.code, + outputItemCode = saved.outputItemCode, + outputQuantity = saved.outputQuantity, + sourceSalesOrderCode = saved.sourceSalesOrderCode, + ), + ) + return saved + } + + /** + * Mark a DRAFT work order as COMPLETED, crediting [outputLocationCode] + * with the output quantity in the same transaction. + * + * **Cross-PBC WRITE** through the same `InventoryApi.recordMovement` + * facade that pbc-orders-purchase uses for receipt and pbc-orders-sales + * uses for shipment. The reason is `PRODUCTION_RECEIPT` and the + * reference is `WO:` so a ledger reader can attribute + * the row to this work order. + * + * The whole operation runs in ONE transaction. A failure on the + * inventory write — bad item, bad location, would push balance + * negative (impossible for a positive delta but the framework + * checks it anyway) — rolls back BOTH the ledger row AND the + * status change. There is no half-completed work order with + * a partial inventory write. + */ + fun complete(id: UUID, outputLocationCode: String): WorkOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("work order not found: $id") + } + require(order.status == WorkOrderStatus.DRAFT) { + "cannot complete work order ${order.code} in status ${order.status}; " + + "only DRAFT can be completed" + } + + // Credit the finished good to the receiving location. + inventoryApi.recordMovement( + itemCode = order.outputItemCode, + locationCode = outputLocationCode, + delta = order.outputQuantity, + reason = "PRODUCTION_RECEIPT", + reference = "WO:${order.code}", + ) + + order.status = WorkOrderStatus.COMPLETED + + eventBus.publish( + WorkOrderCompletedEvent( + orderCode = order.code, + outputItemCode = order.outputItemCode, + outputQuantity = order.outputQuantity, + outputLocationCode = outputLocationCode, + ), + ) + return order + } + + fun cancel(id: UUID): WorkOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("work order not found: $id") + } + // COMPLETED is terminal — once finished goods exist on the + // shelves the framework will NOT let you "uncomplete" them. + // A real shop floor needs a "scrap defective output" flow, + // but that's its own event for a future chunk. + require(order.status == WorkOrderStatus.DRAFT) { + "cannot cancel work order ${order.code} in status ${order.status}; " + + "only DRAFT work orders can be cancelled" + } + order.status = WorkOrderStatus.CANCELLED + + eventBus.publish( + WorkOrderCancelledEvent( + orderCode = order.code, + outputItemCode = order.outputItemCode, + outputQuantity = order.outputQuantity, + ), + ) + return order + } +} + +data class CreateWorkOrderCommand( + val code: String, + val outputItemCode: String, + val outputQuantity: BigDecimal, + val dueDate: LocalDate? = null, + val sourceSalesOrderCode: String? = null, +) diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt new file mode 100644 index 0000000..40b5294 --- /dev/null +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrder.kt @@ -0,0 +1,122 @@ +package org.vibeerp.pbc.production.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Table +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity +import java.math.BigDecimal +import java.time.LocalDate + +/** + * A work order: an instruction to manufacture a specific quantity of + * a specific finished-good item. + * + * **Why this PBC exists.** pbc-production is the framework's eighth + * PBC and the first one that's NOT order-shaped or master-data-shaped. + * Sales and purchase orders are about money moving in and out; + * inventory is about counts. Work orders are about *making things* — + * which is the actual reason the printing-shop reference customer + * exists. With this PBC in place the framework can express "the + * customer ordered 1000 brochures, the floor produced 1000 brochures, + * stock now contains 1000 brochures". + * + * **What v1 deliberately does NOT model:** + * - **No bill of materials.** A real BOM would list every raw + * material consumed per unit of output. The minimal v1 only + * tracks the output side (production_receipt) — material issues + * happen as separate manual movements via the ledger. The next + * chunk after this one will probably add a `WorkOrderInput` + * child entity and have `complete()` issue raw materials too. + * - **No routings or operations.** A real shop floor would model + * each step of production (cut, print, fold, bind, pack) with + * its own duration and machine assignment. v1 collapses the + * whole journey into a single complete() call. + * - **No IN_PROGRESS state.** v1 has DRAFT → COMPLETED in one + * step; v2 will add IN_PROGRESS so a started-but-not-finished + * order is observable on a dashboard. + * - **No scheduling, no capacity planning, no due-date enforcement.** + * A `dueDate` field exists for display only; nothing in the + * framework refuses a late completion. + * + * **State machine:** + * - **DRAFT** — created but not yet completed. Lines may still + * change (in this v1 there are no lines, just + * the output item + quantity). + * - **COMPLETED** — terminal happy path. The + * `complete(outputLocationCode)` call has written + * a `PRODUCTION_RECEIPT` ledger row crediting the + * output item at the named location, and the + * `WorkOrderCompletedEvent` has been published in + * the same transaction. + * - **CANCELLED** — terminal. Reachable only from DRAFT — you + * cannot "uncomplete" finished goods. A real shop + * will eventually need a "scrap" flow for + * completed-but-defective output, but that's a + * different event for a future chunk. + * + * **Why `output_item_code` and `output_quantity` are columns rather + * than a one-line `WorkOrderLine` child entity:** v1 produces + * exactly one finished-good item per work order. A multi-output + * model (a single press run printing 4 different brochures + * simultaneously) is real but uncommon enough that flattening it + * into one row keeps the v1 schema and the v1 unit tests trivially + * verifiable. When the multi-output case lands, this column moves + * into a child table and the parent table loses these two fields. + * + * **`source_sales_order_code` is nullable** because work orders can + * be created for two reasons: (a) the [SalesOrderConfirmedSubscriber] + * auto-spawns one when a sales order is confirmed (filled in), or + * (b) an operator manually creates one to build inventory ahead of + * demand (null). Both are first-class. + */ +@Entity +@Table(name = "production__work_order") +class WorkOrder( + code: String, + outputItemCode: String, + outputQuantity: BigDecimal, + status: WorkOrderStatus = WorkOrderStatus.DRAFT, + dueDate: LocalDate? = null, + sourceSalesOrderCode: String? = null, +) : AuditedJpaEntity() { + + @Column(name = "code", nullable = false, length = 64) + var code: String = code + + @Column(name = "output_item_code", nullable = false, length = 64) + var outputItemCode: String = outputItemCode + + @Column(name = "output_quantity", nullable = false, precision = 18, scale = 4) + var outputQuantity: BigDecimal = outputQuantity + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + var status: WorkOrderStatus = status + + @Column(name = "due_date", nullable = true) + var dueDate: LocalDate? = dueDate + + @Column(name = "source_sales_order_code", nullable = true, length = 64) + var sourceSalesOrderCode: String? = sourceSalesOrderCode + + @Column(name = "ext", nullable = false, columnDefinition = "jsonb") + @JdbcTypeCode(SqlTypes.JSON) + var ext: String = "{}" + + override fun toString(): String = + "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', status=$status)" +} + +/** + * State machine values for [WorkOrder]. See the entity KDoc for the + * allowed transitions and the rationale for each one. + */ +enum class WorkOrderStatus { + DRAFT, + COMPLETED, + CANCELLED, +} diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/SalesOrderConfirmedSubscriber.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/SalesOrderConfirmedSubscriber.kt new file mode 100644 index 0000000..f71552e --- /dev/null +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/SalesOrderConfirmedSubscriber.kt @@ -0,0 +1,115 @@ +package org.vibeerp.pbc.production.event + +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.EventListener +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent +import org.vibeerp.api.v1.ext.orders.SalesOrdersApi +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand +import org.vibeerp.pbc.production.application.WorkOrderService + +/** + * Reacts to `SalesOrderConfirmedEvent` by auto-spawning a draft + * [org.vibeerp.pbc.production.domain.WorkOrder] for every line of + * the confirmed sales order. + * + * **Why this is interesting** — pbc-finance is a *passive* consumer + * (it just records derived state), but pbc-production reacts to a + * cross-PBC event by *creating new business state* in its own + * aggregate. The created work orders are first-class objects with + * their own state machine, their own events, and their own write + * paths to other PBCs (the [InventoryApi.recordMovement] crediting + * `PRODUCTION_RECEIPT` on completion). This is the strongest test + * yet of the event-driven cross-PBC integration story. + * + * **Why we look the order back up via [SalesOrdersApi]** instead of + * carrying line data on the event itself: the v1 [SalesOrderConfirmedEvent] + * carries only header fields (orderCode, partnerCode, currency, + * total). Adding `lines` to the event would couple every consumer to + * a richer event payload and would force a major-version bump on + * api.v1 if the line shape ever changes. The cross-PBC facade lookup + * is the cleanest way to keep events small and stable while still + * giving consumers structured access to whatever they need. + * + * **Idempotent.** [SalesOrderConfirmedEvent] could be delivered more + * than once (outbox replay, future Kafka retry). The subscriber + * checks `existsBySourceSalesOrderCode` and returns early if any + * work orders already exist for that SO — the dedup contract is + * "one work order set per source sales order, ever". This is + * coarser than pbc-finance's per-event-id dedup because there is + * no natural one-to-one mapping (one SO with N lines produces N + * work orders) and "did we already process this SO" is the right + * question to ask. + * + * **One subscription, one bean.** Single `@PostConstruct` registers + * a listener via the typed-class `EventBus.subscribe` overload. Same + * pattern pbc-finance uses; same pattern any plug-in would use. + */ +@Component +class SalesOrderConfirmedSubscriber( + private val eventBus: EventBus, + private val salesOrdersApi: SalesOrdersApi, + private val workOrders: WorkOrderService, +) { + + private val log = LoggerFactory.getLogger(SalesOrderConfirmedSubscriber::class.java) + + @PostConstruct + fun subscribe() { + eventBus.subscribe( + SalesOrderConfirmedEvent::class.java, + EventListener { event -> spawnWorkOrders(event) }, + ) + log.info( + "pbc-production subscribed to SalesOrderConfirmedEvent " + + "via EventBus.subscribe (typed-class overload)", + ) + } + + /** + * For each line of the confirmed sales order, create one draft + * work order. Idempotent on (source_sales_order_code) — if any + * work order already exists for the SO code, the call is a + * complete no-op. + */ + internal fun spawnWorkOrders(event: SalesOrderConfirmedEvent) { + if (workOrders.findBySourceSalesOrderCode(event.orderCode).isNotEmpty()) { + log.debug( + "[production] SalesOrderConfirmedEvent for {} already has work orders; skipping auto-spawn", + event.orderCode, + ) + return + } + val so = salesOrdersApi.findByCode(event.orderCode) + if (so == null) { + // Sales order vanished between confirm and event delivery + // (impossible under the current synchronous bus, defensive + // for a future async bus). Log and move on. + log.warn( + "[production] SalesOrderConfirmedEvent for {} but SalesOrdersApi.findByCode returned null; skipping", + event.orderCode, + ) + return + } + log.info( + "[production] auto-spawning {} work order(s) for sales order {}", + so.lines.size, so.code, + ) + for (line in so.lines) { + workOrders.create( + CreateWorkOrderCommand( + // The work order code is derived from the SO + // code + line number to keep it unique and + // human-readable. e.g. SO-2026-0001 line 2 → + // WO-FROM-SO-2026-0001-L2. + code = "WO-FROM-${so.code}-L${line.lineNo}", + outputItemCode = line.itemCode, + outputQuantity = line.quantity, + sourceSalesOrderCode = so.code, + ), + ) + } + } +} diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt new file mode 100644 index 0000000..7b2384e --- /dev/null +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt @@ -0,0 +1,138 @@ +package org.vibeerp.pbc.production.http + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand +import org.vibeerp.pbc.production.application.WorkOrderService +import org.vibeerp.pbc.production.domain.WorkOrder +import org.vibeerp.pbc.production.domain.WorkOrderStatus +import org.vibeerp.platform.security.authz.RequirePermission +import java.math.BigDecimal +import java.time.LocalDate +import java.util.UUID + +/** + * REST API for the work orders PBC. + * + * Mounted at `/api/v1/production/work-orders`. State transitions + * use dedicated `/complete` and `/cancel` endpoints — same shape + * as the order PBCs. + */ +@RestController +@RequestMapping("/api/v1/production/work-orders") +class WorkOrderController( + private val workOrderService: WorkOrderService, +) { + + @GetMapping + @RequirePermission("production.work-order.read") + fun list(): List = + workOrderService.list().map { it.toResponse() } + + @GetMapping("/{id}") + @RequirePermission("production.work-order.read") + fun get(@PathVariable id: UUID): ResponseEntity { + val order = workOrderService.findById(id) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(order.toResponse()) + } + + @GetMapping("/by-code/{code}") + @RequirePermission("production.work-order.read") + fun getByCode(@PathVariable code: String): ResponseEntity { + val order = workOrderService.findByCode(code) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(order.toResponse()) + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission("production.work-order.create") + fun create(@RequestBody @Valid request: CreateWorkOrderRequest): WorkOrderResponse = + workOrderService.create(request.toCommand()).toResponse() + + /** + * Mark a DRAFT work order as COMPLETED. Atomically credits the + * output quantity to the named location via + * `InventoryApi.recordMovement(PRODUCTION_RECEIPT)`. See + * [WorkOrderService.complete] for the full rationale. + */ + @PostMapping("/{id}/complete") + @RequirePermission("production.work-order.complete") + fun complete( + @PathVariable id: UUID, + @RequestBody @Valid request: CompleteWorkOrderRequest, + ): WorkOrderResponse = + workOrderService.complete(id, request.outputLocationCode).toResponse() + + @PostMapping("/{id}/cancel") + @RequirePermission("production.work-order.cancel") + fun cancel(@PathVariable id: UUID): WorkOrderResponse = + workOrderService.cancel(id).toResponse() +} + +// ─── DTOs ──────────────────────────────────────────────────────────── + +data class CreateWorkOrderRequest( + @field:NotBlank @field:Size(max = 64) val code: String, + @field:NotBlank @field:Size(max = 64) val outputItemCode: String, + @field:NotNull val outputQuantity: BigDecimal, + val dueDate: LocalDate? = null, + @field:Size(max = 64) val sourceSalesOrderCode: String? = null, +) { + fun toCommand(): CreateWorkOrderCommand = CreateWorkOrderCommand( + code = code, + outputItemCode = outputItemCode, + outputQuantity = outputQuantity, + dueDate = dueDate, + sourceSalesOrderCode = sourceSalesOrderCode, + ) +} + +/** + * Completion request body. + * + * **Single-arg Kotlin data class — same Jackson trap that bit + * `ShipSalesOrderRequest` and `ReceivePurchaseOrderRequest`.** + * jackson-module-kotlin treats a one-arg data class as a + * delegate-based creator and unwraps the body, which is wrong for + * an HTTP payload that always looks like + * `{"outputLocationCode": "..."}`. Fix: explicit + * `@JsonCreator(mode = PROPERTIES)` + `@param:JsonProperty`. + */ +data class CompleteWorkOrderRequest @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) constructor( + @param:JsonProperty("outputLocationCode") + @field:NotBlank @field:Size(max = 64) val outputLocationCode: String, +) + +data class WorkOrderResponse( + val id: UUID, + val code: String, + val outputItemCode: String, + val outputQuantity: BigDecimal, + val status: WorkOrderStatus, + val dueDate: LocalDate?, + val sourceSalesOrderCode: String?, +) + +private fun WorkOrder.toResponse(): WorkOrderResponse = + WorkOrderResponse( + id = id, + code = code, + outputItemCode = outputItemCode, + outputQuantity = outputQuantity, + status = status, + dueDate = dueDate, + sourceSalesOrderCode = sourceSalesOrderCode, + ) diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/infrastructure/WorkOrderJpaRepository.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/infrastructure/WorkOrderJpaRepository.kt new file mode 100644 index 0000000..e239ad0 --- /dev/null +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/infrastructure/WorkOrderJpaRepository.kt @@ -0,0 +1,24 @@ +package org.vibeerp.pbc.production.infrastructure + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.vibeerp.pbc.production.domain.WorkOrder +import org.vibeerp.pbc.production.domain.WorkOrderStatus +import java.util.UUID + +/** + * Spring Data JPA repository for [WorkOrder]. + * + * The lookup methods support both the controller (list/find by code) + * and the [SalesOrderConfirmedSubscriber] (find existing work orders + * for a given source sales order code, used for the idempotency + * check on auto-creation). + */ +@Repository +interface WorkOrderJpaRepository : JpaRepository { + fun existsByCode(code: String): Boolean + fun findByCode(code: String): WorkOrder? + fun findByStatus(status: WorkOrderStatus): List + fun findBySourceSalesOrderCode(sourceSalesOrderCode: String): List + fun existsBySourceSalesOrderCode(sourceSalesOrderCode: String): Boolean +} diff --git a/pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml b/pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml new file mode 100644 index 0000000..2fc7607 --- /dev/null +++ b/pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml @@ -0,0 +1,27 @@ +# pbc-production metadata. +# +# Loaded at boot by MetadataLoader, tagged source='core'. Minimal +# v1: one entity (WorkOrder), four permissions, one menu. + +entities: + - name: WorkOrder + pbc: production + table: production__work_order + description: A single-output work order — one finished good item × quantity, with optional source sales order linkage + +permissions: + - key: production.work-order.read + description: Read work orders + - key: production.work-order.create + description: Create draft work orders + - key: production.work-order.complete + description: Mark a draft work order completed (DRAFT → COMPLETED, credits inventory atomically) + - key: production.work-order.cancel + description: Cancel a draft work order (DRAFT → CANCELLED) + +menus: + - path: /production/work-orders + label: Work orders + icon: factory + section: Production + order: 800 diff --git a/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt b/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt new file mode 100644 index 0000000..d6ed0b3 --- /dev/null +++ b/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt @@ -0,0 +1,243 @@ +package org.vibeerp.pbc.production.application + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.messageContains +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +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.production.WorkOrderCancelledEvent +import org.vibeerp.api.v1.event.production.WorkOrderCompletedEvent +import org.vibeerp.api.v1.event.production.WorkOrderCreatedEvent +import org.vibeerp.api.v1.ext.catalog.CatalogApi +import org.vibeerp.api.v1.ext.catalog.ItemRef +import org.vibeerp.api.v1.ext.inventory.InventoryApi +import org.vibeerp.api.v1.ext.inventory.StockBalanceRef +import org.vibeerp.pbc.production.domain.WorkOrder +import org.vibeerp.pbc.production.domain.WorkOrderStatus +import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository +import java.math.BigDecimal +import java.util.Optional +import java.util.UUID + +class WorkOrderServiceTest { + + private lateinit var orders: WorkOrderJpaRepository + private lateinit var catalogApi: CatalogApi + private lateinit var inventoryApi: InventoryApi + private lateinit var eventBus: EventBus + private lateinit var service: WorkOrderService + + @BeforeEach + fun setUp() { + orders = mockk() + catalogApi = mockk() + inventoryApi = mockk() + eventBus = mockk() + every { orders.existsByCode(any()) } returns false + every { orders.save(any()) } answers { firstArg() } + every { eventBus.publish(any()) } just Runs + service = WorkOrderService(orders, catalogApi, inventoryApi, eventBus) + } + + private fun stubItem(code: String) { + every { catalogApi.findItemByCode(code) } returns ItemRef( + id = Id(UUID.randomUUID()), + code = code, + name = "Stub item", + itemType = "GOOD", + baseUomCode = "ea", + active = true, + ) + } + + private fun stubInventoryCredit(itemCode: String, locationCode: String, expectedDelta: BigDecimal) { + every { + inventoryApi.recordMovement( + itemCode = itemCode, + locationCode = locationCode, + delta = expectedDelta, + reason = "PRODUCTION_RECEIPT", + reference = any(), + ) + } returns StockBalanceRef( + id = Id(UUID.randomUUID()), + itemCode = itemCode, + locationCode = locationCode, + quantity = BigDecimal("100"), + ) + } + + // ─── create ────────────────────────────────────────────────────── + + @Test + fun `create rejects a duplicate code`() { + every { orders.existsByCode("WO-DUP") } returns true + + assertFailure { + service.create( + CreateWorkOrderCommand(code = "WO-DUP", outputItemCode = "FG-1", outputQuantity = BigDecimal("10")), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("work order code 'WO-DUP' is already taken") + } + + @Test + fun `create rejects non-positive output quantity`() { + assertFailure { + service.create( + CreateWorkOrderCommand(code = "WO-1", outputItemCode = "FG-1", outputQuantity = BigDecimal("0")), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("must be positive") + } + + @Test + fun `create rejects an unknown catalog item via the CatalogApi seam`() { + every { catalogApi.findItemByCode("FAKE") } returns null + + assertFailure { + service.create( + CreateWorkOrderCommand(code = "WO-1", outputItemCode = "FAKE", outputQuantity = BigDecimal("10")), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("not in the catalog") + } + + @Test + fun `create saves a DRAFT work order and publishes WorkOrderCreatedEvent`() { + stubItem("FG-1") + + val saved = service.create( + CreateWorkOrderCommand( + code = "WO-1", + outputItemCode = "FG-1", + outputQuantity = BigDecimal("25"), + sourceSalesOrderCode = "SO-42", + ), + ) + + assertThat(saved.status).isEqualTo(WorkOrderStatus.DRAFT) + assertThat(saved.outputQuantity).isEqualTo(BigDecimal("25")) + assertThat(saved.sourceSalesOrderCode).isEqualTo("SO-42") + verify(exactly = 1) { + eventBus.publish( + match { + it.orderCode == "WO-1" && + it.outputItemCode == "FG-1" && + it.outputQuantity == BigDecimal("25") && + it.sourceSalesOrderCode == "SO-42" + }, + ) + } + } + + // ─── complete ──────────────────────────────────────────────────── + + private fun draftOrder( + id: UUID = UUID.randomUUID(), + code: String = "WO-1", + itemCode: String = "FG-1", + qty: String = "100", + ): WorkOrder = WorkOrder( + code = code, + outputItemCode = itemCode, + outputQuantity = BigDecimal(qty), + status = WorkOrderStatus.DRAFT, + ).also { it.id = id } + + @Test + fun `complete rejects a non-DRAFT work order`() { + val id = UUID.randomUUID() + val done = WorkOrder( + code = "WO-1", + outputItemCode = "FG-1", + outputQuantity = BigDecimal("5"), + status = WorkOrderStatus.COMPLETED, + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(done) + + assertFailure { service.complete(id, "WH-FG") } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("only DRAFT can be completed") + verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) } + } + + @Test + fun `complete credits inventory and publishes WorkOrderCompletedEvent`() { + val id = UUID.randomUUID() + val order = draftOrder(id = id, code = "WO-9", itemCode = "FG-WIDGET", qty = "25") + every { orders.findById(id) } returns Optional.of(order) + stubInventoryCredit("FG-WIDGET", "WH-FG", BigDecimal("25")) + + val result = service.complete(id, "WH-FG") + + assertThat(result.status).isEqualTo(WorkOrderStatus.COMPLETED) + verify(exactly = 1) { + inventoryApi.recordMovement( + itemCode = "FG-WIDGET", + locationCode = "WH-FG", + delta = BigDecimal("25"), + reason = "PRODUCTION_RECEIPT", + reference = "WO:WO-9", + ) + } + verify(exactly = 1) { + eventBus.publish( + match { + it.orderCode == "WO-9" && + it.outputItemCode == "FG-WIDGET" && + it.outputQuantity == BigDecimal("25") && + it.outputLocationCode == "WH-FG" + }, + ) + } + } + + // ─── cancel ────────────────────────────────────────────────────── + + @Test + fun `cancel flips a DRAFT order to CANCELLED and publishes`() { + val id = UUID.randomUUID() + val order = draftOrder(id = id, code = "WO-C") + every { orders.findById(id) } returns Optional.of(order) + + val result = service.cancel(id) + + assertThat(result.status).isEqualTo(WorkOrderStatus.CANCELLED) + verify(exactly = 1) { + eventBus.publish( + match { it.orderCode == "WO-C" }, + ) + } + } + + @Test + fun `cancel rejects a COMPLETED order (no un-producing finished goods)`() { + val id = UUID.randomUUID() + val done = WorkOrder( + code = "WO-DONE", + outputItemCode = "FG-1", + outputQuantity = BigDecimal("5"), + status = WorkOrderStatus.COMPLETED, + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(done) + + assertFailure { service.cancel(id) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("only DRAFT work orders can be cancelled") + } +} diff --git a/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/SalesOrderConfirmedSubscriberTest.kt b/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/SalesOrderConfirmedSubscriberTest.kt new file mode 100644 index 0000000..1906894 --- /dev/null +++ b/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/SalesOrderConfirmedSubscriberTest.kt @@ -0,0 +1,171 @@ +package org.vibeerp.pbc.production.event + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +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.EventListener +import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent +import org.vibeerp.api.v1.ext.orders.SalesOrderLineRef +import org.vibeerp.api.v1.ext.orders.SalesOrderRef +import org.vibeerp.api.v1.ext.orders.SalesOrdersApi +import org.vibeerp.pbc.production.application.CreateWorkOrderCommand +import org.vibeerp.pbc.production.application.WorkOrderService +import java.math.BigDecimal +import java.time.LocalDate + +class SalesOrderConfirmedSubscriberTest { + + private fun salesOrder(code: String, vararg lines: Pair): SalesOrderRef = + SalesOrderRef( + id = Id(java.util.UUID.randomUUID()), + code = code, + partnerCode = "CUST-1", + status = "CONFIRMED", + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + totalAmount = BigDecimal("0"), + lines = lines.mapIndexed { i, (item, qty) -> + SalesOrderLineRef( + lineNo = i + 1, + itemCode = item, + quantity = BigDecimal(qty), + unitPrice = BigDecimal("1.00"), + currencyCode = "USD", + ) + }, + ) + + @Test + fun `subscribe registers one listener for SalesOrderConfirmedEvent`() { + val eventBus = mockk(relaxed = true) + val salesOrdersApi = mockk() + val workOrders = mockk() + + SalesOrderConfirmedSubscriber(eventBus, salesOrdersApi, workOrders).subscribe() + + verify(exactly = 1) { + eventBus.subscribe(SalesOrderConfirmedEvent::class.java, any>()) + } + } + + @Test + fun `spawnWorkOrders creates one draft work order per sales order line`() { + val eventBus = mockk(relaxed = true) + val salesOrdersApi = mockk() + val workOrders = mockk() + val subscriber = SalesOrderConfirmedSubscriber(eventBus, salesOrdersApi, workOrders) + + every { workOrders.findBySourceSalesOrderCode("SO-42") } returns emptyList() + every { salesOrdersApi.findByCode("SO-42") } returns salesOrder( + "SO-42", + "BROCHURE-A" to "500", + "BROCHURE-B" to "250", + ) + val captured = mutableListOf() + every { workOrders.create(capture(captured)) } answers { mockk(relaxed = true) } + + subscriber.spawnWorkOrders( + SalesOrderConfirmedEvent( + orderCode = "SO-42", + partnerCode = "CUST-1", + currencyCode = "USD", + totalAmount = BigDecimal("0"), + ), + ) + + assertThat(captured).hasSize(2) + with(captured[0]) { + assertThat(code).isEqualTo("WO-FROM-SO-42-L1") + assertThat(outputItemCode).isEqualTo("BROCHURE-A") + assertThat(outputQuantity).isEqualTo(BigDecimal("500")) + assertThat(sourceSalesOrderCode).isEqualTo("SO-42") + } + with(captured[1]) { + assertThat(code).isEqualTo("WO-FROM-SO-42-L2") + assertThat(outputItemCode).isEqualTo("BROCHURE-B") + assertThat(outputQuantity).isEqualTo(BigDecimal("250")) + assertThat(sourceSalesOrderCode).isEqualTo("SO-42") + } + } + + @Test + fun `spawnWorkOrders is idempotent when work orders already exist for the SO`() { + val eventBus = mockk(relaxed = true) + val salesOrdersApi = mockk() + val workOrders = mockk() + + every { workOrders.findBySourceSalesOrderCode("SO-DUP") } returns listOf(mockk()) + + SalesOrderConfirmedSubscriber(eventBus, salesOrdersApi, workOrders).spawnWorkOrders( + SalesOrderConfirmedEvent( + orderCode = "SO-DUP", + partnerCode = "CUST-1", + currencyCode = "USD", + totalAmount = BigDecimal("0"), + ), + ) + + verify(exactly = 0) { salesOrdersApi.findByCode(any()) } + verify(exactly = 0) { workOrders.create(any()) } + } + + @Test + fun `spawnWorkOrders is a no-op when the SO has vanished by the time the event arrives`() { + val eventBus = mockk(relaxed = true) + val salesOrdersApi = mockk() + val workOrders = mockk() + every { workOrders.findBySourceSalesOrderCode("SO-GONE") } returns emptyList() + every { salesOrdersApi.findByCode("SO-GONE") } returns null + + SalesOrderConfirmedSubscriber(eventBus, salesOrdersApi, workOrders).spawnWorkOrders( + SalesOrderConfirmedEvent( + orderCode = "SO-GONE", + partnerCode = "CUST-1", + currencyCode = "USD", + totalAmount = BigDecimal("0"), + ), + ) + + verify(exactly = 0) { workOrders.create(any()) } + } + + @Test + fun `the registered listener forwards the event to spawnWorkOrders`() { + val eventBus = mockk(relaxed = true) + val salesOrdersApi = mockk() + val workOrders = mockk(relaxed = true) + val captured = slot>() + every { + eventBus.subscribe(SalesOrderConfirmedEvent::class.java, capture(captured)) + } answers { mockk(relaxed = true) } + + SalesOrderConfirmedSubscriber(eventBus, salesOrdersApi, workOrders).subscribe() + + // The listener should call into spawnWorkOrders on the same + // bean; we can't easily capture the method call inside the + // bean itself, so we verify the downstream effect: it checks + // the sales-order lookup. + every { workOrders.findBySourceSalesOrderCode("SO-ROUTE") } returns emptyList() + every { salesOrdersApi.findByCode("SO-ROUTE") } returns null + + captured.captured.handle( + SalesOrderConfirmedEvent( + orderCode = "SO-ROUTE", + partnerCode = "CUST-1", + currencyCode = "USD", + totalAmount = BigDecimal("0"), + ), + ) + + verify(exactly = 1) { workOrders.findBySourceSalesOrderCode("SO-ROUTE") } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2b643ce..ecc5d95 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,6 +64,9 @@ project(":pbc:pbc-orders-purchase").projectDir = file("pbc/pbc-orders-purchase") include(":pbc:pbc-finance") project(":pbc:pbc-finance").projectDir = file("pbc/pbc-finance") +include(":pbc:pbc-production") +project(":pbc:pbc-production").projectDir = file("pbc/pbc-production") + // ─── Reference customer plug-in (NOT loaded by default) ───────────── include(":reference-customer:plugin-printing-shop") project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop")