| @@ -226,6 +226,27 @@ open class ItemUomService( | |||||
| return stockQty.setScale(0, RoundingMode.DOWN) | return stockQty.setScale(0, RoundingMode.DOWN) | ||||
| } | } | ||||
| /** | |||||
| * Count-type stock units must be whole numbers: PCS/CTN…, 包, 箱, 件, 個, PCS, CTN, etc. | |||||
| * Weight/volume units (克, 公斤, 升…) keep 2 decimals. | |||||
| */ | |||||
| open fun isIntegerCountStockUom(stockUnit: ItemUom?): Boolean { | |||||
| if (stockUnit == null) return false | |||||
| val code = stockUnit.uom?.code?.trim().orEmpty() | |||||
| if (code.startsWith("PCS/CTN", ignoreCase = true)) return true | |||||
| val short = stockUnit.uom?.udfShortDesc?.trim().orEmpty() | |||||
| return isIntegerCountUnitToken(short) || isIntegerCountUnitToken(code) | |||||
| } | |||||
| private fun isIntegerCountUnitToken(raw: String): Boolean { | |||||
| val token = raw.trim() | |||||
| if (token.isEmpty()) return false | |||||
| if (token == "包" || token == "箱" || token == "件" || token == "個") return true | |||||
| val upper = token.uppercase() | |||||
| return upper == "PCS" || upper == "PC" || upper == "CTN" || upper == "EA" || | |||||
| upper == "PK" || upper == "PKT" || upper == "BOX" | |||||
| } | |||||
| /** | /** | ||||
| * Stock UOM code [PCS/CTN]* denotes pieces-per-carton; displayed stock qty should be whole cartons (integer). | * Stock UOM code [PCS/CTN]* denotes pieces-per-carton; displayed stock qty should be whole cartons (integer). | ||||
| */ | */ | ||||
| @@ -234,9 +255,9 @@ open class ItemUomService( | |||||
| return code.startsWith("PCS/CTN", ignoreCase = true) | return code.startsWith("PCS/CTN", ignoreCase = true) | ||||
| } | } | ||||
| /** After ratio math: 2 decimals, or integer when stock unit is PCS/CTN… */ | |||||
| /** After ratio math: integer HALF_UP for count units (包/箱/PCS…), else 2 decimals. */ | |||||
| private fun finalizePreciseStockQty(stockUnit: ItemUom?, stockQty: BigDecimal): BigDecimal { | private fun finalizePreciseStockQty(stockUnit: ItemUom?, stockQty: BigDecimal): BigDecimal { | ||||
| if (isPiecesPerCartonStockUomCode(stockUnit)) { | |||||
| if (isIntegerCountStockUom(stockUnit) || isPiecesPerCartonStockUomCode(stockUnit)) { | |||||
| return stockQty.setScale(0, RoundingMode.HALF_UP) | return stockQty.setScale(0, RoundingMode.HALF_UP) | ||||
| } | } | ||||
| return stockQty.setScale(2, RoundingMode.HALF_UP) | return stockQty.setScale(2, RoundingMode.HALF_UP) | ||||
| @@ -57,9 +57,13 @@ data class StockUomForPoLine ( | |||||
| val id: Long? = null, | val id: Long? = null, | ||||
| val stockUomCode: String? = null, | val stockUomCode: String? = null, | ||||
| val stockUomDesc: String? = null, | val stockUomDesc: String? = null, | ||||
| val stockUomShortDesc: String? = null, | |||||
| val stockQty: BigDecimal? = null, | val stockQty: BigDecimal? = null, | ||||
| val stockRatioN: BigDecimal? = null, | val stockRatioN: BigDecimal? = null, | ||||
| val stockRatioD: BigDecimal? = null, | val stockRatioD: BigDecimal? = null, | ||||
| val purchaseRatioN: BigDecimal? = null, | val purchaseRatioN: BigDecimal? = null, | ||||
| val purchaseRatioD: BigDecimal? = null, | val purchaseRatioD: BigDecimal? = null, | ||||
| val sourceRatioN: BigDecimal? = null, | |||||
| val sourceRatioD: BigDecimal? = null, | |||||
| val stockQtyInteger: Boolean? = null, | |||||
| ) | ) | ||||
| @@ -294,6 +294,12 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| val itemId = thisPol.item?.id | val itemId = thisPol.item?.id | ||||
| val qtyM18 = thisPol.qtyM18 | val qtyM18 = thisPol.qtyM18 | ||||
| val uomM18Id = thisPol.uomM18?.id | val uomM18Id = thisPol.uomM18?.id | ||||
| val sourceUomId = uomM18Id ?: thisPol.uom?.id | |||||
| val sourceItemUom = if (itemId != null && sourceUomId != null) { | |||||
| itemUomService.findFirstByItemIdAndUomId(itemId, sourceUomId) | |||||
| } else { | |||||
| null | |||||
| } | |||||
| val stockQtyFromM18 = if (itemId != null && qtyM18 != null && uomM18Id != null) { | val stockQtyFromM18 = if (itemId != null && qtyM18 != null && uomM18Id != null) { | ||||
| itemUomService.convertQtyToStockQtyPrecise(itemId, uomM18Id, qtyM18) | itemUomService.convertQtyToStockQtyPrecise(itemId, uomM18Id, qtyM18) | ||||
| } else { | } else { | ||||
| @@ -303,6 +309,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| id = iu?.id, | id = iu?.id, | ||||
| stockUomCode = iu?.uom?.code, | stockUomCode = iu?.uom?.code, | ||||
| stockUomDesc = iu?.uom?.udfudesc, | stockUomDesc = iu?.uom?.udfudesc, | ||||
| stockUomShortDesc = iu?.uom?.udfShortDesc, | |||||
| stockQty = if (stockQtyFromM18 > BigDecimal.ZERO) stockQtyFromM18 else { | stockQty = if (stockQtyFromM18 > BigDecimal.ZERO) stockQtyFromM18 else { | ||||
| // fallback to legacy behavior when M18 fields are missing | // fallback to legacy behavior when M18 fields are missing | ||||
| iu?.item?.id?.let { iId -> itemUomService.convertPurchaseQtyToStockQtyPrecise(iId, (thisPol.qty ?: BigDecimal.ZERO)) } ?: BigDecimal.ZERO | iu?.item?.id?.let { iId -> itemUomService.convertPurchaseQtyToStockQtyPrecise(iId, (thisPol.qty ?: BigDecimal.ZERO)) } ?: BigDecimal.ZERO | ||||
| @@ -311,6 +318,9 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| stockRatioD = iu?.ratioD, | stockRatioD = iu?.ratioD, | ||||
| purchaseRatioN = purchaseUnit?.ratioN, | purchaseRatioN = purchaseUnit?.ratioN, | ||||
| purchaseRatioD = purchaseUnit?.ratioD, | purchaseRatioD = purchaseUnit?.ratioD, | ||||
| sourceRatioN = sourceItemUom?.ratioN ?: purchaseUnit?.ratioN, | |||||
| sourceRatioD = sourceItemUom?.ratioD ?: purchaseUnit?.ratioD, | |||||
| stockQtyInteger = itemUomService.isIntegerCountStockUom(iu), | |||||
| ) | ) | ||||
| } } ?: StockUomForPoLine() | } } ?: StockUomForPoLine() | ||||
| PoLineWithStockInLine( | PoLineWithStockInLine( | ||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -507,8 +507,8 @@ return result | |||||
| 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( | private fun buildMultiValueLikeClause( | ||||
| paramValue: String?, | paramValue: String?, | ||||
| @@ -518,7 +518,7 @@ return result | |||||
| ): String { | ): String { | ||||
| if (paramValue.isNullOrBlank()) return "" | if (paramValue.isNullOrBlank()) return "" | ||||
| val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } | |||||
| val values = ReportMultiValueTokens.split(paramValue) | |||||
| if (values.isEmpty()) return "" | if (values.isEmpty()) return "" | ||||
| val conditions = values.mapIndexed { index, value -> | val conditions = values.mapIndexed { index, value -> | ||||
| @@ -530,6 +530,33 @@ return result | |||||
| return "AND (${conditions.joinToString(" OR ")})" | 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. | * 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 =. | * Supports multiple values like "val1, val2, val3" and generates OR conditions with =. | ||||
| @@ -1501,7 +1528,9 @@ return result | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.1 | 2026-09-16 | * FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.1 | 2026-09-16 | ||||
| * Stock Balance Report (date-driven). One summarized row per item. | * Stock Balance Report (date-driven). One summarized row per item. | ||||
| * | * | ||||
| * 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. | |||||
| * Full pasted item codes use indexed equality (`=` / `IN`); short tokens stay prefix LIKE. | |||||
| * | |||||
| * - 期初存量: historical = that day's opening; today = yesterday's closing | * - 期初存量: historical = that day's opening; today = yesterday's closing | ||||
| * - 現存存貨: historical = that day's closing; today = inventory.onHandQty | * - 現存存貨: historical = that day's closing; today = inventory.onHandQty | ||||
| * (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 | ||||
| @@ -1529,9 +1558,9 @@ return result | |||||
| args["d0EndExclusive"] = stockLocalDate.plusDays(1).atStartOfDay() | args["d0EndExclusive"] = stockLocalDate.plusDays(1).atStartOfDay() | ||||
| val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args) | val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args) | ||||
| val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) | |||||
| val itemCodeSqlDay = buildMultiValueLikeClause(itemCode, "d.itemCode", "itemCodeDay", args) | |||||
| val itemCodeSqlSl = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCodeSl", args) | |||||
| val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", args) | |||||
| val itemCodeSqlDay = buildItemCodeFilterClause(itemCode, "d.itemCode", "itemCodeDay", args) | |||||
| val itemCodeSqlSl = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCodeSl", args) | |||||
| val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | ||||
| args["storeLocation"] = "%$storeLocation%" | args["storeLocation"] = "%$storeLocation%" | ||||
| """ | """ | ||||
| @@ -23,6 +23,7 @@ import java.time.LocalTime | |||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import com.ffii.fpsms.modules.common.SecurityUtils | import com.ffii.fpsms.modules.common.SecurityUtils | ||||
| import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService | 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.ReportService | ||||
| import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService | import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService | ||||
| @@ -309,10 +310,164 @@ class ReportController( | |||||
| @RequestParam(required = false) lastOutDateStart: String?, | @RequestParam(required = false) lastOutDateStart: String?, | ||||
| @RequestParam(required = false) lastOutDateEnd: String?, | @RequestParam(required = false) lastOutDateEnd: String?, | ||||
| @RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long, | @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> { | ): 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>() | val parameters = mutableMapOf<String, Any>() | ||||
| parameters["stockCategory"] = stockCategory ?: "All" | 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["reportDate"] = (stockDate ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) | ||||
| parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | ||||
| parameters["storeLocation"] = storeLocation ?: "" | parameters["storeLocation"] = storeLocation ?: "" | ||||
| @@ -323,35 +478,24 @@ class ReportController( | |||||
| parameters["lastOutDateStart"] = lastOutDateStart ?: "" | parameters["lastOutDateStart"] = lastOutDateStart ?: "" | ||||
| parameters["lastOutDateEnd"] = lastOutDateEnd ?: "" | 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( | val pdfBytes = reportService.createPdfResponse( | ||||
| "/jasper/StockBalanceReport.jrxml", | "/jasper/StockBalanceReport.jrxml", | ||||
| parameters, | parameters, | ||||
| dbData | |||||
| dbData, | |||||
| ) | ) | ||||
| val headers = HttpHeaders().apply { | val headers = HttpHeaders().apply { | ||||
| @@ -359,48 +503,35 @@ class ReportController( | |||||
| setContentDispositionFormData("attachment", "StockBalanceReport.pdf") | setContentDispositionFormData("attachment", "StockBalanceReport.pdf") | ||||
| set("filename", "StockBalanceReport.pdf") | set("filename", "StockBalanceReport.pdf") | ||||
| } | } | ||||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | 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> { | ): 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( | val excelBytes = createStockBalanceExcel( | ||||
| dbData = dbData, | dbData = dbData, | ||||
| @@ -1130,4 +1261,20 @@ class ReportController( | |||||
| return mapOf("rows" to rows) | 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, | |||||
| ) | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||