| @@ -122,6 +122,22 @@ open class DeliveryOrderService( | |||||
| private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, | private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, | ||||
| private val itemsRepository: ItemsRepository, | private val itemsRepository: ItemsRepository, | ||||
| ) { | ) { | ||||
| /** | |||||
| * 樓層篩選:2F → P07/P06D(車線- 族);4F → P06B(P06B_ 族);全部 → 三者。 | |||||
| * 車線-X 仍屬該 DO 的 supplier,故 P06B+車線-X 不會出現在 2F,P06D+車線-X 不會出現在 4F。 | |||||
| */ | |||||
| private fun allowedSupplierCodesForFloor(floor: String?): List<String> { | |||||
| val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty() | |||||
| if (f.isEmpty() || f == "ALL" || f == "All") { | |||||
| return listOf("P06B", "P07", "P06D") | |||||
| } | |||||
| return when (f) { | |||||
| "2F" -> listOf("P07", "P06D") | |||||
| "4F" -> listOf("P06B") | |||||
| else -> listOf("P06B", "P07", "P06D") | |||||
| } | |||||
| } | |||||
| open fun searchDoLiteByPage( | open fun searchDoLiteByPage( | ||||
| code: String?, | code: String?, | ||||
| shopName: String?, | shopName: String?, | ||||
| @@ -129,7 +145,8 @@ open class DeliveryOrderService( | |||||
| estimatedArrivalDate: LocalDateTime?, | estimatedArrivalDate: LocalDateTime?, | ||||
| pageNum: Int?, | pageNum: Int?, | ||||
| pageSize: Int?, | pageSize: Int?, | ||||
| truckLanceCode: String? | |||||
| truckLanceCode: String?, | |||||
| floor: String? = null, | |||||
| ): RecordsRes<DeliveryOrderInfoLiteDto> { | ): RecordsRes<DeliveryOrderInfoLiteDto> { | ||||
| val page = (pageNum ?: 1) - 1 | val page = (pageNum ?: 1) - 1 | ||||
| @@ -142,81 +159,26 @@ open class DeliveryOrderService( | |||||
| val searchTruckLanceCode = truckLanceCode?.ifBlank { null }?.lowercase() | val searchTruckLanceCode = truckLanceCode?.ifBlank { null }?.lowercase() | ||||
| if (searchTruckLanceCode != null) { | if (searchTruckLanceCode != null) { | ||||
| println("DEBUG: Filtering by truckLanceCode: $searchTruckLanceCode") | |||||
| // ✅ 优化:先从 truck 表找到匹配的 truck,获取 Store_id 和 shopId | |||||
| val matchingTrucks = truckRepository.findAllByTruckLanceCodeContainingAndDeletedFalse(searchTruckLanceCode) | |||||
| println("DEBUG: Found ${matchingTrucks.size} matching trucks") | |||||
| // 收集所有匹配的 Store_id 和 shopId | |||||
| val matchingStoreIds = matchingTrucks.mapNotNull { it.storeId }.distinct() | |||||
| val matchingShopIds = matchingTrucks.mapNotNull { it.shop?.id }.distinct() | |||||
| println("DEBUG: Matching storeIds: $matchingStoreIds") | |||||
| println("DEBUG: Matching shopIds: ${matchingShopIds.size} shops") | |||||
| // 根据 Store_id 确定需要过滤的 supplier codes | |||||
| // Store_id = "2F" → supplier code != "P06B" | |||||
| // Store_id = "4F" → supplier code = "P06B" | |||||
| val supplierCodesToInclude = mutableSetOf<String?>() | |||||
| val supplierCodesToExclude = mutableSetOf<String?>() | |||||
| if (matchingStoreIds.contains("2F")) { | |||||
| // 2F 只关心 P07 / P06D | |||||
| supplierCodesToInclude.addAll(listOf("P07", "P06D")) | |||||
| // 同时排除 P06B,避免混在 2F 结果里 | |||||
| supplierCodesToExclude.add("P06B") | |||||
| } | |||||
| if (matchingStoreIds.contains("4F")) { | |||||
| // 4F 只关心 P06B | |||||
| supplierCodesToInclude.add("P06B") | |||||
| } | |||||
| // 查询符合条件的 DeliveryOrder(根据 supplier code 和 shopId 预过滤) | |||||
| // 注意:这里需要在 Repository 层面添加 supplier code 过滤 | |||||
| // 或者先查询所有,然后在代码层面过滤 | |||||
| println("DEBUG: Filtering by truckLanceCode: $searchTruckLanceCode, floor=$floor") | |||||
| val allResult = deliveryOrderRepository.searchDoLitePage( | |||||
| val allowedForFloor = allowedSupplierCodesForFloor(floor) | |||||
| val allResult = deliveryOrderRepository.searchDoLitePageWithSupplierCodes( | |||||
| code = code?.ifBlank { null }, | code = code?.ifBlank { null }, | ||||
| shopName = shopName?.ifBlank { null }, | shopName = shopName?.ifBlank { null }, | ||||
| status = statusEnum, | status = statusEnum, | ||||
| etaStart = etaStart, | etaStart = etaStart, | ||||
| etaEnd = etaEnd, | etaEnd = etaEnd, | ||||
| pageable = PageRequest.of(0, 100000) | |||||
| allowedSupplierCodes = allowedForFloor, | |||||
| pageable = PageRequest.of(0, 100_000), | |||||
| ) | ) | ||||
| println("DEBUG: Total records from DB before filtering: ${allResult.totalElements}") | |||||
| println("DEBUG: Total records from DB (supplier+floor filter): ${allResult.totalElements}") | |||||
| // ✅ 优化1: 批量查询所有 DeliveryOrder 实体 | |||||
| val deliveryOrderIds = allResult.content.mapNotNull { it.id } | val deliveryOrderIds = allResult.content.mapNotNull { it.id } | ||||
| val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds) | val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds) | ||||
| .associateBy { it.id } | .associateBy { it.id } | ||||
| println("DEBUG: Loaded ${deliveryOrdersMap.size} delivery orders in batch") | |||||
| // ✅ 优化2: 预过滤 - 根据 supplier code 和 shopId 过滤 | |||||
| val preFilteredContent = allResult.content.filter { info -> | |||||
| val deliveryOrder = deliveryOrdersMap[info.id] | |||||
| val supplierCode = deliveryOrder?.supplier?.code | |||||
| // 检查 supplier code 是否匹配 | |||||
| val supplierMatches = when { | |||||
| supplierCodesToExclude.contains(supplierCode) -> false | |||||
| supplierCodesToInclude.isNotEmpty() && !supplierCodesToInclude.contains(supplierCode) -> false | |||||
| else -> true | |||||
| } | |||||
| // 如果提供了 shopId 过滤,也检查 shopId | |||||
| val shopMatches = if (matchingShopIds.isNotEmpty()) { | |||||
| deliveryOrder?.shop?.id in matchingShopIds | |||||
| } else { | |||||
| true // 如果没有匹配的 shopId,不过滤 | |||||
| } | |||||
| supplierMatches && shopMatches | |||||
| } | |||||
| println("DEBUG: Pre-filtered records: ${preFilteredContent.size} (from ${allResult.content.size})") | |||||
| val preFilteredContent = allResult.content | |||||
| // ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录) | // ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录) | ||||
| val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> | val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> | ||||
| @@ -243,15 +205,8 @@ open class DeliveryOrderService( | |||||
| // ✅ 优化4: 批量查询所有需要的 Truck | // ✅ 优化4: 批量查询所有需要的 Truck | ||||
| val truckCache = mutableMapOf<Triple<Long, String, String>, Truck?>() | val truckCache = mutableMapOf<Triple<Long, String, String>, Truck?>() | ||||
| shopIdAndDatePairs.forEach { (shopId, preferredFloor, dayAbbr) -> | shopIdAndDatePairs.forEach { (shopId, preferredFloor, dayAbbr) -> | ||||
| val trucks = truckRepository.findByShopIdAndStoreIdAndDayOfWeek(shopId, preferredFloor, dayAbbr) | |||||
| val matchedTruck = if (trucks.isEmpty()) { | |||||
| truckRepository.findByShopIdAndDeletedFalse(shopId) | |||||
| .filter { it.storeId == preferredFloor } | |||||
| .minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) } | |||||
| } else { | |||||
| trucks.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) } | |||||
| } | |||||
| truckCache[Triple(shopId, preferredFloor, dayAbbr)] = matchedTruck | |||||
| truckCache[Triple(shopId, preferredFloor, dayAbbr)] = | |||||
| resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr) | |||||
| } | } | ||||
| println("DEBUG: Cached ${truckCache.size} truck lookups") | println("DEBUG: Cached ${truckCache.size} truck lookups") | ||||
| @@ -263,7 +218,7 @@ open class DeliveryOrderService( | |||||
| val preferredFloor = when (supplierCode) { | val preferredFloor = when (supplierCode) { | ||||
| "P06B" -> "4F" | "P06B" -> "4F" | ||||
| "P07", "P06D" -> "2F" | "P07", "P06D" -> "2F" | ||||
| else -> null | |||||
| else -> "2F" | |||||
| } | } | ||||
| val shop = deliveryOrder?.shop | val shop = deliveryOrder?.shop | ||||
| val shopId = shop?.id | val shopId = shop?.id | ||||
| @@ -314,7 +269,7 @@ open class DeliveryOrderService( | |||||
| return RecordsRes(paginatedRecords, totalCount) | return RecordsRes(paginatedRecords, totalCount) | ||||
| } else { | } else { | ||||
| // 未提供 truckLanceCode:在 DB 層依允許的供應商分頁,避免先取 10 筆再過濾導致每頁顯示少於 pageSize | // 未提供 truckLanceCode:在 DB 層依允許的供應商分頁,避免先取 10 筆再過濾導致每頁顯示少於 pageSize | ||||
| val allowedSupplierCodes = listOf("P06B", "P07", "P06D") | |||||
| val allowedSupplierCodes = allowedSupplierCodesForFloor(floor) | |||||
| val result = deliveryOrderRepository.searchDoLitePageWithSupplierCodes( | val result = deliveryOrderRepository.searchDoLitePageWithSupplierCodes( | ||||
| code = code?.ifBlank { null }, | code = code?.ifBlank { null }, | ||||
| shopName = shopName?.ifBlank { null }, | shopName = shopName?.ifBlank { null }, | ||||
| @@ -341,17 +296,7 @@ open class DeliveryOrderService( | |||||
| if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) { | if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) { | ||||
| val targetDate = estimatedArrivalDate.toLocalDate() | val targetDate = estimatedArrivalDate.toLocalDate() | ||||
| val dayAbbr = getDayOfWeekAbbr(targetDate) | val dayAbbr = getDayOfWeekAbbr(targetDate) | ||||
| val trucks = truckRepository.findByShopIdAndStoreIdAndDayOfWeek(shopId, preferredFloor, dayAbbr) | |||||
| val matchedTruck = if (trucks.isEmpty()) { | |||||
| truckRepository.findByShopIdAndDeletedFalse(shopId) | |||||
| .filter { it.storeId == preferredFloor } | |||||
| .minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) } | |||||
| } else { | |||||
| trucks.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) } | |||||
| } | |||||
| matchedTruck?.truckLanceCode | |||||
| resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode | |||||
| } else { | } else { | ||||
| null | null | ||||
| } | } | ||||
| @@ -375,6 +320,57 @@ open class DeliveryOrderService( | |||||
| } | } | ||||
| /** | |||||
| * DO 輕量搜尋 v2:車線關鍵字正規化(`車線-X`/`x`/`車線-` 前綴含未指派)、 | |||||
| * 以允許供應商 + 分批掃描取代單次 100000 筆載入;無車線條件時等同 [searchDoLiteByPage] 無車線分支。 | |||||
| */ | |||||
| open fun searchDoLiteByPageV2( | |||||
| code: String?, | |||||
| shopName: String?, | |||||
| status: String?, | |||||
| estimatedArrivalDate: LocalDateTime?, | |||||
| pageNum: Int?, | |||||
| pageSize: Int?, | |||||
| truckLanceCode: String?, | |||||
| floor: String? = null, | |||||
| ): RecordsRes<DeliveryOrderInfoLiteDto> { | |||||
| val mode = TruckLaneSearchSpec.parse(truckLanceCode) | |||||
| if (mode is TruckLaneSearchSpec.Mode.NoFilter) { | |||||
| return searchDoLiteByPage( | |||||
| code, | |||||
| shopName, | |||||
| status, | |||||
| estimatedArrivalDate, | |||||
| pageNum, | |||||
| pageSize, | |||||
| null, | |||||
| floor, | |||||
| ) | |||||
| } | |||||
| val pageIdx = (pageNum ?: 1).coerceAtLeast(1) - 1 | |||||
| val size = (pageSize ?: 10).coerceAtLeast(1).coerceAtMost(10_000) | |||||
| val statusEnum = status?.let { s -> DeliveryOrderStatus.entries.find { it.value == s } } | |||||
| val etaStart = estimatedArrivalDate | |||||
| val etaEnd = estimatedArrivalDate?.plusDays(1) | |||||
| val lanePredicate: (String?) -> Boolean = { lane -> TruckLaneSearchSpec.matches(mode, lane) } | |||||
| val matched = scanDoLiteSupplierFilteredWithLanePredicate( | |||||
| code = code?.ifBlank { null }, | |||||
| shopName = shopName?.ifBlank { null }, | |||||
| statusEnum = statusEnum, | |||||
| etaStart = etaStart, | |||||
| etaEnd = etaEnd, | |||||
| allowedSupplierCodes = allowedSupplierCodesForFloor(floor), | |||||
| lanePredicate = lanePredicate, | |||||
| ) | |||||
| val total = matched.size | |||||
| val from = pageIdx * size | |||||
| val pageRecords = | |||||
| if (from >= total) emptyList() | |||||
| else matched.subList(from, minOf(from + size, total)) | |||||
| return RecordsRes(pageRecords, total) | |||||
| } | |||||
| /** | /** | ||||
| * 僅回傳依店鋪/ETA 從 truck 排程**推算後** `truckLanceCode` 為 null 或空白的送貨單(畫面上對應「車線-X」)。 | * 僅回傳依店鋪/ETA 從 truck 排程**推算後** `truckLanceCode` 為 null 或空白的送貨單(畫面上對應「車線-X」)。 | ||||
| * 與 [searchDoLiteByPage] 帶一般車線關鍵字分開,避免 `車線-X` 在 truck 表無 shopId 時走舊邏輯漏單。 | * 與 [searchDoLiteByPage] 帶一般車線關鍵字分開,避免 `車線-X` 在 truck 表無 shopId 時走舊邏輯漏單。 | ||||
| @@ -386,13 +382,14 @@ open class DeliveryOrderService( | |||||
| estimatedArrivalDate: LocalDateTime?, | estimatedArrivalDate: LocalDateTime?, | ||||
| pageNum: Int?, | pageNum: Int?, | ||||
| pageSize: Int?, | pageSize: Int?, | ||||
| floor: String? = null, | |||||
| ): RecordsRes<DeliveryOrderInfoLiteDto> { | ): RecordsRes<DeliveryOrderInfoLiteDto> { | ||||
| val page = (pageNum ?: 1) - 1 | val page = (pageNum ?: 1) - 1 | ||||
| val size = pageSize ?: 10 | val size = pageSize ?: 10 | ||||
| val statusEnum = status?.let { s -> DeliveryOrderStatus.entries.find { it.value == s } } | val statusEnum = status?.let { s -> DeliveryOrderStatus.entries.find { it.value == s } } | ||||
| val etaStart = estimatedArrivalDate | val etaStart = estimatedArrivalDate | ||||
| val etaEnd = estimatedArrivalDate?.plusDays(1) | val etaEnd = estimatedArrivalDate?.plusDays(1) | ||||
| val allowedSupplierCodes = listOf("P06B", "P07", "P06D") | |||||
| val allowedSupplierCodes = allowedSupplierCodesForFloor(floor) | |||||
| val allResult = deliveryOrderRepository.searchDoLitePageWithSupplierCodes( | val allResult = deliveryOrderRepository.searchDoLitePageWithSupplierCodes( | ||||
| code = code?.ifBlank { null }, | code = code?.ifBlank { null }, | ||||
| @@ -423,15 +420,7 @@ open class DeliveryOrderService( | |||||
| if (deliveryOrder != null && shopId != null && infoEta != null) { | if (deliveryOrder != null && shopId != null && infoEta != null) { | ||||
| val targetDate = infoEta.toLocalDate() | val targetDate = infoEta.toLocalDate() | ||||
| val dayAbbr = getDayOfWeekAbbr(targetDate) | val dayAbbr = getDayOfWeekAbbr(targetDate) | ||||
| val trucks = truckRepository.findByShopIdAndStoreIdAndDayOfWeek(shopId, preferredFloor, dayAbbr) | |||||
| val matchedTruck = if (trucks.isEmpty()) { | |||||
| truckRepository.findByShopIdAndDeletedFalse(shopId) | |||||
| .filter { it.storeId == preferredFloor } | |||||
| .minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) } | |||||
| } else { | |||||
| trucks.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) } | |||||
| } | |||||
| matchedTruck?.truckLanceCode | |||||
| resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode | |||||
| } else { | } else { | ||||
| null | null | ||||
| } | } | ||||
| @@ -447,7 +436,7 @@ open class DeliveryOrderService( | |||||
| shopAddress = info.shopAddress, | shopAddress = info.shopAddress, | ||||
| truckLanceCode = calculatedTruckLanceCode, | truckLanceCode = calculatedTruckLanceCode, | ||||
| ) | ) | ||||
| }.filter { dto -> dto.truckLanceCode.isNullOrBlank() } | |||||
| }.filter { dto -> TruckLaneSearchSpec.isUnassignedResolvedLane(dto.truckLanceCode) } | |||||
| val totalCount = processedRecords.size | val totalCount = processedRecords.size | ||||
| val startIndex = page * size | val startIndex = page * size | ||||
| @@ -2096,6 +2085,129 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| ) | ) | ||||
| } | } | ||||
| private fun scanDoLiteSupplierFilteredWithLanePredicate( | |||||
| code: String?, | |||||
| shopName: String?, | |||||
| statusEnum: DeliveryOrderStatus?, | |||||
| etaStart: LocalDateTime?, | |||||
| etaEnd: LocalDateTime?, | |||||
| allowedSupplierCodes: List<String>, | |||||
| lanePredicate: (String?) -> Boolean, | |||||
| ): List<DeliveryOrderInfoLiteDto> { | |||||
| val out = ArrayList<DeliveryOrderInfoLiteDto>() | |||||
| var dbPage = 0 | |||||
| var rawScanned = 0 | |||||
| while (rawScanned < 100_000) { | |||||
| val slice = deliveryOrderRepository.searchDoLitePageWithSupplierCodes( | |||||
| code = code, | |||||
| shopName = shopName, | |||||
| status = statusEnum, | |||||
| etaStart = etaStart, | |||||
| etaEnd = etaEnd, | |||||
| allowedSupplierCodes = allowedSupplierCodes, | |||||
| pageable = PageRequest.of(dbPage, 500), | |||||
| ) | |||||
| if (slice.content.isEmpty()) break | |||||
| val dtos = buildResolvedTruckDtosForLiteRows(slice.content) | |||||
| for (dto in dtos) { | |||||
| if (lanePredicate(dto.truckLanceCode)) out.add(dto) | |||||
| } | |||||
| rawScanned += slice.content.size | |||||
| dbPage++ | |||||
| if (!slice.hasNext()) break | |||||
| } | |||||
| return out | |||||
| } | |||||
| private fun buildResolvedTruckDtosForLiteRows( | |||||
| rows: List<DeliveryOrderInfoLite>, | |||||
| ): List<DeliveryOrderInfoLiteDto> { | |||||
| val ids = rows.mapNotNull { it.id } | |||||
| if (ids.isEmpty()) return emptyList() | |||||
| val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id } | |||||
| val shopIdAndDatePairs = rows.mapNotNull { info -> | |||||
| val d = deliveryOrdersMap[info.id] | |||||
| val shopId = d?.shop?.id | |||||
| val eta = info.estimatedArrivalDate | |||||
| if (shopId != null && eta != null) { | |||||
| val targetDate = eta.toLocalDate() | |||||
| val dayAbbr = getDayOfWeekAbbr(targetDate) | |||||
| val supplierCode = d.supplier?.code | |||||
| val preferredFloor = when (supplierCode) { | |||||
| "P06B" -> "4F" | |||||
| "P07", "P06D" -> "2F" | |||||
| else -> "2F" | |||||
| } | |||||
| Triple(shopId, preferredFloor, dayAbbr) | |||||
| } else { | |||||
| null | |||||
| } | |||||
| }.distinct() | |||||
| val truckCache = mutableMapOf<Triple<Long, String, String>, Truck?>() | |||||
| shopIdAndDatePairs.forEach { (shopId, preferredFloor, dayAbbr) -> | |||||
| truckCache[Triple(shopId, preferredFloor, dayAbbr)] = | |||||
| resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr) | |||||
| } | |||||
| return rows.map { info -> | |||||
| val deliveryOrder = deliveryOrdersMap[info.id] | |||||
| val supplierCode = deliveryOrder?.supplier?.code | |||||
| val preferredFloor = when (supplierCode) { | |||||
| "P06B" -> "4F" | |||||
| "P07", "P06D" -> "2F" | |||||
| else -> "2F" | |||||
| } | |||||
| val shopId = deliveryOrder?.shop?.id | |||||
| val infoEta = info.estimatedArrivalDate | |||||
| val calculatedTruckLanceCode = | |||||
| if (deliveryOrder != null && shopId != null && infoEta != null) { | |||||
| val targetDate = infoEta.toLocalDate() | |||||
| val dayAbbr = getDayOfWeekAbbr(targetDate) | |||||
| truckCache[Triple(shopId, preferredFloor, dayAbbr)]?.truckLanceCode | |||||
| } else { | |||||
| null | |||||
| } | |||||
| DeliveryOrderInfoLiteDto( | |||||
| id = info.id, | |||||
| code = info.code, | |||||
| orderDate = info.orderDate, | |||||
| estimatedArrivalDate = info.estimatedArrivalDate, | |||||
| status = info.status, | |||||
| shopName = info.shopName, | |||||
| supplierName = info.supplierName, | |||||
| shopAddress = info.shopAddress, | |||||
| truckLanceCode = calculatedTruckLanceCode, | |||||
| ) | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 依店鋪 + 揀貨樓層解析當日應顯示之車線。 | |||||
| * - **2F**(P07/P06D):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。 | |||||
| * - **4F**(P06B):維持以星期縮寫篩選 [TruckRepository.findByShopIdAndStoreIdAndDayOfWeek];無命中時再退回同樓層最早出發。 | |||||
| */ | |||||
| private fun resolveTruckForShopFloorAndDay( | |||||
| shopId: Long, | |||||
| preferredFloor: String, | |||||
| dayAbbr: String, | |||||
| ): Truck? { | |||||
| if (preferredFloor == "2F") { | |||||
| return truckRepository.findByShopIdAndDeletedFalse(shopId) | |||||
| .filter { it.storeId == preferredFloor } | |||||
| .minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) } | |||||
| } | |||||
| val trucks = truckRepository.findByShopIdAndStoreIdAndDayOfWeek(shopId, preferredFloor, dayAbbr) | |||||
| return if (trucks.isEmpty()) { | |||||
| truckRepository.findByShopIdAndDeletedFalse(shopId) | |||||
| .filter { it.storeId == preferredFloor } | |||||
| .minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) } | |||||
| } else { | |||||
| trucks.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) } | |||||
| } | |||||
| } | |||||
| private fun getDayOfWeekAbbr(date: LocalDate): String = | private fun getDayOfWeekAbbr(date: LocalDate): String = | ||||
| when (date.dayOfWeek) { | when (date.dayOfWeek) { | ||||
| java.time.DayOfWeek.MONDAY -> "Mon" | java.time.DayOfWeek.MONDAY -> "Mon" | ||||
| @@ -847,139 +847,90 @@ open class DoPickOrderService( | |||||
| * Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics. | * Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics. | ||||
| */ | */ | ||||
| open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> { | open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> { | ||||
| // Fetch all active DoPickOrders for the target date | |||||
| val doPickOrders = doPickOrderRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| "2/F", targetDate, listOf(DoPickOrderStatus.pending, DoPickOrderStatus.released, DoPickOrderStatus.completed) | |||||
| ) + doPickOrderRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| "4/F", targetDate, listOf(DoPickOrderStatus.pending, DoPickOrderStatus.released, DoPickOrderStatus.completed) | |||||
| ) | |||||
| // Fetch all DoPickOrderRecords for the target date (completed records) | |||||
| val doPickOrderRecords = doPickOrderRecordRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| "2/F", targetDate, listOf(DoPickOrderStatus.completed) | |||||
| ) + doPickOrderRecordRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| "4/F", targetDate, listOf(DoPickOrderStatus.completed) | |||||
| ) | |||||
| // Combine both types into a unified data structure for aggregation | |||||
| data class TicketData( | |||||
| val storeId: String?, | |||||
| val truckId: Long?, | |||||
| val truckLanceCode: String?, | |||||
| val truckDepartureTime: java.time.LocalTime?, | |||||
| val shopId: Long?, | |||||
| val shopCode: String?, | |||||
| val ticketNo: String?, | |||||
| val ticketReleaseTime: LocalDateTime?, | |||||
| val ticketCompleteDateTime: LocalDateTime?, | |||||
| val ticketStatus: DoPickOrderStatus?, | |||||
| val doPickOrderId: Long?, | |||||
| val isRecord: Boolean | |||||
| ) | |||||
| val allTickets = mutableListOf<TicketData>() | |||||
| doPickOrders.forEach { dpo -> | |||||
| allTickets.add(TicketData( | |||||
| storeId = dpo.storeId, | |||||
| truckId = dpo.truckId, | |||||
| truckLanceCode = dpo.truckLanceCode, | |||||
| truckDepartureTime = dpo.truckDepartureTime, | |||||
| shopId = dpo.shopId, | |||||
| shopCode = dpo.shopCode, | |||||
| ticketNo = dpo.ticketNo, | |||||
| ticketReleaseTime = dpo.ticketReleaseTime, | |||||
| ticketCompleteDateTime = dpo.ticketCompleteDateTime, | |||||
| ticketStatus = dpo.ticketStatus, | |||||
| doPickOrderId = dpo.id, | |||||
| isRecord = false | |||||
| )) | |||||
| } | |||||
| doPickOrderRecords.forEach { record -> | |||||
| allTickets.add(TicketData( | |||||
| storeId = record.storeId, | |||||
| truckId = record.truckId, | |||||
| truckLanceCode = record.truckLanceCode, | |||||
| truckDepartureTime = record.truckDepartureTime, | |||||
| shopId = record.shopId, | |||||
| shopCode = record.shopCode, | |||||
| ticketNo = record.ticketNo, | |||||
| ticketReleaseTime = record.ticketReleaseTime, | |||||
| ticketCompleteDateTime = record.ticketCompleteDateTime, | |||||
| ticketStatus = record.ticketStatus, | |||||
| doPickOrderId = record.recordId, | |||||
| isRecord = true | |||||
| )) | |||||
| } | |||||
| // Group by storeId, truckLanceCode, truckDepartureTime | |||||
| val grouped = allTickets.groupBy { | |||||
| Triple(it.storeId, it.truckLanceCode, it.truckDepartureTime) | |||||
| } | |||||
| return grouped.map { (key, tickets) -> | |||||
| val (storeId, truckLanceCode, truckDepartureTime) = key | |||||
| // Count distinct shops | |||||
| val distinctShops = tickets.mapNotNull { it.shopId ?: it.shopCode?.hashCode()?.toLong() }.distinct().size | |||||
| // Count distinct tickets | |||||
| val distinctTickets = tickets.mapNotNull { it.ticketNo }.distinct().size | |||||
| // Calculate total items to pick | |||||
| var totalItems = 0 | |||||
| tickets.forEach { ticket -> | |||||
| if (ticket.doPickOrderId != null) { | |||||
| if (ticket.isRecord) { | |||||
| totalItems += countFGItemsFromRecordById(ticket.doPickOrderId) | |||||
| } else { | |||||
| totalItems += countFGItemsById(ticket.doPickOrderId) | |||||
| } | |||||
| } | |||||
| // Source of truth: delivery_order_pick_order (+ linked pick_order / pick_order_line) | |||||
| // | |||||
| // NOTE: delivery_order_pick_order 沒有 truckId 欄位;dashboard 的 truckId 目前僅作為展示/鍵值用途, | |||||
| // 回傳 null 讓前端保持相容即可。 | |||||
| val sql = """ | |||||
| SELECT | |||||
| dop.storeId AS storeId, | |||||
| dop.truckLanceCode AS truckLanceCode, | |||||
| dop.truckDepartureTime AS truckDepartureTime, | |||||
| COUNT(DISTINCT dop.shopCode) AS numberOfShopsToServe, | |||||
| COUNT(DISTINCT dop.ticketNo) AS numberOfPickTickets, | |||||
| COALESCE(SUM(pol_cnt.cnt), 0) AS totalItemsToPick, | |||||
| SUM(CASE WHEN dop.ticketReleaseTime IS NOT NULL THEN 1 ELSE 0 END) AS numberOfTicketsReleased, | |||||
| MIN(dop.ticketReleaseTime) AS firstTicketStartTime, | |||||
| SUM(CASE WHEN dop.ticketCompleteDateTime IS NOT NULL THEN 1 ELSE 0 END) AS numberOfTicketsCompleted, | |||||
| MAX(dop.ticketCompleteDateTime) AS lastTicketEndTime | |||||
| FROM fpsmsdb.delivery_order_pick_order dop | |||||
| LEFT JOIN ( | |||||
| SELECT | |||||
| po.deliveryOrderPickOrderId AS dopId, | |||||
| COUNT(pol.id) AS cnt | |||||
| FROM fpsmsdb.pick_order po | |||||
| INNER JOIN fpsmsdb.pick_order_line pol | |||||
| ON pol.poId = po.id | |||||
| AND pol.deleted = 0 | |||||
| WHERE po.deleted = 0 | |||||
| AND po.deliveryOrderPickOrderId IS NOT NULL | |||||
| GROUP BY po.deliveryOrderPickOrderId | |||||
| ) pol_cnt | |||||
| ON pol_cnt.dopId = dop.id | |||||
| WHERE dop.deleted = 0 | |||||
| AND dop.requiredDeliveryDate = :targetDate | |||||
| AND dop.ticketStatus IN ('pending', 'released', 'completed') | |||||
| GROUP BY dop.storeId, dop.truckLanceCode, dop.truckDepartureTime | |||||
| ORDER BY dop.storeId, dop.truckDepartureTime | |||||
| """.trimIndent() | |||||
| val rows = jdbcDao.queryForList(sql, mapOf("targetDate" to targetDate)) | |||||
| fun str(row: Map<String, Any?>, key: String): String? = row[key]?.toString() | |||||
| fun intVal(row: Map<String, Any?>, key: String): Int = | |||||
| when (val v = row[key]) { | |||||
| null -> 0 | |||||
| is Number -> v.toInt() | |||||
| else -> v.toString().toBigDecimalOrNull()?.toInt() ?: 0 | |||||
| } | } | ||||
| // Count released tickets (ticketReleaseTime is not null) | |||||
| val releasedTickets = tickets.count { it.ticketReleaseTime != null } | |||||
| // Find first ticket start time (earliest ticketReleaseTime) | |||||
| val firstTicketStartTime = tickets | |||||
| .mapNotNull { it.ticketReleaseTime } | |||||
| .minOrNull() | |||||
| // Count completed tickets (ticketCompleteDateTime is not null) | |||||
| val completedTickets = tickets.count { it.ticketCompleteDateTime != null } | |||||
| // Find last ticket end time (latest ticketCompleteDateTime) | |||||
| val lastTicketEndTime = tickets | |||||
| .mapNotNull { it.ticketCompleteDateTime } | |||||
| .maxOrNull() | |||||
| // Calculate pick time taken in minutes | |||||
| val pickTimeTakenMinutes = if (firstTicketStartTime != null && lastTicketEndTime != null) { | |||||
| ChronoUnit.MINUTES.between(firstTicketStartTime, lastTicketEndTime) | |||||
| } else { | |||||
| null | |||||
| fun timeVal(row: Map<String, Any?>, key: String): java.time.LocalTime? = | |||||
| when (val v = row[key]) { | |||||
| null -> null | |||||
| is java.time.LocalTime -> v | |||||
| is java.sql.Time -> v.toLocalTime() | |||||
| is java.time.OffsetTime -> v.toLocalTime() | |||||
| is String -> runCatching { java.time.LocalTime.parse(v) }.getOrNull() | |||||
| else -> null | |||||
| } | } | ||||
| // Get truck ID (use first non-null) | |||||
| val truckId = tickets.firstOrNull { it.truckId != null }?.truckId | |||||
| fun dtVal(row: Map<String, Any?>, key: String): LocalDateTime? = | |||||
| when (val v = row[key]) { | |||||
| null -> null | |||||
| is LocalDateTime -> v | |||||
| is java.sql.Timestamp -> v.toLocalDateTime() | |||||
| is String -> runCatching { LocalDateTime.parse(v) }.getOrNull() | |||||
| else -> null | |||||
| } | |||||
| return rows.map { row -> | |||||
| val first = dtVal(row, "firstTicketStartTime") | |||||
| val last = dtVal(row, "lastTicketEndTime") | |||||
| val minutes = if (first != null && last != null) ChronoUnit.MINUTES.between(first, last) else null | |||||
| TruckScheduleDashboardResponse( | TruckScheduleDashboardResponse( | ||||
| storeId = storeId, | |||||
| truckId = truckId, | |||||
| truckLanceCode = truckLanceCode, | |||||
| truckDepartureTime = truckDepartureTime, | |||||
| numberOfShopsToServe = distinctShops, | |||||
| numberOfPickTickets = distinctTickets, | |||||
| totalItemsToPick = totalItems, | |||||
| numberOfTicketsReleased = releasedTickets, | |||||
| firstTicketStartTime = firstTicketStartTime, | |||||
| numberOfTicketsCompleted = completedTickets, | |||||
| lastTicketEndTime = lastTicketEndTime, | |||||
| pickTimeTakenMinutes = pickTimeTakenMinutes | |||||
| storeId = str(row, "storeId"), | |||||
| truckId = null, | |||||
| truckLanceCode = str(row, "truckLanceCode"), | |||||
| truckDepartureTime = timeVal(row, "truckDepartureTime"), | |||||
| numberOfShopsToServe = intVal(row, "numberOfShopsToServe"), | |||||
| numberOfPickTickets = intVal(row, "numberOfPickTickets"), | |||||
| totalItemsToPick = intVal(row, "totalItemsToPick"), | |||||
| numberOfTicketsReleased = intVal(row, "numberOfTicketsReleased"), | |||||
| firstTicketStartTime = first, | |||||
| numberOfTicketsCompleted = intVal(row, "numberOfTicketsCompleted"), | |||||
| lastTicketEndTime = last, | |||||
| pickTimeTakenMinutes = minutes, | |||||
| ) | ) | ||||
| }.sortedWith(compareBy({ it.storeId }, { it.truckDepartureTime })) | |||||
| } | |||||
| } | } | ||||
| private fun countFGItemsById(doPickOrderId: Long): Int { | private fun countFGItemsById(doPickOrderId: Long): Int { | ||||
| @@ -110,7 +110,7 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| "4/F" -> "4/F" | "4/F" -> "4/F" | ||||
| else -> request.storeId | else -> request.storeId | ||||
| } | } | ||||
| println(" DEBUG: assignByLaneForWorkbench storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}") | |||||
| println(" DEBUG: assignByLaneForWorkbench storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime} seq=${request.loadingSequence}") | |||||
| val params = mutableMapOf<String, Any>( | val params = mutableMapOf<String, Any>( | ||||
| "storeId" to actualStoreId, | "storeId" to actualStoreId, | ||||
| @@ -140,12 +140,21 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| sql.append(" AND dop.truckDepartureTime = :depTime ") | sql.append(" AND dop.truckDepartureTime = :depTime ") | ||||
| params["depTime"] = depSqlTime | params["depTime"] = depSqlTime | ||||
| } | } | ||||
| if (request.loadingSequence != null) { | |||||
| sql.append(" AND dop.loadingSequence = :loadingSequence ") | |||||
| params["loadingSequence"] = request.loadingSequence | |||||
| } | |||||
| // Fetch a batch of candidates and try atomic-assign sequentially. | // Fetch a batch of candidates and try atomic-assign sequentially. | ||||
| // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. | // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. | ||||
| val candidateLimit = 50 | val candidateLimit = 50 | ||||
| val maxRounds = 3 | val maxRounds = 3 | ||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT $candidateLimit ") | |||||
| val shouldOrderBySequence = actualStoreId == "2/F" && request.loadingSequence == null | |||||
| if (shouldOrderBySequence) { | |||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT $candidateLimit ") | |||||
| } else { | |||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT $candidateLimit ") | |||||
| } | |||||
| fun extractIds(rows: List<Map<String, Any?>>): List<Long> { | fun extractIds(rows: List<Map<String, Any?>>): List<Long> { | ||||
| if (rows.isEmpty()) return emptyList() | if (rows.isEmpty()) return emptyList() | ||||
| @@ -205,7 +214,7 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| "4/F" -> "4/F" | "4/F" -> "4/F" | ||||
| else -> request.storeId | else -> request.storeId | ||||
| } | } | ||||
| println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}") | |||||
| println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime} seq=${request.loadingSequence}") | |||||
| val params = mutableMapOf<String, Any>( | val params = mutableMapOf<String, Any>( | ||||
| "storeId" to actualStoreId, | "storeId" to actualStoreId, | ||||
| @@ -234,7 +243,16 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| sql.append(" AND dop.truckDepartureTime = :depTime ") | sql.append(" AND dop.truckDepartureTime = :depTime ") | ||||
| params["depTime"] = depSqlTime | params["depTime"] = depSqlTime | ||||
| } | } | ||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT 1 ") | |||||
| if (request.loadingSequence != null) { | |||||
| sql.append(" AND dop.loadingSequence = :loadingSequence ") | |||||
| params["loadingSequence"] = request.loadingSequence | |||||
| } | |||||
| val shouldOrderBySequenceV1 = actualStoreId == "2/F" && request.loadingSequence == null | |||||
| if (shouldOrderBySequenceV1) { | |||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT 1 ") | |||||
| } else { | |||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT 1 ") | |||||
| } | |||||
| val candidates = try { | val candidates = try { | ||||
| jdbcDao.queryForList(sql.toString(), params) | jdbcDao.queryForList(sql.toString(), params) | ||||
| @@ -477,27 +477,7 @@ open class DoWorkbenchMainService( | |||||
| val solSnapshot = infos.joinToString("; ") { info -> | val solSnapshot = infos.joinToString("; ") { info -> | ||||
| "sol${info.id} st=${info.status} qty=${info.qty} picked=${WorkbenchStockOutLinePickProgress.isCountedAsPicked(info)}" | "sol${info.id} st=${info.status} qty=${info.qty} picked=${WorkbenchStockOutLinePickProgress.isCountedAsPicked(info)}" | ||||
| } | } | ||||
| /* | |||||
| log.info( | |||||
| "WORKBENCH_SCAN_TRACE polId={} solId={} scanLotNo={} scanIllId={} polRequired={} [{}] endedSumOthers={} currentSolQty={} remainingPol={} splMatchQty={} chunkTarget={} stillNeedOnThisSol={} requestedCap={} availScanLot={} plannedDelta={} lotSplit={}", | |||||
| polId, | |||||
| sol.id, | |||||
| lotNo, | |||||
| scannedIll.id, | |||||
| required, | |||||
| solSnapshot, | |||||
| endedSumOthers, | |||||
| currentQtyBd, | |||||
| remainingPol, | |||||
| splForLot?.qty, | |||||
| chunkTarget, | |||||
| stillNeedOnThisSol, | |||||
| requestedCap, | |||||
| availablePickable, | |||||
| plannedDelta, | |||||
| isLotExhaustedSplit, // initial trace only | |||||
| ) | |||||
| */ | |||||
| val prepMs = lapMs() | val prepMs = lapMs() | ||||
| // retry-related state | // retry-related state | ||||
| @@ -608,12 +588,15 @@ sol.id?.let { suggestedPickLotWorkbenchService.linkSplToStockOutLineAfterWorkben | |||||
| val saveSolMs = lapMs() | val saveSolMs = lapMs() | ||||
| val pickOrderId = pol.pickOrder?.id | val pickOrderId = pol.pickOrder?.id | ||||
| val poType = pol.pickOrder?.type | |||||
| var rebuildMs = 0L | var rebuildMs = 0L | ||||
| var ensureMs = 0L | var ensureMs = 0L | ||||
| var polPartialMs = 0L | var polPartialMs = 0L | ||||
| var postMs = 0L | var postMs = 0L | ||||
| val effectiveExcludeWarehouseCodes = when (poType) { | |||||
| PickOrderType.JOB_ORDER -> request.excludeWarehouseCodes ?: emptyList() | |||||
| else -> request.excludeWarehouseCodes // null → DO 走 default;有傳則整份取代 default | |||||
| } | |||||
| if (pickOrderId != null) { | if (pickOrderId != null) { | ||||
| if (hasExplicitQty) { | if (hasExplicitQty) { | ||||
| rebuildMs = measureTimeMillis { | rebuildMs = measureTimeMillis { | ||||
| @@ -623,13 +606,13 @@ if (pickOrderId != null) { | |||||
| targetQty = explicitRemainder, | targetQty = explicitRemainder, | ||||
| storeId = request.storeId, | storeId = request.storeId, | ||||
| excludeInventoryLotLineId = scannedIll.id, | excludeInventoryLotLineId = scannedIll.id, | ||||
| excludeWarehouseCodes = request.excludeWarehouseCodes, | |||||
| excludeWarehouseCodes = effectiveExcludeWarehouseCodes, | |||||
| ) | ) | ||||
| } else { | } else { | ||||
| suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineExactQty( | suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineExactQty( | ||||
| pickOrderLineId = polId, | pickOrderLineId = polId, | ||||
| targetQty = BigDecimal.ZERO, | targetQty = BigDecimal.ZERO, | ||||
| excludeWarehouseCodes = request.excludeWarehouseCodes, | |||||
| excludeWarehouseCodes = effectiveExcludeWarehouseCodes, | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| @@ -656,7 +639,7 @@ if (pickOrderId != null) { | |||||
| suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( | suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( | ||||
| pickOrderLineId = polId, | pickOrderLineId = polId, | ||||
| storeId = request.storeId, | storeId = request.storeId, | ||||
| excludeWarehouseCodes = request.excludeWarehouseCodes, | |||||
| excludeWarehouseCodes = effectiveExcludeWarehouseCodes, | |||||
| ) | ) | ||||
| } | } | ||||
| ensureMs = measureTimeMillis { | ensureMs = measureTimeMillis { | ||||
| @@ -723,7 +706,8 @@ return MessageResponse( | |||||
| /** | /** | ||||
| * Lane summary for DO workbench: one card per **delivery_order_pick_order** ticket (not per pick_order). | * Lane summary for DO workbench: one card per **delivery_order_pick_order** ticket (not per pick_order). | ||||
| * `unassigned` = ticket still has at least one linked pick_order with assignTo null. | |||||
| * `unassigned` = tickets with `handledBy` null (aligned with normal FG `do_pick_order.handledBy` semantics). | |||||
| * `total` = all tickets in the lane/time bucket (including already assigned), so denominators match normal FG. | |||||
| */ | */ | ||||
| open fun getDeliveryOrderPickOrderSummaryByStore(storeId: String, requiredDate: LocalDate?, releaseType: String): StoreLaneSummary { | open fun getDeliveryOrderPickOrderSummaryByStore(storeId: String, requiredDate: LocalDate?, releaseType: String): StoreLaneSummary { | ||||
| val targetDate = requiredDate ?: LocalDate.now() | val targetDate = requiredDate ?: LocalDate.now() | ||||
| @@ -745,12 +729,7 @@ return MessageResponse( | |||||
| dop.loadingSequence AS loadingSequence, | dop.loadingSequence AS loadingSequence, | ||||
| COUNT(DISTINCT dop.id) AS total_cnt, | COUNT(DISTINCT dop.id) AS total_cnt, | ||||
| SUM( | SUM( | ||||
| CASE WHEN EXISTS ( | |||||
| SELECT 1 FROM fpsmsdb.pick_order po2 | |||||
| WHERE po2.deliveryOrderPickOrderId = dop.id | |||||
| AND po2.deleted = 0 | |||||
| AND po2.assignTo IS NULL | |||||
| ) THEN 1 ELSE 0 END | |||||
| CASE WHEN dop.handledBy IS NULL THEN 1 ELSE 0 END | |||||
| ) AS unassigned_cnt, | ) AS unassigned_cnt, | ||||
| GROUP_CONCAT( | GROUP_CONCAT( | ||||
| DISTINCT NULLIF(TRIM(dop.handlerName), '') | DISTINCT NULLIF(TRIM(dop.handlerName), '') | ||||
| @@ -761,7 +740,7 @@ return MessageResponse( | |||||
| WHERE dop.deleted = 0 | WHERE dop.deleted = 0 | ||||
| AND dop.storeId = :storeId | AND dop.storeId = :storeId | ||||
| AND dop.requiredDeliveryDate = :requiredDate | AND dop.requiredDeliveryDate = :requiredDate | ||||
| AND dop.ticketStatus IN ('pending', 'released', 'completed') | |||||
| AND dop.ticketStatus IN ('pending', 'released') | |||||
| AND EXISTS ( | AND EXISTS ( | ||||
| SELECT 1 FROM fpsmsdb.pick_order po | SELECT 1 FROM fpsmsdb.pick_order po | ||||
| WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 | WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 | ||||
| @@ -835,7 +814,7 @@ return MessageResponse( | |||||
| val loadingSequence = cellNullableInt(row, "loadingSequence") | val loadingSequence = cellNullableInt(row, "loadingSequence") | ||||
| val unassigned = cellNum(row, "unassigned_cnt", "unassignedCnt") | val unassigned = cellNum(row, "unassigned_cnt", "unassignedCnt") | ||||
| val total = cellNum(row, "total_cnt", "totalCnt") | val total = cellNum(row, "total_cnt", "totalCnt") | ||||
| if (unassigned <= 0) return@mapNotNull null | |||||
| if (total <= 0) return@mapNotNull null | |||||
| val sortTime = cellTime(row) ?: LocalTime.MIDNIGHT | val sortTime = cellTime(row) ?: LocalTime.MIDNIGHT | ||||
| val handlerName = cellStr(row, "handler_names") | val handlerName = cellStr(row, "handler_names") | ||||
| LaneAgg(sortTime, lance, loadingSequence, unassigned, total, handlerName) | LaneAgg(sortTime, lance, loadingSequence, unassigned, total, handlerName) | ||||
| @@ -931,12 +910,17 @@ return MessageResponse( | |||||
| ): List<ReleasedDoPickOrderListItem> = | ): List<ReleasedDoPickOrderListItem> = | ||||
| queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true) | queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true) | ||||
| /** | |||||
| * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). | |||||
| * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). | |||||
| */ | |||||
| open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | ||||
| shopName: String?, | shopName: String?, | ||||
| storeId: String?, | storeId: String?, | ||||
| truck: String?, | truck: String?, | ||||
| requiredDeliveryDate: LocalDate? = null, | |||||
| ): List<ReleasedDoPickOrderListItem> = | ): List<ReleasedDoPickOrderListItem> = | ||||
| queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = false) | |||||
| queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = false, equalsDeliveryDate = requiredDeliveryDate) | |||||
| /** | /** | ||||
| * Workbench completed tickets: query `delivery_order_pick_order` where `ticketStatus = completed`. | * Workbench completed tickets: query `delivery_order_pick_order` where `ticketStatus = completed`. | ||||
| @@ -1007,7 +991,10 @@ return MessageResponse( | |||||
| sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ") | sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ") | ||||
| params["dnPat"] = "%${request.deliveryNoteCode!!.trim()}%" | params["dnPat"] = "%${request.deliveryNoteCode!!.trim()}%" | ||||
| } | } | ||||
| if (!request.ticketNo.isNullOrBlank()) { | |||||
| sql.append(" AND dop.ticketNo LIKE :ticketNoPat ") | |||||
| params["ticketNoPat"] = "%${request.ticketNo!!.trim()}%" | |||||
| } | |||||
| sql.append(" GROUP BY dop.id ORDER BY dop.modified DESC ") | sql.append(" GROUP BY dop.id ORDER BY dop.modified DESC ") | ||||
| val rows: List<Map<String, Any?>> = try { | val rows: List<Map<String, Any?>> = try { | ||||
| @@ -1154,7 +1141,10 @@ return MessageResponse( | |||||
| sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ") | sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ") | ||||
| params["dnPat"] = "%${request.deliveryNoteCode!!.trim()}%" | params["dnPat"] = "%${request.deliveryNoteCode!!.trim()}%" | ||||
| } | } | ||||
| if (!request.ticketNo.isNullOrBlank()) { | |||||
| sql.append(" AND dop.ticketNo LIKE :ticketNoPat ") | |||||
| params["ticketNoPat"] = "%${request.ticketNo!!.trim()}%" | |||||
| } | |||||
| sql.append(" GROUP BY dop.id ORDER BY dop.modified DESC ") | sql.append(" GROUP BY dop.id ORDER BY dop.modified DESC ") | ||||
| val rows: List<Map<String, Any?>> = try { | val rows: List<Map<String, Any?>> = try { | ||||
| @@ -1527,11 +1517,18 @@ return MessageResponse( | |||||
| storeId: String?, | storeId: String?, | ||||
| truck: String?, | truck: String?, | ||||
| beforeToday: Boolean, | beforeToday: Boolean, | ||||
| equalsDeliveryDate: LocalDate? = null, | |||||
| ): List<ReleasedDoPickOrderListItem> { | ): List<ReleasedDoPickOrderListItem> { | ||||
| val today = LocalDate.now() | val today = LocalDate.now() | ||||
| val dateClause = | |||||
| if (beforeToday) " dop.requiredDeliveryDate < :today " else " dop.requiredDeliveryDate = :today " | |||||
| val params = mutableMapOf<String, Any>("today" to today) | |||||
| val params = mutableMapOf<String, Any>() | |||||
| val dateClause = if (beforeToday) { | |||||
| params["today"] = today | |||||
| " dop.requiredDeliveryDate < :today " | |||||
| } else { | |||||
| val target = equalsDeliveryDate ?: today | |||||
| params["targetDate"] = target | |||||
| " dop.requiredDeliveryDate = :targetDate " | |||||
| } | |||||
| val sqlBuilder = StringBuilder( | val sqlBuilder = StringBuilder( | ||||
| """ | """ | ||||
| SELECT | SELECT | ||||
| @@ -1583,7 +1580,8 @@ return MessageResponse( | |||||
| private fun mapRowToReleasedDoPickOrderListItem(row: Map<String, Any?>): ReleasedDoPickOrderListItem? { | private fun mapRowToReleasedDoPickOrderListItem(row: Map<String, Any?>): ReleasedDoPickOrderListItem? { | ||||
| val idKey = row.keys.find { it.equals("id", true) } ?: return null | val idKey = row.keys.find { it.equals("id", true) } ?: return null | ||||
| val id = (row[idKey] as? Number)?.toLong() ?: return null | |||||
| // MySQL BIGINT may come back as BigInteger, which is not a Kotlin Number — avoid dropping rows. | |||||
| val id = cellToLong(row[idKey]) ?: return null | |||||
| val rdKey = row.keys.find { it.equals("requiredDeliveryDate", true) } | val rdKey = row.keys.find { it.equals("requiredDeliveryDate", true) } | ||||
| val reqDate = when (val v = rdKey?.let { row[it] }) { | val reqDate = when (val v = rdKey?.let { row[it] }) { | ||||
| null -> null | null -> null | ||||
| @@ -1616,6 +1614,16 @@ return MessageResponse( | |||||
| ) | ) | ||||
| } | } | ||||
| private fun cellToLong(v: Any?): Long? { | |||||
| if (v == null) return null | |||||
| return when (v) { | |||||
| is Number -> v.toLong() | |||||
| is java.lang.Number -> v.longValue() | |||||
| is String -> v.trim().toLongOrNull() | |||||
| else -> v.toString().trim().toLongOrNull() | |||||
| } | |||||
| } | |||||
| /** | /** | ||||
| * DO workbench: header [delivery_order_pick_order] + lines from [pick_order.deliveryOrderPickOrderId] (no do_pick_order_line). | * DO workbench: header [delivery_order_pick_order] + lines from [pick_order.deliveryOrderPickOrderId] (no do_pick_order_line). | ||||
| */ | */ | ||||
| @@ -1646,13 +1654,15 @@ return MessageResponse( | |||||
| dop.ticketStatus as doTicketStatus | dop.ticketStatus as doTicketStatus | ||||
| FROM fpsmsdb.delivery_order_pick_order dop | FROM fpsmsdb.delivery_order_pick_order dop | ||||
| WHERE dop.handledBy = :userId | WHERE dop.handledBy = :userId | ||||
| AND dop.ticketStatus IN ('pending', 'released', 'completed') | |||||
| AND dop.ticketStatus IN ('pending', 'released') | |||||
| AND dop.deleted = 0 | AND dop.deleted = 0 | ||||
| AND EXISTS ( | AND EXISTS ( | ||||
| SELECT 1 | SELECT 1 | ||||
| FROM fpsmsdb.pick_order po | FROM fpsmsdb.pick_order po | ||||
| WHERE po.deliveryOrderPickOrderId = dop.id | WHERE po.deliveryOrderPickOrderId = dop.id | ||||
| AND po.assignTo = :userId | AND po.assignTo = :userId | ||||
| AND po.type = 'do' | AND po.type = 'do' | ||||
| AND po.deleted = 0 | AND po.deleted = 0 | ||||
| ) | ) | ||||
| @@ -1828,7 +1838,7 @@ return MessageResponse( | |||||
| /** DO workbench FG list from [delivery_order_pick_order] + linked [pick_order] (no do_pick_order_line). */ | /** DO workbench FG list from [delivery_order_pick_order] + linked [pick_order] (no do_pick_order_line). */ | ||||
| open fun getFgPickOrdersByUserIdWorkbench(userId: Long): List<Map<String, Any?>> { | open fun getFgPickOrdersByUserIdWorkbench(userId: Long): List<Map<String, Any?>> { | ||||
| try { | try { | ||||
| println(" Starting getFgPickOrdersByUserIdWorkbench with userId: $userId") | |||||
| //println(" Starting getFgPickOrdersByUserIdWorkbench with userId: $userId") | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| @@ -25,6 +25,12 @@ import org.springframework.orm.ObjectOptimisticLockingFailureException | |||||
| private const val WORKBENCH_RELEASE_RETRY_MAX = 3 | private const val WORKBENCH_RELEASE_RETRY_MAX = 3 | ||||
| /** 與 workbench 車線摘要一致:`車線-X`(預設車)不帶樓層 `storeId`。 */ | |||||
| private const val WORKBENCH_DEFAULT_TRUCK_LANCE_CODE = "車線-X" | |||||
| /** 票號 `TI-B-yyyyMMdd-{floor}-nnn` 中段;無樓層時用 `X` 與 2F/4F 區隔。 */ | |||||
| private const val WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK = "X" | |||||
| private fun isWorkbenchOptimisticLockFailure(t: Throwable?): Boolean { | private fun isWorkbenchOptimisticLockFailure(t: Throwable?): Boolean { | ||||
| var c: Throwable? = t | var c: Throwable? = t | ||||
| while (c != null) { | while (c != null) { | ||||
| @@ -114,21 +120,32 @@ open class DoWorkbenchReleaseService( | |||||
| } | } | ||||
| open fun startBatchReleaseAsync(ids: List<Long>, userId: Long): MessageResponse = | open fun startBatchReleaseAsync(ids: List<Long>, userId: Long): MessageResponse = | ||||
| startBatchReleaseAsyncInternal(ids, userId, useV2 = false) | |||||
| startBatchReleaseAsyncInternal(ids, userId, useV2 = false, dopReleaseType = "batch") | |||||
| /** | /** | ||||
| * V2: deferred suggested pick / stock out / stock out lines until [DoWorkbenchDopoAssignmentService] assigns the ticket. | * V2: deferred suggested pick / stock out / stock out lines until [DoWorkbenchDopoAssignmentService] assigns the ticket. | ||||
| */ | */ | ||||
| open fun startBatchReleaseAsyncV2(ids: List<Long>, userId: Long): MessageResponse = | open fun startBatchReleaseAsyncV2(ids: List<Long>, userId: Long): MessageResponse = | ||||
| startBatchReleaseAsyncInternal(ids, userId, useV2 = true) | |||||
| startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "batch") | |||||
| private fun startBatchReleaseAsyncInternal(ids: List<Long>, userId: Long, useV2: Boolean): MessageResponse { | |||||
| /** | |||||
| * V2 async for one (or few) DOs: [delivery_order_pick_order.releaseType] = `single`, ticket prefix `TI-S-` (aligned with legacy single DO pick tickets). | |||||
| */ | |||||
| open fun startBatchReleaseAsyncSingleV2(ids: List<Long>, userId: Long): MessageResponse = | |||||
| startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "single") | |||||
| private fun startBatchReleaseAsyncInternal( | |||||
| ids: List<Long>, | |||||
| userId: Long, | |||||
| useV2: Boolean, | |||||
| dopReleaseType: String, | |||||
| ): MessageResponse { | |||||
| if (ids.isEmpty()) { | if (ids.isEmpty()) { | ||||
| return MessageResponse( | return MessageResponse( | ||||
| id = null, | id = null, | ||||
| code = "NO_IDS", | code = "NO_IDS", | ||||
| name = null, | name = null, | ||||
| type = if (useV2) "workbench_batch_release_async_v2" else "workbench_batch_release_async", | |||||
| type = asyncJobType(useV2, dopReleaseType), | |||||
| message = "No delivery order ids provided", | message = "No delivery order ids provided", | ||||
| errorPosition = null, | errorPosition = null, | ||||
| entity = null | entity = null | ||||
| @@ -178,7 +195,7 @@ open class DoWorkbenchReleaseService( | |||||
| } | } | ||||
| try { | try { | ||||
| createAndLinkDeliveryOrderPickOrders(successResults) | |||||
| createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType) | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| // header-link failure shouldn't crash job; status.failed already includes per-DO failures | // header-link failure shouldn't crash job; status.failed already includes per-DO failures | ||||
| println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}") | println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}") | ||||
| @@ -207,8 +224,8 @@ open class DoWorkbenchReleaseService( | |||||
| id = null, | id = null, | ||||
| code = "STARTED", | code = "STARTED", | ||||
| name = null, | name = null, | ||||
| type = if (useV2) "workbench_batch_release_async_v2" else "workbench_batch_release_async", | |||||
| message = if (useV2) "Workbench batch release V2 started" else "Workbench batch release started", | |||||
| type = asyncJobType(useV2, dopReleaseType), | |||||
| message = asyncJobMessage(useV2, dopReleaseType), | |||||
| errorPosition = null, | errorPosition = null, | ||||
| entity = mapOf("jobId" to jobId, "total" to ids.size) | entity = mapOf("jobId" to jobId, "total" to ids.size) | ||||
| ) | ) | ||||
| @@ -312,7 +329,7 @@ open class DoWorkbenchReleaseService( | |||||
| } | } | ||||
| } | } | ||||
| val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults) | |||||
| val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch") | |||||
| if (!useV2) { | if (!useV2) { | ||||
| successResults.forEach { result -> | successResults.forEach { result -> | ||||
| try { | try { | ||||
| @@ -342,14 +359,17 @@ open class DoWorkbenchReleaseService( | |||||
| } | } | ||||
| /** | /** | ||||
| * Same visual format as batch DO pick tickets (`DoReleaseCoordinatorService`): `TI-B-yyyyMMdd-2F-001`. | |||||
| * Allocates the next 3-digit suffix by scanning existing `do_pick_order.ticket_no` and | |||||
| * `delivery_order_pick_order.ticketNo` with the same prefix (avoids `uk_dopo_ticket_no` clashes). | |||||
| * `TI-B-yyyyMMdd-2F-001` (batch) or `TI-S-yyyyMMdd-2F-001` (single), same suffix rules as [DoReleaseCoordinatorService] / legacy `do_pick_order`. | |||||
| */ | */ | ||||
| private fun nextDeliveryOrderPickOrderBatchTicketNo(requiredDate: LocalDate, storeDisplay: String): String { | |||||
| private fun nextDeliveryOrderPickOrderTicketNo( | |||||
| requiredDate: LocalDate, | |||||
| storeDisplay: String, | |||||
| ticketLetter: String, | |||||
| ): String { | |||||
| require(ticketLetter == "B" || ticketLetter == "S") { "ticketLetter must be B or S" } | |||||
| val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")) | val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")) | ||||
| val floor = storeDisplay.replace("/", "").trim() | val floor = storeDisplay.replace("/", "").trim() | ||||
| val prefix = "TI-B-$ymd-$floor-" | |||||
| val prefix = "TI-$ticketLetter-$ymd-$floor-" | |||||
| val sql = """ | val sql = """ | ||||
| SELECT ticket_no AS t FROM fpsmsdb.do_pick_order WHERE deleted = 0 AND ticket_no LIKE CONCAT(:prefix, '%') | SELECT ticket_no AS t FROM fpsmsdb.do_pick_order WHERE deleted = 0 AND ticket_no LIKE CONCAT(:prefix, '%') | ||||
| UNION ALL | UNION ALL | ||||
| @@ -371,6 +391,32 @@ open class DoWorkbenchReleaseService( | |||||
| return "$prefix${next.toString().padStart(3, '0')}" | return "$prefix${next.toString().padStart(3, '0')}" | ||||
| } | } | ||||
| private fun nextDeliveryOrderPickOrderBatchTicketNo(requiredDate: LocalDate, storeDisplay: String): String = | |||||
| nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "B") | |||||
| private fun nextDeliveryOrderPickOrderSingleTicketNo(requiredDate: LocalDate, storeDisplay: String): String = | |||||
| nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "S") | |||||
| private fun asyncJobType(useV2: Boolean, dopReleaseType: String): String { | |||||
| val single = dopReleaseType.equals("single", ignoreCase = true) | |||||
| return when { | |||||
| useV2 && single -> "workbench_single_release_async_v2" | |||||
| useV2 -> "workbench_batch_release_async_v2" | |||||
| single -> "workbench_single_release_async" | |||||
| else -> "workbench_batch_release_async" | |||||
| } | |||||
| } | |||||
| private fun asyncJobMessage(useV2: Boolean, dopReleaseType: String): String { | |||||
| val single = dopReleaseType.equals("single", ignoreCase = true) | |||||
| return when { | |||||
| useV2 && single -> "Workbench single release V2 started" | |||||
| useV2 -> "Workbench batch release V2 started" | |||||
| single -> "Workbench single release started" | |||||
| else -> "Workbench batch release started" | |||||
| } | |||||
| } | |||||
| private fun getOrderedDeliveryOrderIds(ids: List<Long>): List<Long> { | private fun getOrderedDeliveryOrderIds(ids: List<Long>): List<Long> { | ||||
| if (ids.isEmpty()) return emptyList() | if (ids.isEmpty()) return emptyList() | ||||
| return try { | return try { | ||||
| @@ -388,9 +434,17 @@ open class DoWorkbenchReleaseService( | |||||
| } | } | ||||
| } | } | ||||
| private fun createAndLinkDeliveryOrderPickOrders(results: List<ReleaseDoResult>): Int { | |||||
| private fun createAndLinkDeliveryOrderPickOrders( | |||||
| results: List<ReleaseDoResult>, | |||||
| dopReleaseType: String = "batch", | |||||
| ): Int { | |||||
| if (results.isEmpty()) return 0 | if (results.isEmpty()) return 0 | ||||
| val releaseTypeCol = when (dopReleaseType.lowercase()) { | |||||
| "single" -> "single" | |||||
| else -> "batch" | |||||
| } | |||||
| val grouped = results.groupBy { | val grouped = results.groupBy { | ||||
| listOf( | listOf( | ||||
| it.shopId?.toString() ?: "", | it.shopId?.toString() ?: "", | ||||
| @@ -405,13 +459,29 @@ open class DoWorkbenchReleaseService( | |||||
| var createdHeaders = 0 | var createdHeaders = 0 | ||||
| grouped.values.forEach { group -> | grouped.values.forEach { group -> | ||||
| val first = group.first() | val first = group.first() | ||||
| val storeId = when (first.preferredFloor) { | |||||
| "2F" -> "2/F" | |||||
| "4F" -> "4/F" | |||||
| else -> "2/F" | |||||
| val isDefaultTruckLane = | |||||
| first.usedDefaultTruck == true || | |||||
| first.truckLanceCode?.trim() == WORKBENCH_DEFAULT_TRUCK_LANCE_CODE | |||||
| val storeId: String? = if (isDefaultTruckLane) { | |||||
| null | |||||
| } else { | |||||
| when (first.preferredFloor) { | |||||
| "2F" -> "2/F" | |||||
| "4F" -> "4/F" | |||||
| else -> "2/F" | |||||
| } | |||||
| } | |||||
| val ticketFloorSegment = if (isDefaultTruckLane) { | |||||
| WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK | |||||
| } else { | |||||
| (storeId ?: "2/F").replace("/", "").trim() | |||||
| } | } | ||||
| val requiredDate = first.estimatedArrivalDate ?: LocalDate.now() | val requiredDate = first.estimatedArrivalDate ?: LocalDate.now() | ||||
| val tempTicket = nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, storeId) | |||||
| val tempTicket = if (releaseTypeCol == "single") { | |||||
| nextDeliveryOrderPickOrderSingleTicketNo(requiredDate, ticketFloorSegment) | |||||
| } else { | |||||
| nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment) | |||||
| } | |||||
| val now = LocalDateTime.now() | val now = LocalDateTime.now() | ||||
| // Column names must match Liquibase `01_alter_stock_take.sql` (camelCase), not snake_case. | // Column names must match Liquibase `01_alter_stock_take.sql` (camelCase), not snake_case. | ||||
| @@ -425,7 +495,7 @@ open class DoWorkbenchReleaseService( | |||||
| ) VALUES ( | ) VALUES ( | ||||
| :truckId, :shopId, :storeId, :requiredDeliveryDate, :truckDepartureTime, | :truckId, :shopId, :storeId, :requiredDeliveryDate, :truckDepartureTime, | ||||
| :truckLanceCode, :shopCode, :shopName, :loadingSequence, :ticketNo, | :truckLanceCode, :shopCode, :shopName, :loadingSequence, :ticketNo, | ||||
| NULL, 'pending', 'batch', NULL, NULL, | |||||
| NULL, 'pending', :releaseType, NULL, NULL, | |||||
| :created, :createdBy, 0, :modified, :modifiedBy, 0 | :created, :createdBy, 0, :modified, :modifiedBy, 0 | ||||
| ) | ) | ||||
| """.trimIndent(), | """.trimIndent(), | ||||
| @@ -440,6 +510,7 @@ open class DoWorkbenchReleaseService( | |||||
| "shopName" to first.shopName, | "shopName" to first.shopName, | ||||
| "loadingSequence" to first.loadingSequence, | "loadingSequence" to first.loadingSequence, | ||||
| "ticketNo" to tempTicket, | "ticketNo" to tempTicket, | ||||
| "releaseType" to releaseTypeCol, | |||||
| "created" to now, | "created" to now, | ||||
| "createdBy" to "system", | "createdBy" to "system", | ||||
| "modified" to now, | "modified" to now, | ||||
| @@ -0,0 +1,55 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||||
| import java.util.Locale | |||||
| /** | |||||
| * 車線搜尋正規化(search-do-lite-v2)。 | |||||
| * 實際 [com.ffii.fpsms.modules.pickOrder.entity.Truck] 編碼主要為 `車線-…` 或 `P06B_…`;未指派預設列為 `車線-X`(shopId 可為 null)。 | |||||
| */ | |||||
| object TruckLaneSearchSpec { | |||||
| const val UNASSIGNED_LANE_LABEL: String = "車線-X" | |||||
| sealed interface Mode { | |||||
| data object NoFilter : Mode | |||||
| /** 僅未指派:推算為 null/空白/字面量 車線-X(與預設車列一致) */ | |||||
| data object UnassignedOnly : Mode | |||||
| /** | |||||
| * 一般關鍵字:以 [needleLower](trim + [Locale.ROOT] lowercase)對推算車線做 [String.contains]。 | |||||
| * 不再因「`車線-` 開頭」額外併入未指派,避免搜 `車線-待1` 卻出現 `車線-X`;廣義條件(無車線欄位)仍由 [NoFilter] 帶出含 X 的列。 | |||||
| */ | |||||
| data class Keyword( | |||||
| val needleLower: String, | |||||
| ) : Mode | |||||
| } | |||||
| fun parse(raw: String?): Mode { | |||||
| val trimmed = raw?.trim().orEmpty() | |||||
| if (trimmed.isEmpty()) return Mode.NoFilter | |||||
| if (isUnassignedSearchToken(trimmed)) return Mode.UnassignedOnly | |||||
| return Mode.Keyword( | |||||
| needleLower = trimmed.lowercase(Locale.ROOT), | |||||
| ) | |||||
| } | |||||
| private fun isUnassignedSearchToken(trimmed: String): Boolean { | |||||
| if (trimmed.length == 1 && trimmed.equals("x", ignoreCase = true)) return true | |||||
| val normalized = trimmed.lowercase(Locale.ROOT).replace("车线", "車線") | |||||
| return normalized == "車線-x" | |||||
| } | |||||
| fun isUnassignedResolvedLane(calculated: String?): Boolean { | |||||
| if (calculated.isNullOrBlank()) return true | |||||
| return calculated.trim().equals(UNASSIGNED_LANE_LABEL, ignoreCase = true) | |||||
| } | |||||
| fun matches(mode: Mode, resolvedTruckLanceCode: String?): Boolean { | |||||
| when (mode) { | |||||
| Mode.NoFilter -> return true | |||||
| Mode.UnassignedOnly -> return isUnassignedResolvedLane(resolvedTruckLanceCode) | |||||
| is Mode.Keyword -> { | |||||
| val lane = resolvedTruckLanceCode?.trim()?.lowercase(Locale.ROOT).orEmpty() | |||||
| return lane.contains(mode.needleLower) | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -70,7 +70,8 @@ class DeliveryOrderController( | |||||
| estimatedArrivalDate = request.estimatedArrivalDate, | estimatedArrivalDate = request.estimatedArrivalDate, | ||||
| pageNum = request.pageNum, | pageNum = request.pageNum, | ||||
| pageSize = request.pageSize, | pageSize = request.pageSize, | ||||
| truckLanceCode = request.truckLanceCode | |||||
| truckLanceCode = request.truckLanceCode, | |||||
| floor = request.floor, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -86,6 +87,25 @@ class DeliveryOrderController( | |||||
| estimatedArrivalDate = request.estimatedArrivalDate, | estimatedArrivalDate = request.estimatedArrivalDate, | ||||
| pageNum = request.pageNum, | pageNum = request.pageNum, | ||||
| pageSize = request.pageSize, | pageSize = request.pageSize, | ||||
| floor = request.floor, | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * DO 輕量搜尋 v2:車線關鍵字正規化(`車線-X`/`x`/`車線-` 前綴併入未指派)、 | |||||
| * 允許供應商條件下分批掃描,避免單次載入過大;請求體同 [searchDoLite]。 | |||||
| */ | |||||
| @PostMapping("/search-do-lite-v2") | |||||
| fun searchDoLiteV2(@RequestBody request: SearchDeliveryOrderInfoRequest): RecordsRes<DeliveryOrderInfoLiteDto> { | |||||
| return deliveryOrderService.searchDoLiteByPageV2( | |||||
| code = request.code, | |||||
| shopName = request.shopName, | |||||
| status = request.status, | |||||
| estimatedArrivalDate = request.estimatedArrivalDate, | |||||
| pageNum = request.pageNum, | |||||
| pageSize = request.pageSize, | |||||
| truckLanceCode = request.truckLanceCode, | |||||
| floor = request.floor, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -110,9 +110,15 @@ class DoWorkbenchController( | |||||
| fun getWorkbenchReleasedDoPickOrdersToday( | fun getWorkbenchReleasedDoPickOrdersToday( | ||||
| @RequestParam(required = false) shopName: String?, | @RequestParam(required = false) shopName: String?, | ||||
| @RequestParam(required = false) storeId: String?, | @RequestParam(required = false) storeId: String?, | ||||
| @RequestParam(required = false) truck: String? | |||||
| @RequestParam(required = false) truck: String?, | |||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, | |||||
| ): List<ReleasedDoPickOrderListItem> { | ): List<ReleasedDoPickOrderListItem> { | ||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(shopName, storeId, truck) | |||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | |||||
| shopName, | |||||
| storeId, | |||||
| truck, | |||||
| requiredDeliveryDate = requiredDate, | |||||
| ) | |||||
| } | } | ||||
| @PostMapping("/assign-by-delivery-order-pick-order-id") | @PostMapping("/assign-by-delivery-order-pick-order-id") | ||||
| @@ -158,6 +164,19 @@ class DoWorkbenchController( | |||||
| return doWorkbenchReleaseService.startBatchReleaseAsyncV2(ids, userId) | return doWorkbenchReleaseService.startBatchReleaseAsyncV2(ids, userId) | ||||
| } | } | ||||
| /** | |||||
| * One delivery order, same release pipeline as [startWorkbenchBatchReleaseAsyncV2], but | |||||
| * [delivery_order_pick_order.releaseType] = `single` and ticket prefix `TI-S-` (not batch / `TI-B-`). | |||||
| * Body: JSON number (mirrors [DoPickOrderController.startBatchReleaseAsyncSingle]). | |||||
| */ | |||||
| @PostMapping("/batch-release/async-single-v2") | |||||
| fun startWorkbenchBatchReleaseAsyncSingleV2( | |||||
| @RequestBody doId: Long, | |||||
| @RequestParam(defaultValue = "1") userId: Long | |||||
| ): MessageResponse { | |||||
| return doWorkbenchReleaseService.startBatchReleaseAsyncSingleV2(listOf(doId), userId) | |||||
| } | |||||
| /** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */ | /** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */ | ||||
| @PostMapping("/batch-release/sync-v2") | @PostMapping("/batch-release/sync-v2") | ||||
| fun workbenchBatchReleaseSyncV2( | fun workbenchBatchReleaseSyncV2( | ||||
| @@ -30,5 +30,7 @@ data class SearchDeliveryOrderInfoRequest( | |||||
| val estimatedArrivalDate: LocalDateTime?, | val estimatedArrivalDate: LocalDateTime?, | ||||
| val pageSize: Int?, | val pageSize: Int?, | ||||
| val pageNum: Int?, | val pageNum: Int?, | ||||
| val truckLanceCode: String? | |||||
| val truckLanceCode: String?, | |||||
| /** `ALL`/`All`/null:P06B+P07+P06D;`2F`:P07+P06D;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */ | |||||
| val floor: String? = null, | |||||
| ) | ) | ||||
| @@ -3,11 +3,12 @@ package com.ffii.fpsms.modules.jobOrder.service | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository | import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository | ||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | ||||
| import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus | import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderLotsHierarchicalResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoWorkbenchResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderLotsHierarchicalWorkbenchResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoWorkbenchResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.LotDetailResponse | import com.ffii.fpsms.modules.jobOrder.web.model.LotDetailResponse | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoResponse | import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoResponse | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsWorkbenchResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.StockOutLineDetailResponse | import com.ffii.fpsms.modules.jobOrder.web.model.StockOutLineDetailResponse | ||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | import com.ffii.fpsms.modules.master.web.models.MessageResponse | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository | import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository | ||||
| @@ -194,7 +195,7 @@ open class JoWorkbenchMainService( | |||||
| /** | /** | ||||
| * Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line. | * Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line. | ||||
| */ | */ | ||||
| open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalResponse { | |||||
| open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { | |||||
| println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") | println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") | ||||
| println("pickOrderId: $pickOrderId") | println("pickOrderId: $pickOrderId") | ||||
| @@ -299,8 +300,8 @@ open class JoWorkbenchMainService( | |||||
| } | } | ||||
| val joPickOrders = joPickOrderRepository.findByPickOrderId(pickOrder.id!!) | val joPickOrders = joPickOrderRepository.findByPickOrderId(pickOrder.id!!) | ||||
| val pickOrderInfo = PickOrderInfoResponse( | |||||
| val pickOrderInfo = PickOrderInfoWorkbenchResponse( | |||||
| id = pickOrder.id, | id = pickOrder.id, | ||||
| code = pickOrder.code, | code = pickOrder.code, | ||||
| consoCode = pickOrder.consoCode, | consoCode = pickOrder.consoCode, | ||||
| @@ -310,10 +311,12 @@ open class JoWorkbenchMainService( | |||||
| type = pickOrder.type?.value, | type = pickOrder.type?.value, | ||||
| status = pickOrder.status?.value, | status = pickOrder.status?.value, | ||||
| assignTo = pickOrder.assignTo?.id, | assignTo = pickOrder.assignTo?.id, | ||||
| jobOrder = JobOrderBasicInfoResponse( | |||||
| jobOrder = JobOrderBasicInfoWorkbenchResponse( | |||||
| id = jobOrder.id!!, | id = jobOrder.id!!, | ||||
| code = jobOrder.code ?: "", | code = jobOrder.code ?: "", | ||||
| name = "Job Order ${jobOrder.code}" | |||||
| name = "Job Order ${jobOrder.code}", | |||||
| itemCode = jobOrder.bom?.code, | |||||
| itemName = jobOrder.bom?.name, | |||||
| ) | ) | ||||
| ) | ) | ||||
| @@ -342,7 +345,7 @@ open class JoWorkbenchMainService( | |||||
| val handlerNameInner = jpoInner?.handledBy?.let { uid -> | val handlerNameInner = jpoInner?.handledBy?.let { uid -> | ||||
| userService.find(uid).orElse(null)?.name | userService.find(uid).orElse(null)?.name | ||||
| } | } | ||||
| println("handlerName: $handlerNameInner") | |||||
| //println("handlerName: $handlerNameInner") | |||||
| val availableQty = if (sol?.status == "rejected") { | val availableQty = if (sol?.status == "rejected") { | ||||
| null | null | ||||
| } else { | } else { | ||||
| @@ -429,7 +432,7 @@ open class JoWorkbenchMainService( | |||||
| ) | ) | ||||
| } | } | ||||
| PickOrderLineWithLotsResponse( | |||||
| PickOrderLineWithLotsWorkbenchResponse( | |||||
| id = pol.id!!, | id = pol.id!!, | ||||
| itemId = item?.id, | itemId = item?.id, | ||||
| itemCode = item?.code, | itemCode = item?.code, | ||||
| @@ -445,7 +448,7 @@ open class JoWorkbenchMainService( | |||||
| ) | ) | ||||
| } | } | ||||
| JobOrderLotsHierarchicalResponse( | |||||
| JobOrderLotsHierarchicalWorkbenchResponse( | |||||
| pickOrder = pickOrderInfo, | pickOrder = pickOrderInfo, | ||||
| pickOrderLines = pickOrderLinesResult | pickOrderLines = pickOrderLinesResult | ||||
| ) | ) | ||||
| @@ -456,10 +459,10 @@ open class JoWorkbenchMainService( | |||||
| } | } | ||||
| } | } | ||||
| private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalResponse { | |||||
| private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse { | |||||
| println("❌ $message") | println("❌ $message") | ||||
| return JobOrderLotsHierarchicalResponse( | |||||
| pickOrder = PickOrderInfoResponse( | |||||
| return JobOrderLotsHierarchicalWorkbenchResponse( | |||||
| pickOrder = PickOrderInfoWorkbenchResponse( | |||||
| id = null, | id = null, | ||||
| code = null, | code = null, | ||||
| consoCode = null, | consoCode = null, | ||||
| @@ -467,7 +470,7 @@ open class JoWorkbenchMainService( | |||||
| type = null, | type = null, | ||||
| status = null, | status = null, | ||||
| assignTo = null, | assignTo = null, | ||||
| jobOrder = JobOrderBasicInfoResponse(0, "", "") | |||||
| jobOrder = JobOrderBasicInfoWorkbenchResponse(0, "", "",null,null) | |||||
| ), | ), | ||||
| pickOrderLines = emptyList() | pickOrderLines = emptyList() | ||||
| ) | ) | ||||
| @@ -332,7 +332,7 @@ fun getJobOrderPickOrderLotDetails( | |||||
| /** Workbench: available qty uses in−out (matches scan-pick); stockouts include suggested SPL qty when matched. */ | /** Workbench: available qty uses in−out (matches scan-pick); stockouts include suggested SPL qty when matched. */ | ||||
| @GetMapping("/all-lots-hierarchical-by-pick-order-workbench/{pickOrderId}") | @GetMapping("/all-lots-hierarchical-by-pick-order-workbench/{pickOrderId}") | ||||
| fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalResponse { | |||||
| fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { | |||||
| return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId) | return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId) | ||||
| } | } | ||||
| @@ -90,9 +90,45 @@ data class PickOrderInfoResponse( | |||||
| data class JobOrderBasicInfoResponse( | data class JobOrderBasicInfoResponse( | ||||
| val id: Long, | val id: Long, | ||||
| val code: String, | val code: String, | ||||
| val name: String | |||||
| val name: String, | |||||
| ) | |||||
| data class JobOrderLotsHierarchicalWorkbenchResponse( | |||||
| val pickOrder: PickOrderInfoWorkbenchResponse, | |||||
| val pickOrderLines: List<PickOrderLineWithLotsWorkbenchResponse> | |||||
| ) | ) | ||||
| data class PickOrderInfoWorkbenchResponse( | |||||
| val id: Long?, | |||||
| val code: String?, | |||||
| val consoCode: String?, | |||||
| val targetDate: String?, | |||||
| val type: String?, | |||||
| val status: String?, | |||||
| val assignTo: Long?, | |||||
| val jobOrder: JobOrderBasicInfoWorkbenchResponse | |||||
| ) | |||||
| data class PickOrderLineWithLotsWorkbenchResponse( | |||||
| val id: Long, | |||||
| val itemId: Long?, | |||||
| val itemCode: String?, | |||||
| val itemName: String?, | |||||
| val requiredQty: Double?, | |||||
| // Total available qty across all inventory lot lines for this item (used by JO pick UI) | |||||
| val totalAvailableQty: Double? = null, | |||||
| val uomCode: String?, | |||||
| val uomDesc: String?, | |||||
| val status: String?, | |||||
| val lots: List<LotDetailResponse>, | |||||
| val stockouts: List<StockOutLineDetailResponse> = emptyList(), | |||||
| val handler: String? | |||||
| ) | |||||
| data class JobOrderBasicInfoWorkbenchResponse( | |||||
| val id: Long, | |||||
| val code: String, | |||||
| val name: String, | |||||
| val itemCode: String?, | |||||
| val itemName: String?, | |||||
| ) | |||||
| data class PickOrderLineWithLotsResponse( | data class PickOrderLineWithLotsResponse( | ||||
| val id: Long, | val id: Long, | ||||
| val itemId: Long?, | val itemId: Long?, | ||||
| @@ -15,7 +15,7 @@ import java.time.LocalTime | |||||
| @Entity | @Entity | ||||
| @Table(name = "shop") | @Table(name = "shop") | ||||
| @SecondaryTable(name="Truck", pkJoinColumns = [PrimaryKeyJoinColumn(name = "shopId", referencedColumnName = "id")]) | |||||
| @SecondaryTable(name = "truck", pkJoinColumns = [PrimaryKeyJoinColumn(name = "shopId", referencedColumnName = "id")]) | |||||
| open class ShopAndTruck : BaseEntity<Long>() { | open class ShopAndTruck : BaseEntity<Long>() { | ||||
| // --- Shop fields --- | // --- Shop fields --- | ||||
| @@ -49,8 +49,9 @@ open class ShopAndTruck : BaseEntity<Long>() { | |||||
| @Column(table = "truck", name = "LoadingSequence") | @Column(table = "truck", name = "LoadingSequence") | ||||
| open var loadingSequence: Long? = null | open var loadingSequence: Long? = null | ||||
| @Column(table = "truck", name = "districtReference") | |||||
| open var districtReference: Long? = null | |||||
| @Size(max = 255) | |||||
| @Column(table = "truck", name = "districtReference", length = 255) | |||||
| open var districtReference: String? = null | |||||
| @Column(table = "truck", name = "Store_id") | @Column(table = "truck", name = "Store_id") | ||||
| open var storeId: String? = null | open var storeId: String? = null | ||||
| @@ -16,7 +16,7 @@ interface ShopAndTruck { | |||||
| val truckLanceCode: String? | val truckLanceCode: String? | ||||
| val departureTime: LocalTime? | val departureTime: LocalTime? | ||||
| val LoadingSequence: Long? | val LoadingSequence: Long? | ||||
| val districtReference: Long? | |||||
| val districtReference: String? | |||||
| val Store_id: String? | val Store_id: String? | ||||
| val remark: String? | val remark: String? | ||||
| val truckId: Long? | val truckId: Long? | ||||
| @@ -4,30 +4,52 @@ import com.ffii.core.utils.PdfUtils | |||||
| import com.ffii.core.utils.QrCodeUtil | import com.ffii.core.utils.QrCodeUtil | ||||
| import com.ffii.fpsms.modules.master.entity.EquipmentDetailRepository | import com.ffii.fpsms.modules.master.entity.EquipmentDetailRepository | ||||
| import com.ffii.fpsms.modules.master.web.ExportEquipmentQrCodeRequest | import com.ffii.fpsms.modules.master.web.ExportEquipmentQrCodeRequest | ||||
| import com.ffii.fpsms.modules.master.web.PrintEquipmentQrCodeRequest | |||||
| import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry | |||||
| import net.sf.jasperreports.engine.JasperCompileManager | import net.sf.jasperreports.engine.JasperCompileManager | ||||
| import net.sf.jasperreports.engine.JasperExportManager | |||||
| import net.sf.jasperreports.engine.JasperReport | |||||
| import net.sf.jasperreports.engine.JasperPrint | import net.sf.jasperreports.engine.JasperPrint | ||||
| import org.springframework.core.io.ClassPathResource | import org.springframework.core.io.ClassPathResource | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.io.FileNotFoundException | import java.io.FileNotFoundException | ||||
| import java.io.File | |||||
| import java.awt.GraphicsEnvironment | import java.awt.GraphicsEnvironment | ||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||
| import kotlinx.serialization.encodeToString | import kotlinx.serialization.encodeToString | ||||
| @Service | @Service | ||||
| class EquipmentQrCodeService( | class EquipmentQrCodeService( | ||||
| private val equipmentDetailRepository: EquipmentDetailRepository | |||||
| private val equipmentDetailRepository: EquipmentDetailRepository, | |||||
| private val printerService: PrinterService, | |||||
| ) { | ) { | ||||
| private val qrCodeHandleJrxmlPath = "qrCodeHandle/equipment_QrHandle.jrxml" | |||||
| fun exportEquipmentQrCode(request: ExportEquipmentQrCodeRequest): Map<String, Any> { | |||||
| val QRCODE_HANDLE_PDF = "qrCodeHandle/equipment_QrHandle.jrxml" | |||||
| val resource = ClassPathResource(QRCODE_HANDLE_PDF) | |||||
| /** | |||||
| * Compile the Jasper template once; compiling per request is expensive. | |||||
| */ | |||||
| private val qrCodeHandleReport: JasperReport by lazy { | |||||
| val resource = ClassPathResource(qrCodeHandleJrxmlPath) | |||||
| if (!resource.exists()) { | if (!resource.exists()) { | ||||
| throw FileNotFoundException("Report file not found: $QRCODE_HANDLE_PDF") | |||||
| throw FileNotFoundException("Report file not found: $qrCodeHandleJrxmlPath") | |||||
| } | } | ||||
| val inputStream = resource.inputStream | |||||
| val qrCodeHandleReport = JasperCompileManager.compileReport(inputStream) | |||||
| resource.inputStream.use { JasperCompileManager.compileReport(it) } | |||||
| } | |||||
| /** | |||||
| * Cache the chosen Chinese font family name (font scanning is expensive). | |||||
| */ | |||||
| private val chineseFontFamily: String by lazy { | |||||
| val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames | |||||
| availableFonts.find { family -> | |||||
| family.contains("SimSun", ignoreCase = true) || | |||||
| family.contains("Microsoft YaHei", ignoreCase = true) || | |||||
| family.contains("STSong", ignoreCase = true) || | |||||
| family.contains("SimHei", ignoreCase = true) | |||||
| } ?: "Arial Unicode MS" | |||||
| } | |||||
| fun exportEquipmentQrCode(request: ExportEquipmentQrCodeRequest): Map<String, Any> { | |||||
| val equipmentDetails = equipmentDetailRepository.findAllById(request.equipmentDetailIds) | val equipmentDetails = equipmentDetailRepository.findAllById(request.equipmentDetailIds) | ||||
| if (equipmentDetails.isEmpty()) { | if (equipmentDetails.isEmpty()) { | ||||
| throw IllegalArgumentException("No equipment details found for the provided equipment detail IDs: ${request.equipmentDetailIds}") | throw IllegalArgumentException("No equipment details found for the provided equipment detail IDs: ${request.equipmentDetailIds}") | ||||
| @@ -63,18 +85,10 @@ class EquipmentQrCodeService( | |||||
| } | } | ||||
| val params: MutableMap<String, Any> = mutableMapOf() | val params: MutableMap<String, Any> = mutableMapOf() | ||||
| val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames | |||||
| val chineseFont = availableFonts.find { | |||||
| it.contains("SimSun", ignoreCase = true) || | |||||
| it.contains("Microsoft YaHei", ignoreCase = true) || | |||||
| it.contains("STSong", ignoreCase = true) || | |||||
| it.contains("SimHei", ignoreCase = true) | |||||
| } ?: "Arial Unicode MS" | |||||
| params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" | params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" | ||||
| params["net.sf.jasperreports.default.pdf.embedded"] = true | params["net.sf.jasperreports.default.pdf.embedded"] = true | ||||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont | |||||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily | |||||
| val firstEquipmentDetail = equipmentDetails.firstOrNull() | val firstEquipmentDetail = equipmentDetails.firstOrNull() | ||||
| @@ -83,4 +97,23 @@ class EquipmentQrCodeService( | |||||
| "fileName" to (firstEquipmentDetail?.code ?: "equipment_qrcode") | "fileName" to (firstEquipmentDetail?.code ?: "equipment_qrcode") | ||||
| ) | ) | ||||
| } | } | ||||
| fun printEquipmentQrCode(request: PrintEquipmentQrCodeRequest) { | |||||
| val printer = printerService.findById(request.printerId) ?: throw NoSuchElementException("No such printer") | |||||
| val pdf = exportEquipmentQrCode(ExportEquipmentQrCodeRequest(request.equipmentDetailIds)) | |||||
| val jasperPrint = pdf["report"] as JasperPrint | |||||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | |||||
| try { | |||||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | |||||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | |||||
| printer.ip?.let { ip -> | |||||
| val port = printer.port ?: 9100 | |||||
| val driver = A4PrintDriverRegistry.getDriver(printer.brand) | |||||
| driver.print(tempPdfFile, ip, port, printQty) | |||||
| } | |||||
| } finally { | |||||
| tempPdfFile.delete() | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @@ -340,6 +340,7 @@ open class ItemsService( | |||||
| //println("Query result size: ${result.size}") | //println("Query result size: ${result.size}") | ||||
| // result.forEach { row -> println("Result row: $row") } | // result.forEach { row -> println("Result row: $row") } | ||||
| return result | return result | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| println("Error in getPickOrderItemsByPage: ${e.message}") | println("Error in getPickOrderItemsByPage: ${e.message}") | ||||
| e.printStackTrace() | e.printStackTrace() | ||||
| @@ -2,12 +2,17 @@ package com.ffii.fpsms.modules.master.service | |||||
| import com.ffii.core.utils.PdfUtils | import com.ffii.core.utils.PdfUtils | ||||
| import com.ffii.core.utils.QrCodeUtil | import com.ffii.core.utils.QrCodeUtil | ||||
| import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry | |||||
| import com.ffii.fpsms.modules.master.entity.WarehouseRepository | import com.ffii.fpsms.modules.master.entity.WarehouseRepository | ||||
| import com.ffii.fpsms.modules.master.web.ExportWarehouseQrCodeRequest | import com.ffii.fpsms.modules.master.web.ExportWarehouseQrCodeRequest | ||||
| import com.ffii.fpsms.modules.master.web.PrintWarehouseQrCodeRequest | |||||
| import net.sf.jasperreports.engine.JasperCompileManager | import net.sf.jasperreports.engine.JasperCompileManager | ||||
| import net.sf.jasperreports.engine.JasperExportManager | |||||
| import net.sf.jasperreports.engine.JasperReport | |||||
| import net.sf.jasperreports.engine.JasperPrint | import net.sf.jasperreports.engine.JasperPrint | ||||
| import org.springframework.core.io.ClassPathResource | import org.springframework.core.io.ClassPathResource | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.io.File | |||||
| import java.io.FileNotFoundException | import java.io.FileNotFoundException | ||||
| import java.awt.GraphicsEnvironment | import java.awt.GraphicsEnvironment | ||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||
| @@ -15,19 +20,32 @@ import kotlinx.serialization.encodeToString | |||||
| @Service | @Service | ||||
| class WarehouseQrCodeService( | class WarehouseQrCodeService( | ||||
| private val warehouseRepository: WarehouseRepository | |||||
| private val warehouseRepository: WarehouseRepository, | |||||
| private val printerService: PrinterService, | |||||
| ) { | ) { | ||||
| private val qrCodeHandleJrxmlPath = "qrCodeHandle/warehouse_QrHandle.jrxml" | |||||
| fun exportWarehouseQrCode(request: ExportWarehouseQrCodeRequest): Map<String, Any> { | |||||
| val QRCODE_HANDLE_PDF = "qrCodeHandle/warehouse_QrHandle.jrxml" | |||||
| val resource = ClassPathResource(QRCODE_HANDLE_PDF) | |||||
| /** Compile the Jasper template once; compiling per request is expensive. */ | |||||
| private val qrCodeHandleReport: JasperReport by lazy { | |||||
| val resource = ClassPathResource(qrCodeHandleJrxmlPath) | |||||
| if (!resource.exists()) { | if (!resource.exists()) { | ||||
| throw FileNotFoundException("Report file not found: $QRCODE_HANDLE_PDF") | |||||
| throw FileNotFoundException("Report file not found: $qrCodeHandleJrxmlPath") | |||||
| } | } | ||||
| val inputStream = resource.inputStream | |||||
| val qrCodeHandleReport = JasperCompileManager.compileReport(inputStream) | |||||
| resource.inputStream.use { JasperCompileManager.compileReport(it) } | |||||
| } | |||||
| /** Cache the chosen Chinese font family name (font scanning is expensive). */ | |||||
| private val chineseFontFamily: String by lazy { | |||||
| val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames | |||||
| availableFonts.find { family -> | |||||
| family.contains("SimSun", ignoreCase = true) || | |||||
| family.contains("Microsoft YaHei", ignoreCase = true) || | |||||
| family.contains("STSong", ignoreCase = true) || | |||||
| family.contains("SimHei", ignoreCase = true) | |||||
| } ?: "Arial Unicode MS" | |||||
| } | |||||
| fun exportWarehouseQrCode(request: ExportWarehouseQrCodeRequest): Map<String, Any> { | |||||
| val warehouses = warehouseRepository.findAllById(request.warehouseIds) | val warehouses = warehouseRepository.findAllById(request.warehouseIds) | ||||
| if (warehouses.isEmpty()) { | if (warehouses.isEmpty()) { | ||||
| throw IllegalArgumentException("No warehouses found for the provided warehouse IDs: ${request.warehouseIds}") | throw IllegalArgumentException("No warehouses found for the provided warehouse IDs: ${request.warehouseIds}") | ||||
| @@ -68,18 +86,10 @@ class WarehouseQrCodeService( | |||||
| } | } | ||||
| val params: MutableMap<String, Any> = mutableMapOf() | val params: MutableMap<String, Any> = mutableMapOf() | ||||
| val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames | |||||
| val chineseFont = availableFonts.find { | |||||
| it.contains("SimSun", ignoreCase = true) || | |||||
| it.contains("Microsoft YaHei", ignoreCase = true) || | |||||
| it.contains("STSong", ignoreCase = true) || | |||||
| it.contains("SimHei", ignoreCase = true) | |||||
| } ?: "Arial Unicode MS" | |||||
| params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" | params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" | ||||
| params["net.sf.jasperreports.default.pdf.embedded"] = true | params["net.sf.jasperreports.default.pdf.embedded"] = true | ||||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont | |||||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily | |||||
| val firstWarehouse = warehouses.firstOrNull() | val firstWarehouse = warehouses.firstOrNull() | ||||
| @@ -88,4 +98,23 @@ class WarehouseQrCodeService( | |||||
| "fileName" to (firstWarehouse?.code ?: "warehouse_qrcode") | "fileName" to (firstWarehouse?.code ?: "warehouse_qrcode") | ||||
| ) | ) | ||||
| } | } | ||||
| fun printWarehouseQrCode(request: PrintWarehouseQrCodeRequest) { | |||||
| val printer = printerService.findById(request.printerId) ?: throw NoSuchElementException("No such printer") | |||||
| val pdf = exportWarehouseQrCode(ExportWarehouseQrCodeRequest(request.warehouseIds)) | |||||
| val jasperPrint = pdf["report"] as JasperPrint | |||||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | |||||
| try { | |||||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | |||||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | |||||
| printer.ip?.let { ip -> | |||||
| val port = printer.port ?: 9100 | |||||
| val driver = A4PrintDriverRegistry.getDriver(printer.brand) | |||||
| driver.print(tempPdfFile, ip, port, printQty) | |||||
| } | |||||
| } finally { | |||||
| tempPdfFile.delete() | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @@ -93,4 +93,9 @@ fun getAllEquipmentByPage( | |||||
| out.flush() | out.flush() | ||||
| } | } | ||||
| @PostMapping("/print-qrcode") | |||||
| fun printQrCode(@Valid @RequestBody request: PrintEquipmentQrCodeRequest) { | |||||
| equipmentQrCodeService.printEquipmentQrCode(request) | |||||
| } | |||||
| } | } | ||||
| @@ -0,0 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.master.web | |||||
| data class PrintEquipmentQrCodeRequest( | |||||
| val equipmentDetailIds: List<Long>, | |||||
| val printerId: Long, | |||||
| val printQty: Int? = 1, | |||||
| ) | |||||
| @@ -0,0 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.master.web | |||||
| data class PrintWarehouseQrCodeRequest( | |||||
| val warehouseIds: List<Long>, | |||||
| val printerId: Long, | |||||
| val printQty: Int? = 1, | |||||
| ) | |||||
| @@ -98,6 +98,10 @@ class WarehouseController( | |||||
| out.write(JasperExportManager.exportReportToPdf(jasperPrint)) | out.write(JasperExportManager.exportReportToPdf(jasperPrint)) | ||||
| out.flush() | out.flush() | ||||
| } | } | ||||
| @PostMapping("/print-qrcode") | |||||
| fun printQrCode(@Valid @RequestBody request: PrintWarehouseQrCodeRequest) { | |||||
| warehouseQrCodeService.printWarehouseQrCode(request) | |||||
| } | |||||
| @GetMapping("/stockTakeSections") | @GetMapping("/stockTakeSections") | ||||
| fun getStockTakeSections(): List<StockTakeSectionInfo> { | fun getStockTakeSections(): List<StockTakeSectionInfo> { | ||||
| return warehouseService.getStockTakeSections() | return warehouseService.getStockTakeSections() | ||||
| @@ -35,8 +35,9 @@ open class Truck : BaseEntity<Long>() { | |||||
| @Column(name = "Store_id") | @Column(name = "Store_id") | ||||
| open var storeId: String? = null | open var storeId: String? = null | ||||
| @Column(name = "districtReference") | |||||
| open var districtReference: Int? = null | |||||
| @Size(max = 255) | |||||
| @Column(name = "districtReference", length = 255) | |||||
| open var districtReference: String? = null | |||||
| @Column(name = "remark") | @Column(name = "remark") | ||||
| open var remark: String? = null | open var remark: String? = null | ||||
| @@ -1624,16 +1624,17 @@ open class PickOrderService( | |||||
| } | } | ||||
| throw IllegalStateException("Failed to generate unique delivery note code after $maxAttempts attempts") | throw IllegalStateException("Failed to generate unique delivery note code after $maxAttempts attempts") | ||||
| } | } | ||||
| /* | |||||
| @Transactional(rollbackFor = [java.lang.Exception::class]) | @Transactional(rollbackFor = [java.lang.Exception::class]) | ||||
| open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageResponse { | open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageResponse { | ||||
| try { | try { | ||||
| println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===") | |||||
| println("consoCode: $consoCode") | |||||
| // println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===") | |||||
| // println("consoCode: $consoCode") | |||||
| val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(consoCode) | val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(consoCode) | ||||
| if (stockOut == null) { | if (stockOut == null) { | ||||
| println("❌ No stock_out found for consoCode: $consoCode") | |||||
| //println("❌ No stock_out found for consoCode: $consoCode") | |||||
| return MessageResponse( | return MessageResponse( | ||||
| id = null, | id = null, | ||||
| name = "Stock out not found", | name = "Stock out not found", | ||||
| @@ -1680,13 +1681,13 @@ open class PickOrderService( | |||||
| !(isComplete || isRejected || isPartiallyComplete) | !(isComplete || isRejected || isPartiallyComplete) | ||||
| } | } | ||||
| println("📊 Stock out lines: ${stockOutLines.size}, Unfinished: ${unfinishedLines.size}") | |||||
| // println("📊 Stock out lines: ${stockOutLines.size}, Unfinished: ${unfinishedLines.size}") | |||||
| if (unfinishedLines.isEmpty()) { | if (unfinishedLines.isEmpty()) { | ||||
| println(" All stock out lines completed, updating pick order statuses...") | println(" All stock out lines completed, updating pick order statuses...") | ||||
| return completeStockOut(consoCode) | return completeStockOut(consoCode) | ||||
| } else { | } else { | ||||
| println("⏳ Still have ${unfinishedLines.size} unfinished lines") | |||||
| //println("⏳ Still have ${unfinishedLines.size} unfinished lines") | |||||
| return MessageResponse( | return MessageResponse( | ||||
| id = stockOut.id, | id = stockOut.id, | ||||
| name = stockOut.consoPickOrderCode ?: stockOut.deliveryOrderCode, | name = stockOut.consoPickOrderCode ?: stockOut.deliveryOrderCode, | ||||
| @@ -1710,8 +1711,47 @@ open class PickOrderService( | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| */ | |||||
| @Transactional(readOnly = true) | |||||
| open fun countUnfinishedLinesByConsoCode(consoCode: String): Int { | |||||
| val sql = """ | |||||
| SELECT COUNT(1) AS unfinished_count | |||||
| FROM stock_out_line sol | |||||
| JOIN pick_order_line pol ON pol.id = sol.pickOrderLineId | |||||
| JOIN pick_order po ON po.id = pol.poId | |||||
| WHERE po.consoCode = :consoCode | |||||
| AND sol.deleted = false | |||||
| AND LOWER(TRIM(COALESCE(sol.status, ''))) NOT IN ( | |||||
| 'completed', | |||||
| 'complete', | |||||
| 'rejected', | |||||
| 'partially_completed', | |||||
| 'partially_complete' | |||||
| ) | |||||
| """.trimIndent() | |||||
| val rows = jdbcDao.queryForList(sql, mapOf("consoCode" to consoCode)) | |||||
| val countAny = rows.firstOrNull()?.get("unfinished_count") | |||||
| return when (countAny) { | |||||
| is Number -> countAny.toInt() | |||||
| is String -> countAny.toIntOrNull() ?: 0 | |||||
| else -> 0 | |||||
| } | |||||
| } | |||||
| @Transactional(rollbackFor = [Exception::class]) | |||||
| open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageResponse { | |||||
| val unfinished = countUnfinishedLinesByConsoCode(consoCode) | |||||
| if (unfinished > 0) { | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = consoCode, | |||||
| code = "NOT_COMPLETED", | |||||
| type = "pickorder", | |||||
| message = "Pick order not completed yet, $unfinished lines remaining", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| return completeStockOut(consoCode) | |||||
| } | |||||
| open fun createGroup(name: String, targetDate: LocalDate, pickOrderId: Long?): PickOrderGroup { | open fun createGroup(name: String, targetDate: LocalDate, pickOrderId: Long?): PickOrderGroup { | ||||
| val group = PickOrderGroup().apply { | val group = PickOrderGroup().apply { | ||||
| this.name = name | this.name = name | ||||
| @@ -212,7 +212,7 @@ open class TruckService( | |||||
| // Use remark from request (user input) - no auto-fill | // Use remark from request (user input) - no auto-fill | ||||
| updateTruckLance.truckLanceCode = request.truckLanceCode | updateTruckLance.truckLanceCode = request.truckLanceCode | ||||
| updateTruckLance.loadingSequence = request.loadingSequence.toInt() | updateTruckLance.loadingSequence = request.loadingSequence.toInt() | ||||
| updateTruckLance.districtReference = request.districtReference.toInt() | |||||
| updateTruckLance.districtReference = request.districtReference | |||||
| updateTruckLance.departureTime = request.departureTime | updateTruckLance.departureTime = request.departureTime | ||||
| updateTruckLance.storeId = request.storeId | updateTruckLance.storeId = request.storeId | ||||
| // Only set remark if storeId is "4F", otherwise set to null | // Only set remark if storeId is "4F", otherwise set to null | ||||
| @@ -374,12 +374,14 @@ fun getCompletedDoPickOrdersWorkbench( | |||||
| @RequestParam(required = false) targetDate: String?, | @RequestParam(required = false) targetDate: String?, | ||||
| @RequestParam(required = false) deliveryNoteCode: String?, | @RequestParam(required = false) deliveryNoteCode: String?, | ||||
| @RequestParam(required = false) truckLanceCode: String?, | @RequestParam(required = false) truckLanceCode: String?, | ||||
| @RequestParam(required = false) ticketNo: String?, | |||||
| ): List<CompletedDoPickOrderResponse> { | ): List<CompletedDoPickOrderResponse> { | ||||
| val request = GetCompletedDoPickOrdersRequest( | val request = GetCompletedDoPickOrdersRequest( | ||||
| targetDate = targetDate, | targetDate = targetDate, | ||||
| shopName = shopName, | shopName = shopName, | ||||
| deliveryNoteCode = deliveryNoteCode, | deliveryNoteCode = deliveryNoteCode, | ||||
| truckLanceCode = truckLanceCode, | truckLanceCode = truckLanceCode, | ||||
| ticketNo = ticketNo, | |||||
| ) | ) | ||||
| return doWorkbenchMainService.getCompletedDoPickOrdersWorkbench(userId, request) | return doWorkbenchMainService.getCompletedDoPickOrdersWorkbench(userId, request) | ||||
| } | } | ||||
| @@ -390,12 +392,14 @@ fun getCompletedDoPickOrdersWorkbenchAll( | |||||
| @RequestParam(required = false) targetDate: String?, | @RequestParam(required = false) targetDate: String?, | ||||
| @RequestParam(required = false) deliveryNoteCode: String?, | @RequestParam(required = false) deliveryNoteCode: String?, | ||||
| @RequestParam(required = false) truckLanceCode: String?, | @RequestParam(required = false) truckLanceCode: String?, | ||||
| @RequestParam(required = false) ticketNo: String?, | |||||
| ): List<CompletedDoPickOrderResponse> { | ): List<CompletedDoPickOrderResponse> { | ||||
| val request = GetCompletedDoPickOrdersRequest( | val request = GetCompletedDoPickOrdersRequest( | ||||
| targetDate = targetDate, | targetDate = targetDate, | ||||
| shopName = shopName, | shopName = shopName, | ||||
| deliveryNoteCode = deliveryNoteCode, | deliveryNoteCode = deliveryNoteCode, | ||||
| truckLanceCode = truckLanceCode, | truckLanceCode = truckLanceCode, | ||||
| ticketNo = ticketNo, | |||||
| ) | ) | ||||
| return doWorkbenchMainService.getCompletedDoPickOrdersWorkbenchAll(request) | return doWorkbenchMainService.getCompletedDoPickOrdersWorkbenchAll(request) | ||||
| } | } | ||||
| @@ -10,14 +10,14 @@ data class SaveTruckRequest( | |||||
| val shopCode: String, | val shopCode: String, | ||||
| val loadingSequence: Int, | val loadingSequence: Int, | ||||
| val remark: String? = null, | val remark: String? = null, | ||||
| val districtReference: Int? = null, | |||||
| val districtReference: String? = null, | |||||
| ) | ) | ||||
| data class SaveTruckLane( | data class SaveTruckLane( | ||||
| val id: Long, | val id: Long, | ||||
| val truckLanceCode: String, | val truckLanceCode: String, | ||||
| val departureTime: LocalTime, | val departureTime: LocalTime, | ||||
| val loadingSequence: Long, | val loadingSequence: Long, | ||||
| val districtReference: Long, | |||||
| val districtReference: String?, | |||||
| val storeId: String, | val storeId: String, | ||||
| val remark: String? = null | val remark: String? = null | ||||
| ) | ) | ||||
| @@ -37,6 +37,6 @@ data class CreateTruckWithoutShopRequest( | |||||
| val truckLanceCode: String, | val truckLanceCode: String, | ||||
| val departureTime: LocalTime, | val departureTime: LocalTime, | ||||
| val loadingSequence: Int = 0, | val loadingSequence: Int = 0, | ||||
| val districtReference: Int? = null, | |||||
| val districtReference: String? = null, | |||||
| val remark: String? = null, | val remark: String? = null, | ||||
| ) | ) | ||||
| @@ -23,6 +23,7 @@ data class GetCompletedDoPickOrdersRequest( | |||||
| val deliveryNoteCode: String? = null, | val deliveryNoteCode: String? = null, | ||||
| /** 卡車 / 車道代碼(模糊匹配 truck_lance_code) */ | /** 卡車 / 車道代碼(模糊匹配 truck_lance_code) */ | ||||
| val truckLanceCode: String? = null, | val truckLanceCode: String? = null, | ||||
| val ticketNo: String? = null, | |||||
| ) | ) | ||||
| data class CompletedDoPickOrderResponse( | data class CompletedDoPickOrderResponse( | ||||
| @@ -107,7 +107,10 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| open fun getPoList(args: MutableMap<String, Any>): List<PurchaseOrderDataClass> { | |||||
| /** | |||||
| * `select * from ( ... one row per PO ... ) r [where r.itemDetail ...]` — same as the legacy list query, without LIMIT. | |||||
| */ | |||||
| private fun buildPoListUnpagedSelectSql(args: MutableMap<String, Any>): String { | |||||
| val sql = StringBuilder( | val sql = StringBuilder( | ||||
| "select * from ( " + | "select * from ( " + | ||||
| "select " + | "select " + | ||||
| @@ -211,9 +214,11 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| if (args.containsKey("itemDetail")){ | if (args.containsKey("itemDetail")){ | ||||
| sql.append(" where r.itemDetail like :itemDetail "); | sql.append(" where r.itemDetail like :itemDetail "); | ||||
| } | } | ||||
| val list = jdbcDao.queryForList(sql.toString(), args); | |||||
| return sql.toString() | |||||
| } | |||||
| val mappedList = list.map { | |||||
| private fun mapRowsToPoListDataClass(list: List<Map<String, Any>>): List<PurchaseOrderDataClass> { | |||||
| return list.map { | |||||
| PurchaseOrderDataClass( | PurchaseOrderDataClass( | ||||
| id = (it["id"] as Int).toLong(), | id = (it["id"] as Int).toLong(), | ||||
| code = it["code"] as String, | code = it["code"] as String, | ||||
| @@ -231,11 +236,32 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| escalated = it["escalated"] == 1L, | escalated = it["escalated"] == 1L, | ||||
| ) | ) | ||||
| } | } | ||||
| // println(value1) | |||||
| // println(value1 == 1L) | |||||
| // println(value2) | |||||
| // println(mappedList) | |||||
| return mappedList | |||||
| } | |||||
| open fun getPoListTotalCount(args: MutableMap<String, Any>): Int { | |||||
| val base = buildPoListUnpagedSelectSql(args) | |||||
| val countSql = "SELECT COUNT(1) AS cnt FROM ( $base ) po_list_count_wrap" | |||||
| val list = jdbcDao.queryForList(countSql, args) | |||||
| if (list.isEmpty()) { | |||||
| return 0 | |||||
| } | |||||
| return (list.first()["cnt"] as Number).toInt() | |||||
| } | |||||
| open fun getPoListPage( | |||||
| args: MutableMap<String, Any>, | |||||
| pageSize: Int, | |||||
| pageNum: Int, | |||||
| ): List<PurchaseOrderDataClass> { | |||||
| val size = pageSize.coerceAtLeast(1) | |||||
| val page = pageNum.coerceAtLeast(1) | |||||
| val pagedArgs: MutableMap<String, Any> = HashMap(args) | |||||
| pagedArgs["limit"] = size | |||||
| pagedArgs["offset"] = (page - 1) * size | |||||
| val dataSql = buildPoListUnpagedSelectSql(args) + | |||||
| " ORDER BY r.orderDate DESC LIMIT :limit OFFSET :offset" | |||||
| val list = jdbcDao.queryForList(dataSql, pagedArgs) | |||||
| return mapRowsToPoListDataClass(list) | |||||
| } | } | ||||
| open fun allPurchaseOrder(): List<PurchaseOrder> { | open fun allPurchaseOrder(): List<PurchaseOrder> { | ||||
| @@ -3,7 +3,6 @@ package com.ffii.fpsms.modules.purchaseOrder.web | |||||
| import com.ffii.core.response.RecordsRes | import com.ffii.core.response.RecordsRes | ||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import com.ffii.core.utils.CriteriaArgsBuilder | import com.ffii.core.utils.CriteriaArgsBuilder | ||||
| import com.ffii.core.utils.PagingUtils | |||||
| import com.ffii.core.utils.ZebraPrinterUtil | import com.ffii.core.utils.ZebraPrinterUtil | ||||
| import com.ffii.fpsms.modules.master.entity.Items | import com.ffii.fpsms.modules.master.entity.Items | ||||
| import com.ffii.fpsms.modules.master.service.ItemsService | import com.ffii.fpsms.modules.master.service.ItemsService | ||||
| @@ -49,13 +48,15 @@ class PurchaseOrderController( | |||||
| .addDate("estimatedArrivalDateTo") | .addDate("estimatedArrivalDateTo") | ||||
| .build() | .build() | ||||
| // println(criteriaArgs) | // println(criteriaArgs) | ||||
| val pageSize = request.getParameter("pageSize")?.toIntOrNull() ?: 10 | |||||
| val pageNum = request.getParameter("pageNum")?.toIntOrNull() ?: 1 | |||||
| val pageSize = request.getParameter("pageSize")?.toIntOrNull()?.coerceAtLeast(1) ?: 10 | |||||
| val pageNum = request.getParameter("pageNum")?.toIntOrNull()?.coerceAtLeast(1) ?: 1 | |||||
| val fullList = purchaseOrderService.getPoList(criteriaArgs) | |||||
| val paginatedList = PagingUtils.getPaginatedList(fullList,pageSize, pageNum) | |||||
| return RecordsRes(paginatedList, fullList.size) | |||||
| val total = purchaseOrderService.getPoListTotalCount(criteriaArgs) | |||||
| if (total == 0) { | |||||
| return RecordsRes(emptyList<PurchaseOrderDataClass>(), 0) | |||||
| } | |||||
| val pageRows = purchaseOrderService.getPoListPage(criteriaArgs, pageSize, pageNum) | |||||
| return RecordsRes(pageRows, total) | |||||
| } | } | ||||
| /** Class mapping is `/po`; path must be `/summary` → full path `/api/po/summary` (not `/po/po/summary`). */ | /** Class mapping is `/po`; path must be `/summary` → full path `/api/po/summary` (not `/po/po/summary`). */ | ||||
| @GetMapping("/summary") | @GetMapping("/summary") | ||||
| @@ -305,6 +305,8 @@ ORDER BY | |||||
| fun searchStockTakeVarianceReportV2( | fun searchStockTakeVarianceReportV2( | ||||
| stockTakeRoundId: Long, | stockTakeRoundId: Long, | ||||
| itemCode: String?, | itemCode: String?, | ||||
| storeId: String?, | |||||
| status: String?, | |||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val countSql = """ | val countSql = """ | ||||
| SELECT COUNT(*) AS c FROM stocktakerecord s | SELECT COUNT(*) AS c FROM stocktakerecord s | ||||
| @@ -320,6 +322,34 @@ ORDER BY | |||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| args["stockTakeRoundId"] = stockTakeRoundId | args["stockTakeRoundId"] = stockTakeRoundId | ||||
| val statusNormalized = status?.trim()?.lowercase().orEmpty() | |||||
| // status 映射规则: | |||||
| // - All/null:不过滤 | |||||
| // - pending:包含 pending/pass/notMatch | |||||
| // - completed:只看 completed | |||||
| val statusLatestSql = when (statusNormalized) { | |||||
| "pending" -> """ | |||||
| AND str.status IN ('pending', 'pass', 'notMatch') | |||||
| """.trimIndent() | |||||
| "completed" -> """ | |||||
| AND str.status = 'completed' | |||||
| """.trimIndent() | |||||
| else -> "" | |||||
| } | |||||
| val storeIdSql = run { | |||||
| val normalized = storeId?.trim() | |||||
| if (normalized.isNullOrBlank() || normalized.equals("all", ignoreCase = true)) { | |||||
| "" | |||||
| } else { | |||||
| args["storeId"] = normalized | |||||
| // DB 里 store_id 可能是 "2/F" 或 "2F";用 REPLACE 去斜線做匹配 | |||||
| """ | |||||
| AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '') | |||||
| """.trimIndent() | |||||
| } | |||||
| } | |||||
| val itemCodeSql = buildMultiValueLikeClause( | val itemCodeSql = buildMultiValueLikeClause( | ||||
| itemCode, | itemCode, | ||||
| "it.code", | "it.code", | ||||
| @@ -345,10 +375,12 @@ latest_str AS ( | |||||
| str.approverStockTakeQty, | str.approverStockTakeQty, | ||||
| str.date AS strDate, | str.date AS strDate, | ||||
| str.id, | str.id, | ||||
| str.approverTime | |||||
| str.approverTime, | |||||
| str.status AS stockTakeRecordStatus | |||||
| FROM stocktakerecord str | FROM stocktakerecord str | ||||
| WHERE str.deleted = 0 | WHERE str.deleted = 0 | ||||
| AND str.stockTakeRoundId = :stockTakeRoundId | AND str.stockTakeRoundId = :stockTakeRoundId | ||||
| $statusLatestSql | |||||
| ), | ), | ||||
| in_agg AS ( | in_agg AS ( | ||||
| SELECT | SELECT | ||||
| @@ -443,7 +475,8 @@ data AS ( | |||||
| ls.approverStockTakeQty AS stkApproverQty, | ls.approverStockTakeQty AS stkApproverQty, | ||||
| ls.varianceQty AS stkVarianceQty, | ls.varianceQty AS stkVarianceQty, | ||||
| ls.strDate AS stockTakeDateRaw, | ls.strDate AS stockTakeDateRaw, | ||||
| ls.approverTime AS approvalDateTimeRaw | |||||
| ls.approverTime AS approvalDateTimeRaw, | |||||
| ls.stockTakeRecordStatus AS stockTakeRecordStatus | |||||
| FROM latest_str ls | FROM latest_str ls | ||||
| INNER JOIN inventory_lot il | INNER JOIN inventory_lot il | ||||
| ON ls.lotId = il.id | ON ls.lotId = il.id | ||||
| @@ -471,6 +504,7 @@ data AS ( | |||||
| WHERE 1=1 | WHERE 1=1 | ||||
| $itemCodeSql | $itemCodeSql | ||||
| $storeIdSql | |||||
| ) | ) | ||||
| SELECT | SELECT | ||||
| @@ -501,12 +535,14 @@ SELECT | |||||
| END AS stockTakeQty, | END AS stockTakeQty, | ||||
| CASE | CASE | ||||
| WHEN COALESCE(stockTakeRecordStatus, '') <> 'completed' THEN '0' | |||||
| WHEN stkVarianceQty IS NULL THEN '0' | WHEN stkVarianceQty IS NULL THEN '0' | ||||
| WHEN COALESCE(stkVarianceQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkVarianceQty, 0), ')') | WHEN COALESCE(stkVarianceQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkVarianceQty, 0), ')') | ||||
| ELSE FORMAT(COALESCE(stkVarianceQty, 0), 0) | ELSE FORMAT(COALESCE(stkVarianceQty, 0), 0) | ||||
| END AS variance, | END AS variance, | ||||
| CASE | CASE | ||||
| WHEN COALESCE(stockTakeRecordStatus, '') <> 'completed' THEN '0%' | |||||
| WHEN stkVarianceQty IS NULL THEN '0%' | WHEN stkVarianceQty IS NULL THEN '0%' | ||||
| WHEN COALESCE(stkBookQty, 0) = 0 THEN '0%' | WHEN COALESCE(stkBookQty, 0) = 0 THEN '0%' | ||||
| WHEN (COALESCE(stkVarianceQty, 0) / stkBookQty) * 100 < 0 THEN CONCAT('(', FORMAT(-(COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%)') | WHEN (COALESCE(stkVarianceQty, 0) / stkBookQty) * 100 < 0 THEN CONCAT('(', FORMAT(-(COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%)') | ||||
| @@ -143,6 +143,8 @@ class StockTakeVarianceReportController( | |||||
| fun generateStockTakeVarianceReportV2( | fun generateStockTakeVarianceReportV2( | ||||
| @RequestParam stockTakeRoundId: Long, | @RequestParam stockTakeRoundId: Long, | ||||
| @RequestParam(required = false) itemCode: String?, | @RequestParam(required = false) itemCode: String?, | ||||
| @RequestParam(required = false, name = "store_id") storeId: String?, | |||||
| @RequestParam(required = false) status: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val parameters = mutableMapOf<String, Any>() | val parameters = mutableMapOf<String, Any>() | ||||
| @@ -169,6 +171,8 @@ class StockTakeVarianceReportController( | |||||
| val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( | val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( | ||||
| stockTakeRoundId = stockTakeRoundId, | stockTakeRoundId = stockTakeRoundId, | ||||
| itemCode = itemCode, | itemCode = itemCode, | ||||
| storeId = storeId, | |||||
| status = status, | |||||
| ) | ) | ||||
| val stockTakeDateDisplay = dbData | val stockTakeDateDisplay = dbData | ||||
| .mapNotNull { it["stockTakeDate"] as? String } | .mapNotNull { it["stockTakeDate"] as? String } | ||||
| @@ -196,11 +200,15 @@ class StockTakeVarianceReportController( | |||||
| fun exportStockTakeVarianceReportV2Excel( | fun exportStockTakeVarianceReportV2Excel( | ||||
| @RequestParam stockTakeRoundId: Long, | @RequestParam stockTakeRoundId: Long, | ||||
| @RequestParam(required = false) itemCode: String?, | @RequestParam(required = false) itemCode: String?, | ||||
| @RequestParam(required = false, name = "store_id") storeId: String?, | |||||
| @RequestParam(required = false) status: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val cap = stockTakeVarianceReportService.getStockTakeRoundCaption(stockTakeRoundId) | val cap = stockTakeVarianceReportService.getStockTakeRoundCaption(stockTakeRoundId) | ||||
| val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( | val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( | ||||
| stockTakeRoundId = stockTakeRoundId, | stockTakeRoundId = stockTakeRoundId, | ||||
| itemCode = itemCode, | itemCode = itemCode, | ||||
| storeId = storeId, | |||||
| status = status, | |||||
| ) | ) | ||||
| val excelBytes = createStockTakeVarianceExcel( | val excelBytes = createStockTakeVarianceExcel( | ||||
| @@ -40,6 +40,7 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||||
| fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo> | fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo> | ||||
| fun findByItemId(itemId: Long): Optional<Inventory> | fun findByItemId(itemId: Long): Optional<Inventory> | ||||
| fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection<Long>): List<Inventory> | |||||
| @Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false") | @Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false") | ||||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Inventory> | fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Inventory> | ||||
| @@ -9,6 +9,7 @@ interface StockTakeLineRepository : AbstractRepository<StockTakeLine, Long> { | |||||
| fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?; | fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?; | ||||
| fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?; | fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?; | ||||
| fun findByStockTakeRecord_IdAndDeletedIsFalse(stockTakeRecordId: Long): StockTakeLine? | fun findByStockTakeRecord_IdAndDeletedIsFalse(stockTakeRecordId: Long): StockTakeLine? | ||||
| fun findAllByStockTakeRecord_IdInAndDeletedIsFalse(stockTakeRecordIds: Collection<Long>): List<StockTakeLine> | |||||
| fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse( | fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse( | ||||
| stockTakeIds: Collection<Long>, | stockTakeIds: Collection<Long>, | ||||
| inventoryLotLineIds: Collection<Long> | inventoryLotLineIds: Collection<Long> | ||||
| @@ -440,8 +440,14 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit | |||||
| .filter { !it.deleted && it.inventoryLot?.item != null } | .filter { !it.deleted && it.inventoryLot?.item != null } | ||||
| .toList() | .toList() | ||||
| val item = source.firstOrNull()?.inventoryLot?.item | val item = source.firstOrNull()?.inventoryLot?.item | ||||
| ?: throw IllegalStateException("Item not found for itemId=$itemId") | |||||
| if (item == null) { | |||||
| return WorkbenchItemLotsResponse( | |||||
| itemId = itemId, | |||||
| itemCode = "", | |||||
| itemName = "", | |||||
| sameItemLots = emptyList() | |||||
| ) | |||||
| } | |||||
| val sameItemLots = source | val sameItemLots = source | ||||
| .mapNotNull { lotLine -> | .mapNotNull { lotLine -> | ||||
| val lot = lotLine.inventoryLot ?: return@mapNotNull null | val lot = lotLine.inventoryLot ?: return@mapNotNull null | ||||
| @@ -66,11 +66,14 @@ open class StockAdjustmentService( | |||||
| if (diff.compareTo(BigDecimal.ZERO) == 0) continue // Branch 1: no change | if (diff.compareTo(BigDecimal.ZERO) == 0) continue // Branch 1: no change | ||||
| if (diff.compareTo(BigDecimal.ZERO) > 0) { | if (diff.compareTo(BigDecimal.ZERO) > 0) { | ||||
| // Branch 2 (qty up): createStockIn | |||||
| // Branch 2 (qty up): increase inQty on the same lot line; new StockIn/StockInLine for audit only | |||||
| val inventoryLotLine = inventoryLotLineRepository.findById(current.id) | val inventoryLotLine = inventoryLotLineRepository.findById(current.id) | ||||
| .orElseThrow { IllegalArgumentException("InventoryLotLine not found: ${current.id}") } | .orElseThrow { IllegalArgumentException("InventoryLotLine not found: ${current.id}") } | ||||
| val stockInRequest = buildStockInRequestFromExistingLotLine(inventoryLotLine, diff) | val stockInRequest = buildStockInRequestFromExistingLotLine(inventoryLotLine, diff) | ||||
| val stockInLine = stockInLineService.createStockIn(stockInRequest) | |||||
| val stockInLine = stockInLineService.createStockInForExistingInventoryLotLine( | |||||
| stockInRequest, | |||||
| inventoryLotLine | |||||
| ) | |||||
| saveAdjustmentRecordForStockIn(stockInLine) | saveAdjustmentRecordForStockIn(stockInLine) | ||||
| } else { | } else { | ||||
| // Branch 3 (qty down): adjustment outbound only (not pick createStockOut) | // Branch 3 (qty down): adjustment outbound only (not pick createStockOut) | ||||
| @@ -422,6 +422,14 @@ open class StockTakeRecordService( | |||||
| .mapNotNull { it.stockTakeSectionDescription } // 先去掉 null | .mapNotNull { it.stockTakeSectionDescription } // 先去掉 null | ||||
| .distinct() // 去重(防止误填多个不同值) | .distinct() // 去重(防止误填多个不同值) | ||||
| .firstOrNull() | .firstOrNull() | ||||
| val warehouseArea = warehouses | |||||
| .mapNotNull { it.area } | |||||
| .distinct() | |||||
| .firstOrNull() | |||||
| val storeId = warehouses | |||||
| .mapNotNull { it.store_id } | |||||
| .distinct() | |||||
| .firstOrNull() | |||||
| val roundIdForLatest = latestStockTake?.let { st -> st.stockTakeRoundId ?: st.id } | val roundIdForLatest = latestStockTake?.let { st -> st.stockTakeRoundId ?: st.id } | ||||
| val roundRecordsForSection = if (latestStockTake != null && roundIdForLatest != null) { | val roundRecordsForSection = if (latestStockTake != null && roundIdForLatest != null) { | ||||
| @@ -483,7 +491,9 @@ open class StockTakeRecordService( | |||||
| endTime = latestStockTake?.actualEnd, | endTime = latestStockTake?.actualEnd, | ||||
| ReStockTakeTrueFalse = reStockTakeTrueFalse, | ReStockTakeTrueFalse = reStockTakeTrueFalse, | ||||
| planStartDate = latestStockTake?.planStart?.toLocalDate(), | planStartDate = latestStockTake?.planStart?.toLocalDate(), | ||||
| stockTakeSectionDescription = sectionDescription | |||||
| stockTakeSectionDescription = sectionDescription, | |||||
| warehouseArea = warehouseArea, | |||||
| storeId = storeId | |||||
| ) | ) | ||||
| ) | ) | ||||
| @@ -804,7 +814,9 @@ open class StockTakeRecordService( | |||||
| endTime = latestBaseStockTake.actualEnd, | endTime = latestBaseStockTake.actualEnd, | ||||
| ReStockTakeTrueFalse = anyNotMatch, | ReStockTakeTrueFalse = anyNotMatch, | ||||
| planStartDate = latestBaseStockTake.planStart?.toLocalDate(), | planStartDate = latestBaseStockTake.planStart?.toLocalDate(), | ||||
| stockTakeSectionDescription = null | |||||
| stockTakeSectionDescription = null, | |||||
| warehouseArea = null, | |||||
| storeId = null | |||||
| ) | ) | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -839,7 +851,9 @@ open class StockTakeRecordService( | |||||
| endTime = latestBaseStockTake.actualEnd, | endTime = latestBaseStockTake.actualEnd, | ||||
| ReStockTakeTrueFalse = false, | ReStockTakeTrueFalse = false, | ||||
| planStartDate = latestBaseStockTake.planStart?.toLocalDate(), | planStartDate = latestBaseStockTake.planStart?.toLocalDate(), | ||||
| stockTakeSectionDescription = null | |||||
| stockTakeSectionDescription = null, | |||||
| warehouseArea = null, | |||||
| storeId = null | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -1842,11 +1856,14 @@ if (itemParts.isNotEmpty()) { | |||||
| open fun batchSaveApproverStockTakeRecordsByIds( | open fun batchSaveApproverStockTakeRecordsByIds( | ||||
| request: BatchSaveApproverStockTakeByIdsRequest | request: BatchSaveApproverStockTakeByIdsRequest | ||||
| ): BatchSaveApproverStockTakeRecordResponse { | ): BatchSaveApproverStockTakeRecordResponse { | ||||
| println("batchSaveApproverStockTakeRecordsByIds called for stockTakeId: ${request.stockTakeId}, ids=${request.recordIds.size}") | |||||
| val totalStartNs = System.nanoTime() | |||||
| fun elapsedMs(startNs: Long): Long = (System.nanoTime() - startNs) / 1_000_000 | |||||
| logger.info("batchSaveApproverStockTakeRecordsByIds start: stockTakeId={}, ids={}", request.stockTakeId, request.recordIds.size) | |||||
| if (request.recordIds.isEmpty()) { | if (request.recordIds.isEmpty()) { | ||||
| return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No record IDs provided")) | return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No record IDs provided")) | ||||
| } | } | ||||
| val loadStartNs = System.nanoTime() | |||||
| val user = userRepository.findById(request.approverId).orElse(null) | val user = userRepository.findById(request.approverId).orElse(null) | ||||
| val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(request.stockTakeId) | val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(request.stockTakeId) | ||||
| ?: throw IllegalArgumentException("Stock take not found: ${request.stockTakeId}") | ?: throw IllegalArgumentException("Stock take not found: ${request.stockTakeId}") | ||||
| @@ -1859,15 +1876,33 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||||
| (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && | (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && | ||||
| it.approverStockTakeQty == null | it.approverStockTakeQty == null | ||||
| } | } | ||||
| println("Found ${stockTakeRecords.size} records to process by IDs") | |||||
| logger.info( | |||||
| "batchSaveApproverStockTakeRecordsByIds load completed: candidates={}, loadMs={}", | |||||
| stockTakeRecords.size, | |||||
| elapsedMs(loadStartNs) | |||||
| ) | |||||
| if (stockTakeRecords.isEmpty()) { | if (stockTakeRecords.isEmpty()) { | ||||
| return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No records found matching criteria")) | return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No records found matching criteria")) | ||||
| } | } | ||||
| val cacheBuildStartNs = System.nanoTime() | |||||
| val adjustmentCache = buildBatchAdjustmentCache(stockTakeRecords) | |||||
| logger.info( | |||||
| "batchSaveApproverStockTakeRecordsByIds cache build completed: lotLinePairs={}, lots={}, inventories={}, stockTakeLines={}, cacheBuildMs={}", | |||||
| adjustmentCache.inventoryLotLineByWarehouseLot.size, | |||||
| adjustmentCache.inventoryLotById.size, | |||||
| adjustmentCache.inventoryByItemId.size, | |||||
| adjustmentCache.stockTakeLineByRecordId.size, | |||||
| elapsedMs(cacheBuildStartNs) | |||||
| ) | |||||
| var successCount = 0 | var successCount = 0 | ||||
| var errorCount = 0 | var errorCount = 0 | ||||
| val errors = mutableListOf<String>() | val errors = mutableListOf<String>() | ||||
| val processedStockTakes = mutableSetOf<Pair<Long, String>>() | val processedStockTakes = mutableSetOf<Pair<Long, String>>() | ||||
| val prepareStartNs = System.nanoTime() | |||||
| val recordsToPersist = mutableListOf<StockTakeRecord>() | |||||
| val postPersistActions = mutableListOf<Triple<StockTakeRecord, BigDecimal, BigDecimal>>() | |||||
| stockTakeRecords.forEach { record -> | stockTakeRecords.forEach { record -> | ||||
| try { | try { | ||||
| val qty: BigDecimal | val qty: BigDecimal | ||||
| @@ -1901,27 +1936,8 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||||
| } | } | ||||
| } | } | ||||
| stockTakeRecordRepository.save(record) | |||||
| if (varianceQty != BigDecimal.ZERO) { | |||||
| try { | |||||
| applyVarianceAdjustment(record.stockTake ?: stockTake, record, qty, varianceQty, request.approverId) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Failed to apply variance adjustment for record ${record.id}", e) | |||||
| errorCount++ | |||||
| errors.add("Record ${record.id}: ${e.message}") | |||||
| return@forEach | |||||
| } | |||||
| } else { | |||||
| completeStockTakeLineForApproverNoVariance(record.stockTake ?: stockTake, record, qty) | |||||
| } | |||||
| val stId = record.stockTake?.id | |||||
| val section = record.stockTakeSection | |||||
| if (stId != null && section != null) { | |||||
| processedStockTakes.add(Pair(stId, section)) | |||||
| } | |||||
| successCount++ | |||||
| recordsToPersist.add(record) | |||||
| postPersistActions.add(Triple(record, qty, varianceQty)) | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| errorCount++ | errorCount++ | ||||
| val errorMsg = "Error processing record ${record.id}: ${e.message}" | val errorMsg = "Error processing record ${record.id}: ${e.message}" | ||||
| @@ -1929,14 +1945,118 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||||
| logger.error(errorMsg, e) | logger.error(errorMsg, e) | ||||
| } | } | ||||
| } | } | ||||
| logger.info( | |||||
| "batchSaveApproverStockTakeRecordsByIds prepare completed: readyToPersist={}, precheckErrors={}, prepareMs={}", | |||||
| recordsToPersist.size, | |||||
| errorCount, | |||||
| elapsedMs(prepareStartNs) | |||||
| ) | |||||
| if (recordsToPersist.isNotEmpty()) { | |||||
| val persistStartNs = System.nanoTime() | |||||
| stockTakeRecordRepository.saveAll(recordsToPersist) | |||||
| logger.info( | |||||
| "batchSaveApproverStockTakeRecordsByIds persist completed: persisted={}, persistMs={}", | |||||
| recordsToPersist.size, | |||||
| elapsedMs(persistStartNs) | |||||
| ) | |||||
| val adjustmentStartNs = System.nanoTime() | |||||
| val runtimeCache = BatchAdjustmentRuntimeCache( | |||||
| stockOutByStockTakeId = mutableMapOf(), | |||||
| stockInByStockTakeId = mutableMapOf(), | |||||
| runningLedgerBalanceByItemId = mutableMapOf() | |||||
| ) | |||||
| val adjustmentContext = StockTakeAdjustmentBatchContext() | |||||
| var varianceCount = 0 | |||||
| var noVarianceCount = 0 | |||||
| var varianceMs = 0L | |||||
| var noVarianceMs = 0L | |||||
| postPersistActions.forEach { (record, qty, varianceQty) -> | |||||
| try { | |||||
| if (varianceQty != BigDecimal.ZERO) { | |||||
| val varianceStartNs = System.nanoTime() | |||||
| applyVarianceAdjustment( | |||||
| record.stockTake ?: stockTake, | |||||
| record, | |||||
| qty, | |||||
| varianceQty, | |||||
| request.approverId, | |||||
| adjustmentCache, | |||||
| runtimeCache, | |||||
| adjustmentContext | |||||
| ) | |||||
| varianceMs += elapsedMs(varianceStartNs) | |||||
| varianceCount++ | |||||
| } else { | |||||
| val noVarianceStartNs = System.nanoTime() | |||||
| completeStockTakeLineForApproverNoVariance( | |||||
| record.stockTake ?: stockTake, | |||||
| record, | |||||
| qty, | |||||
| adjustmentCache, | |||||
| adjustmentContext | |||||
| ) | |||||
| noVarianceMs += elapsedMs(noVarianceStartNs) | |||||
| noVarianceCount++ | |||||
| } | |||||
| val stId = record.stockTake?.id | |||||
| val section = record.stockTakeSection | |||||
| if (stId != null && section != null) { | |||||
| processedStockTakes.add(Pair(stId, section)) | |||||
| } | |||||
| successCount++ | |||||
| } catch (e: Exception) { | |||||
| logger.error("Failed to apply inventory/line update for record ${record.id}", e) | |||||
| errorCount++ | |||||
| errors.add("Record ${record.id}: ${e.message}") | |||||
| } | |||||
| } | |||||
| val flushStartNs = System.nanoTime() | |||||
| flushStockTakeAdjustmentBatchContext(adjustmentContext) | |||||
| val flushMs = elapsedMs(flushStartNs) | |||||
| logger.info( | |||||
| "batchSaveApproverStockTakeRecordsByIds adjustment completed: successSoFar={}, errorsSoFar={}, adjustmentMs={}, varianceCount={}, varianceMs={}, noVarianceCount={}, noVarianceMs={}, flushMs={}", | |||||
| successCount, | |||||
| errorCount, | |||||
| elapsedMs(adjustmentStartNs), | |||||
| varianceCount, | |||||
| varianceMs, | |||||
| noVarianceCount, | |||||
| noVarianceMs, | |||||
| flushMs | |||||
| ) | |||||
| logger.info( | |||||
| "batchSaveApproverStockTakeRecordsByIds runtime cache stats: stockOutHeads={}, stockInHeads={}, ledgerItems={}, batchedStockTakeLines={}, batchedOutLines={}, batchedInLines={}, batchedInventoryLotLines={}, batchedLedgers={}", | |||||
| runtimeCache.stockOutByStockTakeId.size, | |||||
| runtimeCache.stockInByStockTakeId.size, | |||||
| runtimeCache.runningLedgerBalanceByItemId.size, | |||||
| adjustmentContext.stockTakeLineByRecordId.size, | |||||
| adjustmentContext.stockOutLines.size, | |||||
| adjustmentContext.stockInLines.size, | |||||
| adjustmentContext.inventoryLotLineById.size, | |||||
| adjustmentContext.stockLedgers.size | |||||
| ) | |||||
| } | |||||
| if (successCount > 0) { | if (successCount > 0) { | ||||
| val statusStartNs = System.nanoTime() | |||||
| processedStockTakes.forEach { (stId, section) -> | processedStockTakes.forEach { (stId, section) -> | ||||
| checkAndUpdateStockTakeStatus(stId, section) | checkAndUpdateStockTakeStatus(stId, section) | ||||
| } | } | ||||
| logger.info( | |||||
| "batchSaveApproverStockTakeRecordsByIds status update completed: stockTakes={}, statusMs={}", | |||||
| processedStockTakes.size, | |||||
| elapsedMs(statusStartNs) | |||||
| ) | |||||
| } | } | ||||
| println("batchSaveApproverStockTakeRecordsByIds completed: success=$successCount, errors=$errorCount") | |||||
| logger.info( | |||||
| "batchSaveApproverStockTakeRecordsByIds completed: success={}, errors={}, totalMs={}", | |||||
| successCount, | |||||
| errorCount, | |||||
| elapsedMs(totalStartNs) | |||||
| ) | |||||
| return BatchSaveApproverStockTakeRecordResponse( | return BatchSaveApproverStockTakeRecordResponse( | ||||
| successCount = successCount, | successCount = successCount, | ||||
| errorCount = errorCount, | errorCount = errorCount, | ||||
| @@ -1948,10 +2068,19 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||||
| * stockTakeRecord 上存的是 inventory_lot.id(批次),不是 inventory_lot_line.id;用倉庫 + 批次找唯一庫存行。 | * stockTakeRecord 上存的是 inventory_lot.id(批次),不是 inventory_lot_line.id;用倉庫 + 批次找唯一庫存行。 | ||||
| */ | */ | ||||
| private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord): InventoryLotLine { | private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord): InventoryLotLine { | ||||
| return resolveInventoryLotLineForStockTakeRecord(record, null) | |||||
| } | |||||
| private fun resolveInventoryLotLineForStockTakeRecord( | |||||
| record: StockTakeRecord, | |||||
| cache: BatchAdjustmentCache? | |||||
| ): InventoryLotLine { | |||||
| val warehouseId = record.warehouse?.id | val warehouseId = record.warehouse?.id | ||||
| ?: throw IllegalArgumentException("Warehouse not found on stock take record") | ?: throw IllegalArgumentException("Warehouse not found on stock take record") | ||||
| val lotId = record.inventoryLotId ?: record.lotId | val lotId = record.inventoryLotId ?: record.lotId | ||||
| ?: throw IllegalArgumentException("Inventory lot ID not found on stock take record") | ?: throw IllegalArgumentException("Inventory lot ID not found on stock take record") | ||||
| val cacheKey = Pair(warehouseId, lotId) | |||||
| cache?.inventoryLotLineByWarehouseLot?.get(cacheKey)?.let { return it } | |||||
| val lines = inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse( | val lines = inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse( | ||||
| listOf(warehouseId), | listOf(warehouseId), | ||||
| listOf(lotId) | listOf(lotId) | ||||
| @@ -1971,10 +2100,14 @@ private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord): | |||||
| private fun completeStockTakeLineForApproverNoVariance( | private fun completeStockTakeLineForApproverNoVariance( | ||||
| stockTake: StockTake, | stockTake: StockTake, | ||||
| stockTakeRecord: StockTakeRecord, | stockTakeRecord: StockTakeRecord, | ||||
| finalQty: BigDecimal | |||||
| finalQty: BigDecimal, | |||||
| cache: BatchAdjustmentCache? = null, | |||||
| context: StockTakeAdjustmentBatchContext? = null | |||||
| ) { | ) { | ||||
| val rid = stockTakeRecord.id ?: return | val rid = stockTakeRecord.id ?: return | ||||
| val line = stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) ?: return | |||||
| val line = cache?.stockTakeLineByRecordId?.get(rid) | |||||
| ?: stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) | |||||
| ?: return | |||||
| line.apply { | line.apply { | ||||
| this.stockTake = stockTake | this.stockTake = stockTake | ||||
| this.initialQty = this.initialQty ?: stockTakeRecord.bookQty | this.initialQty = this.initialQty ?: stockTakeRecord.bookQty | ||||
| @@ -1983,7 +2116,11 @@ private fun completeStockTakeLineForApproverNoVariance( | |||||
| this.completeDate = LocalDateTime.now() | this.completeDate = LocalDateTime.now() | ||||
| this.stockTakeRecord = stockTakeRecord | this.stockTakeRecord = stockTakeRecord | ||||
| } | } | ||||
| stockTakeLineRepository.save(line) | |||||
| if (context != null) { | |||||
| context.stockTakeLineByRecordId[rid] = line | |||||
| } else { | |||||
| stockTakeLineRepository.save(line) | |||||
| } | |||||
| } | } | ||||
| /** | /** | ||||
| @@ -1997,23 +2134,29 @@ private fun applyVarianceAdjustment( | |||||
| stockTakeRecord: StockTakeRecord, | stockTakeRecord: StockTakeRecord, | ||||
| finalQty: BigDecimal, | finalQty: BigDecimal, | ||||
| varianceQty: BigDecimal, | varianceQty: BigDecimal, | ||||
| approverId: Long? | |||||
| approverId: Long?, | |||||
| cache: BatchAdjustmentCache? = null, | |||||
| runtimeCache: BatchAdjustmentRuntimeCache? = null, | |||||
| context: StockTakeAdjustmentBatchContext? = null | |||||
| ) { | ) { | ||||
| if (varianceQty == BigDecimal.ZERO) return | if (varianceQty == BigDecimal.ZERO) return | ||||
| val inventoryLotLine = resolveInventoryLotLineForStockTakeRecord(stockTakeRecord) | |||||
| val inventoryLotLine = resolveInventoryLotLineForStockTakeRecord(stockTakeRecord, cache) | |||||
| val inventoryLot = inventoryLotRepository.findByIdAndDeletedFalse( | |||||
| inventoryLotLine.inventoryLot?.id ?: throw IllegalArgumentException("Inventory lot ID not found") | |||||
| ) ?: throw IllegalArgumentException("Inventory lot not found") | |||||
| val inventoryLotId = inventoryLotLine.inventoryLot?.id | |||||
| ?: throw IllegalArgumentException("Inventory lot ID not found") | |||||
| val inventoryLot = cache?.inventoryLotById?.get(inventoryLotId) | |||||
| ?: inventoryLotRepository.findByIdAndDeletedFalse(inventoryLotId) | |||||
| ?: throw IllegalArgumentException("Inventory lot not found") | |||||
| val inventory = inventoryRepository.findByItemId( | |||||
| inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found") | |||||
| ).orElse(null) ?: throw IllegalArgumentException("Inventory not found for item") | |||||
| val itemId = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found") | |||||
| val inventory = cache?.inventoryByItemId?.get(itemId) | |||||
| ?: inventoryRepository.findByItemId(itemId).orElse(null) | |||||
| ?: throw IllegalArgumentException("Inventory not found for item") | |||||
| // 1. 更新開輪預建的 StockTakeLine,或舊資料無預建時新建 | // 1. 更新開輪預建的 StockTakeLine,或舊資料無預建時新建 | ||||
| val stockTakeLine = stockTakeRecord.id?.let { rid -> | val stockTakeLine = stockTakeRecord.id?.let { rid -> | ||||
| stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) | |||||
| cache?.stockTakeLineByRecordId?.get(rid) ?: stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) | |||||
| }?.also { existing -> | }?.also { existing -> | ||||
| existing.apply { | existing.apply { | ||||
| this.stockTake = stockTake | this.stockTake = stockTake | ||||
| @@ -2033,7 +2176,12 @@ private fun applyVarianceAdjustment( | |||||
| this.completeDate = LocalDateTime.now() | this.completeDate = LocalDateTime.now() | ||||
| this.stockTakeRecord = stockTakeRecord | this.stockTakeRecord = stockTakeRecord | ||||
| } | } | ||||
| stockTakeLineRepository.save(stockTakeLine) | |||||
| val stockTakeRecordId = stockTakeRecord.id | |||||
| if (context != null && stockTakeRecordId != null) { | |||||
| context.stockTakeLineByRecordId[stockTakeRecordId] = stockTakeLine | |||||
| } else { | |||||
| stockTakeLineRepository.save(stockTakeLine) | |||||
| } | |||||
| val zero = BigDecimal.ZERO | val zero = BigDecimal.ZERO | ||||
| @@ -2048,12 +2196,24 @@ private fun applyVarianceAdjustment( | |||||
| return | return | ||||
| } | } | ||||
| var stockOut = stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTake.id!!) | |||||
| ?: StockOut().apply { | |||||
| this.type = "stockTake" | |||||
| this.status = "completed" | |||||
| this.handler = approverId | |||||
| }.also { stockOutRepository.save(it) } | |||||
| val stockTakeId = stockTake.id ?: throw IllegalArgumentException("Stock take ID not found") | |||||
| val stockOut = if (runtimeCache != null) { | |||||
| runtimeCache.stockOutByStockTakeId.getOrPut(stockTakeId) { | |||||
| stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTakeId) | |||||
| ?: StockOut().apply { | |||||
| this.type = "stockTake" | |||||
| this.status = "completed" | |||||
| this.handler = approverId | |||||
| }.also { stockOutRepository.save(it) } | |||||
| } | |||||
| } else { | |||||
| stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTakeId) | |||||
| ?: StockOut().apply { | |||||
| this.type = "stockTake" | |||||
| this.status = "completed" | |||||
| this.handler = approverId | |||||
| }.also { stockOutRepository.save(it) } | |||||
| } | |||||
| val stockOutLine = StockOutLine().apply { | val stockOutLine = StockOutLine().apply { | ||||
| this.item = inventoryLot.item | this.item = inventoryLot.item | ||||
| @@ -2063,15 +2223,21 @@ private fun applyVarianceAdjustment( | |||||
| this.status = "completed" | this.status = "completed" | ||||
| this.type = "TKE" | this.type = "TKE" | ||||
| } | } | ||||
| stockOutLineRepository.save(stockOutLine) | |||||
| if (context != null) { | |||||
| context.stockOutLines.add(stockOutLine) | |||||
| } else { | |||||
| stockOutLineRepository.save(stockOutLine) | |||||
| } | |||||
| // 與 StockOutLineService.createStockLedgerForStockOut 一致:依上一筆 ledger balance 扣減, | // 與 StockOutLineService.createStockLedgerForStockOut 一致:依上一筆 ledger balance 扣減, | ||||
| // 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。 | // 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。 | ||||
| val itemIdForLedger = inventoryLot.item?.id | val itemIdForLedger = inventoryLot.item?.id | ||||
| ?: throw IllegalArgumentException("Item ID not found for stock take ledger") | ?: throw IllegalArgumentException("Item ID not found for stock take ledger") | ||||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) | |||||
| val previousBalance = latestLedger?.balance | |||||
| ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||||
| val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger) | |||||
| ?: run { | |||||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) | |||||
| latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||||
| } | |||||
| val newBalance = previousBalance - qtyToRemove.toDouble() | val newBalance = previousBalance - qtyToRemove.toDouble() | ||||
| val stockLedger = StockLedger().apply { | val stockLedger = StockLedger().apply { | ||||
| @@ -2087,21 +2253,37 @@ private fun applyVarianceAdjustment( | |||||
| this.date = LocalDate.now() | this.date = LocalDate.now() | ||||
| } | } | ||||
| stockLedgerRepository.save(stockLedger) | |||||
| if (context != null) { | |||||
| context.stockLedgers.add(stockLedger) | |||||
| } else { | |||||
| stockLedgerRepository.save(stockLedger) | |||||
| } | |||||
| runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance) | |||||
| val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove) | val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove) | ||||
| val updateRequest = SaveInventoryLotLineRequest( | |||||
| id = latestLine.id, | |||||
| inventoryLotId = latestLine.inventoryLot?.id, | |||||
| warehouseId = latestLine.warehouse?.id, | |||||
| stockUomId = latestLine.stockUom?.id, | |||||
| inQty = latestLine.inQty, | |||||
| outQty = newOutQty, | |||||
| holdQty = latestLine.holdQty, | |||||
| status = latestLine.status?.value, | |||||
| remarks = latestLine.remarks | |||||
| latestLine.outQty = newOutQty | |||||
| latestLine.status = inventoryLotLineService.deriveInventoryLotLineStatus( | |||||
| latestLine.status, | |||||
| latestLine.inQty, | |||||
| latestLine.outQty, | |||||
| latestLine.holdQty | |||||
| ) | ) | ||||
| inventoryLotLineService.saveInventoryLotLine(updateRequest) | |||||
| if (context != null && latestLine.id != null) { | |||||
| context.inventoryLotLineById[latestLine.id!!] = latestLine | |||||
| } else { | |||||
| val updateRequest = SaveInventoryLotLineRequest( | |||||
| id = latestLine.id, | |||||
| inventoryLotId = latestLine.inventoryLot?.id, | |||||
| warehouseId = latestLine.warehouse?.id, | |||||
| stockUomId = latestLine.stockUom?.id, | |||||
| inQty = latestLine.inQty, | |||||
| outQty = latestLine.outQty, | |||||
| holdQty = latestLine.holdQty, | |||||
| status = latestLine.status?.value, | |||||
| remarks = latestLine.remarks | |||||
| ) | |||||
| inventoryLotLineService.saveInventoryLotLine(updateRequest) | |||||
| } | |||||
| } | } | ||||
| // 3. variance > 0:入庫加在既有庫存行 inQty + StockLedger | // 3. variance > 0:入庫加在既有庫存行 inQty + StockLedger | ||||
| @@ -2110,12 +2292,24 @@ private fun applyVarianceAdjustment( | |||||
| val latestLine = inventoryLotLineRepository.findById(inventoryLotLine.id!!).orElseThrow() | val latestLine = inventoryLotLineRepository.findById(inventoryLotLine.id!!).orElseThrow() | ||||
| val newInQty = (latestLine.inQty ?: zero).add(plusQty) | val newInQty = (latestLine.inQty ?: zero).add(plusQty) | ||||
| var stockIn = stockInRepository.findByStockTakeIdAndDeletedFalse(stockTake.id!!) | |||||
| ?: StockIn().apply { | |||||
| this.code = stockTake.code | |||||
| this.status = "completed" | |||||
| this.stockTake = stockTake | |||||
| }.also { stockInRepository.save(it) } | |||||
| val stockTakeId = stockTake.id ?: throw IllegalArgumentException("Stock take ID not found") | |||||
| val stockIn = if (runtimeCache != null) { | |||||
| runtimeCache.stockInByStockTakeId.getOrPut(stockTakeId) { | |||||
| stockInRepository.findByStockTakeIdAndDeletedFalse(stockTakeId) | |||||
| ?: StockIn().apply { | |||||
| this.code = stockTake.code | |||||
| this.status = "completed" | |||||
| this.stockTake = stockTake | |||||
| }.also { stockInRepository.save(it) } | |||||
| } | |||||
| } else { | |||||
| stockInRepository.findByStockTakeIdAndDeletedFalse(stockTakeId) | |||||
| ?: StockIn().apply { | |||||
| this.code = stockTake.code | |||||
| this.status = "completed" | |||||
| this.stockTake = stockTake | |||||
| }.also { stockInRepository.save(it) } | |||||
| } | |||||
| val stockInLine = StockInLine().apply { | val stockInLine = StockInLine().apply { | ||||
| this.stockTakeLine = stockTakeLine | this.stockTakeLine = stockTakeLine | ||||
| @@ -2132,26 +2326,43 @@ private fun applyVarianceAdjustment( | |||||
| // 原入庫已綁定之 inventory_lot_line 與 SIL 多為 OneToOne;盤盈改加同一行 inQty,此處不綁 line 以免衝突 | // 原入庫已綁定之 inventory_lot_line 與 SIL 多為 OneToOne;盤盈改加同一行 inQty,此處不綁 line 以免衝突 | ||||
| this.inventoryLotLine = null | this.inventoryLotLine = null | ||||
| } | } | ||||
| stockInLineRepository.save(stockInLine) | |||||
| val updateRequest = SaveInventoryLotLineRequest( | |||||
| id = latestLine.id, | |||||
| inventoryLotId = latestLine.inventoryLot?.id, | |||||
| warehouseId = latestLine.warehouse?.id, | |||||
| stockUomId = latestLine.stockUom?.id, | |||||
| inQty = newInQty, | |||||
| outQty = latestLine.outQty, | |||||
| holdQty = latestLine.holdQty, | |||||
| status = latestLine.status?.value, | |||||
| remarks = latestLine.remarks | |||||
| if (context != null) { | |||||
| context.stockInLines.add(stockInLine) | |||||
| } else { | |||||
| stockInLineRepository.save(stockInLine) | |||||
| } | |||||
| latestLine.inQty = newInQty | |||||
| latestLine.status = inventoryLotLineService.deriveInventoryLotLineStatus( | |||||
| latestLine.status, | |||||
| latestLine.inQty, | |||||
| latestLine.outQty, | |||||
| latestLine.holdQty | |||||
| ) | ) | ||||
| inventoryLotLineService.saveInventoryLotLine(updateRequest) | |||||
| if (context != null && latestLine.id != null) { | |||||
| context.inventoryLotLineById[latestLine.id!!] = latestLine | |||||
| } else { | |||||
| val updateRequest = SaveInventoryLotLineRequest( | |||||
| id = latestLine.id, | |||||
| inventoryLotId = latestLine.inventoryLot?.id, | |||||
| warehouseId = latestLine.warehouse?.id, | |||||
| stockUomId = latestLine.stockUom?.id, | |||||
| inQty = latestLine.inQty, | |||||
| outQty = latestLine.outQty, | |||||
| holdQty = latestLine.holdQty, | |||||
| status = latestLine.status?.value, | |||||
| remarks = latestLine.remarks | |||||
| ) | |||||
| inventoryLotLineService.saveInventoryLotLine(updateRequest) | |||||
| } | |||||
| val itemIdForLedger = inventoryLot.item?.id | val itemIdForLedger = inventoryLot.item?.id | ||||
| ?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)") | ?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)") | ||||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) | |||||
| val previousBalance = latestLedger?.balance | |||||
| ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||||
| val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger) | |||||
| ?: run { | |||||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) | |||||
| latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||||
| } | |||||
| val newBalance = previousBalance + plusQty.toDouble() | val newBalance = previousBalance + plusQty.toDouble() | ||||
| val stockLedger = StockLedger().apply { | val stockLedger = StockLedger().apply { | ||||
| @@ -2167,8 +2378,112 @@ private fun applyVarianceAdjustment( | |||||
| this.date = LocalDate.now() | this.date = LocalDate.now() | ||||
| } | } | ||||
| stockLedgerRepository.save(stockLedger) | |||||
| if (context != null) { | |||||
| context.stockLedgers.add(stockLedger) | |||||
| } else { | |||||
| stockLedgerRepository.save(stockLedger) | |||||
| } | |||||
| runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance) | |||||
| } | |||||
| } | |||||
| private data class BatchAdjustmentCache( | |||||
| val inventoryLotLineByWarehouseLot: Map<Pair<Long, Long>, InventoryLotLine>, | |||||
| val inventoryLotById: Map<Long, InventoryLot>, | |||||
| val inventoryByItemId: Map<Long, com.ffii.fpsms.modules.stock.entity.Inventory>, | |||||
| val stockTakeLineByRecordId: Map<Long, StockTakeLine> | |||||
| ) | |||||
| private data class BatchAdjustmentRuntimeCache( | |||||
| val stockOutByStockTakeId: MutableMap<Long, StockOut>, | |||||
| val stockInByStockTakeId: MutableMap<Long, StockIn>, | |||||
| val runningLedgerBalanceByItemId: MutableMap<Long, Double> | |||||
| ) | |||||
| private data class StockTakeAdjustmentBatchContext( | |||||
| val stockTakeLineByRecordId: MutableMap<Long, StockTakeLine> = LinkedHashMap(), | |||||
| val stockOutLines: MutableList<StockOutLine> = mutableListOf(), | |||||
| val stockInLines: MutableList<StockInLine> = mutableListOf(), | |||||
| val inventoryLotLineById: MutableMap<Long, InventoryLotLine> = LinkedHashMap(), | |||||
| val stockLedgers: MutableList<StockLedger> = mutableListOf() | |||||
| ) | |||||
| private fun flushStockTakeAdjustmentBatchContext(context: StockTakeAdjustmentBatchContext) { | |||||
| if (context.stockTakeLineByRecordId.isNotEmpty()) { | |||||
| stockTakeLineRepository.saveAll(context.stockTakeLineByRecordId.values.toList()) | |||||
| } | |||||
| if (context.stockOutLines.isNotEmpty()) { | |||||
| stockOutLineRepository.saveAll(context.stockOutLines) | |||||
| } | |||||
| if (context.stockInLines.isNotEmpty()) { | |||||
| stockInLineRepository.saveAll(context.stockInLines) | |||||
| } | |||||
| if (context.inventoryLotLineById.isNotEmpty()) { | |||||
| inventoryLotLineRepository.saveAll(context.inventoryLotLineById.values.toList()) | |||||
| } | |||||
| if (context.stockLedgers.isNotEmpty()) { | |||||
| stockLedgerRepository.saveAll(context.stockLedgers) | |||||
| } | |||||
| } | |||||
| private fun buildBatchAdjustmentCache(records: List<StockTakeRecord>): BatchAdjustmentCache { | |||||
| val pairs = records.mapNotNull { r -> | |||||
| val warehouseId = r.warehouse?.id | |||||
| val lotId = r.inventoryLotId ?: r.lotId | |||||
| if (warehouseId != null && lotId != null) Pair(warehouseId, lotId) else null | |||||
| }.toSet() | |||||
| val warehouseIds = pairs.map { it.first }.toSet() | |||||
| val lotIds = pairs.map { it.second }.toSet() | |||||
| val lotLines = | |||||
| if (warehouseIds.isNotEmpty() && lotIds.isNotEmpty()) { | |||||
| inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse(warehouseIds, lotIds) | |||||
| } else { | |||||
| emptyList() | |||||
| } | |||||
| val inventoryLotLineByWarehouseLot = lotLines | |||||
| .groupBy { Pair(it.warehouse?.id ?: 0L, it.inventoryLot?.id ?: 0L) } | |||||
| .mapValues { (_, lines) -> lines.maxByOrNull { it.id ?: 0L }!! } | |||||
| val inventoryLotIds = lotLines.mapNotNull { it.inventoryLot?.id }.distinct() | |||||
| val inventoryLotById = | |||||
| if (inventoryLotIds.isNotEmpty()) { | |||||
| inventoryLotRepository.findAllByIdIn(inventoryLotIds).associateByNotNull { it.id } | |||||
| } else { | |||||
| emptyMap() | |||||
| } | |||||
| val itemIds = inventoryLotById.values.mapNotNull { it.item?.id }.distinct() | |||||
| val inventoryByItemId = | |||||
| if (itemIds.isNotEmpty()) { | |||||
| inventoryRepository.findAllByItemIdInAndDeletedIsFalse(itemIds) | |||||
| .groupBy { it.item?.id ?: 0L } | |||||
| .mapValues { (_, list) -> list.minByOrNull { it.id ?: Long.MAX_VALUE }!! } | |||||
| } else { | |||||
| emptyMap() | |||||
| } | |||||
| val recordIds = records.mapNotNull { it.id }.distinct() | |||||
| val stockTakeLineByRecordId = | |||||
| if (recordIds.isNotEmpty()) { | |||||
| stockTakeLineRepository.findAllByStockTakeRecord_IdInAndDeletedIsFalse(recordIds) | |||||
| .associateByNotNull { it.stockTakeRecord?.id } | |||||
| } else { | |||||
| emptyMap() | |||||
| } | |||||
| return BatchAdjustmentCache( | |||||
| inventoryLotLineByWarehouseLot = inventoryLotLineByWarehouseLot, | |||||
| inventoryLotById = inventoryLotById, | |||||
| inventoryByItemId = inventoryByItemId, | |||||
| stockTakeLineByRecordId = stockTakeLineByRecordId | |||||
| ) | |||||
| } | |||||
| private inline fun <T : Any, K : Any> Iterable<T>.associateByNotNull(keySelector: (T) -> K?): Map<K, T> { | |||||
| val destination = LinkedHashMap<K, T>() | |||||
| for (element in this) { | |||||
| val key = keySelector(element) ?: continue | |||||
| destination[key] = element | |||||
| } | } | ||||
| return destination | |||||
| } | } | ||||
| open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTakeRecord { | open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTakeRecord { | ||||
| println("updateStockTakeRecordStatusToNotMatch called with stockTakeRecordId: $stockTakeRecordId") | println("updateStockTakeRecordStatusToNotMatch called with stockTakeRecordId: $stockTakeRecordId") | ||||
| @@ -29,7 +29,10 @@ class StockTakeRecordController( | |||||
| @RequestParam(required = false, defaultValue = "0") pageNum: Int, | @RequestParam(required = false, defaultValue = "0") pageNum: Int, | ||||
| @RequestParam(required = false, defaultValue = "6") pageSize: Int, | @RequestParam(required = false, defaultValue = "6") pageSize: Int, | ||||
| @RequestParam(required = false) sectionDescription: String?, | @RequestParam(required = false) sectionDescription: String?, | ||||
| @RequestParam(required = false) stockTakeSections: String? | |||||
| @RequestParam(required = false) stockTakeSections: String?, | |||||
| @RequestParam(required = false) status: String?, | |||||
| @RequestParam(required = false) area: String?, | |||||
| @RequestParam(required = false) storeId: String? | |||||
| ): RecordsRes<AllPickedStockTakeListReponse> { | ): RecordsRes<AllPickedStockTakeListReponse> { | ||||
| var all = stockOutRecordService.AllPickedStockTakeList() | var all = stockOutRecordService.AllPickedStockTakeList() | ||||
| if (sectionDescription != null && sectionDescription != "All") { | if (sectionDescription != null && sectionDescription != "All") { | ||||
| @@ -46,6 +49,28 @@ class StockTakeRecordController( | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| if (!status.isNullOrBlank() && status != "All") { | |||||
| val normalizedStatus = status.trim().lowercase() | |||||
| val acceptedStatuses = when (normalizedStatus) { | |||||
| "stocktaking" -> setOf("stocktaking", "processing", "in_progress") | |||||
| else -> setOf(normalizedStatus) | |||||
| } | |||||
| all = all.filter { item -> | |||||
| val itemStatus = item.status.trim().lowercase() | |||||
| itemStatus in acceptedStatuses | |||||
| } | |||||
| } | |||||
| if (!area.isNullOrBlank()) { | |||||
| val areaKeyword = area.trim() | |||||
| all = all.filter { it.warehouseArea?.contains(areaKeyword, ignoreCase = true) == true } | |||||
| } | |||||
| if (!storeId.isNullOrBlank() && storeId != "All") { | |||||
| val storeIdKeyword = storeId.trim() | |||||
| all = all.filter { | |||||
| it.storeId?.equals(storeIdKeyword, ignoreCase = true) == true || | |||||
| it.storeId?.contains(storeIdKeyword, ignoreCase = true) == true | |||||
| } | |||||
| } | |||||
| val total = all.size | val total = all.size | ||||
| val fromIndex = pageNum * pageSize | val fromIndex = pageNum * pageSize | ||||
| val toIndex = minOf(fromIndex + pageSize, total) | val toIndex = minOf(fromIndex + pageSize, total) | ||||
| @@ -27,6 +27,8 @@ data class AllPickedStockTakeListReponse( | |||||
| @JsonFormat(pattern = "yyyy-MM-dd") | @JsonFormat(pattern = "yyyy-MM-dd") | ||||
| val planStartDate: LocalDate?, | val planStartDate: LocalDate?, | ||||
| val stockTakeSectionDescription: String?, | val stockTakeSectionDescription: String?, | ||||
| val warehouseArea: String?, | |||||
| val storeId: String?, | |||||
| ) | ) | ||||
| data class InventoryLotDetailResponse( | data class InventoryLotDetailResponse( | ||||
| val id: Long, | val id: Long, | ||||
| @@ -2,15 +2,18 @@ package com.ffii.fpsms.modules.user.service | |||||
| import com.ffii.core.utils.PdfUtils | import com.ffii.core.utils.PdfUtils | ||||
| import com.ffii.core.utils.QrCodeUtil | import com.ffii.core.utils.QrCodeUtil | ||||
| import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry | |||||
| import com.ffii.fpsms.modules.master.service.PrinterService | |||||
| import com.ffii.fpsms.modules.user.entity.UserRepository | import com.ffii.fpsms.modules.user.entity.UserRepository | ||||
| import com.ffii.fpsms.modules.user.web.ExportUserQrCodeRequest | import com.ffii.fpsms.modules.user.web.ExportUserQrCodeRequest | ||||
| import com.ffii.fpsms.modules.user.web.PrintUserQrCodeRequest | |||||
| import net.sf.jasperreports.engine.JasperCompileManager | import net.sf.jasperreports.engine.JasperCompileManager | ||||
| import net.sf.jasperreports.engine.JasperExportManager | |||||
| import net.sf.jasperreports.engine.JasperReport | |||||
| import net.sf.jasperreports.engine.JasperPrint | import net.sf.jasperreports.engine.JasperPrint | ||||
| import net.sf.jasperreports.engine.export.JRPdfExporter | |||||
| import net.sf.jasperreports.export.SimpleExporterInput | |||||
| import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput | |||||
| import org.springframework.core.io.ClassPathResource | import org.springframework.core.io.ClassPathResource | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.io.File | |||||
| import java.io.FileNotFoundException | import java.io.FileNotFoundException | ||||
| import java.awt.GraphicsEnvironment | import java.awt.GraphicsEnvironment | ||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||
| @@ -18,19 +21,34 @@ import kotlinx.serialization.encodeToString | |||||
| @Service | @Service | ||||
| class UserQrCodeService( | class UserQrCodeService( | ||||
| private val userRepository: UserRepository | |||||
| private val userRepository: UserRepository, | |||||
| private val printerService: PrinterService, | |||||
| ) { | ) { | ||||
| private val qrCodeHandleJrxmlPath = "qrCodeHandle/qrCodeHandle.jrxml" | |||||
| fun exportUserQrCode(request: ExportUserQrCodeRequest): Map<String, Any> { | |||||
| val QRCODE_HANDLE_PDF = "qrCodeHandle/qrCodeHandle.jrxml" | |||||
| val resource = ClassPathResource(QRCODE_HANDLE_PDF) | |||||
| /** | |||||
| * Compile the Jasper template once; compiling per request is expensive. | |||||
| */ | |||||
| private val qrCodeHandleReport: JasperReport by lazy { | |||||
| val resource = ClassPathResource(qrCodeHandleJrxmlPath) | |||||
| if (!resource.exists()) { | if (!resource.exists()) { | ||||
| throw FileNotFoundException("Report file not found: $QRCODE_HANDLE_PDF") | |||||
| throw FileNotFoundException("Report file not found: $qrCodeHandleJrxmlPath") | |||||
| } | } | ||||
| val inputStream = resource.inputStream | |||||
| val qrCodeHandleReport = JasperCompileManager.compileReport(inputStream) | |||||
| resource.inputStream.use { JasperCompileManager.compileReport(it) } | |||||
| } | |||||
| /** Cache the chosen Chinese font family name (font scanning is expensive). */ | |||||
| private val chineseFontFamily: String by lazy { | |||||
| val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames | |||||
| availableFonts.find { family -> | |||||
| family.contains("SimSun", ignoreCase = true) || | |||||
| family.contains("Microsoft YaHei", ignoreCase = true) || | |||||
| family.contains("STSong", ignoreCase = true) || | |||||
| family.contains("SimHei", ignoreCase = true) | |||||
| } ?: "Arial Unicode MS" | |||||
| } | |||||
| fun exportUserQrCode(request: ExportUserQrCodeRequest): Map<String, Any> { | |||||
| val users = userRepository.findAllById(request.userIds) | val users = userRepository.findAllById(request.userIds) | ||||
| val fields = mutableListOf<MutableMap<String, Any>>() | val fields = mutableListOf<MutableMap<String, Any>>() | ||||
| @@ -53,24 +71,33 @@ class UserQrCodeService( | |||||
| } | } | ||||
| val params: MutableMap<String, Any> = mutableMapOf() | val params: MutableMap<String, Any> = mutableMapOf() | ||||
| // Configure for Chinese character support | |||||
| // Try to find a Chinese-supporting font | |||||
| val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames | |||||
| val chineseFont = availableFonts.find { | |||||
| it.contains("SimSun", ignoreCase = true) || | |||||
| it.contains("Microsoft YaHei", ignoreCase = true) || | |||||
| it.contains("STSong", ignoreCase = true) || | |||||
| it.contains("SimHei", ignoreCase = true) | |||||
| } ?: "Arial Unicode MS" // Fallback | |||||
| params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" | params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" | ||||
| params["net.sf.jasperreports.default.pdf.embedded"] = true | params["net.sf.jasperreports.default.pdf.embedded"] = true | ||||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont | |||||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily | |||||
| return mapOf( | return mapOf( | ||||
| "report" to PdfUtils.fillReport(qrCodeHandleReport, fields, params), | "report" to PdfUtils.fillReport(qrCodeHandleReport, fields, params), | ||||
| "fileName" to (users.firstOrNull()?.username ?: "user_qrcode") | "fileName" to (users.firstOrNull()?.username ?: "user_qrcode") | ||||
| ) | ) | ||||
| } | } | ||||
| fun printUserQrCode(request: PrintUserQrCodeRequest) { | |||||
| val printer = printerService.findById(request.printerId) ?: throw NoSuchElementException("No such printer") | |||||
| val pdf = exportUserQrCode(ExportUserQrCodeRequest(request.userIds)) | |||||
| val jasperPrint = pdf["report"] as JasperPrint | |||||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | |||||
| try { | |||||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | |||||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | |||||
| printer.ip?.let { ip -> | |||||
| val port = printer.port ?: 9100 | |||||
| val driver = A4PrintDriverRegistry.getDriver(printer.brand) | |||||
| driver.print(tempPdfFile, ip, port, printQty) | |||||
| } | |||||
| } finally { | |||||
| tempPdfFile.delete() | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @@ -0,0 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.user.web | |||||
| data class PrintUserQrCodeRequest( | |||||
| val userIds: List<Long>, | |||||
| val printerId: Long, | |||||
| val printQty: Int? = 1, | |||||
| ) | |||||
| @@ -42,13 +42,11 @@ import com.ffii.fpsms.modules.user.req.UpdateUserReq; | |||||
| import com.ffii.fpsms.modules.user.service.UserService; | import com.ffii.fpsms.modules.user.service.UserService; | ||||
| import com.ffii.fpsms.modules.user.service.res.LoadUserRes; | import com.ffii.fpsms.modules.user.service.res.LoadUserRes; | ||||
| import com.ffii.fpsms.modules.user.web.ExportUserQrCodeRequest; | |||||
| import com.ffii.fpsms.modules.user.service.UserQrCodeService; | import com.ffii.fpsms.modules.user.service.UserQrCodeService; | ||||
| import jakarta.servlet.http.HttpServletResponse; | import jakarta.servlet.http.HttpServletResponse; | ||||
| import net.sf.jasperreports.engine.JasperExportManager; | import net.sf.jasperreports.engine.JasperExportManager; | ||||
| import net.sf.jasperreports.engine.JasperPrint; | import net.sf.jasperreports.engine.JasperPrint; | ||||
| import java.io.OutputStream; | import java.io.OutputStream; | ||||
| import java.io.UnsupportedEncodingException; | |||||
| import jakarta.validation.Valid; | import jakarta.validation.Valid; | ||||
| import jakarta.validation.constraints.NotBlank; | import jakarta.validation.constraints.NotBlank; | ||||
| @@ -255,6 +253,11 @@ public class UserController{ | |||||
| out.flush(); | out.flush(); | ||||
| } | } | ||||
| @PostMapping("/print-qrcode") | |||||
| public void printQrCode(@Valid @RequestBody PrintUserQrCodeRequest request) { | |||||
| userQrCodeService.printUserQrCode(request); | |||||
| } | |||||
| public static class AdminChangePwdReq { | public static class AdminChangePwdReq { | ||||
| private Long id; | private Long id; | ||||
| @NotBlank | @NotBlank | ||||
| @@ -0,0 +1,8 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset Enson:20260429-01 | |||||
| CREATE INDEX idx_stock_out_stockTakeId_deleted | |||||
| ON stock_out (stockTakeId, deleted); | |||||
| CREATE INDEX idx_stock_take_line_record_deleted | |||||
| ON stock_take_line (stockTakeRecordId, deleted); | |||||
| @@ -0,0 +1,23 @@ | |||||
| -- liquibase formatted sql | |||||
| -- changeset 2fi:20260430_01_create_logistic | |||||
| CREATE TABLE `logistic` | |||||
| ( | |||||
| `id` INT NOT NULL AUTO_INCREMENT, | |||||
| `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| `createdBy` VARCHAR(30) NULL DEFAULT NULL, | |||||
| `version` INT NOT NULL DEFAULT '0', | |||||
| `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||||
| `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, | |||||
| `deleted` TINYINT(1) NOT NULL DEFAULT '0', | |||||
| `logisticName` VARCHAR(255) NOT NULL, | |||||
| `carPlate` VARCHAR(50) NOT NULL, | |||||
| `driverName` VARCHAR(255) NOT NULL, | |||||
| `driverNumber` INT NOT NULL, | |||||
| CONSTRAINT pk_logistic PRIMARY KEY (`id`) | |||||
| ); | |||||
| -- changeset 2fi:20260430_02_truck_district_reference_to_string | |||||
| ALTER TABLE `truck` | |||||
| MODIFY COLUMN `districtReference` VARCHAR(255) NULL; | |||||
| @@ -0,0 +1,5 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset Enson:20260504-01 | |||||
| CREATE INDEX idx_dopo_handled_deleted_status | |||||
| ON fpsmsdb.delivery_order_pick_order (handledBy, deleted, ticketStatus); | |||||
| @@ -0,0 +1,8 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset Enson:20260504-02 | |||||
| CREATE INDEX idx_dopo_workbench_current_user | |||||
| ON fpsmsdb.delivery_order_pick_order ( | |||||
| handledBy, deleted, ticketStatus, | |||||
| requiredDeliveryDate, truckDepartureTime, id | |||||
| ); | |||||
| @@ -0,0 +1,7 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset Enson:20260504-03 | |||||
| DROP INDEX idx_dopo_handled_deleted_status | |||||
| ON fpsmsdb.delivery_order_pick_order; | |||||
| @@ -0,0 +1,19 @@ | |||||
| -- liquibase formatted sql | |||||
| -- changeset fpsms:20260507_idx_do_deleted_status_eta_id | |||||
| -- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'delivery_order' AND index_name = 'idx_do_deleted_status_eta_id' | |||||
| CREATE INDEX idx_do_deleted_status_eta_id | |||||
| ON delivery_order (deleted, status, estimatedArrivalDate, id); | |||||
| -- changeset fpsms:20260507_idx_do_deleted_shop_eta_id | |||||
| -- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'delivery_order' AND index_name = 'idx_do_deleted_shop_eta_id' | |||||
| CREATE INDEX idx_do_deleted_shop_eta_id | |||||
| ON delivery_order (deleted, shopId, estimatedArrivalDate, id); | |||||
| -- changeset fpsms:20260507_idx_truck_TruckLanceCode | |||||
| -- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'truck' AND index_name = 'idx_truck_TruckLanceCode' | |||||
| CREATE INDEX idx_truck_TruckLanceCode | |||||
| ON truck (TruckLanceCode); | |||||