From 781caff39fee100b215bd66bf5212d413a7e3df5 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 10 Aug 2026 23:29:43 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E5=93=A1=E5=B7=A5=E7=99=BC=E8=B2=A8?= =?UTF-8?q?=E7=B8=BE=E6=95=88=E5=9C=96=E8=A1=A8=202.=20=20=E6=96=B0?= =?UTF-8?q?=E5=A0=B1=E8=A1=A8=EF=BC=9A=E9=80=81=E8=B2=A8=E8=A8=82=E5=96=AE?= =?UTF-8?q?=E8=88=87=E5=80=89=E5=AD=98=E5=96=AE=E4=BD=8D=E4=B8=8D=E7=AC=A6?= =?UTF-8?q?=203=20=E5=BA=97=E9=8B=AA=E8=A3=9C=E8=B2=A8=E5=A0=B1=E8=A1=A8?= =?UTF-8?q?=20=E5=8A=A0=E3=80=8C=E5=8E=9F=E5=96=AE=E6=8F=90=E6=96=99?= =?UTF-8?q?=E4=BA=BA=E3=80=8D=E3=80=8C=E5=AF=A6=E9=9A=9B=E8=A3=9C=E8=B2=A8?= =?UTF-8?q?=E6=8F=90=E6=96=99=E4=BA=BA=E3=80=8D=EF=BC=9BExcel=20=E6=AC=84?= =?UTF-8?q?=E4=BD=8D=E6=94=B9=E7=B4=94=E4=B8=AD=E6=96=87=E8=A1=A8=E9=A0=AD?= =?UTF-8?q?=204=20=20=E5=8C=85=E8=A3=9D=20autoPass=205.=20JO=20=E6=8F=90?= =?UTF-8?q?=E6=96=99=20re-suggest=20JO=20=E9=A1=9E=E5=9E=8B=20storeId=20?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=20null=EF=BC=88=E9=81=BF=E5=85=8D=E8=A2=AB?= =?UTF-8?q?=E9=A0=90=E8=A8=AD=E6=88=90=202/F=20=E6=BF=BE=E6=8E=89=203F?= =?UTF-8?q?=EF=BC=89=20=20=E5=89=8D=E7=AB=AF=E5=B0=8D=E9=BD=8A=20DO?= =?UTF-8?q?=EF=BC=9A=E7=94=A8=E5=BE=8C=E7=AB=AF=20stockout=20qty=E3=80=81?= =?UTF-8?q?=E5=90=8C=20POL=20=E4=BE=9D=20status=20=E6=8E=92=E5=BA=8F=206.?= =?UTF-8?q?=20=E4=BD=BF=E7=94=A8=E8=80=85=E5=BB=BA=E7=AB=8B=EF=BC=8F?= =?UTF-8?q?=E9=A9=97=E8=AD=89=207.=20=20BOM=20=E8=A8=AD=E5=82=99=208?= =?UTF-8?q?=E3=80=8C=E4=B8=8D=E5=90=88=E7=94=A8=EF=BC=8F=E4=B8=8D=E9=81=A9?= =?UTF-8?q?=E7=94=A8=E3=80=8D=E7=B5=B1=E4=B8=80=E4=B8=8D=E6=8E=9B=20equipm?= =?UTF-8?q?ent=20data=209=20hilde=20some=20useless=20console=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/chart/service/ChartService.kt | 79 ++++++++--- .../modules/chart/web/ChartController.kt | 8 +- .../ffii/fpsms/modules/common/ErrorCodes.java | 2 + .../service/DoWorkbenchMainService.kt | 13 +- .../service/JoWorkbenchPickConstants.kt | 1 + .../modules/master/service/BomService.kt | 12 +- .../entity/ProductProcessLineRepository.kt | 3 +- .../service/ProductProcessService.kt | 62 ++++----- .../web/ProductProcessController.kt | 2 +- .../web/model/SaveProductProcessRequest.kt | 2 +- .../DoInventoryUomMismatchReportService.kt | 128 ++++++++++++++++++ .../ShopOrderReplenishmentReportService.kt | 32 ++++- .../DoInventoryUomMismatchReportController.kt | 116 ++++++++++++++++ .../modules/user/entity/UserRepository.java | 4 + .../modules/user/service/UserService.java | 23 ++++ 15 files changed, 413 insertions(+), 74 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt diff --git a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt index 3f1c503..faaf4f0 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt @@ -717,9 +717,13 @@ open class ChartService( } /** - * Staff delivery performance: daily pick ticket count and total time per staff. + * FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 + * Staff delivery performance: daily pick ticket count, duration, item kinds, and actual picked qty. * Uses delivery_order_pick_order (handler = handledBy); time = sum of * (ticketCompleteDateTime - ticketReleaseTime) per completed ticket. + * itemKindCount = sum of per-ticket COUNT(DISTINCT pol.itemId); + * itemQtyPicked = sum of stock_out_line.qty via pick_order_line (actual picked, not pol.qty). + * Scoped CTE first (date/store/staff) then lineAgg — avoids full-history pick/sol scan. * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select). * storeIdNull: when true, only rows with dop.storeId IS NULL (takes precedence over storeId). * storeId: when non-blank and storeIdNull is not true, filter dop.storeId equality (trimmed). @@ -742,12 +746,13 @@ open class ChartService( args["endExclusive"] = endDate.plusDays(1).atStartOfDay() "AND dop.ticketCompleteDateTime < :endExclusive" } else "" - val staffSql = if (!staffNos.isNullOrEmpty()) { - val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } - if (nos.isEmpty()) "" else { - args["staffNos"] = nos - "AND u.staffNo IN (:staffNos)" - } + val staffNosFiltered = staffNos?.map { it.trim() }?.filter { it.isNotBlank() }.orEmpty() + val staffSql = if (staffNosFiltered.isNotEmpty()) { + args["staffNos"] = staffNosFiltered + "AND u_scope.staffNo IN (:staffNos)" + } else "" + val scopeUserJoin = if (staffSql.isNotEmpty()) { + "LEFT JOIN user u_scope ON dop.handledBy = u_scope.id AND u_scope.deleted = 0" } else "" val storeSql = when { storeIdNull == true -> "AND dop.storeId IS NULL" @@ -758,31 +763,61 @@ open class ChartService( else -> "" } val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank() - val fromClause = if (useStoreFilter) { + val dopFromClause = if (useStoreFilter) { "FROM delivery_order_pick_order dop" } else { "FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)" } val sql = """ + WITH dop_scoped AS ( + SELECT + dop.id, + dop.ticketCompleteDateTime, + dop.ticketReleaseTime, + dop.handledBy, + dop.handlerName + $dopFromClause + $scopeUserJoin + WHERE dop.deleted = 0 + AND dop.ticketStatus = 'completed' + AND dop.ticketCompleteDateTime IS NOT NULL + $startSql $endSql $storeSql $staffSql + ), + lineAgg AS ( + SELECT + po.deliveryOrderPickOrderId AS dopId, + COUNT(DISTINCT pol.itemId) AS itemKindCount, + COALESCE(SUM(sol.qty), 0) AS itemQtyPicked + FROM dop_scoped d + INNER JOIN pick_order po + ON po.deliveryOrderPickOrderId = d.id + AND po.deleted = 0 + INNER JOIN pick_order_line pol + ON pol.poId = po.id + AND pol.deleted = 0 + LEFT JOIN stock_out_line sol FORCE INDEX (idx_sol_polid_deleted_status_qty) + ON sol.pickOrderLineId = pol.id + AND sol.deleted = 0 + GROUP BY po.deliveryOrderPickOrderId + ) SELECT - DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d') AS date, - COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), dop.handlerName, 'Unknown') AS staffName, - COUNT(dop.id) AS orderCount, + DATE_FORMAT(d.ticketCompleteDateTime, '%Y-%m-%d') AS date, + COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), d.handlerName, 'Unknown') AS staffName, + COUNT(d.id) AS orderCount, COALESCE(SUM( CASE - WHEN dop.ticketReleaseTime IS NOT NULL AND dop.ticketCompleteDateTime IS NOT NULL - THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dop.ticketReleaseTime, dop.ticketCompleteDateTime)) + WHEN d.ticketReleaseTime IS NOT NULL AND d.ticketCompleteDateTime IS NOT NULL + THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, d.ticketReleaseTime, d.ticketCompleteDateTime)) ELSE 0 END - ), 0) AS totalMinutes - $fromClause - LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0 - WHERE dop.deleted = 0 - AND dop.ticketStatus = 'completed' - AND dop.ticketCompleteDateTime IS NOT NULL - $startSql $endSql $staffSql $storeSql - GROUP BY DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d'), - dop.handledBy, u.name, dop.handlerName + ), 0) AS totalMinutes, + COALESCE(SUM(la.itemKindCount), 0) AS itemKindCount, + COALESCE(SUM(la.itemQtyPicked), 0) AS itemQtyPicked + FROM dop_scoped d + LEFT JOIN user u ON d.handledBy = u.id AND u.deleted = 0 + LEFT JOIN lineAgg la ON la.dopId = d.id + GROUP BY DATE_FORMAT(d.ticketCompleteDateTime, '%Y-%m-%d'), + d.handledBy, u.name, d.handlerName ORDER BY date, orderCount DESC """.trimIndent() return jdbcDao.queryForList(sql, args) diff --git a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt index 7d568ec..f520e77 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt @@ -194,9 +194,13 @@ class ChartController( chartService.getStaffDeliveryPerformanceHandlers() /** + * FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002&storeId=2/F&storeIdNull=true - * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from delivery_order_pick_order - * (handledBy), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). + * Returns [{ date, staffName, orderCount, totalMinutes, itemKindCount, itemQtyPicked }]. + * Data from delivery_order_pick_order (handledBy); orderCount = completed pick tickets; + * totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime); + * itemKindCount = sum of per-ticket COUNT(DISTINCT pick_order_line.itemId); + * itemQtyPicked = sum of stock_out_line.qty via pick_order_line. * Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId). */ @GetMapping("/staff-delivery-performance") diff --git a/src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java b/src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java index 7da8d24..612aa4e 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java +++ b/src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java @@ -10,6 +10,8 @@ public class ErrorCodes { public static final String SEND_EMAIL_ERROR = "SEND_EMAIL_ERROR"; public static final String USERNAME_NOT_AVAILABLE = "USERNAME_NOT_AVAILABLE"; + public static final String NAME_NOT_AVAILABLE = "NAME_NOT_AVAILABLE"; + public static final String STAFF_NO_NOT_AVAILABLE = "STAFF_NO_NOT_AVAILABLE"; public static final String INIT_EXCEL_ERROR = "INIT_EXCEL_ERROR"; diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index 5c2bc1e..ae9d47c 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -267,7 +267,7 @@ open class DoWorkbenchMainService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.1 | 2026-08-10 * Workbench scan-pick (DO FG): * 1) Post outbound on scanned inventory lot line first; on failure return a clear message. * 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty], @@ -2386,10 +2386,15 @@ return MessageResponse( var postMs = 0L try { if (pickOrderId != null) { - val suggestionStoreId = resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) + // JO assign primes with storeId=null; re-suggest must match or 3F lots are + // filtered out after resolveWorkbenchSuggestionStoreId defaults to "2/F". + val resolvedPoType = pickOrderRepository.findById(pickOrderId).orElse(null)?.type + val suggestionStoreId = + if (resolvedPoType == PickOrderType.JOB_ORDER) null + else resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId) val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( pickOrderId = pickOrderId, - poType = null, + poType = resolvedPoType, userId = userId, requestExcludeWarehouseCodes = effectiveExcludeWarehouseCodes, ) @@ -2620,7 +2625,7 @@ return MessageResponse( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.1 | 2026-08-10 * DO user-audit: Just Complete is reportable only when the item still has pickable stock * at complete time (AVAILABLE, not expired, in−out > 0). * diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt index 76480c9..727c54a 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt @@ -5,6 +5,7 @@ package com.ffii.fpsms.modules.jobOrder.service * * [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService]) * and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]). + * JO re-suggest keeps [storeId] null (same as assign); it does not use DO floor store resolution. */ object JoWorkbenchPickConstants { val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set = setOf( diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index 41ef63f..c0da6bd 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -1430,7 +1430,8 @@ open class BomService( } 3 -> { val equipmentName = tempCell.stringCellValue.trim() - if (equipmentName != "不適用") { + // 不合用 / 不適用:不掛 equipment(與格式檢查同等處理) + if (!isNotApplicableEquipment(equipmentName)) { val equipment = bomGetOrCreateEquipment(equipmentName) // println("equipment created") bomProcessRequest.equipment = equipment @@ -1977,10 +1978,17 @@ open class BomService( issueLogFileId = issueLogId ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 63 | v1.0.0 | 2026-08-10 */ + /** BOM 工序「使用設備」為不合用/不適用時,不掛 equipment FK */ + private fun isNotApplicableEquipment(value: String): Boolean { + val trimmed = value.trim() + return trimmed == "不合用" || trimmed == "不適用" + } + private fun isValidEquipmentType(value: String): Boolean { val trimmed = value.trim() if (trimmed.isEmpty()) return false - if (trimmed == "不合用" || trimmed == "不適用") return true + if (isNotApplicableEquipment(trimmed)) return true if (trimmed.contains(",")) return false // 新增:不允許逗號 val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機 return regex.matches(trimmed) diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt index d121b4b..db23083 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt @@ -29,12 +29,13 @@ interface ProductProcessLineRepository : JpaRepository """) fun findByProductProcess_IdInWithOperatorAndEquipment(@Param("ids") ids: List): List + /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 */ @Query( """ SELECT p.jobOrder.id AS jobOrderId, COUNT(l.id) AS totalLines, - SUM(CASE WHEN l.status IN ('Completed', 'Pass') THEN 1 ELSE 0 END) AS doneLines + SUM(CASE WHEN l.status IN ('Completed', 'Pass', 'autoPass') THEN 1 ELSE 0 END) AS doneLines FROM ProductProcessLine l JOIN l.productProcess p WHERE l.deleted = false 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 51f0ac8..72e7aca 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 @@ -1250,7 +1250,7 @@ open class ProductProcessService( ) } - /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 */ open fun updateProductProcessLineStatus(productProcessLineId: Long, status: String): MessageResponse { println(" Service: Updating ProductProcessLine Status: $productProcessLineId") val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) @@ -1260,8 +1260,8 @@ open class ProductProcessService( 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)) { + // One packaging Complete/Pass → autoPass remaining packaging lines on the same JO. + if (isManualLineDone(status) && isPackagingLine(productProcessLine)) { autoPassSiblingPackagingLines(productProcessLineId) } @@ -1319,7 +1319,7 @@ open class ProductProcessService( val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) println(" Service: ProductProcessLines: $productProcessLines") - if (productProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { + if (productProcessLines.all { isLineDone(it.status) }) { productProcess.status = ProductProcessStatus.COMPLETED if (productProcess.endTime == null) { productProcess.endTime = LocalDateTime.now() @@ -1511,9 +1511,9 @@ open class ProductProcessService( } /** - * 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,因此不再需要「任一包裝即可」特例。) + * FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 + * QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass/autoPass 决定。 + * (包裝 Complete/Pass 時會自動 autoPass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。) * * 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。 */ @@ -1663,10 +1663,6 @@ open class ProductProcessService( val linesByJobOrder = candidateLines.groupBy { it.productProcess.jobOrder?.id ?: 0L } - val done = { s: String? -> - val x = s?.trim()?.lowercase() - x == "completed" || x == "pass" - } val qcKeys = candidateAggregates.mapNotNull { agg -> val jobOrderId = agg.jobOrderId val stockInLine = stockInLineByJobOrderId[jobOrderId] @@ -1688,15 +1684,10 @@ open class ProductProcessService( val lineAggregate = lineStatusByJobOrderId[jobOrderId] val totalLines = lineAggregate?.totalLines ?: 0L - val done = { s: String? -> - val x = s?.trim()?.lowercase() - x == "completed" || x == "pass" - } - - - // After packaging Complete/Pass auto-passes sibling 包裝 lines, qcReady is simply all lines done. + + // After packaging Complete/Pass autoPasses sibling 包裝 lines, qcReady is simply all lines done. val jobLines = linesByJobOrder[jobOrderId].orEmpty() - val allLinesDone = jobLines.isNotEmpty() && jobLines.all { done(it.status) } + val allLinesDone = jobLines.isNotEmpty() && jobLines.all { isLineDone(it.status) } val ready = includedInList && stockInLine != null && allLinesDone @@ -1886,10 +1877,7 @@ open class ProductProcessService( val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList() val productProcessLines = linesByProcessId[productProcess.id ?: 0L].orEmpty() - val finishedCount = productProcessLines.count { - val s = it.status?.trim()?.lowercase() - s == "completed" || s == "pass" - } + val finishedCount = productProcessLines.count { isLineDone(it.status) } val bomIsDrink = productProcess.bom?.isDrink val matchStatus = if (joPickOrdersList.isNotEmpty() && @@ -1963,7 +1951,7 @@ open class ProductProcessService( } /** - * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass, stock-in exists and not completed/rejected; + * FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass/autoPass, stock-in exists and not completed/rejected; * only job orders that have a product process dated **today or yesterday** (server local date). */ open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse { @@ -2017,7 +2005,7 @@ open class ProductProcessService( val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } val allLinesDone = jobOrderLines.isNotEmpty() && - jobOrderLines.all { it.status == "Completed" || it.status == "Pass" } + jobOrderLines.all { isLineDone(it.status) } if (!allLinesDone) continue val maxDate = processes.mapNotNull { it.date }.maxOrNull() @@ -2119,6 +2107,12 @@ open class ProductProcessService( status?.trim()?.lowercase()?.replace(" ", "") ?: "" private fun isLineDone(status: String?): Boolean { + val n = normalizeLineStatus(status) + return n == "completed" || n == "pass" || n == "autopass" + } + + /** Manual Complete/Pass only — does not include autoPass (avoids cascade loops). */ + private fun isManualLineDone(status: String?): Boolean { val n = normalizeLineStatus(status) return n == "completed" || n == "pass" } @@ -2128,14 +2122,14 @@ open class ProductProcessService( return n == "inprogress" || n == "paused" } - /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 */ 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). + * FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 + * When one 包裝 line is Completed/Pass, set other unfinished 包裝 lines on the same job order to autoPass, + * then sync each affected ProductProcess.status (autoPass counts as done → COMPLETED). */ private fun autoPassSiblingPackagingLines(triggerLineId: Long) { val trigger = productProcessLineRepository.findById(triggerLineId).orElse(null) ?: return @@ -2163,7 +2157,7 @@ open class ProductProcessService( line.startTime = now } line.endTime = now - line.status = "Pass" + line.status = "autoPass" productProcessLineRepository.save(line) line.productProcess?.id?.let { affectedProcessIds.add(it) } } @@ -2175,7 +2169,7 @@ open class ProductProcessService( /** * Align parent [ProductProcess.status] with all line states. - * - All Completed/Pass -> completed (via [ifAllLinesCompletedOrPassed]) + * - All Completed/Pass/autoPass -> completed (via [ifAllLinesCompletedOrPassed]) * - Any line started but not all done -> in_progress * - Does not override STOPPED or CANCELLED on the parent. */ @@ -2255,8 +2249,8 @@ open class ProductProcessService( // 获取所有 product process lines val allproductProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) - // 检查是否所有 lines 都是 "Completed" 或 "Pass" - if (allproductProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { + // 检查是否所有 lines 都是 Completed / Pass / autoPass + if (allproductProcessLines.all { isLineDone(it.status) }) { // 更新 product process 的 endTime 和状态 updateProductProcessEndTime(productProcessId) updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED) @@ -3097,7 +3091,7 @@ open class ProductProcessService( } val currentProcesses = operatorLines - .filter { it.endTime == null || (it.status != null && it.status != "Completed" && it.status != "Pass") } + .filter { it.endTime == null || (it.status != null && !isLineDone(it.status)) } .map { line -> val productProcess = line.productProcess val jobOrder = productProcess.jobOrder 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 c081000..748c661 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.1 | 2026-08-06 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 */ @GetMapping("/Demo/Process/search") fun demoprocesssearch( @RequestParam(required = false) date: String?, 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 68a8a82..ab9e59e 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 @@ -218,7 +218,7 @@ data class JobOrderProductProcessPageResponse( ) /** - * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass, stock-in not completed/rejected; + * Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass/autoPass, stock-in not completed/rejected; * job order has at least one process dated today or yesterday. [qc] = before received; [putAway] = received / partially_completed. */ data class JobOrderFgAlertRowResponse( diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt new file mode 100644 index 0000000..a5fcde2 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt @@ -0,0 +1,128 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.format.DateTimeParseException + +@Service +class DoInventoryUomMismatchReportService( + private val jdbcDao: JdbcDao, + private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, +) { + /** + * FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.0 | 2026-08-10 + * Pending DO lines by estimatedArrivalDate whose current inventory lot UOM + * does not match the DO line UOM. Excel-only report. + * Supplier scope follows DoSearch floor lists (2F / 4F / All = both). + */ + fun searchDoInventoryUomMismatch( + deliveryDate: String?, + storeId: String? = null, + ): List> { + val raw = deliveryDate?.trim()?.replace("/", "-").orEmpty() + if (raw.isBlank()) { + throw IllegalArgumentException("deliveryDate is required") + } + + val date = try { + LocalDate.parse(raw) + } catch (_: DateTimeParseException) { + throw IllegalArgumentException("deliveryDate must be yyyy-MM-dd") + } + + val today = LocalDate.now() + if (date.isBefore(today)) { + throw IllegalArgumentException("deliveryDate must not be before today") + } + + val floor = storeId?.trim().orEmpty().let { f -> + when { + f.isEmpty() || f.equals("All", ignoreCase = true) -> "ALL" + else -> f + } + } + val allowedSupplierCodes = doFloorSupplierSettingsService.allowedSupplierCodesForFloor(floor) + if (allowedSupplierCodes.isEmpty()) { + return emptyList() + } + + val args = mutableMapOf( + "deliveryDate" to date.toString(), + "allowedSupplierCodes" to allowedSupplierCodes, + ) + + // Drive from pending DO by estimatedArrivalDate (not orderDate / pick requiredDeliveryDate). + // STRAIGHT_JOIN keeps DO CTE as driver (avoids full inventory_lot scan). + // Supplier filter matches DoSearch: d.supplier.code IN floor settings (2F/4F/All). + val sql = """ + WITH do_lines AS ( + SELECT DISTINCT + do.code AS doCode, + DATE(do.estimatedArrivalDate) AS deliveryDate, + s.code AS supplierCode, + s.name AS supplierName, + dol.id AS doLineId, + dol.itemId AS itemId, + dol.itemNo AS itemNo, + dol.qty AS doQty, + dol.uomId AS doUomId, + uc_do.udfudesc AS doUom + FROM delivery_order do + INNER JOIN shop s + ON s.id = do.supplierId + AND s.deleted = 0 + AND s.code IN (:allowedSupplierCodes) + INNER JOIN delivery_order_line dol + ON dol.deliveryOrderId = do.id + AND dol.deleted = 0 + LEFT JOIN uom_conversion uc_do + ON uc_do.id = dol.uomId + AND uc_do.deleted = 0 + WHERE do.deleted = 0 + AND do.status = 'pending' + AND do.supplierId IS NOT NULL + AND do.estimatedArrivalDate IS NOT NULL + AND DATE(do.estimatedArrivalDate) = :deliveryDate + ) + SELECT + DATE_FORMAT(d.deliveryDate, '%Y-%m-%d') AS deliveryDate, + d.doCode AS doCode, + d.supplierCode AS supplierCode, + d.supplierName AS supplierName, + d.itemNo AS itemCode, + it.name AS itemName, + d.doQty AS doQty, + d.doUom AS doUom, + d.doUomId AS doUomId, + il.lotNo AS mismatchLotNo, + (IFNULL(ill.inQty, 0) - IFNULL(ill.outQty, 0)) AS mismatchLotQty, + uc_ill.udfudesc AS mismatchLotUom, + iu.uomId AS inventoryUomId + FROM do_lines d + STRAIGHT_JOIN inventory_lot il + ON il.itemId = d.itemId + AND il.deleted = 0 + STRAIGHT_JOIN inventory_lot_line ill + ON ill.inventoryLotId = il.id + AND ill.deleted = 0 + AND (IFNULL(ill.inQty, 0) - IFNULL(ill.outQty, 0)) > 0 + STRAIGHT_JOIN item_uom iu + ON iu.id = ill.stockItemUomId + AND iu.deleted = 0 + LEFT JOIN uom_conversion uc_ill + ON uc_ill.id = iu.uomId + AND uc_ill.deleted = 0 + LEFT JOIN items it + ON it.id = d.itemId + AND it.deleted = 0 + WHERE d.doUomId IS NULL + OR iu.uomId IS NULL + OR d.doUomId <> iu.uomId + ORDER BY d.doCode, d.itemNo, il.lotNo + """.trimIndent() + + return jdbcDao.queryForList(sql, args) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt index 7319118..a66e7f5 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt @@ -15,6 +15,7 @@ import java.math.BigDecimal class ShopOrderReplenishmentReportService( private val jdbcDao: JdbcDao, ) { + /** FP-MTMS Version Checklist | Functions Ref. No. 49 | v1.0.1 | 2026-08-10 */ fun searchShopOrderReplenishmentReport( reorderDateStart: String?, reorderDateEnd: String?, @@ -87,6 +88,7 @@ class ShopOrderReplenishmentReportService( DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate, dr.reason AS reason, DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate, + TRIM(IFNULL(dop.handlerName, '')) AS actualDeliveredHandler, dr.sourceDoId AS sourceDoId, dr.itemId AS itemId, dr.pickOrderLineId AS pickOrderLineId @@ -130,7 +132,7 @@ class ShopOrderReplenishmentReportService( val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct() val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds) - val firstOrderPickBySourceKey = loadFirstOrderActualPickQty(sourceDoIds, itemIds, pickOrderLineIds) + val firstOrderPickBySourceKey = loadFirstOrderPickInfo(sourceDoIds, itemIds, pickOrderLineIds) val rows = baseRows.map { row -> val polId = longVal(row["pickOrderLineId"]) @@ -138,6 +140,7 @@ class ShopOrderReplenishmentReportService( val itemId = longVal(row["itemId"]) val sourceKey = if (sourceDoId != null && itemId != null) sourceDoId to itemId else null + val firstOrder = sourceKey?.let { firstOrderPickBySourceKey[it] } linkedMapOf( "shopNo" to row["shopNo"], @@ -147,11 +150,13 @@ class ShopOrderReplenishmentReportService( "itemNo" to row["itemNo"], "itemName" to row["itemName"], "firstOrderQty" to row["firstOrderQty"], - "firstOrderActualPickQty" to (sourceKey?.let { firstOrderPickBySourceKey[it] } ?: BigDecimal.ZERO), + "firstOrderActualPickQty" to (firstOrder?.qty ?: BigDecimal.ZERO), + "firstOrderPickerHandler" to (firstOrder?.handler ?: ""), "reorderQty" to row["reorderQty"], "reorderDate" to row["reorderDate"], "reason" to row["reason"], "actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO), + "actualDeliveredHandler" to (row["actualDeliveredHandler"] ?: ""), "deliveredDate" to row["deliveredDate"], ) } @@ -185,14 +190,15 @@ class ShopOrderReplenishmentReportService( } /** - * Sum stock_out_line.qty for source DO pick order lines of the same item, + * First-order pick qty + handler for source DO + item, * excluding any POL that is itself a replenishment line (incl. current report POLs). + * Same ticket has at most one handler; MAX() is for GROUP BY only. */ - private fun loadFirstOrderActualPickQty( + private fun loadFirstOrderPickInfo( sourceDoIds: List, itemIds: List, excludePickOrderLineIds: List, - ): Map, BigDecimal> { + ): Map, FirstOrderPickInfo> { if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() val args = mutableMapOf( @@ -210,7 +216,8 @@ class ShopOrderReplenishmentReportService( SELECT po.doId AS sourceDoId, pol.itemId AS itemId, - SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty + SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty, + MAX(TRIM(IFNULL(dop.handlerName, ''))) AS firstOrderPickerHandler FROM pick_order po INNER JOIN pick_order_line pol ON pol.poId = po.id @@ -218,6 +225,9 @@ class ShopOrderReplenishmentReportService( LEFT JOIN stock_out_line sol ON sol.pickOrderLineId = pol.id AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN delivery_order_pick_order dop + ON dop.id = po.deliveryOrderPickOrderId + AND IFNULL(dop.deleted, 0) = 0 WHERE IFNULL(po.deleted, 0) = 0 AND po.doId IN (:sourceDoIds) AND pol.itemId IN (:itemIds) @@ -236,10 +246,18 @@ class ShopOrderReplenishmentReportService( return rows.mapNotNull { row -> val sourceDoId = longVal(row["sourceDoId"]) ?: return@mapNotNull null val itemId = longVal(row["itemId"]) ?: return@mapNotNull null - (sourceDoId to itemId) to decimalVal(row["firstOrderActualPickQty"]) + (sourceDoId to itemId) to FirstOrderPickInfo( + qty = decimalVal(row["firstOrderActualPickQty"]), + handler = row["firstOrderPickerHandler"]?.toString()?.trim().orEmpty(), + ) }.toMap() } + private data class FirstOrderPickInfo( + val qty: BigDecimal, + val handler: String, + ) + private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-") private fun dateStartClause( diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt new file mode 100644 index 0000000..c84b90e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt @@ -0,0 +1,116 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.DoInventoryUomMismatchReportService +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.io.ByteArrayOutputStream + +@RestController +@RequestMapping("/report") +class DoInventoryUomMismatchReportController( + private val doInventoryUomMismatchReportService: DoInventoryUomMismatchReportService, +) { + /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.0 | 2026-08-10 */ + @GetMapping("/print-do-inventory-uom-mismatch-excel") + fun exportExcel( + @RequestParam(required = false) deliveryDate: String?, + @RequestParam(required = false) storeId: String?, + ): ResponseEntity { + val rows = try { + doInventoryUomMismatchReportService.searchDoInventoryUomMismatch(deliveryDate, storeId) + } catch (ex: IllegalArgumentException) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build() + } + + val bytes = buildExcel(rows) + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + // ASCII only — Tomcat rejects non-Latin-1 in Content-Disposition + setContentDispositionFormData("attachment", "DoInventoryUomMismatchReport.xlsx") + } + return ResponseEntity(bytes, headers, HttpStatus.OK) + } + + private fun buildExcel(rows: List>): ByteArray { + val workbook = XSSFWorkbook() + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName("DO_UOM_mismatch")) + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val font = workbook.createFont().apply { bold = true } + setFont(font) + } + val textStyle = workbook.createCellStyle().apply { + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val columns = listOf( + "deliveryDate" to "預計到貨日期", + "doCode" to "送貨單號", + "supplierCode" to "供應商編號", + "supplierName" to "供應商名稱", + "itemCode" to "貨品編號", + "itemName" to "貨品名稱", + "doQty" to "送貨單數量", + "doUom" to "送貨單單位", + "mismatchLotNo" to "不符批號", + "mismatchLotQty" to "不符批數量", + "mismatchLotUom" to "不符批單位", + ) + + var r = 0 + val headerRow = sheet.createRow(r++) + columns.forEachIndexed { i, (_, label) -> + headerRow.createCell(i).apply { + setCellValue(label) + cellStyle = headerStyle + } + } + + for (row in rows) { + val excelRow = sheet.createRow(r++) + columns.forEachIndexed { i, (key, _) -> + val v = row[key] + val cell = excelRow.createCell(i) + cell.cellStyle = textStyle + when (v) { + null -> cell.setCellValue("") + is Number -> cell.setCellValue(v.toDouble()) + else -> cell.setCellValue(v.toString()) + } + } + } + + val widths = intArrayOf(14, 16, 12, 18, 14, 28, 12, 14, 18, 12, 14) + widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + + val out = ByteArrayOutputStream() + workbook.write(out) + workbook.close() + return out.toByteArray() + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java b/src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java index 51d7b02..d5c0554 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java +++ b/src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java @@ -13,6 +13,8 @@ import com.ffii.fpsms.modules.user.entity.projections.UserCombo; public interface UserRepository extends AbstractRepository { List findByName(@Param("name") String name); + + List findByNameAndDeletedFalse(String name); Optional findByUsernameAndDeletedFalse(String username); @@ -20,6 +22,8 @@ public interface UserRepository extends AbstractRepository { Optional findByStaffNo(@Param("staffNo") String staffNo); + Optional findByStaffNoAndDeletedFalse(String staffNo); + @Modifying @Query(value = """ INSERT INTO user_authority (userID, authId) diff --git a/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java b/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java index 9c57e79..9832457 100644 --- a/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java +++ b/src/main/java/com/ffii/fpsms/modules/user/service/UserService.java @@ -92,6 +92,22 @@ public class UserService extends AbstractBaseEntityService search(SearchUserReq req) { StringBuilder sql = new StringBuilder("SELECT" @@ -199,11 +215,18 @@ public class UserService extends AbstractBaseEntityService