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