| @@ -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<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| 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. | |||