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 index 689a453..47fc626 100644 --- 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 @@ -80,6 +80,17 @@ public data class WorkOrderRequestedEvent( public val outputItemCode: String, public val outputQuantity: BigDecimal, public val sourceReference: String, + /** + * Optional routing — zero or more shop-floor operations to + * attach to the created work order (pbc-production v3). Empty + * list is the default and matches v2 behavior exactly (no + * sequential walk, no gate on complete). A non-empty list + * feeds WorkOrderOperation children on the created aggregate. + * + * Additive to the existing fields — defaulted so producers + * that don't yet speak routings compile without changes. + */ + public val operations: List = emptyList(), override val eventId: Id = Id.random(), override val occurredAt: Instant = Instant.now(), ) : DomainEvent { @@ -93,6 +104,48 @@ public data class WorkOrderRequestedEvent( "WorkOrderRequestedEvent.outputQuantity must be positive (got $outputQuantity)" } require(sourceReference.isNotBlank()) { "WorkOrderRequestedEvent.sourceReference must not be blank" } + val seenLineNos = HashSet(operations.size) + for (op in operations) { + require(seenLineNos.add(op.lineNo)) { + "WorkOrderRequestedEvent.operations has duplicate lineNo ${op.lineNo}" + } + } + } +} + +/** + * One routing step on a [WorkOrderRequestedEvent]. Mirrors the + * shape of pbc-production v3's internal `WorkOrderOperationCommand` + * but lives in api.v1 so producers — core PBCs AND customer + * plug-ins — can populate it without importing pbc-production. + * + * @property lineNo 1-based sequence number. Must be unique within + * the event's operations list; determines the sequential walk + * order (op N must complete before op N+1 can start). + * @property operationCode short label for the step (e.g. "CUT", + * "PRINT", "BIND"). Free-form; no enumeration. + * @property workCenter free-form label for the resource running + * this step (e.g. "PRESS-A"). Will eventually become a FK to a + * future `pbc-equipment` PBC; kept as a string for now to avoid + * pre-emptively coupling PBC schemas. + * @property standardMinutes planned runtime of this step per WORK + * ORDER (not per unit of output). A 100-brochure cut step + * supposed to take 30 minutes passes `BigDecimal("30")`. Must + * be non-negative; zero is legal for instantaneous steps. + */ +public data class RoutingOperationSpec( + public val lineNo: Int, + public val operationCode: String, + public val workCenter: String, + public val standardMinutes: BigDecimal, +) { + init { + require(lineNo > 0) { "RoutingOperationSpec.lineNo must be positive (got $lineNo)" } + require(operationCode.isNotBlank()) { "RoutingOperationSpec.operationCode must not be blank" } + require(workCenter.isNotBlank()) { "RoutingOperationSpec.workCenter must not be blank" } + require(standardMinutes.signum() >= 0) { + "RoutingOperationSpec.standardMinutes must be non-negative (got $standardMinutes)" + } } } diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriber.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriber.kt index 014f4af..0186396 100644 --- a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriber.kt +++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriber.kt @@ -7,6 +7,7 @@ import org.vibeerp.api.v1.event.EventBus import org.vibeerp.api.v1.event.EventListener import org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent import org.vibeerp.pbc.production.application.CreateWorkOrderCommand +import org.vibeerp.pbc.production.application.WorkOrderOperationCommand import org.vibeerp.pbc.production.application.WorkOrderService /** @@ -76,8 +77,9 @@ class WorkOrderRequestedSubscriber( return } log.info( - "[production] WorkOrderRequestedEvent creating work order '{}' for item '{}' x {} (source='{}')", - event.code, event.outputItemCode, event.outputQuantity, event.sourceReference, + "[production] WorkOrderRequestedEvent creating work order '{}' for item '{}' x {} " + + "with {} routing operation(s) (source='{}')", + event.code, event.outputItemCode, event.outputQuantity, event.operations.size, event.sourceReference, ) workOrders.create( CreateWorkOrderCommand( @@ -89,6 +91,20 @@ class WorkOrderRequestedSubscriber( // logged above and echoed into the audit trail via // the event itself, which the outbox persists. sourceSalesOrderCode = null, + // v3: pass through any routing operations the + // producer attached to the event. Empty list keeps + // the v2 behavior exactly (no sequential walk, no + // gate on complete). The conversion is 1:1 — + // RoutingOperationSpec is the api.v1 mirror of the + // internal WorkOrderOperationCommand. + operations = event.operations.map { + WorkOrderOperationCommand( + lineNo = it.lineNo, + operationCode = it.operationCode, + workCenter = it.workCenter, + standardMinutes = it.standardMinutes, + ) + }, ), ) } diff --git a/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriberTest.kt b/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriberTest.kt index 55a61df..32d8449 100644 --- a/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriberTest.kt +++ b/pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/event/WorkOrderRequestedSubscriberTest.kt @@ -11,6 +11,7 @@ import io.mockk.verify import org.junit.jupiter.api.Test import org.vibeerp.api.v1.event.EventBus import org.vibeerp.api.v1.event.EventListener +import org.vibeerp.api.v1.event.production.RoutingOperationSpec import org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent import org.vibeerp.pbc.production.application.CreateWorkOrderCommand import org.vibeerp.pbc.production.application.WorkOrderService @@ -59,6 +60,41 @@ class WorkOrderRequestedSubscriberTest { assertThat(captured.captured.outputQuantity).isEqualTo(BigDecimal("500")) assertThat(captured.captured.sourceSalesOrderCode).isEqualTo(null) assertThat(captured.captured.inputs.size).isEqualTo(0) + // v3 backwards compat: empty operations on the event → + // empty operations on the command. + assertThat(captured.captured.operations.size).isEqualTo(0) + } + + @Test + fun `handle passes event operations through as WorkOrderOperationCommand`() { + val eventBus = mockk(relaxed = true) + val workOrders = mockk() + val captured = slot() + every { workOrders.findByCode("WO-WITH-ROUTING") } returns null + every { workOrders.create(capture(captured)) } returns fakeWorkOrder("WO-WITH-ROUTING") + + val subscriber = WorkOrderRequestedSubscriber(eventBus, workOrders) + subscriber.handle( + WorkOrderRequestedEvent( + code = "WO-WITH-ROUTING", + outputItemCode = "BOOK", + outputQuantity = BigDecimal("10"), + sourceReference = "test", + operations = listOf( + RoutingOperationSpec(1, "CUT", "WC-A", BigDecimal("5")), + RoutingOperationSpec(2, "PRINT", "WC-B", BigDecimal("15")), + ), + ), + ) + + val ops = captured.captured.operations + assertThat(ops.size).isEqualTo(2) + assertThat(ops[0].lineNo).isEqualTo(1) + assertThat(ops[0].operationCode).isEqualTo("CUT") + assertThat(ops[0].workCenter).isEqualTo("WC-A") + assertThat(ops[0].standardMinutes).isEqualTo(BigDecimal("5")) + assertThat(ops[1].lineNo).isEqualTo(2) + assertThat(ops[1].operationCode).isEqualTo("PRINT") } @Test diff --git a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/CreateWorkOrderFromQuoteTaskHandler.kt b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/CreateWorkOrderFromQuoteTaskHandler.kt index 2ac6f44..75bedee 100644 --- a/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/CreateWorkOrderFromQuoteTaskHandler.kt +++ b/reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/workflow/CreateWorkOrderFromQuoteTaskHandler.kt @@ -1,5 +1,6 @@ package org.vibeerp.reference.printingshop.workflow +import org.vibeerp.api.v1.event.production.RoutingOperationSpec import org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent import org.vibeerp.api.v1.plugin.PluginContext import org.vibeerp.api.v1.workflow.TaskContext @@ -75,9 +76,25 @@ class CreateWorkOrderFromQuoteTaskHandler( val workOrderCode = "WO-FROM-PRINTINGSHOP-$quoteCode" val sourceReference = "plugin:printing-shop:quote:$quoteCode" + // v3: attach a default printing-shop routing so the created + // work order already has a shop-floor sequence to walk. + // Four steps modeled on the reference business doc's + // brochure production flow — cut the stock, print on the + // press, fold the sheets, bind the spine. Each gets a + // plausible standard time and a distinct work center so + // the operator dashboard can show which station is busy. + // + // This is deliberately hard-coded in v1: a real printing + // shop with a dozen different flows would either (a) ship + // a richer plug-in that picks a routing per item type, or + // (b) wait for the future Tier 1 "routing template" + // metadata entity. v1 just proves the event-driven seam + // carries v3 operations end-to-end. + val routing = DEFAULT_PRINTING_SHOP_ROUTING + context.logger.info( "quote $quoteCode: publishing WorkOrderRequestedEvent " + - "(code=$workOrderCode, item=$itemCode, qty=$quantity)", + "(code=$workOrderCode, item=$itemCode, qty=$quantity, ops=${routing.size})", ) context.eventBus.publish( @@ -86,6 +103,7 @@ class CreateWorkOrderFromQuoteTaskHandler( outputItemCode = itemCode, outputQuantity = quantity, sourceReference = sourceReference, + operations = routing, ), ) @@ -95,5 +113,40 @@ class CreateWorkOrderFromQuoteTaskHandler( companion object { const val KEY: String = "printing_shop.quote.create_work_order" + + /** + * The default 4-step printing-shop routing attached to + * every WO spawned from a quote. Each step names its own + * work center so a future shop-floor dashboard can show + * "which station is running which job" without any extra + * coupling. Standard times are round-number placeholders; + * a real customer would tune them from historical data. + */ + internal val DEFAULT_PRINTING_SHOP_ROUTING: List = listOf( + RoutingOperationSpec( + lineNo = 1, + operationCode = "CUT", + workCenter = "PRINTING-CUT-01", + standardMinutes = BigDecimal("15"), + ), + RoutingOperationSpec( + lineNo = 2, + operationCode = "PRINT", + workCenter = "PRINTING-PRESS-A", + standardMinutes = BigDecimal("30"), + ), + RoutingOperationSpec( + lineNo = 3, + operationCode = "FOLD", + workCenter = "PRINTING-FOLD-01", + standardMinutes = BigDecimal("10"), + ), + RoutingOperationSpec( + lineNo = 4, + operationCode = "BIND", + workCenter = "PRINTING-BIND-01", + standardMinutes = BigDecimal("20"), + ), + ) } }