From 640700044e317f682735de353205c3a7ac0ef4ec Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 6 Aug 2026 22:56:32 +0800 Subject: [PATCH 1/3] =?UTF-8?q?List=20=E5=8D=A1=E7=89=87=E7=9A=84=E3=80=8C?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=B7=A5=E5=96=AE=E3=80=8D=E7=8F=BE=E5=9C=A8?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=20ADMIN=20=E6=89=8D=E6=9C=83=E9=A1=AF?= =?UTF-8?q?=E7=A4=BA=EF=BC=8C=E9=9D=9E=20ADMIN=20=E5=AE=8C=E5=85=A8?= =?UTF-8?q?=E7=9C=8B=E4=B8=8D=E5=88=B0=E6=8C=89=E9=88=95=E3=80=82=20?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=B7=A5=E5=96=AE=EF=BC=8F=E5=AE=8C=E6=88=90?= =?UTF-8?q?=20API=20=E6=94=B9=E7=82=BA=E5=83=85=20ADMIN=EF=BC=9B=E3=80=8C?= =?UTF-8?q?=E5=B7=A5=E5=96=AE=E7=94=9F=E7=94=A2=E6=B5=81=E7=A8=8B=E3=80=8D?= =?UTF-8?q?tab=20=E5=83=85=20ADMIN=20=E5=8F=AF=E8=A6=8B=EF=BC=9B=E3=80=8C?= =?UTF-8?q?=E5=B7=B2=E6=8F=90=E6=96=99=E3=80=8D=E6=94=B9=E7=82=BA=E5=85=A8?= =?UTF-8?q?=E9=83=A8=20POL=20completed=EF=BC=9B=E6=96=87=E6=A1=88=E3=80=8C?= =?UTF-8?q?=E6=9C=AA=E6=8F=90=E6=96=99=E3=80=8D=E2=86=92=E3=80=8C=E6=9C=AA?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=8F=90=E6=96=99=E3=80=8D=EF=BC=8FPick=20in?= =?UTF-8?q?complete=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fpsms/config/security/SecurityConfig.java | 6 ++-- .../pickOrder/entity/PickOrderRepository.kt | 35 ++++++++++--------- .../entity/ProductProcessRepository.kt | 4 +-- .../service/ProductProcessService.kt | 2 +- .../web/ProductProcessController.kt | 2 +- .../modules/user/service/GroupService.java | 2 +- .../01_product_process_complete_authority.sql | 2 +- 7 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index 842f330..e59628a 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -75,7 +75,7 @@ public class SecurityConfig { } /** - * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05 + * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 * (stockAdjustment/submit → INVENTORY_ADJUST only) */ @@ -122,9 +122,9 @@ public class SecurityConfig { .hasAuthority("ITEM_TRACING") .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**") .hasAuthority("ITEM_TRACING") - /* 工單 生產流程 完成工單 */ + /* 工單 生產流程 完成工單:僅 ADMIN */ .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/complete/**") - .hasAuthority("PRODUCT_PROCESS") + .hasAuthority("ADMIN") /* 工序「已完成」(Just Pass):僅 ADMIN */ .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**") .hasAuthority("ADMIN") diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt index 9700536..12462c3 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderRepository.kt @@ -256,31 +256,34 @@ fun findAllReleasedJoWorkbenchPickOrders( fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int /** - * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 - * JO ids that have at least one stock_out_line counted as picked - * (checked / partially_completed / completed / picking, or rejected with qty > 0). + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 + * JO ids whose pick order lines are all completed (one JO ↔ one pick order in practice). + * Used for production-process 「已提料」 bucket. */ @Query( value = """ - SELECT DISTINCT po.joId + SELECT po.joId FROM pick_order po INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 - INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 WHERE po.deleted = 0 AND po.joId IN (:joIds) - AND ( - LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking') - OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0) - ) + GROUP BY po.joId, po.id + HAVING COUNT(*) > 0 + AND COUNT(*) = SUM( + CASE WHEN LOWER(COALESCE(pol.status, '')) = 'completed' THEN 1 ELSE 0 END + ) """, nativeQuery = true, ) fun findPickedJobOrderIds(@Param("joIds") joIds: Collection): List - /** Unlimited: picked + product process still pending / not started. */ + /** + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 + * Unlimited: all POL completed + product process still pending / not started. + */ @Query( value = """ - SELECT DISTINCT po.joId + SELECT po.joId FROM pick_order po INNER JOIN job_order jo ON jo.id = po.joId @@ -293,13 +296,13 @@ fun incrementSubmittedLines(@Param("pickOrderId") pickOrderId: Long): Int AND LOWER(pp.status) = 'pending' AND pp.startTime IS NULL INNER JOIN pick_order_line pol ON pol.poId = po.id AND pol.deleted = 0 - INNER JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = 0 WHERE po.deleted = 0 AND po.joId IS NOT NULL - AND ( - LOWER(COALESCE(sol.status, '')) IN ('checked', 'partially_completed', 'completed', 'picking') - OR (LOWER(COALESCE(sol.status, '')) = 'rejected' AND COALESCE(sol.qty, 0) > 0) - ) + GROUP BY po.joId, po.id + HAVING COUNT(*) > 0 + AND COUNT(*) = SUM( + CASE WHEN LOWER(COALESCE(pol.status, '')) = 'completed' THEN 1 ELSE 0 END + ) """, nativeQuery = true, ) diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt index ce34319..46b4863 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt @@ -57,7 +57,7 @@ interface ProductProcessRepository : JpaRepository, JpaSpe ): List /** - * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 * Production list lookback: inclusive date range on ProductProcess.date. */ @Query( @@ -114,7 +114,7 @@ interface ProductProcessRepository : JpaRepository, JpaSpe GROUP BY p.jobOrder.id """ ) - /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 */ fun findCandidateJobOrderAggregatesByJobOrderIds( @Param("jobOrderIds") jobOrderIds: Collection, @Param("itemCode") itemCode: String?, diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index f52fd6e..682faa1 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -1498,7 +1498,7 @@ open class ProductProcessService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 * * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt index 350ce48..5ad8842 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt @@ -200,7 +200,7 @@ class ProductProcessController( return productProcessService.getAllJoborderProductProcessInfo(bomType) } - /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.0 | 2026-08-03 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 */ @GetMapping("/Demo/Process/search") fun demoprocesssearch( @RequestParam(required = false) date: String?, diff --git a/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java b/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java index d78acaf..707f196 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java +++ b/src/main/java/com/ffii/fpsms/modules/user/service/GroupService.java @@ -187,7 +187,7 @@ public class GroupService extends AbstractBaseEntityService> listAuth(Map args) { diff --git a/src/main/resources/db/changelog/changes/20260805_product_process_complete/01_product_process_complete_authority.sql b/src/main/resources/db/changelog/changes/20260805_product_process_complete/01_product_process_complete_authority.sql index d08ede3..9b6d5cf 100644 --- a/src/main/resources/db/changelog/changes/20260805_product_process_complete/01_product_process_complete_authority.sql +++ b/src/main/resources/db/changelog/changes/20260805_product_process_complete/01_product_process_complete_authority.sql @@ -3,6 +3,6 @@ --changeset fpsms:product_process_complete_authority --preconditions onFail:MARK_RAN --precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'PRODUCT_PROCESS' ---comment: FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.0 | 2026-08-05 — Allow completing job order product process lines (工單 生產流程 完成工單) +--comment: FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 — Allow completing job order product process lines (工單 生產流程 完成工單) INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`) VALUES ('PRODUCT_PROCESS', '工單 生產流程 完成工單', 'JOB_ORDER', 'Allow completing job order product process lines'); From a4aaefd1f78172dc3fc9d86ead156719887f9f12 Mon Sep 17 00:00:00 2001 From: tommy Date: Fri, 7 Aug 2026 18:56:14 +0800 Subject: [PATCH 2/3] =?UTF-8?q?DrinkProductionQty=20=E6=94=AF=E6=8F=B4=20a?= =?UTF-8?q?ctual/planned=E3=80=81=E7=9C=9F=E5=AF=A6=E7=94=A2=E9=87=8F?= =?UTF-8?q?=E4=B8=8D=20fallback=20reqQty=EF=BC=8C=E4=B8=A6=E5=9B=9E?= =?UTF-8?q?=E5=82=B3=E5=B7=A5=E5=BA=8F=EF=BC=8FQC=EF=BC=8F=E4=B8=8A?= =?UTF-8?q?=E6=9E=B6=E4=BA=BA=E5=93=A1=EF=BC=9BItem=20Lot=20Trace=20?= =?UTF-8?q?=E8=A3=9C=20JO=E2=86=92=E7=94=9F=E7=94=A2=E6=AD=A5=E9=A9=9F?= =?UTF-8?q?=E9=80=A3=E7=B7=9A=E3=80=81=E6=8F=90=E6=96=99=E5=96=AE=E8=A1=8C?= =?UTF-8?q?=E7=AF=80=E9=BB=9E=E8=88=87=20FG=20UOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/ProductProcessRepository.kt | 15 ++ .../service/ProductProcessService.kt | 234 ++++++++++++++++-- .../web/ProductProcessController.kt | 11 +- .../web/model/SaveProductProcessRequest.kt | 25 ++ .../modules/qc/entity/QcResultRepository.kt | 2 + .../entity/InventoryLotLineRepository.kt | 2 + .../modules/stock/trace/TraceBomLoader.kt | 20 +- .../stock/trace/TraceGraphNodeBuilder.kt | 97 +++++++- .../stock/trace/TraceGraphSemanticsBuilder.kt | 118 ++++++++- .../modules/stock/trace/TraceInboundLoader.kt | 27 +- .../stock/trace/TraceJoPreludeLoader.kt | 11 +- .../modules/stock/trace/TraceLotResolver.kt | 35 ++- .../stock/trace/TraceProductionLoader.kt | 20 +- 13 files changed, 550 insertions(+), 67 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt index 46b4863..4de81aa 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt @@ -7,6 +7,7 @@ import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository import java.util.* import java.time.LocalDate +import java.time.LocalDateTime interface JobOrderProcessAggregate { val jobOrderId: Long @@ -27,6 +28,20 @@ interface ProductProcessRepository : JpaRepository, JpaSpe fun findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds: List): List fun findByJobOrder_IdInAndDeletedIsFalseOrderByDateDescProductionPriorityAsc(jobOrderIds: Collection): List + @Query( + """ + SELECT p FROM ProductProcess p + WHERE p.deleted = false + AND p.startTime IS NOT NULL + AND p.startTime >= :startFrom + AND p.startTime < :startToExclusive + """ + ) + fun findByDeletedFalseAndStartTimeFromBeforeExclusive( + @Param("startFrom") startFrom: LocalDateTime, + @Param("startToExclusive") startToExclusive: LocalDateTime, + ): List + @Query( """ SELECT diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index 682faa1..0a35199 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -54,6 +54,7 @@ import java.math.RoundingMode import java.time.LocalDate import java.time.format.DateTimeFormatter import com.ffii.fpsms.modules.stock.entity.StockInLineRepository +import com.ffii.fpsms.modules.qc.entity.QcResultRepository import org.springframework.dao.DataIntegrityViolationException @Service @Transactional @@ -81,7 +82,8 @@ open class ProductProcessService( private val itemUomRepository: ItemUomRespository, private val uomConversionRepository: UomConversionRepository, private val itemUomService: ItemUomService, - private val stockInLineRepository: StockInLineRepository + private val stockInLineRepository: StockInLineRepository, + private val qcResultRepository: QcResultRepository, ) { open fun findAll(pageable: Pageable): Page { @@ -2629,21 +2631,19 @@ open class ProductProcessService( } // ===== Drink Production Qty Dashboard ===== + private companion object { + const val DRINK_PLANNED_LOOKBACK_DAYS = 30L + } + /** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */ - open fun getDrinkProductionQty(date: LocalDate?): List { + open fun getDrinkProductionQty( + date: LocalDate?, + view: String = "actual", + ): List { val targetDate = date ?: LocalDate.now() val startOfDay = targetDate.atStartOfDay() val endOfDayExclusive = targetDate.plusDays(1).atStartOfDay() - - // 依「生產日」彙總:與既有 dashboard 的用法一致,採半開區間 [startOfDay, endExclusive) - val jobOrders = jobOrderRepository.findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc( - planStartFrom = startOfDay, - planStartToExclusive = endOfDayExclusive, - ) - - if (jobOrders.isEmpty()) { - return emptyList() - } + val isPlannedView = view.equals("planned", ignoreCase = true) data class GroupKey( val itemCode: String?, @@ -2659,11 +2659,71 @@ open class ProductProcessService( val jobOrderId: Long?, val jobOrderCode: String?, val productionDate: LocalDate?, + val jobOrderStatus: String?, + val startTime: LocalDateTime?, + val assumeTimeNeedMins: Int, + val assumeEndTime: LocalDateTime?, + val actualEndTime: LocalDateTime?, + val latestStartBy: LocalDateTime?, + val processOperators: String?, + val processHandlers: String?, + val qcUsers: String?, + val putAwayUsers: String?, + val processSteps: List, ) - val drinkOrders = jobOrders + val candidateJobOrders = if (isPlannedView) { + val lookbackStart = targetDate.minusDays(DRINK_PLANNED_LOOKBACK_DAYS).atStartOfDay() + val byPlanStart = jobOrderRepository.findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc( + planStartFrom = lookbackStart, + planStartToExclusive = endOfDayExclusive, + ) + val processesInWindow = productProcessRepository.findByDeletedFalseAndStartTimeFromBeforeExclusive( + startFrom = lookbackStart, + startToExclusive = endOfDayExclusive, + ) + val joIdsFromProcess = processesInWindow.mapNotNull { it.jobOrder?.id }.distinct() + val byProcessStart = if (joIdsFromProcess.isNotEmpty()) { + jobOrderRepository.findAllById(joIdsFromProcess).filter { it.deleted != true } + } else { + emptyList() + } + (byPlanStart + byProcessStart) + .filter { it.id != null } + .associateBy { it.id!! } + .values + .toList() + } else { + val byPlanStart = jobOrderRepository.findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc( + planStartFrom = startOfDay, + planStartToExclusive = endOfDayExclusive, + ) + val processesStartedToday = productProcessRepository.findByDeletedFalseAndStartTimeFromBeforeExclusive( + startFrom = startOfDay, + startToExclusive = endOfDayExclusive, + ) + val joIdsFromProcess = processesStartedToday.mapNotNull { it.jobOrder?.id }.distinct() + val byProcessStart = if (joIdsFromProcess.isNotEmpty()) { + jobOrderRepository.findAllById(joIdsFromProcess).filter { it.deleted != true } + } else { + emptyList() + } + (byPlanStart + byProcessStart) + .filter { it.id != null } + .associateBy { it.id!! } + .values + .toList() + } + + if (candidateJobOrders.isEmpty()) { + return emptyList() + } + + val drinkOrders = candidateJobOrders .filter { it.bom?.isDrink == true } - .filter { it.status != JobOrderStatus.PLANNING } + .filter { jo -> + isPlannedView || jo.status != JobOrderStatus.PLANNING + } if (drinkOrders.isEmpty()) { return emptyList() @@ -2671,6 +2731,15 @@ open class ProductProcessService( val jobOrderIds = drinkOrders.mapNotNull { it.id } val itemIds = drinkOrders.mapNotNull { it.bom?.item?.id }.distinct() + val bomIds = drinkOrders.mapNotNull { it.bom?.id }.distinct() + + val bomDurationMinsByBomId: Map = if (bomIds.isNotEmpty()) { + bomProcessRepository.sumDurationByBomIds(bomIds).associate { agg -> + agg.bomId to agg.totalMinutes.toInt().coerceAtLeast(0) + } + } else { + emptyMap() + } val processesByJobOrderId = productProcessRepository .findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds) @@ -2685,9 +2754,34 @@ open class ProductProcessService( emptyMap() } - val stockInLineByJobOrderId = if (jobOrderIds.isNotEmpty()) { + val stockInLinesByJobOrderId = if (jobOrderIds.isNotEmpty()) { stockInLineRepository.findAllByJobOrder_IdInAndDeletedFalse(jobOrderIds) - .associateBy { it.jobOrder?.id } + .groupBy { it.jobOrder?.id } + } else { + emptyMap() + } + + val allStockInLines = stockInLinesByJobOrderId.values.flatten() + val stockInLineIds = allStockInLines.mapNotNull { it.id } + val qcUsersByStockInLineId: Map> = if (stockInLineIds.isNotEmpty()) { + qcResultRepository.findByStockInLine_IdInAndDeletedFalse(stockInLineIds) + .groupBy { it.stockInLine?.id ?: 0L } + .mapValues { (_, results) -> + results.mapNotNull { it.createdBy?.trim()?.takeIf { n -> n.isNotEmpty() } } + .distinct() + } + } else { + emptyMap() + } + + val inventoryLotIds = allStockInLines.mapNotNull { it.inventoryLot?.id }.distinct() + val putAwayUsersByLotId: Map> = if (inventoryLotIds.isNotEmpty()) { + inventoryLotLineRepository.findByInventoryLot_IdInAndDeletedFalse(inventoryLotIds) + .groupBy { it.inventoryLot?.id ?: 0L } + .mapValues { (_, ills) -> + ills.mapNotNull { it.createdBy?.trim()?.takeIf { n -> n.isNotEmpty() } } + .distinct() + } } else { emptyMap() } @@ -2712,25 +2806,111 @@ open class ProductProcessService( uomById[iu.uom?.id]?.udfudesc } - val drinkRows = drinkOrders.map { jo -> + fun joinNames(names: Collection): String? = + names.map { it.trim() }.filter { it.isNotEmpty() }.distinct() + .takeIf { it.isNotEmpty() }?.joinToString("; ") + + val drinkRows = drinkOrders.mapNotNull { jo -> val bom = jo.bom + val bomId = bom?.id val itemId = bom?.item?.id val process = processesByJobOrderId[jo.id] val lines = process?.id?.let { linesByProcessId[it].orEmpty() }.orEmpty() - val stockInLine = stockInLineByJobOrderId[jo.id] + val sils = stockInLinesByJobOrderId[jo.id].orEmpty() + val stockInLine = sils.maxByOrNull { it.acceptedQty ?: BigDecimal.ZERO } + val startTime = process?.startTime + val lineProcessingMins = lines.sumOf { it.processingTime ?: 0 } + val bomDurationMins = bomId?.let { bomDurationMinsByBomId[it] } ?: 0 + + val assumeTimeNeedMins = if (isPlannedView) { + if (bomDurationMins > 0) bomDurationMins else lineProcessingMins + } else { + if (lineProcessingMins > 0) lineProcessingMins else bomDurationMins + } + + val assumeEndTime = + startTime?.plusMinutes(assumeTimeNeedMins.toLong()) + + if (isPlannedView) { + val completionDate = when { + assumeEndTime != null -> assumeEndTime.toLocalDate() + jo.planStart != null && assumeTimeNeedMins > 0 -> + jo.planStart!!.plusMinutes(assumeTimeNeedMins.toLong()).toLocalDate() + jo.planStart != null -> jo.planStart!!.toLocalDate() + else -> null + } + if (completionDate == null || completionDate != targetDate) { + return@mapNotNull null + } + } + + val latestStartBy = if (isPlannedView && startTime == null && assumeTimeNeedMins > 0) { + endOfDayExclusive.minusMinutes(assumeTimeNeedMins.toLong()) + } else { + null + } + + val processOperators = joinNames(lines.mapNotNull { it.operator?.name }) + val processHandlers = joinNames(lines.mapNotNull { it.handler?.name }) + val qcUsers = joinNames( + sils.flatMap { sil -> + val id = sil.id ?: return@flatMap emptyList() + qcUsersByStockInLineId[id].orEmpty() + }, + ) + val putAwayUsers = joinNames( + sils.flatMap { sil -> + val fromLot = sil.inventoryLot?.id?.let { putAwayUsersByLotId[it] }.orEmpty() + val fromIll = listOfNotNull(sil.inventoryLotLine?.createdBy) + val fromSil = listOfNotNull(sil.createdBy, sil.user?.name) + fromLot + fromIll + fromSil + }, + ) + val processSteps = lines + .sortedBy { it.seqNo ?: 0L } + .map { line -> + DrinkProductionQtyProcessStep( + jobOrderId = jo.id ?: 0L, + jobOrderCode = jo.code, + itemCode = bom?.item?.code, + itemName = bom?.item?.name, + seqNo = line.seqNo, + processName = line.name, + operatorName = line.operator?.name, + handlerName = line.handler?.name, + startTime = line.startTime, + endTime = line.endTime, + status = line.status, + ) + } DrinkOrderRow( itemCode = bom?.item?.code, itemName = bom?.item?.name, uom = resolveDrinkProductionUom(bom, itemId, uomDescByItemId), reqQty = jo.reqQty ?: BigDecimal.ZERO, - productionQty = resolveDrinkProductionQty(jo, lines, stockInLine), + productionQty = resolveDrinkProductionQty(lines, stockInLine), jobOrderId = jo.id, jobOrderCode = jo.code, productionDate = jo.planStart?.toLocalDate(), + jobOrderStatus = jo.status?.value, + startTime = startTime, + assumeTimeNeedMins = assumeTimeNeedMins, + assumeEndTime = assumeEndTime, + actualEndTime = process?.endTime, + latestStartBy = latestStartBy, + processOperators = processOperators, + processHandlers = processHandlers, + qcUsers = qcUsers, + putAwayUsers = putAwayUsers, + processSteps = processSteps, ) } + if (drinkRows.isEmpty()) { + return emptyList() + } + return drinkRows .groupBy { GroupKey(it.itemCode, it.itemName) } .map { (key, rows) -> @@ -2751,6 +2931,17 @@ open class ProductProcessService( productionDate = row.productionDate, reqQty = row.reqQty, productionQty = row.productionQty, + jobOrderStatus = row.jobOrderStatus, + startTime = row.startTime, + assumeTimeNeedMins = row.assumeTimeNeedMins, + assumeEndTime = row.assumeEndTime, + actualEndTime = row.actualEndTime, + latestStartBy = row.latestStartBy, + processOperators = row.processOperators, + processHandlers = row.processHandlers, + qcUsers = row.qcUsers, + putAwayUsers = row.putAwayUsers, + processSteps = row.processSteps, ) }.sortedBy { it.jobOrderCode ?: "" }, ) @@ -2772,9 +2963,8 @@ open class ProductProcessService( return null } - /** 實際生產數量:優先工序產出 → QC 入庫接受量 → 工單 reqQty(當日計劃生產量) */ + /** 實際生產數量:優先工序產出 → QC 入庫接受量;皆無則 0(不 fallback 到 reqQty) */ private fun resolveDrinkProductionQty( - jobOrder: com.ffii.fpsms.modules.jobOrder.entity.JobOrder, lines: List, stockInLine: com.ffii.fpsms.modules.stock.entity.StockInLine?, ): BigDecimal { @@ -2792,7 +2982,7 @@ open class ProductProcessService( return acceptedQty } - return jobOrder.reqQty ?: BigDecimal.ZERO + return BigDecimal.ZERO } // ===== Operator KPI Dashboard ===== diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt index 5ad8842..c081000 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt @@ -320,10 +320,17 @@ class ProductProcessController( /** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */ @GetMapping("/Demo/DrinkProductionQty") - fun getDrinkProductionQty(@RequestParam(required = false) date: String?): List { + fun getDrinkProductionQty( + @RequestParam(required = false) date: String?, + @RequestParam(required = false, defaultValue = "actual") view: String?, + ): List { val parsedDate = date?.takeIf { it.isNotBlank() }?.let { LocalDate.parse(it, DateTimeFormatter.ISO_DATE) } - return productProcessService.getDrinkProductionQty(parsedDate) + val viewMode = when (view?.trim()?.lowercase()) { + "planned" -> "planned" + else -> "actual" + } + return productProcessService.getDrinkProductionQty(parsedDate, viewMode) } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt index ff80dcd..68a8a82 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt @@ -359,6 +359,20 @@ data class EquipmentStatusByTypeResponse( ) // ===== Drink Production Qty Dashboard ===== +data class DrinkProductionQtyProcessStep( + val jobOrderId: Long, + val jobOrderCode: String?, + val itemCode: String?, + val itemName: String?, + val seqNo: Long?, + val processName: String?, + val operatorName: String?, + val handlerName: String?, + val startTime: LocalDateTime?, + val endTime: LocalDateTime?, + val status: String?, +) + data class DrinkProductionQtyJobOrderDetail( val jobOrderId: Long, val jobOrderCode: String?, @@ -366,6 +380,17 @@ data class DrinkProductionQtyJobOrderDetail( val productionDate: LocalDate?, val reqQty: BigDecimal, val productionQty: BigDecimal, + val jobOrderStatus: String? = null, + val startTime: LocalDateTime? = null, + val assumeTimeNeedMins: Int = 0, + val assumeEndTime: LocalDateTime? = null, + val actualEndTime: LocalDateTime? = null, + val latestStartBy: LocalDateTime? = null, + val processOperators: String? = null, + val processHandlers: String? = null, + val qcUsers: String? = null, + val putAwayUsers: String? = null, + val processSteps: List = emptyList(), ) data class DrinkProductionQtyResponse( diff --git a/src/main/java/com/ffii/fpsms/modules/qc/entity/QcResultRepository.kt b/src/main/java/com/ffii/fpsms/modules/qc/entity/QcResultRepository.kt index 0af9e71..91c6084 100644 --- a/src/main/java/com/ffii/fpsms/modules/qc/entity/QcResultRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/qc/entity/QcResultRepository.kt @@ -12,6 +12,8 @@ interface QcResultRepository: AbstractRepository { fun findQcResultInfoByStockInLineIdAndDeletedFalse(stockInLineId: Long): List fun findQcResultInfoByStockOutLineIdAndDeletedFalse(stockOutLineId: Long): List + fun findByStockInLine_IdInAndDeletedFalse(stockInLineIds: Collection): List + @Query( """ SELECT DISTINCT qr.stockInLine.id diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index b2b190f..630b6d6 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -118,6 +118,8 @@ WHERE ill.id = :id fun findAllByInventoryLotId(id: Serializable): List + fun findByInventoryLot_IdInAndDeletedFalse(inventoryLotIds: Collection): List + fun findByInventoryLotStockInLineIdAndWarehouseId(inventoryLotStockInLineId: Long, warehouseId: Long): InventoryLotLine? fun findAllByInventoryLotItemIdAndStatus(itemId: Long, status: String): List diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt index 24c1d1d..33017a1 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceBomLoader.kt @@ -122,7 +122,7 @@ open class TraceBomLoader( COALESCE(fg_il.lotNo, fg_sil.lotNo, '') AS finishedLotNo, fg_sil.id AS finishedStockInLineId, COALESCE(fg_sil.acceptedQty, 0) AS fgQty, - COALESCE(fg_uc.udfudesc, '') AS fgUom, + COALESCE(fg_uc_tx.udfudesc, fg_uc_fb.udfudesc, '') AS fgUom, COALESCE(sol.qty, 0) AS materialQtyUsed FROM stock_out_line sol INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 @@ -136,8 +136,22 @@ open class TraceBomLoader( LEFT JOIN inventory_lot fg_il ON fg_sil.inventoryLotId = fg_il.id AND fg_il.deleted = 0 LEFT JOIN items fg_stock_it ON fg_il.itemId = fg_stock_it.id AND fg_stock_it.deleted = 0 LEFT JOIN items fg_item ON fg_item.id = COALESCE(fg_stock_it.id, fg_bom_it.id) - LEFT JOIN item_uom fg_iu ON fg_item.id = fg_iu.itemId AND fg_iu.stockUnit = 1 AND fg_iu.deleted = 0 - LEFT JOIN uom_conversion fg_uc ON fg_iu.uomId = fg_uc.id AND fg_uc.deleted = 0 + LEFT JOIN inventory_lot_line fg_ill ON fg_ill.id = COALESCE( + fg_sil.inventoryLotLineId, + ( + SELECT ill2.id + FROM inventory_lot_line ill2 + WHERE ill2.inventoryLotId = fg_il.id + AND ill2.deleted = 0 + AND ill2.stockItemUomId IS NOT NULL + ORDER BY ill2.id ASC + LIMIT 1 + ) + ) + LEFT JOIN item_uom fg_iu_tx ON fg_iu_tx.id = fg_ill.stockItemUomId AND fg_iu_tx.deleted = 0 + LEFT JOIN uom_conversion fg_uc_tx ON fg_uc_tx.id = fg_iu_tx.uomId AND fg_uc_tx.deleted = 0 + LEFT JOIN item_uom fg_iu_fb ON fg_item.id = fg_iu_fb.itemId AND fg_iu_fb.stockUnit = 1 AND fg_iu_fb.deleted = 0 + LEFT JOIN uom_conversion fg_uc_fb ON fg_iu_fb.uomId = fg_uc_fb.id AND fg_uc_fb.deleted = 0 WHERE sol.deleted = 0 AND sol.inventoryLotLineId IN (:lotLineIds) ORDER BY jobOrderCode, finishedLotNo """.trimIndent() diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt index a3a203a..3c0b18d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphNodeBuilder.kt @@ -1,5 +1,6 @@ package com.ffii.fpsms.modules.stock.trace +import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceJoPickLine import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceJoPrelude import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceMaterialInput import com.ffii.fpsms.modules.stock.web.model.ItemLotTraceQcResult @@ -389,11 +390,42 @@ internal object TraceGraphNodeBuilder { val ctx = PreludeBuildContext() val lotPicks = buildLotPickOrderMap(joPrelude.materialInputs) val chainNodes = buildMaterialInputChainNodes(joPrelude.materialInputs, lotPicks, ctx) - val pickNodes = + val coveredItems = coveredPickItemCodes(joPrelude.materialInputs) + val lotPickNodes = joPrelude.materialInputs.mapIndexed { i, m -> createMaterialPickNode(m, i, ctx) } - return (chainNodes + pickNodes).sortedWith(compareBy { it.sortKey }.thenBy { it.id }) + val linePickNodes = mutableListOf() + joPrelude.pickOrders.forEach { po -> + po.lines.forEachIndexed { li, line -> + val code = line.itemCode.trim().uppercase() + if (code.isEmpty() || !coveredItems.add(code)) return@forEachIndexed + linePickNodes += + createMaterialPickNodeFromPickLine( + line = line, + index = li, + ctx = ctx, + pickOrderCode = po.pickOrderCode, + pickOrderId = po.pickOrderId, + consoCode = po.consoCode, + jobOrderCode = joPrelude.jobOrder.jobOrderCode, + timestamp = po.releasedDate ?: po.targetDate ?: joPrelude.jobOrder.createdAt, + feedsProductionScopeLotNo = null, + ) + } + } + return (chainNodes + lotPickNodes + linePickNodes).sortedWith( + compareBy { it.sortKey }.thenBy { it.id }, + ) + } + + private fun coveredPickItemCodes(materialInputs: List): MutableSet { + val set = mutableSetOf() + materialInputs.forEach { m -> + val code = m.materialItemCode.trim().uppercase() + if (code.isNotEmpty()) set += code + } + return set } private fun buildLotPickOrderMap( @@ -447,13 +479,38 @@ internal object TraceGraphNodeBuilder { if (joProduced && m.nestedJoPrelude != null) { buildNestedJoCreatedNode(m, ctx)?.let { nodes += it } - } - if (joProduced && nestedInputs.isNotEmpty()) { - val nestedLotPicks = buildLotPickOrderMap(nestedInputs) - nodes += buildMaterialInputChainNodes(nestedInputs, nestedLotPicks, ctx) - nestedInputs.forEachIndexed { ni, nm -> - nodes += createMaterialPickNode(nm, ni, ctx, m.materialLotNo) + val nestedPrelude = m.nestedJoPrelude!! + if (nestedInputs.isNotEmpty()) { + val nestedLotPicks = buildLotPickOrderMap(nestedInputs) + nodes += buildMaterialInputChainNodes(nestedInputs, nestedLotPicks, ctx) + nestedInputs.forEachIndexed { ni, nm -> + nodes += createMaterialPickNode(nm, ni, ctx, m.materialLotNo) + } + } + + // Always show nested JO pick-order lines (even with no stock-outs yet). + val nestedCovered = coveredPickItemCodes(nestedInputs) + nestedPrelude.pickOrders.forEach { po -> + po.lines.forEachIndexed { li, line -> + val code = line.itemCode.trim().uppercase() + if (code.isEmpty() || !nestedCovered.add(code)) return@forEachIndexed + nodes += + createMaterialPickNodeFromPickLine( + line = line, + index = li, + ctx = ctx, + pickOrderCode = po.pickOrderCode, + pickOrderId = po.pickOrderId, + consoCode = po.consoCode, + jobOrderCode = nestedPrelude.jobOrder.jobOrderCode, + timestamp = + po.releasedDate + ?: po.targetDate + ?: nestedPrelude.jobOrder.createdAt, + feedsProductionScopeLotNo = m.materialLotNo, + ) + } } } @@ -635,6 +692,30 @@ internal object TraceGraphNodeBuilder { feedsProductionScopeLotNo = feedsProductionScopeLotNo, ) + private fun createMaterialPickNodeFromPickLine( + line: ItemLotTraceJoPickLine, + index: Int, + ctx: PreludeBuildContext, + pickOrderCode: String, + pickOrderId: Long?, + consoCode: String, + jobOrderCode: String, + timestamp: String?, + feedsProductionScopeLotNo: String?, + ): TraceGraphNode = + TraceGraphNode( + id = "mat-pick-line-$index-$pickOrderId-${line.pickOrderLineId}", + kind = "MATERIAL_PICK", + timestamp = timestamp, + sortKey = parseSortKey(timestamp, ctx.seq++), + refCode = pickOrderCode, + refId = pickOrderId, + traceItemCode = line.itemCode, + consoCode = consoCode, + jobOrderCode = jobOrderCode.trim().ifEmpty { null }, + feedsProductionScopeLotNo = feedsProductionScopeLotNo, + ) + private fun isJoProducedMaterial(m: ItemLotTraceMaterialInput): Boolean = m.productionSteps.isNotEmpty() || m.nestedJoPrelude != null || diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt index 06f85a5..2300412 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceGraphSemanticsBuilder.kt @@ -428,14 +428,22 @@ internal object TraceGraphEdgeSemantics { to: TraceGraphLayoutNode, ): Boolean { if (from.kind == "JO_CREATED" && !isMaterialPreludeNode(from)) { - if (to.kind == "MATERIAL_PICK" || to.kind == "PICK_GROUP") return false + if ( + to.kind == "MATERIAL_PICK" || + to.kind == "PICK_GROUP" || + to.kind == "PRODUCTION_STEP" + ) { + return false + } return true } + // Allow pick/JO_CREATED → PRODUCTION_STEP (dedicated builders). if ( to.kind == "PRODUCTION_STEP" && from.kind != "MATERIAL_PICK" && from.kind != "PICK_GROUP" && - from.kind != "PRODUCTION_STEP" + from.kind != "PRODUCTION_STEP" && + from.kind != "JO_CREATED" ) { return true } @@ -574,9 +582,9 @@ internal object TraceGraphEdgeSemantics { val pairs = mutableListOf() val seen = mutableSetOf() - fun add(from: TraceGraphLayoutNode, to: TraceGraphLayoutNode) { + fun add(from: TraceGraphLayoutNode, to: TraceGraphLayoutNode, bypassSkip: Boolean = false) { if (NO_FLOW_EDGE_KINDS.contains(from.kind) || NO_FLOW_EDGE_KINDS.contains(to.kind)) return - if (shouldSkipTraceFlowEdge(from, to)) return + if (!bypassSkip && shouldSkipTraceFlowEdge(from, to)) return val key = "${from.id}->${to.id}" if (!seen.add(key)) return pairs += ItemLotTraceGraphEdge(fromKey = from.id, toKey = to.id) @@ -614,6 +622,8 @@ internal object TraceGraphEdgeSemantics { val toList = nodesInPhase(dayNodes, phasesPresent[i + 1]) if (fromList.isEmpty() || toList.isEmpty()) continue if (isMaterialPreludeNode(fromList.last()) || isMaterialPreludeNode(toList.first())) continue + // Dedicated builder owns JO_CREATED → PRODUCTION_STEP. + if (fromList.last().kind == "JO_CREATED" && toList.first().kind == "PRODUCTION_STEP") continue // STOCK_TAKE now precedes OUTBOUND in phase order; edge flows naturally if (phasesPresent[i] == "PUTAWAY" && phasesPresent[i + 1] == "WAREHOUSE") { connectPutawayToWarehousePhase(fromList, toList, ::add) @@ -627,6 +637,7 @@ internal object TraceGraphEdgeSemantics { buildProductionStepChainEdges(nodes, ::add) buildProductionToFgQcEdges(nodes, ::add) buildJoCreatedToMaterialPickEdges(nodes, ::add) + buildJoCreatedToProductionStepEdges(nodes, ::add) buildReplenishmentToDoOutEdges(nodes, ::add) buildMaterialPickToProductionStepEdges(nodes, ::add) buildProductionStepLossEdges(nodes, ::add) @@ -651,6 +662,9 @@ internal object TraceGraphEdgeSemantics { nodes.forEach { n -> if (!isMaterialPreludeNode(n)) return@forEach + // JO_CREATED sits in MATERIAL_PICK for layout; its early planStart would pull that + // phase before PUTAWAY and break 已上架 → 工單提料. Dedicated builder handles JO→提料. + if (n.kind == "JO_CREATED") return@forEach val key = materialLotKey(n) ?: return@forEach byLot.getOrPut(key) { mutableListOf() } += n } @@ -678,11 +692,14 @@ internal object TraceGraphEdgeSemantics { if (toPhase == "MATERIAL_PICK") { val upstream = fromList.last() val seenPickTargets = mutableSetOf() - toList.forEach { pick -> - val target = resolvePickFlowTarget(pick, nodes) - if (!seenPickTargets.add(target.id)) return@forEach - add(upstream, target) - } + toList + .filter { it.kind == "MATERIAL_PICK" || it.kind == "PICK_GROUP" } + .forEach { pick -> + val target = resolvePickFlowTarget(pick, nodes) + if (seenPickTargets.add(target.id)) { + add(upstream, target) + } + } } else { add(fromList.last(), toList.first()) } @@ -1012,6 +1029,89 @@ internal object TraceGraphEdgeSemantics { } } + /** When a JO has no 提料 that reaches 生產步驟, link 工單建立 → first 生產步驟 in scope. */ + private fun buildJoCreatedToProductionStepEdges( + nodes: List, + add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, + ) { + val joCreatedNodes = nodes.filter { it.kind == "JO_CREATED" } + val prodSteps = nodes.filter { it.kind == "PRODUCTION_STEP" && !isDoGroupChild(it) } + if (joCreatedNodes.isEmpty() || prodSteps.isEmpty()) return + + val pickTargets = + nodes.filter { + !isDoGroupChild(it) && + (it.kind == "PICK_GROUP" || (it.kind == "MATERIAL_PICK" && it.doGroupId.isNullOrBlank())) + } + + val productionKeys = + prodSteps + .mapNotNull { n -> + val bomId = n.bomProcessId ?: return@mapNotNull null + "${productionScopeKey(n)}::$bomId" + } + .toSet() + + fun feedLotForTarget(target: TraceGraphLayoutNode): String = + when (target.kind) { + "PICK_GROUP" -> + nodes + .firstOrNull { + it.doGroupId == target.id && !it.feedsProductionScopeLotNo.isNullOrBlank() + } + ?.feedsProductionScopeLotNo + ?.trim() + .orEmpty() + else -> target.feedsProductionScopeLotNo?.trim().orEmpty() + } + + fun materialPicksForTarget(target: TraceGraphLayoutNode): List = + if (target.kind == "PICK_GROUP") { + nodes.filter { it.doGroupId == target.id && it.kind == "MATERIAL_PICK" } + } else { + listOf(target) + } + + fun joHasPickReachingProduction(jo: TraceGraphLayoutNode): Boolean { + val joCode = jo.refCode?.trim()?.ifEmpty { null } ?: return false + val joLot = jo.traceLotNo?.trim().orEmpty() + val scope = productionScopeKey(jo) + return pickTargets.any { target -> + if (joCodeForPickTarget(target, nodes) != joCode) return@any false + val feedLot = feedLotForTarget(target) + if (joLot.isNotEmpty()) { + if (feedLot != joLot) return@any false + } else if (feedLot.isNotEmpty()) { + return@any false + } + materialPicksForTarget(target).any { pick -> + val bomId = pick.bomProcessId ?: return@any false + productionKeys.contains("$scope::$bomId") + } + } + } + + joCreatedNodes.forEach { jo -> + if (joHasPickReachingProduction(jo)) return@forEach + val scope = productionScopeKey(jo) + val firstStep = + prodSteps + .filter { productionScopeKey(it) == scope } + .sortedWith { a, b -> + val sa = a.bomProcessSeqNo + val sb = b.bomProcessSeqNo + when { + sa != null && sb != null && sa != sb -> sa.compareTo(sb) + sa != null && sb == null -> -1 + sa == null && sb != null -> 1 + else -> TraceGraphLayoutSupport.sortNodesInPhase.compare(a, b) + } + } + .firstOrNull() + if (firstStep != null) add(jo, firstStep) + } + } + private fun connectWarehousePhaseChainEdges( nodes: List, add: (TraceGraphLayoutNode, TraceGraphLayoutNode) -> Unit, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt index 0b62350..b397494 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt @@ -72,7 +72,8 @@ open class TraceInboundLoader( COALESCE(i.name, '') AS itemName, COALESCE(pol.qtyM18, pol.qty, 0) AS orderQty, COALESCE(pa.putAwayQty, 0) AS putAwayQty, - COALESCE(ucm18.udfudesc, uc.udfudesc, '') AS purchaseUnit, + -- Prefer PO-line UOM snapshot (M18 then pol.uomId); avoid live purchaseUnit flag. + COALESCE(ucm18.udfudesc, uc_pol.udfudesc, '') AS purchaseUnit, COALESCE(sp.code, '') AS supplierCode, COALESCE(sp.name, '') AS supplierName, po.orderDate AS orderDate, @@ -82,9 +83,8 @@ open class TraceInboundLoader( INNER JOIN purchase_order po ON pol.purchaseOrderId = po.id AND po.deleted = 0 LEFT JOIN shop sp ON po.supplierId = sp.id AND sp.deleted = 0 LEFT JOIN items i ON pol.itemId = i.id AND i.deleted = 0 - LEFT JOIN item_uom iu ON iu.itemId = pol.itemId AND iu.purchaseUnit = true AND iu.deleted = 0 - LEFT JOIN uom_conversion uc ON uc.id = iu.uomId AND uc.deleted = 0 LEFT JOIN uom_conversion ucm18 ON ucm18.id = pol.uomIdM18 AND ucm18.deleted = 0 + LEFT JOIN uom_conversion uc_pol ON uc_pol.id = pol.uomId AND uc_pol.deleted = 0 LEFT JOIN ( SELECT sil_pa.purchaseOrderLineId, @@ -99,8 +99,11 @@ open class TraceInboundLoader( FROM stock_in_line sil_pa INNER JOIN inventory_lot_line ill ON sil_pa.inventoryLotLineId = ill.id AND ill.deleted = 0 INNER JOIN items it_pa ON sil_pa.itemId = it_pa.id AND it_pa.deleted = 0 - LEFT JOIN item_uom iu_s ON iu_s.itemId = it_pa.id AND iu_s.stockUnit = true AND iu_s.deleted = 0 - LEFT JOIN item_uom iu_p ON iu_p.itemId = it_pa.id AND iu_p.purchaseUnit = true AND iu_p.deleted = 0 + LEFT JOIN purchase_order_line pol_pa ON sil_pa.purchaseOrderLineId = pol_pa.id AND pol_pa.deleted = 0 + LEFT JOIN item_uom iu_s ON iu_s.id = ill.stockItemUomId AND iu_s.deleted = 0 + LEFT JOIN item_uom iu_p ON iu_p.itemId = it_pa.id + AND iu_p.uomId = COALESCE(pol_pa.uomIdM18, pol_pa.uomId) + AND iu_p.deleted = 0 WHERE sil_pa.deleted = 0 AND sil_pa.purchaseOrderLineId IS NOT NULL AND COALESCE(ill.inQty, 0) > 0 @@ -111,7 +114,7 @@ open class TraceInboundLoader( AND sil.purchaseOrderLineId IS NOT NULL GROUP BY pol.id, po.id, po.code, i.code, pol.itemNo, i.name, - pol.qtyM18, pol.qty, pa.putAwayQty, ucm18.udfudesc, uc.udfudesc, + pol.qtyM18, pol.qty, pa.putAwayQty, ucm18.udfudesc, uc_pol.udfudesc, sp.code, sp.name, po.orderDate, pol.status ORDER BY po.orderDate ASC, pol.id ASC """.trimIndent() @@ -125,7 +128,8 @@ open class TraceInboundLoader( COALESCE(i.code, '') AS itemCode, COALESCE(i.name, '') AS itemName, COALESCE(SUM(sil.acceptedQty), 0) AS orderQty, - COALESCE(uc.udfudesc, '') AS purchaseUnit, + -- Header-only PO has no POL UOM; use stock-in lot-line UOM (matches acceptedQty). + COALESCE(MAX(uc_tx.udfudesc), MAX(uc_fb.udfudesc), '') AS purchaseUnit, COALESCE(sp.code, '') AS supplierCode, COALESCE(sp.name, '') AS supplierName, po.orderDate AS orderDate, @@ -134,13 +138,16 @@ open class TraceInboundLoader( INNER JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 LEFT JOIN shop sp ON po.supplierId = sp.id AND sp.deleted = 0 LEFT JOIN items i ON sil.itemId = i.id AND i.deleted = 0 - LEFT JOIN item_uom iu ON iu.itemId = sil.itemId AND iu.purchaseUnit = true AND iu.deleted = 0 - LEFT JOIN uom_conversion uc ON uc.id = iu.uomId AND uc.deleted = 0 + LEFT JOIN inventory_lot_line ill ON sil.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN item_uom iu_tx ON iu_tx.id = ill.stockItemUomId AND iu_tx.deleted = 0 + LEFT JOIN uom_conversion uc_tx ON uc_tx.id = iu_tx.uomId AND uc_tx.deleted = 0 + LEFT JOIN item_uom iu_fb ON iu_fb.itemId = sil.itemId AND iu_fb.stockUnit = 1 AND iu_fb.deleted = 0 + LEFT JOIN uom_conversion uc_fb ON uc_fb.id = iu_fb.uomId AND uc_fb.deleted = 0 WHERE sil.deleted = 0 AND sil.id IN (:stockInLineIds) AND sil.purchaseOrderId IS NOT NULL AND sil.purchaseOrderLineId IS NULL - GROUP BY po.id, po.code, i.code, i.name, uc.udfudesc, sp.code, sp.name, po.orderDate + GROUP BY po.id, po.code, i.code, i.name, sp.code, sp.name, po.orderDate ORDER BY po.orderDate ASC, po.id ASC """.trimIndent() val fromHeader = jdbcDao.queryForList(headerQuery, mapOf("stockInLineIds" to stockInLineIds)).map { row -> diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt index a431b1c..2ee5993 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceJoPreludeLoader.kt @@ -1,4 +1,4 @@ -package com.ffii.fpsms.modules.stock.trace +package com.ffii.fpsms.modules.stock.trace import com.ffii.core.support.JdbcDao import com.ffii.fpsms.modules.stock.web.model.* @@ -159,7 +159,7 @@ open class TraceJoPreludeLoader( mat_ill.id AS pickedInventoryLotLineId, COALESCE(wh_pick.code, '') AS pickedWarehouseCode, COALESCE(sol.qty, 0) AS materialQty, - COALESCE(mat_uc.udfudesc, '') AS materialUom, + COALESCE(mat_uc_tx.udfudesc, mat_uc_fb.udfudesc, '') AS materialUom, bm.qty AS bomQtyPerUnit, COALESCE(po.code, '') AS pickOrderCode, po.id AS pickOrderId, @@ -183,8 +183,11 @@ open class TraceJoPreludeLoader( LEFT JOIN warehouse wh_pick ON mat_ill.warehouseId = wh_pick.id AND wh_pick.deleted = 0 LEFT JOIN stock_in_line mat_sil ON mat_il.stockInLineId = mat_sil.id AND mat_sil.deleted = 0 LEFT JOIN items mat_it ON mat_il.itemId = mat_it.id AND mat_it.deleted = 0 - LEFT JOIN item_uom mat_iu ON mat_it.id = mat_iu.itemId AND mat_iu.stockUnit = 1 AND mat_iu.deleted = 0 - LEFT JOIN uom_conversion mat_uc ON mat_iu.uomId = mat_uc.id AND mat_uc.deleted = 0 + -- UOM frozen on the picked inventory_lot_line (transaction-time), not current master stockUnit. + LEFT JOIN item_uom mat_iu_tx ON mat_iu_tx.id = mat_ill.stockItemUomId AND mat_iu_tx.deleted = 0 + LEFT JOIN uom_conversion mat_uc_tx ON mat_iu_tx.uomId = mat_uc_tx.id AND mat_uc_tx.deleted = 0 + LEFT JOIN item_uom mat_iu_fb ON mat_it.id = mat_iu_fb.itemId AND mat_iu_fb.stockUnit = 1 AND mat_iu_fb.deleted = 0 + LEFT JOIN uom_conversion mat_uc_fb ON mat_iu_fb.uomId = mat_uc_fb.id AND mat_uc_fb.deleted = 0 LEFT JOIN bom b ON jo.bomId = b.id AND b.deleted = 0 LEFT JOIN bom_material bm ON bm.bomId = b.id AND bm.itemId = mat_it.id AND bm.deleted = 0 LEFT JOIN jo_pick_order jpo ON jpo.pick_order_id = po.id diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt index 576b188..775e45c 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceLotResolver.kt @@ -130,12 +130,24 @@ open class TraceLotResolver( ORDER BY sil.receiptDate DESC, sil.id DESC LIMIT 1) AS productionDate, il.stockInDate AS stockInDate, - COALESCE(uc.udfudesc, '') AS uom, + -- Prefer UOM frozen on inventory_lot_line at stock-in time (survives master sync). + COALESCE(uc_tx.udfudesc, uc_fb.udfudesc, '') AS uom, il.stockInLineId AS primaryStockInLineId FROM inventory_lot il INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0 - LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = 1 - LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + LEFT JOIN inventory_lot_line ill_uom ON ill_uom.id = ( + SELECT ill2.id + FROM inventory_lot_line ill2 + WHERE ill2.inventoryLotId = il.id + AND ill2.deleted = 0 + AND ill2.stockItemUomId IS NOT NULL + ORDER BY ill2.id ASC + LIMIT 1 + ) + LEFT JOIN item_uom iu_tx ON iu_tx.id = ill_uom.stockItemUomId AND iu_tx.deleted = 0 + LEFT JOIN uom_conversion uc_tx ON uc_tx.id = iu_tx.uomId AND uc_tx.deleted = 0 + LEFT JOIN item_uom iu_fb ON it.id = iu_fb.itemId AND iu_fb.stockUnit = 1 AND iu_fb.deleted = 0 + LEFT JOIN uom_conversion uc_fb ON iu_fb.uomId = uc_fb.id AND uc_fb.deleted = 0 WHERE il.id = :inventoryLotId AND il.deleted = 0 """.trimIndent() val row = jdbcDao.queryForList(query, mapOf("inventoryLotId" to inventoryLotId)).firstOrNull() @@ -258,12 +270,23 @@ open class TraceLotResolver( ORDER BY sil.receiptDate DESC, sil.id DESC LIMIT 1) AS productionDate, il.stockInDate AS stockInDate, - COALESCE(uc.udfudesc, '') AS uom, + COALESCE(uc_tx.udfudesc, uc_fb.udfudesc, '') AS uom, il.stockInLineId AS primaryStockInLineId FROM inventory_lot il INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0 - LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = 1 - LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + LEFT JOIN inventory_lot_line ill_uom ON ill_uom.id = ( + SELECT ill2.id + FROM inventory_lot_line ill2 + WHERE ill2.inventoryLotId = il.id + AND ill2.deleted = 0 + AND ill2.stockItemUomId IS NOT NULL + ORDER BY ill2.id ASC + LIMIT 1 + ) + LEFT JOIN item_uom iu_tx ON iu_tx.id = ill_uom.stockItemUomId AND iu_tx.deleted = 0 + LEFT JOIN uom_conversion uc_tx ON uc_tx.id = iu_tx.uomId AND uc_tx.deleted = 0 + LEFT JOIN item_uom iu_fb ON it.id = iu_fb.itemId AND iu_fb.stockUnit = 1 AND iu_fb.deleted = 0 + LEFT JOIN uom_conversion uc_fb ON iu_fb.uomId = uc_fb.id AND uc_fb.deleted = 0 WHERE il.id IN (:inventoryLotIds) AND il.deleted = 0 """.trimIndent() return jdbcDao.queryForList(query, mapOf("inventoryLotIds" to inventoryLotIds)) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt index 7117e9d..e0bff91 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceProductionLoader.kt @@ -70,7 +70,7 @@ open class TraceProductionLoader( COALESCE(bp_it.code, '') AS itemCode, COALESCE(bp_it.name, '') AS itemName, COALESCE(sil.acceptedQty, 0) AS qty, - COALESCE(uc.udfudesc, '') AS uom, + COALESCE(uc_tx.udfudesc, uc_fb.udfudesc, '') AS uom, COALESCE(ppl.name, '') AS processStepName, COALESCE(sil.created, sil.modified, sil.receiptDate) AS producedAt, COALESCE(jo.code, '') AS jobOrderCode, @@ -79,8 +79,22 @@ open class TraceProductionLoader( INNER JOIN stock_in_line sil ON sil.jobOrderId = jo.id AND sil.deleted = 0 INNER JOIN inventory_lot il ON sil.inventoryLotId = il.id AND il.deleted = 0 INNER JOIN items bp_it ON il.itemId = bp_it.id AND bp_it.deleted = 0 - LEFT JOIN item_uom iu ON bp_it.id = iu.itemId AND iu.stockUnit = 1 - LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + LEFT JOIN inventory_lot_line bp_ill ON bp_ill.id = COALESCE( + sil.inventoryLotLineId, + ( + SELECT ill2.id + FROM inventory_lot_line ill2 + WHERE ill2.inventoryLotId = il.id + AND ill2.deleted = 0 + AND ill2.stockItemUomId IS NOT NULL + ORDER BY ill2.id ASC + LIMIT 1 + ) + ) + LEFT JOIN item_uom iu_tx ON iu_tx.id = bp_ill.stockItemUomId AND iu_tx.deleted = 0 + LEFT JOIN uom_conversion uc_tx ON uc_tx.id = iu_tx.uomId AND uc_tx.deleted = 0 + LEFT JOIN item_uom iu_fb ON bp_it.id = iu_fb.itemId AND iu_fb.stockUnit = 1 AND iu_fb.deleted = 0 + LEFT JOIN uom_conversion uc_fb ON iu_fb.uomId = uc_fb.id AND uc_fb.deleted = 0 LEFT JOIN productprocess pp ON pp.jobOrderId = jo.id AND pp.deleted = 0 LEFT JOIN productprocessline ppl ON ppl.productprocessid = pp.id AND ppl.deleted = 0 From e70712049619d03ef744ddceb3381bffbb7bd945 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Sun, 9 Aug 2026 22:11:04 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E5=8C=85=E8=A3=9D=20Complete/Pass=20?= =?UTF-8?q?=E2=86=92=20=E5=90=8C=20JO=20=E5=85=B6=E4=BB=96=E5=8C=85?= =?UTF-8?q?=E8=A3=9D=20auto=20Pass=20QC=20ready=20=E6=94=B9=E6=88=90?= =?UTF-8?q?=E3=80=8C=E5=85=A8=E9=83=A8=20line=20done=E3=80=8D=EF=BC=88?= =?UTF-8?q?=E6=8B=BF=E6=8E=89=E3=80=8C=E4=BB=BB=E4=B8=80=E5=8C=85=E8=A3=9D?= =?UTF-8?q?=E5=8D=B3=E5=8F=AF=E3=80=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ProductProcessService.kt | 71 ++++++++++++++++--- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index 0a35199..51f0ac8 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -85,6 +85,10 @@ open class ProductProcessService( private val stockInLineRepository: StockInLineRepository, private val qcResultRepository: QcResultRepository, ) { + companion object { + private const val PACKAGING_PROCESS_NAME = "包裝" + private const val DRINK_PLANNED_LOOKBACK_DAYS = 30L + } open fun findAll(pageable: Pageable): Page { println(" Service: Finding all ProductProcess with page: ${pageable.pageNumber}, size: ${pageable.pageSize}") @@ -1246,6 +1250,7 @@ open class ProductProcessService( ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */ open fun updateProductProcessLineStatus(productProcessLineId: Long, status: String): MessageResponse { println(" Service: Updating ProductProcessLine Status: $productProcessLineId") val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) @@ -1254,6 +1259,12 @@ open class ProductProcessService( // productProcessLine.endTime = LocalDateTime.now() productProcessLineRepository.save(productProcessLine) println(" Service: ProductProcessLine Status Updated: ${productProcessLine.status}") + + // One packaging Complete/Pass → auto-Pass remaining packaging lines on the same JO. + if (isLineDone(status) && isPackagingLine(productProcessLine)) { + autoPassSiblingPackagingLines(productProcessLineId) + } + syncProductProcessStatusFromLines(productProcessLine.productProcess?.id ?: 0L) return MessageResponse( @@ -1500,8 +1511,9 @@ open class ProductProcessService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.2 | 2026-08-09 * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。 + * (包裝 Complete/Pass 時會自動 Pass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。) * * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 */ @@ -1682,14 +1694,11 @@ open class ProductProcessService( } + // After packaging Complete/Pass auto-passes sibling 包裝 lines, qcReady is simply all lines done. val jobLines = linesByJobOrder[jobOrderId].orEmpty() - val packing = jobLines.filter { (it.name ?: "").trim() == "包裝" } - val nonPacking = jobLines.filter { (it.name ?: "").trim() != "包裝" } - - val nonPackingOk = nonPacking.isEmpty() || nonPacking.all { done(it.status) } - val packingOk = packing.isEmpty() || packing.any { done(it.status) } + val allLinesDone = jobLines.isNotEmpty() && jobLines.all { done(it.status) } - val ready = includedInList && stockInLine != null && nonPackingOk && packingOk + val ready = includedInList && stockInLine != null && allLinesDone JobOrderQcKey( jobOrderId = jobOrderId, @@ -2119,6 +2128,51 @@ open class ProductProcessService( return n == "inprogress" || n == "paused" } + /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */ + private fun isPackagingLine(line: ProductProcessLine): Boolean = + (line.name ?: "").trim() == PACKAGING_PROCESS_NAME + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 + * When one 包裝 line is Completed/Pass, auto-Pass other unfinished 包裝 lines on the same job order, + * then sync each affected ProductProcess.status (Pass counts as done → COMPLETED). + */ + private fun autoPassSiblingPackagingLines(triggerLineId: Long) { + val trigger = productProcessLineRepository.findById(triggerLineId).orElse(null) ?: return + if (!isPackagingLine(trigger)) return + + val jobOrderId = trigger.productProcess?.jobOrder?.id ?: return + val processes = productProcessRepository.findByJobOrder_Id(jobOrderId) + .filter { it.deleted != true } + val processIds = processes.mapNotNull { it.id } + if (processIds.isEmpty()) return + + val allLines = productProcessLineRepository.findByProductProcess_IdIn(processIds) + val siblings = allLines.filter { line -> + line.id != null && + line.id != triggerLineId && + isPackagingLine(line) && + !isLineDone(line.status) + } + if (siblings.isEmpty()) return + + val now = LocalDateTime.now() + val affectedProcessIds = linkedSetOf() + for (line in siblings) { + if (line.startTime == null) { + line.startTime = now + } + line.endTime = now + line.status = "Pass" + productProcessLineRepository.save(line) + line.productProcess?.id?.let { affectedProcessIds.add(it) } + } + + for (processId in affectedProcessIds) { + syncProductProcessStatusFromLines(processId) + } + } + /** * Align parent [ProductProcess.status] with all line states. * - All Completed/Pass -> completed (via [ifAllLinesCompletedOrPassed]) @@ -2631,9 +2685,6 @@ open class ProductProcessService( } // ===== Drink Production Qty Dashboard ===== - private companion object { - const val DRINK_PLANNED_LOOKBACK_DAYS = 30L - } /** FP-MTMS Version Checklist | Functions Ref. No. 27 | v1.0.0 | 2026-07-20 */ open fun getDrinkProductionQty(