Commit 1b0f6d8e4da4c60f63a8bfb6aef19d6794d33419

Authored by zichun
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.
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 }
... ...