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 481024d..a4ba7f0 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 @@ -26,6 +26,7 @@ open class ReportService( private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { private val log = LoggerFactory.getLogger(javaClass) + private val compiledReports = java.util.concurrent.ConcurrentHashMap() /** * Queries the database for inventory data based on dates and optional item type. */ @@ -1498,8 +1499,8 @@ return result } /** - * FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.1 | 2026-09-16 - * Stock Balance Report (date-driven). One summarized row per item. + * FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.2 | 2026-09-21 + * Stock Balance Report (date-driven). One summarized row per item + stock UOM. * * Qty comes from [stock_lot_day] (daily lot close) instead of scanning all stock_ledger: * - 期初存量: historical = that day's opening; today = yesterday's closing @@ -1507,8 +1508,8 @@ return result * (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 - * - 庫存總價值: 單位均價 * 現存存貨 + * - 單位均價: items.AverageUnitPrice (HKD / stock UOM), converted by item_uom ratio + * - 庫存總價值: 該 UOM 均價 * 該 UOM 現存存貨 */ fun searchStockBalanceReportByDate( stockCategory: String?, @@ -1540,8 +1541,10 @@ return result 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 + LEFT JOIN item_uom iu_loc ON iu_loc.id = ill.stockItemUomId AND iu_loc.deleted = 0 WHERE ill.deleted = 0 AND il.itemId = q.itemId + AND iu_loc.uomId <=> q.uomId AND wh.code LIKE :storeLocation ) """.trimIndent() @@ -1554,7 +1557,7 @@ return result val inventoryScopeUnion = if (useLiveInventory) { """ UNION - SELECT itemId FROM inventory_per_item WHERE currentBalance <> 0 + SELECT itemId, uomId FROM inventory_per_item WHERE currentBalance <> 0 """.trimIndent() } else { "" @@ -1564,6 +1567,7 @@ return result WITH day0 AS ( SELECT d.itemId, + d.uomId, SUM(COALESCE(d.opening, 0)) AS openingBalance, SUM(COALESCE(d.closing, 0)) AS currentBalance FROM stock_lot_day d @@ -1571,33 +1575,36 @@ return result AND d.date = :d0 AND d.itemId IS NOT NULL $itemCodeSqlDay - GROUP BY d.itemId + GROUP BY d.itemId, d.uomId ), day1 AS ( SELECT d.itemId, + d.uomId, 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 + GROUP BY d.itemId, d.uomId ), inventory_per_item AS ( SELECT iv.itemId, + iv.stockUomId AS uomId, 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 - GROUP BY iv.itemId + GROUP BY iv.itemId, iv.stockUomId ), ledger_moves AS ( SELECT sl.itemId, + sl.uomId, SUM( CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') @@ -1644,14 +1651,14 @@ return result AND sl.date >= :d0Start AND sl.date < :d0EndExclusive $itemCodeSqlSl - GROUP BY sl.itemId + GROUP BY sl.itemId, sl.uomId ), item_ids AS ( - SELECT itemId FROM day0 + SELECT itemId, uomId FROM day0 UNION - SELECT itemId FROM day1 + SELECT itemId, uomId FROM day1 UNION - SELECT itemId FROM ledger_moves + SELECT itemId, uomId FROM ledger_moves $inventoryScopeUnion ), item_scope AS ( @@ -1661,9 +1668,9 @@ return result 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 <> '' + AND it.id IN (SELECT DISTINCT itemId FROM item_ids) $itemCodeSql $stockCategorySql ), @@ -1680,6 +1687,7 @@ return result qty_per_item AS ( SELECT ids.itemId, + ids.uomId, COALESCE( CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END, d1.openingBalance, @@ -1700,10 +1708,10 @@ return result 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 + LEFT JOIN day0 d0 ON d0.itemId = ids.itemId AND d0.uomId <=> ids.uomId + LEFT JOIN day1 d1 ON d1.itemId = ids.itemId AND d1.uomId <=> ids.uomId + LEFT JOIN inventory_per_item invc ON invc.itemId = ids.itemId AND invc.uomId <=> ids.uomId + LEFT JOIN ledger_moves lm ON lm.itemId = ids.itemId AND lm.uomId <=> ids.uomId ) SELECT '' AS stockSubCategory, @@ -1732,12 +1740,46 @@ return result 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(q.currentBalance, 0), 2), 2) AS totalStockBalance + FORMAT(ROUND( + CASE + WHEN iu.id IS NULL OR so.id IS NULL THEN COALESCE(s.avgUnitPriceRaw, 0) + WHEN iu.uomId <=> so.uomId THEN COALESCE(s.avgUnitPriceRaw, 0) + ELSE ROUND( + COALESCE(s.avgUnitPriceRaw, 0) + * (COALESCE(iu.ratioN, 1) / COALESCE(NULLIF(iu.ratioD, 0), 1)) + * (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)), + 4 + ) + END + , 2), 2) AS avgUnitPrice, + FORMAT(ROUND( + CASE + WHEN iu.id IS NULL OR so.id IS NULL THEN COALESCE(s.avgUnitPriceRaw, 0) + WHEN iu.uomId <=> so.uomId THEN COALESCE(s.avgUnitPriceRaw, 0) + ELSE ROUND( + COALESCE(s.avgUnitPriceRaw, 0) + * (COALESCE(iu.ratioN, 1) / COALESCE(NULLIF(iu.ratioD, 0), 1)) + * (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)), + 4 + ) + END + * 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 + LEFT JOIN item_uom iu + ON iu.itemId = q.itemId + AND iu.deleted = 0 + AND iu.uomId <=> q.uomId + AND iu.id = ( + SELECT MIN(iu2.id) + FROM item_uom iu2 + WHERE iu2.itemId = q.itemId + AND iu2.deleted = 0 + AND iu2.uomId <=> q.uomId + ) + LEFT JOIN uom_conversion uc ON uc.id = COALESCE(iu.uomId, q.uomId) WHERE 1=1 $storeLocationSql """.trimIndent() @@ -1765,7 +1807,7 @@ return result } val t0 = System.nanoTime() - val rows = jdbcDao.queryForList("$finalSql ORDER BY itemNo", args) + val rows = jdbcDao.queryForList("$finalSql ORDER BY itemNo, unitOfMeasure", args) log.info( "stock-balance-by-date date={} liveInventory={} itemCode={} rows={} {}ms", stockLocalDate, @@ -1780,9 +1822,7 @@ return result * Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template. */ fun createExcelResponse(templatePath: String, params: Map, dataList: List>): ByteArray { - val stream = this::class.java.getResourceAsStream(templatePath) - ?: throw RuntimeException("Report template not found: $templatePath") - val jasperReport = JasperCompileManager.compileReport(stream) + val jasperReport = compiledReport(templatePath) val dataSource = JRMapCollectionDataSource(dataList) val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) val out = ByteArrayOutputStream() @@ -1797,17 +1837,20 @@ return result * Compiles and fills a Jasper Report, returning the PDF as a ByteArray. */ fun createPdfResponse(templatePath: String, params: Map, dataList: List>): ByteArray { - val stream = this::class.java.getResourceAsStream(templatePath) - ?: throw RuntimeException("Report template not found: $templatePath") - - val jasperReport = JasperCompileManager.compileReport(stream) - + val jasperReport = compiledReport(templatePath) val dataSource = JRMapCollectionDataSource(dataList) - val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) return JasperExportManager.exportReportToPdf(jasperPrint) } + private fun compiledReport(templatePath: String): JasperReport { + return compiledReports.computeIfAbsent(templatePath) { + val stream = this::class.java.getResourceAsStream(templatePath) + ?: throw RuntimeException("Report template not found: $templatePath") + stream.use { JasperCompileManager.compileReport(it) } + } + } + fun mergePdfBytes(parts: List): ByteArray { if (parts.isEmpty()) return ByteArray(0) if (parts.size == 1) return parts[0] diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 55c546b..139c71d 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -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> { + val tAll = System.nanoTime() + val timings = mutableListOf() + fun 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() - - 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()).firstOrNull() - (minDateRow?.get("firstDate") as? String) - ?: reportPeriodEnd + """.trimIndent(), + emptyMap(), + ).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( + "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 -> "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>() + val lineByOut = HashMap() + 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(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() + val lotByHeader = HashMap() + 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(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() + 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() + val outRefs = HashMap() + 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() + 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> -> "rows=${list.size}" }) { + val sorted = withLot.sortedWith( + compareBy { 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 { + val row = HashMap(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 { + 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, sql: String): List> { + val distinct = ids.filter { it > 0 }.distinct() + if (distinct.isEmpty()) return emptyList() + val out = ArrayList>() + 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 + args: MutableMap, ): 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 { - 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 ")})" } } diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt index 5c2d666..c6bdb0f 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt @@ -159,7 +159,7 @@ class FGStockOutTraceabilityReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { @@ -216,7 +216,7 @@ class FGStockOutTraceabilityReportController( alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") borderTop = BorderStyle.THICK borderBottom = BorderStyle.THICK borderLeft = BorderStyle.THIN diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt index 73902a8..01b4697 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt @@ -271,7 +271,7 @@ class ItemQcFailReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df = workbook.creationHelper.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") } val decimalNumberStyle = workbook.createCellStyle().apply { @@ -282,7 +282,7 @@ class ItemQcFailReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df = workbook.creationHelper.createDataFormat() - dataFormat = df.getFormat("#,##0.##") + dataFormat = df.getFormat("#,##0.00") } val headers = listOf( diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt index 17834af..0000e71 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt @@ -184,7 +184,7 @@ class MaterialStockOutTraceabilityReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { @@ -222,7 +222,7 @@ class MaterialStockOutTraceabilityReportController( alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") borderTop = BorderStyle.THIN borderBottom = BorderStyle.THICK borderLeft = BorderStyle.THIN diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index 324af16..7875a41 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -482,7 +482,7 @@ class ReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { alignment = HorizontalAlignment.RIGHT @@ -874,7 +874,7 @@ class ReportController( setTextCell(r, 4, m["dnNo"], styles.text) setTextCell(r, 5, m["deliveryOrderNo"], styles.text) setTextCell(r, 6, m["customerName"], styles.text) - setNumberCellFromFormatted(r, 7, m["qty"], styles.int, styles.dash, preferInt = true) + setNumberCellFromFormatted(r, 7, m["qty"], styles.int, styles.dash, preferInt = false) setTextCell(r, 8, m["truckNo"], styles.center) itemTotalQty += parseSignedNumber(m["qty"]) ?: 0.0 @@ -956,13 +956,13 @@ class ReportController( setTextCell(r, 0, m["itemNo"], styles.text) setTextCell(r, 1, m["itemName"], styles.text) setTextCell(r, 2, m["unitOfMeasure"], styles.center) - setNumberCellFromFormatted(r, 3, m["totalOpeningBalance"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 4, m["totalCumStockIn"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 5, m["totalCumStockOut"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 6, m["totalMisInputAndLost"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.int, styles.dash, preferInt = true) + setNumberCellFromFormatted(r, 3, m["totalOpeningBalance"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 4, m["totalCumStockIn"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 5, m["totalCumStockOut"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 6, m["totalMisInputAndLost"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.number, styles.dash, preferInt = false) setNumberCellFromFormatted(r, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true) setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true) } diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt index 2362797..7bc1084 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt @@ -174,7 +174,7 @@ class SemiFGProductionAnalysisReportController( borderBottom = BorderStyle.THIN borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN - dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") + dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt index 97a5abd..fae51b4 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt @@ -191,7 +191,7 @@ class StockItemConsumptionTrendReportController( borderBottom = BorderStyle.THIN borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN - dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") + dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt index 5345032..01b5bf2 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt @@ -1,4 +1,5 @@ package com.ffii.fpsms.modules.report.web +import org.slf4j.LoggerFactory import org.springframework.web.bind.annotation.* import org.springframework.http.* import java.time.LocalDate @@ -26,6 +27,7 @@ class StockLedgerReportController( private val reportService: ReportService, private val stockLedgerReportService: StockLedgerReportService, ) { + private val log = LoggerFactory.getLogger(StockLedgerReportController::class.java) private data class ExcelStyles( val title: CellStyle, val subtitle: CellStyle, @@ -64,6 +66,7 @@ class StockLedgerReportController( parameters["reportPeriodStart"] = reportPeriodStart ?: "" parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" + val tSearch = System.nanoTime() val dbData = stockLedgerReportService.searchStockLedgerReport( stockCategory = stockCategory, itemCode = itemCode, @@ -71,12 +74,20 @@ class StockLedgerReportController( reportPeriodStart = reportPeriodStart, reportPeriodEnd = reportPeriodEnd, ) - + val searchMs = (System.nanoTime() - tSearch) / 1_000_000 + val tPdf = System.nanoTime() val pdfBytes = reportService.createPdfResponse( "/jasper/StockLedgarReport.jrxml", parameters, dbData, ) + log.info( + "stock-ledger pdf rows={} search={}ms pdf={}ms bytes={}", + dbData.size, + searchMs, + (System.nanoTime() - tPdf) / 1_000_000, + pdfBytes.size, + ) val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_PDF @@ -95,6 +106,7 @@ class StockLedgerReportController( @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, ): ResponseEntity { + val tSearch = System.nanoTime() val dbData = stockLedgerReportService.searchStockLedgerReport( stockCategory = stockCategory, itemCode = itemCode, @@ -102,12 +114,20 @@ class StockLedgerReportController( reportPeriodStart = reportPeriodStart, reportPeriodEnd = reportPeriodEnd, ) - + val searchMs = (System.nanoTime() - tSearch) / 1_000_000 + val tExcel = System.nanoTime() val excelBytes = createStockLedgerExcel( dbData = dbData, reportPeriodStart = reportPeriodStart ?: "", reportPeriodEnd = reportPeriodEnd ?: "", ) + log.info( + "stock-ledger excel rows={} search={}ms excel={}ms bytes={}", + dbData.size, + searchMs, + (System.nanoTime() - tExcel) / 1_000_000, + excelBytes.size, + ) val headers = HttpHeaders().apply { contentType = MediaType.parseMediaType( @@ -174,7 +194,7 @@ class StockLedgerReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT @@ -188,7 +208,7 @@ class StockLedgerReportController( alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") borderTop = BorderStyle.THICK borderBottom = BorderStyle.THICK borderLeft = BorderStyle.THIN @@ -270,7 +290,7 @@ class StockLedgerReportController( return } if (parsed < 0) { - val s = "%,d".format(kotlin.math.abs(parsed).toLong()) + val s = "%,.2f".format(kotlin.math.abs(parsed)) cell.setCellValue("($s)") cell.cellStyle = dashStyle } else { diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt index 818647d..0f51091 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt @@ -151,7 +151,7 @@ class StockLotBalanceReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0.##") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt index 083d558..d8ac95d 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt @@ -145,7 +145,7 @@ class StockLotLedgerReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0.##") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt index bfaca59..b457e2c 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt @@ -328,7 +328,7 @@ class StockTakeVarianceReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { @@ -344,7 +344,7 @@ class StockTakeVarianceReportController( alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") borderTop = BorderStyle.THICK borderBottom = BorderStyle.THICK borderLeft = BorderStyle.THIN @@ -578,8 +578,7 @@ class StockTakeVarianceReportController( } private fun formatAbsInt(v: Double): String { - val abs = kotlin.math.abs(v).toLong() - return "%,d".format(abs) + return "%,.2f".format(kotlin.math.abs(v)) } private data class Rep012ReportData( diff --git a/src/main/resources/jasper/StockBalanceReport.jrxml b/src/main/resources/jasper/StockBalanceReport.jrxml index 072560b..b31e17a 100644 --- a/src/main/resources/jasper/StockBalanceReport.jrxml +++ b/src/main/resources/jasper/StockBalanceReport.jrxml @@ -71,7 +71,7 @@ - + diff --git a/src/main/resources/jasper/StockLedgarReport.jrxml b/src/main/resources/jasper/StockLedgarReport.jrxml index a245642..a85ee22 100644 --- a/src/main/resources/jasper/StockLedgarReport.jrxml +++ b/src/main/resources/jasper/StockLedgarReport.jrxml @@ -73,7 +73,7 @@ - +