| @@ -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 | |||
| @@ -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 | |||
| } | |||
| } | |||
| @@ -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()) | |||
| } | |||
| @@ -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, | |||
| ), | |||
| ) | |||
| } | |||
| @@ -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 | |||
| @@ -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", | |||
| ) | |||
| } | |||
| @@ -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?; | |||
| @@ -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 | |||
| @@ -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 | |||
| } | |||
| @@ -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> | |||
| } | |||
| @@ -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() | |||
| @@ -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 | |||
| } | |||
| } | |||
| @@ -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() | |||
| } | |||
| } | |||
| @@ -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>, | |||
| @@ -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() | |||
| } | |||
| @@ -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, | |||
| ) | |||
| @@ -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() | |||
| @@ -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" } | |||
| } | |||
| } | |||
| @@ -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, | |||
| @@ -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) | |||
| @@ -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 ( | |||
| @@ -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`) | |||
| ); | |||
| @@ -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`; | |||
| @@ -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箱")) | |||
| } | |||
| } | |||