Selaa lähdekoodia

Merge branch 'production' into fix負數倉

fix負數倉
CANCERYS\kw093 13 tuntia sitten
vanhempi
commit
a5846dcb76
24 muutettua tiedostoa jossa 1195 lisäystä ja 133 poistoa
  1. +1
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt
  2. +289
    -79
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
  3. +3
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt
  4. +10
    -4
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  5. +2
    -3
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt
  6. +5
    -24
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt
  7. +1
    -1
      src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt
  8. +44
    -6
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  9. +36
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt
  10. +9
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLogRepository.kt
  11. +2
    -2
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt
  12. +61
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameRules.kt
  13. +299
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt
  14. +26
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt
  15. +15
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt
  16. +44
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt
  17. +30
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  18. +114
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt
  19. +32
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  20. +50
    -12
      src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt
  21. +8
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt
  22. +23
    -0
      src/main/resources/db/changelog/changes/20261026_truck_lane_rename/01_create_truck_lane_rename_log.sql
  23. +9
    -0
      src/main/resources/db/changelog/changes/20261028_truck_lane_rename_log_truck_ids/01_add_truck_ids.sql
  24. +82
    -0
      src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt Näytä tiedosto

@@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value
import java.math.BigDecimal

interface DeliveryOrderLineInfo {
val id: Long
@get:Value("#{target.item?.name}")
val itemName: String?
val itemNo: String


+ 289
- 79
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt Näytä tiedosto

@@ -160,6 +160,7 @@ open class DeliveryOrderService(
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 */
open fun searchDoLiteByPage(
code: String?,
shopName: String?,
@@ -204,10 +205,14 @@ open class DeliveryOrderService(

val preFilteredContent = allResult.content
val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val ticketLaneByDoId = loadTicketTruckLanesByDoId(
deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id },
)

// ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录)
// 未放單才依供應商樓層查 truck;已放單用票據車線
val shopIdAndDatePairs = preFilteredContent.mapNotNull { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
if (isReleasedDeliveryOrder(deliveryOrder)) return@mapNotNull null
val shopId = deliveryOrder?.shop?.id
val estimatedArrivalDate = info.estimatedArrivalDate
if (shopId != null && estimatedArrivalDate != null) {
@@ -235,23 +240,16 @@ open class DeliveryOrderService(
// 处理预过滤后的记录:计算 truckLanceCode 并过滤
val processedRecords = preFilteredContent.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
val shop = deliveryOrder?.shop
val shopId = shop?.id
val estimatedArrivalDate = info.estimatedArrivalDate

val calculatedTruckLanceCode =
if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) {
val targetDate = estimatedArrivalDate.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate)

// 从缓存中获取 Truck
val matchedTruck = truckCache[Triple(shopId, preferredFloor, dayAbbr)]
matchedTruck?.truckLanceCode
} else {
null
}
val calculatedTruckLanceCode = displayedTruckLanceCode(
deliveryOrder = deliveryOrder,
estimatedArrivalDate = estimatedArrivalDate,
floorSuppliers2F = floorSuppliers2F,
floorSuppliers4F = floorSuppliers4F,
truckCache = truckCache,
ticketLaneByDoId = ticketLaneByDoId,
)

DeliveryOrderInfoLiteDto(
id = info.id,
@@ -300,22 +298,22 @@ open class DeliveryOrderService(
)

val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val loadedOrders = result.content.associate { info ->
info.id to deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id)
}
val ticketLaneByDoId = loadTicketTruckLanesByDoId(
loadedOrders.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it?.id },
)
val records = result.content.map { info ->
val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id)
val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
val shop = deliveryOrder?.shop
val shopId = shop?.id
val estimatedArrivalDate = info.estimatedArrivalDate

val calculatedTruckLanceCode =
if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) {
val targetDate = estimatedArrivalDate.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate)
resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode
} else {
null
}
val deliveryOrder = loadedOrders[info.id]
val calculatedTruckLanceCode = displayedTruckLanceCode(
deliveryOrder = deliveryOrder,
estimatedArrivalDate = info.estimatedArrivalDate,
floorSuppliers2F = floorSuppliers2F,
floorSuppliers4F = floorSuppliers4F,
truckCache = null,
ticketLaneByDoId = ticketLaneByDoId,
)

DeliveryOrderInfoLiteDto(
id = info.id,
@@ -392,8 +390,9 @@ open class DeliveryOrderService(
}

/**
* 僅回傳依店鋪/ETA 從 truck 排程**推算後** `truckLanceCode` 為 null 或空白的送貨單(畫面上對應「車線-X」)。
* 與 [searchDoLiteByPage] 帶一般車線關鍵字分開,避免 `車線-X` 在 truck 表無 shopId 時走舊邏輯漏單。
* FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28
* 僅回傳顯示車線為 null 或空白的送貨單(畫面上對應「車線-X」)。
* 已放單(receiving/completed)用票據車線;未放單才依店鋪/ETA 從 truck 排程推算。
*/
open fun searchDoLiteUnassignedTruckByPage(
code: String?,
@@ -426,23 +425,20 @@ open class DeliveryOrderService(
val deliveryOrderIds = allResult.content.mapNotNull { it.id }
val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id }
val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val ticketLaneByDoId = loadTicketTruckLanesByDoId(
deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id },
)

val processedRecords = allResult.content.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
val shop = deliveryOrder?.shop
val shopId = shop?.id
val infoEta = info.estimatedArrivalDate

val calculatedTruckLanceCode =
if (deliveryOrder != null && shopId != null && infoEta != null) {
val targetDate = infoEta.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate)
resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode
} else {
null
}
val calculatedTruckLanceCode = displayedTruckLanceCode(
deliveryOrder = deliveryOrder,
estimatedArrivalDate = info.estimatedArrivalDate,
floorSuppliers2F = floorSuppliers2F,
floorSuppliers4F = floorSuppliers4F,
truckCache = null,
ticketLaneByDoId = ticketLaneByDoId,
)

DeliveryOrderInfoLiteDto(
id = info.id,
@@ -1399,6 +1395,7 @@ open class DeliveryOrderService(
)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */
private fun exportDeliveryNoteFromRecord(
request: ExportDeliveryNoteRequest,
deliveryNote: net.sf.jasperreports.engine.JasperReport,
@@ -1492,15 +1489,19 @@ open class DeliveryOrderService(
val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") }
val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra }
val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(deliveryOrderIds)
val pickOrderLineIdByDeliveryLine = matchDeliveryNotePickOrderLines(
exportLines = exportLines,
pickOrderIdByDoId = pickOrderIdByDoId,
pickOrderLines = pickOrderLines,
replenishmentPickOrderLineIds = replenishPdfIndex.replenishmentPickOrderLineIds(),
)

sortedLines.forEach { row ->
fields.add(
buildDeliveryNotePdfLineField(
deliveryOrderId = row.deliveryOrderId,
line = row.line,
sequenceNumber = fields.size + 1,
pickOrderIdByDoId = pickOrderIdByDoId,
pickOrderLines = pickOrderLines,
stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId,
illById = illById,
itemsById = itemsById,
@@ -1509,6 +1510,7 @@ open class DeliveryOrderService(
headerTicketNo = null,
headerIsMerge = false,
replenishPdfIndex = replenishPdfIndex,
pickOrderLineIdByDeliveryLine = pickOrderLineIdByDeliveryLine,
),
)
}
@@ -1569,17 +1571,47 @@ open class DeliveryOrderService(
info.deliveryOrderLines.filter { it.deleted != true }.map { line -> DeliveryNoteExportLine(info.id, line) }
}

fun resolvePickOrderLineIdForDeliveryNoteLine(
deliveryOrderId: Long,
itemId: Long?,
/**
* FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28
* Pair each delivery-order line with its own pick line.
* Same item on one pick order is matched by qty first (4↔4, 1↔1).
* Equal qty is paired in id order. Replenishment pick lines are left out;
* those rows still print from do_replenishment.pickOrderLineId.
*/
fun matchDeliveryNotePickOrderLines(
exportLines: List<DeliveryNoteExportLine>,
pickOrderIdByDoId: Map<Long, Long>,
pickOrderLines: List<PickOrderLine>,
): Long? {
if (itemId == null) return null
val pickOrderId = pickOrderIdByDoId[deliveryOrderId] ?: return null
return pickOrderLines
.firstOrNull { pol -> pol.pickOrder?.id == pickOrderId && pol.item?.id == itemId }
?.id
replenishmentPickOrderLineIds: Set<Long> = emptySet(),
): Map<Pair<Long, Long>, Long> {
val keys = exportLines.mapNotNull { row ->
val itemId = row.line.itemId ?: return@mapNotNull null
if (row.line.deleted == true) return@mapNotNull null
DeliveryNoteLineMatchKey(
deliveryOrderId = row.deliveryOrderId,
lineId = row.line.id,
itemId = itemId,
qty = row.line.qty,
)
}
val candidates = pickOrderLines.mapNotNull { pol ->
val polId = pol.id ?: return@mapNotNull null
val pickOrderId = pol.pickOrder?.id ?: return@mapNotNull null
val itemId = pol.item?.id ?: return@mapNotNull null
val qty = pol.qty ?: return@mapNotNull null
DeliveryNotePickLineCandidate(
id = polId,
pickOrderId = pickOrderId,
itemId = itemId,
qty = qty,
)
}
return DeliveryNotePickLineMatcher.match(
lines = keys,
pickOrderIdByDoId = pickOrderIdByDoId,
pickLines = candidates,
replenishmentPickOrderLineIds = replenishmentPickOrderLineIds,
)
}

fun parseTicketTypeLetter(ticketNo: String?): String? {
@@ -1691,12 +1723,11 @@ open class DeliveryOrderService(
return field
}

/** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */
fun buildDeliveryNotePdfLineField(
deliveryOrderId: Long,
line: DeliveryOrderLineInfo,
sequenceNumber: Int,
pickOrderIdByDoId: Map<Long, Long>,
pickOrderLines: List<PickOrderLine>,
stockOutLinesByPickOrderLineId: Map<Long, List<StockOutLineInfo>>,
illById: Map<Long, InventoryLotLine>,
itemsById: Map<Long, Items>,
@@ -1705,6 +1736,7 @@ open class DeliveryOrderService(
headerTicketNo: String? = null,
headerIsMerge: Boolean = false,
replenishPdfIndex: DoReplenishmentService.ReplenishPdfIndex = DoReplenishmentService.ReplenishPdfIndex.EMPTY,
pickOrderLineIdByDeliveryLine: Map<Pair<Long, Long>, Long> = emptyMap(),
): MutableMap<String, Any> {
val field = mutableMapOf<String, Any>()
val isExtra = isExtraDeliveryTicket(
@@ -1713,12 +1745,7 @@ open class DeliveryOrderService(
headerIsMerge = headerIsMerge,
)

val polId = resolvePickOrderLineIdForDeliveryNoteLine(
deliveryOrderId = deliveryOrderId,
itemId = line.itemId,
pickOrderIdByDoId = pickOrderIdByDoId,
pickOrderLines = pickOrderLines,
)
val polId = pickOrderLineIdByDeliveryLine[deliveryOrderId to line.id]
val isReplenish = replenishPdfIndex.matches(deliveryOrderId, line.itemId, polId)

field["sequenceNumber"] = formatSequenceNumber(sequenceNumber, isExtra, isReplenish)
@@ -2514,6 +2541,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
return out
}

/** FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 */
private fun buildResolvedTruckDtosForLiteRows(
rows: List<DeliveryOrderInfoLite>,
): List<DeliveryOrderInfoLiteDto> {
@@ -2521,9 +2549,13 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
if (ids.isEmpty()) return emptyList()
val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id }
val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val ticketLaneByDoId = loadTicketTruckLanesByDoId(
deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id },
)

val shopIdAndDatePairs = rows.mapNotNull { info ->
val d = deliveryOrdersMap[info.id]
if (isReleasedDeliveryOrder(d)) return@mapNotNull null
val shopId = d?.shop?.id
val eta = info.estimatedArrivalDate
if (shopId != null && eta != null) {
@@ -2545,18 +2577,14 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }

return rows.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
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
}
val calculatedTruckLanceCode = displayedTruckLanceCode(
deliveryOrder = deliveryOrder,
estimatedArrivalDate = info.estimatedArrivalDate,
floorSuppliers2F = floorSuppliers2F,
floorSuppliers4F = floorSuppliers4F,
truckCache = truckCache,
ticketLaneByDoId = ticketLaneByDoId,
)
DeliveryOrderInfoLiteDto(
id = info.id,
code = info.code,
@@ -2572,6 +2600,139 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
}
}

private class TicketLaneHit(
val lane: String?,
val ticketId: Long,
val srcRank: Int,
)

/**
* FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28
* 放單後狀態:receiving 表示已發布,completed 表示已完成。
*/
private fun isReleasedDeliveryOrder(deliveryOrder: DeliveryOrder?): Boolean =
deliveryOrder?.status == DeliveryOrderStatus.RECEIVING ||
deliveryOrder?.status == DeliveryOrderStatus.COMPLETED

/**
* FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28
* 已放單讀票據車線(工作台 `delivery_order_pick_order`,否則 `do_pick_order`/record)。
* 未放單才依供應商樓層 + 店鋪 + 星期從 truck 推算。
* [truckCache] 為 null 時,未放單即時查 truck。
*/
private fun displayedTruckLanceCode(
deliveryOrder: DeliveryOrder?,
estimatedArrivalDate: LocalDateTime?,
floorSuppliers2F: List<String>,
floorSuppliers4F: List<String>,
truckCache: Map<Triple<Long, String, String>, Truck?>?,
ticketLaneByDoId: Map<Long, String?>,
): String? {
if (isReleasedDeliveryOrder(deliveryOrder)) {
return deliveryOrder?.id?.let { ticketLaneByDoId[it] }
}
val shopId = deliveryOrder?.shop?.id ?: return null
val eta = estimatedArrivalDate ?: return null
val preferredFloor = preferredStoreFloorForSupplier(
deliveryOrder.supplier?.code,
floorSuppliers2F,
floorSuppliers4F,
)
val dayAbbr = getDayOfWeekAbbr(eta.toLocalDate())
val key = Triple(shopId, preferredFloor, dayAbbr)
if (truckCache != null) {
return truckCache[key]?.truckLanceCode
}
return resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28
* 一批送貨單對應的票據車線。同一張單有多張票時,工作台票優先,同來源取 id 最大的一張。
* 票上的車線為空白時仍回傳 null,呼叫端不再改查 truck。
*/
private fun loadTicketTruckLanesByDoId(doIds: Collection<Long>): Map<Long, String?> {
val ids = doIds.distinct()
if (ids.isEmpty()) return emptyMap()
val best = HashMap<Long, TicketLaneHit>()
val sql = """
SELECT do_id, truck_lance_code, ticket_id, src_rank
FROM (
SELECT po.doId AS do_id,
NULLIF(TRIM(dop.truckLanceCode), '') AS truck_lance_code,
dop.id AS ticket_id,
1 AS src_rank
FROM fpsmsdb.delivery_order_pick_order dop
INNER JOIN fpsmsdb.pick_order po
ON po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
WHERE dop.deleted = 0
AND po.doId IN (:doIds)

UNION ALL

SELECT dpo.do_order_id AS do_id,
NULLIF(TRIM(dpo.TruckLanceCode), '') AS truck_lance_code,
dpo.id AS ticket_id,
2 AS src_rank
FROM fpsmsdb.do_pick_order dpo
WHERE dpo.deleted = 0
AND dpo.do_order_id IN (:doIds)

UNION ALL

SELECT dpol.do_order_id AS do_id,
NULLIF(TRIM(dpo.TruckLanceCode), '') AS truck_lance_code,
dpo.id AS ticket_id,
2 AS src_rank
FROM fpsmsdb.do_pick_order dpo
INNER JOIN fpsmsdb.do_pick_order_line dpol
ON dpol.do_pick_order_id = dpo.id AND dpol.deleted = 0
WHERE dpo.deleted = 0
AND dpol.do_order_id IN (:doIds)

UNION ALL

SELECT rec.do_order_id AS do_id,
NULLIF(TRIM(rec.TruckLanceCode), '') AS truck_lance_code,
rec.id AS ticket_id,
3 AS src_rank
FROM fpsmsdb.do_pick_order_record rec
WHERE rec.deleted = 0
AND rec.do_order_id IN (:doIds)
) lanes
""".trimIndent()

ids.chunked(400).forEach { chunk ->
val rows = jdbcDao.queryForList(sql, mapOf("doIds" to chunk))
for (row in rows) {
val doId = jdbcNumber(row, "do_id")?.toLong() ?: continue
val ticketId = jdbcNumber(row, "ticket_id")?.toLong() ?: 0L
val srcRank = jdbcNumber(row, "src_rank")?.toInt() ?: 9
val lane = jdbcText(row, "truck_lance_code")
val hit = TicketLaneHit(lane, ticketId, srcRank)
val current = best[doId]
if (
current == null ||
hit.srcRank < current.srcRank ||
(hit.srcRank == current.srcRank && hit.ticketId > current.ticketId)
) {
best[doId] = hit
}
}
}
return best.mapValues { it.value.lane }
}

private fun jdbcNumber(row: Map<*, *>, name: String): Number? {
val value = row.entries.firstOrNull { it.key?.toString().equals(name, true) }?.value ?: return null
return value as? Number
}

private fun jdbcText(row: Map<*, *>, name: String): String? {
val value = row.entries.firstOrNull { it.key?.toString().equals(name, true) }?.value ?: return null
return value.toString().trim().takeIf { it.isNotEmpty() }
}

/**
* 依店鋪 + 揀貨樓層解析當日應顯示之車線。
* - **2F**(P07/P06D/P06Y):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。
@@ -2688,3 +2849,52 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
}
}
}

internal data class DeliveryNoteLineMatchKey(
val deliveryOrderId: Long,
val lineId: Long,
val itemId: Long,
val qty: BigDecimal,
)

internal data class DeliveryNotePickLineCandidate(
val id: Long,
val pickOrderId: Long,
val itemId: Long,
val qty: BigDecimal,
)

internal object DeliveryNotePickLineMatcher {
/** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */
fun match(
lines: List<DeliveryNoteLineMatchKey>,
pickOrderIdByDoId: Map<Long, Long>,
pickLines: List<DeliveryNotePickLineCandidate>,
replenishmentPickOrderLineIds: Set<Long> = emptySet(),
): Map<Pair<Long, Long>, Long> {
val result = mutableMapOf<Pair<Long, Long>, Long>()
val rowsByDoAndItem = lines.groupBy { it.deliveryOrderId to it.itemId }
for ((key, rows) in rowsByDoAndItem) {
val (deliveryOrderId, itemId) = key
val pickOrderId = pickOrderIdByDoId[deliveryOrderId] ?: continue
val available = pickLines
.filter { pol ->
pol.pickOrderId == pickOrderId &&
pol.itemId == itemId &&
pol.id !in replenishmentPickOrderLineIds
}
.sortedBy { it.id }
.toMutableList()
for (row in rows.sortedBy { it.lineId }) {
val qtyIndex = available.indexOfFirst { it.qty.compareTo(row.qty) == 0 }
val chosen = when {
qtyIndex >= 0 -> available.removeAt(qtyIndex)
available.isNotEmpty() -> available.removeAt(0)
else -> null
} ?: continue
result[row.deliveryOrderId to row.lineId] = chosen.id
}
}
return result
}
}

+ 3
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt Näytä tiedosto

@@ -162,6 +162,9 @@ open class DoReplenishmentService(
return deliveryOrderId to resolvedItemId in targetDoItemKeys
}

/** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */
fun replenishmentPickOrderLineIds(): Set<Long> = pickOrderLineIds

companion object {
val EMPTY = ReplenishPdfIndex(emptySet(), emptySet())
}


+ 10
- 4
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt Näytä tiedosto

@@ -261,9 +261,9 @@ open class DoWorkbenchMainService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.3 | 2026-11-02
* Warehouse exclude list for workbench **re-suggest** after scan-pick (shortfall / lot split).
* - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES]).
* - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES], all `4F-` bins).
* - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else empty (no limit).
* - DO / other: pass through request (`null` → service default excludes).
*/
@@ -3060,6 +3060,7 @@ return MessageResponse(
return deliveryOrderService.formatDoCodesPlain(codes)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */
open fun exportDeliveryNoteWorkbench(request: ExportDeliveryNoteRequest): Map<String, Any> {
val deliveryNote = deliveryNoteJasperTemplate
val fields = mutableListOf<MutableMap<String, Any>>()
@@ -3111,6 +3112,12 @@ return MessageResponse(
val headerIsMerge = ctx.header.ticketNo?.startsWith("TI-M-") == true
val headerTicketNo = ctx.header.ticketNo
val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(ctx.deliveryOrderIds)
val pickOrderLineIdByDeliveryLine = deliveryOrderService.matchDeliveryNotePickOrderLines(
exportLines = exportLines,
pickOrderIdByDoId = ctx.pickOrderIdByDoId,
pickOrderLines = pickOrderLines,
replenishmentPickOrderLineIds = replenishPdfIndex.replenishmentPickOrderLineIds(),
)

sortedLines.forEach { row ->
fields.add(
@@ -3118,8 +3125,6 @@ return MessageResponse(
deliveryOrderId = row.deliveryOrderId,
line = row.line,
sequenceNumber = fields.size + 1,
pickOrderIdByDoId = ctx.pickOrderIdByDoId,
pickOrderLines = pickOrderLines,
stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId,
illById = illById,
itemsById = itemsById,
@@ -3128,6 +3133,7 @@ return MessageResponse(
headerTicketNo = headerTicketNo,
headerIsMerge = headerIsMerge,
replenishPdfIndex = replenishPdfIndex,
pickOrderLineIdByDeliveryLine = pickOrderLineIdByDeliveryLine,
),
)
}


+ 2
- 3
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt Näytä tiedosto

@@ -23,12 +23,12 @@ import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository
import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus
import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService
import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService
import com.ffii.fpsms.modules.stock.service.isWorkbenchWarehouseCodeExcluded
import com.ffii.fpsms.modules.user.service.UserService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.time.LocalDate
import java.util.Locale

/**
* Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI).
@@ -79,8 +79,7 @@ open class JoWorkbenchMainService(
val availableStatus = notDeleted.filter { it.status == InventoryLotLineStatus.AVAILABLE }
val notExpired = availableStatus.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true }
val notExcluded = notExpired.filter { lot ->
val code = lot.warehouse?.code?.trim()?.uppercase(Locale.ROOT)
code.isNullOrEmpty() || code !in workbenchDefaultExcludeWarehouseCodes
!isWorkbenchWarehouseCodeExcluded(lot.warehouse?.code, workbenchDefaultExcludeWarehouseCodes)
}
val positiveQty = notExcluded.filter { lot ->
val inQ = lot.inQty ?: BigDecimal.ZERO


+ 5
- 24
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt Näytä tiedosto

@@ -1,37 +1,18 @@
package com.ffii.fpsms.modules.jobOrder.service

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* JO workbench pick constants.
*
* [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService])
* and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]).
* JO re-suggest keeps [storeId] null (same as assign); it does not use DO floor store resolution.
*
* Each entry matches a warehouse code exactly, or any code with that prefix plus `-`.
* `"4F"` therefore excludes every 4F bin (`4F-W402-…`, `4F-W401-…`, and the rest).
*/
object JoWorkbenchPickConstants {
val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set<String> = setOf(
"4F-W402-01-00",
"4F-W402-02-00",
"4F-W402-03-00",
"4F-W402-04-00",
"4F-W402-05-00",
"4F-W402-#A-00",
"4F-W402-#B-00",
"4F-W402-#C-00",
"4F-W402-#D-00",
"4F-W402-#E-00",
"4F-W402-#F-00",
"4F-W402-#G-00",
"4F-W402-#H-00",
"4F-W402-#I-00",
"4F-W402-#J-00",
"4F-W402-#K-00",
"4F-W402-#L-00",
"4F-W402-#M-00",
"4F-W402-#N-00",
"4F-W402-#O-00",
"4F-W402-#P-00",
"4F-W402-#Q-00",
"4F-W402-#R-00",
"4F-W402-#S-00",
"4F",
)
}

+ 1
- 1
src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt Näytä tiedosto

@@ -7,7 +7,7 @@ interface BomCombo {
val id: Long;
@get:Value("#{target.id}")
val value: Long;
@get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[salesUnit == true && deleted == false]?.uom?.udfudesc ?: ''}")
@get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[stockUnit == true && deleted == false]?.uom?.udfudesc ?: ''}")
val label: String;
val outputQty: BigDecimal;
val outputQtyUom: String?;


+ 44
- 6
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt Näytä tiedosto

@@ -106,6 +106,9 @@ open class ItemUomService(
open fun convertPurchaseQtyToStockQty(itemId: Long, purchaseQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty;
val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty;
if (sameUomId(purchaseUnit, stockUnit)) {
return purchaseQty.setScale(0, RoundingMode.UP)
}
val one = BigDecimal.ONE;

// IMPORTANT:
@@ -131,6 +134,9 @@ open class ItemUomService(
open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty
val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty
if (sameUomId(purchaseUnit, stockUnit)) {
return purchaseQty.setScale(0, RoundingMode.DOWN)
}
val one = BigDecimal.ONE

// Use high precision for intermediate steps, and round down only at the end.
@@ -151,6 +157,9 @@ open class ItemUomService(
open fun convertStockQtyToPurchaseQty(itemId: Long, stockQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty
val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty
if (sameUomId(purchaseUnit, stockUnit)) {
return stockQty.setScale(0, RoundingMode.UP)
}
val one = BigDecimal.ONE
val calcScale = 10

@@ -170,8 +179,11 @@ open class ItemUomService(
* Returns source qty when source/purchase unit mapping is not found.
*/
open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return sourceQty
if (sameUomId(uomId, purchaseUnit)) {
return sourceQty.setScale(0, RoundingMode.UP)
}
val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val one = BigDecimal.ONE
val calcScale = 10

@@ -187,9 +199,12 @@ open class ItemUomService(
}

open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty;

val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO;
val stockUnit = findStockUnitByItemId(itemId)
if (stockUnit != null && sameUomId(uomId, stockUnit)) {
return sourceQty.setScale(0, RoundingMode.UP)
}
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
if (stockUnit == null) return BigDecimal.ZERO
val one = BigDecimal.ONE;

val calcScale = 10
@@ -210,8 +225,12 @@ open class ItemUomService(
* Same as convertQtyToStockQty but rounds down the final stock qty to integer.
*/
open fun convertQtyToStockQtyRoundDown(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val stockUnit = findStockUnitByItemId(itemId)
if (stockUnit != null && sameUomId(uomId, stockUnit)) {
return sourceQty.setScale(0, RoundingMode.DOWN)
}
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO
if (stockUnit == null) return BigDecimal.ZERO
val one = BigDecimal.ONE
val calcScale = 10

@@ -226,6 +245,18 @@ open class ItemUomService(
return stockQty.setScale(0, RoundingMode.DOWN)
}

/** True when both item UOMs point at the same `uom_conversion` row. */
private fun sameUomId(left: ItemUom?, right: ItemUom?): Boolean {
val leftId = left?.uom?.id ?: return false
val rightId = right?.uom?.id ?: return false
return leftId == rightId
}

private fun sameUomId(uomId: Long, unit: ItemUom?): Boolean {
val unitUomId = unit?.uom?.id ?: return false
return unitUomId == uomId
}

/**
* Count-type stock units must be whole numbers: PCS/CTN…, 包, 箱, 件, 個, PCS, CTN, etc.
* Weight/volume units (克, 公斤, 升…) keep 2 decimals.
@@ -270,6 +301,9 @@ open class ItemUomService(
open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty
val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty
if (sameUomId(purchaseUnit, stockUnit)) {
return finalizePreciseStockQty(stockUnit, purchaseQty)
}
val one = BigDecimal.ONE
val calcScale = 10

@@ -289,8 +323,12 @@ open class ItemUomService(
* Used for PO (M18 UOM) conversion where round-down is not allowed.
*/
open fun convertQtyToStockQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val stockUnit = findStockUnitByItemId(itemId)
if (stockUnit != null && sameUomId(uomId, stockUnit)) {
return finalizePreciseStockQty(stockUnit, sourceQty)
}
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO
if (stockUnit == null) return BigDecimal.ZERO
val one = BigDecimal.ONE
val calcScale = 10



+ 36
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt Näytä tiedosto

@@ -0,0 +1,36 @@
package com.ffii.fpsms.modules.pickOrder.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Table

@Entity
@Table(name = "truck_lane_rename_log")
open class TruckLaneRenameLog : BaseEntity<Long>() {
@Column(name = "oldCode", nullable = false, length = 50)
open var oldCode: String = ""

@Column(name = "newCode", nullable = false, length = 50)
open var newCode: String = ""

@Column(name = "storeId", nullable = false, length = 10)
open var storeId: String = ""

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

/** A clock time, or `mixed` when rows in the bucket did not share one time. */
@Column(name = "oldDepartureTime", length = 20)
open var oldDepartureTime: String? = null

@Column(name = "newDepartureTime", length = 20)
open var newDepartureTime: String? = null

@Column(name = "ticketCount", nullable = false)
open var ticketCount: Int = 0

/** `truck.id` of each shop row in the renamed lane, comma-separated, ascending. */
@Column(name = "truckIds", columnDefinition = "TEXT")
open var truckIds: String? = null
}

+ 9
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLogRepository.kt Näytä tiedosto

@@ -0,0 +1,9 @@
package com.ffii.fpsms.modules.pickOrder.entity

import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface TruckLaneRenameLogRepository : JpaRepository<TruckLaneRenameLog, Long> {
fun findTop50ByDeletedFalseOrderByIdDesc(): List<TruckLaneRenameLog>
}

+ 2
- 2
src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt Näytä tiedosto

@@ -5,7 +5,7 @@ import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchPickConstants
/**
* Temporary consumable workbench exclude list until per-user DB config (Scheme A) lands.
*
* - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants]).
* - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants], all `4F-` bins).
* - All other users → empty list (no warehouse limit). Do not return null:
* [SuggestedPickLotWorkbenchService] treats null as DO default 2F excludes.
* Consumable re-suggest keeps storeId null (same as first prime / JO); it does not use DO floor store resolution.
@@ -13,7 +13,7 @@ import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchPickConstants
object ConsumableWorkbenchPickConstants {
const val HARDCODED_EXCLUDE_USER_ID: Long = 246L

/** FP-MTMS Version Checklist | Functions Ref. No. 66 | v1.0.0 | 2026-08-13 */
/** FP-MTMS Version Checklist | Functions Ref. No. 66 | v1.0.1 | 2026-11-02 */
fun resolveExcludeWarehouseCodes(userId: Long): List<String> {
if (userId != HARDCODED_EXCLUDE_USER_ID) return emptyList()
return JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES.toList()


+ 61
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameRules.kt Näytä tiedosto

@@ -0,0 +1,61 @@
package com.ffii.fpsms.modules.pickOrder.service

/**
* Bucket identity shared by the route board and the rename transaction.
* Remark is part of the key only on 4F. Comparison trims, and does not collapse inner spaces.
*/
object TruckLaneRenameRules {
const val RESERVED = "車線-X"
const val MAX_LEN = 50

data class BucketRef(
val code: String,
val floor: String,
val remarkKey: String,
)

fun floorOf(storeId: String?): String {
val compact = storeId?.trim()?.uppercase()?.replace(" ", "")?.replace("/", "") ?: ""
return if (compact == "4F") "4F" else "2F"
}

/** Blank, null, and whitespace-only remarks share the empty key. 2F ignores remark. */
fun remarkKey(floor: String, remark: String?): String {
if (floor != "4F") return ""
return remark?.trim().orEmpty()
}

/** Null when the name is acceptable. Otherwise a stable error code. */
fun validateNames(oldName: String, newName: String): String? {
val oldTrim = oldName.trim()
val newTrim = newName.trim()
if (oldTrim.isEmpty() || newTrim.isEmpty()) return "LANE_NAME_EMPTY"
if (newTrim.length > MAX_LEN) return "LANE_NAME_TOO_LONG"
if (oldTrim == RESERVED || newTrim == RESERVED) return "LANE_NAME_RESERVED"
return null
}

/**
* Same-remark collision ignores case and excludes the source bucket.
* A different remark refuses only when the trimmed new name is exactly the same,
* because tickets have no remark and could not tell the buckets apart.
*/
fun collisionCode(source: BucketRef, newCode: String, live: List<BucketRef>): String? {
val next = newCode.trim()
for (other in live) {
if (other.floor != source.floor) continue
val sameSource = other.remarkKey == source.remarkKey &&
other.code.trim().equals(source.code.trim(), ignoreCase = true)
if (sameSource) continue
if (other.remarkKey == source.remarkKey &&
other.code.trim().equals(next, ignoreCase = true)
) {
return "LANE_NAME_TAKEN"
}
if (other.remarkKey != source.remarkKey && other.code.trim() == next) {
return "LANE_NAME_REMARK_AMBIGUOUS"
}
}
return null
}
}

+ 299
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt Näytä tiedosto

@@ -0,0 +1,299 @@
package com.ffii.fpsms.modules.pickOrder.service

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.pickOrder.entity.Truck
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneRenameLog
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneRenameLogRepository
import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository
import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameRequest
import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameResponse
import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneRequest
import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneResponse
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneRenameLogResponse
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException
import java.time.LocalTime
import java.time.format.DateTimeParseException

@Service
open class TruckLaneRenameService(
private val jdbcDao: JdbcDao,
private val truckRepository: TruckRepository,
private val renameLogRepository: TruckLaneRenameLogRepository,
) {
@Transactional(readOnly = true)
open fun preview(req: PreviewTruckLaneRenameRequest): PreviewTruckLaneRenameResponse {
val code = req.truckLanceCode.trim()
if (code.isEmpty()) throw badRequest("LANE_NAME_EMPTY")
val floor = TruckLaneRenameRules.floorOf(req.storeId)
val remarkKey = TruckLaneRenameRules.remarkKey(floor, req.remark)
val params = ticketParams(code, floor, remarkKey)
return PreviewTruckLaneRenameResponse(
unfinishedCount = count(UNFINISHED_COUNT_SQL, params),
completedCount = count(COMPLETED_COUNT_SQL, params),
handlerNames = jdbcDao.queryForStrings(HANDLER_SQL, params)
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct(),
openScheduleCount = count(OPEN_SCHEDULE_COUNT_SQL, params),
)
}

@Transactional
open fun rename(req: RenameTruckLaneRequest): RenameTruckLaneResponse {
val oldCode = req.truckLanceCode.trim()
val newCode = req.replacementTruckLanceCode.trim()
val nameError = TruckLaneRenameRules.validateNames(oldCode, newCode)
if (nameError != null) throw badRequest(nameError)
val floor = TruckLaneRenameRules.floorOf(req.storeId)
val remarkKey = TruckLaneRenameRules.remarkKey(floor, req.remark)
val newTime = if (req.departureTimeChanged) parseTime(req.newDepartureTime) else null
if (req.departureTimeChanged && newTime == null) throw badRequest("LANE_TIME_INVALID")

jdbcDao.queryForList(
"SELECT id FROM truck WHERE deleted = 0 FOR UPDATE",
emptyMap<String, Any>(),
)

val live = truckRepository.findAllForRouteBoard().filter { it.deleted != true }
val sourceRows = live.filter { inBucket(it, oldCode, floor, remarkKey) }
if (sourceRows.isEmpty()) throw badRequest("LANE_NOT_FOUND")

val refs = liveBuckets(live)
val collision = TruckLaneRenameRules.collisionCode(
TruckLaneRenameRules.BucketRef(oldCode, floor, remarkKey),
newCode,
refs,
)
if (collision != null) throw conflict(collision)

val oldTimeLabel = describeTimes(sourceRows.map { it.departureTime })
val casings = sourceRows.map { it.truckLanceCode?.trim().orEmpty() }.filter { it.isNotEmpty() }.distinct()
// Existing delivery tickets (pending / released / assigned / completed) keep the old name.
// Later releases copy the new master name at insert time.
var scheduleCount = 0
for (casing in casings) {
scheduleCount += applyOpenScheduleRename(casing, newCode, floor, remarkKey)
}
for (row in sourceRows) {
row.truckLanceCode = newCode
if (req.departureTimeChanged) row.departureTime = newTime
}
truckRepository.saveAll(sourceRows)

val log = TruckLaneRenameLog()
log.oldCode = oldCode
log.newCode = newCode
log.storeId = floor
log.remark = remarkKey.ifEmpty { null }
log.oldDepartureTime = oldTimeLabel
log.newDepartureTime = if (req.departureTimeChanged) newTime.toString() else oldTimeLabel
log.ticketCount = 0
log.truckIds = sourceRows.mapNotNull { it.id }.distinct().sorted().joinToString(",")
renameLogRepository.save(log)

return RenameTruckLaneResponse(
truckLanceCode = newCode,
ticketCount = 0,
scheduleLineCount = scheduleCount,
)
}

/**
* Restore writes master rows back to the snapshot name and does not move unfinished tickets.
* Open schedule from/to that still point at the current name are moved back here.
*/
@Transactional
open fun rewriteOpenScheduleNames(
oldCode: String,
newCode: String,
storeId: String?,
remark: String?,
): Int {
val floor = TruckLaneRenameRules.floorOf(storeId)
val remarkKey = TruckLaneRenameRules.remarkKey(floor, remark)
return applyOpenScheduleRename(oldCode.trim(), newCode.trim(), floor, remarkKey)
}

@Transactional(readOnly = true)
open fun recentLogs(): List<TruckLaneRenameLogResponse> =
renameLogRepository.findTop50ByDeletedFalseOrderByIdDesc().map { row ->
TruckLaneRenameLogResponse(
id = row.id ?: 0,
created = row.created?.toString(),
createdBy = row.createdBy,
oldCode = row.oldCode,
newCode = row.newCode,
storeId = row.storeId,
remark = row.remark,
oldDepartureTime = row.oldDepartureTime,
newDepartureTime = row.newDepartureTime,
ticketCount = row.ticketCount,
truckIds = row.truckIds,
)
}

private fun applyOpenScheduleRename(
oldCode: String,
newCode: String,
floor: String,
remarkKey: String,
): Int {
if (oldCode.isEmpty() || newCode.isEmpty() || oldCode == newCode) return 0
val params = ticketParams(oldCode, floor, remarkKey).toMutableMap()
params["newCode"] = newCode
val matched = count(OPEN_SCHEDULE_COUNT_SQL, params)
jdbcDao.executeUpdate(SCHEDULE_FROM_SQL, params)
jdbcDao.executeUpdate(SCHEDULE_TO_SQL, params)
return matched
}

private fun liveBuckets(live: List<Truck>): List<TruckLaneRenameRules.BucketRef> {
val grouped = linkedMapOf<Triple<String, String, String>, String>()
for (row in live) {
val code = row.truckLanceCode?.trim().orEmpty()
if (code.isEmpty()) continue
val floor = TruckLaneRenameRules.floorOf(row.storeId)
val remarkKey = TruckLaneRenameRules.remarkKey(floor, row.remark)
val key = Triple(code.lowercase(), floor, remarkKey)
grouped.putIfAbsent(key, code)
}
return grouped.map { (key, code) ->
TruckLaneRenameRules.BucketRef(code, key.second, key.third)
}
}

private fun inBucket(row: Truck, code: String, floor: String, remarkKey: String): Boolean {
val rowFloor = TruckLaneRenameRules.floorOf(row.storeId)
val rowRemark = TruckLaneRenameRules.remarkKey(rowFloor, row.remark)
return rowFloor == floor &&
rowRemark == remarkKey &&
row.truckLanceCode?.trim().orEmpty().equals(code, ignoreCase = true)
}

private fun describeTimes(times: List<LocalTime?>): String {
val labels = times.map { it?.withNano(0)?.toString() ?: "" }.distinct()
return if (labels.size <= 1) labels.firstOrNull().orEmpty() else "mixed"
}

private fun parseTime(raw: String?): LocalTime? {
val text = raw?.trim().orEmpty()
if (text.isEmpty()) return null
val normalized = if (text.length == 5) "$text:00" else text
return try {
LocalTime.parse(normalized)
} catch (_: DateTimeParseException) {
null
}
}

private fun ticketParams(code: String, floor: String, remarkKey: String): Map<String, Any?> = mapOf(
"oldCode" to code,
"floor" to floor,
"remark" to remarkKey,
"blankRemark" to if (remarkKey.isEmpty()) 1 else 0,
)

private fun count(sql: String, params: Map<String, Any?>): Int {
val row = jdbcDao.queryForList(sql, params).firstOrNull() ?: return 0
val value = row.values.firstOrNull()
return (value as? Number)?.toInt() ?: 0
}

private fun badRequest(code: String) = ResponseStatusException(HttpStatus.BAD_REQUEST, code)

private fun conflict(code: String) = ResponseStatusException(HttpStatus.CONFLICT, code)

companion object {
private const val FLOOR_MATCH = """
(
(:floor = '4F' AND UPPER(REPLACE(TRIM(storeCol), ' ', '')) IN ('4F', '4/F'))
OR (:floor <> '4F' AND UPPER(REPLACE(TRIM(storeCol), ' ', '')) IN ('2F', '2/F'))
)
"""

private const val REMARK_MATCH_4F = """
(
:floor <> '4F'
OR (
(:blankRemark = 1 AND (remarkCol IS NULL OR TRIM(remarkCol) = ''))
OR (:blankRemark = 0 AND TRIM(remarkCol) = :remark)
)
)
"""

private val UNFINISHED_COUNT_SQL = """
SELECT COUNT(*) AS cnt
FROM delivery_order_pick_order
WHERE deleted = 0
AND ticketStatus IN ('pending', 'released')
AND TRIM(truckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "storeId")}
""".trimIndent()

private val COMPLETED_COUNT_SQL = """
SELECT COUNT(*) AS cnt
FROM delivery_order_pick_order
WHERE deleted = 0
AND ticketStatus = 'completed'
AND TRIM(truckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "storeId")}
""".trimIndent()

private val HANDLER_SQL = """
SELECT DISTINCT TRIM(handlerName)
FROM delivery_order_pick_order
WHERE deleted = 0
AND ticketStatus IN ('pending', 'released')
AND handledBy IS NOT NULL
AND handlerName IS NOT NULL
AND TRIM(handlerName) <> ''
AND TRIM(truckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "storeId")}
""".trimIndent()

private val OPEN_SCHEDULE_COUNT_SQL = """
SELECT COUNT(*) AS cnt
FROM truck_lane_schedule_line l
JOIN truck_lane_schedule s ON s.id = l.scheduleId
WHERE l.deleted = 0 AND s.deleted = 0
AND s.status NOT IN ('APPLIED', 'CANCELLED')
AND (
(
TRIM(l.fromTruckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "l.fromStoreId")}
AND ${REMARK_MATCH_4F.replace("remarkCol", "l.fromRemark")}
)
OR (
TRIM(l.toTruckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "l.toStoreId")}
AND ${REMARK_MATCH_4F.replace("remarkCol", "l.toRemark")}
)
)
""".trimIndent()

private val SCHEDULE_FROM_SQL = """
UPDATE truck_lane_schedule_line l
JOIN truck_lane_schedule s ON s.id = l.scheduleId
SET l.fromTruckLanceCode = :newCode, l.version = l.version + 1
WHERE l.deleted = 0 AND s.deleted = 0
AND s.status NOT IN ('APPLIED', 'CANCELLED')
AND TRIM(l.fromTruckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "l.fromStoreId")}
AND ${REMARK_MATCH_4F.replace("remarkCol", "l.fromRemark")}
""".trimIndent()

private val SCHEDULE_TO_SQL = """
UPDATE truck_lane_schedule_line l
JOIN truck_lane_schedule s ON s.id = l.scheduleId
SET l.toTruckLanceCode = :newCode, l.version = l.version + 1
WHERE l.deleted = 0 AND s.deleted = 0
AND s.status NOT IN ('APPLIED', 'CANCELLED')
AND TRIM(l.toTruckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "l.toStoreId")}
AND ${REMARK_MATCH_4F.replace("remarkCol", "l.toRemark")}
""".trimIndent()
}
}

+ 26
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt Näytä tiedosto

@@ -16,6 +16,7 @@ open class TruckLaneVersionService(
private val truckLaneVersionRepository: TruckLaneVersionRepository,
private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository,
private val logisticRepository: LogisticRepository,
private val truckLaneRenameService: TruckLaneRenameService,
) {
private fun toResponse(v: TruckLaneVersion): TruckLaneVersionResponse =
TruckLaneVersionResponse(
@@ -271,6 +272,7 @@ open class TruckLaneVersionService(
}
}

rewriteOpenSchedulesBackToSnapshot(version, lines)
restoreRowsFromLines(version, lines)

if (!skipPostSnapshot) {
@@ -316,6 +318,7 @@ open class TruckLaneVersionService(
return "No snapshot lines for affected lanes (versionId=$versionId)"
}

rewriteOpenSchedulesBackToSnapshot(version, scopedLines)
val fullSnapshotIds = allLines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet()
for (lane in codes) {
val currentLane = truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane)
@@ -340,6 +343,29 @@ open class TruckLaneVersionService(
return "Restored ${scopedLines.size} rows in ${codes.size} lane(s) from versionId=$versionId"
}

/**
* Open schedule from/to that still use the live name are pointed back at the snapshot name.
* Unfinished delivery tickets are left on the current name.
*/
private fun rewriteOpenSchedulesBackToSnapshot(
version: TruckLaneVersion,
lines: List<TruckLaneVersionLine>,
) {
val ids = lines.mapNotNull { it.truckRowId }.filter { it > 0 }.distinct()
if (ids.isEmpty()) return
val trucks = truckRepository.findAllById(ids).associateBy { it.id }
val seen = HashSet<String>()
for (line in lines) {
val truck = trucks[line.truckRowId] ?: continue
val before = truck.truckLanceCode?.trim().orEmpty()
val after = (line.truckLanceCode ?: version.truckLanceCode)?.trim().orEmpty()
if (before.isEmpty() || after.isEmpty() || before == after) continue
val key = "$before|$after|${truck.storeId}|${truck.remark}"
if (!seen.add(key)) continue
truckLaneRenameService.rewriteOpenScheduleNames(before, after, truck.storeId, truck.remark)
}
}

private fun restoreRowsFromLines(
version: TruckLaneVersion,
lines: List<TruckLaneVersionLine>,


+ 15
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt Näytä tiedosto

@@ -18,8 +18,11 @@ import org.springframework.web.multipart.MultipartHttpServletRequest
import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckRequest
import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckWithoutShopRequest
import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest
import com.ffii.fpsms.modules.pickOrder.service.TruckLaneRenameService
import com.ffii.fpsms.modules.pickOrder.service.TruckService
import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository
import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameRequest
import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneRequest
import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse
import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest
@@ -37,6 +40,7 @@ import jakarta.validation.Valid
open class TruckController(
private val truckService: TruckService,
private val truckRepository: TruckRepository,
private val truckLaneRenameService: TruckLaneRenameService,
) {

@@ -469,4 +473,15 @@ open class TruckController(
}
}

@PostMapping("/previewTruckLaneRename")
open fun previewTruckLaneRename(@RequestBody req: PreviewTruckLaneRenameRequest) =
truckLaneRenameService.preview(req)

@PostMapping("/renameTruckLane")
open fun renameTruckLane(@RequestBody req: RenameTruckLaneRequest) =
truckLaneRenameService.rename(req)

@GetMapping("/truckLaneRenameLog")
open fun truckLaneRenameLog() = truckLaneRenameService.recentLogs()

}

+ 44
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt Näytä tiedosto

@@ -0,0 +1,44 @@
package com.ffii.fpsms.modules.pickOrder.web.models

data class PreviewTruckLaneRenameRequest(
val truckLanceCode: String = "",
val storeId: String = "2F",
val remark: String? = null,
)

data class PreviewTruckLaneRenameResponse(
val unfinishedCount: Int = 0,
val completedCount: Int = 0,
val handlerNames: List<String> = emptyList(),
val openScheduleCount: Int = 0,
)

data class RenameTruckLaneRequest(
val truckLanceCode: String = "",
val storeId: String = "2F",
val remark: String? = null,
@param:com.fasterxml.jackson.annotation.JsonProperty("newTruckLanceCode")
val replacementTruckLanceCode: String = "",
val departureTimeChanged: Boolean = false,
val newDepartureTime: String? = null,
)

data class RenameTruckLaneResponse(
val truckLanceCode: String = "",
val ticketCount: Int = 0,
val scheduleLineCount: Int = 0,
)

data class TruckLaneRenameLogResponse(
val id: Long = 0,
val created: String? = null,
val createdBy: String? = null,
val oldCode: String = "",
val newCode: String = "",
val storeId: String = "",
val remark: String? = null,
val oldDepartureTime: String? = null,
val newDepartureTime: String? = null,
val ticketCount: Int = 0,
val truckIds: String? = null,
)

+ 30
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Näytä tiedosto

@@ -334,6 +334,33 @@ open class StockInLineService(
latestCode = latestCode,
)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
private fun applyRequestedStockQtyRound(
stockInLine: StockInLine,
request: SaveStockInLineRequest,
isCreate: Boolean,
) {
if (request.stockQtyRoundMode.isNullOrBlank()) return
if (stockInLine.purchaseOrderLine == null) return
if (!isCreate) {
val status = stockInLine.status?.lowercase()?.trim()
if (status != StockInLineStatus.PENDING.status && status != StockInLineStatus.ESCALATED.status) {
return
}
}
val currentQty = stockInLine.acceptedQty ?: return
val (roundedQty, remarks) = StockQtyRoundSupport.applyIfRequested(
currentQty = currentQty,
currentRemarks = stockInLine.remarks ?: request.remarks,
request = request,
defaultSource = if (isCreate) "CREATE" else "QC",
)
stockInLine.acceptedQty = roundedQty
stockInLine.remarks = remarks
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
@Throws(IOException::class)
@Transactional
open fun create(request: SaveStockInLineRequest): MessageResponse {
@@ -454,6 +481,7 @@ open class StockInLineService(
this.type = "NOR"
status = StockInLineStatus.PENDING.status
}
applyRequestedStockQtyRound(stockInLine, request, isCreate = true)
if (jo != null) {
val planStartDate = jo.planStart?.toLocalDate()
stockInLine.lotNo = assignLotNoForJo(planStartDate)
@@ -956,6 +984,7 @@ open class StockInLineService(
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
@Throws(IOException::class)
@Transactional
open fun update(request: SaveStockInLineRequest): MessageResponse {
@@ -1042,6 +1071,7 @@ open class StockInLineService(
this.expiryDate = stockInLine.expiryDate ?: request.expiryDate
this.remarks = stockInLine.remarks ?: request.remarks
}
applyRequestedStockQtyRound(stockInLine, request, isCreate = false)
_logStep("apply_fields", _tApplyFields)
if (request.status == StockInLineStatus.RECEIVED.status) {
val _tPutAwayTotal = System.nanoTime()


+ 114
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt Näytä tiedosto

@@ -0,0 +1,114 @@
package com.ffii.fpsms.modules.stock.service

import com.ffii.core.exception.BadRequestException
import com.ffii.fpsms.modules.common.SecurityUtils
import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

object StockQtyRoundSupport {
private val TAG_REGEX = Regex("""\[QTY_ROUND[^\]]*\]""")
private val AT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")
private val REMARKS_MAX = 500

enum class Mode {
HALF_UP,
CEILING,
FLOOR,
CUSTOM,
}

fun isNotInteger(qty: BigDecimal): Boolean {
return qty.stripTrailingZeros().scale() > 0
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
fun applyIfRequested(
currentQty: BigDecimal,
currentRemarks: String?,
request: SaveStockInLineRequest,
defaultSource: String,
): Pair<BigDecimal, String?> {
val mode = parseMode(request.stockQtyRoundMode) ?: return currentQty to currentRemarks
if (!isNotInteger(currentQty)) {
return currentQty to currentRemarks
}
val after = roundQty(currentQty, mode, request.stockQtyCustomQty)
val source = request.stockQtyRoundSource?.trim()?.ifBlank { null } ?: defaultSource
val tag = buildTag(
mode = mode,
before = currentQty,
after = after,
source = source,
)
val withReason = if (mode == Mode.CUSTOM) {
val reason = request.stockQtyCustomReason?.trim().orEmpty()
if (reason.isEmpty()) tag else "$tag 原因:$reason"
} else {
tag
}
return after to mergeRemark(currentRemarks, withReason)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
fun parseMode(raw: String?): Mode? {
val value = raw?.trim()?.uppercase() ?: return null
if (value.isEmpty()) return null
return try {
Mode.valueOf(value)
} catch (_: IllegalArgumentException) {
throw BadRequestException("Invalid stockQtyRoundMode: $raw")
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
fun roundQty(before: BigDecimal, mode: Mode, customQty: BigDecimal?): BigDecimal {
return when (mode) {
Mode.HALF_UP -> before.setScale(0, RoundingMode.HALF_UP)
Mode.CEILING -> before.setScale(0, RoundingMode.CEILING)
Mode.FLOOR -> before.setScale(0, RoundingMode.FLOOR)
Mode.CUSTOM -> {
val custom = customQty ?: throw BadRequestException("stockQtyCustomQty is required for CUSTOM")
if (custom <= BigDecimal.ZERO) {
throw BadRequestException("stockQtyCustomQty must be greater than 0")
}
if (isNotInteger(custom)) {
throw BadRequestException("stockQtyCustomQty must be an integer")
}
custom.setScale(0, RoundingMode.DOWN)
}
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
fun buildTag(
mode: Mode,
before: BigDecimal,
after: BigDecimal,
source: String,
at: LocalDateTime = LocalDateTime.now(),
by: String = currentUsername(),
): String {
return "[QTY_ROUND mode=${mode.name} before=${plain(before)} after=${plain(after)} source=$source by=${sanitize(by)} at=${at.format(AT_FORMAT)}]"
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
fun mergeRemark(existing: String?, tag: String): String {
val stripped = existing?.replace(TAG_REGEX, "")?.trim().orEmpty()
val merged = if (stripped.isEmpty()) tag else "$tag $stripped"
return if (merged.length <= REMARKS_MAX) merged else merged.take(REMARKS_MAX)
}

private fun currentUsername(): String {
val user = SecurityUtils.getUser().orElse(null)
return user?.username ?: user?.name ?: "unknown"
}

private fun plain(qty: BigDecimal): String = qty.stripTrailingZeros().toPlainString()

private fun sanitize(value: String): String {
return value.replace(Regex("""[\s\[\]]+"""), "_").ifBlank { "unknown" }
}
}

+ 32
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Näytä tiedosto

@@ -317,8 +317,31 @@ open class StockTakeRecordService(
}
}

/** 與 Approver 列表前端一致:優先第二次盤點數,否則第一次 */
/**
* 與核准列表「差異百分比」同一個選擇數量。
* lastSelect:1 第一次、2 第二次、3 審核員(盤點數 − 不良數)。
* 沒有 lastSelect 時用 finalQty 對應;再沒有才退回第二次,否則第一次。
*/
private fun resolvePickerQtyForVarianceFilter(response: InventoryLotDetailResponse): BigDecimal {
when (response.lastSelect) {
1 -> return response.firstStockTakeQty ?: BigDecimal.ZERO
2 -> return response.secondStockTakeQty ?: BigDecimal.ZERO
3 -> return approverGoodQty(response)
}
val finalQty = response.finalQty
if (finalQty != null) {
val approverGood = approverGoodQty(response)
if (response.approverQty != null && sameStockTakeQty(approverGood, finalQty)) {
return approverGood
}
val second = response.secondStockTakeQty
if (second != null && sameStockTakeQty(second, finalQty)) return second
val first = response.firstStockTakeQty
if (first != null && sameStockTakeQty(first, finalQty)) return first
if (response.approverQty != null && sameStockTakeQty(response.approverQty, finalQty)) {
return approverGood
}
}
val second = response.secondStockTakeQty
val first = response.firstStockTakeQty
return when {
@@ -328,6 +351,13 @@ open class StockTakeRecordService(
}
}

/** 與前端選擇數量一致:審核員盤點數減不良數。 */
private fun approverGoodQty(response: InventoryLotDetailResponse): BigDecimal =
(response.approverQty ?: BigDecimal.ZERO).subtract(response.approverBadQty ?: BigDecimal.ZERO)

private fun sameStockTakeQty(a: BigDecimal?, b: BigDecimal?): Boolean =
a != null && b != null && a.compareTo(b) == 0

private fun computeInventoryLotDetailVariancePercentage(response: InventoryLotDetailResponse): BigDecimal {
val selectedQty = resolvePickerQtyForVarianceFilter(response)
val bookQty = response.bookQty ?: response.availableQty ?: BigDecimal.ZERO
@@ -647,6 +677,7 @@ open class StockTakeRecordService(
return stockTakeRepository.findByIdAndDeletedIsFalse(roundKey)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 90 | v1.0.0 | 2026-11-02 */
open fun getApproverInventoryLotDetailsAll(
stockTakeId: Long? = null,
pageNum: Int = 0,


+ 50
- 12
src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt Näytä tiedosto

@@ -23,6 +23,21 @@ data class WorkbenchSuggestionSummary(
val skippedExisting: Int,
)

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* Case-insensitive match of [warehouseCode] against [excludeCodes].
* An entry matches the code exactly, or any code that starts with `{entry}-`.
* `"4F"` therefore excludes every 4F bin.
*/
fun isWorkbenchWarehouseCodeExcluded(warehouseCode: String?, excludeCodes: Collection<String>): Boolean {
val code = warehouseCode?.trim()?.uppercase() ?: return false
if (code.isEmpty() || excludeCodes.isEmpty()) return false
return excludeCodes.any { raw ->
val entry = raw.trim().uppercase()
entry.isNotEmpty() && (code == entry || code.startsWith("$entry-"))
}
}

@Service
open class SuggestedPickLotWorkbenchService(
private val pickOrderLineRepository: PickOrderLineRepository,
@@ -127,7 +142,7 @@ open class SuggestedPickLotWorkbenchService(

/**
* Backend-default avoid warehouse list for workbench suggestions.
* - Case-insensitive match against `warehouse.code`.
* - Case-insensitive match against `warehouse.code` (exact, or `{entry}-` prefix).
* - When request.excludeWarehouseCodes is provided, it overrides this default.
*
* Keep it empty if you don't need any default excludes.
@@ -143,9 +158,32 @@ open class SuggestedPickLotWorkbenchService(
}

private fun isWarehouseExcluded(lot: InventoryLotLine, excludeWarehouseCodes: Set<String>): Boolean {
if (excludeWarehouseCodes.isEmpty()) return false
val code = lot.warehouse?.code?.trim()?.uppercase()
return !code.isNullOrEmpty() && code in excludeWarehouseCodes
return isWorkbenchWarehouseCodeExcluded(lot.warehouse?.code, excludeWarehouseCodes)
}

/** 3F before 2F when expiry is the same. Other floors stay after those two. */
private fun suggestionFloorRank(lot: InventoryLotLine): Int {
val store = normalizeStoreId(lot.warehouse?.store_id)
?: floorTokenFromWarehouseCode(lot.warehouse?.code)
return when (store) {
"3F" -> 0
"2F" -> 1
else -> 2
}
}

private fun floorTokenFromWarehouseCode(code: String?): String? {
val head = code?.trim()?.substringBefore("-") ?: return null
return normalizeStoreId(head)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* Closest expiry first; same day prefers 3F, then 2F.
*/
private fun compareSuggestedLots(a: InventoryLotLine, b: InventoryLotLine): Int {
val byExpiry = compareValues(a.inventoryLot?.expiryDate, b.inventoryLot?.expiryDate)
if (byExpiry != 0) return byExpiry
return suggestionFloorRank(a).compareTo(suggestionFloorRank(b))
}

private fun effectiveExcludeWarehouseCodes(raw: List<String>?): Set<String> {
@@ -168,7 +206,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* Build suggestions with no-hold rule:
* available = inQty - outQty (ignore holdQty).
* When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id).
@@ -204,7 +242,7 @@ open class SuggestedPickLotWorkbenchService(
.filter { it.status == InventoryLotLineStatus.AVAILABLE }
.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true }
.filter { !isWarehouseExcluded(it, excludeSet) }
.sortedBy { it.inventoryLot?.expiryDate }
.sortedWith(this::compareSuggestedLots)
.groupBy { it.inventoryLot?.item?.id }

var created = 0
@@ -301,7 +339,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId].
* Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order.
*/
@@ -365,7 +403,7 @@ open class SuggestedPickLotWorkbenchService(
if (storeKey == null) true
else normalizeStoreId(lot.warehouse?.store_id) == storeKey
}
.sortedBy { it.inventoryLot?.expiryDate }
.sortedWith(this::compareSuggestedLots)

val desired = buildDesiredAllocations(lots, remaining, pickOrderLineId)
val created = syncSuggestedPickLotsForLine(lineFresh, desired)
@@ -393,7 +431,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* Workbench explicit-qty remainder support (single-next-lot):
* Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot.
* When [storeId] is provided, the next lot must be within the same store (warehouse.store_id).
@@ -441,7 +479,7 @@ open class SuggestedPickLotWorkbenchService(
if (storeKey == null) true
else normalizeStoreId(lot.warehouse?.store_id) == storeKey
}
.sortedBy { it.inventoryLot?.expiryDate }
.sortedWith(this::compareSuggestedLots)
.firstOrNull()

val desired = listOf(nextLot to targetQty)
@@ -454,7 +492,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* Workbench explicit-qty split support:
* Set suggestions for this POL to an exact [targetQty] (independent of POL.qty).
* - If [targetQty] <= 0: soft-delete all active suggestions for the line.
@@ -491,7 +529,7 @@ open class SuggestedPickLotWorkbenchService(
.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true }
.filter { !isWarehouseExcluded(it, excludeSet) }
.filter { matchesPolUom(it, line) }
.sortedBy { it.inventoryLot?.expiryDate }
.sortedWith(this::compareSuggestedLots)

val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId)
val created = syncSuggestedPickLotsForLine(line, desired)


+ 8
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt Näytä tiedosto

@@ -37,6 +37,7 @@ data class SaveStockInRequest(
// val m18
)

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
data class SaveStockInLineRequest(
var id: Long? = null,
var stockInId: Long? = null,
@@ -63,7 +64,13 @@ data class SaveStockInLineRequest(
var escalationLog: SaveEscalationLogRequest? = null,
var warehouseId: Long? = null,
var rejectQty: BigDecimal? = null,
var inventoryLotLines: List<SaveInventoryLotLineForSil>? = null
var inventoryLotLines: List<SaveInventoryLotLineForSil>? = null,
/** HALF_UP / CEILING / FLOOR / CUSTOM. Null keeps current decimal stock qty. */
var stockQtyRoundMode: String? = null,
var stockQtyCustomQty: BigDecimal? = null,
/** CREATE (PO submit) or QC (old pending line). */
var stockQtyRoundSource: String? = null,
var stockQtyCustomReason: String? = null,
)

data class SaveInventoryLotLineForSil (


+ 23
- 0
src/main/resources/db/changelog/changes/20261026_truck_lane_rename/01_create_truck_lane_rename_log.sql Näytä tiedosto

@@ -0,0 +1,23 @@
-- liquibase formatted sql
-- changeset fpsms:20261026_01_create_truck_lane_rename_log
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'truck_lane_rename_log'

CREATE TABLE IF NOT EXISTS `truck_lane_rename_log`
(
`id` BIGINT 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',
`oldCode` VARCHAR(50) NOT NULL,
`newCode` VARCHAR(50) NOT NULL,
`storeId` VARCHAR(10) NOT NULL,
`remark` VARCHAR(255) NULL DEFAULT NULL,
`oldDepartureTime` VARCHAR(20) NULL DEFAULT NULL,
`newDepartureTime` VARCHAR(20) NULL DEFAULT NULL,
`ticketCount` INT NOT NULL DEFAULT '0',
CONSTRAINT pk_truck_lane_rename_log PRIMARY KEY (`id`)
);

+ 9
- 0
src/main/resources/db/changelog/changes/20261028_truck_lane_rename_log_truck_ids/01_add_truck_ids.sql Näytä tiedosto

@@ -0,0 +1,9 @@
-- liquibase formatted sql
-- changeset fpsms:20261028_01_truck_lane_rename_log_truck_ids
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_rename_log' AND column_name = 'truckIds'

-- truck.id values of the shops in the renamed lane, comma-separated, ascending.
-- Nullable so rows written before this column existed stay valid.
ALTER TABLE `truck_lane_rename_log`
ADD COLUMN `truckIds` TEXT NULL DEFAULT NULL AFTER `ticketCount`;

+ 82
- 0
src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt Näytä tiedosto

@@ -0,0 +1,82 @@
package com.ffii.fpsms.modules.stock.service

import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.time.LocalDateTime

class StockQtyRoundSupportTest {
@Test
fun isNotInteger_detects_decimals() {
assertTrue(StockQtyRoundSupport.isNotInteger(BigDecimal("10.50")))
assertFalse(StockQtyRoundSupport.isNotInteger(BigDecimal("10.00")))
assertFalse(StockQtyRoundSupport.isNotInteger(BigDecimal("10")))
}

@Test
fun roundQty_halfUp_ceiling_floor() {
val before = BigDecimal("10.50")
assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.HALF_UP, null).compareTo(BigDecimal("11")))
assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.CEILING, null).compareTo(BigDecimal("11")))
assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.FLOOR, null).compareTo(BigDecimal("10")))
assertEquals(
0,
StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.CUSTOM, BigDecimal("12")).compareTo(BigDecimal("12")),
)
}

@Test
fun mergeRemark_replaces_previous_tag() {
val first = StockQtyRoundSupport.buildTag(
mode = StockQtyRoundSupport.Mode.FLOOR,
before = BigDecimal("10.50"),
after = BigDecimal("10"),
source = "CREATE",
at = LocalDateTime.of(2026, 8, 20, 17, 40),
by = "kw093",
)
val mergedOnce = StockQtyRoundSupport.mergeRemark("舊備註", first)
val second = StockQtyRoundSupport.buildTag(
mode = StockQtyRoundSupport.Mode.CUSTOM,
before = BigDecimal("10.50"),
after = BigDecimal("12"),
source = "QC",
at = LocalDateTime.of(2026, 8, 20, 17, 45),
by = "kw093",
)
val mergedTwice = StockQtyRoundSupport.mergeRemark(mergedOnce, second)
assertTrue(mergedTwice.startsWith("[QTY_ROUND mode=CUSTOM"))
assertTrue(mergedTwice.contains("舊備註"))
assertFalse(mergedTwice.contains("mode=FLOOR"))
}

@Test
fun applyIfRequested_writes_custom_tag() {
val request = SaveStockInLineRequest(
itemId = 1,
acceptedQty = BigDecimal("10.50"),
acceptQty = null,
status = null,
expiryDate = null,
stockQtyRoundMode = "CUSTOM",
stockQtyCustomQty = BigDecimal("12"),
stockQtyRoundSource = "QC",
stockQtyCustomReason = "實收12箱",
)
val (qty, remarks) = StockQtyRoundSupport.applyIfRequested(
currentQty = BigDecimal("10.50"),
currentRemarks = null,
request = request,
defaultSource = "QC",
)
assertEquals(0, qty.compareTo(BigDecimal("12")))
assertTrue(remarks!!.contains("mode=CUSTOM"))
assertTrue(remarks.contains("before=10.5"))
assertTrue(remarks.contains("after=12"))
assertTrue(remarks.contains("source=QC"))
assertTrue(remarks.contains("原因:實收12箱"))
}
}

Ladataan…
Peruuta
Tallenna