From 97ee03e82ef20a26ee8d7c49953cd9a772c669ec Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Wed, 16 Sep 2026 12:25:06 +0800 Subject: [PATCH] Stock balance report: download time improved --- .../modules/report/service/ReportService.kt | 281 ++++++++++-------- 1 file changed, 165 insertions(+), 116 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 392733c..481024d 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -12,9 +12,11 @@ import com.ffii.fpsms.modules.master.enums.ShopType import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService import java.math.BigDecimal +import java.time.LocalDate import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter import net.sf.jasperreports.export.SimpleExporterInput import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput +import org.slf4j.LoggerFactory @Service open class ReportService( @@ -23,6 +25,7 @@ open class ReportService( private val shopRepository: ShopRepository, private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { + private val log = LoggerFactory.getLogger(javaClass) /** * Queries the database for inventory data based on dates and optional item type. */ @@ -1495,13 +1498,16 @@ return result } /** - * Stock Balance Report (date-driven). + * FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.1 | 2026-09-16 + * Stock Balance Report (date-driven). One summarized row per item. * - * - 期初存量: stockDate - 1 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序) - * - 現存存貨: stockDate 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序) - * - 單位均價: 依 item 判斷是否 BOM - * - BOM: 用 delivery_order_line (prefer up, else price/qty, else price) 算加權平均 - * - 非 BOM: 用 purchase_order_line (prefer up, else price/qty, else price) 算加權平均 + * Qty comes from [stock_lot_day] (daily lot close) instead of scanning all stock_ledger: + * - 期初存量: historical = that day's opening; today = yesterday's closing + * - 現存存貨: historical = that day's closing; today = inventory.onHandQty + * (today is not closed yet). If the day row is missing, fall back to previous + * close + that day's ledger net. + * - 當日入/出/MISS/BAD/TKE: that calendar day's stock_ledger only + * - 單位均價: items.AverageUnitPrice * - 庫存總價值: 單位均價 * 現存存貨 */ fun searchStockBalanceReportByDate( @@ -1514,101 +1520,80 @@ return result ): List> { val args = mutableMapOf() val formattedStockDate = stockDate.replace("/", "-") - args["stockDate"] = formattedStockDate + val stockLocalDate = LocalDate.parse(formattedStockDate) + val useLiveInventory = !stockLocalDate.isBefore(LocalDate.now()) + val liveFlag = if (useLiveInventory) "1" else "0" + args["d0"] = stockLocalDate + args["d1"] = stockLocalDate.minusDays(1) + args["d0Start"] = stockLocalDate.atStartOfDay() + args["d0EndExclusive"] = stockLocalDate.plusDays(1).atStartOfDay() val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args) val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) + val itemCodeSqlDay = buildMultiValueLikeClause(itemCode, "d.itemCode", "itemCodeDay", args) + val itemCodeSqlSl = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCodeSl", args) val storeLocationSql = if (!storeLocation.isNullOrBlank()) { args["storeLocation"] = "%$storeLocation%" - "AND COALESCE(store_location.storeLocation, '') LIKE :storeLocation" + """ + AND EXISTS ( + SELECT 1 + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 + INNER JOIN warehouse wh ON wh.id = ill.warehouseId AND wh.deleted = 0 + WHERE ill.deleted = 0 + AND il.itemId = q.itemId + AND wh.code LIKE :storeLocation + ) + """.trimIndent() } else "" + val forceLedgerIndex = if (itemCode.isNullOrBlank()) { + "FORCE INDEX (idx_sl_deleted_date)" + } else { + "" + } + val inventoryScopeUnion = if (useLiveInventory) { + """ + UNION + SELECT itemId FROM inventory_per_item WHERE currentBalance <> 0 + """.trimIndent() + } else { + "" + } val baseSql = """ - WITH params AS ( + WITH day0 AS ( SELECT - DATE(:stockDate) AS d0, - DATE_SUB(DATE(:stockDate), INTERVAL 1 DAY) AS d1 + d.itemId, + SUM(COALESCE(d.opening, 0)) AS openingBalance, + SUM(COALESCE(d.closing, 0)) AS currentBalance + FROM stock_lot_day d + WHERE IFNULL(d.deleted, 0) = 0 + AND d.date = :d0 + AND d.itemId IS NOT NULL + $itemCodeSqlDay + GROUP BY d.itemId ), - 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 + day1 AS ( + SELECT + d.itemId, + SUM(COALESCE(d.closing, 0)) AS openingBalance + FROM stock_lot_day d + WHERE IFNULL(d.deleted, 0) = 0 + AND d.date = :d1 + AND d.itemId IS NOT NULL + $itemCodeSqlDay + GROUP BY d.itemId ), - item_scope AS ( + inventory_per_item 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 <> '' + iv.itemId, + SUM(COALESCE(iv.onHandQty, 0)) AS currentBalance + FROM inventory iv + INNER JOIN items it ON it.id = iv.itemId AND it.deleted = 0 + WHERE iv.deleted = 0 $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 - 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 + GROUP BY iv.itemId ), ledger_moves AS ( SELECT @@ -1651,14 +1636,74 @@ return result 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 + ) AS variance, + SUM(COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS dayNet + FROM stock_ledger sl $forceLedgerIndex WHERE sl.deleted = 0 AND sl.itemId IS NOT NULL - AND DATE(sl.date) > p.d1 - AND DATE(sl.date) <= p.d0 + AND sl.date >= :d0Start + AND sl.date < :d0EndExclusive + $itemCodeSqlSl GROUP BY sl.itemId + ), + item_ids AS ( + SELECT itemId FROM day0 + UNION + SELECT itemId FROM day1 + UNION + SELECT itemId FROM ledger_moves + $inventoryScopeUnion + ), + item_scope AS ( + 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 + INNER JOIN item_ids ids ON ids.itemId = it.id + WHERE it.deleted = 0 + AND it.code IS NOT NULL AND it.code <> '' + $itemCodeSql + $stockCategorySql + ), + stock_one AS ( + SELECT iu.* + FROM item_uom iu + INNER JOIN ( + SELECT itemId, MIN(id) AS id + FROM item_uom + WHERE deleted = 0 AND stockUnit = 1 + GROUP BY itemId + ) x ON x.id = iu.id + ), + qty_per_item AS ( + SELECT + ids.itemId, + COALESCE( + CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END, + d1.openingBalance, + 0 + ) AS openingBalance, + COALESCE( + CASE WHEN $liveFlag = 1 THEN invc.currentBalance ELSE d0.currentBalance END, + COALESCE( + CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END, + d1.openingBalance, + 0 + ) + COALESCE(lm.dayNet, 0), + 0 + ) AS currentBalance, + COALESCE(lm.cumStockIn, 0) AS cumStockIn, + COALESCE(lm.cumStockOut, 0) AS cumStockOut, + COALESCE(lm.misInputAndLost, 0) AS misInputAndLost, + COALESCE(lm.variance, 0) AS variance, + COALESCE(lm.defectiveGoods, 0) AS defectiveGoods + FROM item_ids ids + LEFT JOIN day0 d0 ON d0.itemId = ids.itemId + LEFT JOIN day1 d1 ON d1.itemId = ids.itemId + LEFT JOIN inventory_per_item invc ON invc.itemId = ids.itemId + LEFT JOIN ledger_moves lm ON lm.itemId = ids.itemId ) SELECT '' AS stockSubCategory, @@ -1672,31 +1717,27 @@ return result '' 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 storeLocation, + '' AS lastInDate, + '' AS lastOutDate, + q.openingBalance AS openingBalanceRaw, + q.currentBalance AS currentBalanceRaw, + CASE WHEN q.openingBalance < 0 THEN CONCAT('(', FORMAT(-q.openingBalance, 0), ')') ELSE FORMAT(q.openingBalance, 0) END AS totalOpeningBalance, + CASE WHEN q.cumStockIn < 0 THEN CONCAT('(', FORMAT(-q.cumStockIn, 0), ')') ELSE FORMAT(q.cumStockIn, 0) END AS totalCumStockIn, + CASE WHEN q.cumStockOut < 0 THEN CONCAT('(', FORMAT(-q.cumStockOut, 0), ')') ELSE FORMAT(q.cumStockOut, 0) END AS totalCumStockOut, + CASE WHEN q.currentBalance < 0 THEN CONCAT('(', FORMAT(-q.currentBalance, 0), ')') ELSE FORMAT(q.currentBalance, 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, + CASE WHEN q.misInputAndLost < 0 THEN CONCAT('(', FORMAT(-q.misInputAndLost, 0), ')') ELSE FORMAT(q.misInputAndLost, 0) END AS totalMisInputAndLost, + CASE WHEN q.variance < 0 THEN CONCAT('(', FORMAT(-q.variance, 0), ')') ELSE FORMAT(q.variance, 0) END AS totalVariance, + CASE WHEN q.defectiveGoods < 0 THEN CONCAT('(', FORMAT(-q.defectiveGoods, 0), ')') ELSE FORMAT(q.defectiveGoods, 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 + FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0) * COALESCE(q.currentBalance, 0), 2), 2) AS totalStockBalance + FROM qty_per_item q + INNER JOIN item_scope s ON s.itemId = q.itemId + LEFT JOIN stock_one so ON so.itemId = q.itemId + LEFT JOIN uom_conversion uc ON so.uomId = uc.id WHERE 1=1 $storeLocationSql """.trimIndent() @@ -1713,10 +1754,8 @@ return result val finalSql = if (filters.isEmpty()) { - // no numeric filter: just order by itemNo baseSql } else { - // wrap to filter on raw numeric current balance """ SELECT * FROM ( $baseSql @@ -1725,7 +1764,17 @@ return result """.trimIndent() } - return jdbcDao.queryForList("$finalSql ORDER BY itemNo", args) + val t0 = System.nanoTime() + val rows = jdbcDao.queryForList("$finalSql ORDER BY itemNo", args) + log.info( + "stock-balance-by-date date={} liveInventory={} itemCode={} rows={} {}ms", + stockLocalDate, + useLiveInventory, + itemCode ?: "", + rows.size, + (System.nanoTime() - t0) / 1_000_000, + ) + return rows } /** * Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template.