From 1b0f6d8e4da4c60f63a8bfb6aef19d6794d33419 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 9 Apr 2026 17:17:49 +0800 Subject: [PATCH] feat(production): shop-floor dashboard snapshot endpoint --- pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml | 2 ++ pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+), 0 deletions(-) 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 d0f042a..a22a231 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 @@ -93,6 +93,70 @@ class WorkOrderService( orders.findBySourceSalesOrderCode(salesOrderCode) /** + * Build a shop-floor snapshot: every IN_PROGRESS work order + * paired with its current operation and planned/actual time + * totals. This is a pure read — no state changes, no events + * published — designed to feed a future shop-floor dashboard + * (web SPA, mobile app, or an external reporting tool via + * the REST API). + * + * **Current-operation rule.** For a WO with a non-empty routing + * the "current operation" is the first IN_PROGRESS op in + * `lineNo` order, or, if none is IN_PROGRESS, the first PENDING + * op. This handles the two live states a WO can be in on the + * shop floor: "operator is running step N right now" and + * "operator just finished step N and hasn't picked up step N+1 + * yet". A WO with no operations at all (the v2-compat path) + * returns `currentOperationLineNo = null` — the dashboard UI + * shows it as "no routing". + * + * **Time sums**: + * - `totalStandardMinutes` — sum of `standardMinutes` across + * every operation (the planned runtime of the whole WO) + * - `totalActualMinutes` — sum of `actualMinutes` across the + * COMPLETED operations only (null on pending/in-progress + * ops is treated as zero) + * - `operationsCompleted` / `operationsTotal` — for an + * at-a-glance "step 2 of 5" display + * + * Sorted deterministically by the work order's business code + * so a dashboard poll returns a stable order. + */ + @Transactional(readOnly = true) + fun shopFloorSnapshot(): List { + val inProgressOrders = orders.findByStatus(WorkOrderStatus.IN_PROGRESS) + .sortedBy { it.code } + + return inProgressOrders.map { order -> + val ops = order.operations + val current = ops.firstOrNull { it.status == WorkOrderOperationStatus.IN_PROGRESS } + ?: ops.firstOrNull { it.status == WorkOrderOperationStatus.PENDING } + + val totalStd = ops.fold(BigDecimal.ZERO) { acc, op -> acc + op.standardMinutes } + val totalAct = ops.fold(BigDecimal.ZERO) { acc, op -> + acc + (op.actualMinutes ?: BigDecimal.ZERO) + } + val completedCount = ops.count { it.status == WorkOrderOperationStatus.COMPLETED } + + ShopFloorEntry( + workOrderId = order.id, + workOrderCode = order.code, + outputItemCode = order.outputItemCode, + outputQuantity = order.outputQuantity, + sourceSalesOrderCode = order.sourceSalesOrderCode, + currentOperationLineNo = current?.lineNo, + currentOperationCode = current?.operationCode, + currentWorkCenter = current?.workCenter, + currentOperationStatus = current?.status, + operationsCompleted = completedCount, + operationsTotal = ops.size, + totalStandardMinutes = totalStd, + totalActualMinutes = totalAct, + ) + } + } + + /** * Convenience passthrough for response mappers — delegates to * [ExtJsonValidator.parseExt]. Returns an empty map on an empty * or unparseable column so response rendering never 500s. @@ -605,3 +669,41 @@ data class WorkOrderOperationCommand( val workCenter: String, val standardMinutes: BigDecimal, ) + +/** + * One row in the shop-floor dashboard snapshot returned by + * [WorkOrderService.shopFloorSnapshot]. Carries enough info for a + * dashboard UI to render "WO X is running step N of M at work + * center Y" without any follow-up round trips. + * + * **Nullable current-op fields.** A WO with no operations at all + * (the v2-compat path — auto-spawned by SalesOrderConfirmedSubscriber + * before the v3 routings era) has all four `current*` fields set + * to `null`. The dashboard should render these as "no routing" or + * similar. + * + * **`operationsCompleted` vs `operationsTotal`** gives a dashboard + * the raw numbers for a "step 2 of 5" badge without requiring it + * to scan the full operations list. + * + * **`totalActualMinutes`** counts only COMPLETED operations — + * PENDING or IN_PROGRESS ops contribute zero because nobody has + * entered the actual runtime yet. Compared against + * `totalStandardMinutes` this gives an early-warning variance + * signal ("we're 10 minutes into the planned 30-minute run"). + */ +data class ShopFloorEntry( + val workOrderId: UUID, + val workOrderCode: String, + val outputItemCode: String, + val outputQuantity: BigDecimal, + val sourceSalesOrderCode: String?, + val currentOperationLineNo: Int?, + val currentOperationCode: String?, + val currentWorkCenter: String?, + val currentOperationStatus: WorkOrderOperationStatus?, + val operationsCompleted: Int, + val operationsTotal: Int, + val totalStandardMinutes: BigDecimal, + val totalActualMinutes: BigDecimal, +) 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 04b8ebc..1d4c2ae 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 @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.vibeerp.pbc.production.application.CreateWorkOrderCommand +import org.vibeerp.pbc.production.application.ShopFloorEntry import org.vibeerp.pbc.production.application.WorkOrderInputCommand import org.vibeerp.pbc.production.application.WorkOrderOperationCommand import org.vibeerp.pbc.production.application.WorkOrderService @@ -48,6 +49,29 @@ class WorkOrderController( fun list(): List = workOrderService.list().map { it.toResponse(workOrderService) } + /** + * Shop-floor dashboard snapshot — every IN_PROGRESS work order + * with its current operation and planned/actual time totals. + * A pure read; no state changes. Designed to feed a dashboard + * poll ("which WOs are running right now and how far along are + * they?"). A WO with no routing at all returns `null` for the + * four `current*` fields so the UI can render it as "no routing" + * without any downstream round trips. + * + * **Sort order**: by work order code ascending, so a poll + * returns a stable order. + * + * Mounted as a sub-path of `/work-orders` rather than a + * top-level `/shop-floor` because this is the existing PBC's + * own read endpoint and keeping every production read under + * the `/production/work-orders` root makes the + * permission/audit/OpenAPI story trivially consistent. + */ + @GetMapping("/shop-floor") + @RequirePermission("production.shop-floor.read") + fun shopFloor(): List = + workOrderService.shopFloorSnapshot().map { it.toResponse() } + @GetMapping("/{id}") @RequirePermission("production.work-order.read") fun get(@PathVariable id: UUID): ResponseEntity { @@ -327,3 +351,41 @@ private fun WorkOrderOperation.toResponse(): WorkOrderOperationResponse = startedAt = startedAt, completedAt = completedAt, ) + +/** + * One row in the shop-floor dashboard. Flat by design so a web + * SPA can render a table without joining across nested JSON. See + * [ShopFloorEntry] for the per-field rationale. + */ +data class ShopFloorEntryResponse( + val workOrderId: UUID, + val workOrderCode: String, + val outputItemCode: String, + val outputQuantity: BigDecimal, + val sourceSalesOrderCode: String?, + val currentOperationLineNo: Int?, + val currentOperationCode: String?, + val currentWorkCenter: String?, + val currentOperationStatus: WorkOrderOperationStatus?, + val operationsCompleted: Int, + val operationsTotal: Int, + val totalStandardMinutes: BigDecimal, + val totalActualMinutes: BigDecimal, +) + +private fun ShopFloorEntry.toResponse(): ShopFloorEntryResponse = + ShopFloorEntryResponse( + workOrderId = workOrderId, + workOrderCode = workOrderCode, + outputItemCode = outputItemCode, + outputQuantity = outputQuantity, + sourceSalesOrderCode = sourceSalesOrderCode, + currentOperationLineNo = currentOperationLineNo, + currentOperationCode = currentOperationCode, + currentWorkCenter = currentWorkCenter, + currentOperationStatus = currentOperationStatus, + operationsCompleted = operationsCompleted, + operationsTotal = operationsTotal, + totalStandardMinutes = totalStandardMinutes, + totalActualMinutes = totalActualMinutes, + ) 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 6e288da..2b0e3f8 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 @@ -26,6 +26,8 @@ permissions: 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. + - key: production.shop-floor.read + description: Read the shop-floor dashboard snapshot (in-progress work orders with their current operation and time totals). 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 b580834..de01da7 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 @@ -809,4 +809,102 @@ class WorkOrderServiceTest { .isInstanceOf(IllegalArgumentException::class) .messageContains("refusing to overwrite") } + + // ─── shop-floor snapshot ───────────────────────────────────────── + + @Test + fun `shopFloorSnapshot is empty when no WO is IN_PROGRESS`() { + every { orders.findByStatus(WorkOrderStatus.IN_PROGRESS) } returns emptyList() + + val snapshot = service.shopFloorSnapshot() + + assertThat(snapshot).hasSize(0) + } + + @Test + fun `shopFloorSnapshot returns one entry per IN_PROGRESS WO`() { + val wo1 = inProgressOrderWithOps( + id = UUID.randomUUID(), + code = "WO-AAA", + itemCode = "FG-A", + qty = "10", + ) + val wo2 = inProgressOrderWithOps( + id = UUID.randomUUID(), + code = "WO-BBB", + itemCode = "FG-B", + qty = "20", + ) + every { orders.findByStatus(WorkOrderStatus.IN_PROGRESS) } returns listOf(wo2, wo1) + + val snapshot = service.shopFloorSnapshot() + + assertThat(snapshot).hasSize(2) + // Sorted by code ascending regardless of repository order. + assertThat(snapshot[0].workOrderCode).isEqualTo("WO-AAA") + assertThat(snapshot[1].workOrderCode).isEqualTo("WO-BBB") + } + + @Test + fun `shopFloorSnapshot picks IN_PROGRESS op as current over PENDING`() { + val wo = inProgressOrderWithOps( + id = UUID.randomUUID(), + code = "WO-CURR", + ops = listOf( + op(1, WorkOrderOperationStatus.COMPLETED).also { + it.actualMinutes = BigDecimal("12") + }, + op(2, WorkOrderOperationStatus.IN_PROGRESS), + op(3, WorkOrderOperationStatus.PENDING), + ), + ) + every { orders.findByStatus(WorkOrderStatus.IN_PROGRESS) } returns listOf(wo) + + val entry = service.shopFloorSnapshot().single() + + assertThat(entry.currentOperationLineNo).isEqualTo(2) + assertThat(entry.currentOperationStatus).isEqualTo(WorkOrderOperationStatus.IN_PROGRESS) + assertThat(entry.operationsCompleted).isEqualTo(1) + assertThat(entry.operationsTotal).isEqualTo(3) + assertThat(entry.totalStandardMinutes).isEqualTo(BigDecimal("30")) + assertThat(entry.totalActualMinutes).isEqualTo(BigDecimal("12")) + } + + @Test + fun `shopFloorSnapshot picks first PENDING op when none is IN_PROGRESS`() { + val wo = inProgressOrderWithOps( + id = UUID.randomUUID(), + code = "WO-BETWEEN", + ops = listOf( + op(1, WorkOrderOperationStatus.COMPLETED).also { + it.actualMinutes = BigDecimal("9") + }, + op(2, WorkOrderOperationStatus.PENDING), + op(3, WorkOrderOperationStatus.PENDING), + ), + ) + every { orders.findByStatus(WorkOrderStatus.IN_PROGRESS) } returns listOf(wo) + + val entry = service.shopFloorSnapshot().single() + + assertThat(entry.currentOperationLineNo).isEqualTo(2) + assertThat(entry.currentOperationStatus).isEqualTo(WorkOrderOperationStatus.PENDING) + } + + @Test + fun `shopFloorSnapshot handles a v2-compat WO with no operations`() { + val wo = inProgressOrderWithOps(id = UUID.randomUUID(), code = "WO-NO-OPS") + every { orders.findByStatus(WorkOrderStatus.IN_PROGRESS) } returns listOf(wo) + + val entry = service.shopFloorSnapshot().single() + + assertThat(entry.currentOperationLineNo).isEqualTo(null) + assertThat(entry.currentOperationCode).isEqualTo(null) + assertThat(entry.currentWorkCenter).isEqualTo(null) + assertThat(entry.currentOperationStatus).isEqualTo(null) + assertThat(entry.operationsTotal).isEqualTo(0) + assertThat(entry.operationsCompleted).isEqualTo(0) + assertThat(entry.totalStandardMinutes).isEqualTo(BigDecimal.ZERO) + assertThat(entry.totalActualMinutes).isEqualTo(BigDecimal.ZERO) + } } -- libgit2 0.22.2