diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportMultiValueTokens.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportMultiValueTokens.kt new file mode 100644 index 0000000..9e54bd3 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportMultiValueTokens.kt @@ -0,0 +1,29 @@ +package com.ffii.fpsms.modules.report.service + +/** + * Splits report filter values pasted from Excel or typed in a text field. + * Accepts commas, semicolons, Chinese commas, and any whitespace (spaces, tabs, newlines). + */ +object ReportMultiValueTokens { + private val SEPARATORS = Regex("[\\s,;,、\\u00A0\\u3000]+") + + fun split(paramValue: String?): List { + if (paramValue.isNullOrBlank()) return emptyList() + return paramValue + .split(SEPARATORS) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinctBy { it.uppercase() } + } + + /** + * Full item codes (e.g. FA0123) can use an indexed IN-list. + * Short / wildcard tokens stay as prefix/LIKE matches. + */ + fun partitionExactAndFuzzy(paramValue: String?, exactMinLength: Int = 5): Pair, List> { + val tokens = split(paramValue) + val exact = tokens.filter { it.length >= exactMinLength && '%' !in it && '_' !in it } + val fuzzy = tokens.filter { token -> exact.none { it.equals(token, ignoreCase = true) } } + return exact to fuzzy + } +} 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 845af94..377b2f2 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -12,6 +12,11 @@ import com.ffii.fpsms.modules.master.enums.ShopType import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService import java.math.BigDecimal +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.time.LocalDate +import java.util.ArrayList +import java.util.Locale import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter import net.sf.jasperreports.export.SimpleExporterInput import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput @@ -504,8 +509,8 @@ return result return result } /** - * Helper function to build SQL clause for comma-separated values. - * Supports multiple values like "val1, val2, val3" and generates OR conditions with LIKE. + * Helper function to build SQL clause for comma/space-separated values. + * Supports Excel paste like "FA0123 FA2332" or "FA0123,FA2332, FA23238" (LIKE OR). */ private fun buildMultiValueLikeClause( paramValue: String?, @@ -515,7 +520,7 @@ return result ): String { if (paramValue.isNullOrBlank()) return "" - val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } + val values = ReportMultiValueTokens.split(paramValue) if (values.isEmpty()) return "" val conditions = values.mapIndexed { index, value -> @@ -527,6 +532,33 @@ return result return "AND (${conditions.joinToString(" OR ")})" } + /** + * Item-code filter that prefers indexed equality (`IN`) for pasted/full codes, + * and prefix LIKE only for short tokens such as "FA". + */ + private fun buildItemCodeFilterClause( + paramValue: String?, + columnName: String, + paramPrefix: String, + args: MutableMap, + ): String { + val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(paramValue) + if (exact.isEmpty() && fuzzy.isEmpty()) return "" + + val parts = mutableListOf() + exact.forEachIndexed { index, value -> + val paramName = "${paramPrefix}_eq_$index" + args[paramName] = value + parts.add("$columnName = :$paramName") + } + fuzzy.forEachIndexed { index, value -> + val paramName = "${paramPrefix}_like_$index" + args[paramName] = if ('%' in value || '_' in value) value else "$value%" + parts.add("$columnName LIKE :$paramName") + } + return "AND (${parts.joinToString(" OR ")})" + } + /** * Helper function to build SQL clause for comma-separated values with exact match. * Supports multiple values like "val1, val2, val3" and generates OR conditions with =. @@ -1455,12 +1487,12 @@ return result /** * Stock Balance Report (date-driven). * + * Opening / current balance use the latest stock_ledger row per item (index on itemId+date) + * instead of ranking the whole ledger. Item codes prefer `IN` over `LIKE %x%`. + * * - 期初存量: stockDate - 1 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序) * - 現存存貨: stockDate 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序) - * - 單位均價: 依 item 判斷是否 BOM - * - BOM: 用 delivery_order_line (prefer up, else price/qty, else price) 算加權平均 - * - 非 BOM: 用 purchase_order_line (prefer up, else price/qty, else price) 算加權平均 - * - 庫存總價值: 單位均價 * 現存存貨 + * - 單位均價 / 庫存總價值: items.AverageUnitPrice * 現存存貨 */ fun searchStockBalanceReportByDate( stockCategory: String?, @@ -1470,220 +1502,230 @@ return result balanceFilterEnd: String?, storeLocation: String?, ): List> { - val args = mutableMapOf() - val formattedStockDate = stockDate.replace("/", "-") - args["stockDate"] = formattedStockDate + val itemArgs = mutableMapOf() + val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", itemArgs) + val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", itemArgs) + val hasItemCodeFilter = itemCodeSql.isNotBlank() - val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args) - val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) - val storeLocationSql = if (!storeLocation.isNullOrBlank()) { - args["storeLocation"] = "%$storeLocation%" - "AND COALESCE(store_location.storeLocation, '') LIKE :storeLocation" - } else "" + val items = jdbcDao.queryForList( + """ + SELECT + it.id AS itemId, + it.code AS itemNo, + it.name AS itemName, + COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw + FROM items it + WHERE it.deleted = 0 + AND it.code IS NOT NULL AND it.code <> '' + $itemCodeSql + $stockCategorySql + ORDER BY it.code + """.trimIndent(), + itemArgs, + ) + if (items.isEmpty()) return emptyList() - val baseSql = """ - WITH params AS ( - SELECT - DATE(:stockDate) AS d0, - DATE_SUB(DATE(:stockDate), INTERVAL 1 DAY) AS d1 - ), - ledger_item_ids AS ( - SELECT DISTINCT sl.itemId - FROM stock_ledger sl - INNER JOIN params p ON 1=1 - WHERE sl.deleted = 0 - AND sl.itemId IS NOT NULL - AND DATE(sl.date) <= p.d0 - ), - item_scope AS ( - SELECT - it.id AS itemId, - it.code AS itemNo, - it.name AS itemName, - it.type AS itemType, - COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw - FROM items it - INNER JOIN ledger_item_ids li ON li.itemId = it.id - WHERE it.deleted = 0 - AND it.code IS NOT NULL AND it.code <> '' - $itemCodeSql - $stockCategorySql - ), - store_location AS ( - SELECT - sl.itemId, - MAX(wh.code) AS storeLocation - FROM stock_ledger sl - LEFT JOIN stock_in_line sil ON sl.stockInLineId = sil.id AND sil.deleted = 0 - LEFT JOIN inventory_lot il_in ON sil.inventoryLotId = il_in.id AND il_in.deleted = 0 - LEFT JOIN stock_out_line sol ON sl.stockOutLineId = sol.id AND sol.deleted = 0 - LEFT JOIN inventory_lot_line ill_out ON sol.inventoryLotLineId = ill_out.id AND ill_out.deleted = 0 - LEFT JOIN inventory_lot il_out ON ill_out.inventoryLotId = il_out.id AND il_out.deleted = 0 - LEFT JOIN inventory_lot_line ill_any ON ill_any.inventoryLotId = COALESCE(il_in.id, il_out.id) AND ill_any.deleted = 0 - LEFT JOIN warehouse wh ON ill_any.warehouseId = wh.id AND wh.deleted = 0 - WHERE sl.deleted = 0 - GROUP BY sl.itemId - ), - opening_ranked AS ( - SELECT - sl.itemId, - COALESCE(sl.balance, 0) AS openingBalance, - ROW_NUMBER() OVER (PARTITION BY sl.itemId ORDER BY sl.date DESC, sl.id DESC) AS rn - FROM stock_ledger sl - INNER JOIN params p ON 1=1 - WHERE sl.deleted = 0 - AND sl.itemId IS NOT NULL - AND DATE(sl.date) <= p.d1 - ), - opening_per_item AS ( - SELECT itemId, openingBalance - FROM opening_ranked - WHERE rn = 1 - ), - current_ranked AS ( - SELECT - sl.itemId, - COALESCE(sl.balance, 0) AS currentBalance, - ROW_NUMBER() OVER (PARTITION BY sl.itemId ORDER BY sl.date DESC, sl.id DESC) AS rn - FROM stock_ledger sl - INNER JOIN params p ON 1=1 - WHERE sl.deleted = 0 - AND sl.itemId IS NOT NULL - AND DATE(sl.date) <= p.d0 - ), - current_per_item AS ( - SELECT itemId, currentBalance - FROM current_ranked - WHERE rn = 1 - ), - last_in_out AS ( - SELECT - sl.itemId, - MAX(CASE WHEN COALESCE(sl.inQty, 0) > 0 THEN sl.date END) AS lastInDate, - MAX(CASE WHEN COALESCE(sl.outQty, 0) > 0 THEN sl.date END) AS lastOutDate + val itemIds = items.mapNotNull { jdbcLong(it["itemId"]) }.distinct() + if (itemIds.isEmpty()) return emptyList() + + val day = LocalDate.parse(stockDate.replace("/", "-").take(10)) + val openingCutoff = day.atStartOfDay() + val currentCutoff = day.plusDays(1).atStartOfDay() + + val openingByItem = loadLatestLedgerBalances(itemIds, openingCutoff) + val currentByItem = loadLatestLedgerBalances(itemIds, currentCutoff) + val movesByItem = loadStockDateLedgerMoves(itemIds, openingCutoff, currentCutoff) + val uomByItem = loadStockUnitLabels(itemIds) + + val minBalance = balanceFilterStart?.toDoubleOrNull() + val maxBalance = balanceFilterEnd?.toDoubleOrNull() + + return items.mapNotNull { item -> + val itemId = jdbcLong(item["itemId"]) ?: return@mapNotNull null + val opening = openingByItem[itemId] ?: 0.0 + val current = currentByItem[itemId] ?: 0.0 + val moves = movesByItem[itemId] + val hasLedger = openingByItem.containsKey(itemId) || + currentByItem.containsKey(itemId) || + movesByItem.containsKey(itemId) + if (!hasItemCodeFilter && !hasLedger) return@mapNotNull null + if (minBalance != null && current < minBalance) return@mapNotNull null + if (maxBalance != null && current > maxBalance) return@mapNotNull null + + val avg = jdbcDouble(item["avgUnitPriceRaw"]) + val cumIn = moves?.cumStockIn ?: 0.0 + val cumOut = moves?.cumStockOut ?: 0.0 + val miss = moves?.misInputAndLost ?: 0.0 + val variance = moves?.variance ?: 0.0 + val defective = moves?.defectiveGoods ?: 0.0 + mapOf( + "stockSubCategory" to "", + "itemNo" to (item["itemNo"]?.toString().orEmpty()), + "itemName" to (item["itemName"]?.toString().orEmpty()), + "unitOfMeasure" to (uomByItem[itemId] ?: ""), + "lotNo" to "", + "expiryDate" to "", + "openingBalance" to "", + "cumStockIn" to "", + "cumStockOut" to "", + "currentBalance" to "", + "reOrderQty" to "", + "storeLocation" to "", + "lastInDate" to "", + "lastOutDate" to "", + "openingBalanceRaw" to opening, + "currentBalanceRaw" to current, + "totalOpeningBalance" to formatReportQty(opening), + "totalCumStockIn" to formatReportQty(cumIn), + "totalCumStockOut" to formatReportQty(cumOut), + "totalCurrentBalance" to formatReportQty(current), + "misInputAndLost" to "", + "defectiveGoods" to "", + "variance" to "", + "totalMisInputAndLost" to formatReportQty(miss), + "totalVariance" to formatReportQty(variance), + "totalDefectiveGoods" to formatReportQty(defective), + "avgUnitPrice" to formatReportMoney(avg), + "totalStockBalance" to formatReportMoney(avg * current), + ) + } + } + + private data class StockBalanceMoves( + val cumStockIn: Double, + val cumStockOut: Double, + val misInputAndLost: Double, + val defectiveGoods: Double, + val variance: Double, + ) + + private fun loadLatestLedgerBalances( + itemIds: List, + cutoffExclusive: java.time.LocalDateTime, + ): Map { + val out = HashMap(itemIds.size) + itemIds.chunked(400).forEach { chunk -> + val args = mutableMapOf( + "itemIds" to ArrayList(chunk), + "cutoff" to cutoffExclusive, + ) + val rows = jdbcDao.queryForList( + """ + SELECT sl.itemId AS itemId, COALESCE(sl.balance, 0) AS balance FROM stock_ledger sl - INNER JOIN params p ON 1=1 - WHERE sl.deleted = 0 - AND sl.itemId IS NOT NULL - AND DATE(sl.date) <= p.d0 - GROUP BY sl.itemId - ), - ledger_moves AS ( + INNER JOIN ( + SELECT s.itemId AS itemId, MAX(s.id) AS maxId + FROM stock_ledger s + INNER JOIN ( + SELECT itemId, MAX(date) AS maxDate + FROM stock_ledger + WHERE deleted = 0 + AND itemId IN (:itemIds) + AND date < :cutoff + GROUP BY itemId + ) latest ON latest.itemId = s.itemId AND s.date = latest.maxDate + WHERE s.deleted = 0 + AND s.itemId IN (:itemIds) + GROUP BY s.itemId + ) t ON sl.id = t.maxId + """.trimIndent(), + args, + ) + for (row in rows) { + val id = jdbcLong(row["itemId"]) ?: continue + out[id] = jdbcDouble(row["balance"]) + } + } + return out + } + + private fun loadStockDateLedgerMoves( + itemIds: List, + startInclusive: java.time.LocalDateTime, + endExclusive: java.time.LocalDateTime, + ): Map { + val out = HashMap(itemIds.size) + itemIds.chunked(400).forEach { chunk -> + val args = mutableMapOf( + "itemIds" to ArrayList(chunk), + "startAt" to startInclusive, + "endAt" to endExclusive, + ) + val rows = jdbcDao.queryForList( + """ SELECT - sl.itemId, - SUM( - CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') - AND COALESCE(sl.inQty, 0) > 0 - THEN COALESCE(sl.inQty, 0) - ELSE 0 - END - ) AS cumStockIn, - SUM( - CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') - AND COALESCE(sl.outQty, 0) > 0 - THEN COALESCE(sl.outQty, 0) - ELSE 0 - END - ) AS cumStockOut, - SUM( - CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'MISS' - AND COALESCE(sl.outQty, 0) > 0 - THEN COALESCE(sl.outQty, 0) - ELSE 0 - END - ) AS misInputAndLost, - SUM( - CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'BAD' - AND COALESCE(sl.outQty, 0) > 0 - THEN COALESCE(sl.outQty, 0) - ELSE 0 - END - ) AS defectiveGoods, - SUM( - CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' - THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) - ELSE 0 - END - ) AS variance + sl.itemId AS itemId, + SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') AND COALESCE(sl.inQty, 0) > 0 THEN COALESCE(sl.inQty, 0) ELSE 0 END) AS cumStockIn, + SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS cumStockOut, + SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'MISS' AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS misInputAndLost, + SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'BAD' AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS defectiveGoods, + SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) ELSE 0 END) AS variance FROM stock_ledger sl - INNER JOIN params p ON 1=1 WHERE sl.deleted = 0 - AND sl.itemId IS NOT NULL - AND DATE(sl.date) > p.d1 - AND DATE(sl.date) <= p.d0 + AND sl.itemId IN (:itemIds) + AND sl.date >= :startAt + AND sl.date < :endAt GROUP BY sl.itemId + """.trimIndent(), + args, ) - SELECT - '' AS stockSubCategory, - COALESCE(s.itemNo, '') AS itemNo, - COALESCE(s.itemName, '') AS itemName, - COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, - '' AS lotNo, - '' AS expiryDate, - '' AS openingBalance, - '' AS cumStockIn, - '' AS cumStockOut, - '' AS currentBalance, - '' AS reOrderQty, - COALESCE(store_location.storeLocation, '') AS storeLocation, - COALESCE(DATE_FORMAT(lio.lastInDate, '%Y-%m-%d'), '') AS lastInDate, - COALESCE(DATE_FORMAT(lio.lastOutDate, '%Y-%m-%d'), '') AS lastOutDate, - COALESCE(op.openingBalance, 0) AS openingBalanceRaw, - COALESCE(cp.currentBalance, 0) AS currentBalanceRaw, - CASE WHEN COALESCE(op.openingBalance, 0) < 0 THEN CONCAT('(', FORMAT(-op.openingBalance, 0), ')') ELSE FORMAT(COALESCE(op.openingBalance, 0), 0) END AS totalOpeningBalance, - CASE WHEN COALESCE(lm.cumStockIn, 0) < 0 THEN CONCAT('(', FORMAT(-lm.cumStockIn, 0), ')') ELSE FORMAT(COALESCE(lm.cumStockIn, 0), 0) END AS totalCumStockIn, - CASE WHEN COALESCE(lm.cumStockOut, 0) < 0 THEN CONCAT('(', FORMAT(-lm.cumStockOut, 0), ')') ELSE FORMAT(COALESCE(lm.cumStockOut, 0), 0) END AS totalCumStockOut, - CASE WHEN COALESCE(cp.currentBalance, 0) < 0 THEN CONCAT('(', FORMAT(-cp.currentBalance, 0), ')') ELSE FORMAT(COALESCE(cp.currentBalance, 0), 0) END AS totalCurrentBalance, - '' AS misInputAndLost, - '' AS defectiveGoods, - '' AS variance, - CASE WHEN COALESCE(lm.misInputAndLost, 0) < 0 THEN CONCAT('(', FORMAT(-lm.misInputAndLost, 0), ')') ELSE FORMAT(COALESCE(lm.misInputAndLost, 0), 0) END AS totalMisInputAndLost, - CASE WHEN COALESCE(lm.variance, 0) < 0 THEN CONCAT('(', FORMAT(-lm.variance, 0), ')') ELSE FORMAT(COALESCE(lm.variance, 0), 0) END AS totalVariance, - CASE WHEN COALESCE(lm.defectiveGoods, 0) < 0 THEN CONCAT('(', FORMAT(-lm.defectiveGoods, 0), ')') ELSE FORMAT(COALESCE(lm.defectiveGoods, 0), 0) END AS totalDefectiveGoods, - FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0), 2), 2) AS avgUnitPrice, - FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0) * COALESCE(cp.currentBalance, 0), 2), 2) AS totalStockBalance - FROM item_scope s - LEFT JOIN opening_per_item op ON op.itemId = s.itemId - LEFT JOIN current_per_item cp ON cp.itemId = s.itemId - LEFT JOIN last_in_out lio ON lio.itemId = s.itemId - LEFT JOIN ledger_moves lm ON lm.itemId = s.itemId - LEFT JOIN item_uom iu ON iu.itemId = s.itemId AND iu.stockUnit = 1 AND iu.deleted = 0 - LEFT JOIN uom_conversion uc ON iu.uomId = uc.id - LEFT JOIN store_location ON store_location.itemId = s.itemId - WHERE 1=1 - $storeLocationSql - """.trimIndent() - - val filters = mutableListOf() - if (!balanceFilterStart.isNullOrBlank()) { - args["balanceFilterStart"] = balanceFilterStart.toDoubleOrNull() ?: 0.0 - filters.add("COALESCE(currentBalanceRaw, 0) >= :balanceFilterStart") - } - if (!balanceFilterEnd.isNullOrBlank()) { - args["balanceFilterEnd"] = balanceFilterEnd.toDoubleOrNull() ?: 0.0 - filters.add("COALESCE(currentBalanceRaw, 0) <= :balanceFilterEnd") + for (row in rows) { + val id = jdbcLong(row["itemId"]) ?: continue + out[id] = StockBalanceMoves( + cumStockIn = jdbcDouble(row["cumStockIn"]), + cumStockOut = jdbcDouble(row["cumStockOut"]), + misInputAndLost = jdbcDouble(row["misInputAndLost"]), + defectiveGoods = jdbcDouble(row["defectiveGoods"]), + variance = jdbcDouble(row["variance"]), + ) + } } + return out + } - val finalSql = - if (filters.isEmpty()) { - // no numeric filter: just order by itemNo - baseSql - } else { - // wrap to filter on raw numeric current balance + private fun loadStockUnitLabels(itemIds: List): Map { + val out = HashMap(itemIds.size) + itemIds.chunked(400).forEach { chunk -> + val args = mutableMapOf("itemIds" to ArrayList(chunk)) + val rows = jdbcDao.queryForList( """ - SELECT * FROM ( - $baseSql - ) base - WHERE ${filters.joinToString(" AND ")} - """.trimIndent() + SELECT iu.itemId AS itemId, COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure + FROM item_uom iu + LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + WHERE iu.deleted = 0 + AND iu.stockUnit = 1 + AND iu.itemId IN (:itemIds) + """.trimIndent(), + args, + ) + for (row in rows) { + val id = jdbcLong(row["itemId"]) ?: continue + out[id] = row["unitOfMeasure"]?.toString().orEmpty() } + } + return out + } - return jdbcDao.queryForList("$finalSql ORDER BY itemNo", args) + private fun jdbcLong(value: Any?): Long? = when (value) { + null -> null + is Number -> value.toLong() + else -> value.toString().toLongOrNull() + } + + private fun jdbcDouble(value: Any?): Double = when (value) { + null -> 0.0 + is Number -> value.toDouble() + else -> value.toString().replace(",", "").toDoubleOrNull() ?: 0.0 + } + + private fun formatReportQty(value: Double): String { + val fmt = DecimalFormat("#,##0", DecimalFormatSymbols(Locale.US)) + return if (value < 0) "(${fmt.format(-value)})" else fmt.format(value) + } + + private fun formatReportMoney(value: Double): String { + val fmt = DecimalFormat("#,##0.00", DecimalFormatSymbols(Locale.US)) + return fmt.format(kotlin.math.round(value * 100.0) / 100.0) } /** * Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template. 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 dfcc5ba..7fba474 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 @@ -23,6 +23,7 @@ import java.time.LocalTime import java.time.format.DateTimeFormatter import com.ffii.fpsms.modules.common.SecurityUtils import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService +import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens import com.ffii.fpsms.modules.report.service.ReportService import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService @@ -278,10 +279,164 @@ class ReportController( @RequestParam(required = false) lastOutDateStart: String?, @RequestParam(required = false) lastOutDateEnd: String?, @RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long, + ): ResponseEntity = + generateStockBalancePdf( + stockCategory = stockCategory, + itemCode = itemCode, + stockDate = stockDate, + balanceFilterStart = balanceFilterStart, + balanceFilterEnd = balanceFilterEnd, + storeLocation = storeLocation, + lastInDateStart = lastInDateStart, + lastInDateEnd = lastInDateEnd, + lastOutDateStart = lastOutDateStart, + lastOutDateEnd = lastOutDateEnd, + stockTakeRoundId = stockTakeRoundId, + ) + + @PostMapping("/print-stock-balance") + fun generateStockBalanceReportPost( + @RequestBody(required = false) req: StockBalanceReportRequest?, + ): ResponseEntity { + val r = req ?: StockBalanceReportRequest() + return generateStockBalancePdf( + stockCategory = r.stockCategory, + itemCode = combinedItemCode(r.itemCode, r.itemCodePaste, r.itemCodes), + stockDate = r.stockDate, + balanceFilterStart = r.balanceFilterStart, + balanceFilterEnd = r.balanceFilterEnd, + storeLocation = r.storeLocation, + lastInDateStart = r.lastInDateStart, + lastInDateEnd = r.lastInDateEnd, + lastOutDateStart = r.lastOutDateStart, + lastOutDateEnd = r.lastOutDateEnd, + stockTakeRoundId = r.stockTakeRoundId ?: 0L, + ) + } + + @GetMapping("/print-stock-balance-excel") + fun exportStockBalanceReportExcel( + @RequestParam(required = false) stockCategory: String?, + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) stockDate: String?, + @RequestParam(required = false) balanceFilterStart: String?, + @RequestParam(required = false) balanceFilterEnd: String?, + @RequestParam(required = false) storeLocation: String?, + @RequestParam(required = false) lastInDateStart: String?, + @RequestParam(required = false) lastInDateEnd: String?, + @RequestParam(required = false) lastOutDateStart: String?, + @RequestParam(required = false) lastOutDateEnd: String?, + @RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long, + ): ResponseEntity = + generateStockBalanceExcel( + stockCategory = stockCategory, + itemCode = itemCode, + stockDate = stockDate, + balanceFilterStart = balanceFilterStart, + balanceFilterEnd = balanceFilterEnd, + storeLocation = storeLocation, + lastInDateStart = lastInDateStart, + lastInDateEnd = lastInDateEnd, + lastOutDateStart = lastOutDateStart, + lastOutDateEnd = lastOutDateEnd, + stockTakeRoundId = stockTakeRoundId, + ) + + @PostMapping("/print-stock-balance-excel") + fun exportStockBalanceReportExcelPost( + @RequestBody(required = false) req: StockBalanceReportRequest?, ): ResponseEntity { + val r = req ?: StockBalanceReportRequest() + return generateStockBalanceExcel( + stockCategory = r.stockCategory, + itemCode = combinedItemCode(r.itemCode, r.itemCodePaste, r.itemCodes), + stockDate = r.stockDate, + balanceFilterStart = r.balanceFilterStart, + balanceFilterEnd = r.balanceFilterEnd, + storeLocation = r.storeLocation, + lastInDateStart = r.lastInDateStart, + lastInDateEnd = r.lastInDateEnd, + lastOutDateStart = r.lastOutDateStart, + lastOutDateEnd = r.lastOutDateEnd, + stockTakeRoundId = r.stockTakeRoundId ?: 0L, + ) + } + + private fun combinedItemCode( + itemCode: String?, + itemCodePaste: String?, + itemCodes: List?, + ): String? { + val joined = buildString { + if (!itemCode.isNullOrBlank()) append(itemCode).append(' ') + if (!itemCodePaste.isNullOrBlank()) append(itemCodePaste).append(' ') + itemCodes?.forEach { code -> + if (code.isNotBlank()) append(code).append(' ') + } + } + val tokens = ReportMultiValueTokens.split(joined) + return tokens.takeIf { it.isNotEmpty() }?.joinToString(",") + } + + private fun loadStockBalanceRows( + stockCategory: String?, + itemCode: String?, + stockDate: String?, + balanceFilterStart: String?, + balanceFilterEnd: String?, + storeLocation: String?, + lastInDateStart: String?, + lastInDateEnd: String?, + lastOutDateStart: String?, + lastOutDateEnd: String?, + stockTakeRoundId: Long, + ): List> = + if (!stockDate.isNullOrBlank()) { + reportService.searchStockBalanceReportByDate( + stockCategory = stockCategory, + itemCode = itemCode, + stockDate = stockDate, + balanceFilterStart = balanceFilterStart, + balanceFilterEnd = balanceFilterEnd, + storeLocation = storeLocation, + ) + } else { + reportService.searchStockBalanceReport( + stockCategory, + itemCode, + balanceFilterStart, + balanceFilterEnd, + storeLocation, + lastInDateStart, + lastInDateEnd, + lastOutDateStart, + lastOutDateEnd, + stockTakeRoundId, + ) + } + + private fun generateStockBalancePdf( + stockCategory: String?, + itemCode: String?, + stockDate: String?, + balanceFilterStart: String?, + balanceFilterEnd: String?, + storeLocation: String?, + lastInDateStart: String?, + lastInDateEnd: String?, + lastOutDateStart: String?, + lastOutDateEnd: String?, + stockTakeRoundId: Long, + ): ResponseEntity { + val tokenCount = ReportMultiValueTokens.split(itemCode).size val parameters = mutableMapOf() parameters["stockCategory"] = stockCategory ?: "All" - parameters["itemNo"] = itemCode ?: "All" + parameters["itemNo"] = + when { + itemCode.isNullOrBlank() -> "All" + tokenCount > 30 -> "$tokenCount items" + else -> itemCode + } parameters["reportDate"] = (stockDate ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) parameters["storeLocation"] = storeLocation ?: "" @@ -292,35 +447,24 @@ class ReportController( parameters["lastOutDateStart"] = lastOutDateStart ?: "" parameters["lastOutDateEnd"] = lastOutDateEnd ?: "" - val dbData = - if (!stockDate.isNullOrBlank()) { - reportService.searchStockBalanceReportByDate( - stockCategory = stockCategory, - itemCode = itemCode, - stockDate = stockDate, - balanceFilterStart = balanceFilterStart, - balanceFilterEnd = balanceFilterEnd, - storeLocation = storeLocation, - ) - } else { - reportService.searchStockBalanceReport( - stockCategory, - itemCode, - balanceFilterStart, - balanceFilterEnd, - storeLocation, - lastInDateStart, - lastInDateEnd, - lastOutDateStart, - lastOutDateEnd, - stockTakeRoundId - ) - } + val dbData = loadStockBalanceRows( + stockCategory = stockCategory, + itemCode = itemCode, + stockDate = stockDate, + balanceFilterStart = balanceFilterStart, + balanceFilterEnd = balanceFilterEnd, + storeLocation = storeLocation, + lastInDateStart = lastInDateStart, + lastInDateEnd = lastInDateEnd, + lastOutDateStart = lastOutDateStart, + lastOutDateEnd = lastOutDateEnd, + stockTakeRoundId = stockTakeRoundId, + ) val pdfBytes = reportService.createPdfResponse( "/jasper/StockBalanceReport.jrxml", parameters, - dbData + dbData, ) val headers = HttpHeaders().apply { @@ -328,48 +472,35 @@ class ReportController( setContentDispositionFormData("attachment", "StockBalanceReport.pdf") set("filename", "StockBalanceReport.pdf") } - return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } - @GetMapping("/print-stock-balance-excel") - fun exportStockBalanceReportExcel( - @RequestParam(required = false) stockCategory: String?, - @RequestParam(required = false) itemCode: String?, - @RequestParam(required = false) stockDate: String?, - @RequestParam(required = false) balanceFilterStart: String?, - @RequestParam(required = false) balanceFilterEnd: String?, - @RequestParam(required = false) storeLocation: String?, - @RequestParam(required = false) lastInDateStart: String?, - @RequestParam(required = false) lastInDateEnd: String?, - @RequestParam(required = false) lastOutDateStart: String?, - @RequestParam(required = false) lastOutDateEnd: String?, - @RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long, + private fun generateStockBalanceExcel( + stockCategory: String?, + itemCode: String?, + stockDate: String?, + balanceFilterStart: String?, + balanceFilterEnd: String?, + storeLocation: String?, + lastInDateStart: String?, + lastInDateEnd: String?, + lastOutDateStart: String?, + lastOutDateEnd: String?, + stockTakeRoundId: Long, ): ResponseEntity { - val dbData = - if (!stockDate.isNullOrBlank()) { - reportService.searchStockBalanceReportByDate( - stockCategory = stockCategory, - itemCode = itemCode, - stockDate = stockDate, - balanceFilterStart = balanceFilterStart, - balanceFilterEnd = balanceFilterEnd, - storeLocation = storeLocation, - ) - } else { - reportService.searchStockBalanceReport( - stockCategory, - itemCode, - balanceFilterStart, - balanceFilterEnd, - storeLocation, - lastInDateStart, - lastInDateEnd, - lastOutDateStart, - lastOutDateEnd, - stockTakeRoundId - ) - } + val dbData = loadStockBalanceRows( + stockCategory = stockCategory, + itemCode = itemCode, + stockDate = stockDate, + balanceFilterStart = balanceFilterStart, + balanceFilterEnd = balanceFilterEnd, + storeLocation = storeLocation, + lastInDateStart = lastInDateStart, + lastInDateEnd = lastInDateEnd, + lastOutDateStart = lastOutDateStart, + lastOutDateEnd = lastOutDateEnd, + stockTakeRoundId = stockTakeRoundId, + ) val excelBytes = createStockBalanceExcel( dbData = dbData, @@ -1082,4 +1213,20 @@ class ReportController( return mapOf("rows" to rows) } -} \ No newline at end of file +} + +data class StockBalanceReportRequest( + val stockDate: String? = null, + val itemCode: String? = null, + val itemCodePaste: String? = null, + val itemCodes: List? = null, + val stockCategory: String? = null, + val balanceFilterStart: String? = null, + val balanceFilterEnd: String? = null, + val storeLocation: String? = null, + val lastInDateStart: String? = null, + val lastInDateEnd: String? = null, + val lastOutDateStart: String? = null, + val lastOutDateEnd: String? = null, + val stockTakeRoundId: Long? = 0L, +) \ No newline at end of file diff --git a/src/test/kotlin/com/ffii/fpsms/modules/report/service/ReportMultiValueTokensTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/report/service/ReportMultiValueTokensTest.kt new file mode 100644 index 0000000..a24dd94 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/report/service/ReportMultiValueTokensTest.kt @@ -0,0 +1,51 @@ +package com.ffii.fpsms.modules.report.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ReportMultiValueTokensTest { + @Test + fun `splits space-separated item codes`() { + assertEquals( + listOf("FA0123", "FA2332"), + ReportMultiValueTokens.split("FA0123 FA2332"), + ) + } + + @Test + fun `splits commas with extra spaces`() { + assertEquals( + listOf("FA0123", "FA2332", "FA23238"), + ReportMultiValueTokens.split("FA0123,FA2332, FA23238"), + ) + } + + @Test + fun `splits mixed commas spaces and newlines`() { + val raw = "FA0123,FA2332, FA23238\nFA9999 FA0001,, FA2332" + assertEquals( + listOf("FA0123", "FA2332", "FA23238", "FA9999", "FA0001"), + ReportMultiValueTokens.split(raw), + ) + } + + @Test + fun `returns empty for blank input`() { + assertEquals(emptyList(), ReportMultiValueTokens.split(null)) + assertEquals(emptyList(), ReportMultiValueTokens.split(" , ; ")) + } + + @Test + fun `full item codes use exact IN partition`() { + val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy("FA0123 FA2332, FA23238") + assertEquals(listOf("FA0123", "FA2332", "FA23238"), exact) + assertEquals(emptyList(), fuzzy) + } + + @Test + fun `short prefix stays fuzzy`() { + val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy("FA FA0123") + assertEquals(listOf("FA0123"), exact) + assertEquals(listOf("FA"), fuzzy) + } +}