コミットを比較

...

20 コミット

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

+ 208
- 96
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt ファイルの表示

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

supplierMatches && shopMatches
}

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

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

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

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

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

}

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

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

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

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

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

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

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

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

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

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

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

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


+ 80
- 129
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt ファイルの表示

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

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

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

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

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


+ 22
- 4
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchDopoAssignmentService.kt ファイルの表示

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

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

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

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

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

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


+ 54
- 44
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt ファイルの表示

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

val prepMs = lapMs()

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

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

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

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

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

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

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

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

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

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

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

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

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

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

val sql = """
SELECT


+ 91
- 20
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt ファイルの表示

@@ -25,6 +25,12 @@ import org.springframework.orm.ObjectOptimisticLockingFailureException

private const val WORKBENCH_RELEASE_RETRY_MAX = 3

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


+ 55
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/TruckLaneSearchSpec.kt ファイルの表示

@@ -0,0 +1,55 @@
package com.ffii.fpsms.modules.deliveryOrder.service

import java.util.Locale

/**
* 車線搜尋正規化(search-do-lite-v2)。
* 實際 [com.ffii.fpsms.modules.pickOrder.entity.Truck] 編碼主要為 `車線-…` 或 `P06B_…`;未指派預設列為 `車線-X`(shopId 可為 null)。
*/
object TruckLaneSearchSpec {
const val UNASSIGNED_LANE_LABEL: String = "車線-X"

sealed interface Mode {
data object NoFilter : Mode
/** 僅未指派:推算為 null/空白/字面量 車線-X(與預設車列一致) */
data object UnassignedOnly : Mode
/**
* 一般關鍵字:以 [needleLower](trim + [Locale.ROOT] lowercase)對推算車線做 [String.contains]。
* 不再因「`車線-` 開頭」額外併入未指派,避免搜 `車線-待1` 卻出現 `車線-X`;廣義條件(無車線欄位)仍由 [NoFilter] 帶出含 X 的列。
*/
data class Keyword(
val needleLower: String,
) : Mode
}

fun parse(raw: String?): Mode {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return Mode.NoFilter
if (isUnassignedSearchToken(trimmed)) return Mode.UnassignedOnly
return Mode.Keyword(
needleLower = trimmed.lowercase(Locale.ROOT),
)
}

private fun isUnassignedSearchToken(trimmed: String): Boolean {
if (trimmed.length == 1 && trimmed.equals("x", ignoreCase = true)) return true
val normalized = trimmed.lowercase(Locale.ROOT).replace("车线", "車線")
return normalized == "車線-x"
}

fun isUnassignedResolvedLane(calculated: String?): Boolean {
if (calculated.isNullOrBlank()) return true
return calculated.trim().equals(UNASSIGNED_LANE_LABEL, ignoreCase = true)
}

fun matches(mode: Mode, resolvedTruckLanceCode: String?): Boolean {
when (mode) {
Mode.NoFilter -> return true
Mode.UnassignedOnly -> return isUnassignedResolvedLane(resolvedTruckLanceCode)
is Mode.Keyword -> {
val lane = resolvedTruckLanceCode?.trim()?.lowercase(Locale.ROOT).orEmpty()
return lane.contains(mode.needleLower)
}
}
}
}

+ 21
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt ファイルの表示

@@ -70,7 +70,8 @@ class DeliveryOrderController(
estimatedArrivalDate = request.estimatedArrivalDate,
pageNum = request.pageNum,
pageSize = request.pageSize,
truckLanceCode = request.truckLanceCode
truckLanceCode = request.truckLanceCode,
floor = request.floor,
)
}

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

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



+ 21
- 2
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt ファイルの表示

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

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

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

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


+ 3
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt ファイルの表示

@@ -30,5 +30,7 @@ data class SearchDeliveryOrderInfoRequest(
val estimatedArrivalDate: LocalDateTime?,
val pageSize: Int?,
val pageNum: Int?,
val truckLanceCode: String?
val truckLanceCode: String?,
/** `ALL`/`All`/null:P06B+P07+P06D;`2F`:P07+P06D;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */
val floor: String? = null,
)

+ 18
- 15
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt ファイルの表示

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

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

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

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

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

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

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


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt ファイルの表示

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


+ 37
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt ファイルの表示

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

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


+ 4
- 3
src/main/java/com/ffii/fpsms/modules/master/entity/ShopAndTruck.kt ファイルの表示

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

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

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

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

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


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/master/entity/projections/ShopAndTruck.kt ファイルの表示

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


+ 52
- 19
src/main/java/com/ffii/fpsms/modules/master/service/EquipmentQrCodeService.kt ファイルの表示

@@ -4,30 +4,52 @@ import com.ffii.core.utils.PdfUtils
import com.ffii.core.utils.QrCodeUtil
import com.ffii.fpsms.modules.master.entity.EquipmentDetailRepository
import com.ffii.fpsms.modules.master.web.ExportEquipmentQrCodeRequest
import com.ffii.fpsms.modules.master.web.PrintEquipmentQrCodeRequest
import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry
import net.sf.jasperreports.engine.JasperCompileManager
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperReport
import net.sf.jasperreports.engine.JasperPrint
import org.springframework.core.io.ClassPathResource
import org.springframework.stereotype.Service
import java.io.FileNotFoundException
import java.io.File
import java.awt.GraphicsEnvironment
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString

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

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

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

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

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

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

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

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt ファイルの表示

@@ -340,6 +340,7 @@ open class ItemsService(
//println("Query result size: ${result.size}")
// result.forEach { row -> println("Result row: $row") }
return result
} catch (e: Exception) {
println("Error in getPickOrderItemsByPage: ${e.message}")
e.printStackTrace()


+ 48
- 19
src/main/java/com/ffii/fpsms/modules/master/service/WarehouseQrCodeService.kt ファイルの表示

@@ -2,12 +2,17 @@ package com.ffii.fpsms.modules.master.service

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

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

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

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

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

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

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

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

+ 5
- 0
src/main/java/com/ffii/fpsms/modules/master/web/EquipmentController.kt ファイルの表示

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

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

}

+ 7
- 0
src/main/java/com/ffii/fpsms/modules/master/web/PrintEquipmentQrCodeRequest.kt ファイルの表示

@@ -0,0 +1,7 @@
package com.ffii.fpsms.modules.master.web

data class PrintEquipmentQrCodeRequest(
val equipmentDetailIds: List<Long>,
val printerId: Long,
val printQty: Int? = 1,
)

+ 7
- 0
src/main/java/com/ffii/fpsms/modules/master/web/PrintWarehouseQrCodeRequest.kt ファイルの表示

@@ -0,0 +1,7 @@
package com.ffii.fpsms.modules.master.web

data class PrintWarehouseQrCodeRequest(
val warehouseIds: List<Long>,
val printerId: Long,
val printQty: Int? = 1,
)

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/master/web/WarehouseController.kt ファイルの表示

@@ -98,6 +98,10 @@ class WarehouseController(
out.write(JasperExportManager.exportReportToPdf(jasperPrint))
out.flush()
}
@PostMapping("/print-qrcode")
fun printQrCode(@Valid @RequestBody request: PrintWarehouseQrCodeRequest) {
warehouseQrCodeService.printWarehouseQrCode(request)
}
@GetMapping("/stockTakeSections")
fun getStockTakeSections(): List<StockTakeSectionInfo> {
return warehouseService.getStockTakeSections()


+ 3
- 2
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt ファイルの表示

@@ -35,8 +35,9 @@ open class Truck : BaseEntity<Long>() {
@Column(name = "Store_id")
open var storeId: String? = null

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

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


+ 48
- 8
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt ファイルの表示

@@ -1624,16 +1624,17 @@ open class PickOrderService(
}
throw IllegalStateException("Failed to generate unique delivery note code after $maxAttempts attempts")
}
/*
@Transactional(rollbackFor = [java.lang.Exception::class])
open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageResponse {
try {
println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===")
println("consoCode: $consoCode")
// println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===")
// println("consoCode: $consoCode")

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

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

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

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


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt ファイルの表示

@@ -212,7 +212,7 @@ open class TruckService(
// Use remark from request (user input) - no auto-fill
updateTruckLance.truckLanceCode = request.truckLanceCode
updateTruckLance.loadingSequence = request.loadingSequence.toInt()
updateTruckLance.districtReference = request.districtReference.toInt()
updateTruckLance.districtReference = request.districtReference
updateTruckLance.departureTime = request.departureTime
updateTruckLance.storeId = request.storeId
// Only set remark if storeId is "4F", otherwise set to null


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickOrderController.kt ファイルの表示

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


+ 3
- 3
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt ファイルの表示

@@ -10,14 +10,14 @@ data class SaveTruckRequest(
val shopCode: String,
val loadingSequence: Int,
val remark: String? = null,
val districtReference: Int? = null,
val districtReference: String? = null,
)
data class SaveTruckLane(
val id: Long,
val truckLanceCode: String,
val departureTime: LocalTime,
val loadingSequence: Long,
val districtReference: Long,
val districtReference: String?,
val storeId: String,
val remark: String? = null
)
@@ -37,6 +37,6 @@ data class CreateTruckWithoutShopRequest(
val truckLanceCode: String,
val departureTime: LocalTime,
val loadingSequence: Int = 0,
val districtReference: Int? = null,
val districtReference: String? = null,
val remark: String? = null,
)

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt ファイルの表示

@@ -23,6 +23,7 @@ data class GetCompletedDoPickOrdersRequest(
val deliveryNoteCode: String? = null,
/** 卡車 / 車道代碼(模糊匹配 truck_lance_code) */
val truckLanceCode: String? = null,
val ticketNo: String? = null,
)

data class CompletedDoPickOrderResponse(


+ 34
- 8
src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt ファイルの表示

@@ -107,7 +107,10 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
)
}
}
open fun getPoList(args: MutableMap<String, Any>): List<PurchaseOrderDataClass> {
/**
* `select * from ( ... one row per PO ... ) r [where r.itemDetail ...]` — same as the legacy list query, without LIMIT.
*/
private fun buildPoListUnpagedSelectSql(args: MutableMap<String, Any>): String {
val sql = StringBuilder(
"select * from ( " +
"select " +
@@ -211,9 +214,11 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
if (args.containsKey("itemDetail")){
sql.append(" where r.itemDetail like :itemDetail ");
}
val list = jdbcDao.queryForList(sql.toString(), args);
return sql.toString()
}

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

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

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

open fun allPurchaseOrder(): List<PurchaseOrder> {


+ 8
- 7
src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt ファイルの表示

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

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

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


+ 38
- 2
src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt ファイルの表示

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

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

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

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

WHERE 1=1
$itemCodeSql
$storeIdSql
)

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

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

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


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt ファイルの表示

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

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

val excelBytes = createStockTakeVarianceExcel(


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt ファイルの表示

@@ -40,6 +40,7 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> {
fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo>

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

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


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt ファイルの表示

@@ -9,6 +9,7 @@ interface StockTakeLineRepository : AbstractRepository<StockTakeLine, Long> {
fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?;
fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?;
fun findByStockTakeRecord_IdAndDeletedIsFalse(stockTakeRecordId: Long): StockTakeLine?
fun findAllByStockTakeRecord_IdInAndDeletedIsFalse(stockTakeRecordIds: Collection<Long>): List<StockTakeLine>
fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse(
stockTakeIds: Collection<Long>,
inventoryLotLineIds: Collection<Long>


+ 8
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt ファイルの表示

@@ -440,8 +440,14 @@ open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantit
.filter { !it.deleted && it.inventoryLot?.item != null }
.toList()
val item = source.firstOrNull()?.inventoryLot?.item
?: throw IllegalStateException("Item not found for itemId=$itemId")

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


+ 5
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt ファイルの表示

@@ -66,11 +66,14 @@ open class StockAdjustmentService(
if (diff.compareTo(BigDecimal.ZERO) == 0) continue // Branch 1: no change

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


+ 400
- 85
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt ファイルの表示

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

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

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

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

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

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

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

stockTakeRecordRepository.save(record)

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

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

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

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

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

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

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

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

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

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

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

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

val zero = BigDecimal.ZERO

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


+ 26
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt ファイルの表示

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


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt ファイルの表示

@@ -27,6 +27,8 @@ data class AllPickedStockTakeListReponse(
@JsonFormat(pattern = "yyyy-MM-dd")
val planStartDate: LocalDate?,
val stockTakeSectionDescription: String?,
val warehouseArea: String?,
val storeId: String?,
)
data class InventoryLotDetailResponse(
val id: Long,


+ 51
- 24
src/main/java/com/ffii/fpsms/modules/user/service/UserQrCodeService.kt ファイルの表示

@@ -2,15 +2,18 @@ package com.ffii.fpsms.modules.user.service

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

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

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

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

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

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

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

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

+ 7
- 0
src/main/java/com/ffii/fpsms/modules/user/web/PrintUserQrCodeRequest.kt ファイルの表示

@@ -0,0 +1,7 @@
package com.ffii.fpsms.modules.user.web

data class PrintUserQrCodeRequest(
val userIds: List<Long>,
val printerId: Long,
val printQty: Int? = 1,
)

+ 5
- 2
src/main/java/com/ffii/fpsms/modules/user/web/UserController.java ファイルの表示

@@ -42,13 +42,11 @@ import com.ffii.fpsms.modules.user.req.UpdateUserReq;
import com.ffii.fpsms.modules.user.service.UserService;
import com.ffii.fpsms.modules.user.service.res.LoadUserRes;

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

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

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

public static class AdminChangePwdReq {
private Long id;
@NotBlank


+ 8
- 0
src/main/resources/db/changelog/changes/20260429_01_Enson/01_alter_stock_take.sql ファイルの表示

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

--changeset Enson:20260429-01
CREATE INDEX idx_stock_out_stockTakeId_deleted
ON stock_out (stockTakeId, deleted);

CREATE INDEX idx_stock_take_line_record_deleted
ON stock_take_line (stockTakeRecordId, deleted);

+ 23
- 0
src/main/resources/db/changelog/changes/20260430_01_2fi/01_create_logistic_and_alter_truck.sql ファイルの表示

@@ -0,0 +1,23 @@
-- liquibase formatted sql
-- changeset 2fi:20260430_01_create_logistic

CREATE TABLE `logistic`
(
`id` INT NOT NULL AUTO_INCREMENT,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`createdBy` VARCHAR(30) NULL DEFAULT NULL,
`version` INT NOT NULL DEFAULT '0',
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modifiedBy` VARCHAR(30) NULL DEFAULT NULL,
`deleted` TINYINT(1) NOT NULL DEFAULT '0',
`logisticName` VARCHAR(255) NOT NULL,
`carPlate` VARCHAR(50) NOT NULL,
`driverName` VARCHAR(255) NOT NULL,
`driverNumber` INT NOT NULL,
CONSTRAINT pk_logistic PRIMARY KEY (`id`)
);

-- changeset 2fi:20260430_02_truck_district_reference_to_string

ALTER TABLE `truck`
MODIFY COLUMN `districtReference` VARCHAR(255) NULL;

+ 5
- 0
src/main/resources/db/changelog/changes/20260504_01_Enson/01_alter_stock_take.sql ファイルの表示

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

--changeset Enson:20260504-01
CREATE INDEX idx_dopo_handled_deleted_status
ON fpsmsdb.delivery_order_pick_order (handledBy, deleted, ticketStatus);

+ 8
- 0
src/main/resources/db/changelog/changes/20260504_01_Enson/02_alter_stock_take.sql ファイルの表示

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

--changeset Enson:20260504-02
CREATE INDEX idx_dopo_workbench_current_user
ON fpsmsdb.delivery_order_pick_order (
handledBy, deleted, ticketStatus,
requiredDeliveryDate, truckDepartureTime, id
);

+ 7
- 0
src/main/resources/db/changelog/changes/20260504_01_Enson/03_alter_stock_take.sql ファイルの表示

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



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

+ 19
- 0
src/main/resources/db/changelog/changes/20260507_01_search/01_do_truck_search_indexes.sql ファイルの表示

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

-- changeset fpsms:20260507_idx_do_deleted_status_eta_id
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'delivery_order' AND index_name = 'idx_do_deleted_status_eta_id'

CREATE INDEX idx_do_deleted_status_eta_id
ON delivery_order (deleted, status, estimatedArrivalDate, id);

-- changeset fpsms:20260507_idx_do_deleted_shop_eta_id
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'delivery_order' AND index_name = 'idx_do_deleted_shop_eta_id'

CREATE INDEX idx_do_deleted_shop_eta_id
ON delivery_order (deleted, shopId, estimatedArrivalDate, id);

-- changeset fpsms:20260507_idx_truck_TruckLanceCode
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'truck' AND index_name = 'idx_truck_TruckLanceCode'

CREATE INDEX idx_truck_TruckLanceCode
ON truck (TruckLanceCode);

読み込み中…
キャンセル
保存