Commit 1b0f6d8e4da4c60f63a8bfb6aef19d6794d33419
1 parent
f7ec1479
feat(production): shop-floor dashboard snapshot endpoint
Adds GET /api/v1/production/work-orders/shop-floor — a pure read
that returns every IN_PROGRESS work order with its current
operation and planned/actual time totals. Designed to feed a
future shop-floor dashboard (web SPA, mobile, or an external
reporting tool) without any follow-up round trips.
**Service method.** `WorkOrderService.shopFloorSnapshot()` is a
@Transactional(readOnly = true) query that:
1. Pulls every IN_PROGRESS work order via the existing
`WorkOrderJpaRepository.findByStatus`.
2. Sorts by WO code ascending so a dashboard poll gets a stable
row order.
3. For each WO picks the "current operation" = first op in
IN_PROGRESS status, or, if none, first PENDING op. This
captures both live states: "operator is running step N right
now" and "operator just finished step N and hasn't picked up
step N+1 yet".
4. Computes `totalStandardMinutes` (sum across every op) +
`totalActualMinutes` (sum of completed ops' `actualMinutes`
only, treating null as zero).
5. Counts completed vs total operations for a "step 2 of 5"
badge.
6. Returns a list of `ShopFloorEntry` DTOs — flat structure, one
row per WO, nullable `current*` fields when a WO has no
routing at all (v2-compat path).
**HTTP surface.**
- `GET /api/v1/production/work-orders/shop-floor`
- New permission `production.shop-floor.read`
- Response is `List<ShopFloorEntryResponse>` — flat so a SPA can
render a table without joining across nested JSON. Fields are
1:1 with the service-side `ShopFloorEntry`.
**Design choices.**
- Mounted under `/work-orders/shop-floor` rather than a top-level
`/production/shop-floor` so every production read stays under
the same permission/audit/OpenAPI root.
- Read-only, zero events published, zero ledger writes. Pure
projection over existing state.
- Returns empty list when no WO is in-progress — the dashboard
renders "no jobs running" without a special case.
- Sorted by code so polling is deterministic. A future chunk
might add sort-by-work-center if a dashboard needs a
by-station view.
**Why not a top-level "shop-floor" PBC.** A shop-floor dashboard
doesn't own any state — every field it displays is projected from
pbc-production. A new PBC would duplicate the data model and
create a reaction loop on work order events. Keeping the read in
pbc-production matches the CLAUDE.md guardrail "grow the PBC when
real consumers appear, not on speculation".
**Nullable `current*` fields.** A WO with an empty operations list
(the v2-compat path — auto-spawned from SalesOrderConfirmedSubscriber
before v3 routings) has all four `current*` fields set to null.
The dashboard UI renders "no routing" or similar without any
downstream round trip.
**Tests (5 new).** empty snapshot when no IN_PROGRESS WOs; one
entry per IN_PROGRESS WO with stable sort; current-op picks
IN_PROGRESS over PENDING; current-op picks first PENDING when no
op is IN_PROGRESS (between-operations state); v2-compat WO with
no operations shows null current-op fields and zero time sums.
**Smoke-tested end-to-end against real Postgres:**
1. Empty shop-floor initially (no IN_PROGRESS WOs)
2. Started plugin-printing-shop-quote-to-work-order BPMN with
quoteCode=Q-DASH-1, quantity=500
3. Started the resulting WO — shop-floor showed
currentOperationLineNo=1 (CUT @ PRINTING-CUT-01) status=PENDING,
0/4 completed, totalStandardMinutes=75, totalActualMinutes=0
4. Started op 1 — currentOperationStatus flipped to IN_PROGRESS
5. Completed op 1 with actualMinutes=17 — current op rolled
forward to line 2 (PRINT @ PRINTING-PRESS-A) status=PENDING,
operationsCompleted=1/4, totalActualMinutes=17
24 modules, 355 unit tests (+5), all green.
Showing
4 changed files
with
264 additions
and
0 deletions
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/application/WorkOrderService.kt
| ... | ... | @@ -93,6 +93,70 @@ class WorkOrderService( |
| 93 | 93 | orders.findBySourceSalesOrderCode(salesOrderCode) |
| 94 | 94 | |
| 95 | 95 | /** |
| 96 | + * Build a shop-floor snapshot: every IN_PROGRESS work order | |
| 97 | + * paired with its current operation and planned/actual time | |
| 98 | + * totals. This is a pure read — no state changes, no events | |
| 99 | + * published — designed to feed a future shop-floor dashboard | |
| 100 | + * (web SPA, mobile app, or an external reporting tool via | |
| 101 | + * the REST API). | |
| 102 | + * | |
| 103 | + * **Current-operation rule.** For a WO with a non-empty routing | |
| 104 | + * the "current operation" is the first IN_PROGRESS op in | |
| 105 | + * `lineNo` order, or, if none is IN_PROGRESS, the first PENDING | |
| 106 | + * op. This handles the two live states a WO can be in on the | |
| 107 | + * shop floor: "operator is running step N right now" and | |
| 108 | + * "operator just finished step N and hasn't picked up step N+1 | |
| 109 | + * yet". A WO with no operations at all (the v2-compat path) | |
| 110 | + * returns `currentOperationLineNo = null` — the dashboard UI | |
| 111 | + * shows it as "no routing". | |
| 112 | + * | |
| 113 | + * **Time sums**: | |
| 114 | + * - `totalStandardMinutes` — sum of `standardMinutes` across | |
| 115 | + * every operation (the planned runtime of the whole WO) | |
| 116 | + * - `totalActualMinutes` — sum of `actualMinutes` across the | |
| 117 | + * COMPLETED operations only (null on pending/in-progress | |
| 118 | + * ops is treated as zero) | |
| 119 | + * - `operationsCompleted` / `operationsTotal` — for an | |
| 120 | + * at-a-glance "step 2 of 5" display | |
| 121 | + * | |
| 122 | + * Sorted deterministically by the work order's business code | |
| 123 | + * so a dashboard poll returns a stable order. | |
| 124 | + */ | |
| 125 | + @Transactional(readOnly = true) | |
| 126 | + fun shopFloorSnapshot(): List<ShopFloorEntry> { | |
| 127 | + val inProgressOrders = orders.findByStatus(WorkOrderStatus.IN_PROGRESS) | |
| 128 | + .sortedBy { it.code } | |
| 129 | + | |
| 130 | + return inProgressOrders.map { order -> | |
| 131 | + val ops = order.operations | |
| 132 | + val current = ops.firstOrNull { it.status == WorkOrderOperationStatus.IN_PROGRESS } | |
| 133 | + ?: ops.firstOrNull { it.status == WorkOrderOperationStatus.PENDING } | |
| 134 | + | |
| 135 | + val totalStd = ops.fold(BigDecimal.ZERO) { acc, op -> acc + op.standardMinutes } | |
| 136 | + val totalAct = ops.fold(BigDecimal.ZERO) { acc, op -> | |
| 137 | + acc + (op.actualMinutes ?: BigDecimal.ZERO) | |
| 138 | + } | |
| 139 | + val completedCount = ops.count { it.status == WorkOrderOperationStatus.COMPLETED } | |
| 140 | + | |
| 141 | + ShopFloorEntry( | |
| 142 | + workOrderId = order.id, | |
| 143 | + workOrderCode = order.code, | |
| 144 | + outputItemCode = order.outputItemCode, | |
| 145 | + outputQuantity = order.outputQuantity, | |
| 146 | + sourceSalesOrderCode = order.sourceSalesOrderCode, | |
| 147 | + currentOperationLineNo = current?.lineNo, | |
| 148 | + currentOperationCode = current?.operationCode, | |
| 149 | + currentWorkCenter = current?.workCenter, | |
| 150 | + currentOperationStatus = current?.status, | |
| 151 | + operationsCompleted = completedCount, | |
| 152 | + operationsTotal = ops.size, | |
| 153 | + totalStandardMinutes = totalStd, | |
| 154 | + totalActualMinutes = totalAct, | |
| 155 | + ) | |
| 156 | + } | |
| 157 | + } | |
| 158 | + | |
| 159 | + /** | |
| 96 | 160 | * Convenience passthrough for response mappers — delegates to |
| 97 | 161 | * [ExtJsonValidator.parseExt]. Returns an empty map on an empty |
| 98 | 162 | * or unparseable column so response rendering never 500s. |
| ... | ... | @@ -605,3 +669,41 @@ data class WorkOrderOperationCommand( |
| 605 | 669 | val workCenter: String, |
| 606 | 670 | val standardMinutes: BigDecimal, |
| 607 | 671 | ) |
| 672 | + | |
| 673 | +/** | |
| 674 | + * One row in the shop-floor dashboard snapshot returned by | |
| 675 | + * [WorkOrderService.shopFloorSnapshot]. Carries enough info for a | |
| 676 | + * dashboard UI to render "WO X is running step N of M at work | |
| 677 | + * center Y" without any follow-up round trips. | |
| 678 | + * | |
| 679 | + * **Nullable current-op fields.** A WO with no operations at all | |
| 680 | + * (the v2-compat path — auto-spawned by SalesOrderConfirmedSubscriber | |
| 681 | + * before the v3 routings era) has all four `current*` fields set | |
| 682 | + * to `null`. The dashboard should render these as "no routing" or | |
| 683 | + * similar. | |
| 684 | + * | |
| 685 | + * **`operationsCompleted` vs `operationsTotal`** gives a dashboard | |
| 686 | + * the raw numbers for a "step 2 of 5" badge without requiring it | |
| 687 | + * to scan the full operations list. | |
| 688 | + * | |
| 689 | + * **`totalActualMinutes`** counts only COMPLETED operations — | |
| 690 | + * PENDING or IN_PROGRESS ops contribute zero because nobody has | |
| 691 | + * entered the actual runtime yet. Compared against | |
| 692 | + * `totalStandardMinutes` this gives an early-warning variance | |
| 693 | + * signal ("we're 10 minutes into the planned 30-minute run"). | |
| 694 | + */ | |
| 695 | +data class ShopFloorEntry( | |
| 696 | + val workOrderId: UUID, | |
| 697 | + val workOrderCode: String, | |
| 698 | + val outputItemCode: String, | |
| 699 | + val outputQuantity: BigDecimal, | |
| 700 | + val sourceSalesOrderCode: String?, | |
| 701 | + val currentOperationLineNo: Int?, | |
| 702 | + val currentOperationCode: String?, | |
| 703 | + val currentWorkCenter: String?, | |
| 704 | + val currentOperationStatus: WorkOrderOperationStatus?, | |
| 705 | + val operationsCompleted: Int, | |
| 706 | + val operationsTotal: Int, | |
| 707 | + val totalStandardMinutes: BigDecimal, | |
| 708 | + val totalActualMinutes: BigDecimal, | |
| 709 | +) | ... | ... |
pbc/pbc-production/src/main/kotlin/org/vibeerp/pbc/production/http/WorkOrderController.kt
| ... | ... | @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestMapping |
| 16 | 16 | import org.springframework.web.bind.annotation.ResponseStatus |
| 17 | 17 | import org.springframework.web.bind.annotation.RestController |
| 18 | 18 | import org.vibeerp.pbc.production.application.CreateWorkOrderCommand |
| 19 | +import org.vibeerp.pbc.production.application.ShopFloorEntry | |
| 19 | 20 | import org.vibeerp.pbc.production.application.WorkOrderInputCommand |
| 20 | 21 | import org.vibeerp.pbc.production.application.WorkOrderOperationCommand |
| 21 | 22 | import org.vibeerp.pbc.production.application.WorkOrderService |
| ... | ... | @@ -48,6 +49,29 @@ class WorkOrderController( |
| 48 | 49 | fun list(): List<WorkOrderResponse> = |
| 49 | 50 | workOrderService.list().map { it.toResponse(workOrderService) } |
| 50 | 51 | |
| 52 | + /** | |
| 53 | + * Shop-floor dashboard snapshot — every IN_PROGRESS work order | |
| 54 | + * with its current operation and planned/actual time totals. | |
| 55 | + * A pure read; no state changes. Designed to feed a dashboard | |
| 56 | + * poll ("which WOs are running right now and how far along are | |
| 57 | + * they?"). A WO with no routing at all returns `null` for the | |
| 58 | + * four `current*` fields so the UI can render it as "no routing" | |
| 59 | + * without any downstream round trips. | |
| 60 | + * | |
| 61 | + * **Sort order**: by work order code ascending, so a poll | |
| 62 | + * returns a stable order. | |
| 63 | + * | |
| 64 | + * Mounted as a sub-path of `/work-orders` rather than a | |
| 65 | + * top-level `/shop-floor` because this is the existing PBC's | |
| 66 | + * own read endpoint and keeping every production read under | |
| 67 | + * the `/production/work-orders` root makes the | |
| 68 | + * permission/audit/OpenAPI story trivially consistent. | |
| 69 | + */ | |
| 70 | + @GetMapping("/shop-floor") | |
| 71 | + @RequirePermission("production.shop-floor.read") | |
| 72 | + fun shopFloor(): List<ShopFloorEntryResponse> = | |
| 73 | + workOrderService.shopFloorSnapshot().map { it.toResponse() } | |
| 74 | + | |
| 51 | 75 | @GetMapping("/{id}") |
| 52 | 76 | @RequirePermission("production.work-order.read") |
| 53 | 77 | fun get(@PathVariable id: UUID): ResponseEntity<WorkOrderResponse> { |
| ... | ... | @@ -327,3 +351,41 @@ private fun WorkOrderOperation.toResponse(): WorkOrderOperationResponse = |
| 327 | 351 | startedAt = startedAt, |
| 328 | 352 | completedAt = completedAt, |
| 329 | 353 | ) |
| 354 | + | |
| 355 | +/** | |
| 356 | + * One row in the shop-floor dashboard. Flat by design so a web | |
| 357 | + * SPA can render a table without joining across nested JSON. See | |
| 358 | + * [ShopFloorEntry] for the per-field rationale. | |
| 359 | + */ | |
| 360 | +data class ShopFloorEntryResponse( | |
| 361 | + val workOrderId: UUID, | |
| 362 | + val workOrderCode: String, | |
| 363 | + val outputItemCode: String, | |
| 364 | + val outputQuantity: BigDecimal, | |
| 365 | + val sourceSalesOrderCode: String?, | |
| 366 | + val currentOperationLineNo: Int?, | |
| 367 | + val currentOperationCode: String?, | |
| 368 | + val currentWorkCenter: String?, | |
| 369 | + val currentOperationStatus: WorkOrderOperationStatus?, | |
| 370 | + val operationsCompleted: Int, | |
| 371 | + val operationsTotal: Int, | |
| 372 | + val totalStandardMinutes: BigDecimal, | |
| 373 | + val totalActualMinutes: BigDecimal, | |
| 374 | +) | |
| 375 | + | |
| 376 | +private fun ShopFloorEntry.toResponse(): ShopFloorEntryResponse = | |
| 377 | + ShopFloorEntryResponse( | |
| 378 | + workOrderId = workOrderId, | |
| 379 | + workOrderCode = workOrderCode, | |
| 380 | + outputItemCode = outputItemCode, | |
| 381 | + outputQuantity = outputQuantity, | |
| 382 | + sourceSalesOrderCode = sourceSalesOrderCode, | |
| 383 | + currentOperationLineNo = currentOperationLineNo, | |
| 384 | + currentOperationCode = currentOperationCode, | |
| 385 | + currentWorkCenter = currentWorkCenter, | |
| 386 | + currentOperationStatus = currentOperationStatus, | |
| 387 | + operationsCompleted = operationsCompleted, | |
| 388 | + operationsTotal = operationsTotal, | |
| 389 | + totalStandardMinutes = totalStandardMinutes, | |
| 390 | + totalActualMinutes = totalActualMinutes, | |
| 391 | + ) | ... | ... |
pbc/pbc-production/src/main/resources/META-INF/vibe-erp/metadata/production.yml
| ... | ... | @@ -26,6 +26,8 @@ permissions: |
| 26 | 26 | description: Start a routing operation (PENDING → IN_PROGRESS). Parent work order must be IN_PROGRESS and the operation must come next in sequence. |
| 27 | 27 | - key: production.work-order.operation.complete |
| 28 | 28 | description: Complete a routing operation (IN_PROGRESS → COMPLETED). Records the operator-entered actual_minutes. |
| 29 | + - key: production.shop-floor.read | |
| 30 | + description: Read the shop-floor dashboard snapshot (in-progress work orders with their current operation and time totals). | |
| 29 | 31 | |
| 30 | 32 | customFields: |
| 31 | 33 | - key: production_priority | ... | ... |
pbc/pbc-production/src/test/kotlin/org/vibeerp/pbc/production/application/WorkOrderServiceTest.kt
| ... | ... | @@ -809,4 +809,102 @@ class WorkOrderServiceTest { |
| 809 | 809 | .isInstanceOf(IllegalArgumentException::class) |
| 810 | 810 | .messageContains("refusing to overwrite") |
| 811 | 811 | } |
| 812 | + | |
| 813 | + // ─── shop-floor snapshot ───────────────────────────────────────── | |
| 814 | + | |
| 815 | + @Test | |
| 816 | + fun `shopFloorSnapshot is empty when no WO is IN_PROGRESS`() { | |
| 817 | + every { orders.findByStatus(WorkOrderStatus.IN_PROGRESS) } returns emptyList() | |
| 818 | + | |
| 819 | + val snapshot = service.shopFloorSnapshot() | |
| 820 | + | |
| 821 | + assertThat(snapshot).hasSize(0) | |
| 822 | + } | |
| 823 | + | |
| 824 | + @Test | |
| 825 | + fun `shopFloorSnapshot returns one entry per IN_PROGRESS WO`() { | |
| 826 | + val wo1 = inProgressOrderWithOps( | |
| 827 | + id = UUID.randomUUID(), | |
| 828 | + code = "WO-AAA", | |
| 829 | + itemCode = "FG-A", | |
| 830 | + qty = "10", | |
| 831 | + ) | |
| 832 | + val wo2 = inProgressOrderWithOps( | |
| 833 | + id = UUID.randomUUID(), | |
| 834 | + code = "WO-BBB", | |
| 835 | + itemCode = "FG-B", | |
| 836 | + qty = "20", | |
| 837 | + ) | |
| 838 | + every { orders.findByStatus(WorkOrderStatus.IN_PROGRESS) } returns listOf(wo2, wo1) | |
| 839 | + | |
| 840 | + val snapshot = service.shopFloorSnapshot() | |
| 841 | + | |
| 842 | + assertThat(snapshot).hasSize(2) | |
| 843 | + // Sorted by code ascending regardless of repository order. | |
| 844 | + assertThat(snapshot[0].workOrderCode).isEqualTo("WO-AAA") | |
| 845 | + assertThat(snapshot[1].workOrderCode).isEqualTo("WO-BBB") | |
| 846 | + } | |
| 847 | + | |
| 848 | + @Test | |
| 849 | + fun `shopFloorSnapshot picks IN_PROGRESS op as current over PENDING`() { | |
| 850 | + val wo = inProgressOrderWithOps( | |
| 851 | + id = UUID.randomUUID(), | |
| 852 | + code = "WO-CURR", | |
| 853 | + ops = listOf( | |
| 854 | + op(1, WorkOrderOperationStatus.COMPLETED).also { | |
| 855 | + it.actualMinutes = BigDecimal("12") | |
| 856 | + }, | |
| 857 | + op(2, WorkOrderOperationStatus.IN_PROGRESS), | |
| 858 | + op(3, WorkOrderOperationStatus.PENDING), | |
| 859 | + ), | |
| 860 | + ) | |
| 861 | + every { orders.findByStatus(WorkOrderStatus.IN_PROGRESS) } returns listOf(wo) | |
| 862 | + | |
| 863 | + val entry = service.shopFloorSnapshot().single() | |
| 864 | + | |
| 865 | + assertThat(entry.currentOperationLineNo).isEqualTo(2) | |
| 866 | + assertThat(entry.currentOperationStatus).isEqualTo(WorkOrderOperationStatus.IN_PROGRESS) | |
| 867 | + assertThat(entry.operationsCompleted).isEqualTo(1) | |
| 868 | + assertThat(entry.operationsTotal).isEqualTo(3) | |
| 869 | + assertThat(entry.totalStandardMinutes).isEqualTo(BigDecimal("30")) | |
| 870 | + assertThat(entry.totalActualMinutes).isEqualTo(BigDecimal("12")) | |
| 871 | + } | |
| 872 | + | |
| 873 | + @Test | |
| 874 | + fun `shopFloorSnapshot picks first PENDING op when none is IN_PROGRESS`() { | |
| 875 | + val wo = inProgressOrderWithOps( | |
| 876 | + id = UUID.randomUUID(), | |
| 877 | + code = "WO-BETWEEN", | |
| 878 | + ops = listOf( | |
| 879 | + op(1, WorkOrderOperationStatus.COMPLETED).also { | |
| 880 | + it.actualMinutes = BigDecimal("9") | |
| 881 | + }, | |
| 882 | + op(2, WorkOrderOperationStatus.PENDING), | |
| 883 | + op(3, WorkOrderOperationStatus.PENDING), | |
| 884 | + ), | |
| 885 | + ) | |
| 886 | + every { orders.findByStatus(WorkOrderStatus.IN_PROGRESS) } returns listOf(wo) | |
| 887 | + | |
| 888 | + val entry = service.shopFloorSnapshot().single() | |
| 889 | + | |
| 890 | + assertThat(entry.currentOperationLineNo).isEqualTo(2) | |
| 891 | + assertThat(entry.currentOperationStatus).isEqualTo(WorkOrderOperationStatus.PENDING) | |
| 892 | + } | |
| 893 | + | |
| 894 | + @Test | |
| 895 | + fun `shopFloorSnapshot handles a v2-compat WO with no operations`() { | |
| 896 | + val wo = inProgressOrderWithOps(id = UUID.randomUUID(), code = "WO-NO-OPS") | |
| 897 | + every { orders.findByStatus(WorkOrderStatus.IN_PROGRESS) } returns listOf(wo) | |
| 898 | + | |
| 899 | + val entry = service.shopFloorSnapshot().single() | |
| 900 | + | |
| 901 | + assertThat(entry.currentOperationLineNo).isEqualTo(null) | |
| 902 | + assertThat(entry.currentOperationCode).isEqualTo(null) | |
| 903 | + assertThat(entry.currentWorkCenter).isEqualTo(null) | |
| 904 | + assertThat(entry.currentOperationStatus).isEqualTo(null) | |
| 905 | + assertThat(entry.operationsTotal).isEqualTo(0) | |
| 906 | + assertThat(entry.operationsCompleted).isEqualTo(0) | |
| 907 | + assertThat(entry.totalStandardMinutes).isEqualTo(BigDecimal.ZERO) | |
| 908 | + assertThat(entry.totalActualMinutes).isEqualTo(BigDecimal.ZERO) | |
| 909 | + } | |
| 812 | 910 | } | ... | ... |