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") + } }