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 | * Uses delivery_order_pick_order (handler = handledBy); time = sum of | ||||
| * (ticketCompleteDateTime - ticketReleaseTime) per completed ticket. | * (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). | * 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). | * 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). | * 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() | args["endExclusive"] = endDate.plusDays(1).atStartOfDay() | ||||
| "AND dop.ticketCompleteDateTime < :endExclusive" | "AND dop.ticketCompleteDateTime < :endExclusive" | ||||
| } else "" | } 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 "" | } else "" | ||||
| val storeSql = when { | val storeSql = when { | ||||
| storeIdNull == true -> "AND dop.storeId IS NULL" | storeIdNull == true -> "AND dop.storeId IS NULL" | ||||
| @@ -758,31 +763,61 @@ open class ChartService( | |||||
| else -> "" | else -> "" | ||||
| } | } | ||||
| val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank() | val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank() | ||||
| val fromClause = if (useStoreFilter) { | |||||
| val dopFromClause = if (useStoreFilter) { | |||||
| "FROM delivery_order_pick_order dop" | "FROM delivery_order_pick_order dop" | ||||
| } else { | } else { | ||||
| "FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)" | "FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)" | ||||
| } | } | ||||
| val sql = """ | 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 | 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( | COALESCE(SUM( | ||||
| CASE | 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 | ELSE 0 | ||||
| END | 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 | ORDER BY date, orderCount DESC | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| @@ -194,9 +194,13 @@ class ChartController( | |||||
| chartService.getStaffDeliveryPerformanceHandlers() | 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 | * 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). | * Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId). | ||||
| */ | */ | ||||
| @GetMapping("/staff-delivery-performance") | @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 SEND_EMAIL_ERROR = "SEND_EMAIL_ERROR"; | ||||
| public static final String USERNAME_NOT_AVAILABLE = "USERNAME_NOT_AVAILABLE"; | 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"; | 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): | * Workbench scan-pick (DO FG): | ||||
| * 1) Post outbound on scanned inventory lot line first; on failure return a clear message. | * 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], | * 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 | var postMs = 0L | ||||
| try { | try { | ||||
| if (pickOrderId != null) { | 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( | val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes( | ||||
| pickOrderId = pickOrderId, | pickOrderId = pickOrderId, | ||||
| poType = null, | |||||
| poType = resolvedPoType, | |||||
| userId = userId, | userId = userId, | ||||
| requestExcludeWarehouseCodes = effectiveExcludeWarehouseCodes, | 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 | * DO user-audit: Just Complete is reportable only when the item still has pickable stock | ||||
| * at complete time (AVAILABLE, not expired, in−out > 0). | * 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]) | * [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService]) | ||||
| * and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]). | * 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 { | object JoWorkbenchPickConstants { | ||||
| val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set<String> = setOf( | val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set<String> = setOf( | ||||
| @@ -1430,7 +1430,8 @@ open class BomService( | |||||
| } | } | ||||
| 3 -> { | 3 -> { | ||||
| val equipmentName = tempCell.stringCellValue.trim() | val equipmentName = tempCell.stringCellValue.trim() | ||||
| if (equipmentName != "不適用") { | |||||
| // 不合用 / 不適用:不掛 equipment(與格式檢查同等處理) | |||||
| if (!isNotApplicableEquipment(equipmentName)) { | |||||
| val equipment = bomGetOrCreateEquipment(equipmentName) | val equipment = bomGetOrCreateEquipment(equipmentName) | ||||
| // println("equipment created") | // println("equipment created") | ||||
| bomProcessRequest.equipment = equipment | bomProcessRequest.equipment = equipment | ||||
| @@ -1977,10 +1978,17 @@ open class BomService( | |||||
| issueLogFileId = issueLogId | 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 { | private fun isValidEquipmentType(value: String): Boolean { | ||||
| val trimmed = value.trim() | val trimmed = value.trim() | ||||
| if (trimmed.isEmpty()) return false | if (trimmed.isEmpty()) return false | ||||
| if (trimmed == "不合用" || trimmed == "不適用") return true | |||||
| if (isNotApplicableEquipment(trimmed)) return true | |||||
| if (trimmed.contains(",")) return false // 新增:不允許逗號 | if (trimmed.contains(",")) return false // 新增:不允許逗號 | ||||
| val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機 | val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機 | ||||
| return regex.matches(trimmed) | return regex.matches(trimmed) | ||||
| @@ -29,12 +29,13 @@ interface ProductProcessLineRepository : JpaRepository<ProductProcessLine, Long> | |||||
| """) | """) | ||||
| fun findByProductProcess_IdInWithOperatorAndEquipment(@Param("ids") ids: List<Long>): List<ProductProcessLine> | 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( | @Query( | ||||
| """ | """ | ||||
| SELECT | SELECT | ||||
| p.jobOrder.id AS jobOrderId, | p.jobOrder.id AS jobOrderId, | ||||
| COUNT(l.id) AS totalLines, | 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 | FROM ProductProcessLine l | ||||
| JOIN l.productProcess p | JOIN l.productProcess p | ||||
| WHERE l.deleted = false | 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 { | open fun updateProductProcessLineStatus(productProcessLineId: Long, status: String): MessageResponse { | ||||
| println(" Service: Updating ProductProcessLine Status: $productProcessLineId") | println(" Service: Updating ProductProcessLine Status: $productProcessLineId") | ||||
| val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) | val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null) | ||||
| @@ -1260,8 +1260,8 @@ open class ProductProcessService( | |||||
| productProcessLineRepository.save(productProcessLine) | productProcessLineRepository.save(productProcessLine) | ||||
| println(" Service: ProductProcessLine Status Updated: ${productProcessLine.status}") | 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) | autoPassSiblingPackagingLines(productProcessLineId) | ||||
| } | } | ||||
| @@ -1319,7 +1319,7 @@ open class ProductProcessService( | |||||
| val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | ||||
| println(" Service: ProductProcessLines: $productProcessLines") | println(" Service: ProductProcessLines: $productProcessLines") | ||||
| if (productProcessLines.all { it.status == "Completed" || it.status == "Pass" }) { | |||||
| if (productProcessLines.all { isLineDone(it.status) }) { | |||||
| productProcess.status = ProductProcessStatus.COMPLETED | productProcess.status = ProductProcessStatus.COMPLETED | ||||
| if (productProcess.endTime == null) { | if (productProcess.endTime == null) { | ||||
| productProcess.endTime = LocalDateTime.now() | 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。 | * 注意: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 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 qcKeys = candidateAggregates.mapNotNull { agg -> | ||||
| val jobOrderId = agg.jobOrderId | val jobOrderId = agg.jobOrderId | ||||
| val stockInLine = stockInLineByJobOrderId[jobOrderId] | val stockInLine = stockInLineByJobOrderId[jobOrderId] | ||||
| @@ -1688,15 +1684,10 @@ open class ProductProcessService( | |||||
| val lineAggregate = lineStatusByJobOrderId[jobOrderId] | val lineAggregate = lineStatusByJobOrderId[jobOrderId] | ||||
| val totalLines = lineAggregate?.totalLines ?: 0L | 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 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 | val ready = includedInList && stockInLine != null && allLinesDone | ||||
| @@ -1886,10 +1877,7 @@ open class ProductProcessService( | |||||
| val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList() | val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList() | ||||
| val productProcessLines = linesByProcessId[productProcess.id ?: 0L].orEmpty() | 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 bomIsDrink = productProcess.bom?.isDrink | ||||
| val matchStatus = if (joPickOrdersList.isNotEmpty() && | 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). | * only job orders that have a product process dated **today or yesterday** (server local date). | ||||
| */ | */ | ||||
| open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse { | open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse { | ||||
| @@ -2017,7 +2005,7 @@ open class ProductProcessService( | |||||
| val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } | val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() } | ||||
| val allLinesDone = jobOrderLines.isNotEmpty() && | val allLinesDone = jobOrderLines.isNotEmpty() && | ||||
| jobOrderLines.all { it.status == "Completed" || it.status == "Pass" } | |||||
| jobOrderLines.all { isLineDone(it.status) } | |||||
| if (!allLinesDone) continue | if (!allLinesDone) continue | ||||
| val maxDate = processes.mapNotNull { it.date }.maxOrNull() | val maxDate = processes.mapNotNull { it.date }.maxOrNull() | ||||
| @@ -2119,6 +2107,12 @@ open class ProductProcessService( | |||||
| status?.trim()?.lowercase()?.replace(" ", "") ?: "" | status?.trim()?.lowercase()?.replace(" ", "") ?: "" | ||||
| private fun isLineDone(status: String?): Boolean { | 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) | val n = normalizeLineStatus(status) | ||||
| return n == "completed" || n == "pass" | return n == "completed" || n == "pass" | ||||
| } | } | ||||
| @@ -2128,14 +2122,14 @@ open class ProductProcessService( | |||||
| return n == "inprogress" || n == "paused" | 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 = | private fun isPackagingLine(line: ProductProcessLine): Boolean = | ||||
| (line.name ?: "").trim() == PACKAGING_PROCESS_NAME | (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) { | private fun autoPassSiblingPackagingLines(triggerLineId: Long) { | ||||
| val trigger = productProcessLineRepository.findById(triggerLineId).orElse(null) ?: return | val trigger = productProcessLineRepository.findById(triggerLineId).orElse(null) ?: return | ||||
| @@ -2163,7 +2157,7 @@ open class ProductProcessService( | |||||
| line.startTime = now | line.startTime = now | ||||
| } | } | ||||
| line.endTime = now | line.endTime = now | ||||
| line.status = "Pass" | |||||
| line.status = "autoPass" | |||||
| productProcessLineRepository.save(line) | productProcessLineRepository.save(line) | ||||
| line.productProcess?.id?.let { affectedProcessIds.add(it) } | line.productProcess?.id?.let { affectedProcessIds.add(it) } | ||||
| } | } | ||||
| @@ -2175,7 +2169,7 @@ open class ProductProcessService( | |||||
| /** | /** | ||||
| * Align parent [ProductProcess.status] with all line states. | * 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 | * - Any line started but not all done -> in_progress | ||||
| * - Does not override STOPPED or CANCELLED on the parent. | * - Does not override STOPPED or CANCELLED on the parent. | ||||
| */ | */ | ||||
| @@ -2255,8 +2249,8 @@ open class ProductProcessService( | |||||
| // 获取所有 product process lines | // 获取所有 product process lines | ||||
| val allproductProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId) | 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 和状态 | // 更新 product process 的 endTime 和状态 | ||||
| updateProductProcessEndTime(productProcessId) | updateProductProcessEndTime(productProcessId) | ||||
| updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED) | updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED) | ||||
| @@ -3097,7 +3091,7 @@ open class ProductProcessService( | |||||
| } | } | ||||
| val currentProcesses = operatorLines | 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 -> | .map { line -> | ||||
| val productProcess = line.productProcess | val productProcess = line.productProcess | ||||
| val jobOrder = productProcess.jobOrder | val jobOrder = productProcess.jobOrder | ||||
| @@ -200,7 +200,7 @@ class ProductProcessController( | |||||
| return productProcessService.getAllJoborderProductProcessInfo(bomType) | 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") | @GetMapping("/Demo/Process/search") | ||||
| fun demoprocesssearch( | fun demoprocesssearch( | ||||
| @RequestParam(required = false) date: String?, | @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. | * job order has at least one process dated today or yesterday. [qc] = before received; [putAway] = received / partially_completed. | ||||
| */ | */ | ||||
| data class JobOrderFgAlertRowResponse( | 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( | class ShopOrderReplenishmentReportService( | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| ) { | ) { | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 49 | v1.0.1 | 2026-08-10 */ | |||||
| fun searchShopOrderReplenishmentReport( | fun searchShopOrderReplenishmentReport( | ||||
| reorderDateStart: String?, | reorderDateStart: String?, | ||||
| reorderDateEnd: String?, | reorderDateEnd: String?, | ||||
| @@ -87,6 +88,7 @@ class ShopOrderReplenishmentReportService( | |||||
| DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate, | DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate, | ||||
| dr.reason AS reason, | dr.reason AS reason, | ||||
| DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate, | DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate, | ||||
| TRIM(IFNULL(dop.handlerName, '')) AS actualDeliveredHandler, | |||||
| dr.sourceDoId AS sourceDoId, | dr.sourceDoId AS sourceDoId, | ||||
| dr.itemId AS itemId, | dr.itemId AS itemId, | ||||
| dr.pickOrderLineId AS pickOrderLineId | dr.pickOrderLineId AS pickOrderLineId | ||||
| @@ -130,7 +132,7 @@ class ShopOrderReplenishmentReportService( | |||||
| val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct() | val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct() | ||||
| val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds) | val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds) | ||||
| val firstOrderPickBySourceKey = loadFirstOrderActualPickQty(sourceDoIds, itemIds, pickOrderLineIds) | |||||
| val firstOrderPickBySourceKey = loadFirstOrderPickInfo(sourceDoIds, itemIds, pickOrderLineIds) | |||||
| val rows = baseRows.map { row -> | val rows = baseRows.map { row -> | ||||
| val polId = longVal(row["pickOrderLineId"]) | val polId = longVal(row["pickOrderLineId"]) | ||||
| @@ -138,6 +140,7 @@ class ShopOrderReplenishmentReportService( | |||||
| val itemId = longVal(row["itemId"]) | val itemId = longVal(row["itemId"]) | ||||
| val sourceKey = | val sourceKey = | ||||
| if (sourceDoId != null && itemId != null) sourceDoId to itemId else null | if (sourceDoId != null && itemId != null) sourceDoId to itemId else null | ||||
| val firstOrder = sourceKey?.let { firstOrderPickBySourceKey[it] } | |||||
| linkedMapOf<String, Any?>( | linkedMapOf<String, Any?>( | ||||
| "shopNo" to row["shopNo"], | "shopNo" to row["shopNo"], | ||||
| @@ -147,11 +150,13 @@ class ShopOrderReplenishmentReportService( | |||||
| "itemNo" to row["itemNo"], | "itemNo" to row["itemNo"], | ||||
| "itemName" to row["itemName"], | "itemName" to row["itemName"], | ||||
| "firstOrderQty" to row["firstOrderQty"], | "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"], | "reorderQty" to row["reorderQty"], | ||||
| "reorderDate" to row["reorderDate"], | "reorderDate" to row["reorderDate"], | ||||
| "reason" to row["reason"], | "reason" to row["reason"], | ||||
| "actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO), | "actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO), | ||||
| "actualDeliveredHandler" to (row["actualDeliveredHandler"] ?: ""), | |||||
| "deliveredDate" to row["deliveredDate"], | "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). | * 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>, | sourceDoIds: List<Long>, | ||||
| itemIds: List<Long>, | itemIds: List<Long>, | ||||
| excludePickOrderLineIds: List<Long>, | excludePickOrderLineIds: List<Long>, | ||||
| ): Map<Pair<Long, Long>, BigDecimal> { | |||||
| ): Map<Pair<Long, Long>, FirstOrderPickInfo> { | |||||
| if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() | if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap() | ||||
| val args = mutableMapOf<String, Any>( | val args = mutableMapOf<String, Any>( | ||||
| @@ -210,7 +216,8 @@ class ShopOrderReplenishmentReportService( | |||||
| SELECT | SELECT | ||||
| po.doId AS sourceDoId, | po.doId AS sourceDoId, | ||||
| pol.itemId AS itemId, | 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 | FROM pick_order po | ||||
| INNER JOIN pick_order_line pol | INNER JOIN pick_order_line pol | ||||
| ON pol.poId = po.id | ON pol.poId = po.id | ||||
| @@ -218,6 +225,9 @@ class ShopOrderReplenishmentReportService( | |||||
| LEFT JOIN stock_out_line sol | LEFT JOIN stock_out_line sol | ||||
| ON sol.pickOrderLineId = pol.id | ON sol.pickOrderLineId = pol.id | ||||
| AND IFNULL(sol.deleted, 0) = 0 | 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 | WHERE IFNULL(po.deleted, 0) = 0 | ||||
| AND po.doId IN (:sourceDoIds) | AND po.doId IN (:sourceDoIds) | ||||
| AND pol.itemId IN (:itemIds) | AND pol.itemId IN (:itemIds) | ||||
| @@ -236,10 +246,18 @@ class ShopOrderReplenishmentReportService( | |||||
| return rows.mapNotNull { row -> | return rows.mapNotNull { row -> | ||||
| val sourceDoId = longVal(row["sourceDoId"]) ?: return@mapNotNull null | val sourceDoId = longVal(row["sourceDoId"]) ?: return@mapNotNull null | ||||
| val itemId = longVal(row["itemId"]) ?: 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() | }.toMap() | ||||
| } | } | ||||
| private data class FirstOrderPickInfo( | |||||
| val qty: BigDecimal, | |||||
| val handler: String, | |||||
| ) | |||||
| private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-") | private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-") | ||||
| private fun dateStartClause( | 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> { | public interface UserRepository extends AbstractRepository<User, Long> { | ||||
| List<User> findByName(@Param("name") String name); | List<User> findByName(@Param("name") String name); | ||||
| List<User> findByNameAndDeletedFalse(String name); | |||||
| Optional<User> findByUsernameAndDeletedFalse(String username); | 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> findByStaffNo(@Param("staffNo") String staffNo); | ||||
| Optional<User> findByStaffNoAndDeletedFalse(String staffNo); | |||||
| @Modifying | @Modifying | ||||
| @Query(value = """ | @Query(value = """ | ||||
| INSERT INTO user_authority (userID, authId) | INSERT INTO user_authority (userID, authId) | ||||
| @@ -92,6 +92,22 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||||
| return userRepository.findByUsernameAndDeletedFalse(username); | 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) | // @Transactional(rollbackFor = Exception.class) | ||||
| public List<UserRecord> search(SearchUserReq req) { | public List<UserRecord> search(SearchUserReq req) { | ||||
| StringBuilder sql = new StringBuilder("SELECT" | StringBuilder sql = new StringBuilder("SELECT" | ||||
| @@ -199,11 +215,18 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos | |||||
| return instance; | return instance; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */ | |||||
| @Transactional(rollbackFor = Exception.class) | @Transactional(rollbackFor = Exception.class) | ||||
| public User newRecord(NewUserReq req) throws UnsupportedEncodingException { | public User newRecord(NewUserReq req) throws UnsupportedEncodingException { | ||||
| if (findByUsername(req.getUsername()).isPresent()) { | if (findByUsername(req.getUsername()).isPresent()) { | ||||
| throw new UnprocessableEntityException(ErrorCodes.USERNAME_NOT_AVAILABLE); | 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"); | System.out.println("Start Save"); | ||||