2. 新報表:送貨訂單與倉存單位不符 3 店鋪補貨報表 加「原單提料人」「實際補貨提料人」;Excel 欄位改純中文表頭 4 包裝 autoPass 5. JO 提料 re-suggest JO 類型 storeId 保持 null(避免被預設成 2/F 濾掉 3F) 前端對齊 DO:用後端 stockout qty、同 POL 依 status 排序 6. 使用者建立/驗證 7. BOM 設備 8「不合用/不適用」統一不掛 equipment data 9 hilde some useless console logfix負數倉
| @@ -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) | |||
| @@ -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") | |||
| @@ -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"; | |||
| @@ -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). | |||
| * | |||
| @@ -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<String> = setOf( | |||
| @@ -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) | |||
| @@ -29,12 +29,13 @@ interface ProductProcessLineRepository : JpaRepository<ProductProcessLine, Long> | |||
| """) | |||
| fun findByProductProcess_IdInWithOperatorAndEquipment(@Param("ids") ids: List<Long>): List<ProductProcessLine> | |||
| /** 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 | |||
| @@ -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 | |||
| @@ -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?, | |||
| @@ -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( | |||
| @@ -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<Map<String, Any?>> { | |||
| 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<String, Any>( | |||
| "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) | |||
| } | |||
| } | |||
| @@ -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<String, Any?>( | |||
| "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<Long>, | |||
| itemIds: List<Long>, | |||
| excludePickOrderLineIds: List<Long>, | |||
| ): Map<Pair<Long, Long>, BigDecimal> { | |||
| ): Map<Pair<Long, Long>, FirstOrderPickInfo> { | |||
| if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() | |||
| val args = mutableMapOf<String, Any>( | |||
| @@ -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( | |||
| @@ -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<ByteArray> { | |||
| 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<Map<String, Any?>>): 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() | |||
| } | |||
| } | |||
| @@ -13,6 +13,8 @@ import com.ffii.fpsms.modules.user.entity.projections.UserCombo; | |||
| public interface UserRepository extends AbstractRepository<User, Long> { | |||
| List<User> findByName(@Param("name") String name); | |||
| List<User> findByNameAndDeletedFalse(String name); | |||
| Optional<User> findByUsernameAndDeletedFalse(String username); | |||
| @@ -20,6 +22,8 @@ public interface UserRepository extends AbstractRepository<User, Long> { | |||
| Optional<User> findByStaffNo(@Param("staffNo") String staffNo); | |||
| Optional<User> findByStaffNoAndDeletedFalse(String staffNo); | |||
| @Modifying | |||
| @Query(value = """ | |||
| INSERT INTO user_authority (userID, authId) | |||
| @@ -92,6 +92,22 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||
| return userRepository.findByUsernameAndDeletedFalse(username); | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */ | |||
| public boolean isNameTaken(String name) { | |||
| if (StringUtils.isBlank(name)) { | |||
| return false; | |||
| } | |||
| return !userRepository.findByNameAndDeletedFalse(name).isEmpty(); | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */ | |||
| public boolean isStaffNoTaken(String staffNo) { | |||
| if (StringUtils.isBlank(staffNo)) { | |||
| return false; | |||
| } | |||
| return userRepository.findByStaffNoAndDeletedFalse(staffNo).isPresent(); | |||
| } | |||
| // @Transactional(rollbackFor = Exception.class) | |||
| public List<UserRecord> search(SearchUserReq req) { | |||
| StringBuilder sql = new StringBuilder("SELECT" | |||
| @@ -199,11 +215,18 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||
| return instance; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */ | |||
| @Transactional(rollbackFor = Exception.class) | |||
| public User newRecord(NewUserReq req) throws UnsupportedEncodingException { | |||
| if (findByUsername(req.getUsername()).isPresent()) { | |||
| throw new UnprocessableEntityException(ErrorCodes.USERNAME_NOT_AVAILABLE); | |||
| } | |||
| if (isNameTaken(req.getName())) { | |||
| throw new UnprocessableEntityException(ErrorCodes.NAME_NOT_AVAILABLE); | |||
| } | |||
| if (isStaffNoTaken(req.getStaffNo())) { | |||
| throw new UnprocessableEntityException(ErrorCodes.STAFF_NO_NOT_AVAILABLE); | |||
| } | |||
| System.out.println("Start Save"); | |||