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 doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository,
private val itemsRepository: ItemsRepository, private val itemsRepository: ItemsRepository,
) { ) {
/**
* 樓層篩選:2F → P07/P06D(車線- 族);4F → P06B(P06B_ 族);全部 → 三者。
* 車線-X 仍屬該 DO 的 supplier,故 P06B+車線-X 不會出現在 2F,P06D+車線-X 不會出現在 4F。
*/
private fun allowedSupplierCodesForFloor(floor: String?): List<String> {
val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty()
if (f.isEmpty() || f == "ALL" || f == "All") {
return listOf("P06B", "P07", "P06D")
}
return when (f) {
"2F" -> listOf("P07", "P06D")
"4F" -> listOf("P06B")
else -> listOf("P06B", "P07", "P06D")
}
}

open fun searchDoLiteByPage( open fun searchDoLiteByPage(
code: String?, code: String?,
shopName: String?, shopName: String?,
@@ -129,7 +145,8 @@ open class DeliveryOrderService(
estimatedArrivalDate: LocalDateTime?, estimatedArrivalDate: LocalDateTime?,
pageNum: Int?, pageNum: Int?,
pageSize: Int?, pageSize: Int?,
truckLanceCode: String?
truckLanceCode: String?,
floor: String? = null,
): RecordsRes<DeliveryOrderInfoLiteDto> { ): RecordsRes<DeliveryOrderInfoLiteDto> {


val page = (pageNum ?: 1) - 1 val page = (pageNum ?: 1) - 1
@@ -142,81 +159,26 @@ open class DeliveryOrderService(
val searchTruckLanceCode = truckLanceCode?.ifBlank { null }?.lowercase() val searchTruckLanceCode = truckLanceCode?.ifBlank { null }?.lowercase()


if (searchTruckLanceCode != null) { if (searchTruckLanceCode != null) {
println("DEBUG: Filtering by truckLanceCode: $searchTruckLanceCode")

// ✅ 优化:先从 truck 表找到匹配的 truck,获取 Store_id 和 shopId
val matchingTrucks = truckRepository.findAllByTruckLanceCodeContainingAndDeletedFalse(searchTruckLanceCode)
println("DEBUG: Found ${matchingTrucks.size} matching trucks")

// 收集所有匹配的 Store_id 和 shopId
val matchingStoreIds = matchingTrucks.mapNotNull { it.storeId }.distinct()
val matchingShopIds = matchingTrucks.mapNotNull { it.shop?.id }.distinct()

println("DEBUG: Matching storeIds: $matchingStoreIds")
println("DEBUG: Matching shopIds: ${matchingShopIds.size} shops")

// 根据 Store_id 确定需要过滤的 supplier codes
// Store_id = "2F" → supplier code != "P06B"
// Store_id = "4F" → supplier code = "P06B"
val supplierCodesToInclude = mutableSetOf<String?>()
val supplierCodesToExclude = mutableSetOf<String?>()

if (matchingStoreIds.contains("2F")) {
// 2F 只关心 P07 / P06D
supplierCodesToInclude.addAll(listOf("P07", "P06D"))
// 同时排除 P06B,避免混在 2F 结果里
supplierCodesToExclude.add("P06B")
}
if (matchingStoreIds.contains("4F")) {
// 4F 只关心 P06B
supplierCodesToInclude.add("P06B")
}

// 查询符合条件的 DeliveryOrder(根据 supplier code 和 shopId 预过滤)
// 注意:这里需要在 Repository 层面添加 supplier code 过滤
// 或者先查询所有,然后在代码层面过滤
println("DEBUG: Filtering by truckLanceCode: $searchTruckLanceCode, floor=$floor")


val allResult = deliveryOrderRepository.searchDoLitePage(
val allowedForFloor = allowedSupplierCodesForFloor(floor)
val allResult = deliveryOrderRepository.searchDoLitePageWithSupplierCodes(
code = code?.ifBlank { null }, code = code?.ifBlank { null },
shopName = shopName?.ifBlank { null }, shopName = shopName?.ifBlank { null },
status = statusEnum, status = statusEnum,
etaStart = etaStart, etaStart = etaStart,
etaEnd = etaEnd, etaEnd = etaEnd,
pageable = PageRequest.of(0, 100000)
allowedSupplierCodes = allowedForFloor,
pageable = PageRequest.of(0, 100_000),
) )


println("DEBUG: Total records from DB before filtering: ${allResult.totalElements}")
println("DEBUG: Total records from DB (supplier+floor filter): ${allResult.totalElements}")


// ✅ 优化1: 批量查询所有 DeliveryOrder 实体
val deliveryOrderIds = allResult.content.mapNotNull { it.id } val deliveryOrderIds = allResult.content.mapNotNull { it.id }
val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds) val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds)
.associateBy { it.id } .associateBy { it.id }


println("DEBUG: Loaded ${deliveryOrdersMap.size} delivery orders in batch")

// ✅ 优化2: 预过滤 - 根据 supplier code 和 shopId 过滤
val preFilteredContent = allResult.content.filter { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
val supplierCode = deliveryOrder?.supplier?.code

// 检查 supplier code 是否匹配
val supplierMatches = when {
supplierCodesToExclude.contains(supplierCode) -> false
supplierCodesToInclude.isNotEmpty() && !supplierCodesToInclude.contains(supplierCode) -> false
else -> true
}

// 如果提供了 shopId 过滤,也检查 shopId
val shopMatches = if (matchingShopIds.isNotEmpty()) {
deliveryOrder?.shop?.id in matchingShopIds
} else {
true // 如果没有匹配的 shopId,不过滤
}

supplierMatches && shopMatches
}

println("DEBUG: Pre-filtered records: ${preFilteredContent.size} (from ${allResult.content.size})")
val preFilteredContent = allResult.content


// ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录) // ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录)
val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> val shopIdAndDatePairs = preFilteredContent.mapNotNull { info ->
@@ -243,15 +205,8 @@ open class DeliveryOrderService(
// ✅ 优化4: 批量查询所有需要的 Truck // ✅ 优化4: 批量查询所有需要的 Truck
val truckCache = mutableMapOf<Triple<Long, String, String>, Truck?>() val truckCache = mutableMapOf<Triple<Long, String, String>, Truck?>()
shopIdAndDatePairs.forEach { (shopId, preferredFloor, dayAbbr) -> shopIdAndDatePairs.forEach { (shopId, preferredFloor, dayAbbr) ->
val trucks = truckRepository.findByShopIdAndStoreIdAndDayOfWeek(shopId, preferredFloor, dayAbbr)
val matchedTruck = if (trucks.isEmpty()) {
truckRepository.findByShopIdAndDeletedFalse(shopId)
.filter { it.storeId == preferredFloor }
.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) }
} else {
trucks.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) }
}
truckCache[Triple(shopId, preferredFloor, dayAbbr)] = matchedTruck
truckCache[Triple(shopId, preferredFloor, dayAbbr)] =
resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)
} }


println("DEBUG: Cached ${truckCache.size} truck lookups") println("DEBUG: Cached ${truckCache.size} truck lookups")
@@ -263,7 +218,7 @@ open class DeliveryOrderService(
val preferredFloor = when (supplierCode) { val preferredFloor = when (supplierCode) {
"P06B" -> "4F" "P06B" -> "4F"
"P07", "P06D" -> "2F" "P07", "P06D" -> "2F"
else -> null
else -> "2F"
} }
val shop = deliveryOrder?.shop val shop = deliveryOrder?.shop
val shopId = shop?.id val shopId = shop?.id
@@ -314,7 +269,7 @@ open class DeliveryOrderService(
return RecordsRes(paginatedRecords, totalCount) return RecordsRes(paginatedRecords, totalCount)
} else { } else {
// 未提供 truckLanceCode:在 DB 層依允許的供應商分頁,避免先取 10 筆再過濾導致每頁顯示少於 pageSize // 未提供 truckLanceCode:在 DB 層依允許的供應商分頁,避免先取 10 筆再過濾導致每頁顯示少於 pageSize
val allowedSupplierCodes = listOf("P06B", "P07", "P06D")
val allowedSupplierCodes = allowedSupplierCodesForFloor(floor)
val result = deliveryOrderRepository.searchDoLitePageWithSupplierCodes( val result = deliveryOrderRepository.searchDoLitePageWithSupplierCodes(
code = code?.ifBlank { null }, code = code?.ifBlank { null },
shopName = shopName?.ifBlank { null }, shopName = shopName?.ifBlank { null },
@@ -341,17 +296,7 @@ open class DeliveryOrderService(
if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) { if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) {
val targetDate = estimatedArrivalDate.toLocalDate() val targetDate = estimatedArrivalDate.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate) val dayAbbr = getDayOfWeekAbbr(targetDate)
val trucks = truckRepository.findByShopIdAndStoreIdAndDayOfWeek(shopId, preferredFloor, dayAbbr)

val matchedTruck = if (trucks.isEmpty()) {
truckRepository.findByShopIdAndDeletedFalse(shopId)
.filter { it.storeId == preferredFloor }
.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) }
} else {
trucks.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) }
}

matchedTruck?.truckLanceCode
resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode
} else { } else {
null null
} }
@@ -375,6 +320,57 @@ open class DeliveryOrderService(


} }


/**
* DO 輕量搜尋 v2:車線關鍵字正規化(`車線-X`/`x`/`車線-` 前綴含未指派)、
* 以允許供應商 + 分批掃描取代單次 100000 筆載入;無車線條件時等同 [searchDoLiteByPage] 無車線分支。
*/
open fun searchDoLiteByPageV2(
code: String?,
shopName: String?,
status: String?,
estimatedArrivalDate: LocalDateTime?,
pageNum: Int?,
pageSize: Int?,
truckLanceCode: String?,
floor: String? = null,
): RecordsRes<DeliveryOrderInfoLiteDto> {
val mode = TruckLaneSearchSpec.parse(truckLanceCode)
if (mode is TruckLaneSearchSpec.Mode.NoFilter) {
return searchDoLiteByPage(
code,
shopName,
status,
estimatedArrivalDate,
pageNum,
pageSize,
null,
floor,
)
}
val pageIdx = (pageNum ?: 1).coerceAtLeast(1) - 1
val size = (pageSize ?: 10).coerceAtLeast(1).coerceAtMost(10_000)
val statusEnum = status?.let { s -> DeliveryOrderStatus.entries.find { it.value == s } }
val etaStart = estimatedArrivalDate
val etaEnd = estimatedArrivalDate?.plusDays(1)

val lanePredicate: (String?) -> Boolean = { lane -> TruckLaneSearchSpec.matches(mode, lane) }
val matched = scanDoLiteSupplierFilteredWithLanePredicate(
code = code?.ifBlank { null },
shopName = shopName?.ifBlank { null },
statusEnum = statusEnum,
etaStart = etaStart,
etaEnd = etaEnd,
allowedSupplierCodes = allowedSupplierCodesForFloor(floor),
lanePredicate = lanePredicate,
)
val total = matched.size
val from = pageIdx * size
val pageRecords =
if (from >= total) emptyList()
else matched.subList(from, minOf(from + size, total))
return RecordsRes(pageRecords, total)
}

/** /**
* 僅回傳依店鋪/ETA 從 truck 排程**推算後** `truckLanceCode` 為 null 或空白的送貨單(畫面上對應「車線-X」)。 * 僅回傳依店鋪/ETA 從 truck 排程**推算後** `truckLanceCode` 為 null 或空白的送貨單(畫面上對應「車線-X」)。
* 與 [searchDoLiteByPage] 帶一般車線關鍵字分開,避免 `車線-X` 在 truck 表無 shopId 時走舊邏輯漏單。 * 與 [searchDoLiteByPage] 帶一般車線關鍵字分開,避免 `車線-X` 在 truck 表無 shopId 時走舊邏輯漏單。
@@ -386,13 +382,14 @@ open class DeliveryOrderService(
estimatedArrivalDate: LocalDateTime?, estimatedArrivalDate: LocalDateTime?,
pageNum: Int?, pageNum: Int?,
pageSize: Int?, pageSize: Int?,
floor: String? = null,
): RecordsRes<DeliveryOrderInfoLiteDto> { ): RecordsRes<DeliveryOrderInfoLiteDto> {
val page = (pageNum ?: 1) - 1 val page = (pageNum ?: 1) - 1
val size = pageSize ?: 10 val size = pageSize ?: 10
val statusEnum = status?.let { s -> DeliveryOrderStatus.entries.find { it.value == s } } val statusEnum = status?.let { s -> DeliveryOrderStatus.entries.find { it.value == s } }
val etaStart = estimatedArrivalDate val etaStart = estimatedArrivalDate
val etaEnd = estimatedArrivalDate?.plusDays(1) val etaEnd = estimatedArrivalDate?.plusDays(1)
val allowedSupplierCodes = listOf("P06B", "P07", "P06D")
val allowedSupplierCodes = allowedSupplierCodesForFloor(floor)


val allResult = deliveryOrderRepository.searchDoLitePageWithSupplierCodes( val allResult = deliveryOrderRepository.searchDoLitePageWithSupplierCodes(
code = code?.ifBlank { null }, code = code?.ifBlank { null },
@@ -423,15 +420,7 @@ open class DeliveryOrderService(
if (deliveryOrder != null && shopId != null && infoEta != null) { if (deliveryOrder != null && shopId != null && infoEta != null) {
val targetDate = infoEta.toLocalDate() val targetDate = infoEta.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate) val dayAbbr = getDayOfWeekAbbr(targetDate)
val trucks = truckRepository.findByShopIdAndStoreIdAndDayOfWeek(shopId, preferredFloor, dayAbbr)
val matchedTruck = if (trucks.isEmpty()) {
truckRepository.findByShopIdAndDeletedFalse(shopId)
.filter { it.storeId == preferredFloor }
.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) }
} else {
trucks.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) }
}
matchedTruck?.truckLanceCode
resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode
} else { } else {
null null
} }
@@ -447,7 +436,7 @@ open class DeliveryOrderService(
shopAddress = info.shopAddress, shopAddress = info.shopAddress,
truckLanceCode = calculatedTruckLanceCode, truckLanceCode = calculatedTruckLanceCode,
) )
}.filter { dto -> dto.truckLanceCode.isNullOrBlank() }
}.filter { dto -> TruckLaneSearchSpec.isUnassignedResolvedLane(dto.truckLanceCode) }


val totalCount = processedRecords.size val totalCount = processedRecords.size
val startIndex = page * size val startIndex = page * size
@@ -2096,6 +2085,129 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
) )
} }


private fun scanDoLiteSupplierFilteredWithLanePredicate(
code: String?,
shopName: String?,
statusEnum: DeliveryOrderStatus?,
etaStart: LocalDateTime?,
etaEnd: LocalDateTime?,
allowedSupplierCodes: List<String>,
lanePredicate: (String?) -> Boolean,
): List<DeliveryOrderInfoLiteDto> {
val out = ArrayList<DeliveryOrderInfoLiteDto>()
var dbPage = 0
var rawScanned = 0
while (rawScanned < 100_000) {
val slice = deliveryOrderRepository.searchDoLitePageWithSupplierCodes(
code = code,
shopName = shopName,
status = statusEnum,
etaStart = etaStart,
etaEnd = etaEnd,
allowedSupplierCodes = allowedSupplierCodes,
pageable = PageRequest.of(dbPage, 500),
)
if (slice.content.isEmpty()) break
val dtos = buildResolvedTruckDtosForLiteRows(slice.content)
for (dto in dtos) {
if (lanePredicate(dto.truckLanceCode)) out.add(dto)
}
rawScanned += slice.content.size
dbPage++
if (!slice.hasNext()) break
}
return out
}

private fun buildResolvedTruckDtosForLiteRows(
rows: List<DeliveryOrderInfoLite>,
): List<DeliveryOrderInfoLiteDto> {
val ids = rows.mapNotNull { it.id }
if (ids.isEmpty()) return emptyList()
val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id }

val shopIdAndDatePairs = rows.mapNotNull { info ->
val d = deliveryOrdersMap[info.id]
val shopId = d?.shop?.id
val eta = info.estimatedArrivalDate
if (shopId != null && eta != null) {
val targetDate = eta.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate)
val supplierCode = d.supplier?.code
val preferredFloor = when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> "2F"
}
Triple(shopId, preferredFloor, dayAbbr)
} else {
null
}
}.distinct()

val truckCache = mutableMapOf<Triple<Long, String, String>, Truck?>()
shopIdAndDatePairs.forEach { (shopId, preferredFloor, dayAbbr) ->
truckCache[Triple(shopId, preferredFloor, dayAbbr)] =
resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)
}

return rows.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = when (supplierCode) {
"P06B" -> "4F"
"P07", "P06D" -> "2F"
else -> "2F"
}
val shopId = deliveryOrder?.shop?.id
val infoEta = info.estimatedArrivalDate
val calculatedTruckLanceCode =
if (deliveryOrder != null && shopId != null && infoEta != null) {
val targetDate = infoEta.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate)
truckCache[Triple(shopId, preferredFloor, dayAbbr)]?.truckLanceCode
} else {
null
}
DeliveryOrderInfoLiteDto(
id = info.id,
code = info.code,
orderDate = info.orderDate,
estimatedArrivalDate = info.estimatedArrivalDate,
status = info.status,
shopName = info.shopName,
supplierName = info.supplierName,
shopAddress = info.shopAddress,
truckLanceCode = calculatedTruckLanceCode,
)
}
}

/**
* 依店鋪 + 揀貨樓層解析當日應顯示之車線。
* - **2F**(P07/P06D):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。
* - **4F**(P06B):維持以星期縮寫篩選 [TruckRepository.findByShopIdAndStoreIdAndDayOfWeek];無命中時再退回同樓層最早出發。
*/
private fun resolveTruckForShopFloorAndDay(
shopId: Long,
preferredFloor: String,
dayAbbr: String,
): Truck? {
if (preferredFloor == "2F") {
return truckRepository.findByShopIdAndDeletedFalse(shopId)
.filter { it.storeId == preferredFloor }
.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) }
}
val trucks = truckRepository.findByShopIdAndStoreIdAndDayOfWeek(shopId, preferredFloor, dayAbbr)
return if (trucks.isEmpty()) {
truckRepository.findByShopIdAndDeletedFalse(shopId)
.filter { it.storeId == preferredFloor }
.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) }
} else {
trucks.minByOrNull { it.departureTime ?: LocalTime.of(23, 59, 59) }
}
}

private fun getDayOfWeekAbbr(date: LocalDate): String = private fun getDayOfWeekAbbr(date: LocalDate): String =
when (date.dayOfWeek) { when (date.dayOfWeek) {
java.time.DayOfWeek.MONDAY -> "Mon" java.time.DayOfWeek.MONDAY -> "Mon"


+ 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. * Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics.
*/ */
open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> { open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> {
// Fetch all active DoPickOrders for the target date
val doPickOrders = doPickOrderRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn(
"2/F", targetDate, listOf(DoPickOrderStatus.pending, DoPickOrderStatus.released, DoPickOrderStatus.completed)
) + doPickOrderRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn(
"4/F", targetDate, listOf(DoPickOrderStatus.pending, DoPickOrderStatus.released, DoPickOrderStatus.completed)
)
// Fetch all DoPickOrderRecords for the target date (completed records)
val doPickOrderRecords = doPickOrderRecordRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn(
"2/F", targetDate, listOf(DoPickOrderStatus.completed)
) + doPickOrderRecordRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn(
"4/F", targetDate, listOf(DoPickOrderStatus.completed)
)
// Combine both types into a unified data structure for aggregation
data class TicketData(
val storeId: String?,
val truckId: Long?,
val truckLanceCode: String?,
val truckDepartureTime: java.time.LocalTime?,
val shopId: Long?,
val shopCode: String?,
val ticketNo: String?,
val ticketReleaseTime: LocalDateTime?,
val ticketCompleteDateTime: LocalDateTime?,
val ticketStatus: DoPickOrderStatus?,
val doPickOrderId: Long?,
val isRecord: Boolean
)
val allTickets = mutableListOf<TicketData>()
doPickOrders.forEach { dpo ->
allTickets.add(TicketData(
storeId = dpo.storeId,
truckId = dpo.truckId,
truckLanceCode = dpo.truckLanceCode,
truckDepartureTime = dpo.truckDepartureTime,
shopId = dpo.shopId,
shopCode = dpo.shopCode,
ticketNo = dpo.ticketNo,
ticketReleaseTime = dpo.ticketReleaseTime,
ticketCompleteDateTime = dpo.ticketCompleteDateTime,
ticketStatus = dpo.ticketStatus,
doPickOrderId = dpo.id,
isRecord = false
))
}
doPickOrderRecords.forEach { record ->
allTickets.add(TicketData(
storeId = record.storeId,
truckId = record.truckId,
truckLanceCode = record.truckLanceCode,
truckDepartureTime = record.truckDepartureTime,
shopId = record.shopId,
shopCode = record.shopCode,
ticketNo = record.ticketNo,
ticketReleaseTime = record.ticketReleaseTime,
ticketCompleteDateTime = record.ticketCompleteDateTime,
ticketStatus = record.ticketStatus,
doPickOrderId = record.recordId,
isRecord = true
))
}
// Group by storeId, truckLanceCode, truckDepartureTime
val grouped = allTickets.groupBy {
Triple(it.storeId, it.truckLanceCode, it.truckDepartureTime)
}
return grouped.map { (key, tickets) ->
val (storeId, truckLanceCode, truckDepartureTime) = key
// Count distinct shops
val distinctShops = tickets.mapNotNull { it.shopId ?: it.shopCode?.hashCode()?.toLong() }.distinct().size
// Count distinct tickets
val distinctTickets = tickets.mapNotNull { it.ticketNo }.distinct().size
// Calculate total items to pick
var totalItems = 0
tickets.forEach { ticket ->
if (ticket.doPickOrderId != null) {
if (ticket.isRecord) {
totalItems += countFGItemsFromRecordById(ticket.doPickOrderId)
} else {
totalItems += countFGItemsById(ticket.doPickOrderId)
}
}
// Source of truth: delivery_order_pick_order (+ linked pick_order / pick_order_line)
//
// NOTE: delivery_order_pick_order 沒有 truckId 欄位;dashboard 的 truckId 目前僅作為展示/鍵值用途,
// 回傳 null 讓前端保持相容即可。
val sql = """
SELECT
dop.storeId AS storeId,
dop.truckLanceCode AS truckLanceCode,
dop.truckDepartureTime AS truckDepartureTime,
COUNT(DISTINCT dop.shopCode) AS numberOfShopsToServe,
COUNT(DISTINCT dop.ticketNo) AS numberOfPickTickets,
COALESCE(SUM(pol_cnt.cnt), 0) AS totalItemsToPick,
SUM(CASE WHEN dop.ticketReleaseTime IS NOT NULL THEN 1 ELSE 0 END) AS numberOfTicketsReleased,
MIN(dop.ticketReleaseTime) AS firstTicketStartTime,
SUM(CASE WHEN dop.ticketCompleteDateTime IS NOT NULL THEN 1 ELSE 0 END) AS numberOfTicketsCompleted,
MAX(dop.ticketCompleteDateTime) AS lastTicketEndTime
FROM fpsmsdb.delivery_order_pick_order dop
LEFT JOIN (
SELECT
po.deliveryOrderPickOrderId AS dopId,
COUNT(pol.id) AS cnt
FROM fpsmsdb.pick_order po
INNER JOIN fpsmsdb.pick_order_line pol
ON pol.poId = po.id
AND pol.deleted = 0
WHERE po.deleted = 0
AND po.deliveryOrderPickOrderId IS NOT NULL
GROUP BY po.deliveryOrderPickOrderId
) pol_cnt
ON pol_cnt.dopId = dop.id
WHERE dop.deleted = 0
AND dop.requiredDeliveryDate = :targetDate
AND dop.ticketStatus IN ('pending', 'released', 'completed')
GROUP BY dop.storeId, dop.truckLanceCode, dop.truckDepartureTime
ORDER BY dop.storeId, dop.truckDepartureTime
""".trimIndent()

val rows = jdbcDao.queryForList(sql, mapOf("targetDate" to targetDate))

fun str(row: Map<String, Any?>, key: String): String? = row[key]?.toString()
fun intVal(row: Map<String, Any?>, key: String): Int =
when (val v = row[key]) {
null -> 0
is Number -> v.toInt()
else -> v.toString().toBigDecimalOrNull()?.toInt() ?: 0
} }
// Count released tickets (ticketReleaseTime is not null)
val releasedTickets = tickets.count { it.ticketReleaseTime != null }
// Find first ticket start time (earliest ticketReleaseTime)
val firstTicketStartTime = tickets
.mapNotNull { it.ticketReleaseTime }
.minOrNull()
// Count completed tickets (ticketCompleteDateTime is not null)
val completedTickets = tickets.count { it.ticketCompleteDateTime != null }
// Find last ticket end time (latest ticketCompleteDateTime)
val lastTicketEndTime = tickets
.mapNotNull { it.ticketCompleteDateTime }
.maxOrNull()
// Calculate pick time taken in minutes
val pickTimeTakenMinutes = if (firstTicketStartTime != null && lastTicketEndTime != null) {
ChronoUnit.MINUTES.between(firstTicketStartTime, lastTicketEndTime)
} else {
null
fun timeVal(row: Map<String, Any?>, key: String): java.time.LocalTime? =
when (val v = row[key]) {
null -> null
is java.time.LocalTime -> v
is java.sql.Time -> v.toLocalTime()
is java.time.OffsetTime -> v.toLocalTime()
is String -> runCatching { java.time.LocalTime.parse(v) }.getOrNull()
else -> null
} }
// Get truck ID (use first non-null)
val truckId = tickets.firstOrNull { it.truckId != null }?.truckId
fun dtVal(row: Map<String, Any?>, key: String): LocalDateTime? =
when (val v = row[key]) {
null -> null
is LocalDateTime -> v
is java.sql.Timestamp -> v.toLocalDateTime()
is String -> runCatching { LocalDateTime.parse(v) }.getOrNull()
else -> null
}

return rows.map { row ->
val first = dtVal(row, "firstTicketStartTime")
val last = dtVal(row, "lastTicketEndTime")
val minutes = if (first != null && last != null) ChronoUnit.MINUTES.between(first, last) else null

TruckScheduleDashboardResponse( TruckScheduleDashboardResponse(
storeId = storeId,
truckId = truckId,
truckLanceCode = truckLanceCode,
truckDepartureTime = truckDepartureTime,
numberOfShopsToServe = distinctShops,
numberOfPickTickets = distinctTickets,
totalItemsToPick = totalItems,
numberOfTicketsReleased = releasedTickets,
firstTicketStartTime = firstTicketStartTime,
numberOfTicketsCompleted = completedTickets,
lastTicketEndTime = lastTicketEndTime,
pickTimeTakenMinutes = pickTimeTakenMinutes
storeId = str(row, "storeId"),
truckId = null,
truckLanceCode = str(row, "truckLanceCode"),
truckDepartureTime = timeVal(row, "truckDepartureTime"),
numberOfShopsToServe = intVal(row, "numberOfShopsToServe"),
numberOfPickTickets = intVal(row, "numberOfPickTickets"),
totalItemsToPick = intVal(row, "totalItemsToPick"),
numberOfTicketsReleased = intVal(row, "numberOfTicketsReleased"),
firstTicketStartTime = first,
numberOfTicketsCompleted = intVal(row, "numberOfTicketsCompleted"),
lastTicketEndTime = last,
pickTimeTakenMinutes = minutes,
) )
}.sortedWith(compareBy({ it.storeId }, { it.truckDepartureTime }))
}
} }
private fun countFGItemsById(doPickOrderId: Long): Int { private fun countFGItemsById(doPickOrderId: Long): Int {


+ 22
- 4
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt Прегледај датотеку

@@ -110,7 +110,7 @@ open class DoWorkbenchDopoAssignmentService(
"4/F" -> "4/F" "4/F" -> "4/F"
else -> request.storeId else -> request.storeId
} }
println(" DEBUG: assignByLaneForWorkbench storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}")
println(" DEBUG: assignByLaneForWorkbench storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime} seq=${request.loadingSequence}")


val params = mutableMapOf<String, Any>( val params = mutableMapOf<String, Any>(
"storeId" to actualStoreId, "storeId" to actualStoreId,
@@ -140,12 +140,21 @@ open class DoWorkbenchDopoAssignmentService(
sql.append(" AND dop.truckDepartureTime = :depTime ") sql.append(" AND dop.truckDepartureTime = :depTime ")
params["depTime"] = depSqlTime params["depTime"] = depSqlTime
} }
if (request.loadingSequence != null) {
sql.append(" AND dop.loadingSequence = :loadingSequence ")
params["loadingSequence"] = request.loadingSequence
}
// Fetch a batch of candidates and try atomic-assign sequentially. // Fetch a batch of candidates and try atomic-assign sequentially.
// This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned.
val candidateLimit = 50 val candidateLimit = 50
val maxRounds = 3 val maxRounds = 3


sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT $candidateLimit ")
val shouldOrderBySequence = actualStoreId == "2/F" && request.loadingSequence == null
if (shouldOrderBySequence) {
sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT $candidateLimit ")
} else {
sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT $candidateLimit ")
}


fun extractIds(rows: List<Map<String, Any?>>): List<Long> { fun extractIds(rows: List<Map<String, Any?>>): List<Long> {
if (rows.isEmpty()) return emptyList() if (rows.isEmpty()) return emptyList()
@@ -205,7 +214,7 @@ open class DoWorkbenchDopoAssignmentService(
"4/F" -> "4/F" "4/F" -> "4/F"
else -> request.storeId else -> request.storeId
} }
println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}")
println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime} seq=${request.loadingSequence}")


val params = mutableMapOf<String, Any>( val params = mutableMapOf<String, Any>(
"storeId" to actualStoreId, "storeId" to actualStoreId,
@@ -234,7 +243,16 @@ open class DoWorkbenchDopoAssignmentService(
sql.append(" AND dop.truckDepartureTime = :depTime ") sql.append(" AND dop.truckDepartureTime = :depTime ")
params["depTime"] = depSqlTime params["depTime"] = depSqlTime
} }
sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT 1 ")
if (request.loadingSequence != null) {
sql.append(" AND dop.loadingSequence = :loadingSequence ")
params["loadingSequence"] = request.loadingSequence
}
val shouldOrderBySequenceV1 = actualStoreId == "2/F" && request.loadingSequence == null
if (shouldOrderBySequenceV1) {
sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT 1 ")
} else {
sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT 1 ")
}


val candidates = try { val candidates = try {
jdbcDao.queryForList(sql.toString(), params) jdbcDao.queryForList(sql.toString(), params)


+ 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 -> val solSnapshot = infos.joinToString("; ") { info ->
"sol${info.id} st=${info.status} qty=${info.qty} picked=${WorkbenchStockOutLinePickProgress.isCountedAsPicked(info)}" "sol${info.id} st=${info.status} qty=${info.qty} picked=${WorkbenchStockOutLinePickProgress.isCountedAsPicked(info)}"
} }
/*
log.info(
"WORKBENCH_SCAN_TRACE polId={} solId={} scanLotNo={} scanIllId={} polRequired={} [{}] endedSumOthers={} currentSolQty={} remainingPol={} splMatchQty={} chunkTarget={} stillNeedOnThisSol={} requestedCap={} availScanLot={} plannedDelta={} lotSplit={}",
polId,
sol.id,
lotNo,
scannedIll.id,
required,
solSnapshot,
endedSumOthers,
currentQtyBd,
remainingPol,
splForLot?.qty,
chunkTarget,
stillNeedOnThisSol,
requestedCap,
availablePickable,
plannedDelta,
isLotExhaustedSplit, // initial trace only
)
*/

val prepMs = lapMs() val prepMs = lapMs()


// retry-related state // retry-related state
@@ -608,12 +588,15 @@ sol.id?.let { suggestedPickLotWorkbenchService.linkSplToStockOutLineAfterWorkben
val saveSolMs = lapMs() val saveSolMs = lapMs()


val pickOrderId = pol.pickOrder?.id val pickOrderId = pol.pickOrder?.id
val poType = pol.pickOrder?.type
var rebuildMs = 0L var rebuildMs = 0L
var ensureMs = 0L var ensureMs = 0L
var polPartialMs = 0L var polPartialMs = 0L
var postMs = 0L var postMs = 0L

val effectiveExcludeWarehouseCodes = when (poType) {
PickOrderType.JOB_ORDER -> request.excludeWarehouseCodes ?: emptyList()
else -> request.excludeWarehouseCodes // null → DO 走 default;有傳則整份取代 default
}
if (pickOrderId != null) { if (pickOrderId != null) {
if (hasExplicitQty) { if (hasExplicitQty) {
rebuildMs = measureTimeMillis { rebuildMs = measureTimeMillis {
@@ -623,13 +606,13 @@ if (pickOrderId != null) {
targetQty = explicitRemainder, targetQty = explicitRemainder,
storeId = request.storeId, storeId = request.storeId,
excludeInventoryLotLineId = scannedIll.id, excludeInventoryLotLineId = scannedIll.id,
excludeWarehouseCodes = request.excludeWarehouseCodes,
excludeWarehouseCodes = effectiveExcludeWarehouseCodes,
) )
} else { } else {
suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineExactQty( suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineExactQty(
pickOrderLineId = polId, pickOrderLineId = polId,
targetQty = BigDecimal.ZERO, targetQty = BigDecimal.ZERO,
excludeWarehouseCodes = request.excludeWarehouseCodes,
excludeWarehouseCodes = effectiveExcludeWarehouseCodes,
) )
} }
} }
@@ -656,7 +639,7 @@ if (pickOrderId != null) {
suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine( suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine(
pickOrderLineId = polId, pickOrderLineId = polId,
storeId = request.storeId, storeId = request.storeId,
excludeWarehouseCodes = request.excludeWarehouseCodes,
excludeWarehouseCodes = effectiveExcludeWarehouseCodes,
) )
} }
ensureMs = measureTimeMillis { ensureMs = measureTimeMillis {
@@ -723,7 +706,8 @@ return MessageResponse(
/** /**
* Lane summary for DO workbench: one card per **delivery_order_pick_order** ticket (not per pick_order). * Lane summary for DO workbench: one card per **delivery_order_pick_order** ticket (not per pick_order).
* `unassigned` = ticket still has at least one linked pick_order with assignTo null.
* `unassigned` = tickets with `handledBy` null (aligned with normal FG `do_pick_order.handledBy` semantics).
* `total` = all tickets in the lane/time bucket (including already assigned), so denominators match normal FG.
*/ */
open fun getDeliveryOrderPickOrderSummaryByStore(storeId: String, requiredDate: LocalDate?, releaseType: String): StoreLaneSummary { open fun getDeliveryOrderPickOrderSummaryByStore(storeId: String, requiredDate: LocalDate?, releaseType: String): StoreLaneSummary {
val targetDate = requiredDate ?: LocalDate.now() val targetDate = requiredDate ?: LocalDate.now()
@@ -745,12 +729,7 @@ return MessageResponse(
dop.loadingSequence AS loadingSequence, dop.loadingSequence AS loadingSequence,
COUNT(DISTINCT dop.id) AS total_cnt, COUNT(DISTINCT dop.id) AS total_cnt,
SUM( SUM(
CASE WHEN EXISTS (
SELECT 1 FROM fpsmsdb.pick_order po2
WHERE po2.deliveryOrderPickOrderId = dop.id
AND po2.deleted = 0
AND po2.assignTo IS NULL
) THEN 1 ELSE 0 END
CASE WHEN dop.handledBy IS NULL THEN 1 ELSE 0 END
) AS unassigned_cnt, ) AS unassigned_cnt,
GROUP_CONCAT( GROUP_CONCAT(
DISTINCT NULLIF(TRIM(dop.handlerName), '') DISTINCT NULLIF(TRIM(dop.handlerName), '')
@@ -761,7 +740,7 @@ return MessageResponse(
WHERE dop.deleted = 0 WHERE dop.deleted = 0
AND dop.storeId = :storeId AND dop.storeId = :storeId
AND dop.requiredDeliveryDate = :requiredDate AND dop.requiredDeliveryDate = :requiredDate
AND dop.ticketStatus IN ('pending', 'released', 'completed')
AND dop.ticketStatus IN ('pending', 'released')
AND EXISTS ( AND EXISTS (
SELECT 1 FROM fpsmsdb.pick_order po SELECT 1 FROM fpsmsdb.pick_order po
WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
@@ -835,7 +814,7 @@ return MessageResponse(
val loadingSequence = cellNullableInt(row, "loadingSequence") val loadingSequence = cellNullableInt(row, "loadingSequence")
val unassigned = cellNum(row, "unassigned_cnt", "unassignedCnt") val unassigned = cellNum(row, "unassigned_cnt", "unassignedCnt")
val total = cellNum(row, "total_cnt", "totalCnt") val total = cellNum(row, "total_cnt", "totalCnt")
if (unassigned <= 0) return@mapNotNull null
if (total <= 0) return@mapNotNull null
val sortTime = cellTime(row) ?: LocalTime.MIDNIGHT val sortTime = cellTime(row) ?: LocalTime.MIDNIGHT
val handlerName = cellStr(row, "handler_names") val handlerName = cellStr(row, "handler_names")
LaneAgg(sortTime, lance, loadingSequence, unassigned, total, handlerName) LaneAgg(sortTime, lance, loadingSequence, unassigned, total, handlerName)
@@ -931,12 +910,17 @@ return MessageResponse(
): List<ReleasedDoPickOrderListItem> = ): List<ReleasedDoPickOrderListItem> =
queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true) queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true)


/**
* @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today).
* When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day).
*/
open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(
shopName: String?, shopName: String?,
storeId: String?, storeId: String?,
truck: String?, truck: String?,
requiredDeliveryDate: LocalDate? = null,
): List<ReleasedDoPickOrderListItem> = ): List<ReleasedDoPickOrderListItem> =
queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = false)
queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = false, equalsDeliveryDate = requiredDeliveryDate)


/** /**
* Workbench completed tickets: query `delivery_order_pick_order` where `ticketStatus = completed`. * Workbench completed tickets: query `delivery_order_pick_order` where `ticketStatus = completed`.
@@ -1007,7 +991,10 @@ return MessageResponse(
sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ") sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ")
params["dnPat"] = "%${request.deliveryNoteCode!!.trim()}%" params["dnPat"] = "%${request.deliveryNoteCode!!.trim()}%"
} }

if (!request.ticketNo.isNullOrBlank()) {
sql.append(" AND dop.ticketNo LIKE :ticketNoPat ")
params["ticketNoPat"] = "%${request.ticketNo!!.trim()}%"
}
sql.append(" GROUP BY dop.id ORDER BY dop.modified DESC ") sql.append(" GROUP BY dop.id ORDER BY dop.modified DESC ")


val rows: List<Map<String, Any?>> = try { val rows: List<Map<String, Any?>> = try {
@@ -1154,7 +1141,10 @@ return MessageResponse(
sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ") sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ")
params["dnPat"] = "%${request.deliveryNoteCode!!.trim()}%" params["dnPat"] = "%${request.deliveryNoteCode!!.trim()}%"
} }

if (!request.ticketNo.isNullOrBlank()) {
sql.append(" AND dop.ticketNo LIKE :ticketNoPat ")
params["ticketNoPat"] = "%${request.ticketNo!!.trim()}%"
}
sql.append(" GROUP BY dop.id ORDER BY dop.modified DESC ") sql.append(" GROUP BY dop.id ORDER BY dop.modified DESC ")


val rows: List<Map<String, Any?>> = try { val rows: List<Map<String, Any?>> = try {
@@ -1527,11 +1517,18 @@ return MessageResponse(
storeId: String?, storeId: String?,
truck: String?, truck: String?,
beforeToday: Boolean, beforeToday: Boolean,
equalsDeliveryDate: LocalDate? = null,
): List<ReleasedDoPickOrderListItem> { ): List<ReleasedDoPickOrderListItem> {
val today = LocalDate.now() val today = LocalDate.now()
val dateClause =
if (beforeToday) " dop.requiredDeliveryDate < :today " else " dop.requiredDeliveryDate = :today "
val params = mutableMapOf<String, Any>("today" to today)
val params = mutableMapOf<String, Any>()
val dateClause = if (beforeToday) {
params["today"] = today
" dop.requiredDeliveryDate < :today "
} else {
val target = equalsDeliveryDate ?: today
params["targetDate"] = target
" dop.requiredDeliveryDate = :targetDate "
}
val sqlBuilder = StringBuilder( val sqlBuilder = StringBuilder(
""" """
SELECT SELECT
@@ -1583,7 +1580,8 @@ return MessageResponse(


private fun mapRowToReleasedDoPickOrderListItem(row: Map<String, Any?>): ReleasedDoPickOrderListItem? { private fun mapRowToReleasedDoPickOrderListItem(row: Map<String, Any?>): ReleasedDoPickOrderListItem? {
val idKey = row.keys.find { it.equals("id", true) } ?: return null val idKey = row.keys.find { it.equals("id", true) } ?: return null
val id = (row[idKey] as? Number)?.toLong() ?: return null
// MySQL BIGINT may come back as BigInteger, which is not a Kotlin Number — avoid dropping rows.
val id = cellToLong(row[idKey]) ?: return null
val rdKey = row.keys.find { it.equals("requiredDeliveryDate", true) } val rdKey = row.keys.find { it.equals("requiredDeliveryDate", true) }
val reqDate = when (val v = rdKey?.let { row[it] }) { val reqDate = when (val v = rdKey?.let { row[it] }) {
null -> null null -> null
@@ -1616,6 +1614,16 @@ return MessageResponse(
) )
} }


private fun cellToLong(v: Any?): Long? {
if (v == null) return null
return when (v) {
is Number -> v.toLong()
is java.lang.Number -> v.longValue()
is String -> v.trim().toLongOrNull()
else -> v.toString().trim().toLongOrNull()
}
}

/** /**
* DO workbench: header [delivery_order_pick_order] + lines from [pick_order.deliveryOrderPickOrderId] (no do_pick_order_line). * DO workbench: header [delivery_order_pick_order] + lines from [pick_order.deliveryOrderPickOrderId] (no do_pick_order_line).
*/ */
@@ -1646,13 +1654,15 @@ return MessageResponse(
dop.ticketStatus as doTicketStatus dop.ticketStatus as doTicketStatus
FROM fpsmsdb.delivery_order_pick_order dop FROM fpsmsdb.delivery_order_pick_order dop
WHERE dop.handledBy = :userId WHERE dop.handledBy = :userId
AND dop.ticketStatus IN ('pending', 'released', 'completed')

AND dop.ticketStatus IN ('pending', 'released')
AND dop.deleted = 0 AND dop.deleted = 0
AND EXISTS ( AND EXISTS (
SELECT 1 SELECT 1
FROM fpsmsdb.pick_order po FROM fpsmsdb.pick_order po
WHERE po.deliveryOrderPickOrderId = dop.id WHERE po.deliveryOrderPickOrderId = dop.id
AND po.assignTo = :userId AND po.assignTo = :userId

AND po.type = 'do' AND po.type = 'do'
AND po.deleted = 0 AND po.deleted = 0
) )
@@ -1828,7 +1838,7 @@ return MessageResponse(
/** DO workbench FG list from [delivery_order_pick_order] + linked [pick_order] (no do_pick_order_line). */ /** DO workbench FG list from [delivery_order_pick_order] + linked [pick_order] (no do_pick_order_line). */
open fun getFgPickOrdersByUserIdWorkbench(userId: Long): List<Map<String, Any?>> { open fun getFgPickOrdersByUserIdWorkbench(userId: Long): List<Map<String, Any?>> {
try { try {
println(" Starting getFgPickOrdersByUserIdWorkbench with userId: $userId")
//println(" Starting getFgPickOrdersByUserIdWorkbench with userId: $userId")


val sql = """ val sql = """
SELECT SELECT


+ 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 private const val WORKBENCH_RELEASE_RETRY_MAX = 3


/** 與 workbench 車線摘要一致:`車線-X`(預設車)不帶樓層 `storeId`。 */
private const val WORKBENCH_DEFAULT_TRUCK_LANCE_CODE = "車線-X"

/** 票號 `TI-B-yyyyMMdd-{floor}-nnn` 中段;無樓層時用 `X` 與 2F/4F 區隔。 */
private const val WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK = "X"

private fun isWorkbenchOptimisticLockFailure(t: Throwable?): Boolean { private fun isWorkbenchOptimisticLockFailure(t: Throwable?): Boolean {
var c: Throwable? = t var c: Throwable? = t
while (c != null) { while (c != null) {
@@ -114,21 +120,32 @@ open class DoWorkbenchReleaseService(
} }


open fun startBatchReleaseAsync(ids: List<Long>, userId: Long): MessageResponse = open fun startBatchReleaseAsync(ids: List<Long>, userId: Long): MessageResponse =
startBatchReleaseAsyncInternal(ids, userId, useV2 = false)
startBatchReleaseAsyncInternal(ids, userId, useV2 = false, dopReleaseType = "batch")


/** /**
* V2: deferred suggested pick / stock out / stock out lines until [DoWorkbenchDopoAssignmentService] assigns the ticket. * V2: deferred suggested pick / stock out / stock out lines until [DoWorkbenchDopoAssignmentService] assigns the ticket.
*/ */
open fun startBatchReleaseAsyncV2(ids: List<Long>, userId: Long): MessageResponse = open fun startBatchReleaseAsyncV2(ids: List<Long>, userId: Long): MessageResponse =
startBatchReleaseAsyncInternal(ids, userId, useV2 = true)
startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "batch")


private fun startBatchReleaseAsyncInternal(ids: List<Long>, userId: Long, useV2: Boolean): MessageResponse {
/**
* V2 async for one (or few) DOs: [delivery_order_pick_order.releaseType] = `single`, ticket prefix `TI-S-` (aligned with legacy single DO pick tickets).
*/
open fun startBatchReleaseAsyncSingleV2(ids: List<Long>, userId: Long): MessageResponse =
startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "single")

private fun startBatchReleaseAsyncInternal(
ids: List<Long>,
userId: Long,
useV2: Boolean,
dopReleaseType: String,
): MessageResponse {
if (ids.isEmpty()) { if (ids.isEmpty()) {
return MessageResponse( return MessageResponse(
id = null, id = null,
code = "NO_IDS", code = "NO_IDS",
name = null, name = null,
type = if (useV2) "workbench_batch_release_async_v2" else "workbench_batch_release_async",
type = asyncJobType(useV2, dopReleaseType),
message = "No delivery order ids provided", message = "No delivery order ids provided",
errorPosition = null, errorPosition = null,
entity = null entity = null
@@ -178,7 +195,7 @@ open class DoWorkbenchReleaseService(
} }


try { try {
createAndLinkDeliveryOrderPickOrders(successResults)
createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType)
} catch (e: Exception) { } catch (e: Exception) {
// header-link failure shouldn't crash job; status.failed already includes per-DO failures // header-link failure shouldn't crash job; status.failed already includes per-DO failures
println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}") println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}")
@@ -207,8 +224,8 @@ open class DoWorkbenchReleaseService(
id = null, id = null,
code = "STARTED", code = "STARTED",
name = null, name = null,
type = if (useV2) "workbench_batch_release_async_v2" else "workbench_batch_release_async",
message = if (useV2) "Workbench batch release V2 started" else "Workbench batch release started",
type = asyncJobType(useV2, dopReleaseType),
message = asyncJobMessage(useV2, dopReleaseType),
errorPosition = null, errorPosition = null,
entity = mapOf("jobId" to jobId, "total" to ids.size) entity = mapOf("jobId" to jobId, "total" to ids.size)
) )
@@ -312,7 +329,7 @@ open class DoWorkbenchReleaseService(
} }
} }


val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults)
val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch")
if (!useV2) { if (!useV2) {
successResults.forEach { result -> successResults.forEach { result ->
try { try {
@@ -342,14 +359,17 @@ open class DoWorkbenchReleaseService(
} }


/** /**
* Same visual format as batch DO pick tickets (`DoReleaseCoordinatorService`): `TI-B-yyyyMMdd-2F-001`.
* Allocates the next 3-digit suffix by scanning existing `do_pick_order.ticket_no` and
* `delivery_order_pick_order.ticketNo` with the same prefix (avoids `uk_dopo_ticket_no` clashes).
* `TI-B-yyyyMMdd-2F-001` (batch) or `TI-S-yyyyMMdd-2F-001` (single), same suffix rules as [DoReleaseCoordinatorService] / legacy `do_pick_order`.
*/ */
private fun nextDeliveryOrderPickOrderBatchTicketNo(requiredDate: LocalDate, storeDisplay: String): String {
private fun nextDeliveryOrderPickOrderTicketNo(
requiredDate: LocalDate,
storeDisplay: String,
ticketLetter: String,
): String {
require(ticketLetter == "B" || ticketLetter == "S") { "ticketLetter must be B or S" }
val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")) val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
val floor = storeDisplay.replace("/", "").trim() val floor = storeDisplay.replace("/", "").trim()
val prefix = "TI-B-$ymd-$floor-"
val prefix = "TI-$ticketLetter-$ymd-$floor-"
val sql = """ val sql = """
SELECT ticket_no AS t FROM fpsmsdb.do_pick_order WHERE deleted = 0 AND ticket_no LIKE CONCAT(:prefix, '%') SELECT ticket_no AS t FROM fpsmsdb.do_pick_order WHERE deleted = 0 AND ticket_no LIKE CONCAT(:prefix, '%')
UNION ALL UNION ALL
@@ -371,6 +391,32 @@ open class DoWorkbenchReleaseService(
return "$prefix${next.toString().padStart(3, '0')}" return "$prefix${next.toString().padStart(3, '0')}"
} }


private fun nextDeliveryOrderPickOrderBatchTicketNo(requiredDate: LocalDate, storeDisplay: String): String =
nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "B")

private fun nextDeliveryOrderPickOrderSingleTicketNo(requiredDate: LocalDate, storeDisplay: String): String =
nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "S")

private fun asyncJobType(useV2: Boolean, dopReleaseType: String): String {
val single = dopReleaseType.equals("single", ignoreCase = true)
return when {
useV2 && single -> "workbench_single_release_async_v2"
useV2 -> "workbench_batch_release_async_v2"
single -> "workbench_single_release_async"
else -> "workbench_batch_release_async"
}
}

private fun asyncJobMessage(useV2: Boolean, dopReleaseType: String): String {
val single = dopReleaseType.equals("single", ignoreCase = true)
return when {
useV2 && single -> "Workbench single release V2 started"
useV2 -> "Workbench batch release V2 started"
single -> "Workbench single release started"
else -> "Workbench batch release started"
}
}

private fun getOrderedDeliveryOrderIds(ids: List<Long>): List<Long> { private fun getOrderedDeliveryOrderIds(ids: List<Long>): List<Long> {
if (ids.isEmpty()) return emptyList() if (ids.isEmpty()) return emptyList()
return try { return try {
@@ -388,9 +434,17 @@ open class DoWorkbenchReleaseService(
} }
} }


private fun createAndLinkDeliveryOrderPickOrders(results: List<ReleaseDoResult>): Int {
private fun createAndLinkDeliveryOrderPickOrders(
results: List<ReleaseDoResult>,
dopReleaseType: String = "batch",
): Int {
if (results.isEmpty()) return 0 if (results.isEmpty()) return 0


val releaseTypeCol = when (dopReleaseType.lowercase()) {
"single" -> "single"
else -> "batch"
}

val grouped = results.groupBy { val grouped = results.groupBy {
listOf( listOf(
it.shopId?.toString() ?: "", it.shopId?.toString() ?: "",
@@ -405,13 +459,29 @@ open class DoWorkbenchReleaseService(
var createdHeaders = 0 var createdHeaders = 0
grouped.values.forEach { group -> grouped.values.forEach { group ->
val first = group.first() val first = group.first()
val storeId = when (first.preferredFloor) {
"2F" -> "2/F"
"4F" -> "4/F"
else -> "2/F"
val isDefaultTruckLane =
first.usedDefaultTruck == true ||
first.truckLanceCode?.trim() == WORKBENCH_DEFAULT_TRUCK_LANCE_CODE
val storeId: String? = if (isDefaultTruckLane) {
null
} else {
when (first.preferredFloor) {
"2F" -> "2/F"
"4F" -> "4/F"
else -> "2/F"
}
}
val ticketFloorSegment = if (isDefaultTruckLane) {
WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK
} else {
(storeId ?: "2/F").replace("/", "").trim()
} }
val requiredDate = first.estimatedArrivalDate ?: LocalDate.now() val requiredDate = first.estimatedArrivalDate ?: LocalDate.now()
val tempTicket = nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, storeId)
val tempTicket = if (releaseTypeCol == "single") {
nextDeliveryOrderPickOrderSingleTicketNo(requiredDate, ticketFloorSegment)
} else {
nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment)
}
val now = LocalDateTime.now() val now = LocalDateTime.now()


// Column names must match Liquibase `01_alter_stock_take.sql` (camelCase), not snake_case. // Column names must match Liquibase `01_alter_stock_take.sql` (camelCase), not snake_case.
@@ -425,7 +495,7 @@ open class DoWorkbenchReleaseService(
) VALUES ( ) VALUES (
:truckId, :shopId, :storeId, :requiredDeliveryDate, :truckDepartureTime, :truckId, :shopId, :storeId, :requiredDeliveryDate, :truckDepartureTime,
:truckLanceCode, :shopCode, :shopName, :loadingSequence, :ticketNo, :truckLanceCode, :shopCode, :shopName, :loadingSequence, :ticketNo,
NULL, 'pending', 'batch', NULL, NULL,
NULL, 'pending', :releaseType, NULL, NULL,
:created, :createdBy, 0, :modified, :modifiedBy, 0 :created, :createdBy, 0, :modified, :modifiedBy, 0
) )
""".trimIndent(), """.trimIndent(),
@@ -440,6 +510,7 @@ open class DoWorkbenchReleaseService(
"shopName" to first.shopName, "shopName" to first.shopName,
"loadingSequence" to first.loadingSequence, "loadingSequence" to first.loadingSequence,
"ticketNo" to tempTicket, "ticketNo" to tempTicket,
"releaseType" to releaseTypeCol,
"created" to now, "created" to now,
"createdBy" to "system", "createdBy" to "system",
"modified" to now, "modified" to now,


+ 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, estimatedArrivalDate = request.estimatedArrivalDate,
pageNum = request.pageNum, pageNum = request.pageNum,
pageSize = request.pageSize, pageSize = request.pageSize,
truckLanceCode = request.truckLanceCode
truckLanceCode = request.truckLanceCode,
floor = request.floor,
) )
} }


@@ -86,6 +87,25 @@ class DeliveryOrderController(
estimatedArrivalDate = request.estimatedArrivalDate, estimatedArrivalDate = request.estimatedArrivalDate,
pageNum = request.pageNum, pageNum = request.pageNum,
pageSize = request.pageSize, pageSize = request.pageSize,
floor = request.floor,
)
}

/**
* DO 輕量搜尋 v2:車線關鍵字正規化(`車線-X`/`x`/`車線-` 前綴併入未指派)、
* 允許供應商條件下分批掃描,避免單次載入過大;請求體同 [searchDoLite]。
*/
@PostMapping("/search-do-lite-v2")
fun searchDoLiteV2(@RequestBody request: SearchDeliveryOrderInfoRequest): RecordsRes<DeliveryOrderInfoLiteDto> {
return deliveryOrderService.searchDoLiteByPageV2(
code = request.code,
shopName = request.shopName,
status = request.status,
estimatedArrivalDate = request.estimatedArrivalDate,
pageNum = request.pageNum,
pageSize = request.pageSize,
truckLanceCode = request.truckLanceCode,
floor = request.floor,
) )
} }




+ 21
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt Прегледај датотеку

@@ -110,9 +110,15 @@ class DoWorkbenchController(
fun getWorkbenchReleasedDoPickOrdersToday( fun getWorkbenchReleasedDoPickOrdersToday(
@RequestParam(required = false) shopName: String?, @RequestParam(required = false) shopName: String?,
@RequestParam(required = false) storeId: String?, @RequestParam(required = false) storeId: String?,
@RequestParam(required = false) truck: String?
@RequestParam(required = false) truck: String?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?,
): List<ReleasedDoPickOrderListItem> { ): List<ReleasedDoPickOrderListItem> {
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(shopName, storeId, truck)
return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(
shopName,
storeId,
truck,
requiredDeliveryDate = requiredDate,
)
} }


@PostMapping("/assign-by-delivery-order-pick-order-id") @PostMapping("/assign-by-delivery-order-pick-order-id")
@@ -158,6 +164,19 @@ class DoWorkbenchController(
return doWorkbenchReleaseService.startBatchReleaseAsyncV2(ids, userId) return doWorkbenchReleaseService.startBatchReleaseAsyncV2(ids, userId)
} }


/**
* One delivery order, same release pipeline as [startWorkbenchBatchReleaseAsyncV2], but
* [delivery_order_pick_order.releaseType] = `single` and ticket prefix `TI-S-` (not batch / `TI-B-`).
* Body: JSON number (mirrors [DoPickOrderController.startBatchReleaseAsyncSingle]).
*/
@PostMapping("/batch-release/async-single-v2")
fun startWorkbenchBatchReleaseAsyncSingleV2(
@RequestBody doId: Long,
@RequestParam(defaultValue = "1") userId: Long
): MessageResponse {
return doWorkbenchReleaseService.startBatchReleaseAsyncSingleV2(listOf(doId), userId)
}

/** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */ /** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */
@PostMapping("/batch-release/sync-v2") @PostMapping("/batch-release/sync-v2")
fun workbenchBatchReleaseSyncV2( fun workbenchBatchReleaseSyncV2(


+ 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 estimatedArrivalDate: LocalDateTime?,
val pageSize: Int?, val pageSize: Int?,
val pageNum: Int?, val pageNum: Int?,
val truckLanceCode: String?
val truckLanceCode: String?,
/** `ALL`/`All`/null:P06B+P07+P06D;`2F`:P07+P06D;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */
val floor: String? = null,
) )

+ 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.JoPickOrderRepository
import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository
import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus
import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoResponse
import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderLotsHierarchicalResponse
import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoWorkbenchResponse
import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderLotsHierarchicalWorkbenchResponse
import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoWorkbenchResponse
import com.ffii.fpsms.modules.jobOrder.web.model.LotDetailResponse import com.ffii.fpsms.modules.jobOrder.web.model.LotDetailResponse
import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoResponse import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoResponse
import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsResponse
import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsWorkbenchResponse
import com.ffii.fpsms.modules.jobOrder.web.model.StockOutLineDetailResponse import com.ffii.fpsms.modules.jobOrder.web.model.StockOutLineDetailResponse
import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository
@@ -194,7 +195,7 @@ open class JoWorkbenchMainService(
/** /**
* Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line. * Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line.
*/ */
open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalResponse {
open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse {
println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===")
println("pickOrderId: $pickOrderId") println("pickOrderId: $pickOrderId")


@@ -299,8 +300,8 @@ open class JoWorkbenchMainService(
} }


val joPickOrders = joPickOrderRepository.findByPickOrderId(pickOrder.id!!) val joPickOrders = joPickOrderRepository.findByPickOrderId(pickOrder.id!!)
val pickOrderInfo = PickOrderInfoResponse(
val pickOrderInfo = PickOrderInfoWorkbenchResponse(
id = pickOrder.id, id = pickOrder.id,
code = pickOrder.code, code = pickOrder.code,
consoCode = pickOrder.consoCode, consoCode = pickOrder.consoCode,
@@ -310,10 +311,12 @@ open class JoWorkbenchMainService(
type = pickOrder.type?.value, type = pickOrder.type?.value,
status = pickOrder.status?.value, status = pickOrder.status?.value,
assignTo = pickOrder.assignTo?.id, assignTo = pickOrder.assignTo?.id,
jobOrder = JobOrderBasicInfoResponse(
jobOrder = JobOrderBasicInfoWorkbenchResponse(
id = jobOrder.id!!, id = jobOrder.id!!,
code = jobOrder.code ?: "", code = jobOrder.code ?: "",
name = "Job Order ${jobOrder.code}"
name = "Job Order ${jobOrder.code}",
itemCode = jobOrder.bom?.code,
itemName = jobOrder.bom?.name,
) )
) )


@@ -342,7 +345,7 @@ open class JoWorkbenchMainService(
val handlerNameInner = jpoInner?.handledBy?.let { uid -> val handlerNameInner = jpoInner?.handledBy?.let { uid ->
userService.find(uid).orElse(null)?.name userService.find(uid).orElse(null)?.name
} }
println("handlerName: $handlerNameInner")
//println("handlerName: $handlerNameInner")
val availableQty = if (sol?.status == "rejected") { val availableQty = if (sol?.status == "rejected") {
null null
} else { } else {
@@ -429,7 +432,7 @@ open class JoWorkbenchMainService(
) )
} }


PickOrderLineWithLotsResponse(
PickOrderLineWithLotsWorkbenchResponse(
id = pol.id!!, id = pol.id!!,
itemId = item?.id, itemId = item?.id,
itemCode = item?.code, itemCode = item?.code,
@@ -445,7 +448,7 @@ open class JoWorkbenchMainService(
) )
} }


JobOrderLotsHierarchicalResponse(
JobOrderLotsHierarchicalWorkbenchResponse(
pickOrder = pickOrderInfo, pickOrder = pickOrderInfo,
pickOrderLines = pickOrderLinesResult pickOrderLines = pickOrderLinesResult
) )
@@ -456,10 +459,10 @@ open class JoWorkbenchMainService(
} }
} }


private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalResponse {
private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse {
println("❌ $message") println("❌ $message")
return JobOrderLotsHierarchicalResponse(
pickOrder = PickOrderInfoResponse(
return JobOrderLotsHierarchicalWorkbenchResponse(
pickOrder = PickOrderInfoWorkbenchResponse(
id = null, id = null,
code = null, code = null,
consoCode = null, consoCode = null,
@@ -467,7 +470,7 @@ open class JoWorkbenchMainService(
type = null, type = null,
status = null, status = null,
assignTo = null, assignTo = null,
jobOrder = JobOrderBasicInfoResponse(0, "", "")
jobOrder = JobOrderBasicInfoWorkbenchResponse(0, "", "",null,null)
), ),
pickOrderLines = emptyList() pickOrderLines = emptyList()
) )


+ 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. */ /** Workbench: available qty uses in−out (matches scan-pick); stockouts include suggested SPL qty when matched. */
@GetMapping("/all-lots-hierarchical-by-pick-order-workbench/{pickOrderId}") @GetMapping("/all-lots-hierarchical-by-pick-order-workbench/{pickOrderId}")
fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalResponse {
fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse {
return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId) return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId)
} }


+ 37
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt Прегледај датотеку

@@ -90,9 +90,45 @@ data class PickOrderInfoResponse(
data class JobOrderBasicInfoResponse( data class JobOrderBasicInfoResponse(
val id: Long, val id: Long,
val code: String, val code: String,
val name: String
val name: String,
)
data class JobOrderLotsHierarchicalWorkbenchResponse(
val pickOrder: PickOrderInfoWorkbenchResponse,
val pickOrderLines: List<PickOrderLineWithLotsWorkbenchResponse>
) )


data class PickOrderInfoWorkbenchResponse(
val id: Long?,
val code: String?,
val consoCode: String?,
val targetDate: String?,
val type: String?,
val status: String?,
val assignTo: Long?,
val jobOrder: JobOrderBasicInfoWorkbenchResponse
)
data class PickOrderLineWithLotsWorkbenchResponse(
val id: Long,
val itemId: Long?,
val itemCode: String?,
val itemName: String?,
val requiredQty: Double?,
// Total available qty across all inventory lot lines for this item (used by JO pick UI)
val totalAvailableQty: Double? = null,
val uomCode: String?,
val uomDesc: String?,
val status: String?,
val lots: List<LotDetailResponse>,
val stockouts: List<StockOutLineDetailResponse> = emptyList(),
val handler: String?
)
data class JobOrderBasicInfoWorkbenchResponse(
val id: Long,
val code: String,
val name: String,
val itemCode: String?,
val itemName: String?,
)
data class PickOrderLineWithLotsResponse( data class PickOrderLineWithLotsResponse(
val id: Long, val id: Long,
val itemId: Long?, val itemId: Long?,


+ 4
- 3
src/main/java/com/ffii/fpsms/modules/master/entity/ShopAndTruck.kt Прегледај датотеку

@@ -15,7 +15,7 @@ import java.time.LocalTime


@Entity @Entity
@Table(name = "shop") @Table(name = "shop")
@SecondaryTable(name="Truck", pkJoinColumns = [PrimaryKeyJoinColumn(name = "shopId", referencedColumnName = "id")])
@SecondaryTable(name = "truck", pkJoinColumns = [PrimaryKeyJoinColumn(name = "shopId", referencedColumnName = "id")])
open class ShopAndTruck : BaseEntity<Long>() { open class ShopAndTruck : BaseEntity<Long>() {


// --- Shop fields --- // --- Shop fields ---
@@ -49,8 +49,9 @@ open class ShopAndTruck : BaseEntity<Long>() {
@Column(table = "truck", name = "LoadingSequence") @Column(table = "truck", name = "LoadingSequence")
open var loadingSequence: Long? = null open var loadingSequence: Long? = null


@Column(table = "truck", name = "districtReference")
open var districtReference: Long? = null
@Size(max = 255)
@Column(table = "truck", name = "districtReference", length = 255)
open var districtReference: String? = null


@Column(table = "truck", name = "Store_id") @Column(table = "truck", name = "Store_id")
open var storeId: String? = null open var storeId: String? = null


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/master/entity/projections/ShopAndTruck.kt Прегледај датотеку

@@ -16,7 +16,7 @@ interface ShopAndTruck {
val truckLanceCode: String? val truckLanceCode: String?
val departureTime: LocalTime? val departureTime: LocalTime?
val LoadingSequence: Long? val LoadingSequence: Long?
val districtReference: Long?
val districtReference: String?
val Store_id: String? val Store_id: String?
val remark: String? val remark: String?
val truckId: Long? val truckId: Long?


+ 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.core.utils.QrCodeUtil
import com.ffii.fpsms.modules.master.entity.EquipmentDetailRepository import com.ffii.fpsms.modules.master.entity.EquipmentDetailRepository
import com.ffii.fpsms.modules.master.web.ExportEquipmentQrCodeRequest import com.ffii.fpsms.modules.master.web.ExportEquipmentQrCodeRequest
import com.ffii.fpsms.modules.master.web.PrintEquipmentQrCodeRequest
import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry
import net.sf.jasperreports.engine.JasperCompileManager import net.sf.jasperreports.engine.JasperCompileManager
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperReport
import net.sf.jasperreports.engine.JasperPrint import net.sf.jasperreports.engine.JasperPrint
import org.springframework.core.io.ClassPathResource import org.springframework.core.io.ClassPathResource
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.File
import java.awt.GraphicsEnvironment import java.awt.GraphicsEnvironment
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString


@Service @Service
class EquipmentQrCodeService( class EquipmentQrCodeService(
private val equipmentDetailRepository: EquipmentDetailRepository
private val equipmentDetailRepository: EquipmentDetailRepository,
private val printerService: PrinterService,
) { ) {
private val qrCodeHandleJrxmlPath = "qrCodeHandle/equipment_QrHandle.jrxml"


fun exportEquipmentQrCode(request: ExportEquipmentQrCodeRequest): Map<String, Any> {
val QRCODE_HANDLE_PDF = "qrCodeHandle/equipment_QrHandle.jrxml"
val resource = ClassPathResource(QRCODE_HANDLE_PDF)
/**
* Compile the Jasper template once; compiling per request is expensive.
*/
private val qrCodeHandleReport: JasperReport by lazy {
val resource = ClassPathResource(qrCodeHandleJrxmlPath)
if (!resource.exists()) { if (!resource.exists()) {
throw FileNotFoundException("Report file not found: $QRCODE_HANDLE_PDF")
throw FileNotFoundException("Report file not found: $qrCodeHandleJrxmlPath")
} }
val inputStream = resource.inputStream
val qrCodeHandleReport = JasperCompileManager.compileReport(inputStream)
resource.inputStream.use { JasperCompileManager.compileReport(it) }
}

/**
* Cache the chosen Chinese font family name (font scanning is expensive).
*/
private val chineseFontFamily: String by lazy {
val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames
availableFonts.find { family ->
family.contains("SimSun", ignoreCase = true) ||
family.contains("Microsoft YaHei", ignoreCase = true) ||
family.contains("STSong", ignoreCase = true) ||
family.contains("SimHei", ignoreCase = true)
} ?: "Arial Unicode MS"
}

fun exportEquipmentQrCode(request: ExportEquipmentQrCodeRequest): Map<String, Any> {
val equipmentDetails = equipmentDetailRepository.findAllById(request.equipmentDetailIds) val equipmentDetails = equipmentDetailRepository.findAllById(request.equipmentDetailIds)
if (equipmentDetails.isEmpty()) { if (equipmentDetails.isEmpty()) {
throw IllegalArgumentException("No equipment details found for the provided equipment detail IDs: ${request.equipmentDetailIds}") throw IllegalArgumentException("No equipment details found for the provided equipment detail IDs: ${request.equipmentDetailIds}")
@@ -63,18 +85,10 @@ class EquipmentQrCodeService(
} }
val params: MutableMap<String, Any> = mutableMapOf() val params: MutableMap<String, Any> = mutableMapOf()
val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames
val chineseFont = availableFonts.find {
it.contains("SimSun", ignoreCase = true) ||
it.contains("Microsoft YaHei", ignoreCase = true) ||
it.contains("STSong", ignoreCase = true) ||
it.contains("SimHei", ignoreCase = true)
} ?: "Arial Unicode MS"

params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H"
params["net.sf.jasperreports.default.pdf.embedded"] = true params["net.sf.jasperreports.default.pdf.embedded"] = true
params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont
params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily
val firstEquipmentDetail = equipmentDetails.firstOrNull() val firstEquipmentDetail = equipmentDetails.firstOrNull()
@@ -83,4 +97,23 @@ class EquipmentQrCodeService(
"fileName" to (firstEquipmentDetail?.code ?: "equipment_qrcode") "fileName" to (firstEquipmentDetail?.code ?: "equipment_qrcode")
) )
} }

fun printEquipmentQrCode(request: PrintEquipmentQrCodeRequest) {
val printer = printerService.findById(request.printerId) ?: throw NoSuchElementException("No such printer")
val pdf = exportEquipmentQrCode(ExportEquipmentQrCodeRequest(request.equipmentDetailIds))
val jasperPrint = pdf["report"] as JasperPrint
val tempPdfFile = File.createTempFile("print_job_", ".pdf")

try {
JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath)
val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty
printer.ip?.let { ip ->
val port = printer.port ?: 9100
val driver = A4PrintDriverRegistry.getDriver(printer.brand)
driver.print(tempPdfFile, ip, port, printQty)
}
} finally {
tempPdfFile.delete()
}
}
} }

+ 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}") //println("Query result size: ${result.size}")
// result.forEach { row -> println("Result row: $row") } // result.forEach { row -> println("Result row: $row") }
return result return result
} catch (e: Exception) { } catch (e: Exception) {
println("Error in getPickOrderItemsByPage: ${e.message}") println("Error in getPickOrderItemsByPage: ${e.message}")
e.printStackTrace() e.printStackTrace()


+ 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.PdfUtils
import com.ffii.core.utils.QrCodeUtil import com.ffii.core.utils.QrCodeUtil
import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry
import com.ffii.fpsms.modules.master.entity.WarehouseRepository import com.ffii.fpsms.modules.master.entity.WarehouseRepository
import com.ffii.fpsms.modules.master.web.ExportWarehouseQrCodeRequest import com.ffii.fpsms.modules.master.web.ExportWarehouseQrCodeRequest
import com.ffii.fpsms.modules.master.web.PrintWarehouseQrCodeRequest
import net.sf.jasperreports.engine.JasperCompileManager import net.sf.jasperreports.engine.JasperCompileManager
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperReport
import net.sf.jasperreports.engine.JasperPrint import net.sf.jasperreports.engine.JasperPrint
import org.springframework.core.io.ClassPathResource import org.springframework.core.io.ClassPathResource
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.awt.GraphicsEnvironment import java.awt.GraphicsEnvironment
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -15,19 +20,32 @@ import kotlinx.serialization.encodeToString


@Service @Service
class WarehouseQrCodeService( class WarehouseQrCodeService(
private val warehouseRepository: WarehouseRepository
private val warehouseRepository: WarehouseRepository,
private val printerService: PrinterService,
) { ) {
private val qrCodeHandleJrxmlPath = "qrCodeHandle/warehouse_QrHandle.jrxml"


fun exportWarehouseQrCode(request: ExportWarehouseQrCodeRequest): Map<String, Any> {
val QRCODE_HANDLE_PDF = "qrCodeHandle/warehouse_QrHandle.jrxml"
val resource = ClassPathResource(QRCODE_HANDLE_PDF)
/** Compile the Jasper template once; compiling per request is expensive. */
private val qrCodeHandleReport: JasperReport by lazy {
val resource = ClassPathResource(qrCodeHandleJrxmlPath)
if (!resource.exists()) { if (!resource.exists()) {
throw FileNotFoundException("Report file not found: $QRCODE_HANDLE_PDF")
throw FileNotFoundException("Report file not found: $qrCodeHandleJrxmlPath")
} }
val inputStream = resource.inputStream
val qrCodeHandleReport = JasperCompileManager.compileReport(inputStream)
resource.inputStream.use { JasperCompileManager.compileReport(it) }
}

/** Cache the chosen Chinese font family name (font scanning is expensive). */
private val chineseFontFamily: String by lazy {
val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames
availableFonts.find { family ->
family.contains("SimSun", ignoreCase = true) ||
family.contains("Microsoft YaHei", ignoreCase = true) ||
family.contains("STSong", ignoreCase = true) ||
family.contains("SimHei", ignoreCase = true)
} ?: "Arial Unicode MS"
}

fun exportWarehouseQrCode(request: ExportWarehouseQrCodeRequest): Map<String, Any> {
val warehouses = warehouseRepository.findAllById(request.warehouseIds) val warehouses = warehouseRepository.findAllById(request.warehouseIds)
if (warehouses.isEmpty()) { if (warehouses.isEmpty()) {
throw IllegalArgumentException("No warehouses found for the provided warehouse IDs: ${request.warehouseIds}") throw IllegalArgumentException("No warehouses found for the provided warehouse IDs: ${request.warehouseIds}")
@@ -68,18 +86,10 @@ class WarehouseQrCodeService(
} }
val params: MutableMap<String, Any> = mutableMapOf() val params: MutableMap<String, Any> = mutableMapOf()
val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames
val chineseFont = availableFonts.find {
it.contains("SimSun", ignoreCase = true) ||
it.contains("Microsoft YaHei", ignoreCase = true) ||
it.contains("STSong", ignoreCase = true) ||
it.contains("SimHei", ignoreCase = true)
} ?: "Arial Unicode MS"

params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H"
params["net.sf.jasperreports.default.pdf.embedded"] = true params["net.sf.jasperreports.default.pdf.embedded"] = true
params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont
params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily
val firstWarehouse = warehouses.firstOrNull() val firstWarehouse = warehouses.firstOrNull()
@@ -88,4 +98,23 @@ class WarehouseQrCodeService(
"fileName" to (firstWarehouse?.code ?: "warehouse_qrcode") "fileName" to (firstWarehouse?.code ?: "warehouse_qrcode")
) )
} }

fun printWarehouseQrCode(request: PrintWarehouseQrCodeRequest) {
val printer = printerService.findById(request.printerId) ?: throw NoSuchElementException("No such printer")
val pdf = exportWarehouseQrCode(ExportWarehouseQrCodeRequest(request.warehouseIds))
val jasperPrint = pdf["report"] as JasperPrint
val tempPdfFile = File.createTempFile("print_job_", ".pdf")

try {
JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath)
val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty
printer.ip?.let { ip ->
val port = printer.port ?: 9100
val driver = A4PrintDriverRegistry.getDriver(printer.brand)
driver.print(tempPdfFile, ip, port, printQty)
}
} finally {
tempPdfFile.delete()
}
}
} }

+ 5
- 0
src/main/java/com/ffii/fpsms/modules/master/web/EquipmentController.kt Прегледај датотеку

@@ -93,4 +93,9 @@ fun getAllEquipmentByPage(
out.flush() 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.write(JasperExportManager.exportReportToPdf(jasperPrint))
out.flush() out.flush()
} }
@PostMapping("/print-qrcode")
fun printQrCode(@Valid @RequestBody request: PrintWarehouseQrCodeRequest) {
warehouseQrCodeService.printWarehouseQrCode(request)
}
@GetMapping("/stockTakeSections") @GetMapping("/stockTakeSections")
fun getStockTakeSections(): List<StockTakeSectionInfo> { fun getStockTakeSections(): List<StockTakeSectionInfo> {
return warehouseService.getStockTakeSections() return warehouseService.getStockTakeSections()


+ 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") @Column(name = "Store_id")
open var storeId: String? = null open var storeId: String? = null


@Column(name = "districtReference")
open var districtReference: Int? = null
@Size(max = 255)
@Column(name = "districtReference", length = 255)
open var districtReference: String? = null


@Column(name = "remark") @Column(name = "remark")
open var remark: String? = null open var remark: String? = null


+ 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") throw IllegalStateException("Failed to generate unique delivery note code after $maxAttempts attempts")
} }
/*
@Transactional(rollbackFor = [java.lang.Exception::class]) @Transactional(rollbackFor = [java.lang.Exception::class])
open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageResponse { open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageResponse {
try { try {
println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===")
println("consoCode: $consoCode")
// println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===")
// println("consoCode: $consoCode")


val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(consoCode) val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(consoCode)
if (stockOut == null) { if (stockOut == null) {
println("❌ No stock_out found for consoCode: $consoCode")
//println("❌ No stock_out found for consoCode: $consoCode")
return MessageResponse( return MessageResponse(
id = null, id = null,
name = "Stock out not found", name = "Stock out not found",
@@ -1680,13 +1681,13 @@ open class PickOrderService(
!(isComplete || isRejected || isPartiallyComplete) !(isComplete || isRejected || isPartiallyComplete)
} }


println("📊 Stock out lines: ${stockOutLines.size}, Unfinished: ${unfinishedLines.size}")
// println("📊 Stock out lines: ${stockOutLines.size}, Unfinished: ${unfinishedLines.size}")


if (unfinishedLines.isEmpty()) { if (unfinishedLines.isEmpty()) {
println(" All stock out lines completed, updating pick order statuses...") println(" All stock out lines completed, updating pick order statuses...")
return completeStockOut(consoCode) return completeStockOut(consoCode)
} else { } else {
println("⏳ Still have ${unfinishedLines.size} unfinished lines")
//println("⏳ Still have ${unfinishedLines.size} unfinished lines")
return MessageResponse( return MessageResponse(
id = stockOut.id, id = stockOut.id,
name = stockOut.consoPickOrderCode ?: stockOut.deliveryOrderCode, name = stockOut.consoPickOrderCode ?: stockOut.deliveryOrderCode,
@@ -1710,8 +1711,47 @@ open class PickOrderService(
) )
} }
} }

*/
@Transactional(readOnly = true)
open fun countUnfinishedLinesByConsoCode(consoCode: String): Int {
val sql = """
SELECT COUNT(1) AS unfinished_count
FROM stock_out_line sol
JOIN pick_order_line pol ON pol.id = sol.pickOrderLineId
JOIN pick_order po ON po.id = pol.poId
WHERE po.consoCode = :consoCode
AND sol.deleted = false
AND LOWER(TRIM(COALESCE(sol.status, ''))) NOT IN (
'completed',
'complete',
'rejected',
'partially_completed',
'partially_complete'
)
""".trimIndent()
val rows = jdbcDao.queryForList(sql, mapOf("consoCode" to consoCode))
val countAny = rows.firstOrNull()?.get("unfinished_count")
return when (countAny) {
is Number -> countAny.toInt()
is String -> countAny.toIntOrNull() ?: 0
else -> 0
}
}
@Transactional(rollbackFor = [Exception::class])
open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageResponse {
val unfinished = countUnfinishedLinesByConsoCode(consoCode)
if (unfinished > 0) {
return MessageResponse(
id = null,
name = consoCode,
code = "NOT_COMPLETED",
type = "pickorder",
message = "Pick order not completed yet, $unfinished lines remaining",
errorPosition = null
)
}
return completeStockOut(consoCode)
}
open fun createGroup(name: String, targetDate: LocalDate, pickOrderId: Long?): PickOrderGroup { open fun createGroup(name: String, targetDate: LocalDate, pickOrderId: Long?): PickOrderGroup {
val group = PickOrderGroup().apply { val group = PickOrderGroup().apply {
this.name = name this.name = name


+ 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 // Use remark from request (user input) - no auto-fill
updateTruckLance.truckLanceCode = request.truckLanceCode updateTruckLance.truckLanceCode = request.truckLanceCode
updateTruckLance.loadingSequence = request.loadingSequence.toInt() updateTruckLance.loadingSequence = request.loadingSequence.toInt()
updateTruckLance.districtReference = request.districtReference.toInt()
updateTruckLance.districtReference = request.districtReference
updateTruckLance.departureTime = request.departureTime updateTruckLance.departureTime = request.departureTime
updateTruckLance.storeId = request.storeId updateTruckLance.storeId = request.storeId
// Only set remark if storeId is "4F", otherwise set to null // Only set remark if storeId is "4F", otherwise set to null


+ 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) targetDate: String?,
@RequestParam(required = false) deliveryNoteCode: String?, @RequestParam(required = false) deliveryNoteCode: String?,
@RequestParam(required = false) truckLanceCode: String?, @RequestParam(required = false) truckLanceCode: String?,
@RequestParam(required = false) ticketNo: String?,
): List<CompletedDoPickOrderResponse> { ): List<CompletedDoPickOrderResponse> {
val request = GetCompletedDoPickOrdersRequest( val request = GetCompletedDoPickOrdersRequest(
targetDate = targetDate, targetDate = targetDate,
shopName = shopName, shopName = shopName,
deliveryNoteCode = deliveryNoteCode, deliveryNoteCode = deliveryNoteCode,
truckLanceCode = truckLanceCode, truckLanceCode = truckLanceCode,
ticketNo = ticketNo,
) )
return doWorkbenchMainService.getCompletedDoPickOrdersWorkbench(userId, request) return doWorkbenchMainService.getCompletedDoPickOrdersWorkbench(userId, request)
} }
@@ -390,12 +392,14 @@ fun getCompletedDoPickOrdersWorkbenchAll(
@RequestParam(required = false) targetDate: String?, @RequestParam(required = false) targetDate: String?,
@RequestParam(required = false) deliveryNoteCode: String?, @RequestParam(required = false) deliveryNoteCode: String?,
@RequestParam(required = false) truckLanceCode: String?, @RequestParam(required = false) truckLanceCode: String?,
@RequestParam(required = false) ticketNo: String?,
): List<CompletedDoPickOrderResponse> { ): List<CompletedDoPickOrderResponse> {
val request = GetCompletedDoPickOrdersRequest( val request = GetCompletedDoPickOrdersRequest(
targetDate = targetDate, targetDate = targetDate,
shopName = shopName, shopName = shopName,
deliveryNoteCode = deliveryNoteCode, deliveryNoteCode = deliveryNoteCode,
truckLanceCode = truckLanceCode, truckLanceCode = truckLanceCode,
ticketNo = ticketNo,
) )
return doWorkbenchMainService.getCompletedDoPickOrdersWorkbenchAll(request) return doWorkbenchMainService.getCompletedDoPickOrdersWorkbenchAll(request)
} }


+ 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 shopCode: String,
val loadingSequence: Int, val loadingSequence: Int,
val remark: String? = null, val remark: String? = null,
val districtReference: Int? = null,
val districtReference: String? = null,
) )
data class SaveTruckLane( data class SaveTruckLane(
val id: Long, val id: Long,
val truckLanceCode: String, val truckLanceCode: String,
val departureTime: LocalTime, val departureTime: LocalTime,
val loadingSequence: Long, val loadingSequence: Long,
val districtReference: Long,
val districtReference: String?,
val storeId: String, val storeId: String,
val remark: String? = null val remark: String? = null
) )
@@ -37,6 +37,6 @@ data class CreateTruckWithoutShopRequest(
val truckLanceCode: String, val truckLanceCode: String,
val departureTime: LocalTime, val departureTime: LocalTime,
val loadingSequence: Int = 0, val loadingSequence: Int = 0,
val districtReference: Int? = null,
val districtReference: String? = null,
val remark: String? = null, val remark: String? = null,
) )

+ 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, val deliveryNoteCode: String? = null,
/** 卡車 / 車道代碼(模糊匹配 truck_lance_code) */ /** 卡車 / 車道代碼(模糊匹配 truck_lance_code) */
val truckLanceCode: String? = null, val truckLanceCode: String? = null,
val ticketNo: String? = null,
) )


data class CompletedDoPickOrderResponse( data class CompletedDoPickOrderResponse(


+ 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( val sql = StringBuilder(
"select * from ( " + "select * from ( " +
"select " + "select " +
@@ -211,9 +214,11 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
if (args.containsKey("itemDetail")){ if (args.containsKey("itemDetail")){
sql.append(" where r.itemDetail like :itemDetail "); sql.append(" where r.itemDetail like :itemDetail ");
} }
val list = jdbcDao.queryForList(sql.toString(), args);
return sql.toString()
}


val mappedList = list.map {
private fun mapRowsToPoListDataClass(list: List<Map<String, Any>>): List<PurchaseOrderDataClass> {
return list.map {
PurchaseOrderDataClass( PurchaseOrderDataClass(
id = (it["id"] as Int).toLong(), id = (it["id"] as Int).toLong(),
code = it["code"] as String, code = it["code"] as String,
@@ -231,11 +236,32 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
escalated = it["escalated"] == 1L, escalated = it["escalated"] == 1L,
) )
} }
// println(value1)
// println(value1 == 1L)
// println(value2)
// println(mappedList)
return mappedList
}

open fun getPoListTotalCount(args: MutableMap<String, Any>): Int {
val base = buildPoListUnpagedSelectSql(args)
val countSql = "SELECT COUNT(1) AS cnt FROM ( $base ) po_list_count_wrap"
val list = jdbcDao.queryForList(countSql, args)
if (list.isEmpty()) {
return 0
}
return (list.first()["cnt"] as Number).toInt()
}

open fun getPoListPage(
args: MutableMap<String, Any>,
pageSize: Int,
pageNum: Int,
): List<PurchaseOrderDataClass> {
val size = pageSize.coerceAtLeast(1)
val page = pageNum.coerceAtLeast(1)
val pagedArgs: MutableMap<String, Any> = HashMap(args)
pagedArgs["limit"] = size
pagedArgs["offset"] = (page - 1) * size
val dataSql = buildPoListUnpagedSelectSql(args) +
" ORDER BY r.orderDate DESC LIMIT :limit OFFSET :offset"
val list = jdbcDao.queryForList(dataSql, pagedArgs)
return mapRowsToPoListDataClass(list)
} }


open fun allPurchaseOrder(): List<PurchaseOrder> { open fun allPurchaseOrder(): List<PurchaseOrder> {


+ 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.response.RecordsRes
import com.ffii.core.support.JdbcDao import com.ffii.core.support.JdbcDao
import com.ffii.core.utils.CriteriaArgsBuilder import com.ffii.core.utils.CriteriaArgsBuilder
import com.ffii.core.utils.PagingUtils
import com.ffii.core.utils.ZebraPrinterUtil import com.ffii.core.utils.ZebraPrinterUtil
import com.ffii.fpsms.modules.master.entity.Items import com.ffii.fpsms.modules.master.entity.Items
import com.ffii.fpsms.modules.master.service.ItemsService import com.ffii.fpsms.modules.master.service.ItemsService
@@ -49,13 +48,15 @@ class PurchaseOrderController(
.addDate("estimatedArrivalDateTo") .addDate("estimatedArrivalDateTo")
.build() .build()
// println(criteriaArgs) // println(criteriaArgs)
val pageSize = request.getParameter("pageSize")?.toIntOrNull() ?: 10
val pageNum = request.getParameter("pageNum")?.toIntOrNull() ?: 1
val pageSize = request.getParameter("pageSize")?.toIntOrNull()?.coerceAtLeast(1) ?: 10
val pageNum = request.getParameter("pageNum")?.toIntOrNull()?.coerceAtLeast(1) ?: 1


val fullList = purchaseOrderService.getPoList(criteriaArgs)
val paginatedList = PagingUtils.getPaginatedList(fullList,pageSize, pageNum)

return RecordsRes(paginatedList, fullList.size)
val total = purchaseOrderService.getPoListTotalCount(criteriaArgs)
if (total == 0) {
return RecordsRes(emptyList<PurchaseOrderDataClass>(), 0)
}
val pageRows = purchaseOrderService.getPoListPage(criteriaArgs, pageSize, pageNum)
return RecordsRes(pageRows, total)
} }
/** Class mapping is `/po`; path must be `/summary` → full path `/api/po/summary` (not `/po/po/summary`). */ /** Class mapping is `/po`; path must be `/summary` → full path `/api/po/summary` (not `/po/po/summary`). */
@GetMapping("/summary") @GetMapping("/summary")


+ 38
- 2
src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt Прегледај датотеку

@@ -305,6 +305,8 @@ ORDER BY
fun searchStockTakeVarianceReportV2( fun searchStockTakeVarianceReportV2(
stockTakeRoundId: Long, stockTakeRoundId: Long,
itemCode: String?, itemCode: String?,
storeId: String?,
status: String?,
): List<Map<String, Any>> { ): List<Map<String, Any>> {
val countSql = """ val countSql = """
SELECT COUNT(*) AS c FROM stocktakerecord s SELECT COUNT(*) AS c FROM stocktakerecord s
@@ -320,6 +322,34 @@ ORDER BY


val args = mutableMapOf<String, Any>() val args = mutableMapOf<String, Any>()
args["stockTakeRoundId"] = stockTakeRoundId args["stockTakeRoundId"] = stockTakeRoundId

val statusNormalized = status?.trim()?.lowercase().orEmpty()
// status 映射规则:
// - All/null:不过滤
// - pending:包含 pending/pass/notMatch
// - completed:只看 completed
val statusLatestSql = when (statusNormalized) {
"pending" -> """
AND str.status IN ('pending', 'pass', 'notMatch')
""".trimIndent()
"completed" -> """
AND str.status = 'completed'
""".trimIndent()
else -> ""
}

val storeIdSql = run {
val normalized = storeId?.trim()
if (normalized.isNullOrBlank() || normalized.equals("all", ignoreCase = true)) {
""
} else {
args["storeId"] = normalized
// DB 里 store_id 可能是 "2/F" 或 "2F";用 REPLACE 去斜線做匹配
"""
AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')
""".trimIndent()
}
}
val itemCodeSql = buildMultiValueLikeClause( val itemCodeSql = buildMultiValueLikeClause(
itemCode, itemCode,
"it.code", "it.code",
@@ -345,10 +375,12 @@ latest_str AS (
str.approverStockTakeQty, str.approverStockTakeQty,
str.date AS strDate, str.date AS strDate,
str.id, str.id,
str.approverTime
str.approverTime,
str.status AS stockTakeRecordStatus
FROM stocktakerecord str FROM stocktakerecord str
WHERE str.deleted = 0 WHERE str.deleted = 0
AND str.stockTakeRoundId = :stockTakeRoundId AND str.stockTakeRoundId = :stockTakeRoundId
$statusLatestSql
), ),
in_agg AS ( in_agg AS (
SELECT SELECT
@@ -443,7 +475,8 @@ data AS (
ls.approverStockTakeQty AS stkApproverQty, ls.approverStockTakeQty AS stkApproverQty,
ls.varianceQty AS stkVarianceQty, ls.varianceQty AS stkVarianceQty,
ls.strDate AS stockTakeDateRaw, ls.strDate AS stockTakeDateRaw,
ls.approverTime AS approvalDateTimeRaw
ls.approverTime AS approvalDateTimeRaw,
ls.stockTakeRecordStatus AS stockTakeRecordStatus
FROM latest_str ls FROM latest_str ls
INNER JOIN inventory_lot il INNER JOIN inventory_lot il
ON ls.lotId = il.id ON ls.lotId = il.id
@@ -471,6 +504,7 @@ data AS (


WHERE 1=1 WHERE 1=1
$itemCodeSql $itemCodeSql
$storeIdSql
) )


SELECT SELECT
@@ -501,12 +535,14 @@ SELECT
END AS stockTakeQty, END AS stockTakeQty,


CASE CASE
WHEN COALESCE(stockTakeRecordStatus, '') <> 'completed' THEN '0'
WHEN stkVarianceQty IS NULL THEN '0' WHEN stkVarianceQty IS NULL THEN '0'
WHEN COALESCE(stkVarianceQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkVarianceQty, 0), ')') WHEN COALESCE(stkVarianceQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkVarianceQty, 0), ')')
ELSE FORMAT(COALESCE(stkVarianceQty, 0), 0) ELSE FORMAT(COALESCE(stkVarianceQty, 0), 0)
END AS variance, END AS variance,


CASE CASE
WHEN COALESCE(stockTakeRecordStatus, '') <> 'completed' THEN '0%'
WHEN stkVarianceQty IS NULL THEN '0%' WHEN stkVarianceQty IS NULL THEN '0%'
WHEN COALESCE(stkBookQty, 0) = 0 THEN '0%' WHEN COALESCE(stkBookQty, 0) = 0 THEN '0%'
WHEN (COALESCE(stkVarianceQty, 0) / stkBookQty) * 100 < 0 THEN CONCAT('(', FORMAT(-(COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%)') WHEN (COALESCE(stkVarianceQty, 0) / stkBookQty) * 100 < 0 THEN CONCAT('(', FORMAT(-(COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%)')


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt Прегледај датотеку

@@ -143,6 +143,8 @@ class StockTakeVarianceReportController(
fun generateStockTakeVarianceReportV2( fun generateStockTakeVarianceReportV2(
@RequestParam stockTakeRoundId: Long, @RequestParam stockTakeRoundId: Long,
@RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemCode: String?,
@RequestParam(required = false, name = "store_id") storeId: String?,
@RequestParam(required = false) status: String?,
): ResponseEntity<ByteArray> { ): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>() val parameters = mutableMapOf<String, Any>()


@@ -169,6 +171,8 @@ class StockTakeVarianceReportController(
val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2(
stockTakeRoundId = stockTakeRoundId, stockTakeRoundId = stockTakeRoundId,
itemCode = itemCode, itemCode = itemCode,
storeId = storeId,
status = status,
) )
val stockTakeDateDisplay = dbData val stockTakeDateDisplay = dbData
.mapNotNull { it["stockTakeDate"] as? String } .mapNotNull { it["stockTakeDate"] as? String }
@@ -196,11 +200,15 @@ class StockTakeVarianceReportController(
fun exportStockTakeVarianceReportV2Excel( fun exportStockTakeVarianceReportV2Excel(
@RequestParam stockTakeRoundId: Long, @RequestParam stockTakeRoundId: Long,
@RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemCode: String?,
@RequestParam(required = false, name = "store_id") storeId: String?,
@RequestParam(required = false) status: String?,
): ResponseEntity<ByteArray> { ): ResponseEntity<ByteArray> {
val cap = stockTakeVarianceReportService.getStockTakeRoundCaption(stockTakeRoundId) val cap = stockTakeVarianceReportService.getStockTakeRoundCaption(stockTakeRoundId)
val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2(
stockTakeRoundId = stockTakeRoundId, stockTakeRoundId = stockTakeRoundId,
itemCode = itemCode, itemCode = itemCode,
storeId = storeId,
status = status,
) )


val excelBytes = createStockTakeVarianceExcel( val excelBytes = createStockTakeVarianceExcel(


+ 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 findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo>


fun findByItemId(itemId: Long): Optional<Inventory> fun findByItemId(itemId: Long): Optional<Inventory>
fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection<Long>): List<Inventory>


@Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false") @Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false")
fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Inventory> fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Inventory>


+ 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 findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?;
fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?; fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?;
fun findByStockTakeRecord_IdAndDeletedIsFalse(stockTakeRecordId: Long): StockTakeLine? fun findByStockTakeRecord_IdAndDeletedIsFalse(stockTakeRecordId: Long): StockTakeLine?
fun findAllByStockTakeRecord_IdInAndDeletedIsFalse(stockTakeRecordIds: Collection<Long>): List<StockTakeLine>
fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse( fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse(
stockTakeIds: Collection<Long>, stockTakeIds: Collection<Long>,
inventoryLotLineIds: Collection<Long> inventoryLotLineIds: Collection<Long>


+ 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 } .filter { !it.deleted && it.inventoryLot?.item != null }
.toList() .toList()
val item = source.firstOrNull()?.inventoryLot?.item val item = source.firstOrNull()?.inventoryLot?.item
?: throw IllegalStateException("Item not found for itemId=$itemId")

if (item == null) {
return WorkbenchItemLotsResponse(
itemId = itemId,
itemCode = "",
itemName = "",
sameItemLots = emptyList()
)
}
val sameItemLots = source val sameItemLots = source
.mapNotNull { lotLine -> .mapNotNull { lotLine ->
val lot = lotLine.inventoryLot ?: return@mapNotNull null val lot = lotLine.inventoryLot ?: return@mapNotNull null


+ 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) continue // Branch 1: no change


if (diff.compareTo(BigDecimal.ZERO) > 0) { if (diff.compareTo(BigDecimal.ZERO) > 0) {
// Branch 2 (qty up): createStockIn
// Branch 2 (qty up): increase inQty on the same lot line; new StockIn/StockInLine for audit only
val inventoryLotLine = inventoryLotLineRepository.findById(current.id) val inventoryLotLine = inventoryLotLineRepository.findById(current.id)
.orElseThrow { IllegalArgumentException("InventoryLotLine not found: ${current.id}") } .orElseThrow { IllegalArgumentException("InventoryLotLine not found: ${current.id}") }
val stockInRequest = buildStockInRequestFromExistingLotLine(inventoryLotLine, diff) val stockInRequest = buildStockInRequestFromExistingLotLine(inventoryLotLine, diff)
val stockInLine = stockInLineService.createStockIn(stockInRequest)
val stockInLine = stockInLineService.createStockInForExistingInventoryLotLine(
stockInRequest,
inventoryLotLine
)
saveAdjustmentRecordForStockIn(stockInLine) saveAdjustmentRecordForStockIn(stockInLine)
} else { } else {
// Branch 3 (qty down): adjustment outbound only (not pick createStockOut) // Branch 3 (qty down): adjustment outbound only (not pick createStockOut)


+ 400
- 85
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Прегледај датотеку

@@ -422,6 +422,14 @@ open class StockTakeRecordService(
.mapNotNull { it.stockTakeSectionDescription } // 先去掉 null .mapNotNull { it.stockTakeSectionDescription } // 先去掉 null
.distinct() // 去重(防止误填多个不同值) .distinct() // 去重(防止误填多个不同值)
.firstOrNull() .firstOrNull()
val warehouseArea = warehouses
.mapNotNull { it.area }
.distinct()
.firstOrNull()
val storeId = warehouses
.mapNotNull { it.store_id }
.distinct()
.firstOrNull()


val roundIdForLatest = latestStockTake?.let { st -> st.stockTakeRoundId ?: st.id } val roundIdForLatest = latestStockTake?.let { st -> st.stockTakeRoundId ?: st.id }
val roundRecordsForSection = if (latestStockTake != null && roundIdForLatest != null) { val roundRecordsForSection = if (latestStockTake != null && roundIdForLatest != null) {
@@ -483,7 +491,9 @@ open class StockTakeRecordService(
endTime = latestStockTake?.actualEnd, endTime = latestStockTake?.actualEnd,
ReStockTakeTrueFalse = reStockTakeTrueFalse, ReStockTakeTrueFalse = reStockTakeTrueFalse,
planStartDate = latestStockTake?.planStart?.toLocalDate(), planStartDate = latestStockTake?.planStart?.toLocalDate(),
stockTakeSectionDescription = sectionDescription
stockTakeSectionDescription = sectionDescription,
warehouseArea = warehouseArea,
storeId = storeId


) )
) )
@@ -804,7 +814,9 @@ open class StockTakeRecordService(
endTime = latestBaseStockTake.actualEnd, endTime = latestBaseStockTake.actualEnd,
ReStockTakeTrueFalse = anyNotMatch, ReStockTakeTrueFalse = anyNotMatch,
planStartDate = latestBaseStockTake.planStart?.toLocalDate(), planStartDate = latestBaseStockTake.planStart?.toLocalDate(),
stockTakeSectionDescription = null
stockTakeSectionDescription = null,
warehouseArea = null,
storeId = null
) )
) )
} }
@@ -839,7 +851,9 @@ open class StockTakeRecordService(
endTime = latestBaseStockTake.actualEnd, endTime = latestBaseStockTake.actualEnd,
ReStockTakeTrueFalse = false, ReStockTakeTrueFalse = false,
planStartDate = latestBaseStockTake.planStart?.toLocalDate(), planStartDate = latestBaseStockTake.planStart?.toLocalDate(),
stockTakeSectionDescription = null
stockTakeSectionDescription = null,
warehouseArea = null,
storeId = null
) )
} }


@@ -1842,11 +1856,14 @@ if (itemParts.isNotEmpty()) {
open fun batchSaveApproverStockTakeRecordsByIds( open fun batchSaveApproverStockTakeRecordsByIds(
request: BatchSaveApproverStockTakeByIdsRequest request: BatchSaveApproverStockTakeByIdsRequest
): BatchSaveApproverStockTakeRecordResponse { ): BatchSaveApproverStockTakeRecordResponse {
println("batchSaveApproverStockTakeRecordsByIds called for stockTakeId: ${request.stockTakeId}, ids=${request.recordIds.size}")
val totalStartNs = System.nanoTime()
fun elapsedMs(startNs: Long): Long = (System.nanoTime() - startNs) / 1_000_000
logger.info("batchSaveApproverStockTakeRecordsByIds start: stockTakeId={}, ids={}", request.stockTakeId, request.recordIds.size)
if (request.recordIds.isEmpty()) { if (request.recordIds.isEmpty()) {
return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No record IDs provided")) return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No record IDs provided"))
} }


val loadStartNs = System.nanoTime()
val user = userRepository.findById(request.approverId).orElse(null) val user = userRepository.findById(request.approverId).orElse(null)
val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(request.stockTakeId) val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(request.stockTakeId)
?: throw IllegalArgumentException("Stock take not found: ${request.stockTakeId}") ?: throw IllegalArgumentException("Stock take not found: ${request.stockTakeId}")
@@ -1859,15 +1876,33 @@ open fun batchSaveApproverStockTakeRecordsByIds(
(it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) &&
it.approverStockTakeQty == null it.approverStockTakeQty == null
} }
println("Found ${stockTakeRecords.size} records to process by IDs")
logger.info(
"batchSaveApproverStockTakeRecordsByIds load completed: candidates={}, loadMs={}",
stockTakeRecords.size,
elapsedMs(loadStartNs)
)
if (stockTakeRecords.isEmpty()) { if (stockTakeRecords.isEmpty()) {
return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No records found matching criteria")) return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No records found matching criteria"))
} }
val cacheBuildStartNs = System.nanoTime()
val adjustmentCache = buildBatchAdjustmentCache(stockTakeRecords)
logger.info(
"batchSaveApproverStockTakeRecordsByIds cache build completed: lotLinePairs={}, lots={}, inventories={}, stockTakeLines={}, cacheBuildMs={}",
adjustmentCache.inventoryLotLineByWarehouseLot.size,
adjustmentCache.inventoryLotById.size,
adjustmentCache.inventoryByItemId.size,
adjustmentCache.stockTakeLineByRecordId.size,
elapsedMs(cacheBuildStartNs)
)


var successCount = 0 var successCount = 0
var errorCount = 0 var errorCount = 0
val errors = mutableListOf<String>() val errors = mutableListOf<String>()
val processedStockTakes = mutableSetOf<Pair<Long, String>>() val processedStockTakes = mutableSetOf<Pair<Long, String>>()
val prepareStartNs = System.nanoTime()
val recordsToPersist = mutableListOf<StockTakeRecord>()
val postPersistActions = mutableListOf<Triple<StockTakeRecord, BigDecimal, BigDecimal>>()

stockTakeRecords.forEach { record -> stockTakeRecords.forEach { record ->
try { try {
val qty: BigDecimal val qty: BigDecimal
@@ -1901,27 +1936,8 @@ open fun batchSaveApproverStockTakeRecordsByIds(
} }
} }


stockTakeRecordRepository.save(record)

if (varianceQty != BigDecimal.ZERO) {
try {
applyVarianceAdjustment(record.stockTake ?: stockTake, record, qty, varianceQty, request.approverId)
} catch (e: Exception) {
logger.error("Failed to apply variance adjustment for record ${record.id}", e)
errorCount++
errors.add("Record ${record.id}: ${e.message}")
return@forEach
}
} else {
completeStockTakeLineForApproverNoVariance(record.stockTake ?: stockTake, record, qty)
}

val stId = record.stockTake?.id
val section = record.stockTakeSection
if (stId != null && section != null) {
processedStockTakes.add(Pair(stId, section))
}
successCount++
recordsToPersist.add(record)
postPersistActions.add(Triple(record, qty, varianceQty))
} catch (e: Exception) { } catch (e: Exception) {
errorCount++ errorCount++
val errorMsg = "Error processing record ${record.id}: ${e.message}" val errorMsg = "Error processing record ${record.id}: ${e.message}"
@@ -1929,14 +1945,118 @@ open fun batchSaveApproverStockTakeRecordsByIds(
logger.error(errorMsg, e) logger.error(errorMsg, e)
} }
} }
logger.info(
"batchSaveApproverStockTakeRecordsByIds prepare completed: readyToPersist={}, precheckErrors={}, prepareMs={}",
recordsToPersist.size,
errorCount,
elapsedMs(prepareStartNs)
)

if (recordsToPersist.isNotEmpty()) {
val persistStartNs = System.nanoTime()
stockTakeRecordRepository.saveAll(recordsToPersist)
logger.info(
"batchSaveApproverStockTakeRecordsByIds persist completed: persisted={}, persistMs={}",
recordsToPersist.size,
elapsedMs(persistStartNs)
)

val adjustmentStartNs = System.nanoTime()
val runtimeCache = BatchAdjustmentRuntimeCache(
stockOutByStockTakeId = mutableMapOf(),
stockInByStockTakeId = mutableMapOf(),
runningLedgerBalanceByItemId = mutableMapOf()
)
val adjustmentContext = StockTakeAdjustmentBatchContext()
var varianceCount = 0
var noVarianceCount = 0
var varianceMs = 0L
var noVarianceMs = 0L
postPersistActions.forEach { (record, qty, varianceQty) ->
try {
if (varianceQty != BigDecimal.ZERO) {
val varianceStartNs = System.nanoTime()
applyVarianceAdjustment(
record.stockTake ?: stockTake,
record,
qty,
varianceQty,
request.approverId,
adjustmentCache,
runtimeCache,
adjustmentContext
)
varianceMs += elapsedMs(varianceStartNs)
varianceCount++
} else {
val noVarianceStartNs = System.nanoTime()
completeStockTakeLineForApproverNoVariance(
record.stockTake ?: stockTake,
record,
qty,
adjustmentCache,
adjustmentContext
)
noVarianceMs += elapsedMs(noVarianceStartNs)
noVarianceCount++
}
val stId = record.stockTake?.id
val section = record.stockTakeSection
if (stId != null && section != null) {
processedStockTakes.add(Pair(stId, section))
}
successCount++
} catch (e: Exception) {
logger.error("Failed to apply inventory/line update for record ${record.id}", e)
errorCount++
errors.add("Record ${record.id}: ${e.message}")
}
}
val flushStartNs = System.nanoTime()
flushStockTakeAdjustmentBatchContext(adjustmentContext)
val flushMs = elapsedMs(flushStartNs)
logger.info(
"batchSaveApproverStockTakeRecordsByIds adjustment completed: successSoFar={}, errorsSoFar={}, adjustmentMs={}, varianceCount={}, varianceMs={}, noVarianceCount={}, noVarianceMs={}, flushMs={}",
successCount,
errorCount,
elapsedMs(adjustmentStartNs),
varianceCount,
varianceMs,
noVarianceCount,
noVarianceMs,
flushMs
)
logger.info(
"batchSaveApproverStockTakeRecordsByIds runtime cache stats: stockOutHeads={}, stockInHeads={}, ledgerItems={}, batchedStockTakeLines={}, batchedOutLines={}, batchedInLines={}, batchedInventoryLotLines={}, batchedLedgers={}",
runtimeCache.stockOutByStockTakeId.size,
runtimeCache.stockInByStockTakeId.size,
runtimeCache.runningLedgerBalanceByItemId.size,
adjustmentContext.stockTakeLineByRecordId.size,
adjustmentContext.stockOutLines.size,
adjustmentContext.stockInLines.size,
adjustmentContext.inventoryLotLineById.size,
adjustmentContext.stockLedgers.size
)
}


if (successCount > 0) { if (successCount > 0) {
val statusStartNs = System.nanoTime()
processedStockTakes.forEach { (stId, section) -> processedStockTakes.forEach { (stId, section) ->
checkAndUpdateStockTakeStatus(stId, section) checkAndUpdateStockTakeStatus(stId, section)
} }
logger.info(
"batchSaveApproverStockTakeRecordsByIds status update completed: stockTakes={}, statusMs={}",
processedStockTakes.size,
elapsedMs(statusStartNs)
)
} }


println("batchSaveApproverStockTakeRecordsByIds completed: success=$successCount, errors=$errorCount")
logger.info(
"batchSaveApproverStockTakeRecordsByIds completed: success={}, errors={}, totalMs={}",
successCount,
errorCount,
elapsedMs(totalStartNs)
)
return BatchSaveApproverStockTakeRecordResponse( return BatchSaveApproverStockTakeRecordResponse(
successCount = successCount, successCount = successCount,
errorCount = errorCount, errorCount = errorCount,
@@ -1948,10 +2068,19 @@ open fun batchSaveApproverStockTakeRecordsByIds(
* stockTakeRecord 上存的是 inventory_lot.id(批次),不是 inventory_lot_line.id;用倉庫 + 批次找唯一庫存行。 * stockTakeRecord 上存的是 inventory_lot.id(批次),不是 inventory_lot_line.id;用倉庫 + 批次找唯一庫存行。
*/ */
private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord): InventoryLotLine { private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord): InventoryLotLine {
return resolveInventoryLotLineForStockTakeRecord(record, null)
}

private fun resolveInventoryLotLineForStockTakeRecord(
record: StockTakeRecord,
cache: BatchAdjustmentCache?
): InventoryLotLine {
val warehouseId = record.warehouse?.id val warehouseId = record.warehouse?.id
?: throw IllegalArgumentException("Warehouse not found on stock take record") ?: throw IllegalArgumentException("Warehouse not found on stock take record")
val lotId = record.inventoryLotId ?: record.lotId val lotId = record.inventoryLotId ?: record.lotId
?: throw IllegalArgumentException("Inventory lot ID not found on stock take record") ?: throw IllegalArgumentException("Inventory lot ID not found on stock take record")
val cacheKey = Pair(warehouseId, lotId)
cache?.inventoryLotLineByWarehouseLot?.get(cacheKey)?.let { return it }
val lines = inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse( val lines = inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse(
listOf(warehouseId), listOf(warehouseId),
listOf(lotId) listOf(lotId)
@@ -1971,10 +2100,14 @@ private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord):
private fun completeStockTakeLineForApproverNoVariance( private fun completeStockTakeLineForApproverNoVariance(
stockTake: StockTake, stockTake: StockTake,
stockTakeRecord: StockTakeRecord, stockTakeRecord: StockTakeRecord,
finalQty: BigDecimal
finalQty: BigDecimal,
cache: BatchAdjustmentCache? = null,
context: StockTakeAdjustmentBatchContext? = null
) { ) {
val rid = stockTakeRecord.id ?: return val rid = stockTakeRecord.id ?: return
val line = stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) ?: return
val line = cache?.stockTakeLineByRecordId?.get(rid)
?: stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid)
?: return
line.apply { line.apply {
this.stockTake = stockTake this.stockTake = stockTake
this.initialQty = this.initialQty ?: stockTakeRecord.bookQty this.initialQty = this.initialQty ?: stockTakeRecord.bookQty
@@ -1983,7 +2116,11 @@ private fun completeStockTakeLineForApproverNoVariance(
this.completeDate = LocalDateTime.now() this.completeDate = LocalDateTime.now()
this.stockTakeRecord = stockTakeRecord this.stockTakeRecord = stockTakeRecord
} }
stockTakeLineRepository.save(line)
if (context != null) {
context.stockTakeLineByRecordId[rid] = line
} else {
stockTakeLineRepository.save(line)
}
} }


/** /**
@@ -1997,23 +2134,29 @@ private fun applyVarianceAdjustment(
stockTakeRecord: StockTakeRecord, stockTakeRecord: StockTakeRecord,
finalQty: BigDecimal, finalQty: BigDecimal,
varianceQty: BigDecimal, varianceQty: BigDecimal,
approverId: Long?
approverId: Long?,
cache: BatchAdjustmentCache? = null,
runtimeCache: BatchAdjustmentRuntimeCache? = null,
context: StockTakeAdjustmentBatchContext? = null
) { ) {
if (varianceQty == BigDecimal.ZERO) return if (varianceQty == BigDecimal.ZERO) return


val inventoryLotLine = resolveInventoryLotLineForStockTakeRecord(stockTakeRecord)
val inventoryLotLine = resolveInventoryLotLineForStockTakeRecord(stockTakeRecord, cache)


val inventoryLot = inventoryLotRepository.findByIdAndDeletedFalse(
inventoryLotLine.inventoryLot?.id ?: throw IllegalArgumentException("Inventory lot ID not found")
) ?: throw IllegalArgumentException("Inventory lot not found")
val inventoryLotId = inventoryLotLine.inventoryLot?.id
?: throw IllegalArgumentException("Inventory lot ID not found")
val inventoryLot = cache?.inventoryLotById?.get(inventoryLotId)
?: inventoryLotRepository.findByIdAndDeletedFalse(inventoryLotId)
?: throw IllegalArgumentException("Inventory lot not found")


val inventory = inventoryRepository.findByItemId(
inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found")
).orElse(null) ?: throw IllegalArgumentException("Inventory not found for item")
val itemId = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found")
val inventory = cache?.inventoryByItemId?.get(itemId)
?: inventoryRepository.findByItemId(itemId).orElse(null)
?: throw IllegalArgumentException("Inventory not found for item")


// 1. 更新開輪預建的 StockTakeLine,或舊資料無預建時新建 // 1. 更新開輪預建的 StockTakeLine,或舊資料無預建時新建
val stockTakeLine = stockTakeRecord.id?.let { rid -> val stockTakeLine = stockTakeRecord.id?.let { rid ->
stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid)
cache?.stockTakeLineByRecordId?.get(rid) ?: stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid)
}?.also { existing -> }?.also { existing ->
existing.apply { existing.apply {
this.stockTake = stockTake this.stockTake = stockTake
@@ -2033,7 +2176,12 @@ private fun applyVarianceAdjustment(
this.completeDate = LocalDateTime.now() this.completeDate = LocalDateTime.now()
this.stockTakeRecord = stockTakeRecord this.stockTakeRecord = stockTakeRecord
} }
stockTakeLineRepository.save(stockTakeLine)
val stockTakeRecordId = stockTakeRecord.id
if (context != null && stockTakeRecordId != null) {
context.stockTakeLineByRecordId[stockTakeRecordId] = stockTakeLine
} else {
stockTakeLineRepository.save(stockTakeLine)
}


val zero = BigDecimal.ZERO val zero = BigDecimal.ZERO


@@ -2048,12 +2196,24 @@ private fun applyVarianceAdjustment(
return return
} }


var stockOut = stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTake.id!!)
?: StockOut().apply {
this.type = "stockTake"
this.status = "completed"
this.handler = approverId
}.also { stockOutRepository.save(it) }
val stockTakeId = stockTake.id ?: throw IllegalArgumentException("Stock take ID not found")
val stockOut = if (runtimeCache != null) {
runtimeCache.stockOutByStockTakeId.getOrPut(stockTakeId) {
stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTakeId)
?: StockOut().apply {
this.type = "stockTake"
this.status = "completed"
this.handler = approverId
}.also { stockOutRepository.save(it) }
}
} else {
stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTakeId)
?: StockOut().apply {
this.type = "stockTake"
this.status = "completed"
this.handler = approverId
}.also { stockOutRepository.save(it) }
}


val stockOutLine = StockOutLine().apply { val stockOutLine = StockOutLine().apply {
this.item = inventoryLot.item this.item = inventoryLot.item
@@ -2063,15 +2223,21 @@ private fun applyVarianceAdjustment(
this.status = "completed" this.status = "completed"
this.type = "TKE" this.type = "TKE"
} }
stockOutLineRepository.save(stockOutLine)
if (context != null) {
context.stockOutLines.add(stockOutLine)
} else {
stockOutLineRepository.save(stockOutLine)
}


// 與 StockOutLineService.createStockLedgerForStockOut 一致:依上一筆 ledger balance 扣減, // 與 StockOutLineService.createStockLedgerForStockOut 一致:依上一筆 ledger balance 扣減,
// 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。 // 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。
val itemIdForLedger = inventoryLot.item?.id val itemIdForLedger = inventoryLot.item?.id
?: throw IllegalArgumentException("Item ID not found for stock take ledger") ?: throw IllegalArgumentException("Item ID not found for stock take ledger")
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger)
val previousBalance = latestLedger?.balance
?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger)
?: run {
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger)
latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
}
val newBalance = previousBalance - qtyToRemove.toDouble() val newBalance = previousBalance - qtyToRemove.toDouble()


val stockLedger = StockLedger().apply { val stockLedger = StockLedger().apply {
@@ -2087,21 +2253,37 @@ private fun applyVarianceAdjustment(
this.date = LocalDate.now() this.date = LocalDate.now()
} }


stockLedgerRepository.save(stockLedger)
if (context != null) {
context.stockLedgers.add(stockLedger)
} else {
stockLedgerRepository.save(stockLedger)
}
runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance)


val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove) val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove)
val updateRequest = SaveInventoryLotLineRequest(
id = latestLine.id,
inventoryLotId = latestLine.inventoryLot?.id,
warehouseId = latestLine.warehouse?.id,
stockUomId = latestLine.stockUom?.id,
inQty = latestLine.inQty,
outQty = newOutQty,
holdQty = latestLine.holdQty,
status = latestLine.status?.value,
remarks = latestLine.remarks
latestLine.outQty = newOutQty
latestLine.status = inventoryLotLineService.deriveInventoryLotLineStatus(
latestLine.status,
latestLine.inQty,
latestLine.outQty,
latestLine.holdQty
) )
inventoryLotLineService.saveInventoryLotLine(updateRequest)
if (context != null && latestLine.id != null) {
context.inventoryLotLineById[latestLine.id!!] = latestLine
} else {
val updateRequest = SaveInventoryLotLineRequest(
id = latestLine.id,
inventoryLotId = latestLine.inventoryLot?.id,
warehouseId = latestLine.warehouse?.id,
stockUomId = latestLine.stockUom?.id,
inQty = latestLine.inQty,
outQty = latestLine.outQty,
holdQty = latestLine.holdQty,
status = latestLine.status?.value,
remarks = latestLine.remarks
)
inventoryLotLineService.saveInventoryLotLine(updateRequest)
}
} }


// 3. variance > 0:入庫加在既有庫存行 inQty + StockLedger // 3. variance > 0:入庫加在既有庫存行 inQty + StockLedger
@@ -2110,12 +2292,24 @@ private fun applyVarianceAdjustment(
val latestLine = inventoryLotLineRepository.findById(inventoryLotLine.id!!).orElseThrow() val latestLine = inventoryLotLineRepository.findById(inventoryLotLine.id!!).orElseThrow()
val newInQty = (latestLine.inQty ?: zero).add(plusQty) val newInQty = (latestLine.inQty ?: zero).add(plusQty)


var stockIn = stockInRepository.findByStockTakeIdAndDeletedFalse(stockTake.id!!)
?: StockIn().apply {
this.code = stockTake.code
this.status = "completed"
this.stockTake = stockTake
}.also { stockInRepository.save(it) }
val stockTakeId = stockTake.id ?: throw IllegalArgumentException("Stock take ID not found")
val stockIn = if (runtimeCache != null) {
runtimeCache.stockInByStockTakeId.getOrPut(stockTakeId) {
stockInRepository.findByStockTakeIdAndDeletedFalse(stockTakeId)
?: StockIn().apply {
this.code = stockTake.code
this.status = "completed"
this.stockTake = stockTake
}.also { stockInRepository.save(it) }
}
} else {
stockInRepository.findByStockTakeIdAndDeletedFalse(stockTakeId)
?: StockIn().apply {
this.code = stockTake.code
this.status = "completed"
this.stockTake = stockTake
}.also { stockInRepository.save(it) }
}


val stockInLine = StockInLine().apply { val stockInLine = StockInLine().apply {
this.stockTakeLine = stockTakeLine this.stockTakeLine = stockTakeLine
@@ -2132,26 +2326,43 @@ private fun applyVarianceAdjustment(
// 原入庫已綁定之 inventory_lot_line 與 SIL 多為 OneToOne;盤盈改加同一行 inQty,此處不綁 line 以免衝突 // 原入庫已綁定之 inventory_lot_line 與 SIL 多為 OneToOne;盤盈改加同一行 inQty,此處不綁 line 以免衝突
this.inventoryLotLine = null this.inventoryLotLine = null
} }
stockInLineRepository.save(stockInLine)
val updateRequest = SaveInventoryLotLineRequest(
id = latestLine.id,
inventoryLotId = latestLine.inventoryLot?.id,
warehouseId = latestLine.warehouse?.id,
stockUomId = latestLine.stockUom?.id,
inQty = newInQty,
outQty = latestLine.outQty,
holdQty = latestLine.holdQty,
status = latestLine.status?.value,
remarks = latestLine.remarks
if (context != null) {
context.stockInLines.add(stockInLine)
} else {
stockInLineRepository.save(stockInLine)
}
latestLine.inQty = newInQty
latestLine.status = inventoryLotLineService.deriveInventoryLotLineStatus(
latestLine.status,
latestLine.inQty,
latestLine.outQty,
latestLine.holdQty
) )
inventoryLotLineService.saveInventoryLotLine(updateRequest)
if (context != null && latestLine.id != null) {
context.inventoryLotLineById[latestLine.id!!] = latestLine
} else {
val updateRequest = SaveInventoryLotLineRequest(
id = latestLine.id,
inventoryLotId = latestLine.inventoryLot?.id,
warehouseId = latestLine.warehouse?.id,
stockUomId = latestLine.stockUom?.id,
inQty = latestLine.inQty,
outQty = latestLine.outQty,
holdQty = latestLine.holdQty,
status = latestLine.status?.value,
remarks = latestLine.remarks
)
inventoryLotLineService.saveInventoryLotLine(updateRequest)
}


val itemIdForLedger = inventoryLot.item?.id val itemIdForLedger = inventoryLot.item?.id
?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)") ?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)")
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger)
val previousBalance = latestLedger?.balance
?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger)
?: run {
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger)
latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
}
val newBalance = previousBalance + plusQty.toDouble() val newBalance = previousBalance + plusQty.toDouble()


val stockLedger = StockLedger().apply { val stockLedger = StockLedger().apply {
@@ -2167,8 +2378,112 @@ private fun applyVarianceAdjustment(
this.date = LocalDate.now() this.date = LocalDate.now()
} }


stockLedgerRepository.save(stockLedger)
if (context != null) {
context.stockLedgers.add(stockLedger)
} else {
stockLedgerRepository.save(stockLedger)
}
runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance)
}
}

private data class BatchAdjustmentCache(
val inventoryLotLineByWarehouseLot: Map<Pair<Long, Long>, InventoryLotLine>,
val inventoryLotById: Map<Long, InventoryLot>,
val inventoryByItemId: Map<Long, com.ffii.fpsms.modules.stock.entity.Inventory>,
val stockTakeLineByRecordId: Map<Long, StockTakeLine>
)

private data class BatchAdjustmentRuntimeCache(
val stockOutByStockTakeId: MutableMap<Long, StockOut>,
val stockInByStockTakeId: MutableMap<Long, StockIn>,
val runningLedgerBalanceByItemId: MutableMap<Long, Double>
)

private data class StockTakeAdjustmentBatchContext(
val stockTakeLineByRecordId: MutableMap<Long, StockTakeLine> = LinkedHashMap(),
val stockOutLines: MutableList<StockOutLine> = mutableListOf(),
val stockInLines: MutableList<StockInLine> = mutableListOf(),
val inventoryLotLineById: MutableMap<Long, InventoryLotLine> = LinkedHashMap(),
val stockLedgers: MutableList<StockLedger> = mutableListOf()
)

private fun flushStockTakeAdjustmentBatchContext(context: StockTakeAdjustmentBatchContext) {
if (context.stockTakeLineByRecordId.isNotEmpty()) {
stockTakeLineRepository.saveAll(context.stockTakeLineByRecordId.values.toList())
}
if (context.stockOutLines.isNotEmpty()) {
stockOutLineRepository.saveAll(context.stockOutLines)
}
if (context.stockInLines.isNotEmpty()) {
stockInLineRepository.saveAll(context.stockInLines)
}
if (context.inventoryLotLineById.isNotEmpty()) {
inventoryLotLineRepository.saveAll(context.inventoryLotLineById.values.toList())
}
if (context.stockLedgers.isNotEmpty()) {
stockLedgerRepository.saveAll(context.stockLedgers)
}
}

private fun buildBatchAdjustmentCache(records: List<StockTakeRecord>): BatchAdjustmentCache {
val pairs = records.mapNotNull { r ->
val warehouseId = r.warehouse?.id
val lotId = r.inventoryLotId ?: r.lotId
if (warehouseId != null && lotId != null) Pair(warehouseId, lotId) else null
}.toSet()
val warehouseIds = pairs.map { it.first }.toSet()
val lotIds = pairs.map { it.second }.toSet()
val lotLines =
if (warehouseIds.isNotEmpty() && lotIds.isNotEmpty()) {
inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse(warehouseIds, lotIds)
} else {
emptyList()
}
val inventoryLotLineByWarehouseLot = lotLines
.groupBy { Pair(it.warehouse?.id ?: 0L, it.inventoryLot?.id ?: 0L) }
.mapValues { (_, lines) -> lines.maxByOrNull { it.id ?: 0L }!! }

val inventoryLotIds = lotLines.mapNotNull { it.inventoryLot?.id }.distinct()
val inventoryLotById =
if (inventoryLotIds.isNotEmpty()) {
inventoryLotRepository.findAllByIdIn(inventoryLotIds).associateByNotNull { it.id }
} else {
emptyMap()
}
val itemIds = inventoryLotById.values.mapNotNull { it.item?.id }.distinct()
val inventoryByItemId =
if (itemIds.isNotEmpty()) {
inventoryRepository.findAllByItemIdInAndDeletedIsFalse(itemIds)
.groupBy { it.item?.id ?: 0L }
.mapValues { (_, list) -> list.minByOrNull { it.id ?: Long.MAX_VALUE }!! }
} else {
emptyMap()
}
val recordIds = records.mapNotNull { it.id }.distinct()
val stockTakeLineByRecordId =
if (recordIds.isNotEmpty()) {
stockTakeLineRepository.findAllByStockTakeRecord_IdInAndDeletedIsFalse(recordIds)
.associateByNotNull { it.stockTakeRecord?.id }
} else {
emptyMap()
}

return BatchAdjustmentCache(
inventoryLotLineByWarehouseLot = inventoryLotLineByWarehouseLot,
inventoryLotById = inventoryLotById,
inventoryByItemId = inventoryByItemId,
stockTakeLineByRecordId = stockTakeLineByRecordId
)
}

private inline fun <T : Any, K : Any> Iterable<T>.associateByNotNull(keySelector: (T) -> K?): Map<K, T> {
val destination = LinkedHashMap<K, T>()
for (element in this) {
val key = keySelector(element) ?: continue
destination[key] = element
} }
return destination
} }
open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTakeRecord { open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTakeRecord {
println("updateStockTakeRecordStatusToNotMatch called with stockTakeRecordId: $stockTakeRecordId") println("updateStockTakeRecordStatusToNotMatch called with stockTakeRecordId: $stockTakeRecordId")


+ 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 = "0") pageNum: Int,
@RequestParam(required = false, defaultValue = "6") pageSize: Int, @RequestParam(required = false, defaultValue = "6") pageSize: Int,
@RequestParam(required = false) sectionDescription: String?, @RequestParam(required = false) sectionDescription: String?,
@RequestParam(required = false) stockTakeSections: String?
@RequestParam(required = false) stockTakeSections: String?,
@RequestParam(required = false) status: String?,
@RequestParam(required = false) area: String?,
@RequestParam(required = false) storeId: String?
): RecordsRes<AllPickedStockTakeListReponse> { ): RecordsRes<AllPickedStockTakeListReponse> {
var all = stockOutRecordService.AllPickedStockTakeList() var all = stockOutRecordService.AllPickedStockTakeList()
if (sectionDescription != null && sectionDescription != "All") { if (sectionDescription != null && sectionDescription != "All") {
@@ -46,6 +49,28 @@ class StockTakeRecordController(
} }
} }
} }
if (!status.isNullOrBlank() && status != "All") {
val normalizedStatus = status.trim().lowercase()
val acceptedStatuses = when (normalizedStatus) {
"stocktaking" -> setOf("stocktaking", "processing", "in_progress")
else -> setOf(normalizedStatus)
}
all = all.filter { item ->
val itemStatus = item.status.trim().lowercase()
itemStatus in acceptedStatuses
}
}
if (!area.isNullOrBlank()) {
val areaKeyword = area.trim()
all = all.filter { it.warehouseArea?.contains(areaKeyword, ignoreCase = true) == true }
}
if (!storeId.isNullOrBlank() && storeId != "All") {
val storeIdKeyword = storeId.trim()
all = all.filter {
it.storeId?.equals(storeIdKeyword, ignoreCase = true) == true ||
it.storeId?.contains(storeIdKeyword, ignoreCase = true) == true
}
}
val total = all.size val total = all.size
val fromIndex = pageNum * pageSize val fromIndex = pageNum * pageSize
val toIndex = minOf(fromIndex + pageSize, total) val toIndex = minOf(fromIndex + pageSize, total)


+ 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") @JsonFormat(pattern = "yyyy-MM-dd")
val planStartDate: LocalDate?, val planStartDate: LocalDate?,
val stockTakeSectionDescription: String?, val stockTakeSectionDescription: String?,
val warehouseArea: String?,
val storeId: String?,
) )
data class InventoryLotDetailResponse( data class InventoryLotDetailResponse(
val id: Long, val id: Long,


+ 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.PdfUtils
import com.ffii.core.utils.QrCodeUtil import com.ffii.core.utils.QrCodeUtil
import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry
import com.ffii.fpsms.modules.master.service.PrinterService
import com.ffii.fpsms.modules.user.entity.UserRepository import com.ffii.fpsms.modules.user.entity.UserRepository
import com.ffii.fpsms.modules.user.web.ExportUserQrCodeRequest import com.ffii.fpsms.modules.user.web.ExportUserQrCodeRequest
import com.ffii.fpsms.modules.user.web.PrintUserQrCodeRequest
import net.sf.jasperreports.engine.JasperCompileManager import net.sf.jasperreports.engine.JasperCompileManager
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperReport
import net.sf.jasperreports.engine.JasperPrint import net.sf.jasperreports.engine.JasperPrint
import net.sf.jasperreports.engine.export.JRPdfExporter
import net.sf.jasperreports.export.SimpleExporterInput
import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput
import org.springframework.core.io.ClassPathResource import org.springframework.core.io.ClassPathResource
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.awt.GraphicsEnvironment import java.awt.GraphicsEnvironment
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -18,19 +21,34 @@ import kotlinx.serialization.encodeToString


@Service @Service
class UserQrCodeService( class UserQrCodeService(
private val userRepository: UserRepository
private val userRepository: UserRepository,
private val printerService: PrinterService,
) { ) {
private val qrCodeHandleJrxmlPath = "qrCodeHandle/qrCodeHandle.jrxml"


fun exportUserQrCode(request: ExportUserQrCodeRequest): Map<String, Any> {
val QRCODE_HANDLE_PDF = "qrCodeHandle/qrCodeHandle.jrxml"
val resource = ClassPathResource(QRCODE_HANDLE_PDF)
/**
* Compile the Jasper template once; compiling per request is expensive.
*/
private val qrCodeHandleReport: JasperReport by lazy {
val resource = ClassPathResource(qrCodeHandleJrxmlPath)
if (!resource.exists()) { if (!resource.exists()) {
throw FileNotFoundException("Report file not found: $QRCODE_HANDLE_PDF")
throw FileNotFoundException("Report file not found: $qrCodeHandleJrxmlPath")
} }
val inputStream = resource.inputStream
val qrCodeHandleReport = JasperCompileManager.compileReport(inputStream)
resource.inputStream.use { JasperCompileManager.compileReport(it) }
}

/** Cache the chosen Chinese font family name (font scanning is expensive). */
private val chineseFontFamily: String by lazy {
val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames
availableFonts.find { family ->
family.contains("SimSun", ignoreCase = true) ||
family.contains("Microsoft YaHei", ignoreCase = true) ||
family.contains("STSong", ignoreCase = true) ||
family.contains("SimHei", ignoreCase = true)
} ?: "Arial Unicode MS"
}

fun exportUserQrCode(request: ExportUserQrCodeRequest): Map<String, Any> {
val users = userRepository.findAllById(request.userIds) val users = userRepository.findAllById(request.userIds)
val fields = mutableListOf<MutableMap<String, Any>>() val fields = mutableListOf<MutableMap<String, Any>>()
@@ -53,24 +71,33 @@ class UserQrCodeService(
} }
val params: MutableMap<String, Any> = mutableMapOf() val params: MutableMap<String, Any> = mutableMapOf()
// Configure for Chinese character support
// Try to find a Chinese-supporting font
val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames
val chineseFont = availableFonts.find {
it.contains("SimSun", ignoreCase = true) ||
it.contains("Microsoft YaHei", ignoreCase = true) ||
it.contains("STSong", ignoreCase = true) ||
it.contains("SimHei", ignoreCase = true)
} ?: "Arial Unicode MS" // Fallback

params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H"
params["net.sf.jasperreports.default.pdf.embedded"] = true params["net.sf.jasperreports.default.pdf.embedded"] = true
params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont
params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily
return mapOf( return mapOf(
"report" to PdfUtils.fillReport(qrCodeHandleReport, fields, params), "report" to PdfUtils.fillReport(qrCodeHandleReport, fields, params),
"fileName" to (users.firstOrNull()?.username ?: "user_qrcode") "fileName" to (users.firstOrNull()?.username ?: "user_qrcode")
) )
} }

fun printUserQrCode(request: PrintUserQrCodeRequest) {
val printer = printerService.findById(request.printerId) ?: throw NoSuchElementException("No such printer")
val pdf = exportUserQrCode(ExportUserQrCodeRequest(request.userIds))
val jasperPrint = pdf["report"] as JasperPrint
val tempPdfFile = File.createTempFile("print_job_", ".pdf")

try {
JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath)
val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty
printer.ip?.let { ip ->
val port = printer.port ?: 9100
val driver = A4PrintDriverRegistry.getDriver(printer.brand)
driver.print(tempPdfFile, ip, port, printQty)
}
} finally {
tempPdfFile.delete()
}
}
} }

+ 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.UserService;
import com.ffii.fpsms.modules.user.service.res.LoadUserRes; import com.ffii.fpsms.modules.user.service.res.LoadUserRes;


import com.ffii.fpsms.modules.user.web.ExportUserQrCodeRequest;
import com.ffii.fpsms.modules.user.service.UserQrCodeService; import com.ffii.fpsms.modules.user.service.UserQrCodeService;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import net.sf.jasperreports.engine.JasperExportManager; import net.sf.jasperreports.engine.JasperExportManager;
import net.sf.jasperreports.engine.JasperPrint; import net.sf.jasperreports.engine.JasperPrint;
import java.io.OutputStream; import java.io.OutputStream;
import java.io.UnsupportedEncodingException;


import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
@@ -255,6 +253,11 @@ public class UserController{
out.flush(); out.flush();
} }


@PostMapping("/print-qrcode")
public void printQrCode(@Valid @RequestBody PrintUserQrCodeRequest request) {
userQrCodeService.printUserQrCode(request);
}

public static class AdminChangePwdReq { public static class AdminChangePwdReq {
private Long id; private Long id;
@NotBlank @NotBlank


+ 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…
Откажи
Сачувај