diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt index 73d783a6..5124191b 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt @@ -21,10 +21,7 @@ interface StockTakeRecordRepository : AbstractRepository fun findAllByLotIdInAndDeletedIsFalse(lotIds: Collection): List - /** - * Open-round busy lot keys without loading full stock_take / stocktakerecord entities. - * Matches records by stockTakeRoundId or stockTakeId against open headers. - */ + /** Busy lot keys for open rounds — caller must pass non-empty roundIds. */ @Query( value = """ SELECT DISTINCT r.lotId AS lotId, r.warehouseId AS warehouseId @@ -32,25 +29,29 @@ interface StockTakeRecordRepository : AbstractRepository WHERE r.deleted = 0 AND r.lotId IS NOT NULL AND r.warehouseId IS NOT NULL - AND ( - EXISTS ( - SELECT 1 FROM stock_take st - WHERE st.deleted = 0 - AND st.status IN ('pending', 'stockTaking', 'approving') - AND r.stockTakeRoundId IS NOT NULL - AND r.stockTakeRoundId = st.stockTakeRoundId - ) - OR EXISTS ( - SELECT 1 FROM stock_take st - WHERE st.deleted = 0 - AND st.status IN ('pending', 'stockTaking', 'approving') - AND r.stockTakeId = st.id - ) - ) + AND r.stockTakeRoundId IN (:roundIds) """, nativeQuery = true, ) - fun findBusyLotWarehouseKeysFromOpenRounds(): List + fun findBusyLotWarehouseKeysByRoundIds( + @Param("roundIds") roundIds: Collection, + ): List + + /** Busy lot keys for open headers — caller must pass non-empty stockTakeIds. */ + @Query( + value = """ + SELECT DISTINCT r.lotId AS lotId, r.warehouseId AS warehouseId + FROM stocktakerecord r + WHERE r.deleted = 0 + AND r.lotId IS NOT NULL + AND r.warehouseId IS NOT NULL + AND r.stockTakeId IN (:stockTakeIds) + """, + nativeQuery = true, + ) + fun findBusyLotWarehouseKeysByStockTakeIds( + @Param("stockTakeIds") stockTakeIds: Collection, + ): List @Query( """ diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index ab9644a3..02a4dea8 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -1339,15 +1339,98 @@ lot_root_origin AS ( .sorted() } - /** 所有 open 輪次中尚未刪除的盤點列 (lotId, warehouseId),供建立時避免重疊。 */ + /** + * 建立區域輪前預覽:所選 section 內已被 open「依貨品」輪佔用的貨品 + *(Option B 會略過這些 lot,本輪不會盤到)。 + */ + open fun previewSectionCreateItemOverlaps(sections: Collection): List { + val wanted = sections.map { it.trim() }.filter { it.isNotBlank() } + .map { it.lowercase() } + .toSet() + if (wanted.isEmpty()) return emptyList() + + val openItemRows = stockTakeRepository.findOpenStockTakeRows().filter { row -> + StockTakeRoundMode.ITEM.equals(row.getStockTakeRoundMode()?.trim(), ignoreCase = true) && + wanted.contains(row.getStockTakeSection()?.trim()?.lowercase()) + } + if (openItemRows.isEmpty()) return emptyList() + + val roundIds = openItemRows.mapNotNull { it.getStockTakeRoundId()?.takeIf { id -> id > 0L } }.distinct() + val headerIds = openItemRows.map { it.getId() }.filter { it > 0L }.distinct() + val headers = buildList { + if (roundIds.isNotEmpty()) { + addAll(stockTakeRepository.findAllByDeletedIsFalseAndStockTakeRoundIdIn(roundIds)) + } + if (headerIds.isNotEmpty()) { + addAll(stockTakeRepository.findAllByDeletedIsFalseAndIdIn(headerIds)) + } + }.distinctBy { it.id } + val roundNameByKey = headers.associate { st -> + val key = st.stockTakeRoundId?.takeIf { it > 0L } ?: st.id!! + key to st.stockTakeRoundName?.trim()?.takeIf { it.isNotBlank() } + } + + val records = buildList { + if (roundIds.isNotEmpty()) { + addAll(stockTakeRecordRepository.findAllByStockTakeRoundIdInAndDeletedIsFalse(roundIds)) + } + if (headerIds.isNotEmpty()) { + addAll(stockTakeRecordRepository.findAllByStockTakeIdInAndDeletedIsFalse(headerIds)) + } + }.distinctBy { it.id } + + val seen = linkedSetOf() + val out = ArrayList() + for (rec in records) { + val section = rec.stockTakeSection?.trim().orEmpty() + if (section.isEmpty() || section.lowercase() !in wanted) continue + val itemId = rec.itemId?.takeIf { it > 0L } ?: continue + val roundKey = rec.stockTakeRoundId?.takeIf { it > 0L } + ?: rec.stockTake?.id + ?: continue + val dedupe = "$itemId|$roundKey|${section.lowercase()}" + if (!seen.add(dedupe)) continue + out.add( + SectionCreateItemOverlap( + itemId = itemId, + itemCode = rec.itemCode, + itemName = rec.itemName, + stockTakeSection = section, + stockTakeRoundId = roundKey, + stockTakeRoundName = roundNameByKey[roundKey], + ) + ) + } + return out.sortedWith( + compareBy({ it.stockTakeSection.orEmpty() }, { it.itemCode.orEmpty() }, { it.stockTakeRoundId }), + ) + } + + /** + * 所有 open 輪次中尚未刪除的盤點列 (lotId, warehouseId),供建立時避免重疊。 + * 無 open header 時直接空集合(不掃 stocktakerecord);有則依 roundId / stockTakeId IN 查。 + */ open fun busyOpenLotWarehouseKeys(): Set> { - return stockTakeRecordRepository.findBusyLotWarehouseKeysFromOpenRounds() - .mapNotNull { row -> - val lotId = row.getLotId().takeIf { it > 0L } ?: return@mapNotNull null - val warehouseId = row.getWarehouseId().takeIf { it > 0L } ?: return@mapNotNull null - lotId to warehouseId + val open = stockTakeRepository.findOpenStockTakeRows() + if (open.isEmpty()) return emptySet() + + val roundIds = open.mapNotNull { it.getStockTakeRoundId()?.takeIf { id -> id > 0L } }.distinct() + val headerIds = open.map { it.getId() }.filter { it > 0L }.distinct() + if (roundIds.isEmpty() && headerIds.isEmpty()) return emptySet() + + val rows = buildList { + if (roundIds.isNotEmpty()) { + addAll(stockTakeRecordRepository.findBusyLotWarehouseKeysByRoundIds(roundIds)) } - .toSet() + if (headerIds.isNotEmpty()) { + addAll(stockTakeRecordRepository.findBusyLotWarehouseKeysByStockTakeIds(headerIds)) + } + } + return rows.mapNotNull { row -> + val lotId = row.getLotId().takeIf { it > 0L } ?: return@mapNotNull null + val warehouseId = row.getWarehouseId().takeIf { it > 0L } ?: return@mapNotNull null + lotId to warehouseId + }.toSet() } /** PP/PF = root PO 前綴;other = 非 PP/PF(與庫存批次現況報告一致) */ @@ -1362,6 +1445,44 @@ lot_root_origin AS ( } } + /** 來源=All 不必溯源;僅 PP/PF/其他才跑 root-PO recursive CTE。 */ + private fun needsCreateLotOriginTrace(lotOrigin: String?): Boolean { + val v = lotOrigin?.trim().orEmpty() + return v.isNotEmpty() && !v.equals("All", ignoreCase = true) + } + + private fun mapCreateLotOptionRows( + rows: List>, + busy: Set>, + ): List { + return rows.mapNotNull { row -> + val lineId = (row["inventoryLotLineId"] as? Number)?.toLong() ?: return@mapNotNull null + val itemId = (row["itemId"] as? Number)?.toLong() ?: return@mapNotNull null + val lotId = (row["lotId"] as? Number)?.toLong() ?: return@mapNotNull null + val warehouseId = (row["warehouseId"] as? Number)?.toLong() ?: return@mapNotNull null + val qty = when (val q = row["availableQty"]) { + is BigDecimal -> q + is Number -> BigDecimal.valueOf(q.toDouble()) + else -> BigDecimal.ZERO + } + StockTakeCreateLotOption( + inventoryLotLineId = lineId, + itemId = itemId, + itemCode = row["itemCode"]?.toString(), + itemName = row["itemName"]?.toString(), + lotId = lotId, + lotNo = row["lotNo"]?.toString(), + expiryDate = row["expiryDate"]?.toString(), + warehouseId = warehouseId, + warehouseCode = row["warehouseCode"]?.toString(), + stockTakeSection = row["stockTakeSection"]?.toString()?.trim(), + storeId = row["storeId"]?.toString(), + availableQty = qty, + busy = (lotId to warehouseId) in busy, + ) + } + } + /** * 搜尋「有可用 lot」的貨品(建立 item 輪左側列表)。 * @param lotOrigin All / PP / PF / other @@ -1374,8 +1495,7 @@ lot_root_origin AS ( ): List { val codeTrim = code?.trim().orEmpty() val nameTrim = name?.trim().orEmpty() - val originTrim = lotOrigin?.trim().orEmpty() - val hasOrigin = originTrim.isNotEmpty() && !originTrim.equals("All", ignoreCase = true) + val hasOrigin = needsCreateLotOriginTrace(lotOrigin) // 來源已選 PP/PF/其他 → 可只依來源列出;來源=全部則仍需編號或名稱 if (codeTrim.isEmpty() && nameTrim.isEmpty() && !hasOrigin) return emptyList() @@ -1388,12 +1508,33 @@ lot_root_origin AS ( args["itemName"] = "%$nameTrim%" "AND it.name LIKE :itemName" } else "" - val seedFilter = "$codeSql\n $nameSql" - val originSql = buildCreateLotOriginFilterSql(lotOrigin) - val cte = CREATE_LOT_ROOT_ORIGIN_CTE.replace("__SEED_ITEM_FILTER__", seedFilter) args["rowLimit"] = limit.coerceIn(1, 2000) - val sql = """ + val sql = if (!hasOrigin) { + // Fast path:不溯源,只查有可用庫存的貨品 + """ +SELECT DISTINCT + it.id AS id, + it.code AS code, + it.name AS name +FROM inventory_lot_line ill +INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 +INNER JOIN items it ON it.id = il.itemId AND it.deleted = 0 +INNER JOIN warehouse wh ON wh.id = ill.warehouseId AND wh.deleted = 0 +WHERE ill.deleted = 0 + AND LOWER(COALESCE(ill.status, '')) = 'available' + AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) > 0 + AND TRIM(COALESCE(wh.stockTakeSection, '')) <> '' + $codeSql + $nameSql +ORDER BY it.code +LIMIT :rowLimit +""".trimIndent() + } else { + val seedFilter = "$codeSql\n $nameSql" + val originSql = buildCreateLotOriginFilterSql(lotOrigin) + val cte = CREATE_LOT_ROOT_ORIGIN_CTE.replace("__SEED_ITEM_FILTER__", seedFilter) + """ WITH RECURSIVE $cte SELECT DISTINCT @@ -1411,6 +1552,7 @@ WHERE 1=1 ORDER BY it.code LIMIT :rowLimit """.trimIndent() + } return jdbcDao.queryForList(sql, args).mapNotNull { row -> val id = (row["id"] as? Number)?.toLong() ?: return@mapNotNull null @@ -1440,11 +1582,39 @@ LIMIT :rowLimit args[key] = id ":$key" }.joinToString(", ") - val seedFilter = "AND it.id IN ($inClause)" - val originSql = buildCreateLotOriginFilterSql(lotOrigin) - val cte = CREATE_LOT_ROOT_ORIGIN_CTE.replace("__SEED_ITEM_FILTER__", seedFilter) + val hasOrigin = needsCreateLotOriginTrace(lotOrigin) - val sql = """ + val sql = if (!hasOrigin) { + """ +SELECT + ill.id AS inventoryLotLineId, + it.id AS itemId, + it.code AS itemCode, + it.name AS itemName, + il.id AS lotId, + il.lotNo AS lotNo, + DATE_FORMAT(il.expiryDate, '%Y-%m-%d') AS expiryDate, + wh.id AS warehouseId, + wh.code AS warehouseCode, + wh.stockTakeSection AS stockTakeSection, + wh.store_id AS storeId, + (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS availableQty +FROM inventory_lot_line ill +INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 +INNER JOIN items it ON it.id = il.itemId AND it.deleted = 0 +INNER JOIN warehouse wh ON wh.id = ill.warehouseId AND wh.deleted = 0 +WHERE ill.deleted = 0 + AND LOWER(COALESCE(ill.status, '')) = 'available' + AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) > 0 + AND TRIM(COALESCE(wh.stockTakeSection, '')) <> '' + AND it.id IN ($inClause) +ORDER BY it.code, wh.stockTakeSection, il.lotNo +""".trimIndent() + } else { + val seedFilter = "AND it.id IN ($inClause)" + val originSql = buildCreateLotOriginFilterSql(lotOrigin) + val cte = CREATE_LOT_ROOT_ORIGIN_CTE.replace("__SEED_ITEM_FILTER__", seedFilter) + """ WITH RECURSIVE $cte SELECT @@ -1476,33 +1646,9 @@ WHERE ill.deleted = 0 $originSql ORDER BY it.code, wh.stockTakeSection, il.lotNo """.trimIndent() - - return jdbcDao.queryForList(sql, args).mapNotNull { row -> - val lineId = (row["inventoryLotLineId"] as? Number)?.toLong() ?: return@mapNotNull null - val itemId = (row["itemId"] as? Number)?.toLong() ?: return@mapNotNull null - val lotId = (row["lotId"] as? Number)?.toLong() ?: return@mapNotNull null - val warehouseId = (row["warehouseId"] as? Number)?.toLong() ?: return@mapNotNull null - val qty = when (val q = row["availableQty"]) { - is BigDecimal -> q - is Number -> BigDecimal.valueOf(q.toDouble()) - else -> BigDecimal.ZERO - } - StockTakeCreateLotOption( - inventoryLotLineId = lineId, - itemId = itemId, - itemCode = row["itemCode"]?.toString(), - itemName = row["itemName"]?.toString(), - lotId = lotId, - lotNo = row["lotNo"]?.toString(), - expiryDate = row["expiryDate"]?.toString(), - warehouseId = warehouseId, - warehouseCode = row["warehouseCode"]?.toString(), - stockTakeSection = row["stockTakeSection"]?.toString()?.trim(), - storeId = row["storeId"]?.toString(), - availableQty = qty, - busy = (lotId to warehouseId) in busy, - ) } + + return mapCreateLotOptionRows(jdbcDao.queryForList(sql, args), busy) } /** diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt index bce65f5c..a8a6ed6e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt @@ -202,6 +202,17 @@ class StockTakeRecordController( return stockOutRecordService.busyStockTakeSections() } + /** + * 建立區域輪預覽:所選區域與 open「依貨品」輪重疊的貨品 + *(本輪會略過這些 lot,不會盤到)。 + */ + @GetMapping("/sectionCreateItemOverlaps") + fun sectionCreateItemOverlaps( + @RequestParam sections: List?, + ): List { + return stockOutRecordService.previewSectionCreateItemOverlaps(sections.orEmpty()) + } + /** 建立 item 輪:搜尋有可用 lot 的貨品(可依來源 PP/PF/其他) */ @GetMapping("/searchItemsForCreate") fun searchItemsForCreate( diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt index 86c9634f..9943fe69 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt @@ -61,6 +61,19 @@ data class StockTakeCreateItemOption( val name: String?, ) +/** + * 建立區域輪預覽:所選區域內已有 open「依貨品」輪佔用的貨品 + *(本輪 placeholder 會略過對應 lot)。 + */ +data class SectionCreateItemOverlap( + val itemId: Long, + val itemCode: String?, + val itemName: String?, + val stockTakeSection: String?, + val stockTakeRoundId: Long, + val stockTakeRoundName: String?, +) + data class AllPickedStockTakeListReponse( val id: Long, val stockTakeSession: String,