diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index 2c2c1128..2d6b2d01 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -226,6 +226,27 @@ open class ItemUomService( 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). */ @@ -234,9 +255,9 @@ open class ItemUomService( 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 { - if (isPiecesPerCartonStockUomCode(stockUnit)) { + if (isIntegerCountStockUom(stockUnit) || isPiecesPerCartonStockUomCode(stockUnit)) { return stockQty.setScale(0, RoundingMode.HALF_UP) } return stockQty.setScale(2, RoundingMode.HALF_UP) diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/projections/PurchaseOrderLineInfo.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/projections/PurchaseOrderLineInfo.kt index 342791e7..f89fc501 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/projections/PurchaseOrderLineInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/projections/PurchaseOrderLineInfo.kt @@ -57,9 +57,13 @@ data class StockUomForPoLine ( val id: Long? = null, val stockUomCode: String? = null, val stockUomDesc: String? = null, + val stockUomShortDesc: String? = null, val stockQty: BigDecimal? = null, val stockRatioN: BigDecimal? = null, val stockRatioD: BigDecimal? = null, val purchaseRatioN: BigDecimal? = null, val purchaseRatioD: BigDecimal? = null, + val sourceRatioN: BigDecimal? = null, + val sourceRatioD: BigDecimal? = null, + val stockQtyInteger: Boolean? = null, ) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt index 1d5b6554..d794d58c 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt @@ -294,6 +294,12 @@ open fun getPoSummariesByIds(ids: List): List { val itemId = thisPol.item?.id val qtyM18 = thisPol.qtyM18 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) { itemUomService.convertQtyToStockQtyPrecise(itemId, uomM18Id, qtyM18) } else { @@ -303,6 +309,7 @@ open fun getPoSummariesByIds(ids: List): List { id = iu?.id, stockUomCode = iu?.uom?.code, stockUomDesc = iu?.uom?.udfudesc, + stockUomShortDesc = iu?.uom?.udfShortDesc, stockQty = if (stockQtyFromM18 > BigDecimal.ZERO) stockQtyFromM18 else { // fallback to legacy behavior when M18 fields are missing iu?.item?.id?.let { iId -> itemUomService.convertPurchaseQtyToStockQtyPrecise(iId, (thisPol.qty ?: BigDecimal.ZERO)) } ?: BigDecimal.ZERO @@ -311,6 +318,9 @@ open fun getPoSummariesByIds(ids: List): List { stockRatioD = iu?.ratioD, purchaseRatioN = purchaseUnit?.ratioN, purchaseRatioD = purchaseUnit?.ratioD, + sourceRatioN = sourceItemUom?.ratioN ?: purchaseUnit?.ratioN, + sourceRatioD = sourceItemUom?.ratioD ?: purchaseUnit?.ratioD, + stockQtyInteger = itemUomService.isIntegerCountStockUom(iu), ) } } ?: StockUomForPoLine() PoLineWithStockInLine( 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 00000000..9e54bd3d --- /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 481024de..1cbbbbe9 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 @@ -507,8 +507,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?, @@ -518,7 +518,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 -> @@ -530,6 +530,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 =. @@ -1501,7 +1528,9 @@ 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. * - * 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 closing; today = inventory.onHandQty * (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() 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()) { args["storeLocation"] = "%$storeLocation%" """ 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 324af163..ccabd70c 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 @@ -309,10 +310,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 ?: "" @@ -323,35 +478,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 { @@ -359,48 +503,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, @@ -1130,4 +1261,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 00000000..a24dd940 --- /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) + } +}