| @@ -26,6 +26,7 @@ open class ReportService( | |||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | ||||
| ) { | ) { | ||||
| private val log = LoggerFactory.getLogger(javaClass) | private val log = LoggerFactory.getLogger(javaClass) | ||||
| private val compiledReports = java.util.concurrent.ConcurrentHashMap<String, JasperReport>() | |||||
| /** | /** | ||||
| * Queries the database for inventory data based on dates and optional item type. | * 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: | * 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 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 | * (today is not closed yet). If the day row is missing, fall back to previous | ||||
| * close + that day's ledger net. | * close + that day's ledger net. | ||||
| * - 當日入/出/MISS/BAD/TKE: that calendar day's stock_ledger only | * - 當日入/出/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( | fun searchStockBalanceReportByDate( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| @@ -1540,8 +1541,10 @@ return result | |||||
| FROM inventory_lot_line ill | FROM inventory_lot_line ill | ||||
| INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 | 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 | 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 | WHERE ill.deleted = 0 | ||||
| AND il.itemId = q.itemId | AND il.itemId = q.itemId | ||||
| AND iu_loc.uomId <=> q.uomId | |||||
| AND wh.code LIKE :storeLocation | AND wh.code LIKE :storeLocation | ||||
| ) | ) | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -1554,7 +1557,7 @@ return result | |||||
| val inventoryScopeUnion = if (useLiveInventory) { | val inventoryScopeUnion = if (useLiveInventory) { | ||||
| """ | """ | ||||
| UNION | UNION | ||||
| SELECT itemId FROM inventory_per_item WHERE currentBalance <> 0 | |||||
| SELECT itemId, uomId FROM inventory_per_item WHERE currentBalance <> 0 | |||||
| """.trimIndent() | """.trimIndent() | ||||
| } else { | } else { | ||||
| "" | "" | ||||
| @@ -1564,6 +1567,7 @@ return result | |||||
| WITH day0 AS ( | WITH day0 AS ( | ||||
| SELECT | SELECT | ||||
| d.itemId, | d.itemId, | ||||
| d.uomId, | |||||
| SUM(COALESCE(d.opening, 0)) AS openingBalance, | SUM(COALESCE(d.opening, 0)) AS openingBalance, | ||||
| SUM(COALESCE(d.closing, 0)) AS currentBalance | SUM(COALESCE(d.closing, 0)) AS currentBalance | ||||
| FROM stock_lot_day d | FROM stock_lot_day d | ||||
| @@ -1571,33 +1575,36 @@ return result | |||||
| AND d.date = :d0 | AND d.date = :d0 | ||||
| AND d.itemId IS NOT NULL | AND d.itemId IS NOT NULL | ||||
| $itemCodeSqlDay | $itemCodeSqlDay | ||||
| GROUP BY d.itemId | |||||
| GROUP BY d.itemId, d.uomId | |||||
| ), | ), | ||||
| day1 AS ( | day1 AS ( | ||||
| SELECT | SELECT | ||||
| d.itemId, | d.itemId, | ||||
| d.uomId, | |||||
| SUM(COALESCE(d.closing, 0)) AS openingBalance | SUM(COALESCE(d.closing, 0)) AS openingBalance | ||||
| FROM stock_lot_day d | FROM stock_lot_day d | ||||
| WHERE IFNULL(d.deleted, 0) = 0 | WHERE IFNULL(d.deleted, 0) = 0 | ||||
| AND d.date = :d1 | AND d.date = :d1 | ||||
| AND d.itemId IS NOT NULL | AND d.itemId IS NOT NULL | ||||
| $itemCodeSqlDay | $itemCodeSqlDay | ||||
| GROUP BY d.itemId | |||||
| GROUP BY d.itemId, d.uomId | |||||
| ), | ), | ||||
| inventory_per_item AS ( | inventory_per_item AS ( | ||||
| SELECT | SELECT | ||||
| iv.itemId, | iv.itemId, | ||||
| iv.stockUomId AS uomId, | |||||
| SUM(COALESCE(iv.onHandQty, 0)) AS currentBalance | SUM(COALESCE(iv.onHandQty, 0)) AS currentBalance | ||||
| FROM inventory iv | FROM inventory iv | ||||
| INNER JOIN items it ON it.id = iv.itemId AND it.deleted = 0 | INNER JOIN items it ON it.id = iv.itemId AND it.deleted = 0 | ||||
| WHERE iv.deleted = 0 | WHERE iv.deleted = 0 | ||||
| $itemCodeSql | $itemCodeSql | ||||
| $stockCategorySql | $stockCategorySql | ||||
| GROUP BY iv.itemId | |||||
| GROUP BY iv.itemId, iv.stockUomId | |||||
| ), | ), | ||||
| ledger_moves AS ( | ledger_moves AS ( | ||||
| SELECT | SELECT | ||||
| sl.itemId, | sl.itemId, | ||||
| sl.uomId, | |||||
| SUM( | SUM( | ||||
| CASE | CASE | ||||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') | WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') | ||||
| @@ -1644,14 +1651,14 @@ return result | |||||
| AND sl.date >= :d0Start | AND sl.date >= :d0Start | ||||
| AND sl.date < :d0EndExclusive | AND sl.date < :d0EndExclusive | ||||
| $itemCodeSqlSl | $itemCodeSqlSl | ||||
| GROUP BY sl.itemId | |||||
| GROUP BY sl.itemId, sl.uomId | |||||
| ), | ), | ||||
| item_ids AS ( | item_ids AS ( | ||||
| SELECT itemId FROM day0 | |||||
| SELECT itemId, uomId FROM day0 | |||||
| UNION | UNION | ||||
| SELECT itemId FROM day1 | |||||
| SELECT itemId, uomId FROM day1 | |||||
| UNION | UNION | ||||
| SELECT itemId FROM ledger_moves | |||||
| SELECT itemId, uomId FROM ledger_moves | |||||
| $inventoryScopeUnion | $inventoryScopeUnion | ||||
| ), | ), | ||||
| item_scope AS ( | item_scope AS ( | ||||
| @@ -1661,9 +1668,9 @@ return result | |||||
| it.name AS itemName, | it.name AS itemName, | ||||
| COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw | COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw | ||||
| FROM items it | FROM items it | ||||
| INNER JOIN item_ids ids ON ids.itemId = it.id | |||||
| WHERE it.deleted = 0 | WHERE it.deleted = 0 | ||||
| AND it.code IS NOT NULL AND it.code <> '' | AND it.code IS NOT NULL AND it.code <> '' | ||||
| AND it.id IN (SELECT DISTINCT itemId FROM item_ids) | |||||
| $itemCodeSql | $itemCodeSql | ||||
| $stockCategorySql | $stockCategorySql | ||||
| ), | ), | ||||
| @@ -1680,6 +1687,7 @@ return result | |||||
| qty_per_item AS ( | qty_per_item AS ( | ||||
| SELECT | SELECT | ||||
| ids.itemId, | ids.itemId, | ||||
| ids.uomId, | |||||
| COALESCE( | COALESCE( | ||||
| CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END, | CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END, | ||||
| d1.openingBalance, | d1.openingBalance, | ||||
| @@ -1700,10 +1708,10 @@ return result | |||||
| COALESCE(lm.variance, 0) AS variance, | COALESCE(lm.variance, 0) AS variance, | ||||
| COALESCE(lm.defectiveGoods, 0) AS defectiveGoods | COALESCE(lm.defectiveGoods, 0) AS defectiveGoods | ||||
| FROM item_ids ids | 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 | SELECT | ||||
| '' AS stockSubCategory, | '' 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.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.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, | 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 | FROM qty_per_item q | ||||
| INNER JOIN item_scope s ON s.itemId = q.itemId | INNER JOIN item_scope s ON s.itemId = q.itemId | ||||
| LEFT JOIN stock_one so ON so.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 | WHERE 1=1 | ||||
| $storeLocationSql | $storeLocationSql | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -1765,7 +1807,7 @@ return result | |||||
| } | } | ||||
| val t0 = System.nanoTime() | 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( | log.info( | ||||
| "stock-balance-by-date date={} liveInventory={} itemCode={} rows={} {}ms", | "stock-balance-by-date date={} liveInventory={} itemCode={} rows={} {}ms", | ||||
| stockLocalDate, | 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. | * Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template. | ||||
| */ | */ | ||||
| fun createExcelResponse(templatePath: String, params: Map<String, Any>, dataList: List<Map<String, Any>>): ByteArray { | fun createExcelResponse(templatePath: String, params: Map<String, Any>, dataList: List<Map<String, Any>>): 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 dataSource = JRMapCollectionDataSource(dataList) | ||||
| val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | ||||
| val out = ByteArrayOutputStream() | val out = ByteArrayOutputStream() | ||||
| @@ -1797,17 +1837,20 @@ return result | |||||
| * Compiles and fills a Jasper Report, returning the PDF as a ByteArray. | * Compiles and fills a Jasper Report, returning the PDF as a ByteArray. | ||||
| */ | */ | ||||
| fun createPdfResponse(templatePath: String, params: Map<String, Any>, dataList: List<Map<String, Any>>): ByteArray { | fun createPdfResponse(templatePath: String, params: Map<String, Any>, dataList: List<Map<String, Any>>): 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 dataSource = JRMapCollectionDataSource(dataList) | ||||
| val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | ||||
| return JasperExportManager.exportReportToPdf(jasperPrint) | 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>): ByteArray { | fun mergePdfBytes(parts: List<ByteArray>): ByteArray { | ||||
| if (parts.isEmpty()) return ByteArray(0) | if (parts.isEmpty()) return ByteArray(0) | ||||
| if (parts.size == 1) return parts[0] | if (parts.size == 1) return parts[0] | ||||
| @@ -1,7 +1,10 @@ | |||||
| package com.ffii.fpsms.modules.report.service | package com.ffii.fpsms.modules.report.service | ||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.math.BigDecimal | |||||
| import java.math.RoundingMode | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| @@ -9,7 +12,7 @@ import java.time.format.DateTimeFormatter | |||||
| open class StockLedgerReportService( | open class StockLedgerReportService( | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| ) { | ) { | ||||
| private val log = LoggerFactory.getLogger(StockLedgerReportService::class.java) | |||||
| private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") | private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") | ||||
| /** | /** | ||||
| @@ -18,11 +21,12 @@ open class StockLedgerReportService( | |||||
| * | * | ||||
| * - stockSubCategory = items.type | * - stockSubCategory = items.type | ||||
| * - trnDate = stock_ledger.date | * - 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( | fun searchStockLedgerReport( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| @@ -31,264 +35,500 @@ open class StockLedgerReportService( | |||||
| reportPeriodStart: String?, | reportPeriodStart: String?, | ||||
| reportPeriodEnd: String?, | reportPeriodEnd: String?, | ||||
| ): List<Map<String, Any>> { | ): 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)) | ?: LocalDate.now().format(dateFmt)) | ||||
| val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { | |||||
| val startDate = if (!reportPeriodStart.isNullOrBlank()) { | |||||
| reportPeriodStart.replace("/", "-") | reportPeriodStart.replace("/", "-") | ||||
| } else { | } else { | ||||
| val minDateSql = """ | |||||
| val minDateRow = jdbcDao.queryForList( | |||||
| """ | |||||
| SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate | SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate | ||||
| FROM stock_ledger sl | FROM stock_ledger sl | ||||
| WHERE sl.deleted = 0 | WHERE sl.deleted = 0 | ||||
| AND sl.itemCode IS NOT NULL | AND sl.itemCode IS NOT NULL | ||||
| AND sl.itemCode <> '' | 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 { | } 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( | private fun buildMultiValueLikeClause( | ||||
| paramValue: String?, | paramValue: String?, | ||||
| columnName: String, | columnName: String, | ||||
| paramPrefix: String, | paramPrefix: String, | ||||
| args: MutableMap<String, Any> | |||||
| args: MutableMap<String, Any>, | |||||
| ): String { | ): String { | ||||
| if (paramValue.isNullOrBlank()) return "" | if (paramValue.isNullOrBlank()) return "" | ||||
| val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } | val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } | ||||
| if (values.isEmpty()) return "" | if (values.isEmpty()) return "" | ||||
| val conditions = values.mapIndexed { index, value -> | val conditions = values.mapIndexed { index, value -> | ||||
| val paramName = "${paramPrefix}_$index" | val paramName = "${paramPrefix}_$index" | ||||
| args[paramName] = "%$value%" | args[paramName] = "%$value%" | ||||
| @@ -297,22 +537,61 @@ ORDER BY | |||||
| return "AND (${conditions.joinToString(" OR ")})" | 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 ")})" | |||||
| } | } | ||||
| } | } | ||||
| @@ -159,7 +159,7 @@ class FGStockOutTraceabilityReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| @@ -216,7 +216,7 @@ class FGStockOutTraceabilityReportController( | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| borderTop = BorderStyle.THICK | borderTop = BorderStyle.THICK | ||||
| borderBottom = BorderStyle.THICK | borderBottom = BorderStyle.THICK | ||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| @@ -271,7 +271,7 @@ class ItemQcFailReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df = workbook.creationHelper.createDataFormat() | val df = workbook.creationHelper.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val decimalNumberStyle = workbook.createCellStyle().apply { | val decimalNumberStyle = workbook.createCellStyle().apply { | ||||
| @@ -282,7 +282,7 @@ class ItemQcFailReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df = workbook.creationHelper.createDataFormat() | val df = workbook.creationHelper.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0.##") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val headers = listOf( | val headers = listOf( | ||||
| @@ -184,7 +184,7 @@ class MaterialStockOutTraceabilityReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| @@ -222,7 +222,7 @@ class MaterialStockOutTraceabilityReportController( | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| borderTop = BorderStyle.THIN | borderTop = BorderStyle.THIN | ||||
| borderBottom = BorderStyle.THICK | borderBottom = BorderStyle.THICK | ||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| @@ -482,7 +482,7 @@ class ReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | ||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| @@ -874,7 +874,7 @@ class ReportController( | |||||
| setTextCell(r, 4, m["dnNo"], styles.text) | setTextCell(r, 4, m["dnNo"], styles.text) | ||||
| setTextCell(r, 5, m["deliveryOrderNo"], styles.text) | setTextCell(r, 5, m["deliveryOrderNo"], styles.text) | ||||
| setTextCell(r, 6, m["customerName"], 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) | setTextCell(r, 8, m["truckNo"], styles.center) | ||||
| itemTotalQty += parseSignedNumber(m["qty"]) ?: 0.0 | itemTotalQty += parseSignedNumber(m["qty"]) ?: 0.0 | ||||
| @@ -956,13 +956,13 @@ class ReportController( | |||||
| setTextCell(r, 0, m["itemNo"], styles.text) | setTextCell(r, 0, m["itemNo"], styles.text) | ||||
| setTextCell(r, 1, m["itemName"], styles.text) | setTextCell(r, 1, m["itemName"], styles.text) | ||||
| setTextCell(r, 2, m["unitOfMeasure"], styles.center) | 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, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true) | ||||
| setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true) | setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true) | ||||
| } | } | ||||
| @@ -174,7 +174,7 @@ class SemiFGProductionAnalysisReportController( | |||||
| borderBottom = BorderStyle.THIN | borderBottom = BorderStyle.THIN | ||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") | |||||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| @@ -191,7 +191,7 @@ class StockItemConsumptionTrendReportController( | |||||
| borderBottom = BorderStyle.THIN | borderBottom = BorderStyle.THIN | ||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") | |||||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| @@ -1,4 +1,5 @@ | |||||
| package com.ffii.fpsms.modules.report.web | package com.ffii.fpsms.modules.report.web | ||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| import org.springframework.http.* | import org.springframework.http.* | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| @@ -26,6 +27,7 @@ class StockLedgerReportController( | |||||
| private val reportService: ReportService, | private val reportService: ReportService, | ||||
| private val stockLedgerReportService: StockLedgerReportService, | private val stockLedgerReportService: StockLedgerReportService, | ||||
| ) { | ) { | ||||
| private val log = LoggerFactory.getLogger(StockLedgerReportController::class.java) | |||||
| private data class ExcelStyles( | private data class ExcelStyles( | ||||
| val title: CellStyle, | val title: CellStyle, | ||||
| val subtitle: CellStyle, | val subtitle: CellStyle, | ||||
| @@ -64,6 +66,7 @@ class StockLedgerReportController( | |||||
| parameters["reportPeriodStart"] = reportPeriodStart ?: "" | parameters["reportPeriodStart"] = reportPeriodStart ?: "" | ||||
| parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" | parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" | ||||
| val tSearch = System.nanoTime() | |||||
| val dbData = stockLedgerReportService.searchStockLedgerReport( | val dbData = stockLedgerReportService.searchStockLedgerReport( | ||||
| stockCategory = stockCategory, | stockCategory = stockCategory, | ||||
| itemCode = itemCode, | itemCode = itemCode, | ||||
| @@ -71,12 +74,20 @@ class StockLedgerReportController( | |||||
| reportPeriodStart = reportPeriodStart, | reportPeriodStart = reportPeriodStart, | ||||
| reportPeriodEnd = reportPeriodEnd, | reportPeriodEnd = reportPeriodEnd, | ||||
| ) | ) | ||||
| val searchMs = (System.nanoTime() - tSearch) / 1_000_000 | |||||
| val tPdf = System.nanoTime() | |||||
| val pdfBytes = reportService.createPdfResponse( | val pdfBytes = reportService.createPdfResponse( | ||||
| "/jasper/StockLedgarReport.jrxml", | "/jasper/StockLedgarReport.jrxml", | ||||
| parameters, | parameters, | ||||
| dbData, | 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 { | val headers = HttpHeaders().apply { | ||||
| contentType = MediaType.APPLICATION_PDF | contentType = MediaType.APPLICATION_PDF | ||||
| @@ -95,6 +106,7 @@ class StockLedgerReportController( | |||||
| @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, | @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, | ||||
| @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, | @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, | ||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val tSearch = System.nanoTime() | |||||
| val dbData = stockLedgerReportService.searchStockLedgerReport( | val dbData = stockLedgerReportService.searchStockLedgerReport( | ||||
| stockCategory = stockCategory, | stockCategory = stockCategory, | ||||
| itemCode = itemCode, | itemCode = itemCode, | ||||
| @@ -102,12 +114,20 @@ class StockLedgerReportController( | |||||
| reportPeriodStart = reportPeriodStart, | reportPeriodStart = reportPeriodStart, | ||||
| reportPeriodEnd = reportPeriodEnd, | reportPeriodEnd = reportPeriodEnd, | ||||
| ) | ) | ||||
| val searchMs = (System.nanoTime() - tSearch) / 1_000_000 | |||||
| val tExcel = System.nanoTime() | |||||
| val excelBytes = createStockLedgerExcel( | val excelBytes = createStockLedgerExcel( | ||||
| dbData = dbData, | dbData = dbData, | ||||
| reportPeriodStart = reportPeriodStart ?: "", | reportPeriodStart = reportPeriodStart ?: "", | ||||
| reportPeriodEnd = reportPeriodEnd ?: "", | 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 { | val headers = HttpHeaders().apply { | ||||
| contentType = MediaType.parseMediaType( | contentType = MediaType.parseMediaType( | ||||
| @@ -174,7 +194,7 @@ class StockLedgerReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| @@ -188,7 +208,7 @@ class StockLedgerReportController( | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| borderTop = BorderStyle.THICK | borderTop = BorderStyle.THICK | ||||
| borderBottom = BorderStyle.THICK | borderBottom = BorderStyle.THICK | ||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| @@ -270,7 +290,7 @@ class StockLedgerReportController( | |||||
| return | return | ||||
| } | } | ||||
| if (parsed < 0) { | if (parsed < 0) { | ||||
| val s = "%,d".format(kotlin.math.abs(parsed).toLong()) | |||||
| val s = "%,.2f".format(kotlin.math.abs(parsed)) | |||||
| cell.setCellValue("($s)") | cell.setCellValue("($s)") | ||||
| cell.cellStyle = dashStyle | cell.cellStyle = dashStyle | ||||
| } else { | } else { | ||||
| @@ -151,7 +151,7 @@ class StockLotBalanceReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0.##") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| @@ -145,7 +145,7 @@ class StockLotLedgerReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0.##") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| @@ -328,7 +328,7 @@ class StockTakeVarianceReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| @@ -344,7 +344,7 @@ class StockTakeVarianceReportController( | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| borderTop = BorderStyle.THICK | borderTop = BorderStyle.THICK | ||||
| borderBottom = BorderStyle.THICK | borderBottom = BorderStyle.THICK | ||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| @@ -578,8 +578,7 @@ class StockTakeVarianceReportController( | |||||
| } | } | ||||
| private fun formatAbsInt(v: Double): String { | 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( | private data class Rep012ReportData( | ||||
| @@ -71,7 +71,7 @@ | |||||
| <field name="defectiveGoods" class="java.lang.String"/> | <field name="defectiveGoods" class="java.lang.String"/> | ||||
| <field name="variance" class="java.lang.String"/> | <field name="variance" class="java.lang.String"/> | ||||
| <group name="Group1" keepTogether="true"> | <group name="Group1" keepTogether="true"> | ||||
| <groupExpression><![CDATA[$F{itemNo}]]></groupExpression> | |||||
| <groupExpression><![CDATA[$F{itemNo} + "|" + $F{unitOfMeasure}]]></groupExpression> | |||||
| <groupHeader> | <groupHeader> | ||||
| <band height="24"> | <band height="24"> | ||||
| <textField> | <textField> | ||||
| @@ -73,7 +73,7 @@ | |||||
| <field name="cumBalance" class="java.lang.String"/> | <field name="cumBalance" class="java.lang.String"/> | ||||
| <field name="jobOrderNo" class="java.lang.String"/> | <field name="jobOrderNo" class="java.lang.String"/> | ||||
| <field name="orderRefNo" class="java.lang.String"/> | <field name="orderRefNo" class="java.lang.String"/> | ||||
| <group name="Group1" keepTogether="true"> | |||||
| <group name="Group1" keepTogether="false"> | |||||
| <groupExpression><![CDATA[$F{itemNo}]]></groupExpression> | <groupExpression><![CDATA[$F{itemNo}]]></groupExpression> | ||||
| <groupHeader> | <groupHeader> | ||||
| <band height="18"> | <band height="18"> | ||||