WorkbenchTicketReleaseTable 加 user filter DO release / FloorLanePanel:Truck X 票 + 搜尋依樓層production
| @@ -706,8 +706,11 @@ class DoReleaseCoordinatorService( | |||
| val existing = candidates.firstOrNull { c -> | |||
| c.truckId == first.truckId && | |||
| 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) { | |||
| println( | |||
| " 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") | |||
| } | |||
| /** | |||
| * 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 { | |||
| try { | |||
| // 1. 获取 do_pick_order 的所有 pick orders 数量 | |||
| @@ -858,7 +888,8 @@ class DoReleaseCoordinatorService( | |||
| .firstOrNull { | |||
| it.requiredDeliveryDate == result.estimatedArrivalDate && | |||
| it.truckDepartureTime == result.truckDepartureTime && | |||
| it.truckLanceCode == result.truckLanceCode | |||
| it.truckLanceCode == result.truckLanceCode && | |||
| (storeId != null || doPickOrderMatchesTruckXPreferredFloor(it, result.preferredFloor)) | |||
| } | |||
| if (existingDoPickOrder != null) { | |||
| @@ -134,6 +134,7 @@ open class DoWorkbenchMainService( | |||
| private val itemsRepository: ItemsRepository, | |||
| private val transactionManager: PlatformTransactionManager, | |||
| private val doReplenishmentService: DoReplenishmentService, | |||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||
| ) { | |||
| @PersistenceContext | |||
| 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 = """ | |||
| SELECT COUNT(DISTINCT dop.id) AS c | |||
| FROM fpsmsdb.delivery_order_pick_order dop | |||
| @@ -862,6 +866,7 @@ return MessageResponse( | |||
| SELECT 1 FROM fpsmsdb.pick_order po | |||
| WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 | |||
| ) | |||
| $truckXFloorClause | |||
| $releaseFilterClause | |||
| """.trimIndent() | |||
| @@ -1033,13 +1038,22 @@ return MessageResponse( | |||
| storeId: String?, | |||
| truck: String?, | |||
| releaseTypeFilter: String? = null, | |||
| floor: String? = null, | |||
| ): 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). | |||
| * 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 floor optional `2F`/`4F`: for Truck X (null storeId) filter by DO supplier preferred floor. | |||
| */ | |||
| open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | |||
| shopName: String?, | |||
| @@ -1047,6 +1061,7 @@ return MessageResponse( | |||
| truck: String?, | |||
| requiredDeliveryDate: LocalDate? = null, | |||
| releaseTypeFilter: String? = null, | |||
| floor: String? = null, | |||
| ): List<ReleasedDoPickOrderListItem> = | |||
| queryWorkbenchReleasedDopoList( | |||
| shopName, | |||
| @@ -1055,6 +1070,7 @@ return MessageResponse( | |||
| beforeToday = false, | |||
| equalsDeliveryDate = requiredDeliveryDate, | |||
| 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( | |||
| shopName: String?, | |||
| storeId: String?, | |||
| @@ -1655,6 +1704,7 @@ return MessageResponse( | |||
| beforeToday: Boolean, | |||
| equalsDeliveryDate: LocalDate? = null, | |||
| releaseTypeFilter: String? = null, | |||
| floor: String? = null, | |||
| ): List<ReleasedDoPickOrderListItem> { | |||
| val today = LocalDate.now() | |||
| val params = mutableMapOf<String, Any>() | |||
| @@ -1709,6 +1759,11 @@ return MessageResponse( | |||
| if (rtNorm == "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 ") | |||
| val rows: List<Map<String, Any?>> = try { | |||
| jdbcDao.queryForList(sqlBuilder.toString(), params) | |||
| @@ -94,6 +94,7 @@ open class DoWorkbenchReleaseService( | |||
| private val jdbcDao: JdbcDao, | |||
| private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | |||
| private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, | |||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||
| ) { | |||
| private val poolSize = Runtime.getRuntime().availableProcessors() | |||
| private val executor = Executors.newFixedThreadPool(kotlin.math.min(poolSize, 4)) | |||
| @@ -523,7 +524,9 @@ open class DoWorkbenchReleaseService( | |||
| params: MutableMap<String, Any>, | |||
| ) { | |||
| 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, '')) = '') ") | |||
| appendTruckXPreferredFloorMatchClauses(sql, first.preferredFloor) | |||
| } else { | |||
| sql.append(" AND 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? { | |||
| fun cell(name: String): String? { | |||
| 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) truck: 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> { | |||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( | |||
| shopName, | |||
| storeId, | |||
| truck, | |||
| releaseTypeFilter = releaseType, | |||
| floor = floor, | |||
| ) | |||
| } | |||
| @@ -131,6 +134,8 @@ class DoWorkbenchController( | |||
| @RequestParam(required = false) truck: String?, | |||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, | |||
| @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> { | |||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | |||
| shopName, | |||
| @@ -138,6 +143,7 @@ class DoWorkbenchController( | |||
| truck, | |||
| requiredDeliveryDate = requiredDate, | |||
| releaseTypeFilter = releaseType, | |||
| floor = floor, | |||
| ) | |||
| } | |||