Kaynağa Gözat

added search criteria for pasting item code for stock balance report

production
Fai Luk 2 gün önce
ebeveyn
işleme
0d6271e1eb
4 değiştirilmiş dosya ile 542 ekleme ve 273 silme
  1. +29
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/ReportMultiValueTokens.kt
  2. +250
    -208
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  3. +212
    -65
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
  4. +51
    -0
      src/test/kotlin/com/ffii/fpsms/modules/report/service/ReportMultiValueTokensTest.kt

+ 29
- 0
src/main/java/com/ffii/fpsms/modules/report/service/ReportMultiValueTokens.kt Dosyayı Görüntüle

@@ -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<String> {
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<String>, List<String>> {
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
}
}

+ 250
- 208
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt Dosyayı Görüntüle

@@ -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, Any>,
): String {
val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(paramValue)
if (exact.isEmpty() && fuzzy.isEmpty()) return ""

val parts = mutableListOf<String>()
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<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val formattedStockDate = stockDate.replace("/", "-")
args["stockDate"] = formattedStockDate
val itemArgs = mutableMapOf<String, Any>()
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<Long>,
cutoffExclusive: java.time.LocalDateTime,
): Map<Long, Double> {
val out = HashMap<Long, Double>(itemIds.size)
itemIds.chunked(400).forEach { chunk ->
val args = mutableMapOf<String, Any>(
"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<Long>,
startInclusive: java.time.LocalDateTime,
endExclusive: java.time.LocalDateTime,
): Map<Long, StockBalanceMoves> {
val out = HashMap<Long, StockBalanceMoves>(itemIds.size)
itemIds.chunked(400).forEach { chunk ->
val args = mutableMapOf<String, Any>(
"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<String>()
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<Long>): Map<Long, String> {
val out = HashMap<Long, String>(itemIds.size)
itemIds.chunked(400).forEach { chunk ->
val args = mutableMapOf<String, Any>("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.


+ 212
- 65
src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt Dosyayı Görüntüle

@@ -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<ByteArray> =
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<ByteArray> {
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<ByteArray> =
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<ByteArray> {
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>?,
): 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<Map<String, Any>> =
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<ByteArray> {
val tokenCount = ReportMultiValueTokens.split(itemCode).size
val parameters = mutableMapOf<String, Any>()
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<ByteArray> {
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)
}

}
}

data class StockBalanceReportRequest(
val stockDate: String? = null,
val itemCode: String? = null,
val itemCodePaste: String? = null,
val itemCodes: List<String>? = 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,
)

+ 51
- 0
src/test/kotlin/com/ffii/fpsms/modules/report/service/ReportMultiValueTokensTest.kt Dosyayı Görüntüle

@@ -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<String>(), ReportMultiValueTokens.split(null))
assertEquals(emptyList<String>(), 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<String>(), fuzzy)
}

@Test
fun `short prefix stays fuzzy`() {
val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy("FA FA0123")
assertEquals(listOf("FA0123"), exact)
assertEquals(listOf("FA"), fuzzy)
}
}

Yükleniyor…
İptal
Kaydet