|
|
|
@@ -47,9 +47,11 @@ import com.ffii.fpsms.modules.stock.entity.StockLedger |
|
|
|
import com.ffii.fpsms.modules.master.entity.UomConversionRepository |
|
|
|
import java.time.LocalDate |
|
|
|
import com.ffii.fpsms.modules.stock.entity.InventoryLotLine |
|
|
|
import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus |
|
|
|
import java.math.RoundingMode |
|
|
|
import com.ffii.fpsms.modules.stock.entity.StockTake |
|
|
|
import com.ffii.fpsms.modules.master.entity.ItemsRepository |
|
|
|
import com.ffii.core.support.JdbcDao |
|
|
|
@Service |
|
|
|
open class StockTakeRecordService( |
|
|
|
val stockTakeRepository: StockTakeRepository, |
|
|
|
@@ -69,8 +71,65 @@ open class StockTakeRecordService( |
|
|
|
val uomConversionRepository: UomConversionRepository, |
|
|
|
val itemsRepository: ItemsRepository, |
|
|
|
@Lazy private val stockInLineService: StockInLineService, |
|
|
|
|
|
|
|
private val jdbcDao: JdbcDao, |
|
|
|
) { |
|
|
|
companion object { |
|
|
|
/** 與庫存批次現況報告相同:TRF 往回找 root PO */ |
|
|
|
private const val CREATE_LOT_ROOT_ORIGIN_CTE = """ |
|
|
|
seed_lots AS ( |
|
|
|
SELECT DISTINCT |
|
|
|
il.id AS lotId, |
|
|
|
il.stockInLineId AS silId |
|
|
|
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, '')) <> '' |
|
|
|
__SEED_ITEM_FILTER__ |
|
|
|
), |
|
|
|
lot_trace AS ( |
|
|
|
SELECT |
|
|
|
sl.lotId AS lotId, |
|
|
|
sl.silId AS silId, |
|
|
|
0 AS depth |
|
|
|
FROM seed_lots sl |
|
|
|
WHERE sl.silId IS NOT NULL |
|
|
|
UNION ALL |
|
|
|
SELECT |
|
|
|
lt.lotId AS lotId, |
|
|
|
il_src.stockInLineId AS silId, |
|
|
|
lt.depth + 1 AS depth |
|
|
|
FROM lot_trace lt |
|
|
|
INNER JOIN stock_in_line sil |
|
|
|
ON sil.id = lt.silId AND sil.deleted = 0 |
|
|
|
INNER JOIN stock_transfer_record tr |
|
|
|
ON tr.id = sil.stockTransferId AND tr.deleted = 0 |
|
|
|
INNER JOIN stock_out_line sol |
|
|
|
ON sol.id = tr.stockOutLineId AND sol.deleted = 0 |
|
|
|
INNER JOIN inventory_lot_line ill_src |
|
|
|
ON ill_src.id = sol.inventoryLotLineId AND ill_src.deleted = 0 |
|
|
|
INNER JOIN inventory_lot il_src |
|
|
|
ON il_src.id = ill_src.inventoryLotId AND il_src.deleted = 0 |
|
|
|
WHERE lt.depth < 8 |
|
|
|
AND il_src.stockInLineId IS NOT NULL |
|
|
|
AND ( |
|
|
|
UPPER(TRIM(COALESCE(sil.type, ''))) = 'TRF' |
|
|
|
OR sil.stockTransferId IS NOT NULL |
|
|
|
) |
|
|
|
), |
|
|
|
lot_root_origin AS ( |
|
|
|
SELECT lotId, silId AS rootSilId |
|
|
|
FROM ( |
|
|
|
SELECT lotId, silId, |
|
|
|
ROW_NUMBER() OVER (PARTITION BY lotId ORDER BY depth DESC) AS rn |
|
|
|
FROM lot_trace |
|
|
|
) t |
|
|
|
WHERE t.rn = 1 |
|
|
|
)""" |
|
|
|
} |
|
|
|
private val logger: Logger = LoggerFactory.getLogger(StockTakeRecordService::class.java) |
|
|
|
|
|
|
|
/** 建立盤點輪次時預建 stock_take_record 用的佔位盤點員(取任一有效 user id)。 */ |
|
|
|
@@ -97,6 +156,7 @@ open class StockTakeRecordService( |
|
|
|
val roundId = stockTake.stockTakeRoundId ?: stockTake.id |
|
|
|
?: throw IllegalStateException("stockTake id is null") |
|
|
|
|
|
|
|
val busyLotKeys = busyOpenLotWarehouseKeys() |
|
|
|
val inventoryLotLines = |
|
|
|
inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalseAndStatusIsAvailable(warehouseIds) |
|
|
|
.filter { ill -> |
|
|
|
@@ -110,6 +170,12 @@ open class StockTakeRecordService( |
|
|
|
val inventoryLot = ill.inventoryLot ?: continue |
|
|
|
val warehouse = ill.warehouse ?: continue |
|
|
|
val item = inventoryLot.item ?: continue |
|
|
|
val lotId = inventoryLot.id ?: continue |
|
|
|
val warehouseId = warehouse.id ?: continue |
|
|
|
// Option B:區域輪可開,但略過已在其他 open 輪次的 lot |
|
|
|
if ((lotId to warehouseId) in busyLotKeys) { |
|
|
|
continue |
|
|
|
} |
|
|
|
val availableQty = (ill.inQty ?: BigDecimal.ZERO) |
|
|
|
.subtract(ill.outQty ?: BigDecimal.ZERO) |
|
|
|
//.subtract(ill.holdQty ?: BigDecimal.ZERO) |
|
|
|
@@ -161,6 +227,80 @@ open class StockTakeRecordService( |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 依明確的 inventory_lot_line id 預建盤點列(item 輪勾選 lot);仍略過 busy/qty≤0。 |
|
|
|
*/ |
|
|
|
open fun createPlaceholderStockTakeRecordsForLotLines( |
|
|
|
stockTake: StockTake, |
|
|
|
placeholderStockTakerId: Long, |
|
|
|
inventoryLotLineIds: Collection<Long>, |
|
|
|
) { |
|
|
|
val section = stockTake.stockTakeSection ?: return |
|
|
|
val wanted = inventoryLotLineIds.filter { it > 0L }.distinct() |
|
|
|
if (wanted.isEmpty()) return |
|
|
|
val roundId = stockTake.stockTakeRoundId ?: stockTake.id |
|
|
|
?: throw IllegalStateException("stockTake id is null") |
|
|
|
val busyLotKeys = busyOpenLotWarehouseKeys() |
|
|
|
val inventoryLotLines = inventoryLotLineRepository.findAllById(wanted).filter { ill -> |
|
|
|
!ill.deleted && |
|
|
|
ill.status == InventoryLotLineStatus.AVAILABLE && |
|
|
|
section.equals(ill.warehouse?.stockTakeSection?.trim(), ignoreCase = true) |
|
|
|
} |
|
|
|
if (inventoryLotLines.isEmpty()) return |
|
|
|
|
|
|
|
val toSave = ArrayList<StockTakeRecord>(inventoryLotLines.size) |
|
|
|
val illPerRecord = ArrayList<InventoryLotLine>(inventoryLotLines.size) |
|
|
|
for (ill in inventoryLotLines) { |
|
|
|
val inventoryLot = ill.inventoryLot ?: continue |
|
|
|
val warehouse = ill.warehouse ?: continue |
|
|
|
val item = inventoryLot.item ?: continue |
|
|
|
val lotId = inventoryLot.id ?: continue |
|
|
|
val warehouseId = warehouse.id ?: continue |
|
|
|
if ((lotId to warehouseId) in busyLotKeys) continue |
|
|
|
val availableQty = (ill.inQty ?: BigDecimal.ZERO) |
|
|
|
.subtract(ill.outQty ?: BigDecimal.ZERO) |
|
|
|
if (availableQty.compareTo(BigDecimal.ZERO) <= 0) continue |
|
|
|
|
|
|
|
toSave.add( |
|
|
|
StockTakeRecord().apply { |
|
|
|
this.itemId = item.id |
|
|
|
this.lotId = inventoryLot.id |
|
|
|
this.warehouse = warehouse |
|
|
|
this.stockTake = stockTake |
|
|
|
this.stockTakeRoundId = roundId |
|
|
|
this.stockTakeSection = section |
|
|
|
this.inventoryLotId = inventoryLot.id |
|
|
|
this.stockTakerId = placeholderStockTakerId |
|
|
|
this.stockTakerName = null |
|
|
|
this.bookQty = availableQty |
|
|
|
this.uom = ill.stockUom?.uom?.udfudesc |
|
|
|
this.date = LocalDate.now() |
|
|
|
this.status = "pending" |
|
|
|
this.itemCode = item.code |
|
|
|
this.itemName = item.name |
|
|
|
this.lotNo = inventoryLot.lotNo |
|
|
|
this.expiredDate = inventoryLot.expiryDate |
|
|
|
} |
|
|
|
) |
|
|
|
illPerRecord.add(ill) |
|
|
|
} |
|
|
|
stockTakeRecordRepository.saveAll(toSave) |
|
|
|
if (toSave.isNotEmpty()) { |
|
|
|
val lines = toSave.zip(illPerRecord).map { (record, ill) -> |
|
|
|
StockTakeLine().apply { |
|
|
|
this.stockTake = stockTake |
|
|
|
this.inventoryLotLine = ill |
|
|
|
this.initialQty = record.bookQty |
|
|
|
this.finalQty = null |
|
|
|
this.uom = ill.stockUom?.uom |
|
|
|
this.status = StockTakeLineStatus.PENDING |
|
|
|
this.stockTakeRecord = record |
|
|
|
} |
|
|
|
} |
|
|
|
stockTakeLineRepository.saveAll(lines) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 同一輪多 section 的 stock_take:優先用 [StockTake.stockTakeRoundId];舊資料為 null 時退回以 planStart 相同辨識一輪。 |
|
|
|
*/ |
|
|
|
@@ -1186,15 +1326,199 @@ open class StockTakeRecordService( |
|
|
|
) |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 僅「依區域」open 輪次佔用的區域(整區不可再開區域輪)。 |
|
|
|
* Item 輪次不佔整區,改以 [busyOpenLotWarehouseKeys] 在建立時排除 lot。 |
|
|
|
*/ |
|
|
|
open fun busyStockTakeSections(): List<String> { |
|
|
|
return stockTakeRepository.findAll() |
|
|
|
.filter { !it.deleted && it.status?.isOpen() == true } |
|
|
|
.mapNotNull { it.stockTakeSection?.trim() } |
|
|
|
return stockTakeRepository.findOpenStockTakeRows() |
|
|
|
.filter { row -> |
|
|
|
val mode = row.getStockTakeRoundMode()?.trim()?.lowercase().orEmpty() |
|
|
|
mode.isEmpty() || mode == StockTakeRoundMode.SECTION |
|
|
|
} |
|
|
|
.mapNotNull { it.getStockTakeSection()?.trim() } |
|
|
|
.filter { it.isNotBlank() } |
|
|
|
.distinct() |
|
|
|
.sorted() |
|
|
|
} |
|
|
|
|
|
|
|
/** 所有 open 輪次中尚未刪除的盤點列 (lotId, warehouseId),供建立時避免重疊。 */ |
|
|
|
open fun busyOpenLotWarehouseKeys(): Set<Pair<Long, Long>> { |
|
|
|
val openHeaders = stockTakeRepository.findAll() |
|
|
|
.filter { !it.deleted && it.status?.isOpen() == true } |
|
|
|
if (openHeaders.isEmpty()) return emptySet() |
|
|
|
val roundIds = openHeaders.mapNotNull { it.stockTakeRoundId?.takeIf { id -> id > 0L } }.distinct() |
|
|
|
val headerIds = openHeaders.mapNotNull { it.id } |
|
|
|
val records = buildList { |
|
|
|
if (roundIds.isNotEmpty()) { |
|
|
|
addAll(stockTakeRecordRepository.findAllByStockTakeRoundIdInAndDeletedIsFalse(roundIds)) |
|
|
|
} |
|
|
|
if (headerIds.isNotEmpty()) { |
|
|
|
addAll(stockTakeRecordRepository.findAllByStockTakeIdInAndDeletedIsFalse(headerIds)) |
|
|
|
} |
|
|
|
} |
|
|
|
return records.mapNotNull { rec -> |
|
|
|
val lotId = rec.lotId ?: return@mapNotNull null |
|
|
|
val warehouseId = rec.warehouse?.id ?: return@mapNotNull null |
|
|
|
lotId to warehouseId |
|
|
|
}.toSet() |
|
|
|
} |
|
|
|
|
|
|
|
/** PP/PF = root PO 前綴;other = 非 PP/PF(與庫存批次現況報告一致) */ |
|
|
|
private fun buildCreateLotOriginFilterSql(lotOrigin: String?): String { |
|
|
|
val v = lotOrigin?.trim().orEmpty() |
|
|
|
if (v.isBlank() || v.equals("All", ignoreCase = true)) return "" |
|
|
|
return when (v.lowercase()) { |
|
|
|
"pp" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PP'" |
|
|
|
"pf" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PF'" |
|
|
|
"other" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) NOT IN ('PP', 'PF')" |
|
|
|
else -> "" |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 搜尋「有可用 lot」的貨品(建立 item 輪左側列表)。 |
|
|
|
* @param lotOrigin All / PP / PF / other |
|
|
|
*/ |
|
|
|
open fun searchItemsForStockTakeCreate( |
|
|
|
code: String?, |
|
|
|
name: String?, |
|
|
|
lotOrigin: String?, |
|
|
|
limit: Int = 1000, |
|
|
|
): List<StockTakeCreateItemOption> { |
|
|
|
val codeTrim = code?.trim().orEmpty() |
|
|
|
val nameTrim = name?.trim().orEmpty() |
|
|
|
val originTrim = lotOrigin?.trim().orEmpty() |
|
|
|
val hasOrigin = originTrim.isNotEmpty() && !originTrim.equals("All", ignoreCase = true) |
|
|
|
// 來源已選 PP/PF/其他 → 可只依來源列出;來源=全部則仍需編號或名稱 |
|
|
|
if (codeTrim.isEmpty() && nameTrim.isEmpty() && !hasOrigin) return emptyList() |
|
|
|
|
|
|
|
val args = mutableMapOf<String, Any>() |
|
|
|
val codeSql = if (codeTrim.isNotEmpty()) { |
|
|
|
args["itemCode"] = "%$codeTrim%" |
|
|
|
"AND it.code LIKE :itemCode" |
|
|
|
} else "" |
|
|
|
val nameSql = if (nameTrim.isNotEmpty()) { |
|
|
|
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 = """ |
|
|
|
WITH RECURSIVE |
|
|
|
$cte |
|
|
|
SELECT DISTINCT |
|
|
|
it.id AS id, |
|
|
|
it.code AS code, |
|
|
|
it.name AS name |
|
|
|
FROM seed_lots sl |
|
|
|
INNER JOIN inventory_lot il ON il.id = sl.lotId AND il.deleted = 0 |
|
|
|
INNER JOIN items it ON it.id = il.itemId AND it.deleted = 0 |
|
|
|
LEFT JOIN lot_root_origin lro ON lro.lotId = sl.lotId |
|
|
|
LEFT JOIN stock_in_line root_sil ON root_sil.id = lro.rootSilId AND root_sil.deleted = 0 |
|
|
|
LEFT JOIN purchase_order root_po ON root_po.id = root_sil.purchaseOrderId AND root_po.deleted = 0 |
|
|
|
WHERE 1=1 |
|
|
|
$originSql |
|
|
|
ORDER BY it.code |
|
|
|
LIMIT :rowLimit |
|
|
|
""".trimIndent() |
|
|
|
|
|
|
|
return jdbcDao.queryForList(sql, args).mapNotNull { row -> |
|
|
|
val id = (row["id"] as? Number)?.toLong() ?: return@mapNotNull null |
|
|
|
StockTakeCreateItemOption( |
|
|
|
id = id, |
|
|
|
code = row["code"]?.toString(), |
|
|
|
name = row["name"]?.toString(), |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 依貨品查可選 lot(建立 item 輪用);已在 open 輪次的 lot 標 busy。 |
|
|
|
* @param lotOrigin All / PP / PF / other |
|
|
|
*/ |
|
|
|
open fun availableLotsForStockTakeCreate( |
|
|
|
itemIds: List<Long>, |
|
|
|
lotOrigin: String? = null, |
|
|
|
): List<StockTakeCreateLotOption> { |
|
|
|
val wanted = itemIds.filter { it > 0L }.distinct() |
|
|
|
if (wanted.isEmpty()) return emptyList() |
|
|
|
val busy = busyOpenLotWarehouseKeys() |
|
|
|
|
|
|
|
val args = mutableMapOf<String, Any>() |
|
|
|
val inClause = wanted.mapIndexed { index, id -> |
|
|
|
val key = "itemId_$index" |
|
|
|
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 sql = """ |
|
|
|
WITH RECURSIVE |
|
|
|
$cte |
|
|
|
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 |
|
|
|
INNER JOIN seed_lots sl ON sl.lotId = il.id |
|
|
|
LEFT JOIN lot_root_origin lro ON lro.lotId = il.id |
|
|
|
LEFT JOIN stock_in_line root_sil ON root_sil.id = lro.rootSilId AND root_sil.deleted = 0 |
|
|
|
LEFT JOIN purchase_order root_po ON root_po.id = root_sil.purchaseOrderId AND root_po.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) |
|
|
|
$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, |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 排程將過期 lot line 標成 UNAVAILABLE 後呼叫:對仍 open 的盤點輪次, |
|
|
|
* soft-delete 對應且尚未 completed 的 [StockTakeRecord]/[StockTakeLine], |
|
|
|
|