| @@ -122,6 +122,22 @@ open class DeliveryOrderService( | |||
| private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, | |||
| 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( | |||
| code: String?, | |||
| shopName: String?, | |||
| @@ -129,7 +145,8 @@ open class DeliveryOrderService( | |||
| estimatedArrivalDate: LocalDateTime?, | |||
| pageNum: Int?, | |||
| pageSize: Int?, | |||
| truckLanceCode: String? | |||
| truckLanceCode: String?, | |||
| floor: String? = null, | |||
| ): RecordsRes<DeliveryOrderInfoLiteDto> { | |||
| val page = (pageNum ?: 1) - 1 | |||
| @@ -142,81 +159,26 @@ open class DeliveryOrderService( | |||
| val searchTruckLanceCode = truckLanceCode?.ifBlank { null }?.lowercase() | |||
| 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 }, | |||
| shopName = shopName?.ifBlank { null }, | |||
| status = statusEnum, | |||
| etaStart = etaStart, | |||
| 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 deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds) | |||
| .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 和日期组合(只处理预过滤后的记录) | |||
| val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> | |||
| @@ -243,15 +205,8 @@ open class DeliveryOrderService( | |||
| // ✅ 优化4: 批量查询所有需要的 Truck | |||
| val truckCache = mutableMapOf<Triple<Long, String, String>, Truck?>() | |||
| 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") | |||
| @@ -263,7 +218,7 @@ open class DeliveryOrderService( | |||
| val preferredFloor = when (supplierCode) { | |||
| "P06B" -> "4F" | |||
| "P07", "P06D" -> "2F" | |||
| else -> null | |||
| else -> "2F" | |||
| } | |||
| val shop = deliveryOrder?.shop | |||
| val shopId = shop?.id | |||
| @@ -314,7 +269,7 @@ open class DeliveryOrderService( | |||
| return RecordsRes(paginatedRecords, totalCount) | |||
| } else { | |||
| // 未提供 truckLanceCode:在 DB 層依允許的供應商分頁,避免先取 10 筆再過濾導致每頁顯示少於 pageSize | |||
| val allowedSupplierCodes = listOf("P06B", "P07", "P06D") | |||
| val allowedSupplierCodes = allowedSupplierCodesForFloor(floor) | |||
| val result = deliveryOrderRepository.searchDoLitePageWithSupplierCodes( | |||
| code = code?.ifBlank { null }, | |||
| shopName = shopName?.ifBlank { null }, | |||
| @@ -341,17 +296,7 @@ open class DeliveryOrderService( | |||
| if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) { | |||
| val targetDate = estimatedArrivalDate.toLocalDate() | |||
| 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 { | |||
| 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」)。 | |||
| * 與 [searchDoLiteByPage] 帶一般車線關鍵字分開,避免 `車線-X` 在 truck 表無 shopId 時走舊邏輯漏單。 | |||
| @@ -386,13 +382,14 @@ open class DeliveryOrderService( | |||
| estimatedArrivalDate: LocalDateTime?, | |||
| pageNum: Int?, | |||
| pageSize: Int?, | |||
| floor: String? = null, | |||
| ): RecordsRes<DeliveryOrderInfoLiteDto> { | |||
| val page = (pageNum ?: 1) - 1 | |||
| val size = pageSize ?: 10 | |||
| val statusEnum = status?.let { s -> DeliveryOrderStatus.entries.find { it.value == s } } | |||
| val etaStart = estimatedArrivalDate | |||
| val etaEnd = estimatedArrivalDate?.plusDays(1) | |||
| val allowedSupplierCodes = listOf("P06B", "P07", "P06D") | |||
| val allowedSupplierCodes = allowedSupplierCodesForFloor(floor) | |||
| val allResult = deliveryOrderRepository.searchDoLitePageWithSupplierCodes( | |||
| code = code?.ifBlank { null }, | |||
| @@ -423,15 +420,7 @@ open class DeliveryOrderService( | |||
| if (deliveryOrder != null && shopId != null && infoEta != null) { | |||
| val targetDate = infoEta.toLocalDate() | |||
| 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 { | |||
| null | |||
| } | |||
| @@ -447,7 +436,7 @@ open class DeliveryOrderService( | |||
| shopAddress = info.shopAddress, | |||
| truckLanceCode = calculatedTruckLanceCode, | |||
| ) | |||
| }.filter { dto -> dto.truckLanceCode.isNullOrBlank() } | |||
| }.filter { dto -> TruckLaneSearchSpec.isUnassignedResolvedLane(dto.truckLanceCode) } | |||
| val totalCount = processedRecords.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 = | |||
| when (date.dayOfWeek) { | |||
| java.time.DayOfWeek.MONDAY -> "Mon" | |||
| @@ -847,139 +847,90 @@ open class DoPickOrderService( | |||
| * Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics. | |||
| */ | |||
| 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( | |||
| 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 { | |||
| @@ -110,7 +110,7 @@ open class DoWorkbenchDopoAssignmentService( | |||
| "4/F" -> "4/F" | |||
| 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>( | |||
| "storeId" to actualStoreId, | |||
| @@ -140,12 +140,21 @@ open class DoWorkbenchDopoAssignmentService( | |||
| sql.append(" AND dop.truckDepartureTime = :depTime ") | |||
| 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. | |||
| // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. | |||
| val candidateLimit = 50 | |||
| 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> { | |||
| if (rows.isEmpty()) return emptyList() | |||
| @@ -205,7 +214,7 @@ open class DoWorkbenchDopoAssignmentService( | |||
| "4/F" -> "4/F" | |||
| 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>( | |||
| "storeId" to actualStoreId, | |||
| @@ -234,7 +243,16 @@ open class DoWorkbenchDopoAssignmentService( | |||
| sql.append(" AND dop.truckDepartureTime = :depTime ") | |||
| 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 { | |||
| jdbcDao.queryForList(sql.toString(), params) | |||
| @@ -477,27 +477,7 @@ open class DoWorkbenchMainService( | |||
| val solSnapshot = infos.joinToString("; ") { 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() | |||
| // retry-related state | |||
| @@ -608,12 +588,15 @@ sol.id?.let { suggestedPickLotWorkbenchService.linkSplToStockOutLineAfterWorkben | |||
| val saveSolMs = lapMs() | |||
| val pickOrderId = pol.pickOrder?.id | |||
| val poType = pol.pickOrder?.type | |||
| var rebuildMs = 0L | |||
| var ensureMs = 0L | |||
| var polPartialMs = 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 (hasExplicitQty) { | |||
| rebuildMs = measureTimeMillis { | |||
| @@ -623,13 +606,13 @@ if (pickOrderId != null) { | |||
| targetQty = explicitRemainder, | |||
| storeId = request.storeId, | |||
| excludeInventoryLotLineId = scannedIll.id, | |||
| excludeWarehouseCodes = request.excludeWarehouseCodes, | |||
| excludeWarehouseCodes = effectiveExcludeWarehouseCodes, | |||
| ) | |||
| } else { | |||
| suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineExactQty( | |||
| pickOrderLineId = polId, | |||
| targetQty = BigDecimal.ZERO, | |||
| excludeWarehouseCodes = request.excludeWarehouseCodes, | |||
| excludeWarehouseCodes = effectiveExcludeWarehouseCodes, | |||
| ) | |||
| } | |||
| } | |||
| @@ -656,7 +639,7 @@ if (pickOrderId != null) { | |||
| suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( | |||
| pickOrderLineId = polId, | |||
| storeId = request.storeId, | |||
| excludeWarehouseCodes = request.excludeWarehouseCodes, | |||
| excludeWarehouseCodes = effectiveExcludeWarehouseCodes, | |||
| ) | |||
| } | |||
| 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). | |||
| * `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 { | |||
| val targetDate = requiredDate ?: LocalDate.now() | |||
| @@ -745,12 +729,7 @@ return MessageResponse( | |||
| dop.loadingSequence AS loadingSequence, | |||
| COUNT(DISTINCT dop.id) AS total_cnt, | |||
| 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, | |||
| GROUP_CONCAT( | |||
| DISTINCT NULLIF(TRIM(dop.handlerName), '') | |||
| @@ -761,7 +740,7 @@ return MessageResponse( | |||
| WHERE dop.deleted = 0 | |||
| AND dop.storeId = :storeId | |||
| AND dop.requiredDeliveryDate = :requiredDate | |||
| AND dop.ticketStatus IN ('pending', 'released', 'completed') | |||
| AND dop.ticketStatus IN ('pending', 'released') | |||
| AND EXISTS ( | |||
| SELECT 1 FROM fpsmsdb.pick_order po | |||
| WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 | |||
| @@ -835,7 +814,7 @@ return MessageResponse( | |||
| val loadingSequence = cellNullableInt(row, "loadingSequence") | |||
| val unassigned = cellNum(row, "unassigned_cnt", "unassignedCnt") | |||
| 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 handlerName = cellStr(row, "handler_names") | |||
| LaneAgg(sortTime, lance, loadingSequence, unassigned, total, handlerName) | |||
| @@ -931,12 +910,17 @@ return MessageResponse( | |||
| ): List<ReleasedDoPickOrderListItem> = | |||
| 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( | |||
| shopName: String?, | |||
| storeId: String?, | |||
| truck: String?, | |||
| requiredDeliveryDate: LocalDate? = null, | |||
| ): 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`. | |||
| @@ -1007,7 +991,10 @@ return MessageResponse( | |||
| sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ") | |||
| 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 ") | |||
| val rows: List<Map<String, Any?>> = try { | |||
| @@ -1154,7 +1141,10 @@ return MessageResponse( | |||
| sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ") | |||
| 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 ") | |||
| val rows: List<Map<String, Any?>> = try { | |||
| @@ -1527,11 +1517,18 @@ return MessageResponse( | |||
| storeId: String?, | |||
| truck: String?, | |||
| beforeToday: Boolean, | |||
| equalsDeliveryDate: LocalDate? = null, | |||
| ): List<ReleasedDoPickOrderListItem> { | |||
| 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( | |||
| """ | |||
| SELECT | |||
| @@ -1583,7 +1580,8 @@ return MessageResponse( | |||
| private fun mapRowToReleasedDoPickOrderListItem(row: Map<String, Any?>): ReleasedDoPickOrderListItem? { | |||
| 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 reqDate = when (val v = rdKey?.let { row[it] }) { | |||
| 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). | |||
| */ | |||
| @@ -1646,13 +1654,15 @@ return MessageResponse( | |||
| dop.ticketStatus as doTicketStatus | |||
| FROM fpsmsdb.delivery_order_pick_order dop | |||
| WHERE dop.handledBy = :userId | |||
| AND dop.ticketStatus IN ('pending', 'released', 'completed') | |||
| AND dop.ticketStatus IN ('pending', 'released') | |||
| AND dop.deleted = 0 | |||
| AND EXISTS ( | |||
| SELECT 1 | |||
| FROM fpsmsdb.pick_order po | |||
| WHERE po.deliveryOrderPickOrderId = dop.id | |||
| AND po.assignTo = :userId | |||
| AND po.type = 'do' | |||
| 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). */ | |||
| open fun getFgPickOrdersByUserIdWorkbench(userId: Long): List<Map<String, Any?>> { | |||
| try { | |||
| println(" Starting getFgPickOrdersByUserIdWorkbench with userId: $userId") | |||
| //println(" Starting getFgPickOrdersByUserIdWorkbench with userId: $userId") | |||
| val sql = """ | |||
| SELECT | |||
| @@ -25,6 +25,12 @@ import org.springframework.orm.ObjectOptimisticLockingFailureException | |||
| 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 { | |||
| var c: Throwable? = t | |||
| while (c != null) { | |||
| @@ -114,21 +120,32 @@ open class DoWorkbenchReleaseService( | |||
| } | |||
| 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. | |||
| */ | |||
| 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()) { | |||
| return MessageResponse( | |||
| id = null, | |||
| code = "NO_IDS", | |||
| 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", | |||
| errorPosition = null, | |||
| entity = null | |||
| @@ -178,7 +195,7 @@ open class DoWorkbenchReleaseService( | |||
| } | |||
| try { | |||
| createAndLinkDeliveryOrderPickOrders(successResults) | |||
| createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType) | |||
| } catch (e: Exception) { | |||
| // header-link failure shouldn't crash job; status.failed already includes per-DO failures | |||
| println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}") | |||
| @@ -207,8 +224,8 @@ open class DoWorkbenchReleaseService( | |||
| id = null, | |||
| code = "STARTED", | |||
| 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, | |||
| 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) { | |||
| successResults.forEach { result -> | |||
| 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 floor = storeDisplay.replace("/", "").trim() | |||
| val prefix = "TI-B-$ymd-$floor-" | |||
| val prefix = "TI-$ticketLetter-$ymd-$floor-" | |||
| val sql = """ | |||
| SELECT ticket_no AS t FROM fpsmsdb.do_pick_order WHERE deleted = 0 AND ticket_no LIKE CONCAT(:prefix, '%') | |||
| UNION ALL | |||
| @@ -371,6 +391,32 @@ open class DoWorkbenchReleaseService( | |||
| 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> { | |||
| if (ids.isEmpty()) return emptyList() | |||
| 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 | |||
| val releaseTypeCol = when (dopReleaseType.lowercase()) { | |||
| "single" -> "single" | |||
| else -> "batch" | |||
| } | |||
| val grouped = results.groupBy { | |||
| listOf( | |||
| it.shopId?.toString() ?: "", | |||
| @@ -405,13 +459,29 @@ open class DoWorkbenchReleaseService( | |||
| var createdHeaders = 0 | |||
| grouped.values.forEach { group -> | |||
| 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 tempTicket = nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, storeId) | |||
| val tempTicket = if (releaseTypeCol == "single") { | |||
| nextDeliveryOrderPickOrderSingleTicketNo(requiredDate, ticketFloorSegment) | |||
| } else { | |||
| nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment) | |||
| } | |||
| val now = LocalDateTime.now() | |||
| // Column names must match Liquibase `01_alter_stock_take.sql` (camelCase), not snake_case. | |||
| @@ -425,7 +495,7 @@ open class DoWorkbenchReleaseService( | |||
| ) VALUES ( | |||
| :truckId, :shopId, :storeId, :requiredDeliveryDate, :truckDepartureTime, | |||
| :truckLanceCode, :shopCode, :shopName, :loadingSequence, :ticketNo, | |||
| NULL, 'pending', 'batch', NULL, NULL, | |||
| NULL, 'pending', :releaseType, NULL, NULL, | |||
| :created, :createdBy, 0, :modified, :modifiedBy, 0 | |||
| ) | |||
| """.trimIndent(), | |||
| @@ -440,6 +510,7 @@ open class DoWorkbenchReleaseService( | |||
| "shopName" to first.shopName, | |||
| "loadingSequence" to first.loadingSequence, | |||
| "ticketNo" to tempTicket, | |||
| "releaseType" to releaseTypeCol, | |||
| "created" to now, | |||
| "createdBy" to "system", | |||
| "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, | |||
| pageNum = request.pageNum, | |||
| pageSize = request.pageSize, | |||
| truckLanceCode = request.truckLanceCode | |||
| truckLanceCode = request.truckLanceCode, | |||
| floor = request.floor, | |||
| ) | |||
| } | |||
| @@ -86,6 +87,25 @@ class DeliveryOrderController( | |||
| estimatedArrivalDate = request.estimatedArrivalDate, | |||
| pageNum = request.pageNum, | |||
| 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( | |||
| @RequestParam(required = false) shopName: 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> { | |||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(shopName, storeId, truck) | |||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | |||
| shopName, | |||
| storeId, | |||
| truck, | |||
| requiredDeliveryDate = requiredDate, | |||
| ) | |||
| } | |||
| @PostMapping("/assign-by-delivery-order-pick-order-id") | |||
| @@ -158,6 +164,19 @@ class DoWorkbenchController( | |||
| 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). */ | |||
| @PostMapping("/batch-release/sync-v2") | |||
| fun workbenchBatchReleaseSyncV2( | |||
| @@ -30,5 +30,7 @@ data class SearchDeliveryOrderInfoRequest( | |||
| val estimatedArrivalDate: LocalDateTime?, | |||
| val pageSize: 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.JobOrderRepository | |||
| 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.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.master.web.models.MessageResponse | |||
| 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. | |||
| */ | |||
| open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalResponse { | |||
| open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { | |||
| println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") | |||
| println("pickOrderId: $pickOrderId") | |||
| @@ -299,8 +300,8 @@ open class JoWorkbenchMainService( | |||
| } | |||
| val joPickOrders = joPickOrderRepository.findByPickOrderId(pickOrder.id!!) | |||
| val pickOrderInfo = PickOrderInfoResponse( | |||
| val pickOrderInfo = PickOrderInfoWorkbenchResponse( | |||
| id = pickOrder.id, | |||
| code = pickOrder.code, | |||
| consoCode = pickOrder.consoCode, | |||
| @@ -310,10 +311,12 @@ open class JoWorkbenchMainService( | |||
| type = pickOrder.type?.value, | |||
| status = pickOrder.status?.value, | |||
| assignTo = pickOrder.assignTo?.id, | |||
| jobOrder = JobOrderBasicInfoResponse( | |||
| jobOrder = JobOrderBasicInfoWorkbenchResponse( | |||
| id = jobOrder.id!!, | |||
| 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 -> | |||
| userService.find(uid).orElse(null)?.name | |||
| } | |||
| println("handlerName: $handlerNameInner") | |||
| //println("handlerName: $handlerNameInner") | |||
| val availableQty = if (sol?.status == "rejected") { | |||
| null | |||
| } else { | |||
| @@ -429,7 +432,7 @@ open class JoWorkbenchMainService( | |||
| ) | |||
| } | |||
| PickOrderLineWithLotsResponse( | |||
| PickOrderLineWithLotsWorkbenchResponse( | |||
| id = pol.id!!, | |||
| itemId = item?.id, | |||
| itemCode = item?.code, | |||
| @@ -445,7 +448,7 @@ open class JoWorkbenchMainService( | |||
| ) | |||
| } | |||
| JobOrderLotsHierarchicalResponse( | |||
| JobOrderLotsHierarchicalWorkbenchResponse( | |||
| pickOrder = pickOrderInfo, | |||
| pickOrderLines = pickOrderLinesResult | |||
| ) | |||
| @@ -456,10 +459,10 @@ open class JoWorkbenchMainService( | |||
| } | |||
| } | |||
| private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalResponse { | |||
| private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse { | |||
| println("❌ $message") | |||
| return JobOrderLotsHierarchicalResponse( | |||
| pickOrder = PickOrderInfoResponse( | |||
| return JobOrderLotsHierarchicalWorkbenchResponse( | |||
| pickOrder = PickOrderInfoWorkbenchResponse( | |||
| id = null, | |||
| code = null, | |||
| consoCode = null, | |||
| @@ -467,7 +470,7 @@ open class JoWorkbenchMainService( | |||
| type = null, | |||
| status = null, | |||
| assignTo = null, | |||
| jobOrder = JobOrderBasicInfoResponse(0, "", "") | |||
| jobOrder = JobOrderBasicInfoWorkbenchResponse(0, "", "",null,null) | |||
| ), | |||
| pickOrderLines = emptyList() | |||
| ) | |||
| @@ -332,7 +332,7 @@ fun getJobOrderPickOrderLotDetails( | |||
| /** 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}") | |||
| fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalResponse { | |||
| fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { | |||
| return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId) | |||
| } | |||
| @@ -90,9 +90,45 @@ data class PickOrderInfoResponse( | |||
| data class JobOrderBasicInfoResponse( | |||
| val id: Long, | |||
| 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( | |||
| val id: Long, | |||
| val itemId: Long?, | |||
| @@ -15,7 +15,7 @@ import java.time.LocalTime | |||
| @Entity | |||
| @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>() { | |||
| // --- Shop fields --- | |||
| @@ -49,8 +49,9 @@ open class ShopAndTruck : BaseEntity<Long>() { | |||
| @Column(table = "truck", name = "LoadingSequence") | |||
| 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") | |||
| open var storeId: String? = null | |||
| @@ -16,7 +16,7 @@ interface ShopAndTruck { | |||
| val truckLanceCode: String? | |||
| val departureTime: LocalTime? | |||
| val LoadingSequence: Long? | |||
| val districtReference: Long? | |||
| val districtReference: String? | |||
| val Store_id: String? | |||
| val remark: String? | |||
| val truckId: Long? | |||
| @@ -4,30 +4,52 @@ import com.ffii.core.utils.PdfUtils | |||
| import com.ffii.core.utils.QrCodeUtil | |||
| import com.ffii.fpsms.modules.master.entity.EquipmentDetailRepository | |||
| 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.JasperExportManager | |||
| import net.sf.jasperreports.engine.JasperReport | |||
| import net.sf.jasperreports.engine.JasperPrint | |||
| import org.springframework.core.io.ClassPathResource | |||
| import org.springframework.stereotype.Service | |||
| import java.io.FileNotFoundException | |||
| import java.io.File | |||
| import java.awt.GraphicsEnvironment | |||
| import kotlinx.serialization.json.Json | |||
| import kotlinx.serialization.encodeToString | |||
| @Service | |||
| 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()) { | |||
| 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) | |||
| if (equipmentDetails.isEmpty()) { | |||
| 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 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.embedded"] = true | |||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont | |||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily | |||
| val firstEquipmentDetail = equipmentDetails.firstOrNull() | |||
| @@ -83,4 +97,23 @@ class EquipmentQrCodeService( | |||
| "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}") | |||
| // result.forEach { row -> println("Result row: $row") } | |||
| return result | |||
| } catch (e: Exception) { | |||
| println("Error in getPickOrderItemsByPage: ${e.message}") | |||
| e.printStackTrace() | |||
| @@ -2,12 +2,17 @@ package com.ffii.fpsms.modules.master.service | |||
| import com.ffii.core.utils.PdfUtils | |||
| 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.web.ExportWarehouseQrCodeRequest | |||
| import com.ffii.fpsms.modules.master.web.PrintWarehouseQrCodeRequest | |||
| 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 org.springframework.core.io.ClassPathResource | |||
| import org.springframework.stereotype.Service | |||
| import java.io.File | |||
| import java.io.FileNotFoundException | |||
| import java.awt.GraphicsEnvironment | |||
| import kotlinx.serialization.json.Json | |||
| @@ -15,19 +20,32 @@ import kotlinx.serialization.encodeToString | |||
| @Service | |||
| 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()) { | |||
| 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) | |||
| if (warehouses.isEmpty()) { | |||
| 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 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.embedded"] = true | |||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont | |||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily | |||
| val firstWarehouse = warehouses.firstOrNull() | |||
| @@ -88,4 +98,23 @@ class WarehouseQrCodeService( | |||
| "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() | |||
| } | |||
| @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.flush() | |||
| } | |||
| @PostMapping("/print-qrcode") | |||
| fun printQrCode(@Valid @RequestBody request: PrintWarehouseQrCodeRequest) { | |||
| warehouseQrCodeService.printWarehouseQrCode(request) | |||
| } | |||
| @GetMapping("/stockTakeSections") | |||
| fun getStockTakeSections(): List<StockTakeSectionInfo> { | |||
| return warehouseService.getStockTakeSections() | |||
| @@ -35,8 +35,9 @@ open class Truck : BaseEntity<Long>() { | |||
| @Column(name = "Store_id") | |||
| 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") | |||
| open var remark: String? = null | |||
| @@ -1624,16 +1624,17 @@ open class PickOrderService( | |||
| } | |||
| throw IllegalStateException("Failed to generate unique delivery note code after $maxAttempts attempts") | |||
| } | |||
| /* | |||
| @Transactional(rollbackFor = [java.lang.Exception::class]) | |||
| open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageResponse { | |||
| try { | |||
| println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===") | |||
| println("consoCode: $consoCode") | |||
| // println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===") | |||
| // println("consoCode: $consoCode") | |||
| val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(consoCode) | |||
| if (stockOut == null) { | |||
| println("❌ No stock_out found for consoCode: $consoCode") | |||
| //println("❌ No stock_out found for consoCode: $consoCode") | |||
| return MessageResponse( | |||
| id = null, | |||
| name = "Stock out not found", | |||
| @@ -1680,13 +1681,13 @@ open class PickOrderService( | |||
| !(isComplete || isRejected || isPartiallyComplete) | |||
| } | |||
| println("📊 Stock out lines: ${stockOutLines.size}, Unfinished: ${unfinishedLines.size}") | |||
| // println("📊 Stock out lines: ${stockOutLines.size}, Unfinished: ${unfinishedLines.size}") | |||
| if (unfinishedLines.isEmpty()) { | |||
| println(" All stock out lines completed, updating pick order statuses...") | |||
| return completeStockOut(consoCode) | |||
| } else { | |||
| println("⏳ Still have ${unfinishedLines.size} unfinished lines") | |||
| //println("⏳ Still have ${unfinishedLines.size} unfinished lines") | |||
| return MessageResponse( | |||
| id = stockOut.id, | |||
| 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 { | |||
| val group = PickOrderGroup().apply { | |||
| this.name = name | |||
| @@ -212,7 +212,7 @@ open class TruckService( | |||
| // Use remark from request (user input) - no auto-fill | |||
| updateTruckLance.truckLanceCode = request.truckLanceCode | |||
| updateTruckLance.loadingSequence = request.loadingSequence.toInt() | |||
| updateTruckLance.districtReference = request.districtReference.toInt() | |||
| updateTruckLance.districtReference = request.districtReference | |||
| updateTruckLance.departureTime = request.departureTime | |||
| updateTruckLance.storeId = request.storeId | |||
| // 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) deliveryNoteCode: String?, | |||
| @RequestParam(required = false) truckLanceCode: String?, | |||
| @RequestParam(required = false) ticketNo: String?, | |||
| ): List<CompletedDoPickOrderResponse> { | |||
| val request = GetCompletedDoPickOrdersRequest( | |||
| targetDate = targetDate, | |||
| shopName = shopName, | |||
| deliveryNoteCode = deliveryNoteCode, | |||
| truckLanceCode = truckLanceCode, | |||
| ticketNo = ticketNo, | |||
| ) | |||
| return doWorkbenchMainService.getCompletedDoPickOrdersWorkbench(userId, request) | |||
| } | |||
| @@ -390,12 +392,14 @@ fun getCompletedDoPickOrdersWorkbenchAll( | |||
| @RequestParam(required = false) targetDate: String?, | |||
| @RequestParam(required = false) deliveryNoteCode: String?, | |||
| @RequestParam(required = false) truckLanceCode: String?, | |||
| @RequestParam(required = false) ticketNo: String?, | |||
| ): List<CompletedDoPickOrderResponse> { | |||
| val request = GetCompletedDoPickOrdersRequest( | |||
| targetDate = targetDate, | |||
| shopName = shopName, | |||
| deliveryNoteCode = deliveryNoteCode, | |||
| truckLanceCode = truckLanceCode, | |||
| ticketNo = ticketNo, | |||
| ) | |||
| return doWorkbenchMainService.getCompletedDoPickOrdersWorkbenchAll(request) | |||
| } | |||
| @@ -10,14 +10,14 @@ data class SaveTruckRequest( | |||
| val shopCode: String, | |||
| val loadingSequence: Int, | |||
| val remark: String? = null, | |||
| val districtReference: Int? = null, | |||
| val districtReference: String? = null, | |||
| ) | |||
| data class SaveTruckLane( | |||
| val id: Long, | |||
| val truckLanceCode: String, | |||
| val departureTime: LocalTime, | |||
| val loadingSequence: Long, | |||
| val districtReference: Long, | |||
| val districtReference: String?, | |||
| val storeId: String, | |||
| val remark: String? = null | |||
| ) | |||
| @@ -37,6 +37,6 @@ data class CreateTruckWithoutShopRequest( | |||
| val truckLanceCode: String, | |||
| val departureTime: LocalTime, | |||
| val loadingSequence: Int = 0, | |||
| val districtReference: Int? = null, | |||
| val districtReference: String? = null, | |||
| val remark: String? = null, | |||
| ) | |||
| @@ -23,6 +23,7 @@ data class GetCompletedDoPickOrdersRequest( | |||
| val deliveryNoteCode: String? = null, | |||
| /** 卡車 / 車道代碼(模糊匹配 truck_lance_code) */ | |||
| val truckLanceCode: String? = null, | |||
| val ticketNo: String? = null, | |||
| ) | |||
| 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( | |||
| "select * from ( " + | |||
| "select " + | |||
| @@ -211,9 +214,11 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||
| if (args.containsKey("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( | |||
| id = (it["id"] as Int).toLong(), | |||
| code = it["code"] as String, | |||
| @@ -231,11 +236,32 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||
| 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> { | |||
| @@ -3,7 +3,6 @@ package com.ffii.fpsms.modules.purchaseOrder.web | |||
| import com.ffii.core.response.RecordsRes | |||
| import com.ffii.core.support.JdbcDao | |||
| import com.ffii.core.utils.CriteriaArgsBuilder | |||
| import com.ffii.core.utils.PagingUtils | |||
| import com.ffii.core.utils.ZebraPrinterUtil | |||
| import com.ffii.fpsms.modules.master.entity.Items | |||
| import com.ffii.fpsms.modules.master.service.ItemsService | |||
| @@ -49,13 +48,15 @@ class PurchaseOrderController( | |||
| .addDate("estimatedArrivalDateTo") | |||
| .build() | |||
| // 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`). */ | |||
| @GetMapping("/summary") | |||
| @@ -305,6 +305,8 @@ ORDER BY | |||
| fun searchStockTakeVarianceReportV2( | |||
| stockTakeRoundId: Long, | |||
| itemCode: String?, | |||
| storeId: String?, | |||
| status: String?, | |||
| ): List<Map<String, Any>> { | |||
| val countSql = """ | |||
| SELECT COUNT(*) AS c FROM stocktakerecord s | |||
| @@ -320,6 +322,34 @@ ORDER BY | |||
| val args = mutableMapOf<String, Any>() | |||
| 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( | |||
| itemCode, | |||
| "it.code", | |||
| @@ -345,10 +375,12 @@ latest_str AS ( | |||
| str.approverStockTakeQty, | |||
| str.date AS strDate, | |||
| str.id, | |||
| str.approverTime | |||
| str.approverTime, | |||
| str.status AS stockTakeRecordStatus | |||
| FROM stocktakerecord str | |||
| WHERE str.deleted = 0 | |||
| AND str.stockTakeRoundId = :stockTakeRoundId | |||
| $statusLatestSql | |||
| ), | |||
| in_agg AS ( | |||
| SELECT | |||
| @@ -443,7 +475,8 @@ data AS ( | |||
| ls.approverStockTakeQty AS stkApproverQty, | |||
| ls.varianceQty AS stkVarianceQty, | |||
| ls.strDate AS stockTakeDateRaw, | |||
| ls.approverTime AS approvalDateTimeRaw | |||
| ls.approverTime AS approvalDateTimeRaw, | |||
| ls.stockTakeRecordStatus AS stockTakeRecordStatus | |||
| FROM latest_str ls | |||
| INNER JOIN inventory_lot il | |||
| ON ls.lotId = il.id | |||
| @@ -471,6 +504,7 @@ data AS ( | |||
| WHERE 1=1 | |||
| $itemCodeSql | |||
| $storeIdSql | |||
| ) | |||
| SELECT | |||
| @@ -501,12 +535,14 @@ SELECT | |||
| END AS stockTakeQty, | |||
| CASE | |||
| WHEN COALESCE(stockTakeRecordStatus, '') <> 'completed' THEN '0' | |||
| WHEN stkVarianceQty IS NULL THEN '0' | |||
| WHEN COALESCE(stkVarianceQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkVarianceQty, 0), ')') | |||
| ELSE FORMAT(COALESCE(stkVarianceQty, 0), 0) | |||
| END AS variance, | |||
| CASE | |||
| WHEN COALESCE(stockTakeRecordStatus, '') <> 'completed' THEN '0%' | |||
| WHEN stkVarianceQty IS NULL 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), '%)') | |||
| @@ -143,6 +143,8 @@ class StockTakeVarianceReportController( | |||
| fun generateStockTakeVarianceReportV2( | |||
| @RequestParam stockTakeRoundId: Long, | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false, name = "store_id") storeId: String?, | |||
| @RequestParam(required = false) status: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val parameters = mutableMapOf<String, Any>() | |||
| @@ -169,6 +171,8 @@ class StockTakeVarianceReportController( | |||
| val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( | |||
| stockTakeRoundId = stockTakeRoundId, | |||
| itemCode = itemCode, | |||
| storeId = storeId, | |||
| status = status, | |||
| ) | |||
| val stockTakeDateDisplay = dbData | |||
| .mapNotNull { it["stockTakeDate"] as? String } | |||
| @@ -196,11 +200,15 @@ class StockTakeVarianceReportController( | |||
| fun exportStockTakeVarianceReportV2Excel( | |||
| @RequestParam stockTakeRoundId: Long, | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false, name = "store_id") storeId: String?, | |||
| @RequestParam(required = false) status: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val cap = stockTakeVarianceReportService.getStockTakeRoundCaption(stockTakeRoundId) | |||
| val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( | |||
| stockTakeRoundId = stockTakeRoundId, | |||
| itemCode = itemCode, | |||
| storeId = storeId, | |||
| status = status, | |||
| ) | |||
| val excelBytes = createStockTakeVarianceExcel( | |||
| @@ -40,6 +40,7 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||
| fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo> | |||
| 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") | |||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Inventory> | |||
| @@ -9,6 +9,7 @@ interface StockTakeLineRepository : AbstractRepository<StockTakeLine, Long> { | |||
| fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?; | |||
| fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?; | |||
| fun findByStockTakeRecord_IdAndDeletedIsFalse(stockTakeRecordId: Long): StockTakeLine? | |||
| fun findAllByStockTakeRecord_IdInAndDeletedIsFalse(stockTakeRecordIds: Collection<Long>): List<StockTakeLine> | |||
| fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse( | |||
| stockTakeIds: Collection<Long>, | |||
| inventoryLotLineIds: Collection<Long> | |||
| @@ -440,8 +440,14 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit | |||
| .filter { !it.deleted && it.inventoryLot?.item != null } | |||
| .toList() | |||
| 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 | |||
| .mapNotNull { lotLine -> | |||
| 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) { | |||
| // 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) | |||
| .orElseThrow { IllegalArgumentException("InventoryLotLine not found: ${current.id}") } | |||
| val stockInRequest = buildStockInRequestFromExistingLotLine(inventoryLotLine, diff) | |||
| val stockInLine = stockInLineService.createStockIn(stockInRequest) | |||
| val stockInLine = stockInLineService.createStockInForExistingInventoryLotLine( | |||
| stockInRequest, | |||
| inventoryLotLine | |||
| ) | |||
| saveAdjustmentRecordForStockIn(stockInLine) | |||
| } else { | |||
| // Branch 3 (qty down): adjustment outbound only (not pick createStockOut) | |||
| @@ -422,6 +422,14 @@ open class StockTakeRecordService( | |||
| .mapNotNull { it.stockTakeSectionDescription } // 先去掉 null | |||
| .distinct() // 去重(防止误填多个不同值) | |||
| .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 roundRecordsForSection = if (latestStockTake != null && roundIdForLatest != null) { | |||
| @@ -483,7 +491,9 @@ open class StockTakeRecordService( | |||
| endTime = latestStockTake?.actualEnd, | |||
| ReStockTakeTrueFalse = reStockTakeTrueFalse, | |||
| planStartDate = latestStockTake?.planStart?.toLocalDate(), | |||
| stockTakeSectionDescription = sectionDescription | |||
| stockTakeSectionDescription = sectionDescription, | |||
| warehouseArea = warehouseArea, | |||
| storeId = storeId | |||
| ) | |||
| ) | |||
| @@ -804,7 +814,9 @@ open class StockTakeRecordService( | |||
| endTime = latestBaseStockTake.actualEnd, | |||
| ReStockTakeTrueFalse = anyNotMatch, | |||
| planStartDate = latestBaseStockTake.planStart?.toLocalDate(), | |||
| stockTakeSectionDescription = null | |||
| stockTakeSectionDescription = null, | |||
| warehouseArea = null, | |||
| storeId = null | |||
| ) | |||
| ) | |||
| } | |||
| @@ -839,7 +851,9 @@ open class StockTakeRecordService( | |||
| endTime = latestBaseStockTake.actualEnd, | |||
| ReStockTakeTrueFalse = false, | |||
| planStartDate = latestBaseStockTake.planStart?.toLocalDate(), | |||
| stockTakeSectionDescription = null | |||
| stockTakeSectionDescription = null, | |||
| warehouseArea = null, | |||
| storeId = null | |||
| ) | |||
| } | |||
| @@ -1842,11 +1856,14 @@ if (itemParts.isNotEmpty()) { | |||
| open fun batchSaveApproverStockTakeRecordsByIds( | |||
| request: BatchSaveApproverStockTakeByIdsRequest | |||
| ): 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()) { | |||
| return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No record IDs provided")) | |||
| } | |||
| val loadStartNs = System.nanoTime() | |||
| val user = userRepository.findById(request.approverId).orElse(null) | |||
| val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(request.stockTakeId) | |||
| ?: throw IllegalArgumentException("Stock take not found: ${request.stockTakeId}") | |||
| @@ -1859,15 +1876,33 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||
| (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != 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()) { | |||
| 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 errorCount = 0 | |||
| val errors = mutableListOf<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 -> | |||
| try { | |||
| 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) { | |||
| errorCount++ | |||
| val errorMsg = "Error processing record ${record.id}: ${e.message}" | |||
| @@ -1929,14 +1945,118 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||
| 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) { | |||
| val statusStartNs = System.nanoTime() | |||
| processedStockTakes.forEach { (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( | |||
| successCount = successCount, | |||
| errorCount = errorCount, | |||
| @@ -1948,10 +2068,19 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||
| * stockTakeRecord 上存的是 inventory_lot.id(批次),不是 inventory_lot_line.id;用倉庫 + 批次找唯一庫存行。 | |||
| */ | |||
| private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord): InventoryLotLine { | |||
| return resolveInventoryLotLineForStockTakeRecord(record, null) | |||
| } | |||
| private fun resolveInventoryLotLineForStockTakeRecord( | |||
| record: StockTakeRecord, | |||
| cache: BatchAdjustmentCache? | |||
| ): InventoryLotLine { | |||
| val warehouseId = record.warehouse?.id | |||
| ?: throw IllegalArgumentException("Warehouse not found on stock take record") | |||
| val lotId = record.inventoryLotId ?: record.lotId | |||
| ?: 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( | |||
| listOf(warehouseId), | |||
| listOf(lotId) | |||
| @@ -1971,10 +2100,14 @@ private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord): | |||
| private fun completeStockTakeLineForApproverNoVariance( | |||
| stockTake: StockTake, | |||
| stockTakeRecord: StockTakeRecord, | |||
| finalQty: BigDecimal | |||
| finalQty: BigDecimal, | |||
| cache: BatchAdjustmentCache? = null, | |||
| context: StockTakeAdjustmentBatchContext? = null | |||
| ) { | |||
| 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 { | |||
| this.stockTake = stockTake | |||
| this.initialQty = this.initialQty ?: stockTakeRecord.bookQty | |||
| @@ -1983,7 +2116,11 @@ private fun completeStockTakeLineForApproverNoVariance( | |||
| this.completeDate = LocalDateTime.now() | |||
| 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, | |||
| finalQty: BigDecimal, | |||
| varianceQty: BigDecimal, | |||
| approverId: Long? | |||
| approverId: Long?, | |||
| cache: BatchAdjustmentCache? = null, | |||
| runtimeCache: BatchAdjustmentRuntimeCache? = null, | |||
| context: StockTakeAdjustmentBatchContext? = null | |||
| ) { | |||
| 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,或舊資料無預建時新建 | |||
| val stockTakeLine = stockTakeRecord.id?.let { rid -> | |||
| stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) | |||
| cache?.stockTakeLineByRecordId?.get(rid) ?: stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) | |||
| }?.also { existing -> | |||
| existing.apply { | |||
| this.stockTake = stockTake | |||
| @@ -2033,7 +2176,12 @@ private fun applyVarianceAdjustment( | |||
| this.completeDate = LocalDateTime.now() | |||
| 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 | |||
| @@ -2048,12 +2196,24 @@ private fun applyVarianceAdjustment( | |||
| 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 { | |||
| this.item = inventoryLot.item | |||
| @@ -2063,15 +2223,21 @@ private fun applyVarianceAdjustment( | |||
| this.status = "completed" | |||
| this.type = "TKE" | |||
| } | |||
| stockOutLineRepository.save(stockOutLine) | |||
| if (context != null) { | |||
| context.stockOutLines.add(stockOutLine) | |||
| } else { | |||
| stockOutLineRepository.save(stockOutLine) | |||
| } | |||
| // 與 StockOutLineService.createStockLedgerForStockOut 一致:依上一筆 ledger balance 扣減, | |||
| // 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。 | |||
| val itemIdForLedger = inventoryLot.item?.id | |||
| ?: 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 stockLedger = StockLedger().apply { | |||
| @@ -2087,21 +2253,37 @@ private fun applyVarianceAdjustment( | |||
| 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 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 | |||
| @@ -2110,12 +2292,24 @@ private fun applyVarianceAdjustment( | |||
| val latestLine = inventoryLotLineRepository.findById(inventoryLotLine.id!!).orElseThrow() | |||
| 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 { | |||
| this.stockTakeLine = stockTakeLine | |||
| @@ -2132,26 +2326,43 @@ private fun applyVarianceAdjustment( | |||
| // 原入庫已綁定之 inventory_lot_line 與 SIL 多為 OneToOne;盤盈改加同一行 inQty,此處不綁 line 以免衝突 | |||
| 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 | |||
| ?: 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 stockLedger = StockLedger().apply { | |||
| @@ -2167,8 +2378,112 @@ private fun applyVarianceAdjustment( | |||
| 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 { | |||
| println("updateStockTakeRecordStatusToNotMatch called with stockTakeRecordId: $stockTakeRecordId") | |||
| @@ -29,7 +29,10 @@ class StockTakeRecordController( | |||
| @RequestParam(required = false, defaultValue = "0") pageNum: Int, | |||
| @RequestParam(required = false, defaultValue = "6") pageSize: Int, | |||
| @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> { | |||
| var all = stockOutRecordService.AllPickedStockTakeList() | |||
| 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 fromIndex = pageNum * pageSize | |||
| val toIndex = minOf(fromIndex + pageSize, total) | |||
| @@ -27,6 +27,8 @@ data class AllPickedStockTakeListReponse( | |||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||
| val planStartDate: LocalDate?, | |||
| val stockTakeSectionDescription: String?, | |||
| val warehouseArea: String?, | |||
| val storeId: String?, | |||
| ) | |||
| data class InventoryLotDetailResponse( | |||
| 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.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.web.ExportUserQrCodeRequest | |||
| import com.ffii.fpsms.modules.user.web.PrintUserQrCodeRequest | |||
| 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.export.JRPdfExporter | |||
| import net.sf.jasperreports.export.SimpleExporterInput | |||
| import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput | |||
| import org.springframework.core.io.ClassPathResource | |||
| import org.springframework.stereotype.Service | |||
| import java.io.File | |||
| import java.io.FileNotFoundException | |||
| import java.awt.GraphicsEnvironment | |||
| import kotlinx.serialization.json.Json | |||
| @@ -18,19 +21,34 @@ import kotlinx.serialization.encodeToString | |||
| @Service | |||
| 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()) { | |||
| 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 fields = mutableListOf<MutableMap<String, Any>>() | |||
| @@ -53,24 +71,33 @@ class UserQrCodeService( | |||
| } | |||
| 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.embedded"] = true | |||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont | |||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily | |||
| return mapOf( | |||
| "report" to PdfUtils.fillReport(qrCodeHandleReport, fields, params), | |||
| "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.res.LoadUserRes; | |||
| import com.ffii.fpsms.modules.user.web.ExportUserQrCodeRequest; | |||
| import com.ffii.fpsms.modules.user.service.UserQrCodeService; | |||
| import jakarta.servlet.http.HttpServletResponse; | |||
| import net.sf.jasperreports.engine.JasperExportManager; | |||
| import net.sf.jasperreports.engine.JasperPrint; | |||
| import java.io.OutputStream; | |||
| import java.io.UnsupportedEncodingException; | |||
| import jakarta.validation.Valid; | |||
| import jakarta.validation.constraints.NotBlank; | |||
| @@ -255,6 +253,11 @@ public class UserController{ | |||
| out.flush(); | |||
| } | |||
| @PostMapping("/print-qrcode") | |||
| public void printQrCode(@Valid @RequestBody PrintUserQrCodeRequest request) { | |||
| userQrCodeService.printUserQrCode(request); | |||
| } | |||
| public static class AdminChangePwdReq { | |||
| private Long id; | |||
| @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); | |||