val formattedStockDate = stockDate.replace("/", "-")
args["stockDate"] = formattedStockDate
val itemArgs = mutableMapOf<String, Any>()
val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", itemArgs)
val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", itemArgs)
val hasItemCodeFilter = itemCodeSql.isNotBlank()
val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args)
val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args)
val storeLocationSql = if (!storeLocation.isNullOrBlank()) {
args["storeLocation"] = "%$storeLocation%"
"AND COALESCE(store_location.storeLocation, '') LIKE :storeLocation"
} else ""
val items = jdbcDao.queryForList(
"""
SELECT
it.id AS itemId,
it.code AS itemNo,
it.name AS itemName,
COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw
FROM items it
WHERE it.deleted = 0
AND it.code IS NOT NULL AND it.code <> ''
$itemCodeSql
$stockCategorySql
ORDER BY it.code
""".trimIndent(),
itemArgs,
)
if (items.isEmpty()) return emptyList()
val baseSql = """
WITH params AS (
SELECT
DATE(:stockDate) AS d0,
DATE_SUB(DATE(:stockDate), INTERVAL 1 DAY) AS d1
),
ledger_item_ids AS (
SELECT DISTINCT sl.itemId
FROM stock_ledger sl
INNER JOIN params p ON 1=1
WHERE sl.deleted = 0
AND sl.itemId IS NOT NULL
AND DATE(sl.date) <= p.d0
),
item_scope AS (
SELECT
it.id AS itemId,
it.code AS itemNo,
it.name AS itemName,
it.type AS itemType,
COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw
FROM items it
INNER JOIN ledger_item_ids li ON li.itemId = it.id
WHERE it.deleted = 0
AND it.code IS NOT NULL AND it.code <> ''
$itemCodeSql
$stockCategorySql
),
store_location AS (
SELECT
sl.itemId,
MAX(wh.code) AS storeLocation
FROM stock_ledger sl
LEFT JOIN stock_in_line sil ON sl.stockInLineId = sil.id AND sil.deleted = 0
LEFT JOIN inventory_lot il_in ON sil.inventoryLotId = il_in.id AND il_in.deleted = 0
LEFT JOIN stock_out_line sol ON sl.stockOutLineId = sol.id AND sol.deleted = 0
LEFT JOIN inventory_lot_line ill_out ON sol.inventoryLotLineId = ill_out.id AND ill_out.deleted = 0
LEFT JOIN inventory_lot il_out ON ill_out.inventoryLotId = il_out.id AND il_out.deleted = 0
LEFT JOIN inventory_lot_line ill_any ON ill_any.inventoryLotId = COALESCE(il_in.id, il_out.id) AND ill_any.deleted = 0
LEFT JOIN warehouse wh ON ill_any.warehouseId = wh.id AND wh.deleted = 0
WHERE sl.deleted = 0
GROUP BY sl.itemId
),
opening_ranked AS (
SELECT
sl.itemId,
COALESCE(sl.balance, 0) AS openingBalance,
ROW_NUMBER() OVER (PARTITION BY sl.itemId ORDER BY sl.date DESC, sl.id DESC) AS rn
FROM stock_ledger sl
INNER JOIN params p ON 1=1
WHERE sl.deleted = 0
AND sl.itemId IS NOT NULL
AND DATE(sl.date) <= p.d1
),
opening_per_item AS (
SELECT itemId, openingBalance
FROM opening_ranked
WHERE rn = 1
),
current_ranked AS (
SELECT
sl.itemId,
COALESCE(sl.balance, 0) AS currentBalance,
ROW_NUMBER() OVER (PARTITION BY sl.itemId ORDER BY sl.date DESC, sl.id DESC) AS rn
FROM stock_ledger sl
INNER JOIN params p ON 1=1
WHERE sl.deleted = 0
AND sl.itemId IS NOT NULL
AND DATE(sl.date) <= p.d0
),
current_per_item AS (
SELECT itemId, currentBalance
FROM current_ranked
WHERE rn = 1
),
last_in_out AS (
SELECT
sl.itemId,
MAX(CASE WHEN COALESCE(sl.inQty, 0) > 0 THEN sl.date END) AS lastInDate,
MAX(CASE WHEN COALESCE(sl.outQty, 0) > 0 THEN sl.date END) AS lastOutDate
val itemIds = items.mapNotNull { jdbcLong(it["itemId"]) }.distinct()
if (itemIds.isEmpty()) return emptyList()
val day = LocalDate.parse(stockDate.replace("/", "-").take(10))
val openingCutoff = day.atStartOfDay()
val currentCutoff = day.plusDays(1).atStartOfDay()
val openingByItem = loadLatestLedgerBalances(itemIds, openingCutoff)
val currentByItem = loadLatestLedgerBalances(itemIds, currentCutoff)
val movesByItem = loadStockDateLedgerMoves(itemIds, openingCutoff, currentCutoff)
val uomByItem = loadStockUnitLabels(itemIds)
val minBalance = balanceFilterStart?.toDoubleOrNull()
val maxBalance = balanceFilterEnd?.toDoubleOrNull()
return items.mapNotNull { item ->
val itemId = jdbcLong(item["itemId"]) ?: return@mapNotNull null
val opening = openingByItem[itemId] ?: 0.0
val current = currentByItem[itemId] ?: 0.0
val moves = movesByItem[itemId]
val hasLedger = openingByItem.containsKey(itemId) ||
currentByItem.containsKey(itemId) ||
movesByItem.containsKey(itemId)
if (!hasItemCodeFilter && !hasLedger) return@mapNotNull null
if (minBalance != null && current < minBalance) return@mapNotNull null
if (maxBalance != null && current > maxBalance) return@mapNotNull null
val avg = jdbcDouble(item["avgUnitPriceRaw"])
val cumIn = moves?.cumStockIn ?: 0.0
val cumOut = moves?.cumStockOut ?: 0.0
val miss = moves?.misInputAndLost ?: 0.0
val variance = moves?.variance ?: 0.0
val defective = moves?.defectiveGoods ?: 0.0
mapOf(
"stockSubCategory" to "",
"itemNo" to (item["itemNo"]?.toString().orEmpty()),
"itemName" to (item["itemName"]?.toString().orEmpty()),
"unitOfMeasure" to (uomByItem[itemId] ?: ""),
"lotNo" to "",
"expiryDate" to "",
"openingBalance" to "",
"cumStockIn" to "",
"cumStockOut" to "",
"currentBalance" to "",
"reOrderQty" to "",
"storeLocation" to "",
"lastInDate" to "",
"lastOutDate" to "",
"openingBalanceRaw" to opening,
"currentBalanceRaw" to current,
"totalOpeningBalance" to formatReportQty(opening),
"totalCumStockIn" to formatReportQty(cumIn),
"totalCumStockOut" to formatReportQty(cumOut),
"totalCurrentBalance" to formatReportQty(current),
"misInputAndLost" to "",
"defectiveGoods" to "",
"variance" to "",
"totalMisInputAndLost" to formatReportQty(miss),
"totalVariance" to formatReportQty(variance),
"totalDefectiveGoods" to formatReportQty(defective),
"avgUnitPrice" to formatReportMoney(avg),
"totalStockBalance" to formatReportMoney(avg * current),
)
}
}
private data class StockBalanceMoves(
val cumStockIn: Double,
val cumStockOut: Double,
val misInputAndLost: Double,
val defectiveGoods: Double,
val variance: Double,
)
private fun loadLatestLedgerBalances(
itemIds: List<Long>,
cutoffExclusive: java.time.LocalDateTime,
): Map<Long, Double> {
val out = HashMap<Long, Double>(itemIds.size)
itemIds.chunked(400).forEach { chunk ->
val args = mutableMapOf<String, Any>(
"itemIds" to ArrayList(chunk),
"cutoff" to cutoffExclusive,
)
val rows = jdbcDao.queryForList(
"""
SELECT sl.itemId AS itemId, COALESCE(sl.balance, 0) AS balance
FROM stock_ledger sl
INNER JOIN params p ON 1=1
WHERE sl.deleted = 0
AND sl.itemId IS NOT NULL
AND DATE(sl.date) <= p.d0
GROUP BY sl.itemId
),
ledger_moves AS (
INNER JOIN (
SELECT s.itemId AS itemId, MAX(s.id) AS maxId
FROM stock_ledger s
INNER JOIN (
SELECT itemId, MAX(date) AS maxDate
FROM stock_ledger
WHERE deleted = 0
AND itemId IN (:itemIds)
AND date < :cutoff
GROUP BY itemId
) latest ON latest.itemId = s.itemId AND s.date = latest.maxDate
WHERE s.deleted = 0
AND s.itemId IN (:itemIds)
GROUP BY s.itemId
) t ON sl.id = t.maxId
""".trimIndent(),
args,
)
for (row in rows) {
val id = jdbcLong(row["itemId"]) ?: continue
out[id] = jdbcDouble(row["balance"])
}
}
return out
}
private fun loadStockDateLedgerMoves(
itemIds: List<Long>,
startInclusive: java.time.LocalDateTime,
endExclusive: java.time.LocalDateTime,
): Map<Long, StockBalanceMoves> {
val out = HashMap<Long, StockBalanceMoves>(itemIds.size)
itemIds.chunked(400).forEach { chunk ->
val args = mutableMapOf<String, Any>(
"itemIds" to ArrayList(chunk),
"startAt" to startInclusive,
"endAt" to endExclusive,
)
val rows = jdbcDao.queryForList(
"""
SELECT
sl.itemId,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ')
AND COALESCE(sl.inQty, 0) > 0
THEN COALESCE(sl.inQty, 0)
ELSE 0
END
) AS cumStockIn,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ')
AND COALESCE(sl.outQty, 0) > 0
THEN COALESCE(sl.outQty, 0)
ELSE 0
END
) AS cumStockOut,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'MISS'
AND COALESCE(sl.outQty, 0) > 0
THEN COALESCE(sl.outQty, 0)
ELSE 0
END
) AS misInputAndLost,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'BAD'
AND COALESCE(sl.outQty, 0) > 0
THEN COALESCE(sl.outQty, 0)
ELSE 0
END
) AS defectiveGoods,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE'
THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)
ELSE 0
END
) AS variance
sl.itemId AS itemId,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') AND COALESCE(sl.inQty, 0) > 0 THEN COALESCE(sl.inQty, 0) ELSE 0 END) AS cumStockIn,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS cumStockOut,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'MISS' AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS misInputAndLost,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'BAD' AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS defectiveGoods,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) ELSE 0 END) AS variance
FROM stock_ledger sl
INNER JOIN params p ON 1=1
WHERE sl.deleted = 0
AND sl.itemId IS NOT NULL
AND DATE(sl.date) > p.d1
AND DATE(sl.date) <= p.d0
AND sl.itemId IN (:itemIds)
AND sl.date >= :startAt
AND sl.date < :endAt
GROUP BY sl.itemId
""".trimIndent(),
args,
)
SELECT
'' AS stockSubCategory,
COALESCE(s.itemNo, '') AS itemNo,
COALESCE(s.itemName, '') AS itemName,
COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure,
'' AS lotNo,
'' AS expiryDate,
'' AS openingBalance,
'' AS cumStockIn,
'' AS cumStockOut,
'' AS currentBalance,
'' AS reOrderQty,
COALESCE(store_location.storeLocation, '') AS storeLocation,
COALESCE(DATE_FORMAT(lio.lastInDate, '%Y-%m-%d'), '') AS lastInDate,
COALESCE(DATE_FORMAT(lio.lastOutDate, '%Y-%m-%d'), '') AS lastOutDate,
COALESCE(op.openingBalance, 0) AS openingBalanceRaw,
COALESCE(cp.currentBalance, 0) AS currentBalanceRaw,
CASE WHEN COALESCE(op.openingBalance, 0) < 0 THEN CONCAT('(', FORMAT(-op.openingBalance, 0), ')') ELSE FORMAT(COALESCE(op.openingBalance, 0), 0) END AS totalOpeningBalance,
CASE WHEN COALESCE(lm.cumStockIn, 0) < 0 THEN CONCAT('(', FORMAT(-lm.cumStockIn, 0), ')') ELSE FORMAT(COALESCE(lm.cumStockIn, 0), 0) END AS totalCumStockIn,
CASE WHEN COALESCE(lm.cumStockOut, 0) < 0 THEN CONCAT('(', FORMAT(-lm.cumStockOut, 0), ')') ELSE FORMAT(COALESCE(lm.cumStockOut, 0), 0) END AS totalCumStockOut,
CASE WHEN COALESCE(cp.currentBalance, 0) < 0 THEN CONCAT('(', FORMAT(-cp.currentBalance, 0), ')') ELSE FORMAT(COALESCE(cp.currentBalance, 0), 0) END AS totalCurrentBalance,
'' AS misInputAndLost,
'' AS defectiveGoods,
'' AS variance,
CASE WHEN COALESCE(lm.misInputAndLost, 0) < 0 THEN CONCAT('(', FORMAT(-lm.misInputAndLost, 0), ')') ELSE FORMAT(COALESCE(lm.misInputAndLost, 0), 0) END AS totalMisInputAndLost,
CASE WHEN COALESCE(lm.variance, 0) < 0 THEN CONCAT('(', FORMAT(-lm.variance, 0), ')') ELSE FORMAT(COALESCE(lm.variance, 0), 0) END AS totalVariance,
CASE WHEN COALESCE(lm.defectiveGoods, 0) < 0 THEN CONCAT('(', FORMAT(-lm.defectiveGoods, 0), ')') ELSE FORMAT(COALESCE(lm.defectiveGoods, 0), 0) END AS totalDefectiveGoods,
FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0), 2), 2) AS avgUnitPrice,
FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0) * COALESCE(cp.currentBalance, 0), 2), 2) AS totalStockBalance
FROM item_scope s
LEFT JOIN opening_per_item op ON op.itemId = s.itemId
LEFT JOIN current_per_item cp ON cp.itemId = s.itemId
LEFT JOIN last_in_out lio ON lio.itemId = s.itemId
LEFT JOIN ledger_moves lm ON lm.itemId = s.itemId
LEFT JOIN item_uom iu ON iu.itemId = s.itemId AND iu.stockUnit = 1 AND iu.deleted = 0
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
LEFT JOIN store_location ON store_location.itemId = s.itemId