diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml
index 0de47bf..b68a129 100644
--- a/distribution/src/main/resources/db/changelog/master.xml
+++ b/distribution/src/main/resources/db/changelog/master.xml
@@ -25,6 +25,7 @@
+
diff --git a/distribution/src/main/resources/db/changelog/pbc-production/003-production-v3.xml b/distribution/src/main/resources/db/changelog/pbc-production/003-production-v3.xml
new file mode 100644
index 0000000..1cb615f
--- /dev/null
+++ b/distribution/src/main/resources/db/changelog/pbc-production/003-production-v3.xml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+ Create production__work_order_operation (routing step child table)
+
+ CREATE TABLE production__work_order_operation (
+ id uuid PRIMARY KEY,
+ work_order_id uuid NOT NULL
+ REFERENCES production__work_order(id) ON DELETE CASCADE,
+ line_no integer NOT NULL,
+ operation_code varchar(64) NOT NULL,
+ work_center varchar(64) NOT NULL,
+ standard_minutes numeric(10,2) NOT NULL,
+ status varchar(16) NOT NULL,
+ actual_minutes numeric(10,2),
+ started_at timestamptz,
+ completed_at timestamptz,
+ 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_operation_status_check
+ CHECK (status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED')),
+ CONSTRAINT production__work_order_operation_line_no_pos
+ CHECK (line_no > 0),
+ CONSTRAINT production__work_order_operation_std_min_pos
+ CHECK (standard_minutes >= 0),
+ CONSTRAINT production__work_order_operation_act_min_pos
+ CHECK (actual_minutes IS NULL OR actual_minutes >= 0),
+ CONSTRAINT production__work_order_operation_line_uk
+ UNIQUE (work_order_id, line_no)
+ );
+ CREATE INDEX production__work_order_operation_wo_idx
+ ON production__work_order_operation (work_order_id);
+ CREATE INDEX production__work_order_operation_status_idx
+ ON production__work_order_operation (status);
+ CREATE INDEX production__work_order_operation_work_center_idx
+ ON production__work_order_operation (work_center);
+
+
+ DROP TABLE production__work_order_operation;
+
+
+
+
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
index bae3d86..d0f042a 100644
--- 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
@@ -13,11 +13,14 @@ 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.WorkOrderInput
+import org.vibeerp.pbc.production.domain.WorkOrderOperation
+import org.vibeerp.pbc.production.domain.WorkOrderOperationStatus
import org.vibeerp.pbc.production.domain.WorkOrderStatus
import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
import java.math.BigDecimal
import java.time.LocalDate
+import java.time.OffsetDateTime
import java.util.UUID
/**
@@ -137,6 +140,29 @@ class WorkOrderService(
)
}
+ // v3: validate every routing operation up-front. Same
+ // discipline as the BOM lines — catch bad line_nos and
+ // empty labels now, not halfway through the shop-floor walk.
+ val seenOpLineNos = HashSet(command.operations.size)
+ for (op in command.operations) {
+ require(op.lineNo > 0) {
+ "work order operation line_no must be positive (got ${op.lineNo})"
+ }
+ require(seenOpLineNos.add(op.lineNo)) {
+ "work order operation line_no ${op.lineNo} is duplicated"
+ }
+ require(op.operationCode.isNotBlank()) {
+ "work order operation line ${op.lineNo} operation_code must not be blank"
+ }
+ require(op.workCenter.isNotBlank()) {
+ "work order operation line ${op.lineNo} work_center must not be blank"
+ }
+ require(op.standardMinutes.signum() >= 0) {
+ "work order operation line ${op.lineNo} standard_minutes must be non-negative " +
+ "(got ${op.standardMinutes})"
+ }
+ }
+
val order = WorkOrder(
code = command.code,
outputItemCode = command.outputItemCode,
@@ -162,6 +188,22 @@ class WorkOrderService(
),
)
}
+ // v3: attach routing operations — same cascade-from-parent
+ // pattern. All operations start in PENDING; startOperation
+ // + completeOperation drive them through IN_PROGRESS →
+ // COMPLETED in lineNo order.
+ for (op in command.operations) {
+ order.operations.add(
+ WorkOrderOperation(
+ workOrder = order,
+ lineNo = op.lineNo,
+ operationCode = op.operationCode,
+ workCenter = op.workCenter,
+ standardMinutes = op.standardMinutes,
+ status = WorkOrderOperationStatus.PENDING,
+ ),
+ )
+ }
val saved = orders.save(order)
eventBus.publish(
@@ -242,6 +284,23 @@ class WorkOrderService(
"only IN_PROGRESS can be completed"
}
+ // v3 routings gate: if the work order has any operations at
+ // all, EVERY one must be COMPLETED before the header can
+ // complete. An empty operations list is legal and preserves
+ // the v2 behavior exactly (auto-spawned orders from
+ // SalesOrderConfirmedSubscriber have no operations and
+ // complete as before). This is the only call site that
+ // needs the gate — startOperation and completeOperation
+ // enforce the per-op state machine themselves.
+ val pendingOps = order.operations.filter {
+ it.status != WorkOrderOperationStatus.COMPLETED
+ }
+ require(pendingOps.isEmpty()) {
+ "cannot complete work order ${order.code}: " +
+ "${pendingOps.size} routing operation(s) are not yet COMPLETED " +
+ "(${pendingOps.joinToString { "line ${it.lineNo}=${it.status}" }})"
+ }
+
// Issue every BOM input FIRST. Doing the materials before the
// output means a bad BOM (missing item, insufficient stock)
// fails the call before any PRODUCTION_RECEIPT is written —
@@ -342,6 +401,125 @@ class WorkOrderService(
return order
}
+ /**
+ * Start one routing operation on an IN_PROGRESS work order.
+ *
+ * **v3 sequential rule** (enforced here, not in SQL):
+ * - The parent work order must be IN_PROGRESS. A DRAFT work
+ * order hasn't started production; a COMPLETED or CANCELLED
+ * one is terminal.
+ * - The target operation must be in PENDING state.
+ * - Every operation with a smaller `lineNo` than the target
+ * must already be COMPLETED. This forces a strict linear
+ * walk of the routing; parallel operations are a future
+ * chunk when a real consumer asks for them.
+ *
+ * Stamps [WorkOrderOperation.startedAt] with the caller's
+ * wall clock (via [OffsetDateTime.now]); a later
+ * [completeOperation] call fills in `completedAt` + the
+ * operator-entered `actualMinutes`. No ledger row is written
+ * here — operations are about time and flow, not inventory.
+ *
+ * Idempotency: if the operation is already IN_PROGRESS and the
+ * parent work order is still IN_PROGRESS, the call is a no-op
+ * and returns the existing operation. Retrying the same
+ * `startOperation` on network failure is therefore safe. It
+ * does NOT grant "restart after complete"; a COMPLETED
+ * operation is terminal.
+ */
+ fun startOperation(workOrderId: UUID, operationId: UUID): WorkOrderOperation {
+ val order = orders.findById(workOrderId).orElseThrow {
+ NoSuchElementException("work order not found: $workOrderId")
+ }
+ require(order.status == WorkOrderStatus.IN_PROGRESS) {
+ "cannot start operation on work order ${order.code} in status ${order.status}; " +
+ "only IN_PROGRESS work orders can progress their operations"
+ }
+ val op = order.operations.firstOrNull { it.id == operationId }
+ ?: throw NoSuchElementException(
+ "operation $operationId not found on work order ${order.code}",
+ )
+ // Idempotent short-circuit for retries.
+ if (op.status == WorkOrderOperationStatus.IN_PROGRESS) {
+ return op
+ }
+ require(op.status == WorkOrderOperationStatus.PENDING) {
+ "cannot start operation line ${op.lineNo} of work order ${order.code} " +
+ "in status ${op.status}; only PENDING operations can be started"
+ }
+ // Sequential walk: every earlier operation must be done.
+ val unfinishedPredecessors = order.operations.filter {
+ it.lineNo < op.lineNo && it.status != WorkOrderOperationStatus.COMPLETED
+ }
+ require(unfinishedPredecessors.isEmpty()) {
+ "cannot start operation line ${op.lineNo} of work order ${order.code}: " +
+ "${unfinishedPredecessors.size} earlier operation(s) are not yet COMPLETED " +
+ "(${unfinishedPredecessors.joinToString { "line ${it.lineNo}=${it.status}" }})"
+ }
+
+ op.status = WorkOrderOperationStatus.IN_PROGRESS
+ op.startedAt = OffsetDateTime.now()
+ return op
+ }
+
+ /**
+ * Mark an IN_PROGRESS operation as COMPLETED. The parent work
+ * order must still be IN_PROGRESS (a completed or cancelled
+ * work order is terminal; v3 doesn't support re-opening one
+ * to finish a lagging operation — fix the data, don't hack
+ * the state machine).
+ *
+ * [actualMinutes] is the operator-entered time the step really
+ * took. Must be non-negative. v3 does NOT derive it from
+ * (completedAt - startedAt) because shift boundaries, pauses,
+ * and lunch breaks all make that subtraction unreliable; the
+ * operator typing in "yes this run took 47 minutes" is the
+ * single source of truth.
+ *
+ * Idempotency: if the operation is already COMPLETED with the
+ * same `actualMinutes`, the call is a no-op. If it's COMPLETED
+ * with a DIFFERENT value, the call refuses rather than
+ * silently clobbering the earlier recorded time. Use a future
+ * "correct operation" gesture when one is needed.
+ */
+ fun completeOperation(
+ workOrderId: UUID,
+ operationId: UUID,
+ actualMinutes: BigDecimal,
+ ): WorkOrderOperation {
+ require(actualMinutes.signum() >= 0) {
+ "actual_minutes must be non-negative (got $actualMinutes)"
+ }
+ val order = orders.findById(workOrderId).orElseThrow {
+ NoSuchElementException("work order not found: $workOrderId")
+ }
+ require(order.status == WorkOrderStatus.IN_PROGRESS) {
+ "cannot complete operation on work order ${order.code} in status ${order.status}; " +
+ "only IN_PROGRESS work orders can progress their operations"
+ }
+ val op = order.operations.firstOrNull { it.id == operationId }
+ ?: throw NoSuchElementException(
+ "operation $operationId not found on work order ${order.code}",
+ )
+ // Idempotent short-circuit for retries.
+ if (op.status == WorkOrderOperationStatus.COMPLETED) {
+ require(op.actualMinutes == actualMinutes) {
+ "operation line ${op.lineNo} of work order ${order.code} is already COMPLETED " +
+ "with actual_minutes=${op.actualMinutes}; refusing to overwrite with $actualMinutes"
+ }
+ return op
+ }
+ require(op.status == WorkOrderOperationStatus.IN_PROGRESS) {
+ "cannot complete operation line ${op.lineNo} of work order ${order.code} " +
+ "in status ${op.status}; only IN_PROGRESS operations can be completed"
+ }
+
+ op.status = WorkOrderOperationStatus.COMPLETED
+ op.actualMinutes = actualMinutes
+ op.completedAt = OffsetDateTime.now()
+ return op
+ }
+
fun cancel(id: UUID): WorkOrder {
val order = orders.findById(id).orElseThrow {
NoSuchElementException("work order not found: $id")
@@ -385,6 +563,14 @@ data class CreateWorkOrderCommand(
*/
val inputs: List = emptyList(),
/**
+ * v3 routing operations — zero or more shop-floor steps that
+ * must be walked between start() and complete(). Empty list is
+ * legal (produces the v2 behavior: complete() has no gate
+ * beyond the BOM materials). A non-empty list forces a strict
+ * sequential walk through the operations' state machines.
+ */
+ val operations: List = emptyList(),
+ /**
* Tier 1 custom-field values. Validated against declarations
* under entity name `WorkOrder` in `metadata__custom_field` via
* [ExtJsonValidator.applyTo]. Null is legal and produces an
@@ -403,3 +589,19 @@ data class WorkOrderInputCommand(
val quantityPerUnit: BigDecimal,
val sourceLocationCode: String,
)
+
+/**
+ * One routing operation on a work order create command.
+ *
+ * `standardMinutes` is the planned time per run of this step — NOT
+ * per unit of output. A 100-brochure cut step that's supposed to
+ * take 30 minutes passes `standardMinutes = 30`, not 0.3; the
+ * v3 model doesn't scale operation time with output quantity
+ * because shop-floor data rarely has that linearity anyway.
+ */
+data class WorkOrderOperationCommand(
+ val lineNo: Int,
+ val operationCode: String,
+ val workCenter: String,
+ val standardMinutes: BigDecimal,
+)
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
index 358dc35..8bf3290 100644
--- 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
@@ -151,8 +151,30 @@ class WorkOrder(
@OrderBy("lineNo ASC")
var inputs: MutableList = mutableListOf()
+ /**
+ * v3 routings — zero or more shop-floor operations that must be
+ * walked between `start()` and `complete()`. An empty list is a
+ * legal v3 state and preserves the v2 behavior exactly (no gate
+ * on complete). A non-empty list gates [complete] behind every
+ * operation reaching [WorkOrderOperationStatus.COMPLETED] and
+ * forces a strict sequential start (see [WorkOrderOperation]).
+ *
+ * Same fetch/cascade discipline as [inputs] — eager because
+ * reads almost always follow reads of the header, and the
+ * @OrderBy gives handlers a deterministic walk.
+ */
+ @OneToMany(
+ mappedBy = "workOrder",
+ cascade = [CascadeType.ALL],
+ orphanRemoval = true,
+ fetch = FetchType.EAGER,
+ )
+ @OrderBy("lineNo ASC")
+ var operations: MutableList = mutableListOf()
+
override fun toString(): String =
- "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', status=$status, inputs=${inputs.size})"
+ "WorkOrder(id=$id, code='$code', output=$outputQuantity '$outputItemCode', " +
+ "status=$status, inputs=${inputs.size}, operations=${operations.size})"
companion object {
/** Key under which WorkOrder's custom fields are declared in `metadata__custom_field`. */
diff --git a/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrderOperation.kt b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrderOperation.kt
new file mode 100644
index 0000000..82c71e0
--- /dev/null
+++ b/pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/domain/WorkOrderOperation.kt
@@ -0,0 +1,129 @@
+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.JoinColumn
+import jakarta.persistence.ManyToOne
+import jakarta.persistence.Table
+import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
+import java.math.BigDecimal
+import java.time.OffsetDateTime
+
+/**
+ * One step on a [WorkOrder]'s routing — a single unit of shop-floor
+ * work (cut, print, fold, bind, pack…) that must happen between the
+ * order being started and the order being completed.
+ *
+ * **Why v3 adds this.** v2 treated a work order as a single atomic
+ * "start → complete" event. Real shops want to know which step the
+ * job is ON right now, how long each step took, and which work
+ * center is running it. A [WorkOrderOperation] carries exactly that:
+ * a label ([operationCode]), where it runs ([workCenter]), how long
+ * it's supposed to take ([standardMinutes]), and how long it
+ * actually did ([actualMinutes], filled in on completion).
+ *
+ * **Per-operation state machine:**
+ * - **PENDING** — created with the work order, not yet started
+ * - **IN_PROGRESS** — currently running. At most ONE operation per
+ * work order is IN_PROGRESS at any time — v3
+ * enforces strict sequential execution.
+ * - **COMPLETED** — terminal. Predecessor for the next PENDING
+ * operation to start.
+ *
+ * **Sequential enforcement.** [WorkOrderService.startOperation]
+ * refuses to start op N if any op in [1..N-1] is not COMPLETED.
+ * This deliberately forbids parallel operations in v3 — a real
+ * shop with a multi-press routing that can run two steps in
+ * parallel is a future-chunk concern. Keeping the v3 invariant to
+ * "one step at a time in lineNo order" makes the on-call dashboard
+ * story trivial ("you are on step 3 of 5") and the test matrix
+ * finite.
+ *
+ * **Gate on the parent work order.** [WorkOrderService.complete]
+ * refuses if ANY operation is not COMPLETED. An empty operations
+ * list is legal and preserves the v2 behavior exactly (auto-spawned
+ * work orders from SalesOrderConfirmedSubscriber still ship with
+ * zero operations and complete as before).
+ *
+ * **Why [workCenter] is a free-form varchar, not a FK:** same
+ * cross-PBC rationale that governs [itemCode] and
+ * [sourceLocationCode] on [WorkOrderInput] — work centers are the
+ * seam for a future pbc-equipment PBC, and pinning a FK now would
+ * couple two PBCs' schemas before the consumer even exists
+ * (CLAUDE.md guardrail #9).
+ *
+ * **Why [standardMinutes] + [actualMinutes] instead of start/end
+ * timestamps alone:** the variance between "how long this step is
+ * supposed to take" and "how long it actually took" is the single
+ * most interesting data point on a routing, and a report that has
+ * to derive it from `completed_at - started_at` (with all the
+ * shift-boundary and pause/resume ambiguity that implies) is much
+ * less accurate than a value the operator enters on completion.
+ * v3 stores both; [startedAt] and [completedAt] are kept as an
+ * audit trail but not used for variance math.
+ *
+ * **No `ext` JSONB on operations** — same rationale as
+ * [WorkOrderInput]: operations are facts on a work order, not
+ * master records that the business would want to extend with
+ * Tier 1 custom fields. Custom fields on the parent work order
+ * still flow through via [WorkOrder.ext].
+ */
+@Entity
+@Table(name = "production__work_order_operation")
+class WorkOrderOperation(
+ workOrder: WorkOrder,
+ lineNo: Int,
+ operationCode: String,
+ workCenter: String,
+ standardMinutes: BigDecimal,
+ status: WorkOrderOperationStatus = WorkOrderOperationStatus.PENDING,
+ actualMinutes: BigDecimal? = null,
+ startedAt: OffsetDateTime? = null,
+ completedAt: OffsetDateTime? = null,
+) : AuditedJpaEntity() {
+
+ @ManyToOne
+ @JoinColumn(name = "work_order_id", nullable = false)
+ var workOrder: WorkOrder = workOrder
+
+ @Column(name = "line_no", nullable = false)
+ var lineNo: Int = lineNo
+
+ @Column(name = "operation_code", nullable = false, length = 64)
+ var operationCode: String = operationCode
+
+ @Column(name = "work_center", nullable = false, length = 64)
+ var workCenter: String = workCenter
+
+ @Column(name = "standard_minutes", nullable = false, precision = 10, scale = 2)
+ var standardMinutes: BigDecimal = standardMinutes
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 16)
+ var status: WorkOrderOperationStatus = status
+
+ @Column(name = "actual_minutes", nullable = true, precision = 10, scale = 2)
+ var actualMinutes: BigDecimal? = actualMinutes
+
+ @Column(name = "started_at", nullable = true)
+ var startedAt: OffsetDateTime? = startedAt
+
+ @Column(name = "completed_at", nullable = true)
+ var completedAt: OffsetDateTime? = completedAt
+
+ override fun toString(): String =
+ "WorkOrderOperation(id=$id, woId=${workOrder.id}, line=$lineNo, " +
+ "op='$operationCode', center='$workCenter', status=$status)"
+}
+
+/**
+ * State machine values for [WorkOrderOperation]. See the entity
+ * KDoc for the allowed transitions and the rationale for each one.
+ */
+enum class WorkOrderOperationStatus {
+ PENDING,
+ IN_PROGRESS,
+ COMPLETED,
+}
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
index cff7634..04b8ebc 100644
--- 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
@@ -17,13 +17,17 @@ 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.WorkOrderInputCommand
+import org.vibeerp.pbc.production.application.WorkOrderOperationCommand
import org.vibeerp.pbc.production.application.WorkOrderService
import org.vibeerp.pbc.production.domain.WorkOrder
import org.vibeerp.pbc.production.domain.WorkOrderInput
+import org.vibeerp.pbc.production.domain.WorkOrderOperation
+import org.vibeerp.pbc.production.domain.WorkOrderOperationStatus
import org.vibeerp.pbc.production.domain.WorkOrderStatus
import org.vibeerp.platform.security.authz.RequirePermission
import java.math.BigDecimal
import java.time.LocalDate
+import java.time.OffsetDateTime
import java.util.UUID
/**
@@ -113,6 +117,38 @@ class WorkOrderController(
quantity = request.quantity,
note = request.note,
).toResponse(workOrderService)
+
+ /**
+ * Start one routing operation — flip from PENDING to
+ * IN_PROGRESS. Parent work order must be IN_PROGRESS; earlier
+ * operations must all be COMPLETED. See
+ * [WorkOrderService.startOperation] for the sequential rule.
+ */
+ @PostMapping("/{id}/operations/{operationId}/start")
+ @RequirePermission("production.work-order.operation.start")
+ fun startOperation(
+ @PathVariable id: UUID,
+ @PathVariable operationId: UUID,
+ ): WorkOrderOperationResponse =
+ workOrderService.startOperation(id, operationId).toResponse()
+
+ /**
+ * Mark a routing operation as COMPLETED. Requires
+ * `actualMinutes` — the operator-entered real runtime of the
+ * step. See [WorkOrderService.completeOperation].
+ */
+ @PostMapping("/{id}/operations/{operationId}/complete")
+ @RequirePermission("production.work-order.operation.complete")
+ fun completeOperation(
+ @PathVariable id: UUID,
+ @PathVariable operationId: UUID,
+ @RequestBody @Valid request: CompleteWorkOrderOperationRequest,
+ ): WorkOrderOperationResponse =
+ workOrderService.completeOperation(
+ workOrderId = id,
+ operationId = operationId,
+ actualMinutes = request.actualMinutes,
+ ).toResponse()
}
// ─── DTOs ────────────────────────────────────────────────────────────
@@ -130,6 +166,12 @@ data class CreateWorkOrderRequest(
*/
@field:Valid val inputs: List = emptyList(),
/**
+ * v3 routing operations. Empty list is legal — the work order
+ * completes without a sequential walk. A non-empty list gates
+ * complete() behind every operation being COMPLETED.
+ */
+ @field:Valid val operations: List = emptyList(),
+ /**
* Tier 1 custom-field values. Validated against declarations
* under entity name `WorkOrder`.
*/
@@ -142,6 +184,7 @@ data class CreateWorkOrderRequest(
dueDate = dueDate,
sourceSalesOrderCode = sourceSalesOrderCode,
inputs = inputs.map { it.toCommand() },
+ operations = operations.map { it.toCommand() },
ext = ext,
)
}
@@ -160,6 +203,35 @@ data class WorkOrderInputRequest(
)
}
+data class WorkOrderOperationRequest(
+ @field:NotNull val lineNo: Int,
+ @field:NotBlank @field:Size(max = 64) val operationCode: String,
+ @field:NotBlank @field:Size(max = 64) val workCenter: String,
+ @field:NotNull val standardMinutes: BigDecimal,
+) {
+ fun toCommand(): WorkOrderOperationCommand = WorkOrderOperationCommand(
+ lineNo = lineNo,
+ operationCode = operationCode,
+ workCenter = workCenter,
+ standardMinutes = standardMinutes,
+ )
+}
+
+/**
+ * Complete-operation request body.
+ *
+ * **Single-arg Kotlin data class — same Jackson trap that bit
+ * [CompleteWorkOrderRequest] and the ship/receive order request
+ * bodies.** 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 `{"actualMinutes": "..."}`.
+ * Fix: explicit `@JsonCreator(mode = PROPERTIES)` + `@param:JsonProperty`.
+ */
+data class CompleteWorkOrderOperationRequest @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) constructor(
+ @param:JsonProperty("actualMinutes")
+ @field:NotNull val actualMinutes: BigDecimal,
+)
+
/**
* Completion request body.
*
@@ -196,6 +268,7 @@ data class WorkOrderResponse(
val dueDate: LocalDate?,
val sourceSalesOrderCode: String?,
val inputs: List,
+ val operations: List,
val ext: Map,
)
@@ -207,6 +280,18 @@ data class WorkOrderInputResponse(
val sourceLocationCode: String,
)
+data class WorkOrderOperationResponse(
+ val id: UUID,
+ val lineNo: Int,
+ val operationCode: String,
+ val workCenter: String,
+ val standardMinutes: BigDecimal,
+ val status: WorkOrderOperationStatus,
+ val actualMinutes: BigDecimal?,
+ val startedAt: OffsetDateTime?,
+ val completedAt: OffsetDateTime?,
+)
+
private fun WorkOrder.toResponse(service: WorkOrderService): WorkOrderResponse =
WorkOrderResponse(
id = id,
@@ -217,6 +302,7 @@ private fun WorkOrder.toResponse(service: WorkOrderService): WorkOrderResponse =
dueDate = dueDate,
sourceSalesOrderCode = sourceSalesOrderCode,
inputs = inputs.map { it.toResponse() },
+ operations = operations.map { it.toResponse() },
ext = service.parseExt(this),
)
@@ -228,3 +314,16 @@ private fun WorkOrderInput.toResponse(): WorkOrderInputResponse =
quantityPerUnit = quantityPerUnit,
sourceLocationCode = sourceLocationCode,
)
+
+private fun WorkOrderOperation.toResponse(): WorkOrderOperationResponse =
+ WorkOrderOperationResponse(
+ id = id,
+ lineNo = lineNo,
+ operationCode = operationCode,
+ workCenter = workCenter,
+ standardMinutes = standardMinutes,
+ status = status,
+ actualMinutes = actualMinutes,
+ startedAt = startedAt,
+ completedAt = completedAt,
+ )
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
index 5d71295..6e288da 100644
--- 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
@@ -22,6 +22,10 @@ permissions:
description: Cancel a work order (DRAFT or IN_PROGRESS → CANCELLED)
- key: production.work-order.scrap
description: Scrap some output from a COMPLETED work order (writes a negative ADJUSTMENT, status unchanged)
+ - key: production.work-order.operation.start
+ description: Start a routing operation (PENDING → IN_PROGRESS). Parent work order must be IN_PROGRESS and the operation must come next in sequence.
+ - key: production.work-order.operation.complete
+ description: Complete a routing operation (IN_PROGRESS → COMPLETED). Records the operator-entered actual_minutes.
customFields:
- key: production_priority
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
index 6fc7ce7..b580834 100644
--- 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
@@ -6,6 +6,7 @@ import assertk.assertions.hasMessage
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
+import assertk.assertions.isNotNull
import assertk.assertions.messageContains
import io.mockk.Runs
import io.mockk.every
@@ -29,6 +30,8 @@ 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.WorkOrderInput
+import org.vibeerp.pbc.production.domain.WorkOrderOperation
+import org.vibeerp.pbc.production.domain.WorkOrderOperationStatus
import org.vibeerp.pbc.production.domain.WorkOrderStatus
import org.vibeerp.pbc.production.infrastructure.WorkOrderJpaRepository
import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
@@ -555,4 +558,255 @@ class WorkOrderServiceTest {
.isInstanceOf(IllegalArgumentException::class)
.messageContains("only DRAFT or IN_PROGRESS")
}
+
+ // ─── v3 routings & operations ────────────────────────────────────
+
+ /**
+ * Seeds an IN_PROGRESS work order with [ops] attached, assigning
+ * a fresh UUID to each so tests can reference them by operationId.
+ * Returns the order.
+ */
+ private fun inProgressOrderWithOps(
+ id: UUID = UUID.randomUUID(),
+ code: String = "WO-OPS",
+ itemCode: String = "FG-1",
+ qty: String = "10",
+ ops: List = emptyList(),
+ ): WorkOrder {
+ val order = inProgressOrder(id = id, code = code, itemCode = itemCode, qty = qty)
+ ops.forEach {
+ it.id = UUID.randomUUID()
+ it.workOrder = order
+ order.operations.add(it)
+ }
+ return order
+ }
+
+ private fun op(lineNo: Int, status: WorkOrderOperationStatus = WorkOrderOperationStatus.PENDING) =
+ WorkOrderOperation(
+ workOrder = WorkOrder("tmp", "FG-1", BigDecimal("1"), WorkOrderStatus.DRAFT),
+ lineNo = lineNo,
+ operationCode = "OP-$lineNo",
+ workCenter = "WC-$lineNo",
+ standardMinutes = BigDecimal("10"),
+ status = status,
+ )
+
+ @Test
+ fun `create with operations attaches them all in PENDING`() {
+ stubItem("FG-1")
+
+ val saved = service.create(
+ CreateWorkOrderCommand(
+ code = "WO-RT",
+ outputItemCode = "FG-1",
+ outputQuantity = BigDecimal("10"),
+ operations = listOf(
+ WorkOrderOperationCommand(1, "CUT", "CUT-01", BigDecimal("15")),
+ WorkOrderOperationCommand(2, "PRINT", "PRESS-A", BigDecimal("30")),
+ WorkOrderOperationCommand(3, "BIND", "BIND-01", BigDecimal("20")),
+ ),
+ ),
+ )
+
+ assertThat(saved.operations).hasSize(3)
+ assertThat(saved.operations[0].operationCode).isEqualTo("CUT")
+ assertThat(saved.operations[0].status).isEqualTo(WorkOrderOperationStatus.PENDING)
+ assertThat(saved.operations[1].operationCode).isEqualTo("PRINT")
+ assertThat(saved.operations[2].operationCode).isEqualTo("BIND")
+ }
+
+ @Test
+ fun `create rejects duplicate operation line numbers`() {
+ stubItem("FG-1")
+
+ assertFailure {
+ service.create(
+ CreateWorkOrderCommand(
+ code = "WO-OPDUP",
+ outputItemCode = "FG-1",
+ outputQuantity = BigDecimal("10"),
+ operations = listOf(
+ WorkOrderOperationCommand(1, "CUT", "WC-1", BigDecimal("5")),
+ WorkOrderOperationCommand(1, "PRINT", "WC-2", BigDecimal("5")),
+ ),
+ ),
+ )
+ }
+ .isInstanceOf(IllegalArgumentException::class)
+ .messageContains("duplicated")
+ }
+
+ @Test
+ fun `create rejects blank operationCode`() {
+ stubItem("FG-1")
+
+ assertFailure {
+ service.create(
+ CreateWorkOrderCommand(
+ code = "WO-OPBLANK",
+ outputItemCode = "FG-1",
+ outputQuantity = BigDecimal("10"),
+ operations = listOf(
+ WorkOrderOperationCommand(1, "", "WC-1", BigDecimal("5")),
+ ),
+ ),
+ )
+ }
+ .isInstanceOf(IllegalArgumentException::class)
+ .messageContains("operation_code must not be blank")
+ }
+
+ @Test
+ fun `complete is gated when any operation is not COMPLETED`() {
+ val id = UUID.randomUUID()
+ val order = inProgressOrderWithOps(
+ id = id,
+ code = "WO-GATED",
+ ops = listOf(
+ op(1, WorkOrderOperationStatus.COMPLETED),
+ op(2, WorkOrderOperationStatus.IN_PROGRESS),
+ ),
+ )
+ every { orders.findById(id) } returns Optional.of(order)
+
+ assertFailure { service.complete(id, "WH-FG") }
+ .isInstanceOf(IllegalArgumentException::class)
+ .messageContains("routing operation")
+
+ // No ledger rows touched when the gate refuses.
+ verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `complete passes through when every operation is COMPLETED`() {
+ val id = UUID.randomUUID()
+ val order = inProgressOrderWithOps(
+ id = id,
+ code = "WO-DONE-OPS",
+ itemCode = "FG-1",
+ qty = "10",
+ ops = listOf(
+ op(1, WorkOrderOperationStatus.COMPLETED),
+ op(2, WorkOrderOperationStatus.COMPLETED),
+ ),
+ )
+ every { orders.findById(id) } returns Optional.of(order)
+ stubInventoryCredit("FG-1", "WH-FG", BigDecimal("10"))
+
+ val result = service.complete(id, "WH-FG")
+
+ assertThat(result.status).isEqualTo(WorkOrderStatus.COMPLETED)
+ }
+
+ @Test
+ fun `startOperation refuses on a DRAFT work order`() {
+ val id = UUID.randomUUID()
+ val order = draftOrder(id = id).also {
+ op(1).apply { this.id = UUID.randomUUID(); this.workOrder = it }.also { o -> it.operations.add(o) }
+ }
+ val opId = order.operations.first().id
+ every { orders.findById(id) } returns Optional.of(order)
+
+ assertFailure { service.startOperation(id, opId) }
+ .isInstanceOf(IllegalArgumentException::class)
+ .messageContains("only IN_PROGRESS work orders can progress their operations")
+ }
+
+ @Test
+ fun `startOperation flips PENDING to IN_PROGRESS and stamps startedAt`() {
+ val id = UUID.randomUUID()
+ val order = inProgressOrderWithOps(
+ id = id,
+ ops = listOf(op(1), op(2)),
+ )
+ val opId = order.operations.first { it.lineNo == 1 }.id
+ every { orders.findById(id) } returns Optional.of(order)
+
+ val started = service.startOperation(id, opId)
+
+ assertThat(started.status).isEqualTo(WorkOrderOperationStatus.IN_PROGRESS)
+ assertThat(started.startedAt).isNotNull()
+ }
+
+ @Test
+ fun `startOperation refuses skip-ahead over a PENDING predecessor`() {
+ val id = UUID.randomUUID()
+ val order = inProgressOrderWithOps(
+ id = id,
+ ops = listOf(op(1), op(2)),
+ )
+ val opId2 = order.operations.first { it.lineNo == 2 }.id
+ every { orders.findById(id) } returns Optional.of(order)
+
+ assertFailure { service.startOperation(id, opId2) }
+ .isInstanceOf(IllegalArgumentException::class)
+ .messageContains("earlier operation(s) are not yet COMPLETED")
+ }
+
+ @Test
+ fun `startOperation is idempotent when already IN_PROGRESS`() {
+ val id = UUID.randomUUID()
+ val order = inProgressOrderWithOps(
+ id = id,
+ ops = listOf(op(1, WorkOrderOperationStatus.IN_PROGRESS)),
+ )
+ val opId = order.operations.first().id
+ every { orders.findById(id) } returns Optional.of(order)
+
+ val result = service.startOperation(id, opId)
+
+ assertThat(result.status).isEqualTo(WorkOrderOperationStatus.IN_PROGRESS)
+ }
+
+ @Test
+ fun `completeOperation records actualMinutes and flips to COMPLETED`() {
+ val id = UUID.randomUUID()
+ val order = inProgressOrderWithOps(
+ id = id,
+ ops = listOf(op(1, WorkOrderOperationStatus.IN_PROGRESS)),
+ )
+ val opId = order.operations.first().id
+ every { orders.findById(id) } returns Optional.of(order)
+
+ val done = service.completeOperation(id, opId, BigDecimal("12.5"))
+
+ assertThat(done.status).isEqualTo(WorkOrderOperationStatus.COMPLETED)
+ assertThat(done.actualMinutes).isEqualTo(BigDecimal("12.5"))
+ assertThat(done.completedAt).isNotNull()
+ }
+
+ @Test
+ fun `completeOperation rejects negative actualMinutes`() {
+ val id = UUID.randomUUID()
+ val order = inProgressOrderWithOps(
+ id = id,
+ ops = listOf(op(1, WorkOrderOperationStatus.IN_PROGRESS)),
+ )
+ val opId = order.operations.first().id
+ every { orders.findById(id) } returns Optional.of(order)
+
+ assertFailure { service.completeOperation(id, opId, BigDecimal("-1")) }
+ .isInstanceOf(IllegalArgumentException::class)
+ .messageContains("non-negative")
+ }
+
+ @Test
+ fun `completeOperation refuses clobbering with a different actualMinutes`() {
+ val id = UUID.randomUUID()
+ val order = inProgressOrderWithOps(
+ id = id,
+ ops = listOf(
+ op(1, WorkOrderOperationStatus.COMPLETED).also {
+ it.actualMinutes = BigDecimal("10")
+ },
+ ),
+ )
+ val opId = order.operations.first().id
+ every { orders.findById(id) } returns Optional.of(order)
+
+ assertFailure { service.completeOperation(id, opId, BigDecimal("99")) }
+ .isInstanceOf(IllegalArgumentException::class)
+ .messageContains("refusing to overwrite")
+ }
}