|
|
|
@@ -1,7 +1,10 @@ |
|
|
|
package com.ffii.fpsms.modules.report.service |
|
|
|
|
|
|
|
import com.ffii.core.support.JdbcDao |
|
|
|
import org.slf4j.LoggerFactory |
|
|
|
import org.springframework.stereotype.Service |
|
|
|
import java.math.BigDecimal |
|
|
|
import java.math.RoundingMode |
|
|
|
import java.time.LocalDate |
|
|
|
import java.time.format.DateTimeFormatter |
|
|
|
|
|
|
|
@@ -9,7 +12,7 @@ import java.time.format.DateTimeFormatter |
|
|
|
open class StockLedgerReportService( |
|
|
|
private val jdbcDao: JdbcDao, |
|
|
|
) { |
|
|
|
|
|
|
|
private val log = LoggerFactory.getLogger(StockLedgerReportService::class.java) |
|
|
|
private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") |
|
|
|
|
|
|
|
/** |
|
|
|
@@ -18,11 +21,12 @@ open class StockLedgerReportService( |
|
|
|
* |
|
|
|
* - stockSubCategory = items.type |
|
|
|
* - trnDate = stock_ledger.date |
|
|
|
* - trnRefNo = stock_ledger.type |
|
|
|
* - cumBalance = stock_ledger.balance(異動後纍計存量) |
|
|
|
* - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) |
|
|
|
* - trnRefNo = stock_ledger.type(ADJ 且關聯盤點單時改為盤點) |
|
|
|
* - cumBalance = stock_ledger.balance(異動後貨品纍計存量) |
|
|
|
* - cumOpeningBal = balance - inQty + outQty(異動前貨品纍計) |
|
|
|
* |
|
|
|
* 只查 [start, end] 期間列,不掃起日以前全歷史。 |
|
|
|
* 帳本先依日期區間撈窄欄,批號/單據再依這段出現的 id 分批補。 |
|
|
|
* 餘額用帳本上的貨品纍計,排序為貨品 → 時間 → id。 |
|
|
|
*/ |
|
|
|
fun searchStockLedgerReport( |
|
|
|
stockCategory: String?, |
|
|
|
@@ -31,264 +35,500 @@ open class StockLedgerReportService( |
|
|
|
reportPeriodStart: String?, |
|
|
|
reportPeriodEnd: String?, |
|
|
|
): List<Map<String, Any>> { |
|
|
|
val tAll = System.nanoTime() |
|
|
|
val timings = mutableListOf<String>() |
|
|
|
fun <T> timed(name: String, extra: (T) -> String, block: () -> T): T { |
|
|
|
val t0 = System.nanoTime() |
|
|
|
val result = block() |
|
|
|
val line = "$name ${extra(result)} ${(System.nanoTime() - t0) / 1_000_000}ms" |
|
|
|
timings.add(line) |
|
|
|
log.info("stock-ledger {}", line) |
|
|
|
return result |
|
|
|
} |
|
|
|
|
|
|
|
val args = mutableMapOf<String, Any>() |
|
|
|
|
|
|
|
val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") |
|
|
|
val endDate = (reportPeriodEnd?.replace("/", "-") |
|
|
|
?: LocalDate.now().format(dateFmt)) |
|
|
|
|
|
|
|
val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { |
|
|
|
val startDate = if (!reportPeriodStart.isNullOrBlank()) { |
|
|
|
reportPeriodStart.replace("/", "-") |
|
|
|
} else { |
|
|
|
val minDateSql = """ |
|
|
|
val minDateRow = jdbcDao.queryForList( |
|
|
|
""" |
|
|
|
SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate |
|
|
|
FROM stock_ledger sl |
|
|
|
WHERE sl.deleted = 0 |
|
|
|
AND sl.itemCode IS NOT NULL |
|
|
|
AND sl.itemCode <> '' |
|
|
|
""".trimIndent() |
|
|
|
|
|
|
|
val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap<String, Any>()).firstOrNull() |
|
|
|
(minDateRow?.get("firstDate") as? String) |
|
|
|
?: reportPeriodEnd |
|
|
|
""".trimIndent(), |
|
|
|
emptyMap<String, Any>(), |
|
|
|
).firstOrNull() |
|
|
|
(minDateRow?.get("firstDate") as? String) ?: endDate |
|
|
|
} |
|
|
|
val endExclusive = LocalDate.parse(endDate).plusDays(1).format(dateFmt) |
|
|
|
|
|
|
|
val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) |
|
|
|
args["reportPeriodStart"] = reportPeriodStart |
|
|
|
args["reportPeriodEndExclusive"] = endExclusive |
|
|
|
|
|
|
|
val stockCategorySql = buildMultiValueExactClause( |
|
|
|
stockCategory, |
|
|
|
"it.type", |
|
|
|
"stockCategory", |
|
|
|
args |
|
|
|
) |
|
|
|
|
|
|
|
val itemCodeSql = buildMultiValueLikeClause( |
|
|
|
itemCode, |
|
|
|
"sl.itemCode", |
|
|
|
"itemCode", |
|
|
|
args |
|
|
|
val args = mutableMapOf<String, Any>( |
|
|
|
"reportPeriodStart" to LocalDate.parse(startDate).atStartOfDay(), |
|
|
|
"reportPeriodEndExclusive" to LocalDate.parse(endExclusive).atStartOfDay(), |
|
|
|
) |
|
|
|
|
|
|
|
// 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數 |
|
|
|
val storeLocationSql = if (!storeLocation.isNullOrBlank()) { |
|
|
|
args["storeLocation"] = "%$storeLocation%" |
|
|
|
"AND lot.storeLocation LIKE :storeLocation" |
|
|
|
val itemCodeSql = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCode", args) |
|
|
|
val categorySql = buildCategoryItemFilter(stockCategory, args) |
|
|
|
val forceLedgerIndex = if (itemCode.isNullOrBlank()) { |
|
|
|
"FORCE INDEX (idx_sl_deleted_date)" |
|
|
|
} else { |
|
|
|
"" |
|
|
|
} |
|
|
|
|
|
|
|
val sql = """ |
|
|
|
SELECT |
|
|
|
stockSubCategory, |
|
|
|
itemNo, |
|
|
|
itemName, |
|
|
|
unitOfMeasure, |
|
|
|
lotNo, |
|
|
|
expiryDate, |
|
|
|
trnDate, |
|
|
|
CASE trnRefNoRaw |
|
|
|
WHEN 'OPEN' THEN '開倉' |
|
|
|
WHEN 'NOR' THEN '出入倉' |
|
|
|
WHEN 'TRF' THEN '轉倉' |
|
|
|
WHEN 'ADJ' THEN '調整' |
|
|
|
WHEN 'MISS' THEN '遺失' |
|
|
|
WHEN 'BAD' THEN '不良品' |
|
|
|
WHEN 'TKE' THEN '盤點' |
|
|
|
ELSE trnRefNoRaw |
|
|
|
END AS trnRefNo, |
|
|
|
storeLocation, |
|
|
|
orderRefNo, |
|
|
|
jobOrderNo, |
|
|
|
|
|
|
|
openingBalance, |
|
|
|
cumStockIn, |
|
|
|
cumStockOut, |
|
|
|
currentBalance, |
|
|
|
lastInDate, |
|
|
|
lastOutDate, |
|
|
|
reOrderLevel, |
|
|
|
reOrderQty, |
|
|
|
val moves = timed("1.ledger", { rows: List<LedgerMove> -> "rows=${rows.size}" }) { |
|
|
|
jdbcDao.queryForList( |
|
|
|
""" |
|
|
|
SELECT |
|
|
|
sl.id AS slId, |
|
|
|
DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, |
|
|
|
TRIM(COALESCE(sl.type, '')) AS rawType, |
|
|
|
sl.itemId AS itemId, |
|
|
|
sl.itemCode AS itemCode, |
|
|
|
COALESCE(sl.inQty, 0) AS inQty, |
|
|
|
COALESCE(sl.outQty, 0) AS outQty, |
|
|
|
COALESCE(sl.balance, 0) AS bal, |
|
|
|
sl.inventoryLotLineId AS inventoryLotLineId, |
|
|
|
sl.stockInLineId AS stockInLineId, |
|
|
|
sl.stockOutLineId AS stockOutLineId |
|
|
|
FROM stock_ledger sl $forceLedgerIndex |
|
|
|
WHERE sl.deleted = 0 |
|
|
|
AND sl.itemCode IS NOT NULL |
|
|
|
AND sl.itemCode <> '' |
|
|
|
AND sl.date >= :reportPeriodStart |
|
|
|
AND sl.date < :reportPeriodEndExclusive |
|
|
|
$itemCodeSql |
|
|
|
$categorySql |
|
|
|
""".trimIndent(), |
|
|
|
args, |
|
|
|
).map { r -> |
|
|
|
LedgerMove( |
|
|
|
slId = toLong(r["slId"]), |
|
|
|
trnDate = r["trnDate"]?.toString().orEmpty(), |
|
|
|
rawType = r["rawType"]?.toString().orEmpty(), |
|
|
|
itemId = toLong(r["itemId"]), |
|
|
|
itemCode = r["itemCode"]?.toString().orEmpty(), |
|
|
|
inQty = toDouble(r["inQty"]), |
|
|
|
outQty = toDouble(r["outQty"]), |
|
|
|
bal = toDouble(r["bal"]), |
|
|
|
lotLineId = toLong(r["inventoryLotLineId"]), |
|
|
|
stockInLineId = toLong(r["stockInLineId"]), |
|
|
|
stockOutLineId = toLong(r["stockOutLineId"]), |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
if (moves.isEmpty()) { |
|
|
|
log.info("stock-ledger DONE rows=0 {}", timings.joinToString(" | ")) |
|
|
|
return emptyList() |
|
|
|
} |
|
|
|
|
|
|
|
CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn, |
|
|
|
CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut, |
|
|
|
val lineByIn = HashMap<Long, Pair<Long, Long>>() |
|
|
|
val lineByOut = HashMap<Long, Long>() |
|
|
|
timed("2.lot_keys", { _: Unit -> |
|
|
|
"in=${lineByIn.size} out=${lineByOut.size}" |
|
|
|
}) { |
|
|
|
val missingIn = moves.filter { it.lotLineId <= 0 && it.stockInLineId > 0 } |
|
|
|
.map { it.stockInLineId } |
|
|
|
val missingOut = moves.filter { it.lotLineId <= 0 && it.stockOutLineId > 0 } |
|
|
|
.map { it.stockOutLineId } |
|
|
|
for (r in queryChunks( |
|
|
|
missingIn, |
|
|
|
""" |
|
|
|
SELECT id, inventoryLotLineId, inventoryLotId |
|
|
|
FROM stock_in_line |
|
|
|
WHERE deleted = 0 AND id IN (:ids) |
|
|
|
""".trimIndent(), |
|
|
|
)) { |
|
|
|
lineByIn[toLong(r["id"])] = toLong(r["inventoryLotLineId"]) to toLong(r["inventoryLotId"]) |
|
|
|
} |
|
|
|
for (r in queryChunks( |
|
|
|
missingOut, |
|
|
|
""" |
|
|
|
SELECT id, inventoryLotLineId |
|
|
|
FROM stock_out_line |
|
|
|
WHERE deleted = 0 AND id IN (:ids) |
|
|
|
""".trimIndent(), |
|
|
|
)) { |
|
|
|
lineByOut[toLong(r["id"])] = toLong(r["inventoryLotLineId"]) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
CASE WHEN cumOpeningBalRaw < 0 |
|
|
|
THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')') |
|
|
|
ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal, |
|
|
|
CASE WHEN bal < 0 |
|
|
|
THEN CONCAT('(', FORMAT(-bal, 0), ')') |
|
|
|
ELSE FORMAT(bal, 0) END AS cumBalance, |
|
|
|
data class LotKey(val lineId: Long, val lotId: Long) |
|
|
|
val lotKeyBySl = HashMap<Long, LotKey>(moves.size * 2) |
|
|
|
for (m in moves) { |
|
|
|
val fromIn = lineByIn[m.stockInLineId] |
|
|
|
val inLine = fromIn?.first ?: 0L |
|
|
|
val inLot = fromIn?.second ?: 0L |
|
|
|
val outLine = lineByOut[m.stockOutLineId] ?: 0L |
|
|
|
val lineId = when { |
|
|
|
m.lotLineId > 0 -> m.lotLineId |
|
|
|
inLine > 0 -> inLine |
|
|
|
inLot > 0 -> 0L |
|
|
|
else -> outLine |
|
|
|
} |
|
|
|
val lotId = if (lineId <= 0 && inLot > 0) inLot else 0L |
|
|
|
if (lineId > 0 || lotId > 0) { |
|
|
|
lotKeyBySl[m.slId] = LotKey(lineId, lotId) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
CASE WHEN totalCumOpeningBalRaw < 0 |
|
|
|
THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')') |
|
|
|
ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal, |
|
|
|
CASE WHEN totalStockInRaw < 0 |
|
|
|
THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')') |
|
|
|
ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn, |
|
|
|
CASE WHEN totalStockOutRaw < 0 |
|
|
|
THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')') |
|
|
|
ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut, |
|
|
|
CASE WHEN totalCumBalanceRaw < 0 |
|
|
|
THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')') |
|
|
|
ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance |
|
|
|
val lotByLine = HashMap<Long, LotInfo>() |
|
|
|
val lotByHeader = HashMap<Long, LotInfo>() |
|
|
|
timed("3.lots", { _: Unit -> "lines=${lotByLine.size} headers=${lotByHeader.size}" }) { |
|
|
|
for (r in queryChunks( |
|
|
|
lotKeyBySl.values.map { it.lineId }, |
|
|
|
""" |
|
|
|
SELECT |
|
|
|
ill.id AS lineId, |
|
|
|
COALESCE(il.lotNo, '') AS lotNo, |
|
|
|
COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, |
|
|
|
COALESCE(wh.code, '') AS storeLocation |
|
|
|
FROM inventory_lot_line ill |
|
|
|
INNER JOIN inventory_lot il |
|
|
|
ON il.id = ill.inventoryLotId AND il.deleted = 0 |
|
|
|
LEFT JOIN warehouse wh |
|
|
|
ON wh.id = ill.warehouseId AND wh.deleted = 0 |
|
|
|
WHERE ill.deleted = 0 |
|
|
|
AND ill.id IN (:ids) |
|
|
|
""".trimIndent(), |
|
|
|
)) { |
|
|
|
lotByLine[toLong(r["lineId"])] = LotInfo( |
|
|
|
lotNo = r["lotNo"]?.toString().orEmpty(), |
|
|
|
expiryDate = r["expiryDate"]?.toString().orEmpty(), |
|
|
|
storeLocation = r["storeLocation"]?.toString().orEmpty(), |
|
|
|
) |
|
|
|
} |
|
|
|
val headerIds = lotKeyBySl.values |
|
|
|
.filter { it.lineId <= 0 && it.lotId > 0 } |
|
|
|
.map { it.lotId } |
|
|
|
for (r in queryChunks( |
|
|
|
headerIds, |
|
|
|
""" |
|
|
|
SELECT |
|
|
|
il.id AS lotId, |
|
|
|
COALESCE(il.lotNo, '') AS lotNo, |
|
|
|
COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, |
|
|
|
COALESCE(MAX(wh.code), '') AS storeLocation |
|
|
|
FROM inventory_lot il |
|
|
|
LEFT JOIN inventory_lot_line ill |
|
|
|
ON ill.inventoryLotId = il.id AND ill.deleted = 0 |
|
|
|
LEFT JOIN warehouse wh |
|
|
|
ON wh.id = ill.warehouseId AND wh.deleted = 0 |
|
|
|
WHERE il.deleted = 0 |
|
|
|
AND il.id IN (:ids) |
|
|
|
GROUP BY il.id, il.lotNo, il.expiryDate |
|
|
|
""".trimIndent(), |
|
|
|
)) { |
|
|
|
lotByHeader[toLong(r["lotId"])] = LotInfo( |
|
|
|
lotNo = r["lotNo"]?.toString().orEmpty(), |
|
|
|
expiryDate = r["expiryDate"]?.toString().orEmpty(), |
|
|
|
storeLocation = r["storeLocation"]?.toString().orEmpty(), |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
FROM ( |
|
|
|
SELECT |
|
|
|
x.*, |
|
|
|
FIRST_VALUE(cumOpeningBalRaw) OVER ( |
|
|
|
PARTITION BY itemCode ORDER BY trnDateRaw, slId |
|
|
|
) AS totalCumOpeningBalRaw, |
|
|
|
SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw, |
|
|
|
SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw, |
|
|
|
FIRST_VALUE(bal) OVER ( |
|
|
|
PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC |
|
|
|
) AS totalCumBalanceRaw |
|
|
|
FROM ( |
|
|
|
SELECT |
|
|
|
sl.id AS slId, |
|
|
|
DATE(sl.date) AS trnDateRaw, |
|
|
|
DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, |
|
|
|
CASE |
|
|
|
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' |
|
|
|
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' |
|
|
|
AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' |
|
|
|
ELSE COALESCE(sl.type, '') |
|
|
|
END AS trnRefNoRaw, |
|
|
|
val locationFilter = storeLocation?.trim().orEmpty() |
|
|
|
val withLot = ArrayList<MoveWithLot>(moves.size) |
|
|
|
for (m in moves) { |
|
|
|
val key = lotKeyBySl[m.slId] ?: continue |
|
|
|
val lot = if (key.lineId > 0) lotByLine[key.lineId] else lotByHeader[key.lotId] |
|
|
|
if (lot == null) continue |
|
|
|
if (locationFilter.isNotEmpty() && |
|
|
|
!lot.storeLocation.contains(locationFilter, ignoreCase = true) |
|
|
|
) { |
|
|
|
continue |
|
|
|
} |
|
|
|
withLot.add(MoveWithLot(m, lot)) |
|
|
|
} |
|
|
|
timings.add("4.filter rows=${withLot.size}") |
|
|
|
if (withLot.isEmpty()) { |
|
|
|
log.info("stock-ledger DONE rows=0 {}", timings.joinToString(" | ")) |
|
|
|
return emptyList() |
|
|
|
} |
|
|
|
|
|
|
|
sl.itemCode AS itemCode, |
|
|
|
COALESCE(sl.inQty, 0) AS inQty, |
|
|
|
COALESCE(sl.outQty, 0) AS outQty, |
|
|
|
COALESCE(sl.balance, 0) AS bal, |
|
|
|
COALESCE(sl.balance, 0) |
|
|
|
- COALESCE(sl.inQty, 0) |
|
|
|
+ COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, |
|
|
|
data class ItemInfo( |
|
|
|
val type: String, |
|
|
|
val code: String, |
|
|
|
val name: String, |
|
|
|
val uom: String, |
|
|
|
) |
|
|
|
val items = HashMap<Long, ItemInfo>() |
|
|
|
timed("5.items", { _: Unit -> "items=${items.size}" }) { |
|
|
|
for (r in queryChunks( |
|
|
|
withLot.map { it.move.itemId }, |
|
|
|
""" |
|
|
|
SELECT |
|
|
|
it.id AS itemId, |
|
|
|
COALESCE(it.type, '') AS stockSubCategory, |
|
|
|
COALESCE(it.code, '') AS itemNo, |
|
|
|
COALESCE(it.name, '') AS itemName, |
|
|
|
COALESCE(uc.udfudesc, '') AS unitOfMeasure |
|
|
|
FROM items it |
|
|
|
LEFT JOIN ( |
|
|
|
SELECT itemId, MIN(id) AS id |
|
|
|
FROM item_uom |
|
|
|
WHERE deleted = 0 AND stockUnit = 1 AND itemId IN (:ids) |
|
|
|
GROUP BY itemId |
|
|
|
) one_uom ON one_uom.itemId = it.id |
|
|
|
LEFT JOIN item_uom iu |
|
|
|
ON iu.id = one_uom.id |
|
|
|
LEFT JOIN uom_conversion uc |
|
|
|
ON uc.id = iu.uomId |
|
|
|
WHERE it.deleted = 0 |
|
|
|
AND it.id IN (:ids) |
|
|
|
""".trimIndent(), |
|
|
|
)) { |
|
|
|
items[toLong(r["itemId"])] = ItemInfo( |
|
|
|
type = r["stockSubCategory"]?.toString().orEmpty(), |
|
|
|
code = r["itemNo"]?.toString().orEmpty(), |
|
|
|
name = r["itemName"]?.toString().orEmpty(), |
|
|
|
uom = r["unitOfMeasure"]?.toString().orEmpty(), |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
it.type AS stockSubCategory, |
|
|
|
it.code AS itemNo, |
|
|
|
it.name AS itemName, |
|
|
|
uc.udfudesc AS unitOfMeasure, |
|
|
|
data class InRef(val orderRefNo: String?, val jobOrderNo: String, val stockTake: Boolean) |
|
|
|
data class OutRef(val orderRefNo: String?, val stockTake: Boolean) |
|
|
|
val inRefs = HashMap<Long, InRef>() |
|
|
|
val outRefs = HashMap<Long, OutRef>() |
|
|
|
timed("6.refs", { _: Unit -> "in=${inRefs.size} out=${outRefs.size}" }) { |
|
|
|
for (r in queryChunks( |
|
|
|
withLot.map { it.move.stockOutLineId }, |
|
|
|
""" |
|
|
|
SELECT |
|
|
|
sol.id AS lineId, |
|
|
|
COALESCE(TRIM(do.code), TRIM(jo_po.code)) AS orderRefNo, |
|
|
|
CASE |
|
|
|
WHEN so.stockTakeId IS NOT NULL OR so.type = 'stockTake' THEN 1 |
|
|
|
ELSE 0 |
|
|
|
END AS isStockTake |
|
|
|
FROM stock_out_line sol |
|
|
|
LEFT JOIN stock_out so |
|
|
|
ON so.id = sol.stockOutId AND so.deleted = 0 |
|
|
|
LEFT JOIN pick_order_line pol |
|
|
|
ON pol.id = sol.pickOrderLineId AND pol.deleted = 0 |
|
|
|
LEFT JOIN pick_order po_out |
|
|
|
ON po_out.id = pol.poId AND po_out.deleted = 0 |
|
|
|
LEFT JOIN job_order jo_po |
|
|
|
ON jo_po.id = po_out.joId AND jo_po.deleted = 0 |
|
|
|
LEFT JOIN delivery_order do |
|
|
|
ON do.id = po_out.doId AND do.deleted = 0 |
|
|
|
WHERE sol.deleted = 0 |
|
|
|
AND sol.id IN (:ids) |
|
|
|
""".trimIndent(), |
|
|
|
)) { |
|
|
|
outRefs[toLong(r["lineId"])] = OutRef( |
|
|
|
orderRefNo = r["orderRefNo"]?.toString(), |
|
|
|
stockTake = toLong(r["isStockTake"]) == 1L, |
|
|
|
) |
|
|
|
} |
|
|
|
for (r in queryChunks( |
|
|
|
withLot.map { it.move.stockInLineId }, |
|
|
|
""" |
|
|
|
SELECT |
|
|
|
sil.id AS lineId, |
|
|
|
COALESCE(TRIM(jo.code), TRIM(po.code)) AS orderRefNo, |
|
|
|
COALESCE(TRIM(jo.code), '') AS jobOrderNo, |
|
|
|
CASE WHEN si.stockTakeId IS NOT NULL THEN 1 ELSE 0 END AS isStockTake |
|
|
|
FROM stock_in_line sil |
|
|
|
LEFT JOIN stock_in si |
|
|
|
ON si.id = sil.stockInId AND si.deleted = 0 |
|
|
|
LEFT JOIN job_order jo |
|
|
|
ON jo.id = sil.jobOrderId AND jo.deleted = 0 |
|
|
|
LEFT JOIN purchase_order po |
|
|
|
ON po.id = sil.purchaseOrderId AND po.deleted = 0 |
|
|
|
WHERE sil.deleted = 0 |
|
|
|
AND sil.id IN (:ids) |
|
|
|
""".trimIndent(), |
|
|
|
)) { |
|
|
|
inRefs[toLong(r["lineId"])] = InRef( |
|
|
|
orderRefNo = r["orderRefNo"]?.toString(), |
|
|
|
jobOrderNo = r["jobOrderNo"]?.toString().orEmpty(), |
|
|
|
stockTake = toLong(r["isStockTake"]) == 1L, |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
lot.lotNo AS lotNo, |
|
|
|
COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, |
|
|
|
lot.storeLocation AS storeLocation, |
|
|
|
val totals = HashMap<String, ItemTotal>() |
|
|
|
for (row in withLot) { |
|
|
|
val m = row.move |
|
|
|
val total = totals.getOrPut(m.itemCode) { ItemTotal() } |
|
|
|
total.inSum += m.inQty |
|
|
|
total.outSum += m.outQty |
|
|
|
val opening = m.bal - m.inQty + m.outQty |
|
|
|
if (total.isBeforeFirst(m.trnDate, m.slId)) { |
|
|
|
total.firstDate = m.trnDate |
|
|
|
total.firstId = m.slId |
|
|
|
total.opening = opening |
|
|
|
} |
|
|
|
if (total.isAfterLast(m.trnDate, m.slId)) { |
|
|
|
total.lastDate = m.trnDate |
|
|
|
total.lastId = m.slId |
|
|
|
total.closing = m.bal |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, |
|
|
|
COALESCE(TRIM(jo.code), '') AS jobOrderNo, |
|
|
|
val rows = timed("7.assemble", { list: List<Map<String, Any>> -> "rows=${list.size}" }) { |
|
|
|
val sorted = withLot.sortedWith( |
|
|
|
compareBy<MoveWithLot> { items[it.move.itemId]?.code.orEmpty() } |
|
|
|
.thenBy { it.move.trnDate } |
|
|
|
.thenBy { it.move.slId } |
|
|
|
.thenBy { it.lot.lotNo }, |
|
|
|
) |
|
|
|
sorted.map { row -> |
|
|
|
val m = row.move |
|
|
|
val item = items[m.itemId] |
|
|
|
val inRef = inRefs[m.stockInLineId] |
|
|
|
val outRef = outRefs[m.stockOutLineId] |
|
|
|
val norm = m.rawType.uppercase() |
|
|
|
val refType = when { |
|
|
|
norm == "TKE" -> "TKE" |
|
|
|
norm == "ADJ" && (outRef?.stockTake == true || inRef?.stockTake == true) -> "TKE" |
|
|
|
else -> m.rawType |
|
|
|
} |
|
|
|
val total = totals[m.itemCode] |
|
|
|
reportRow( |
|
|
|
stockSubCategory = item?.type.orEmpty(), |
|
|
|
itemNo = item?.code.orEmpty(), |
|
|
|
itemName = item?.name.orEmpty(), |
|
|
|
unitOfMeasure = item?.uom.orEmpty(), |
|
|
|
lotNo = row.lot.lotNo, |
|
|
|
expiryDate = row.lot.expiryDate, |
|
|
|
trnDate = m.trnDate, |
|
|
|
trnRefNo = typeLabel(refType), |
|
|
|
storeLocation = row.lot.storeLocation, |
|
|
|
orderRefNo = outRef?.orderRefNo ?: inRef?.orderRefNo ?: "", |
|
|
|
jobOrderNo = inRef?.jobOrderNo.orEmpty(), |
|
|
|
inQty = m.inQty, |
|
|
|
outQty = m.outQty, |
|
|
|
opening = m.bal - m.inQty + m.outQty, |
|
|
|
balance = m.bal, |
|
|
|
totalOpening = total?.opening ?: 0.0, |
|
|
|
totalIn = total?.inSum ?: 0.0, |
|
|
|
totalOut = total?.outSum ?: 0.0, |
|
|
|
totalBalance = total?.closing ?: 0.0, |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
'' AS openingBalance, |
|
|
|
'' AS cumStockIn, |
|
|
|
'' AS cumStockOut, |
|
|
|
'' AS currentBalance, |
|
|
|
'' AS lastInDate, |
|
|
|
'' AS lastOutDate, |
|
|
|
'' AS reOrderLevel, |
|
|
|
'' AS reOrderQty |
|
|
|
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 |
|
|
|
val totalMs = (System.nanoTime() - tAll) / 1_000_000 |
|
|
|
timings.add("search.total ${totalMs}ms") |
|
|
|
log.info("stock-ledger DONE {}", timings.joinToString(" | ")) |
|
|
|
return rows |
|
|
|
} |
|
|
|
|
|
|
|
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 |
|
|
|
private fun reportRow( |
|
|
|
stockSubCategory: String, |
|
|
|
itemNo: String, |
|
|
|
itemName: String, |
|
|
|
unitOfMeasure: String, |
|
|
|
lotNo: String, |
|
|
|
expiryDate: String, |
|
|
|
trnDate: String, |
|
|
|
trnRefNo: String, |
|
|
|
storeLocation: String, |
|
|
|
orderRefNo: String, |
|
|
|
jobOrderNo: String, |
|
|
|
inQty: Double, |
|
|
|
outQty: Double, |
|
|
|
opening: Double, |
|
|
|
balance: Double, |
|
|
|
totalOpening: Double, |
|
|
|
totalIn: Double, |
|
|
|
totalOut: Double, |
|
|
|
totalBalance: Double, |
|
|
|
): Map<String, Any> { |
|
|
|
val row = HashMap<String, Any>(32) |
|
|
|
row["stockSubCategory"] = stockSubCategory |
|
|
|
row["itemNo"] = itemNo |
|
|
|
row["itemName"] = itemName |
|
|
|
row["unitOfMeasure"] = unitOfMeasure |
|
|
|
row["lotNo"] = lotNo |
|
|
|
row["expiryDate"] = expiryDate |
|
|
|
row["trnDate"] = trnDate |
|
|
|
row["trnRefNo"] = trnRefNo |
|
|
|
row["storeLocation"] = storeLocation |
|
|
|
row["orderRefNo"] = orderRefNo |
|
|
|
row["jobOrderNo"] = jobOrderNo |
|
|
|
row["openingBalance"] = "" |
|
|
|
row["cumStockIn"] = "" |
|
|
|
row["cumStockOut"] = "" |
|
|
|
row["currentBalance"] = "" |
|
|
|
row["lastInDate"] = "" |
|
|
|
row["lastOutDate"] = "" |
|
|
|
row["reOrderLevel"] = "" |
|
|
|
row["reOrderQty"] = "" |
|
|
|
row["stockIn"] = formatQty(inQty) |
|
|
|
row["stockOut"] = formatQty(outQty) |
|
|
|
row["cumOpeningBal"] = formatQty(opening) |
|
|
|
row["cumBalance"] = formatQty(balance) |
|
|
|
row["totalCumOpeningBal"] = formatQty(totalOpening) |
|
|
|
row["totalStockIn"] = formatQty(totalIn) |
|
|
|
row["totalStockOut"] = formatQty(totalOut) |
|
|
|
row["totalCumBalance"] = formatQty(totalBalance) |
|
|
|
return row |
|
|
|
} |
|
|
|
|
|
|
|
LEFT JOIN ( |
|
|
|
SELECT |
|
|
|
il.id AS lotId, |
|
|
|
il.lotNo AS lotNo, |
|
|
|
il.expiryDate AS expiryDate, |
|
|
|
MAX(wh.code) AS storeLocation |
|
|
|
FROM inventory_lot il |
|
|
|
LEFT JOIN inventory_lot_line ill |
|
|
|
ON ill.inventoryLotId = il.id |
|
|
|
AND ill.deleted = 0 |
|
|
|
LEFT JOIN warehouse wh |
|
|
|
ON ill.warehouseId = wh.id |
|
|
|
AND wh.deleted = 0 |
|
|
|
WHERE il.deleted = 0 |
|
|
|
GROUP BY |
|
|
|
il.id, il.lotNo, il.expiryDate |
|
|
|
) lot |
|
|
|
ON lot.lotId = COALESCE(il_in.id, il_out.id) |
|
|
|
private fun typeLabel(raw: String): String = when (raw.uppercase()) { |
|
|
|
"OPEN" -> "開倉" |
|
|
|
"NOR" -> "出入倉" |
|
|
|
"TRF" -> "轉倉" |
|
|
|
"ADJ" -> "調整" |
|
|
|
"MISS" -> "遺失" |
|
|
|
"BAD" -> "不良品" |
|
|
|
"TKE" -> "盤點" |
|
|
|
else -> raw |
|
|
|
} |
|
|
|
|
|
|
|
LEFT JOIN items it |
|
|
|
ON sl.itemId = it.id |
|
|
|
AND it.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 |
|
|
|
/** Same shape as MySQL FORMAT(n, 0), negatives in parentheses. */ |
|
|
|
private fun formatQty(value: Double): String { |
|
|
|
val rounded = BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP) |
|
|
|
val negative = rounded.signum() < 0 |
|
|
|
val digits = rounded.abs().toPlainString() |
|
|
|
val grouped = digits.reversed().chunked(3).joinToString(",").reversed() |
|
|
|
return if (negative) "($grouped)" else grouped |
|
|
|
} |
|
|
|
|
|
|
|
LEFT JOIN stock_out so |
|
|
|
ON sol.stockOutId = so.id |
|
|
|
AND so.deleted = 0 |
|
|
|
LEFT JOIN pick_order_line pol |
|
|
|
ON sol.pickOrderLineId = pol.id |
|
|
|
AND pol.deleted = 0 |
|
|
|
LEFT JOIN pick_order po_out |
|
|
|
ON pol.poId = po_out.id |
|
|
|
AND po_out.deleted = 0 |
|
|
|
LEFT JOIN job_order jo_po |
|
|
|
ON po_out.joId = jo_po.id |
|
|
|
AND jo_po.deleted = 0 |
|
|
|
LEFT JOIN delivery_order do |
|
|
|
ON po_out.doId = do.id |
|
|
|
AND do.deleted = 0 |
|
|
|
LEFT JOIN stock_in si |
|
|
|
ON sil.stockInId = si.id |
|
|
|
AND si.deleted = 0 |
|
|
|
LEFT JOIN job_order jo |
|
|
|
ON sil.jobOrderId = jo.id |
|
|
|
AND jo.deleted = 0 |
|
|
|
LEFT JOIN purchase_order po |
|
|
|
ON sil.purchaseOrderId = po.id |
|
|
|
AND po.deleted = 0 |
|
|
|
WHERE |
|
|
|
sl.deleted = 0 |
|
|
|
AND sl.itemCode IS NOT NULL |
|
|
|
AND sl.itemCode <> '' |
|
|
|
AND sl.date >= :reportPeriodStart |
|
|
|
AND sl.date < :reportPeriodEndExclusive |
|
|
|
$stockCategorySql |
|
|
|
$itemCodeSql |
|
|
|
$storeLocationSql |
|
|
|
AND lot.lotId IS NOT NULL |
|
|
|
) x |
|
|
|
) y |
|
|
|
ORDER BY |
|
|
|
itemNo, |
|
|
|
trnDateRaw, |
|
|
|
slId, |
|
|
|
lotNo |
|
|
|
""".trimIndent() |
|
|
|
private fun buildCategoryItemFilter( |
|
|
|
stockCategory: String?, |
|
|
|
args: MutableMap<String, Any>, |
|
|
|
): String { |
|
|
|
if (stockCategory.isNullOrBlank()) return "" |
|
|
|
val values = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() } |
|
|
|
if (values.isEmpty()) return "" |
|
|
|
val ors = values.mapIndexed { index, value -> |
|
|
|
val name = "stockCategory_$index" |
|
|
|
args[name] = value |
|
|
|
"it.type = :$name" |
|
|
|
} |
|
|
|
return """ |
|
|
|
AND sl.itemId IN ( |
|
|
|
SELECT it.id FROM items it |
|
|
|
WHERE it.deleted = 0 AND (${ors.joinToString(" OR ")}) |
|
|
|
) |
|
|
|
""".trimIndent() |
|
|
|
} |
|
|
|
|
|
|
|
return jdbcDao.queryForList(sql, args) |
|
|
|
private fun queryChunks(ids: Collection<Long>, sql: String): List<Map<String, Any>> { |
|
|
|
val distinct = ids.filter { it > 0 }.distinct() |
|
|
|
if (distinct.isEmpty()) return emptyList() |
|
|
|
val out = ArrayList<Map<String, Any>>() |
|
|
|
for (chunk in distinct.chunked(800)) { |
|
|
|
out.addAll(jdbcDao.queryForList(sql, mapOf("ids" to chunk))) |
|
|
|
} |
|
|
|
return out |
|
|
|
} |
|
|
|
|
|
|
|
/** LIKE 多值工具方法 */ |
|
|
|
private fun buildMultiValueLikeClause( |
|
|
|
paramValue: String?, |
|
|
|
columnName: String, |
|
|
|
paramPrefix: String, |
|
|
|
args: MutableMap<String, Any> |
|
|
|
args: MutableMap<String, Any>, |
|
|
|
): String { |
|
|
|
if (paramValue.isNullOrBlank()) return "" |
|
|
|
val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } |
|
|
|
if (values.isEmpty()) return "" |
|
|
|
|
|
|
|
val conditions = values.mapIndexed { index, value -> |
|
|
|
val paramName = "${paramPrefix}_$index" |
|
|
|
args[paramName] = "%$value%" |
|
|
|
@@ -297,22 +537,61 @@ ORDER BY |
|
|
|
return "AND (${conditions.joinToString(" OR ")})" |
|
|
|
} |
|
|
|
|
|
|
|
/** = 多值工具方法 */ |
|
|
|
private fun buildMultiValueExactClause( |
|
|
|
paramValue: String?, |
|
|
|
columnName: String, |
|
|
|
paramPrefix: String, |
|
|
|
args: MutableMap<String, Any> |
|
|
|
): String { |
|
|
|
if (paramValue.isNullOrBlank()) return "" |
|
|
|
val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } |
|
|
|
if (values.isEmpty()) return "" |
|
|
|
private fun toDouble(v: Any?): Double { |
|
|
|
if (v == null) return 0.0 |
|
|
|
if (v is Number) return v.toDouble() |
|
|
|
return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0 |
|
|
|
} |
|
|
|
|
|
|
|
val conditions = values.mapIndexed { index, value -> |
|
|
|
val paramName = "${paramPrefix}_$index" |
|
|
|
args[paramName] = value |
|
|
|
"$columnName = :$paramName" |
|
|
|
private fun toLong(v: Any?): Long { |
|
|
|
if (v == null) return 0L |
|
|
|
if (v is Number) return v.toLong() |
|
|
|
return v.toString().toLongOrNull() ?: 0L |
|
|
|
} |
|
|
|
|
|
|
|
private data class LedgerMove( |
|
|
|
val slId: Long, |
|
|
|
val trnDate: String, |
|
|
|
val rawType: String, |
|
|
|
val itemId: Long, |
|
|
|
val itemCode: String, |
|
|
|
val inQty: Double, |
|
|
|
val outQty: Double, |
|
|
|
val bal: Double, |
|
|
|
val lotLineId: Long, |
|
|
|
val stockInLineId: Long, |
|
|
|
val stockOutLineId: Long, |
|
|
|
) |
|
|
|
|
|
|
|
private data class MoveWithLot( |
|
|
|
val move: LedgerMove, |
|
|
|
val lot: LotInfo, |
|
|
|
) |
|
|
|
|
|
|
|
private data class LotInfo( |
|
|
|
val lotNo: String, |
|
|
|
val expiryDate: String, |
|
|
|
val storeLocation: String, |
|
|
|
) |
|
|
|
|
|
|
|
private class ItemTotal { |
|
|
|
var firstDate: String? = null |
|
|
|
var firstId: Long = 0L |
|
|
|
var lastDate: String? = null |
|
|
|
var lastId: Long = 0L |
|
|
|
var opening: Double = 0.0 |
|
|
|
var closing: Double = 0.0 |
|
|
|
var inSum: Double = 0.0 |
|
|
|
var outSum: Double = 0.0 |
|
|
|
|
|
|
|
fun isBeforeFirst(date: String, id: Long): Boolean { |
|
|
|
val current = firstDate ?: return true |
|
|
|
return date < current || (date == current && id < firstId) |
|
|
|
} |
|
|
|
|
|
|
|
fun isAfterLast(date: String, id: Long): Boolean { |
|
|
|
val current = lastDate ?: return true |
|
|
|
return date > current || (date == current && id > lastId) |
|
|
|
} |
|
|
|
return "AND (${conditions.joinToString(" OR ")})" |
|
|
|
} |
|
|
|
} |