WorkbenchTicketReleaseTable 加 user filter DO release / FloorLanePanel:Truck X 票 + 搜尋依樓層production
| @@ -706,8 +706,11 @@ class DoReleaseCoordinatorService( | |||||
| val existing = candidates.firstOrNull { c -> | val existing = candidates.firstOrNull { c -> | ||||
| c.truckId == first.truckId && | c.truckId == first.truckId && | ||||
| c.truckDepartureTime == first.truckDepartureTime && | c.truckDepartureTime == first.truckDepartureTime && | ||||
| c.truckLanceCode == first.truckLanceCode | |||||
| } ?: candidates.minByOrNull { it.id ?: Long.MAX_VALUE } | |||||
| c.truckLanceCode == first.truckLanceCode && | |||||
| (storeId != null || doPickOrderMatchesTruckXPreferredFloor(c, first.preferredFloor)) | |||||
| } ?: candidates | |||||
| .filter { storeId != null || doPickOrderMatchesTruckXPreferredFloor(it, first.preferredFloor) } | |||||
| .minByOrNull { it.id ?: Long.MAX_VALUE } | |||||
| if (existing != null) { | if (existing != null) { | ||||
| println( | println( | ||||
| " DEBUG: Merging batch into existing DoPickOrder id=${existing.id}, ticket=${existing.ticketNo} " + | " DEBUG: Merging batch into existing DoPickOrder id=${existing.id}, ticket=${existing.ticketNo} " + | ||||
| @@ -754,6 +757,33 @@ class DoReleaseCoordinatorService( | |||||
| println(" DEBUG: Created ${results.size} DoPickOrderLine records") | println(" DEBUG: Created ${results.size} DoPickOrderLine records") | ||||
| } | } | ||||
| /** | |||||
| * Truck X (`storeId` null): only merge when every linked DO supplier preferred floor | |||||
| * matches [preferredFloor] (4F list wins; else 2F). Empty tickets are allowed. | |||||
| */ | |||||
| private fun doPickOrderMatchesTruckXPreferredFloor( | |||||
| dpo: DoPickOrder, | |||||
| preferredFloor: String?, | |||||
| ): Boolean { | |||||
| val target = when (preferredFloor?.trim()?.uppercase(java.util.Locale.ROOT)?.replace("/", "")) { | |||||
| "4F" -> "4F" | |||||
| else -> "2F" | |||||
| } | |||||
| val id = dpo.id ?: return true | |||||
| val lines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(id) | |||||
| if (lines.isEmpty()) return true | |||||
| val (_, s4f) = doFloorSupplierSettingsService.loadDoFloorSupplierLists() | |||||
| fun floorOf(supplierCode: String?): String = | |||||
| if (!supplierCode.isNullOrBlank() && s4f.contains(supplierCode.trim())) "4F" else "2F" | |||||
| for (line in lines) { | |||||
| val doId = line.doOrderId ?: continue | |||||
| val dorder = deliveryOrderRepository.findById(doId).orElse(null) ?: continue | |||||
| if (floorOf(dorder.supplier?.code) != target) return false | |||||
| } | |||||
| return true | |||||
| } | |||||
| private fun checkPickOrderHasStockIssues(doPickOrderId: Long): Boolean { | private fun checkPickOrderHasStockIssues(doPickOrderId: Long): Boolean { | ||||
| try { | try { | ||||
| // 1. 获取 do_pick_order 的所有 pick orders 数量 | // 1. 获取 do_pick_order 的所有 pick orders 数量 | ||||
| @@ -858,7 +888,8 @@ class DoReleaseCoordinatorService( | |||||
| .firstOrNull { | .firstOrNull { | ||||
| it.requiredDeliveryDate == result.estimatedArrivalDate && | it.requiredDeliveryDate == result.estimatedArrivalDate && | ||||
| it.truckDepartureTime == result.truckDepartureTime && | it.truckDepartureTime == result.truckDepartureTime && | ||||
| it.truckLanceCode == result.truckLanceCode | |||||
| it.truckLanceCode == result.truckLanceCode && | |||||
| (storeId != null || doPickOrderMatchesTruckXPreferredFloor(it, result.preferredFloor)) | |||||
| } | } | ||||
| if (existingDoPickOrder != null) { | if (existingDoPickOrder != null) { | ||||
| @@ -134,6 +134,7 @@ open class DoWorkbenchMainService( | |||||
| private val itemsRepository: ItemsRepository, | private val itemsRepository: ItemsRepository, | ||||
| private val transactionManager: PlatformTransactionManager, | private val transactionManager: PlatformTransactionManager, | ||||
| private val doReplenishmentService: DoReplenishmentService, | private val doReplenishmentService: DoReplenishmentService, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||||
| ) { | ) { | ||||
| @PersistenceContext | @PersistenceContext | ||||
| private lateinit var entityManager: EntityManager | private lateinit var entityManager: EntityManager | ||||
| @@ -850,6 +851,9 @@ return MessageResponse( | |||||
| ) | ) | ||||
| } | } | ||||
| // Truck X keeps storeId null; split count by supplier preferred floor (same as DO Search tabs). | |||||
| val truckXFloorKey = actualStoreId.replace("/", "").trim().uppercase(java.util.Locale.ROOT) | |||||
| val truckXFloorClause = truckXPreferredFloorExistsSql(truckXFloorKey) | |||||
| val defaultCountSql = """ | val defaultCountSql = """ | ||||
| SELECT COUNT(DISTINCT dop.id) AS c | SELECT COUNT(DISTINCT dop.id) AS c | ||||
| FROM fpsmsdb.delivery_order_pick_order dop | FROM fpsmsdb.delivery_order_pick_order dop | ||||
| @@ -862,6 +866,7 @@ return MessageResponse( | |||||
| SELECT 1 FROM fpsmsdb.pick_order po | SELECT 1 FROM fpsmsdb.pick_order po | ||||
| WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 | WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 | ||||
| ) | ) | ||||
| $truckXFloorClause | |||||
| $releaseFilterClause | $releaseFilterClause | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -1033,13 +1038,22 @@ return MessageResponse( | |||||
| storeId: String?, | storeId: String?, | ||||
| truck: String?, | truck: String?, | ||||
| releaseTypeFilter: String? = null, | releaseTypeFilter: String? = null, | ||||
| floor: String? = null, | |||||
| ): List<ReleasedDoPickOrderListItem> = | ): List<ReleasedDoPickOrderListItem> = | ||||
| queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true, releaseTypeFilter = releaseTypeFilter) | |||||
| queryWorkbenchReleasedDopoList( | |||||
| shopName, | |||||
| storeId, | |||||
| truck, | |||||
| beforeToday = true, | |||||
| releaseTypeFilter = releaseTypeFilter, | |||||
| floor = floor, | |||||
| ) | |||||
| /** | /** | ||||
| * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). | * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today). | ||||
| * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). | * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day). | ||||
| * @param releaseTypeFilter when `isExtra` (case-insensitive), only `delivery_order_pick_order.releaseType = isExtra` rows. | * @param releaseTypeFilter when `isExtra` (case-insensitive), only `delivery_order_pick_order.releaseType = isExtra` rows. | ||||
| * @param floor optional `2F`/`4F`: for Truck X (null storeId) filter by DO supplier preferred floor. | |||||
| */ | */ | ||||
| open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | ||||
| shopName: String?, | shopName: String?, | ||||
| @@ -1047,6 +1061,7 @@ return MessageResponse( | |||||
| truck: String?, | truck: String?, | ||||
| requiredDeliveryDate: LocalDate? = null, | requiredDeliveryDate: LocalDate? = null, | ||||
| releaseTypeFilter: String? = null, | releaseTypeFilter: String? = null, | ||||
| floor: String? = null, | |||||
| ): List<ReleasedDoPickOrderListItem> = | ): List<ReleasedDoPickOrderListItem> = | ||||
| queryWorkbenchReleasedDopoList( | queryWorkbenchReleasedDopoList( | ||||
| shopName, | shopName, | ||||
| @@ -1055,6 +1070,7 @@ return MessageResponse( | |||||
| beforeToday = false, | beforeToday = false, | ||||
| equalsDeliveryDate = requiredDeliveryDate, | equalsDeliveryDate = requiredDeliveryDate, | ||||
| releaseTypeFilter = releaseTypeFilter, | releaseTypeFilter = releaseTypeFilter, | ||||
| floor = floor, | |||||
| ) | ) | ||||
| /** | /** | ||||
| @@ -1648,6 +1664,39 @@ return MessageResponse( | |||||
| ) | ) | ||||
| } | } | ||||
| /** | |||||
| * EXISTS clause: Truck X ticket has at least one DO whose supplier preferred floor matches [floorKey] | |||||
| * (`2F` / `4F`), and **no** linked DO on the other floor. | |||||
| * Aligns with [DoFloorSupplierSettingsService.preferredStoreFloorForSupplier] | |||||
| * (4F list wins; otherwise 2F). Empty string = no extra filter. | |||||
| */ | |||||
| private fun truckXPreferredFloorExistsSql(floorKey: String?): String { | |||||
| val key = floorKey?.trim()?.uppercase(java.util.Locale.ROOT)?.replace("/", "").orEmpty() | |||||
| if (key != "2F" && key != "4F") return "" | |||||
| val (_, s4f) = doFloorSupplierSettingsService.loadDoFloorSupplierLists() | |||||
| val in4 = s4f.joinToString(", ") { "'" + it.replace("'", "''") + "'" } | |||||
| .ifEmpty { "'__NO_4F_SUPPLIER__'" } | |||||
| val floorExpr = "CASE WHEN s.code IN ($in4) THEN '4F' ELSE '2F' END" | |||||
| return """ | |||||
| AND EXISTS ( | |||||
| SELECT 1 | |||||
| FROM fpsmsdb.pick_order po | |||||
| INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 | |||||
| LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 | |||||
| WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 | |||||
| AND ($floorExpr) = '$key' | |||||
| ) | |||||
| AND NOT EXISTS ( | |||||
| SELECT 1 | |||||
| FROM fpsmsdb.pick_order po2 | |||||
| INNER JOIN fpsmsdb.delivery_order d2 ON d2.id = po2.doId AND d2.deleted = 0 | |||||
| LEFT JOIN fpsmsdb.shop s2 ON s2.id = d2.supplierId AND s2.deleted = 0 | |||||
| WHERE po2.deliveryOrderPickOrderId = dop.id AND po2.deleted = 0 | |||||
| AND (CASE WHEN s2.code IN ($in4) THEN '4F' ELSE '2F' END) <> '$key' | |||||
| ) | |||||
| """.trimIndent() | |||||
| } | |||||
| private fun queryWorkbenchReleasedDopoList( | private fun queryWorkbenchReleasedDopoList( | ||||
| shopName: String?, | shopName: String?, | ||||
| storeId: String?, | storeId: String?, | ||||
| @@ -1655,6 +1704,7 @@ return MessageResponse( | |||||
| beforeToday: Boolean, | beforeToday: Boolean, | ||||
| equalsDeliveryDate: LocalDate? = null, | equalsDeliveryDate: LocalDate? = null, | ||||
| releaseTypeFilter: String? = null, | releaseTypeFilter: String? = null, | ||||
| floor: String? = null, | |||||
| ): List<ReleasedDoPickOrderListItem> { | ): List<ReleasedDoPickOrderListItem> { | ||||
| val today = LocalDate.now() | val today = LocalDate.now() | ||||
| val params = mutableMapOf<String, Any>() | val params = mutableMapOf<String, Any>() | ||||
| @@ -1709,6 +1759,11 @@ return MessageResponse( | |||||
| if (rtNorm == "isExtra") { | if (rtNorm == "isExtra") { | ||||
| sqlBuilder.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ") | sqlBuilder.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ") | ||||
| } | } | ||||
| // Optional floor: used for Truck X (null storeId) display split; does not write storeId. | |||||
| val floorClause = truckXPreferredFloorExistsSql(floor) | |||||
| if (floorClause.isNotBlank()) { | |||||
| sqlBuilder.append(" ").append(floorClause).append(" ") | |||||
| } | |||||
| sqlBuilder.append(" ORDER BY dop.requiredDeliveryDate, dop.truckDepartureTime, dop.truckLanceCode, dop.id ") | sqlBuilder.append(" ORDER BY dop.requiredDeliveryDate, dop.truckDepartureTime, dop.truckLanceCode, dop.id ") | ||||
| val rows: List<Map<String, Any?>> = try { | val rows: List<Map<String, Any?>> = try { | ||||
| jdbcDao.queryForList(sqlBuilder.toString(), params) | jdbcDao.queryForList(sqlBuilder.toString(), params) | ||||
| @@ -94,6 +94,7 @@ open class DoWorkbenchReleaseService( | |||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | ||||
| private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, | private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||||
| ) { | ) { | ||||
| private val poolSize = Runtime.getRuntime().availableProcessors() | private val poolSize = Runtime.getRuntime().availableProcessors() | ||||
| private val executor = Executors.newFixedThreadPool(kotlin.math.min(poolSize, 4)) | private val executor = Executors.newFixedThreadPool(kotlin.math.min(poolSize, 4)) | ||||
| @@ -523,7 +524,9 @@ open class DoWorkbenchReleaseService( | |||||
| params: MutableMap<String, Any>, | params: MutableMap<String, Any>, | ||||
| ) { | ) { | ||||
| if (storeId.isNullOrBlank()) { | if (storeId.isNullOrBlank()) { | ||||
| // Truck X: storeId stays null, but do not merge across supplier preferred floors (2F vs 4F). | |||||
| sql.append(" AND (storeId IS NULL OR TRIM(COALESCE(storeId, '')) = '') ") | sql.append(" AND (storeId IS NULL OR TRIM(COALESCE(storeId, '')) = '') ") | ||||
| appendTruckXPreferredFloorMatchClauses(sql, first.preferredFloor) | |||||
| } else { | } else { | ||||
| sql.append(" AND storeId = :storeId ") | sql.append(" AND storeId = :storeId ") | ||||
| params["storeId"] = storeId | params["storeId"] = storeId | ||||
| @@ -552,6 +555,59 @@ open class DoWorkbenchReleaseService( | |||||
| } | } | ||||
| } | } | ||||
| /** | |||||
| * For Truck X headers (`storeId` null): only merge into a ticket whose linked DO suppliers | |||||
| * all resolve to the same preferred floor as [preferredFloor] (4F list wins; else 2F), | |||||
| * matching normal lane separation by floor / [resolveStoreId]. | |||||
| */ | |||||
| private fun appendTruckXPreferredFloorMatchClauses( | |||||
| sql: StringBuilder, | |||||
| preferredFloor: String?, | |||||
| ) { | |||||
| val floor = when (preferredFloor?.trim()?.uppercase(java.util.Locale.ROOT)?.replace("/", "")) { | |||||
| "4F" -> "4F" | |||||
| else -> "2F" | |||||
| } | |||||
| val (_, s4f) = doFloorSupplierSettingsService.loadDoFloorSupplierLists() | |||||
| val in4 = s4f.joinToString(", ") { "'" + it.replace("'", "''") + "'" } | |||||
| .ifEmpty { "'__NO_4F_SUPPLIER__'" } | |||||
| val floorExpr = "CASE WHEN s.code IN ($in4) THEN '4F' ELSE '2F' END" | |||||
| // Reject tickets that already have any DO on a different floor. | |||||
| sql.append( | |||||
| """ | |||||
| AND NOT EXISTS ( | |||||
| SELECT 1 | |||||
| FROM fpsmsdb.pick_order po | |||||
| INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 | |||||
| LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 | |||||
| WHERE po.deliveryOrderPickOrderId = delivery_order_pick_order.id | |||||
| AND po.deleted = 0 | |||||
| AND ($floorExpr) <> '$floor' | |||||
| ) | |||||
| """.trimIndent() | |||||
| ) | |||||
| // Prefer an existing same-floor ticket; allow empty header (no pick_order yet). | |||||
| sql.append( | |||||
| """ | |||||
| AND ( | |||||
| NOT EXISTS ( | |||||
| SELECT 1 FROM fpsmsdb.pick_order po0 | |||||
| WHERE po0.deliveryOrderPickOrderId = delivery_order_pick_order.id AND po0.deleted = 0 | |||||
| ) | |||||
| OR EXISTS ( | |||||
| SELECT 1 | |||||
| FROM fpsmsdb.pick_order po | |||||
| INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 | |||||
| LEFT JOIN fpsmsdb.shop s ON s.id = d.supplierId AND s.deleted = 0 | |||||
| WHERE po.deliveryOrderPickOrderId = delivery_order_pick_order.id | |||||
| AND po.deleted = 0 | |||||
| AND ($floorExpr) = '$floor' | |||||
| ) | |||||
| ) | |||||
| """.trimIndent() | |||||
| ) | |||||
| } | |||||
| private fun mapRowToMergeableTicket(row: Map<String, Any?>): MergeableWorkbenchTicket? { | private fun mapRowToMergeableTicket(row: Map<String, Any?>): MergeableWorkbenchTicket? { | ||||
| fun cell(name: String): String? { | fun cell(name: String): String? { | ||||
| val key = row.keys.find { it.equals(name, true) } ?: return null | val key = row.keys.find { it.equals(name, true) } ?: return null | ||||
| @@ -115,12 +115,15 @@ class DoWorkbenchController( | |||||
| @RequestParam(required = false) storeId: String?, | @RequestParam(required = false) storeId: String?, | ||||
| @RequestParam(required = false) truck: String?, | @RequestParam(required = false) truck: String?, | ||||
| @RequestParam(required = false) releaseType: String?, | @RequestParam(required = false) releaseType: String?, | ||||
| /** Optional `2F`/`4F`: Truck X list split by DO supplier preferred floor (storeId stays null). */ | |||||
| @RequestParam(required = false) floor: String?, | |||||
| ): List<ReleasedDoPickOrderListItem> { | ): List<ReleasedDoPickOrderListItem> { | ||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( | return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( | ||||
| shopName, | shopName, | ||||
| storeId, | storeId, | ||||
| truck, | truck, | ||||
| releaseTypeFilter = releaseType, | releaseTypeFilter = releaseType, | ||||
| floor = floor, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -131,6 +134,8 @@ class DoWorkbenchController( | |||||
| @RequestParam(required = false) truck: String?, | @RequestParam(required = false) truck: String?, | ||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, | @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, | ||||
| @RequestParam(required = false) releaseType: String?, | @RequestParam(required = false) releaseType: String?, | ||||
| /** Optional `2F`/`4F`: Truck X list split by DO supplier preferred floor (storeId stays null). */ | |||||
| @RequestParam(required = false) floor: String?, | |||||
| ): List<ReleasedDoPickOrderListItem> { | ): List<ReleasedDoPickOrderListItem> { | ||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | ||||
| shopName, | shopName, | ||||
| @@ -138,6 +143,7 @@ class DoWorkbenchController( | |||||
| truck, | truck, | ||||
| requiredDeliveryDate = requiredDate, | requiredDeliveryDate = requiredDate, | ||||
| releaseTypeFilter = releaseType, | releaseTypeFilter = releaseType, | ||||
| floor = floor, | |||||
| ) | ) | ||||
| } | } | ||||