Compare commits

...

20 次程式碼提交

作者 SHA1 備註 提交日期
  tommy 5cb1989ad6 truck dashboard update 4 小時之前
  CANCERYS\kw093 d4af229304 update do search and jo bom name coe 1 天之前
  CANCERYS\kw093 b5af7aad05 update do finish jump page, 2 天之前
  kelvin.yau 5be61f895d Stock Adj fix 3 天之前
  CANCERYS\kw093 a10f069a4f update index again 3 天之前
  CANCERYS\kw093 2d738e9714 added index for delivery_order_pick_order 3 天之前
  [email protected] 24ee1d8f11 revert the index 3 天之前
  [email protected] e1902f3b0e added index to hep querying 3 天之前
  CANCERYS\kw093 15c961d543 update truck X and singal relesae 5 天之前
  CANCERYS\kw093 d5b94751e7 update scan pick reject list 6 天之前
  CANCERYS\kw093 ee2b4d255a update 2F assign by lance 1 周之前
  tommy 66df3b1db6 add logistic table , chnaged district reference type 1 周之前
  CANCERYS\kw093 292ae22a7e update do 4F assign by lance 1 周之前
  B.E.N.S.O.N 0a992c381d Merge remote-tracking branch 'origin/production' into production 1 周之前
  B.E.N.S.O.N 8002b6d621 QR Code Printing Update 1 周之前
  CANCERYS\kw093 777d962f12 update stock take batch handle efficient 1 周之前
  CANCERYS\kw093 31abe1b05a update stock take and stock take report and improved checkAndCompletePickOrderByConsoCode 1 周之前
  CANCERYS\kw093 d04e864323 fix worknbench floor problem 1 周之前
  DESKTOP-064TTA1\Fai LUK 4cb8d0d6de Merge branch 'master' into production 1 周之前
  kelvin.yau 59757eeacf New PO Workbench, Improve loading speed. 1 周之前
共有 47 個檔案被更改,包括 1464 行新增503 行删除
分割檢視
  1. +208
    -96
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
  2. +80
    -129
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt
  3. +22
    -4
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt
  4. +54
    -44
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  5. +91
    -20
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt
  6. +55
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/TruckLaneSearchSpec.kt
  7. +21
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt
  8. +21
    -2
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt
  9. +3
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt
  10. +18
    -15
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt
  11. +1
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt
  12. +37
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt
  13. +4
    -3
      src/main/java/com/ffii/fpsms/modules/master/entity/ShopAndTruck.kt
  14. +1
    -1
      src/main/java/com/ffii/fpsms/modules/master/entity/projections/ShopAndTruck.kt
  15. +52
    -19
      src/main/java/com/ffii/fpsms/modules/master/service/EquipmentQrCodeService.kt
  16. +1
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt
  17. +48
    -19
      src/main/java/com/ffii/fpsms/modules/master/service/WarehouseQrCodeService.kt
  18. +5
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/EquipmentController.kt
  19. +7
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/PrintEquipmentQrCodeRequest.kt
  20. +7
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/PrintWarehouseQrCodeRequest.kt
  21. +4
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/WarehouseController.kt
  22. +3
    -2
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt
  23. +48
    -8
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt
  24. +1
    -1
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt
  25. +4
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt
  26. +3
    -3
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt
  27. +1
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt
  28. +34
    -8
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt
  29. +8
    -7
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt
  30. +38
    -2
      src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt
  31. +8
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt
  32. +1
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt
  33. +1
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt
  34. +8
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  35. +5
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt
  36. +400
    -85
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  37. +26
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt
  38. +2
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt
  39. +51
    -24
      src/main/java/com/ffii/fpsms/modules/user/service/UserQrCodeService.kt
  40. +7
    -0
      src/main/java/com/ffii/fpsms/modules/user/web/PrintUserQrCodeRequest.kt
  41. +5
    -2
      src/main/java/com/ffii/fpsms/modules/user/web/UserController.java
  42. +8
    -0
      src/main/resources/db/changelog/changes/20260429_01_Enson/01_alter_stock_take.sql
  43. +23
    -0
      src/main/resources/db/changelog/changes/20260430_01_2fi/01_create_logistic_and_alter_truck.sql
  44. +5
    -0
      src/main/resources/db/changelog/changes/20260504_01_Enson/01_alter_stock_take.sql
  45. +8
    -0
      src/main/resources/db/changelog/changes/20260504_01_Enson/02_alter_stock_take.sql
  46. +7
    -0
      src/main/resources/db/changelog/changes/20260504_01_Enson/03_alter_stock_take.sql
  47. +19
    -0
      src/main/resources/db/changelog/changes/20260507_01_search/01_do_truck_search_indexes.sql

+ 208
- 96
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt 查看文件

@@ -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"


+ 80
- 129
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt 查看文件

@@ -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 {


+ 22
- 4
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt 查看文件

@@ -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)


+ 54
- 44
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt 查看文件

@@ -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


+ 91
- 20
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt 查看文件

@@ -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,


+ 55
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/TruckLaneSearchSpec.kt 查看文件

@@ -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)
}
}
}
}

+ 21
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt 查看文件

@@ -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,
)
}



+ 21
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt 查看文件

@@ -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(


+ 3
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt 查看文件

@@ -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,
)

+ 18
- 15
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt 查看文件

@@ -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()
)


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt 查看文件

@@ -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)
}


+ 37
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt 查看文件

@@ -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?,


+ 4
- 3
src/main/java/com/ffii/fpsms/modules/master/entity/ShopAndTruck.kt 查看文件

@@ -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


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/master/entity/projections/ShopAndTruck.kt 查看文件

@@ -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?


+ 52
- 19
src/main/java/com/ffii/fpsms/modules/master/service/EquipmentQrCodeService.kt 查看文件

@@ -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()
}
}
}

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt 查看文件

@@ -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()


+ 48
- 19
src/main/java/com/ffii/fpsms/modules/master/service/WarehouseQrCodeService.kt 查看文件

@@ -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()
}
}
}

+ 5
- 0
src/main/java/com/ffii/fpsms/modules/master/web/EquipmentController.kt 查看文件

@@ -93,4 +93,9 @@ fun getAllEquipmentByPage(
out.flush()
}

@PostMapping("/print-qrcode")
fun printQrCode(@Valid @RequestBody request: PrintEquipmentQrCodeRequest) {
equipmentQrCodeService.printEquipmentQrCode(request)
}

}

+ 7
- 0
src/main/java/com/ffii/fpsms/modules/master/web/PrintEquipmentQrCodeRequest.kt 查看文件

@@ -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,
)

+ 7
- 0
src/main/java/com/ffii/fpsms/modules/master/web/PrintWarehouseQrCodeRequest.kt 查看文件

@@ -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,
)

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/master/web/WarehouseController.kt 查看文件

@@ -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()


+ 3
- 2
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt 查看文件

@@ -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


+ 48
- 8
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt 查看文件

@@ -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


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt 查看文件

@@ -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


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt 查看文件

@@ -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)
}


+ 3
- 3
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt 查看文件

@@ -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,
)

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt 查看文件

@@ -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(


+ 34
- 8
src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt 查看文件

@@ -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> {


+ 8
- 7
src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt 查看文件

@@ -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")


+ 38
- 2
src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt 查看文件

@@ -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), '%)')


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt 查看文件

@@ -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(


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt 查看文件

@@ -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>


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt 查看文件

@@ -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>


+ 8
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt 查看文件

@@ -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


+ 5
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt 查看文件

@@ -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)


+ 400
- 85
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt 查看文件

@@ -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")


+ 26
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt 查看文件

@@ -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)


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt 查看文件

@@ -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,


+ 51
- 24
src/main/java/com/ffii/fpsms/modules/user/service/UserQrCodeService.kt 查看文件

@@ -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()
}
}
}

+ 7
- 0
src/main/java/com/ffii/fpsms/modules/user/web/PrintUserQrCodeRequest.kt 查看文件

@@ -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,
)

+ 5
- 2
src/main/java/com/ffii/fpsms/modules/user/web/UserController.java 查看文件

@@ -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


+ 8
- 0
src/main/resources/db/changelog/changes/20260429_01_Enson/01_alter_stock_take.sql 查看文件

@@ -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);

+ 23
- 0
src/main/resources/db/changelog/changes/20260430_01_2fi/01_create_logistic_and_alter_truck.sql 查看文件

@@ -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;

+ 5
- 0
src/main/resources/db/changelog/changes/20260504_01_Enson/01_alter_stock_take.sql 查看文件

@@ -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);

+ 8
- 0
src/main/resources/db/changelog/changes/20260504_01_Enson/02_alter_stock_take.sql 查看文件

@@ -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
);

+ 7
- 0
src/main/resources/db/changelog/changes/20260504_01_Enson/03_alter_stock_take.sql 查看文件

@@ -0,0 +1,7 @@
--liquibase formatted sql



--changeset Enson:20260504-03
DROP INDEX idx_dopo_handled_deleted_status
ON fpsmsdb.delivery_order_pick_order;

+ 19
- 0
src/main/resources/db/changelog/changes/20260507_01_search/01_do_truck_search_indexes.sql 查看文件

@@ -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);

Loading…
取消
儲存