@@ -565,16 +565,311 @@ ORDER BY
return jdbcDao.queryForList(sql, args)
}
/**
* V2 Overall:每個 (lotId, warehouseId) 取 **status=completed** 且 **stockTakeRoundId 最大** 之一筆盤點紀錄
* (同輪多筆時取 id 較大者)。期初/累計區間依該筆所屬輪次之 MIN~MAX(`date`)。
* @param limitedToRoundIds null=全系統所有輪次;非 null=僅在指定輪次內取最新已完成
*/
fun searchStockTakeVarianceReportV2Overall(
itemCode: String?,
storeId: String?,
limitedToRoundIds: List<Long>? = null,
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val roundIdsFilterSql = if (limitedToRoundIds.isNullOrEmpty()) {
""
} else {
val inClause = limitedToRoundIds.mapIndexed { index, id ->
val key = "overallRoundId_$index"
args[key] = id
":$key"
}.joinToString(", ")
"AND s.stockTakeRoundId IN ($inClause)"
}
val storeIdSql = run {
val normalized = storeId?.trim()
if (normalized.isNullOrBlank() || normalized.equals("all", ignoreCase = true)) {
""
} else {
args["storeId"] = normalized
"""
AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')
""".trimIndent()
}
}
val itemCodeSql = buildMultiValueLikeClause(
itemCode,
"it.code",
"itemCode",
args
)
val sql = """
WITH round_bounds AS (
SELECT
s.stockTakeRoundId,
COALESCE(MIN(s.date), CURRENT_DATE) AS fromDate,
COALESCE(MAX(s.date), CURRENT_DATE) AS toDate
FROM stocktakerecord s
WHERE s.deleted = 0
$roundIdsFilterSql
GROUP BY s.stockTakeRoundId
),
latest_str AS (
SELECT
str.lotId,
str.warehouseId,
str.stockTakeRoundId,
str.bookQty,
str.varianceQty,
str.approverStockTakeQty,
str.date AS strDate,
str.id,
str.approverTime,
str.status AS stockTakeRecordStatus
FROM stocktakerecord str
INNER JOIN (
SELECT
s.lotId,
s.warehouseId,
MAX(s.stockTakeRoundId) AS maxRound
FROM stocktakerecord s
WHERE s.deleted = 0
AND s.status = 'completed'
$roundIdsFilterSql
GROUP BY s.lotId, s.warehouseId
) mx ON mx.lotId = str.lotId
AND mx.warehouseId = str.warehouseId
AND mx.maxRound = str.stockTakeRoundId
WHERE str.deleted = 0
AND str.status = 'completed'
AND NOT EXISTS (
SELECT 1
FROM stocktakerecord str2
WHERE str2.deleted = 0
AND str2.status = 'completed'
AND str2.lotId = str.lotId
AND str2.warehouseId = str.warehouseId
AND str2.stockTakeRoundId = str.stockTakeRoundId
AND str2.id > str.id
)
),
line_bounds AS (
SELECT
ill.id AS inventoryLotLineId,
ls.lotId,
ls.warehouseId,
rb.fromDate,
rb.toDate,
ls.bookQty,
ls.varianceQty,
ls.approverStockTakeQty,
ls.strDate,
ls.approverTime,
ls.stockTakeRecordStatus
FROM latest_str ls
INNER JOIN round_bounds rb ON rb.stockTakeRoundId = ls.stockTakeRoundId
INNER JOIN inventory_lot il ON ls.lotId = il.id AND il.deleted = 0
INNER JOIN inventory_lot_line ill ON ill.inventoryLotId = il.id
AND ill.warehouseId = ls.warehouseId
AND ill.deleted = 0
),
in_agg AS (
SELECT
lb.inventoryLotLineId,
SUM(CASE WHEN DATE(sil.receiptDate) < lb.fromDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0))
ELSE COALESCE(sil.acceptedQty, 0)
END
ELSE 0 END) AS inBefore,
SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN lb.fromDate AND lb.toDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0))
ELSE COALESCE(sil.acceptedQty, 0)
END
ELSE 0 END) AS inDuring,
MAX(CASE WHEN sil.receiptDate IS NOT NULL THEN DATE(sil.receiptDate) END) AS lastInDate
FROM line_bounds lb
INNER JOIN inventory_lot_line ill ON ill.id = lb.inventoryLotLineId
INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0
INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0
LEFT JOIN stock_in_line sil
ON sil.inventoryLotLineId = ill.id
AND sil.deleted = 0
AND sil.status = 'completed'
LEFT JOIN item_uom iu_purchase
ON it.id = iu_purchase.itemId
AND iu_purchase.purchaseUnit = 1
AND iu_purchase.deleted = 0
LEFT JOIN item_uom iu_stock
ON it.id = iu_stock.itemId
AND iu_stock.stockUnit = 1
AND iu_stock.deleted = 0
WHERE ill.deleted = 0
GROUP BY lb.inventoryLotLineId
),
out_agg AS (
SELECT
lb.inventoryLotLineId,
SUM(CASE WHEN DATE(sol.endTime) < lb.fromDate THEN COALESCE(sol.qty, 0) ELSE 0 END) AS outBefore,
SUM(CASE WHEN DATE(sol.endTime) BETWEEN lb.fromDate AND lb.toDate THEN COALESCE(sol.qty, 0) ELSE 0 END) AS outDuring,
MAX(CASE WHEN sol.endTime IS NOT NULL THEN DATE(sol.endTime) END) AS lastOutDate
FROM line_bounds lb
INNER JOIN inventory_lot_line ill ON ill.id = lb.inventoryLotLineId
LEFT JOIN stock_out_line sol
ON sol.inventoryLotLineId = ill.id
AND sol.deleted = 0
AND sol.status = 'completed'
WHERE ill.deleted = 0
GROUP BY lb.inventoryLotLineId
),
in_out AS (
SELECT
i.inventoryLotLineId,
COALESCE(i.inBefore, 0) AS inBefore,
COALESCE(o.outBefore, 0) AS outBefore,
COALESCE(i.inDuring, 0) AS inDuring,
COALESCE(o.outDuring, 0) AS outDuring,
i.lastInDate,
o.lastOutDate
FROM in_agg i
LEFT JOIN out_agg o ON o.inventoryLotLineId = i.inventoryLotLineId
),
data AS (
SELECT
it.type AS stockSubCategory,
it.code AS itemNo,
it.name AS itemName,
uc.udfudesc AS unitOfMeasure,
il.lotNo AS lotNo,
COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
wh.code AS storeLocation,
(COALESCE(io.inBefore, 0) - COALESCE(io.outBefore, 0)) AS openingQty,
COALESCE(io.inDuring, 0) AS inQty,
COALESCE(io.outDuring, 0) AS outQty,
((COALESCE(io.inBefore, 0) - COALESCE(io.outBefore, 0)) + COALESCE(io.inDuring, 0) - COALESCE(io.outDuring, 0)) AS currentQty,
io.lastInDate AS lastInDateRaw,
io.lastOutDate AS lastOutDateRaw,
lb.bookQty AS stkBookQty,
lb.approverStockTakeQty AS stkApproverQty,
lb.varianceQty AS stkVarianceQty,
lb.strDate AS stockTakeDateRaw,
lb.approverTime AS approvalDateTimeRaw,
lb.stockTakeRecordStatus AS stockTakeRecordStatus
FROM line_bounds lb
INNER JOIN inventory_lot_line ill ON ill.id = lb.inventoryLotLineId
INNER JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0
INNER JOIN items it ON il.itemId = it.id AND it.deleted = 0
INNER JOIN warehouse wh ON wh.id = lb.warehouseId AND wh.deleted = 0
LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id
LEFT JOIN in_out io
ON io.inventoryLotLineId = ill.id
WHERE 1=1
$itemCodeSql
$storeIdSql
)
SELECT
stockSubCategory,
itemNo,
itemName,
unitOfMeasure,
lotNo,
expiryDate,
storeLocation,
CASE WHEN COALESCE(openingQty, 0) < 0 THEN CONCAT('(', FORMAT(-openingQty, 0), ')') ELSE FORMAT(COALESCE(openingQty, 0), 0) END AS openingBalance,
CASE WHEN COALESCE(inQty, 0) < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(COALESCE(inQty, 0), 0) END AS cumStockIn,
CASE WHEN COALESCE(outQty, 0) < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(COALESCE(outQty, 0), 0) END AS cumStockOut,
CASE WHEN COALESCE(stkBookQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkBookQty, 0), ')') ELSE FORMAT(COALESCE(stkBookQty, 0), 0) END AS currentBookBalance,
COALESCE(DATE_FORMAT(lastInDateRaw, '%Y-%m-%d'), '') AS lastInDate,
COALESCE(DATE_FORMAT(lastOutDateRaw, '%Y-%m-%d'), '') AS lastOutDate,
COALESCE(
DATE_FORMAT(approvalDateTimeRaw, '%Y-%m-%d %H:%i:%s'),
COALESCE(DATE_FORMAT(stockTakeDateRaw, '%Y-%m-%d'), '')
) AS stockTakeDate,
CASE
WHEN stkApproverQty IS NULL THEN '0'
WHEN COALESCE(stkApproverQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkApproverQty, 0), ')')
ELSE FORMAT(COALESCE(stkApproverQty, 0), 0)
END AS stockTakeQty,
CASE
WHEN COALESCE(stockTakeRecordStatus, '') <> 'completed' THEN '0'
WHEN stkVarianceQty IS NULL THEN '0'
WHEN COALESCE(stkVarianceQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkVarianceQty, 0), ')')
ELSE FORMAT(COALESCE(stkVarianceQty, 0), 0)
END AS variance,
CASE
WHEN COALESCE(stockTakeRecordStatus, '') <> 'completed' THEN '0%'
WHEN stkVarianceQty IS NULL THEN '0%'
WHEN COALESCE(stkBookQty, 0) = 0 THEN '0%'
WHEN (COALESCE(stkVarianceQty, 0) / stkBookQty) * 100 < 0 THEN CONCAT('(', FORMAT(-(COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%)')
ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%')
END AS variancePercentage,
CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalOpeningBalance,
CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockIn,
CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockOut,
CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCurrentBalance,
CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalStockTakeQty
FROM data
ORDER BY
itemNo,
lotNo,
storeLocation
""".trimIndent()
return jdbcDao.queryForList(sql, args)
}
/** Overall 模式報表副標題(全系統輪次,舊 API 相容) */
fun getStockTakeVarianceOverallCaption(): String =
"全輪次(各批號/倉別最新已完成盤點)"
/** 多選輪次 Overall 模式報表副標題 */
fun getStockTakeVarianceMultiRoundCaption(stockTakeRoundIds: List<Long>): String {
if (stockTakeRoundIds.isEmpty()) return getStockTakeVarianceOverallCaption()
val parts = stockTakeRoundIds.map { getStockTakeRoundCaption(it) }
return "已選輪次(各批號/倉別最新已完成盤點):${parts.joinToString(";")}"
}
/** 報表表頭:盤點輪次說明(與 /report/stock-take-rounds 選項格式一致) */
fun getStockTakeRoundCaption(stockTakeRoundId: Long): String {
val sql = """
SELECT CONCAT(
'Round ',
CAST(st.stockTakeRoundId AS CHAR),
' (',
DATE_FORMAT(MIN(st.planStart), '%Y-%m-%d'),
')'
) AS cap
SELECT
CONCAT(
CASE
WHEN NULLIF(TRIM(MAX(st.stockTakeRoundName)), '') IS NULL
THEN CONCAT('盤點輪次', CAST(st.stockTakeRoundId AS CHAR))
ELSE TRIM(MAX(st.stockTakeRoundName))
END,
' — ',
DATE_FORMAT(MIN(st.planStart), '%Y-%m-%d')
) AS cap
FROM stock_take st
WHERE st.deleted = 0
AND st.stockTakeRoundId = :stockTakeRoundId
@@ -585,7 +880,7 @@ ORDER BY
mapOf("stockTakeRoundId" to stockTakeRoundId)
).firstOrNull()
val cap = row?.get("cap") as? String
return if (!cap.isNullOrBlank()) cap else "Round $stockTakeRoundId"
return if (!cap.isNullOrBlank()) cap else "盤點輪次 $stockTakeRoundId"
}
/** LIKE 多值工具方法 */