From 0d6271e1eb2a8d3b789e54fd5c5547af214936fd Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 18 Sep 2026 00:33:40 +0800 Subject: [PATCH 01/16] added search criteria for pasting item code for stock balance report --- .../report/service/ReportMultiValueTokens.kt | 29 ++ .../modules/report/service/ReportService.kt | 458 ++++++++++-------- .../modules/report/web/ReportController.kt | 277 ++++++++--- .../service/ReportMultiValueTokensTest.kt | 51 ++ 4 files changed, 542 insertions(+), 273 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/ReportMultiValueTokens.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/report/service/ReportMultiValueTokensTest.kt 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 845af94e..377b2f23 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 dfcc5bad..7fba4747 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 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) + } +} From 638e6b6fae683bddb2bbd3bbd52195dd53aed428 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 18 Sep 2026 01:11:38 +0800 Subject: [PATCH 02/16] added unit description for the number before submit in PO stock in --- .../purchaseOrder/entity/projections/PurchaseOrderLineInfo.kt | 1 + .../fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt | 1 + 2 files changed, 2 insertions(+) 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..0828c758 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,6 +57,7 @@ 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, 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..52a14a92 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 @@ -303,6 +303,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 From c64250987e34bc393b5a32074856a245cc0e254f Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 18 Sep 2026 10:55:18 +0800 Subject: [PATCH 03/16] align the calculation of unit with stock in line data create calculation --- .../modules/master/service/ItemUomService.kt | 25 +++++++++++++++++-- .../projections/PurchaseOrderLineInfo.kt | 3 +++ .../service/PurchaseOrderService.kt | 9 +++++++ 3 files changed, 35 insertions(+), 2 deletions(-) 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 275b80c5..1ccd30e8 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 @@ -224,6 +224,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). */ @@ -232,9 +253,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 0828c758..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 @@ -63,4 +63,7 @@ data class StockUomForPoLine ( 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 52a14a92..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 { @@ -312,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( From 876f3477add699777b328fb1dfc907d9412a240b Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 18 Sep 2026 14:35:47 +0800 Subject: [PATCH 04/16] added paste item code to some reports --- .../m18/service/M18PurchaseOrderService.kt | 132 +++++++++++++--- .../ffii/fpsms/m18/web/M18TestController.kt | 11 +- .../fpsms/modules/common/SettingNames.java | 9 ++ .../entity/PurchaseOrderRepository.kt | 2 + .../service/PurchaseOrderService.kt | 4 + .../service/M18BomShopSyncReportService.kt | 6 +- .../report/service/ReportMultiValueTokens.kt | 12 ++ .../StockItemConsumptionTrendReportService.kt | 2 +- .../service/StockLedgerReportService.kt | 27 ++-- ...ialStockOutTraceabilityReportController.kt | 60 ++++++++ .../modules/report/web/ReportController.kt | 142 ++++++++++++++++-- .../report/web/ReportItemFilterRequest.kt | 33 ++++ ...emiFGProductionAnalysisReportController.kt | 54 +++++++ ...ockItemConsumptionTrendReportController.kt | 46 ++++++ .../report/web/StockLedgerReportController.kt | 81 ++++++++-- .../01_setting.sql | 14 ++ .../service/ReportMultiValueTokensTest.kt | 8 + 17 files changed, 585 insertions(+), 58 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt create mode 100644 src/main/resources/db/changelog/changes/20260918_po_stockin_refresh_if_newer/01_setting.sql diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt index 0cba4706..a0c4eedf 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt @@ -15,10 +15,13 @@ import com.ffii.fpsms.modules.master.service.ShopService import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderLineStatus import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderStatus import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderType +import com.ffii.fpsms.modules.common.SettingNames import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderLineService import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderService import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderLineRequest import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderRequest +import com.ffii.fpsms.modules.settings.entity.Settings +import com.ffii.fpsms.modules.settings.service.SettingsService import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -39,13 +42,15 @@ open class M18PurchaseOrderService( val itemsService: ItemsService, val shopService: ShopService, val itemUomService: ItemUomService, - val m18MasterDataService: M18MasterDataService + val m18MasterDataService: M18MasterDataService, + val settingsService: SettingsService, ) { val commonUtils = CommonUtils() val logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) val lastModifyDateStart = "2025-05-14 14:00:00" val lastModifyDateEnd = "2025-05-14 14:30:00" + private val M18_LAST_MODIFY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") // val lastModifyDateConds = // "lastModifyDate=largerOrEqual=${lastModifyDateStart}=and=lastModifyDate=lessOrEqual=${lastModifyDateEnd}" // val lastModifyDate = LocalDateTime.now().minusMinutes(30) @@ -227,40 +232,129 @@ open class M18PurchaseOrderService( // Follow the scheduler's original approach: // 1) use /search/search to find the PO id by code // 2) then sync by PO id (this reuses the same create/update logic) - val searchRequest = M18PurchaseOrderListRequest( - stSearch = "po", - params = null, - conds = "(code=equal=$code)" - ) - val poListResponse = try { - apiCallerService.get( - M18_FETCH_PURCHASE_ORDER_LIST_API, - searchRequest - ).block() - } catch (e: Exception) { - logger.error("(Getting Po List By Code) Error on Function - ${e.stackTrace}") - logger.error(e.message) - null + val conds = "(code=equal=$code)" + return saveFromPoListSearch(conds, searchPurchaseOrdersByConds(conds)) + } + + /** + * Stock-in `/po` lookup: create if missing; for a local **pending** PO, pull from M18 only when + * M18 list `lastModifyDate` is after [PurchaseOrder.modified] (last MTMS header write, usually last sync). + * Receiving / completed POs are not refreshed. + */ + open fun savePurchaseOrderByCodeIfNewer(code: String): SyncResult { + val trimmed = code.trim() + val localPo = purchaseOrderService.findByCode(trimmed) + if (localPo != null && !isStockInRefreshIfNewerEnabled()) { + logger.info( + "Purchase Order: Skipping if-newer M18 sync — setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} " + + "is not true. code=$trimmed" + ) + return SyncResult( + totalProcessed = 0, + totalSuccess = 0, + totalFail = 0, + query = "skipped (ifNewer): setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} is not true" + ) + } + if (localPo != null && localPo.status != PurchaseOrderStatus.PENDING) { + logger.info( + "Purchase Order: Skipping if-newer M18 sync — local PO id=${localPo.id} code=${localPo.code} " + + "status=${localPo.status?.value} (only pending may be refreshed)." + ) + return SyncResult( + totalProcessed = 0, + totalSuccess = 0, + totalFail = 0, + query = "skipped (ifNewer): local PO not pending: $trimmed status=${localPo.status?.value}" + ) + } + if (localPo == null) { + return savePurchaseOrderByCode(trimmed) + } + + val conds = "(code=equal=$trimmed)" + val poListResponse = searchPurchaseOrdersByConds(conds) + val m18Row = poListResponse?.values?.firstOrNull() + val localModified = localPo.modified ?: localPo.created + val m18LastModify = parseM18LastModifyDate(m18Row?.lastModifyDate) + if (m18Row == null || m18LastModify == null || localModified == null || !m18LastModify.isAfter(localModified)) { + logger.info( + "Purchase Order: Skipping if-newer M18 sync — M18 lastModifyDate not after " + + "purchase_order.modified. code=$trimmed localModified=$localModified " + + "m18LastModifyRaw=${m18Row?.lastModifyDate} m18LastModify=$m18LastModify" + ) + return SyncResult( + totalProcessed = 0, + totalSuccess = 0, + totalFail = 0, + query = "skipped (ifNewer): M18 lastModifyDate not after purchase_order.modified: " + + "code=$trimmed localModified=$localModified m18LastModify=${m18Row?.lastModifyDate}" + ) } + logger.info( + "Purchase Order: M18 lastModifyDate after purchase_order.modified — syncing. " + + "code=$trimmed localModified=$localModified m18LastModify=$m18LastModify" + ) + return saveFromPoListSearch(conds, poListResponse) + } + private fun saveFromPoListSearch( + query: String, + poListResponse: M18PurchaseOrderListResponse?, + ): SyncResult { val poValues = poListResponse?.values if (poValues.isNullOrEmpty()) { return SyncResult( totalProcessed = 1, totalSuccess = 0, totalFail = 1, - query = "code=equal=$code" + query = query ) } - val request = M18PurchaseOrderListResponseWithType( valuesWithType = mutableListOf(Pair(PurchaseOrderType.MATERIAL, poListResponse)), - query = "code=equal=$code" + query = query ) - return savePurchaseOrdersWithPreparedList(purchaseOrdersWithType = request) } + private fun isStockInRefreshIfNewerEnabled(): Boolean = + settingsService.findByName(SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED) + .map { Settings.VALUE_BOOLEAN_TRUE.equals(it.value?.trim(), ignoreCase = true) } + .orElse(false) + + private fun parseM18LastModifyDate(raw: String?): LocalDateTime? { + val value = raw?.trim().orEmpty() + if (value.isEmpty()) return null + return try { + LocalDateTime.parse(value, M18_LAST_MODIFY_FORMATTER) + } catch (_: Exception) { + try { + LocalDateTime.parse(value) + } catch (_: Exception) { + value.toLongOrNull()?.let { commonUtils.timestampToLocalDateTime(it) } + } + } + } + + private fun searchPurchaseOrdersByConds(conds: String): M18PurchaseOrderListResponse? { + val searchRequest = M18PurchaseOrderListRequest( + stSearch = "po", + params = null, + conds = conds + ) + return try { + apiCallerService.get( + M18_FETCH_PURCHASE_ORDER_LIST_API, + searchRequest + ).block() + } catch (e: Exception) { + logger.error("(Getting Po List By Code) Error on Function - ${e.stackTrace}") + logger.error(e.message) + null + } + } + open fun savePurchaseOrders(request: M18CommonRequest) : SyncResult{ val purchaseOrdersWithType = getPurchaseOrdersWithType(request) return savePurchaseOrdersWithPreparedList( diff --git a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt index 21382510..4c5e1d9a 100644 --- a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt +++ b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt @@ -76,8 +76,15 @@ class M18TestController ( } @GetMapping("/test/po-by-code") - fun testSyncPoByCode(@RequestParam code: String): SyncResult { - return m18PurchaseOrderService.savePurchaseOrderByCode(code) + fun testSyncPoByCode( + @RequestParam code: String, + @RequestParam(required = false, defaultValue = "false") ifNewer: Boolean, + ): SyncResult { + return if (ifNewer) { + m18PurchaseOrderService.savePurchaseOrderByCodeIfNewer(code) + } else { + m18PurchaseOrderService.savePurchaseOrderByCode(code) + } } @GetMapping("/test/do-by-code") diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index a80a0773..00a13e2b 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -52,6 +52,15 @@ public abstract class SettingNames { */ public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled"; + /** + * When "true", stock-in {@code /po} lookup for an existing pending PO asks M18 whether + * {@code lastModifyDate} is after {@code purchase_order.modified} and re-syncs if newer. + * Read from DB on each request (no server restart). Missing POs still auto-sync. + */ + public static final String M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED = + "M18.po.stockIn.refreshIfNewer.enabled"; + + /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderRepository.kt index 707e8c2f..6ad54248 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderRepository.kt @@ -16,6 +16,8 @@ import org.springframework.data.repository.query.Param interface PurchaseOrderRepository : AbstractRepository { fun findTopByM18DataLogIdAndDeletedIsFalseOrderByModifiedDesc(m18datalogId: Serializable): PurchaseOrder? + fun findTopByCodeAndDeletedIsFalseOrderByModifiedDesc(code: String): PurchaseOrder? + fun findPurchaseOrderInfoByIdAndDeletedIsFalse(id: Long): PurchaseOrderInfo fun findByIdAndDeletedFalse(id: Long): Optional 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 d794d58c..9474f071 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 @@ -360,6 +360,10 @@ open fun getPoSummariesByIds(ids: List): List { return purchaseOrderRepository.findTopByM18DataLogIdAndDeletedIsFalseOrderByModifiedDesc(m18DataLogId) } + open fun findByCode(code: String): PurchaseOrder? { + return purchaseOrderRepository.findTopByCodeAndDeletedIsFalseOrderByModifiedDesc(code) + } + @Throws(IOException::class) @Transactional open fun savePurchaseOrder(request: SavePurchaseOrderRequest): SavePurchaseOrderResponse { diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/M18BomShopSyncReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/M18BomShopSyncReportService.kt index dbb798d2..812b2343 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/M18BomShopSyncReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/M18BomShopSyncReportService.kt @@ -32,11 +32,7 @@ open class M18BomShopSyncReportService( ): Map { val start = parseDateStart(syncDateStart) val end = parseDateEnd(syncDateEnd) - val itemTokens = finishedItemCode - ?.split(",") - ?.map { it.trim() } - ?.filter { it.isNotEmpty() } - .orEmpty() + val itemTokens = ReportMultiValueTokens.split(finishedItemCode) val sqlItemFilter = itemTokens.singleOrNull() val statusFilter = syncStatus?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "all" } 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 index 9e54bd3d..e14075ef 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportMultiValueTokens.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportMultiValueTokens.kt @@ -26,4 +26,16 @@ object ReportMultiValueTokens { val fuzzy = tokens.filter { token -> exact.none { it.equals(token, ignoreCase = true) } } return exact to fuzzy } + + fun combine(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 = split(joined) + return tokens.takeIf { it.isNotEmpty() }?.joinToString(",") + } } diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockItemConsumptionTrendReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockItemConsumptionTrendReportService.kt index aa854b5b..b244c0be 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockItemConsumptionTrendReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockItemConsumptionTrendReportService.kt @@ -99,7 +99,7 @@ class StockItemConsumptionTrendReportService( // itemCode now contains prefixes (first 2 letters), so we use LIKE to match codes starting with those prefixes val itemCodeSql = if (!itemCode.isNullOrBlank()) { - val prefixes = itemCode.split(",").map { it.trim() }.filter { it.isNotBlank() } + val prefixes = ReportMultiValueTokens.split(itemCode) if (prefixes.isNotEmpty()) { val conditions = prefixes.mapIndexed { index, prefix -> val paramName = "itemCode_$index" diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 55c546b7..ea39625c 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -64,7 +64,7 @@ open class StockLedgerReportService( args ) - val itemCodeSql = buildMultiValueLikeClause( + val itemCodeSql = buildItemCodeFilterClause( itemCode, "sl.itemCode", "itemCode", @@ -278,23 +278,28 @@ ORDER BY return jdbcDao.queryForList(sql, args) } - /** LIKE 多值工具方法 */ - private fun buildMultiValueLikeClause( + /** Full codes use equality; short tokens use prefix LIKE. */ + private fun buildItemCodeFilterClause( paramValue: String?, columnName: String, paramPrefix: String, args: MutableMap ): String { - if (paramValue.isNullOrBlank()) return "" - val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } - if (values.isEmpty()) return "" + val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(paramValue) + if (exact.isEmpty() && fuzzy.isEmpty()) return "" - val conditions = values.mapIndexed { index, value -> - val paramName = "${paramPrefix}_$index" - args[paramName] = "%$value%" - "$columnName LIKE :$paramName" + val parts = mutableListOf() + exact.forEachIndexed { index, value -> + val paramName = "${paramPrefix}_eq_$index" + args[paramName] = value + parts.add("$columnName = :$paramName") } - return "AND (${conditions.joinToString(" OR ")})" + 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 ")})" } /** = 多值工具方法 */ diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt index 17834aff..d43dde9e 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt @@ -7,6 +7,8 @@ import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -45,6 +47,35 @@ class MaterialStockOutTraceabilityReportController( @RequestParam(required = false) lastOutDateStart: String?, @RequestParam(required = false) lastOutDateEnd: String?, @RequestParam(required = false) handler: String?, + ): ResponseEntity = + buildMaterialStockOutTraceabilityPdf( + stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd, handler, + ) + + @PostMapping("/print-material-stock-out-traceability") + fun generateMaterialStockOutTraceabilityReportPost( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + return buildMaterialStockOutTraceabilityPdf( + r.stockCategory, + r.stockSubCategory, + r.combinedItemCode(), + r.year, + r.lastOutDateStart, + r.lastOutDateEnd, + r.handler, + ) + } + + private fun buildMaterialStockOutTraceabilityPdf( + stockCategory: String?, + stockSubCategory: String?, + itemCode: String?, + year: String?, + lastOutDateStart: String?, + lastOutDateEnd: String?, + handler: String?, ): ResponseEntity { val parameters = mutableMapOf() @@ -94,6 +125,35 @@ class MaterialStockOutTraceabilityReportController( @RequestParam(required = false) lastOutDateStart: String?, @RequestParam(required = false) lastOutDateEnd: String?, @RequestParam(required = false) handler: String?, + ): ResponseEntity = + buildMaterialStockOutTraceabilityExcel( + stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd, handler, + ) + + @PostMapping("/print-material-stock-out-traceability-excel") + fun exportMaterialStockOutTraceabilityReportExcelPost( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + return buildMaterialStockOutTraceabilityExcel( + r.stockCategory, + r.stockSubCategory, + r.combinedItemCode(), + r.year, + r.lastOutDateStart, + r.lastOutDateEnd, + r.handler, + ) + } + + private fun buildMaterialStockOutTraceabilityExcel( + stockCategory: String?, + stockSubCategory: String?, + itemCode: String?, + year: String?, + lastOutDateStart: String?, + lastOutDateEnd: String?, + handler: String?, ): ResponseEntity { val parameters = mutableMapOf() 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 7fba4747..816098bd 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 @@ -123,6 +123,27 @@ class ReportController( @RequestParam(required = false) itemCode: String?, @RequestParam(required = false) lastInDateStart: String?, @RequestParam(required = false) lastInDateEnd: String? + ): ResponseEntity = + buildStockInTraceabilityPdf(stockCategory, itemCode, lastInDateStart, lastInDateEnd) + + @PostMapping("/print-stock-in-traceability") + fun generateStockInTraceabilityReportPost( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + return buildStockInTraceabilityPdf( + r.stockCategory, + r.combinedItemCode(), + r.lastInDateStart, + r.lastInDateEnd, + ) + } + + private fun buildStockInTraceabilityPdf( + stockCategory: String?, + itemCode: String?, + lastInDateStart: String?, + lastInDateEnd: String?, ): ResponseEntity { val parameters = mutableMapOf() @@ -162,6 +183,27 @@ class ReportController( @RequestParam(required = false) itemCode: String?, @RequestParam(required = false) lastInDateStart: String?, @RequestParam(required = false) lastInDateEnd: String?, + ): ResponseEntity = + buildStockInTraceabilityExcel(stockCategory, itemCode, lastInDateStart, lastInDateEnd) + + @PostMapping("/print-stock-in-traceability-excel") + fun exportStockInTraceabilityReportExcelPost( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + return buildStockInTraceabilityExcel( + r.stockCategory, + r.combinedItemCode(), + r.lastInDateStart, + r.lastInDateEnd, + ) + } + + private fun buildStockInTraceabilityExcel( + stockCategory: String?, + itemCode: String?, + lastInDateStart: String?, + lastInDateEnd: String?, ): ResponseEntity { val dbData = reportService.searchStockInTraceabilityReport( stockCategory, @@ -193,6 +235,31 @@ class ReportController( @RequestParam(required = false) year: String?, @RequestParam(required = false) lastOutDateStart: String?, @RequestParam(required = false) lastOutDateEnd: String? + ): ResponseEntity = + buildFgDeliveryPdf(stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd) + + @PostMapping("/print-fg-delivery-report") + fun generateFGDeliveryReportPost( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + return buildFgDeliveryPdf( + r.stockCategory, + r.stockSubCategory, + r.combinedItemCode(), + r.year, + r.lastOutDateStart, + r.lastOutDateEnd, + ) + } + + private fun buildFgDeliveryPdf( + stockCategory: String?, + stockSubCategory: String?, + itemCode: String?, + year: String?, + lastOutDateStart: String?, + lastOutDateEnd: String?, ): ResponseEntity { val parameters = mutableMapOf() @@ -240,6 +307,31 @@ class ReportController( @RequestParam(required = false) year: String?, @RequestParam(required = false) lastOutDateStart: String?, @RequestParam(required = false) lastOutDateEnd: String?, + ): ResponseEntity = + buildFgDeliveryExcel(stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd) + + @PostMapping("/print-fg-delivery-report-excel") + fun exportFGDeliveryReportExcelPost( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + return buildFgDeliveryExcel( + r.stockCategory, + r.stockSubCategory, + r.combinedItemCode(), + r.year, + r.lastOutDateStart, + r.lastOutDateEnd, + ) + } + + private fun buildFgDeliveryExcel( + stockCategory: String?, + stockSubCategory: String?, + itemCode: String?, + year: String?, + lastOutDateStart: String?, + lastOutDateEnd: String?, ): ResponseEntity { val dbData = reportService.searchFGDeliveryReport( stockCategory, @@ -366,17 +458,7 @@ class ReportController( 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(",") - } + ): String? = ReportMultiValueTokens.combine(itemCode, itemCodePaste, itemCodes) private fun loadStockBalanceRows( stockCategory: String?, @@ -1075,6 +1157,31 @@ class ReportController( @RequestParam(required = false) supplier: String?, @RequestParam(required = false) poCode: String?, @RequestParam(required = false) grnCode: String?, + ): Map = + buildGrnReport(receiptDateStart, receiptDateEnd, itemCode, supplier, poCode, grnCode) + + @PostMapping("/grn-report") + fun getGrnReportPost( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): Map { + val r = req ?: ReportItemFilterRequest() + return buildGrnReport( + r.receiptDateStart, + r.receiptDateEnd, + r.combinedItemCode(), + r.supplier, + r.poCode, + r.grnCode, + ) + } + + private fun buildGrnReport( + receiptDateStart: String?, + receiptDateEnd: String?, + itemCode: String?, + supplier: String?, + poCode: String?, + grnCode: String?, ): Map { val rows = reportService.searchGrnReport( receiptDateStart, @@ -1135,6 +1242,19 @@ class ReportController( syncStatus = syncStatus, ) + @PostMapping("/bom-shop-sync-history") + fun getBomShopSyncHistoryReportPost( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): Map { + val r = req ?: ReportItemFilterRequest() + return m18BomShopSyncReportService.searchBomShopSyncHistoryReport( + syncDateStart = r.syncDateStart, + syncDateEnd = r.syncDateEnd, + finishedItemCode = r.combinedFinishedItemCode(), + syncStatus = r.syncStatus, + ) + } + /** * Shop Orders Replenishment Records (店鋪訂單補貨記錄). * JSON for Excel export: completed replenishments with linked ticket only. diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt new file mode 100644 index 00000000..168731ab --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt @@ -0,0 +1,33 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens + +/** Shared POST body for report filters that accept pasted item codes. */ +data class ReportItemFilterRequest( + val itemCode: String? = null, + val itemCodePaste: String? = null, + val itemCodes: List? = null, + val finishedItemCode: String? = null, + val stockCategory: String? = null, + val stockSubCategory: String? = null, + val year: String? = null, + val lastInDateStart: String? = null, + val lastInDateEnd: String? = null, + val lastOutDateStart: String? = null, + val lastOutDateEnd: String? = null, + val receiptDateStart: String? = null, + val receiptDateEnd: String? = null, + val syncDateStart: String? = null, + val syncDateEnd: String? = null, + val syncStatus: String? = null, + val handler: String? = null, + val supplier: String? = null, + val poCode: String? = null, + val grnCode: String? = null, +) { + fun combinedItemCode(): String? = + ReportMultiValueTokens.combine(itemCode, itemCodePaste, itemCodes) + + fun combinedFinishedItemCode(): String? = + ReportMultiValueTokens.combine(finishedItemCode, itemCodePaste, itemCodes) +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt index 2362797d..4182292e 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt @@ -46,6 +46,33 @@ class SemiFGProductionAnalysisReportController( @RequestParam(required = false) year: String?, @RequestParam(required = false) lastOutDateStart: String?, @RequestParam(required = false) lastOutDateEnd: String? + ): ResponseEntity = + buildSemiFgProductionAnalysisPdf( + stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd, + ) + + @PostMapping("/print-semi-fg-production-analysis") + fun generateSemiFGProductionAnalysisReportPost( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + return buildSemiFgProductionAnalysisPdf( + r.stockCategory, + r.stockSubCategory, + r.combinedItemCode(), + r.year, + r.lastOutDateStart, + r.lastOutDateEnd, + ) + } + + private fun buildSemiFgProductionAnalysisPdf( + stockCategory: String?, + stockSubCategory: String?, + itemCode: String?, + year: String?, + lastOutDateStart: String?, + lastOutDateEnd: String?, ): ResponseEntity { val parameters = mutableMapOf() @@ -94,6 +121,33 @@ class SemiFGProductionAnalysisReportController( @RequestParam(required = false) year: String?, @RequestParam(required = false) lastOutDateStart: String?, @RequestParam(required = false) lastOutDateEnd: String? + ): ResponseEntity = + buildSemiFgProductionAnalysisExcel( + stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd, + ) + + @PostMapping("/print-semi-fg-production-analysis-excel") + fun exportSemiFGProductionAnalysisReportExcelPost( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + return buildSemiFgProductionAnalysisExcel( + r.stockCategory, + r.stockSubCategory, + r.combinedItemCode(), + r.year, + r.lastOutDateStart, + r.lastOutDateEnd, + ) + } + + private fun buildSemiFgProductionAnalysisExcel( + stockCategory: String?, + stockSubCategory: String?, + itemCode: String?, + year: String?, + lastOutDateStart: String?, + lastOutDateEnd: String?, ): ResponseEntity { val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisReport( stockCategory, diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt index 97a5abdd..7572bd5d 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt @@ -45,6 +45,29 @@ class StockItemConsumptionTrendReportController( @RequestParam(required = false) year: String?, @RequestParam(required = false) lastOutDateStart: String?, @RequestParam(required = false) lastOutDateEnd: String? + ): ResponseEntity = + buildStockItemConsumptionTrendPdf(stockCategory, itemCode, year, lastOutDateStart, lastOutDateEnd) + + @PostMapping("/print-stock-item-consumption-trend") + fun generateStockItemConsumptionTrendReportPost( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + return buildStockItemConsumptionTrendPdf( + r.stockCategory, + r.combinedItemCode(), + r.year, + r.lastOutDateStart, + r.lastOutDateEnd, + ) + } + + private fun buildStockItemConsumptionTrendPdf( + stockCategory: String?, + itemCode: String?, + year: String?, + lastOutDateStart: String?, + lastOutDateEnd: String?, ): ResponseEntity { try { val parameters = mutableMapOf() @@ -98,6 +121,29 @@ class StockItemConsumptionTrendReportController( @RequestParam(required = false) year: String?, @RequestParam(required = false) lastOutDateStart: String?, @RequestParam(required = false) lastOutDateEnd: String? + ): ResponseEntity = + buildStockItemConsumptionTrendExcel(stockCategory, itemCode, year, lastOutDateStart, lastOutDateEnd) + + @PostMapping("/print-stock-item-consumption-trend-excel") + fun exportStockItemConsumptionTrendReportExcelPost( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + return buildStockItemConsumptionTrendExcel( + r.stockCategory, + r.combinedItemCode(), + r.year, + r.lastOutDateStart, + r.lastOutDateEnd, + ) + } + + private fun buildStockItemConsumptionTrendExcel( + stockCategory: String?, + itemCode: String?, + year: String?, + lastOutDateStart: String?, + lastOutDateEnd: String?, ): ResponseEntity { val dbData = stockItemConsumptionTrendReportService.searchStockItemConsumptionTrendReport( stockCategory = stockCategory, diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt index 53450321..7fa4fb43 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt @@ -4,6 +4,7 @@ import org.springframework.http.* import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter +import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens import com.ffii.fpsms.modules.report.service.StockLedgerReportService import com.ffii.fpsms.modules.report.service.ReportService import org.apache.poi.ss.usermodel.BorderStyle @@ -48,12 +49,66 @@ class StockLedgerReportController( @RequestParam(required = false) storeLocation: String?, @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, + ): ResponseEntity = + generateStockLedgerPdf(stockCategory, itemCode, storeLocation, reportPeriodStart, reportPeriodEnd) + + @PostMapping("/print-stock-ledger") + fun generateStockLedgerReportPost( + @RequestBody(required = false) req: StockLedgerReportRequest?, + ): ResponseEntity { + val r = req ?: StockLedgerReportRequest() + return generateStockLedgerPdf( + stockCategory = r.stockCategory, + itemCode = ReportMultiValueTokens.combine(r.itemCode, r.itemCodePaste, r.itemCodes), + storeLocation = r.storeLocation, + reportPeriodStart = r.lastInDateStart, + reportPeriodEnd = r.lastInDateEnd, + ) + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ + @GetMapping("/print-stock-ledger-excel") + fun exportStockLedgerReportExcel( + @RequestParam(required = false) stockCategory: String?, + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) storeLocation: String?, + @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, + @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, + ): ResponseEntity = + generateStockLedgerExcel(stockCategory, itemCode, storeLocation, reportPeriodStart, reportPeriodEnd) + + @PostMapping("/print-stock-ledger-excel") + fun exportStockLedgerReportExcelPost( + @RequestBody(required = false) req: StockLedgerReportRequest?, ): ResponseEntity { + val r = req ?: StockLedgerReportRequest() + return generateStockLedgerExcel( + stockCategory = r.stockCategory, + itemCode = ReportMultiValueTokens.combine(r.itemCode, r.itemCodePaste, r.itemCodes), + storeLocation = r.storeLocation, + reportPeriodStart = r.lastInDateStart, + reportPeriodEnd = r.lastInDateEnd, + ) + } + + private fun generateStockLedgerPdf( + stockCategory: String?, + itemCode: String?, + storeLocation: String?, + reportPeriodStart: String?, + reportPeriodEnd: String?, + ): ResponseEntity { + val tokenCount = ReportMultiValueTokens.split(itemCode).size val parameters = mutableMapOf() parameters["stockCategory"] = stockCategory ?: "All" parameters["stockSubCategory"] = stockCategory ?: "All" - parameters["itemNo"] = itemCode ?: "All" + parameters["itemNo"] = + when { + itemCode.isNullOrBlank() -> "All" + tokenCount > 30 -> "$tokenCount items" + else -> itemCode + } parameters["year"] = LocalDate.now().year.toString() parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) @@ -86,14 +141,12 @@ class StockLedgerReportController( return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } - /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ - @GetMapping("/print-stock-ledger-excel") - fun exportStockLedgerReportExcel( - @RequestParam(required = false) stockCategory: String?, - @RequestParam(required = false) itemCode: String?, - @RequestParam(required = false) storeLocation: String?, - @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, - @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, + private fun generateStockLedgerExcel( + stockCategory: String?, + itemCode: String?, + storeLocation: String?, + reportPeriodStart: String?, + reportPeriodEnd: String?, ): ResponseEntity { val dbData = stockLedgerReportService.searchStockLedgerReport( stockCategory = stockCategory, @@ -436,3 +489,13 @@ class StockLedgerReportController( } } } + +data class StockLedgerReportRequest( + val lastInDateStart: String? = null, + val lastInDateEnd: String? = null, + val itemCode: String? = null, + val itemCodePaste: String? = null, + val itemCodes: List? = null, + val stockCategory: String? = null, + val storeLocation: String? = null, +) diff --git a/src/main/resources/db/changelog/changes/20260918_po_stockin_refresh_if_newer/01_setting.sql b/src/main/resources/db/changelog/changes/20260918_po_stockin_refresh_if_newer/01_setting.sql new file mode 100644 index 00000000..77303c76 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260918_po_stockin_refresh_if_newer/01_setting.sql @@ -0,0 +1,14 @@ +--liquibase formatted sql +--changeset fai:20260918_po_stockin_refresh_if_newer_setting + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT v.name, v.value, v.category, v.type +FROM ( + SELECT 'M18.po.stockIn.refreshIfNewer.enabled' AS name, + 'false' AS value, + 'M18' AS category, + 'boolean' AS type +) v +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` s WHERE s.name = 'M18.po.stockIn.refreshIfNewer.enabled' +); 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 index a24dd940..b796ab4c 100644 --- a/src/test/kotlin/com/ffii/fpsms/modules/report/service/ReportMultiValueTokensTest.kt +++ b/src/test/kotlin/com/ffii/fpsms/modules/report/service/ReportMultiValueTokensTest.kt @@ -48,4 +48,12 @@ class ReportMultiValueTokensTest { assertEquals(listOf("FA0123"), exact) assertEquals(listOf("FA"), fuzzy) } + + @Test + fun `combine merges select paste and json list`() { + assertEquals( + "FA0123,FA2332,FA9999", + ReportMultiValueTokens.combine("FA0123", "FA2332 FA0123", listOf("FA9999")), + ) + } } From a81c02be3f6e57ffe7cc2d7cc22151b3fc6522a5 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 18 Sep 2026 16:16:05 +0800 Subject: [PATCH 05/16] added a sync for stock in PO, when entered poNum and status = pending, and also the database settings M18.po.stockIn.refreshIfNewer.enabled = true (liquidbase default is false), then ask m18 to sync with lastModified after MTMS PO modified datetime --- .../java/com/ffii/fpsms/config/WebConfig.java | 2 +- .../m18/service/M18PurchaseOrderService.kt | 135 +++++++++--------- .../fpsms/modules/common/SettingNames.java | 4 +- .../service/PurchaseOrderService.kt | 14 ++ .../web/PurchaseOrderController.kt | 22 ++- .../settings/service/SettingsService.java | 12 ++ 6 files changed, 114 insertions(+), 75 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/config/WebConfig.java b/src/main/java/com/ffii/fpsms/config/WebConfig.java index 5ef870a4..9a299404 100644 --- a/src/main/java/com/ffii/fpsms/config/WebConfig.java +++ b/src/main/java/com/ffii/fpsms/config/WebConfig.java @@ -16,7 +16,7 @@ public class WebConfig implements WebMvcConfigurer { registry.addMapping("/**") .allowedHeaders("*") .allowedOrigins("*") - .exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry") + .exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry", "X-M18-Po-Refresh") .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"); } diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt index a0c4eedf..dd488c96 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt @@ -20,7 +20,6 @@ import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderLineService import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderService import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderLineRequest import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderRequest -import com.ffii.fpsms.modules.settings.entity.Settings import com.ffii.fpsms.modules.settings.service.SettingsService import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -45,12 +44,18 @@ open class M18PurchaseOrderService( val m18MasterDataService: M18MasterDataService, val settingsService: SettingsService, ) { + companion object { + fun isStockInLookupPoCode(code: String?): Boolean { + val trimmed = code?.trim().orEmpty() + return trimmed.length > 14 && (trimmed.startsWith("PP") || trimmed.startsWith("PF")) + } + } + val commonUtils = CommonUtils() val logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) val lastModifyDateStart = "2025-05-14 14:00:00" val lastModifyDateEnd = "2025-05-14 14:30:00" - private val M18_LAST_MODIFY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") // val lastModifyDateConds = // "lastModifyDate=largerOrEqual=${lastModifyDateStart}=and=lastModifyDate=lessOrEqual=${lastModifyDateEnd}" // val lastModifyDate = LocalDateTime.now().minusMinutes(30) @@ -237,67 +242,65 @@ open class M18PurchaseOrderService( } /** - * Stock-in `/po` lookup: create if missing; for a local **pending** PO, pull from M18 only when - * M18 list `lastModifyDate` is after [PurchaseOrder.modified] (last MTMS header write, usually last sync). - * Receiving / completed POs are not refreshed. + * Stock-in `/po` lookup. + * - Missing local PO: always sync from M18 (same as [savePurchaseOrderByCode]). + * - Local pending + setting [SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED] true: + * sync from M18 (overwrite pending). Receiving / completed are not refreshed. + * - Local PO + setting not true: do not call M18. */ - open fun savePurchaseOrderByCodeIfNewer(code: String): SyncResult { + open fun savePurchaseOrderByCodeIfNewer(code: String): SyncResult = + refreshForStockInLookup(code).syncResult + + open fun refreshForStockInLookup(code: String): StockInPoRefreshOutcome { val trimmed = code.trim() - val localPo = purchaseOrderService.findByCode(trimmed) - if (localPo != null && !isStockInRefreshIfNewerEnabled()) { - logger.info( - "Purchase Order: Skipping if-newer M18 sync — setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} " + - "is not true. code=$trimmed" - ) - return SyncResult( - totalProcessed = 0, - totalSuccess = 0, - totalFail = 0, - query = "skipped (ifNewer): setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} is not true" - ) - } - if (localPo != null && localPo.status != PurchaseOrderStatus.PENDING) { + val enabled = isStockInRefreshIfNewerEnabled() + val localStatus = purchaseOrderService.findActiveStatusByCode(trimmed) + logger.info( + "Purchase Order: stock-in ifNewer code={} settingEnabled={} localStatus={}", + trimmed, + enabled, + localStatus ?: "", + ) + if (localStatus != null && !enabled) { logger.info( - "Purchase Order: Skipping if-newer M18 sync — local PO id=${localPo.id} code=${localPo.code} " + - "status=${localPo.status?.value} (only pending may be refreshed)." + "Purchase Order: Skipping M18 sync — setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} " + + "is not true. code=$trimmed status=$localStatus" ) - return SyncResult( - totalProcessed = 0, - totalSuccess = 0, - totalFail = 0, - query = "skipped (ifNewer): local PO not pending: $trimmed status=${localPo.status?.value}" + return StockInPoRefreshOutcome( + action = StockInPoRefreshAction.SKIPPED_DISABLED, + syncResult = SyncResult( + totalProcessed = 0, + totalSuccess = 0, + totalFail = 0, + query = "skipped (ifNewer): setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} is not true" + ), ) } - if (localPo == null) { - return savePurchaseOrderByCode(trimmed) - } - - val conds = "(code=equal=$trimmed)" - val poListResponse = searchPurchaseOrdersByConds(conds) - val m18Row = poListResponse?.values?.firstOrNull() - val localModified = localPo.modified ?: localPo.created - val m18LastModify = parseM18LastModifyDate(m18Row?.lastModifyDate) - if (m18Row == null || m18LastModify == null || localModified == null || !m18LastModify.isAfter(localModified)) { + if (localStatus != null && + !PurchaseOrderStatus.PENDING.value.equals(localStatus, ignoreCase = true) + ) { logger.info( - "Purchase Order: Skipping if-newer M18 sync — M18 lastModifyDate not after " + - "purchase_order.modified. code=$trimmed localModified=$localModified " + - "m18LastModifyRaw=${m18Row?.lastModifyDate} m18LastModify=$m18LastModify" + "Purchase Order: Skipping M18 sync — local PO code=$trimmed status=$localStatus (only pending may be refreshed)." ) - return SyncResult( - totalProcessed = 0, - totalSuccess = 0, - totalFail = 0, - query = "skipped (ifNewer): M18 lastModifyDate not after purchase_order.modified: " + - "code=$trimmed localModified=$localModified m18LastModify=${m18Row?.lastModifyDate}" + return StockInPoRefreshOutcome( + action = StockInPoRefreshAction.SKIPPED_NOT_PENDING, + syncResult = SyncResult( + totalProcessed = 0, + totalSuccess = 0, + totalFail = 0, + query = "skipped (ifNewer): local PO not pending: $trimmed status=$localStatus" + ), ) } - logger.info( - "Purchase Order: M18 lastModifyDate after purchase_order.modified — syncing. " + - "code=$trimmed localModified=$localModified m18LastModify=$m18LastModify" + return StockInPoRefreshOutcome( + action = StockInPoRefreshAction.SYNCED, + syncResult = savePurchaseOrderByCode(trimmed), ) - return saveFromPoListSearch(conds, poListResponse) } + private fun isStockInRefreshIfNewerEnabled(): Boolean = + settingsService.isBooleanTrueFromDb(SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED) + private fun saveFromPoListSearch( query: String, poListResponse: M18PurchaseOrderListResponse?, @@ -318,25 +321,6 @@ open class M18PurchaseOrderService( return savePurchaseOrdersWithPreparedList(purchaseOrdersWithType = request) } - private fun isStockInRefreshIfNewerEnabled(): Boolean = - settingsService.findByName(SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED) - .map { Settings.VALUE_BOOLEAN_TRUE.equals(it.value?.trim(), ignoreCase = true) } - .orElse(false) - - private fun parseM18LastModifyDate(raw: String?): LocalDateTime? { - val value = raw?.trim().orEmpty() - if (value.isEmpty()) return null - return try { - LocalDateTime.parse(value, M18_LAST_MODIFY_FORMATTER) - } catch (_: Exception) { - try { - LocalDateTime.parse(value) - } catch (_: Exception) { - value.toLongOrNull()?.let { commonUtils.timestampToLocalDateTime(it) } - } - } - } - private fun searchPurchaseOrdersByConds(conds: String): M18PurchaseOrderListResponse? { val searchRequest = M18PurchaseOrderListRequest( stSearch = "po", @@ -752,4 +736,15 @@ open class M18PurchaseOrderService( query = purchaseOrdersWithType?.query ?: "" ) } -} \ No newline at end of file +} + +enum class StockInPoRefreshAction(val headerValue: String) { + SKIPPED_DISABLED("skipped_disabled"), + SKIPPED_NOT_PENDING("skipped_not_pending"), + SYNCED("synced"), +} + +data class StockInPoRefreshOutcome( + val action: StockInPoRefreshAction, + val syncResult: SyncResult, +) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index 00a13e2b..4f747a09 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -53,9 +53,9 @@ public abstract class SettingNames { public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled"; /** - * When "true", stock-in {@code /po} lookup for an existing pending PO asks M18 whether - * {@code lastModifyDate} is after {@code purchase_order.modified} and re-syncs if newer. + * When "true", stock-in {@code /po} lookup for an existing pending PO re-syncs from M18. * Read from DB on each request (no server restart). Missing POs still auto-sync. + * Receiving / completed POs are never overwritten. */ public static final String M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED = "M18.po.stockIn.refreshIfNewer.enabled"; 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 9474f071..80a3e833 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 @@ -364,6 +364,20 @@ open fun getPoSummariesByIds(ids: List): List { return purchaseOrderRepository.findTopByCodeAndDeletedIsFalseOrderByModifiedDesc(code) } + /** JDBC lookup so stock-in skip does not depend on JPA finding the entity. */ + open fun findActiveStatusByCode(code: String): String? { + val status = jdbcDao.queryForString( + """ + SELECT status FROM purchase_order + WHERE deleted = 0 AND code = :code + ORDER BY modified DESC + LIMIT 1 + """.trimIndent(), + mapOf("code" to code), + ) + return status.takeIf { it.isNotBlank() } + } + @Throws(IOException::class) @Transactional open fun savePurchaseOrder(request: SavePurchaseOrderRequest): SavePurchaseOrderResponse { diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt index f2ccc1d9..6e475304 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt @@ -10,12 +10,14 @@ import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderDataClass import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderInfo +import com.ffii.fpsms.m18.service.M18PurchaseOrderService import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderService import com.ffii.fpsms.modules.purchaseOrder.web.model.PagingRequest import com.ffii.fpsms.modules.stock.service.StockInLineService import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse import net.sf.jasperreports.engine.JasperExportManager import net.sf.jasperreports.engine.JasperPrint import org.springframework.data.domain.Page @@ -23,15 +25,20 @@ import org.springframework.data.domain.PageRequest import org.springframework.web.bind.annotation.* import java.io.File import com.ffii.fpsms.modules.purchaseOrder.web.model.PurchaseOrderSummary +import org.slf4j.LoggerFactory + @RestController @RequestMapping("/po") class PurchaseOrderController( private val purchaseOrderService: PurchaseOrderService, - private val stockInLineService: StockInLineService + private val stockInLineService: StockInLineService, + private val m18PurchaseOrderService: M18PurchaseOrderService, ) { + private val logger = LoggerFactory.getLogger(PurchaseOrderController::class.java) @GetMapping("/list") fun getPoList( - request: HttpServletRequest + request: HttpServletRequest, + response: HttpServletResponse, // @RequestParam(required = false) pageNum: Int, // @RequestParam(required = false) pageSize: Int ): RecordsRes { @@ -50,6 +57,17 @@ class PurchaseOrderController( // println(criteriaArgs) val pageSize = request.getParameter("pageSize")?.toIntOrNull()?.coerceAtLeast(1) ?: 10 val pageNum = request.getParameter("pageNum")?.toIntOrNull()?.coerceAtLeast(1) ?: 1 + val rawCode = request.getParameter("code")?.trim() + val refreshFromM18 = request.getParameter("refreshFromM18")?.equals("true", ignoreCase = true) == true + if (refreshFromM18 && M18PurchaseOrderService.isStockInLookupPoCode(rawCode)) { + try { + logger.info("Stock-in /po/list: M18 refresh for code={}", rawCode) + val outcome = m18PurchaseOrderService.refreshForStockInLookup(rawCode!!) + response.setHeader("X-M18-Po-Refresh", outcome.action.headerValue) + } catch (e: Exception) { + logger.error("Stock-in /po/list: M18 refresh failed for code={}", rawCode, e) + } + } val total = purchaseOrderService.getPoListTotalCount(criteriaArgs) if (total == 0) { diff --git a/src/main/java/com/ffii/fpsms/modules/settings/service/SettingsService.java b/src/main/java/com/ffii/fpsms/modules/settings/service/SettingsService.java index 6e43c349..bacadcd9 100644 --- a/src/main/java/com/ffii/fpsms/modules/settings/service/SettingsService.java +++ b/src/main/java/com/ffii/fpsms/modules/settings/service/SettingsService.java @@ -30,6 +30,18 @@ public class SettingsService extends AbstractIdEntityService findAllByCategory(String category) { return this.repository.findAllByCategory(category); } From 263a7fefe1610801aaaea080c8384ce6bde1e4fa Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 18 Sep 2026 17:51:46 +0800 Subject: [PATCH 06/16] qc detail putawayed qty fix --- .../stock/entity/projection/StockInLineInfo.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt index ed4fb6c3..587b7fce 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt @@ -67,12 +67,15 @@ interface StockInLineInfo { interface PutAwayLineForSil { val id: Long?; - /** Stock qty (inventory lot line inQty) */ - @get:Value("#{target.inQty}") + /** + * FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.5 | 2026-09-18 + * Origin putaway qty from inventory_lot.stockInLine (lot line → lot → SIL). + * Do not use inventory_lot_line.inQty: later ADJ/TKE is added on the same line. + */ + @get:Value("#{target.inventoryLot?.stockInLine?.acceptedQty}") val stockQty: BigDecimal?; - @get:Value("#{target.inQty " + - "* ((target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD))" + - "/ ((target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioD))}") + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.5 | 2026-09-18 */ + @get:Value("#{target.inventoryLot?.stockInLine?.acceptedQtyM18 ?: target.inventoryLot?.stockInLine?.acceptedQty}") val qty: BigDecimal?; @get:Value("#{target.warehouse?.code} - #{target.warehouse?.name}") val warehouse: String?; From 942ec0d8e261f5b8258859c3c2c56d01b969a238 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 21 Sep 2026 12:34:38 +0800 Subject: [PATCH 07/16] Revert inventory location filters and item+UoM grouped search. Restore searchLatest to one latest inventory row per item. Report multi-value item codes unchanged. Co-authored-by: Cursor --- .../master/entity/WarehouseRepository.kt | 17 --- .../entity/InventoryLotLineRepository.kt | 113 +----------------- .../projection/InventoryStockUomSearchRow.kt | 30 ----- .../stock/service/InventoryLotLineService.kt | 37 +----- .../modules/stock/service/InventoryService.kt | 62 ++++------ .../modules/stock/web/InventoryController.kt | 7 +- .../SearchInventoryLotLineInfoRequest.kt | 7 -- .../stock/web/model/SearchInventoryRequest.kt | 6 - 8 files changed, 30 insertions(+), 249 deletions(-) delete mode 100644 src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/WarehouseRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/WarehouseRepository.kt index d3ac33bb..45c1ed60 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/WarehouseRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/WarehouseRepository.kt @@ -5,7 +5,6 @@ import com.ffii.fpsms.modules.master.entity.projections.WarehouseCombo import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository import java.io.Serializable @@ -23,22 +22,6 @@ interface WarehouseRepository : AbstractRepository { fun findAllByIdIn(ids: List): List; fun findAllByCodeAndDeletedIsFalse(code: String): List - /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 */ - @Query( - """ - SELECT w FROM Warehouse w - WHERE w.deleted = false - AND (:storeId IS NULL OR :storeId = '' OR w.store_id = :storeId) - AND (:warehouse IS NULL OR :warehouse = '' OR w.warehouse = :warehouse) - AND (:area IS NULL OR :area = '' OR w.area = :area) - """ - ) - fun findByLocationFilters( - @Param("storeId") storeId: String?, - @Param("warehouse") warehouse: String?, - @Param("area") area: String?, - ): List - @Query( """ SELECT COUNT(w) FROM Warehouse w diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index c854c08e..7b5ff3ee 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -3,7 +3,6 @@ package com.ffii.fpsms.modules.stock.entity import com.ffii.core.support.AbstractRepository import com.ffii.fpsms.modules.stock.entity.projection.CurrentInventoryItemInfo import com.ffii.fpsms.modules.stock.entity.projection.InventoryLotLineInfo -import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRow import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.Modifying @@ -57,116 +56,8 @@ WHERE ill.id = :id fun findInventoryLotLineInfoByInventoryLotItemIdIn(ids: List): List - @Query( - """ - SELECT ill FROM InventoryLotLine ill - WHERE (:id IS NULL OR ill.inventoryLot.item.id = :id) - AND (:uomId IS NULL OR ill.stockUom.uom.id = :uomId) - ORDER BY ill.id DESC - """ - ) - /** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.0 | 2026-09-10 */ - fun findInventoryLotLineInfoByItemId( - @Param("id") id: Long?, - @Param("uomId") uomId: Long?, - pageable: Pageable, - ): Page - - @Query( - """ - SELECT DISTINCT ill.inventoryLot.item.id - FROM InventoryLotLine ill - WHERE ill.deleted = false - AND ill.status = :status - AND ill.warehouse.id IN :warehouseIds - """ - ) - fun findDistinctItemIdsByWarehouseIdsAndStatus( - @Param("warehouseIds") warehouseIds: List, - @Param("status") status: InventoryLotLineStatus, - ): List - - @Query( - """ - SELECT ill FROM InventoryLotLine ill - WHERE (:id IS NULL OR ill.inventoryLot.item.id = :id) - AND ill.warehouse.id IN :warehouseIds - AND (:uomId IS NULL OR ill.stockUom.uom.id = :uomId) - ORDER BY ill.id DESC - """ - ) - /** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.0 | 2026-09-10 */ - fun findInventoryLotLineInfoByItemIdAndWarehouseIdIn( - @Param("id") id: Long?, - @Param("warehouseIds") warehouseIds: List, - @Param("uomId") uomId: Long?, - pageable: Pageable, - ): Page - - @Query( - value = """ - SELECT - MAX(CAST(ill.id AS long)) AS id, - CAST(item.id AS long) AS itemId, - item.code AS itemCode, - item.name AS itemName, - item.type AS itemType, - SUM(COALESCE(ill.inQty, 0)) AS onHandQty, - SUM(COALESCE(ill.holdQty, 0)) AS onHoldQty, - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) AS availableQty, - CAST(uom.id AS long) AS uomId, - uom.code AS uomCode, - uom.udfudesc AS uomUdfudesc, - uom.udfShortDesc AS uomShortDesc, - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) AS qtyPerSmallestUnit, - MAX(item.latestMarketUnitPrice) AS latestMarketUnitPrice, - MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate - FROM InventoryLotLine ill - JOIN ill.inventoryLot il - JOIN il.item item - JOIN ill.stockUom su - JOIN su.uom uom - WHERE ill.deleted = false - AND il.deleted = false - AND item.deleted = false - AND ill.status = :status - AND (:code IS NULL OR :code = '' OR LOWER(item.code) LIKE LOWER(CONCAT('%', :code, '%'))) - AND (:name IS NULL OR :name = '' OR LOWER(item.name) LIKE LOWER(CONCAT('%', :name, '%'))) - AND (:type IS NULL OR :type = '' OR item.type = :type) - AND (:hasWarehouseFilter = false OR ill.warehouse.id IN :warehouseIds) - AND (:lotNo IS NULL OR :lotNo = '' OR il.lotNo = :lotNo) - GROUP BY item.id, item.code, item.name, item.type, uom.id, uom.code, uom.udfudesc, uom.udfShortDesc - ORDER BY item.code ASC, uom.udfudesc ASC - """, - countQuery = """ - SELECT COUNT(DISTINCT CONCAT(CAST(item.id AS string), ':', CAST(uom.id AS string))) - FROM InventoryLotLine ill - JOIN ill.inventoryLot il - JOIN il.item item - JOIN ill.stockUom su - JOIN su.uom uom - WHERE ill.deleted = false - AND il.deleted = false - AND item.deleted = false - AND ill.status = :status - AND (:code IS NULL OR :code = '' OR LOWER(item.code) LIKE LOWER(CONCAT('%', :code, '%'))) - AND (:name IS NULL OR :name = '' OR LOWER(item.name) LIKE LOWER(CONCAT('%', :name, '%'))) - AND (:type IS NULL OR :type = '' OR item.type = :type) - AND (:hasWarehouseFilter = false OR ill.warehouse.id IN :warehouseIds) - AND (:lotNo IS NULL OR :lotNo = '' OR il.lotNo = :lotNo) - """, - ) - /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 */ - fun searchInventoryGroupedByStockUom( - @Param("code") code: String, - @Param("name") name: String, - @Param("type") type: String, - @Param("status") status: InventoryLotLineStatus, - @Param("hasWarehouseFilter") hasWarehouseFilter: Boolean, - @Param("warehouseIds") warehouseIds: List, - @Param("lotNo") lotNo: String?, - pageable: Pageable, - ): Page + @Query("select ill from InventoryLotLine ill where :id is null or ill.inventoryLot.item.id = :id order by ill.id desc") + fun findInventoryLotLineInfoByItemId(id: Long?, pageable: Pageable): Page @Query(""" SELECT ill FROM InventoryLotLine ill diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt deleted file mode 100644 index e48f4af7..00000000 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.ffii.fpsms.modules.stock.entity.projection - -import java.math.BigDecimal -import java.time.LocalDateTime - -/** - * Inventory search page: one row per item + lot-line stock UoM. - */ -interface InventoryStockUomSearchRow { - val id: Long? - val itemId: Long? - val itemCode: String? - val itemName: String? - val itemType: String? - val onHandQty: BigDecimal? - val onHoldQty: BigDecimal? - val unavailableQty: BigDecimal? - val availableQty: BigDecimal? - val uomId: Long? - val uomCode: String? - val uomUdfudesc: String? - val uomShortDesc: String? - val qtyPerSmallestUnit: BigDecimal? - val baseUom: String? - val price: BigDecimal? - val currencyName: String? - val status: String? - val latestMarketUnitPrice: Double? - val latestMupUpdatedDate: LocalDateTime? -} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index c47f2fda..78494b98 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -76,15 +76,9 @@ open class InventoryLotLineService( return inventoryLotLineRepository.findInventoryLotLineInfoByInventoryLotItemIdIn(itemIds) } - /** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.0 | 2026-09-10 */ open fun allInventoryLotLinesByPage(request: SearchInventoryLotLineInfoRequest): RecordsRes { val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10); - val warehouseIds = resolveWarehouseIds(request.storeId, request.warehouse, request.area) - if (warehouseIds != null && warehouseIds.isEmpty()) { - return RecordsRes(emptyList(), 0) - } - val response = if (request.stockIssueBadItem == true) { inventoryLotLineRepository.findStockIssueBadItemLotLinesByItemId( request.itemId, @@ -92,15 +86,8 @@ open class InventoryLotLineService( listOf(InventoryLotLineStatus.AVAILABLE, InventoryLotLineStatus.UNAVAILABLE), pageable, ) - } else if (warehouseIds != null) { - inventoryLotLineRepository.findInventoryLotLineInfoByItemIdAndWarehouseIdIn( - request.itemId, - warehouseIds, - request.uomId, - pageable, - ) } else { - inventoryLotLineRepository.findInventoryLotLineInfoByItemId(request.itemId, request.uomId, pageable) + inventoryLotLineRepository.findInventoryLotLineInfoByItemId(request.itemId, pageable) } val records = response.content @@ -108,28 +95,6 @@ open class InventoryLotLineService( return RecordsRes(records, total.toInt()); } - /** - * FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.0 | 2026-09-10 - * Returns null when no location filter is set. - */ - private fun resolveWarehouseIds( - storeId: String?, - warehouse: String?, - area: String?, - ): List? { - val trimmedStoreId = storeId?.trim()?.takeIf { it.isNotEmpty() } - val trimmedWarehouse = warehouse?.trim()?.takeIf { it.isNotEmpty() } - val trimmedArea = area?.trim()?.takeIf { it.isNotEmpty() } - if (trimmedStoreId == null && trimmedWarehouse == null && trimmedArea == null) { - return null - } - return warehouseRepository.findByLocationFilters( - trimmedStoreId, - trimmedWarehouse, - trimmedArea, - ).mapNotNull { it.id } - } - open fun searchStockIssueBadItemLotLines( request: SearchStockIssueBadItemLotLineRequest, ): RecordsRes { diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt index da108a77..d909d742 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt @@ -17,7 +17,6 @@ import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderType import com.ffii.fpsms.modules.stock.entity.* import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo -import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRow import com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest import org.apache.poi.ss.usermodel.Sheet import org.apache.poi.ss.usermodel.Workbook @@ -90,56 +89,43 @@ open class InventoryService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 - * Inventory search page: one row per item + lot-line stock UoM. + * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 + * Inventory search page only: no baseUnit/uomId filter; + * one inventory row per item (latest id). Shared getRecordByPage is unchanged. */ open fun searchInventoriesByLatestInventory( request: SearchInventoryRequest, - ): RecordsRes { + ): RecordsRes { val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10) val code = request.code?.trim().orEmpty() val name = request.name?.trim().orEmpty() val type = request.type?.trim().orEmpty() val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } - val warehouseIds = resolveWarehouseIds(request.storeId, request.warehouse, request.area) - if (warehouseIds != null && warehouseIds.isEmpty()) { - return RecordsRes(emptyList(), 0) - } - val hasWarehouseFilter = warehouseIds != null - val response = inventoryLotLineRepository.searchInventoryGroupedByStockUom( - code = code, - name = name, - type = type, - status = InventoryLotLineStatus.AVAILABLE, - hasWarehouseFilter = hasWarehouseFilter, - warehouseIds = warehouseIds ?: listOf(-1L), - lotNo = lotNo, - pageable = pageable, - ) + val response = if (lotNo != null) { + val itemIds = inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo) + if (itemIds.isEmpty()) { + return RecordsRes(emptyList(), 0) + } + inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( + code = code, + name = name, + type = type, + itemIds = itemIds, + pageable = pageable, + ) + } else { + inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( + code = code, + name = name, + type = type, + pageable = pageable, + ) + } return RecordsRes(response.content, response.totalElements.toInt()) } - /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 */ - private fun resolveWarehouseIds( - storeId: String?, - warehouse: String?, - area: String?, - ): List? { - val trimmedStoreId = storeId?.trim()?.takeIf { it.isNotEmpty() } - val trimmedWarehouse = warehouse?.trim()?.takeIf { it.isNotEmpty() } - val trimmedArea = area?.trim()?.takeIf { it.isNotEmpty() } - if (trimmedStoreId == null && trimmedWarehouse == null && trimmedArea == null) { - return null - } - return warehouseRepository.findByLocationFilters( - trimmedStoreId, - trimmedWarehouse, - trimmedArea, - ).mapNotNull { it.id } - } - open fun allInventoriesByItems(items: List): List{ return inventoryRepository.findInventoryInfoByItemInAndDeletedIsFalse(items); } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt index c868176d..20cf216a 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt @@ -2,7 +2,6 @@ package com.ffii.fpsms.modules.stock.web import com.ffii.core.response.RecordsRes import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo -import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRow import com.ffii.fpsms.modules.stock.service.InventoryService import com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest import org.springframework.web.bind.annotation.* @@ -23,13 +22,13 @@ class InventoryController( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 - * Inventory search page: one row per item + lot-line stock UoM, with optional location filters. + * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 + * Inventory search page only: latest inventory row per item, no baseUnit/uomId filter. */ @GetMapping("/searchLatest/getRecordByPage") fun searchInventoriesByLatestInventory( @ModelAttribute request: SearchInventoryRequest, - ): RecordsRes { + ): RecordsRes { return inventoryService.searchInventoriesByLatestInventory(request) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryLotLineInfoRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryLotLineInfoRequest.kt index edf1b752..53d73273 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryLotLineInfoRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryLotLineInfoRequest.kt @@ -2,15 +2,8 @@ package com.ffii.fpsms.modules.stock.web.model data class SearchInventoryLotLineInfoRequest( val itemId: Long? = null, - val uomId: Long? = null, val pageSize: Int?, val pageNum: Int?, /** When true: non-expired lots with inQty > outQty; includes available and unavailable status. */ val stockIssueBadItem: Boolean? = false, - /** Floor / store, e.g. 2F. Used by inventory location search. */ - val storeId: String? = null, - /** Warehouse zone, e.g. W201. */ - val warehouse: String? = null, - /** Area, e.g. #R. */ - val area: String? = null, ) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryRequest.kt index 7e3cbc2f..c40dcb4b 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryRequest.kt @@ -5,12 +5,6 @@ data class SearchInventoryRequest( val name: String, val type: String, val lotNo: String? = null, - /** Floor / store, e.g. 2F. Used by inventory location search. */ - val storeId: String? = null, - /** Warehouse zone, e.g. W201. */ - val warehouse: String? = null, - /** Area, e.g. #R. */ - val area: String? = null, val pageNum: Int?, val pageSize: Int? ) From 69b36d36e60a87b042e4aef5c9c10ed4435a0d92 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 21 Sep 2026 12:37:58 +0800 Subject: [PATCH 08/16] Update FP-MTMS checklist comments for inventory Location Search revert. Co-authored-by: Cursor --- .../fpsms/modules/stock/entity/InventoryLotLineRepository.kt | 1 + .../ffii/fpsms/modules/stock/service/InventoryLotLineService.kt | 1 + .../com/ffii/fpsms/modules/stock/service/InventoryService.kt | 2 +- .../com/ffii/fpsms/modules/stock/web/InventoryController.kt | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 7b5ff3ee..68911e1c 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -56,6 +56,7 @@ WHERE ill.id = :id fun findInventoryLotLineInfoByInventoryLotItemIdIn(ids: List): List + /** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.1 | 2026-09-21 */ @Query("select ill from InventoryLotLine ill where :id is null or ill.inventoryLot.item.id = :id order by ill.id desc") fun findInventoryLotLineInfoByItemId(id: Long?, pageable: Pageable): Page diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index 78494b98..377e8b48 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -76,6 +76,7 @@ open class InventoryLotLineService( return inventoryLotLineRepository.findInventoryLotLineInfoByInventoryLotItemIdIn(itemIds) } + /** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.1 | 2026-09-21 */ open fun allInventoryLotLinesByPage(request: SearchInventoryLotLineInfoRequest): RecordsRes { val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10); diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt index d909d742..a2447531 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt @@ -89,7 +89,7 @@ open class InventoryService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 + * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.2 | 2026-09-21 * Inventory search page only: no baseUnit/uomId filter; * one inventory row per item (latest id). Shared getRecordByPage is unchanged. */ diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt index 20cf216a..10edbb55 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt @@ -22,7 +22,7 @@ class InventoryController( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 + * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.2 | 2026-09-21 * Inventory search page only: latest inventory row per item, no baseUnit/uomId filter. */ @GetMapping("/searchLatest/getRecordByPage") From 586bb098054c5f7c1e3ebc5a6c23b672bc81af18 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Thu, 24 Sep 2026 00:23:36 +0800 Subject: [PATCH 09/16] added unit showing refine in stock take, allow user to set a warning percent to remind stock take difference when inputing the stock qty, try to quicken the transaction inventory report for PDF and excel --- .../fpsms/modules/common/SettingNames.java | 6 + .../modules/report/service/ReportService.kt | 22 +- .../modules/report/service/StockLedgerPdf.kt | 257 +++++++++++ .../service/StockLedgerReportService.kt | 412 ++++++++++-------- .../report/web/StockLedgerReportController.kt | 34 +- .../settings/web/SettingsController.java | 13 + .../stock/service/StockTakeRecordService.kt | 6 + .../stock/web/model/StockTakeRecordReponse.kt | 2 + .../01_setting.sql | 9 + .../resources/jasper/StockLedgarReport.jrxml | 2 +- .../report/service/StockLedgerPdfSpeedTest.kt | 40 ++ 11 files changed, 578 insertions(+), 225 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt create mode 100644 src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index 4f747a09..f5e4bbae 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -77,6 +77,12 @@ public abstract class SettingNames { * Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday. */ public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart"; + + /** + * Stock-take count vs on-hand gap (percent) that shows a non-blocking warning. + * Either direction. Default 50. + */ + public static final String STOCK_TAKE_QTY_GAP_WARN_PERCENT = "STOCK_TAKE.qtyGapWarnPercent"; /* * Mail settings */ 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 377b2f23..0f9a34b6 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 @@ -4,7 +4,7 @@ import org.springframework.stereotype.Service import net.sf.jasperreports.engine.* import net.sf.jasperreports.engine.data.JRMapCollectionDataSource import java.io.ByteArrayOutputStream -import java.io.InputStream +import java.util.concurrent.ConcurrentHashMap import com.ffii.core.support.JdbcDao import com.ffii.fpsms.m18.M18GrnRules import com.ffii.fpsms.modules.master.entity.ShopRepository @@ -28,6 +28,15 @@ open class ReportService( private val shopRepository: ShopRepository, private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { + private val compiledReports = ConcurrentHashMap() + + private fun compiledReport(templatePath: String): JasperReport = + compiledReports.computeIfAbsent(templatePath) { path -> + val stream = this::class.java.getResourceAsStream(path) + ?: throw RuntimeException("Report template not found: $path") + stream.use { JasperCompileManager.compileReport(it) } + } + /** * Queries the database for inventory data based on dates and optional item type. */ @@ -1731,9 +1740,7 @@ return result * Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template. */ fun createExcelResponse(templatePath: String, params: Map, dataList: List>): ByteArray { - val stream = this::class.java.getResourceAsStream(templatePath) - ?: throw RuntimeException("Report template not found: $templatePath") - val jasperReport = JasperCompileManager.compileReport(stream) + val jasperReport = compiledReport(templatePath) val dataSource = JRMapCollectionDataSource(dataList) val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) val out = ByteArrayOutputStream() @@ -1748,13 +1755,8 @@ return result * Compiles and fills a Jasper Report, returning the PDF as a ByteArray. */ fun createPdfResponse(templatePath: String, params: Map, dataList: List>): ByteArray { - val stream = this::class.java.getResourceAsStream(templatePath) - ?: throw RuntimeException("Report template not found: $templatePath") - - val jasperReport = JasperCompileManager.compileReport(stream) - + val jasperReport = compiledReport(templatePath) val dataSource = JRMapCollectionDataSource(dataList) - val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) return JasperExportManager.exportReportToPdf(jasperPrint) } diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt new file mode 100644 index 00000000..381e5711 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt @@ -0,0 +1,257 @@ +package com.ffii.fpsms.modules.report.service + +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.pdmodel.PDPage +import org.apache.pdfbox.pdmodel.PDPageContentStream +import org.apache.pdfbox.pdmodel.common.PDRectangle +import org.apache.pdfbox.pdmodel.font.PDFont +import org.apache.pdfbox.pdmodel.font.PDType0Font +import org.apache.pdfbox.util.Matrix +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +/** + * Draws 庫存明細報告 directly. Jasper measured each cell and kept whole item groups + * together, which is what made large PDFs take tens of seconds. + */ +object StockLedgerPdf { + private const val PAGE_W = 842f + private const val PAGE_H = 595f + private const val ROW_H = 13f + private const val BOTTOM = 22f + private const val BODY_SIZE = 8f + private const val HEADER_SIZE = 8f + + private val fontBytes: ByteArray by lazy { + StockLedgerPdf::class.java.getResourceAsStream("/fonts/msjh_0.ttf")?.readBytes() + ?: error("Font not found: /fonts/msjh_0.ttf") + } + + private data class Col(val x: Float, val w: Float, val title: String, val alignRight: Boolean = false) + + private val columns = listOf( + Col(11f, 78f, "出入賬日期"), + Col(91f, 58f, "類型"), + Col(151f, 120f, "批號"), + Col(273f, 72f, "到期日"), + Col(347f, 58f, "纍計期初", alignRight = true), + Col(407f, 48f, "入庫", alignRight = true), + Col(457f, 48f, "出庫", alignRight = true), + Col(507f, 62f, "纍計存量", alignRight = true), + Col(571f, 140f, "參考編號"), + Col(713f, 112f, "存貨位置"), + ) + + fun write( + rows: List>, + reportDate: String, + reportTime: String, + periodStart: String, + periodEnd: String, + ): ByteArray { + PDDocument().use { doc -> + val font = PDType0Font.load(doc, ByteArrayInputStream(fontBytes), true) + val canvas = Canvas(doc, font, reportDate, reportTime, periodStart, periodEnd) + if (rows.isEmpty()) { + canvas.ensureRow() + canvas.text(columns[0].x, canvas.y, "-", BODY_SIZE) + } else { + var itemNo: String? = null + var count = 0 + var totalIn = "" + var totalOut = "" + var totalBal = "" + fun closeGroup() { + if (count > 1) { + canvas.ensureRow() + canvas.text(250f, canvas.y, "貨品總量:", BODY_SIZE, 95f, alignRight = true) + canvas.text(columns[5].x, canvas.y, totalIn, BODY_SIZE, columns[5].w, alignRight = true) + canvas.text(columns[6].x, canvas.y, totalOut, BODY_SIZE, columns[6].w, alignRight = true) + canvas.text(columns[7].x, canvas.y, totalBal, BODY_SIZE, columns[7].w, alignRight = true) + canvas.advance() + } else if (count == 1) { + canvas.advance(4f) + } + } + for (row in rows) { + val nextItem = row["itemNo"]?.toString().orEmpty() + if (nextItem != itemNo) { + closeGroup() + itemNo = nextItem + count = 0 + val name = row["itemName"]?.toString().orEmpty() + val uom = row["unitOfMeasure"]?.toString().orEmpty() + val itemTitle = listOf(nextItem, name, uom).filter { it.isNotBlank() }.joinToString(" ") + canvas.ensureRow() + canvas.text(columns[0].x, canvas.y, itemTitle, BODY_SIZE + 1f) + canvas.advance() + } + canvas.ensureRow() + canvas.cell(0, row["trnDate"]) + canvas.cell(1, row["trnRefNo"]) + canvas.cell(2, row["lotNo"]) + canvas.cell(3, row["expiryDate"]) + canvas.cell(4, row["cumOpeningBal"]) + canvas.cell(5, row["stockIn"]) + canvas.cell(6, row["stockOut"]) + canvas.cell(7, row["cumBalance"]) + canvas.cell(8, row["orderRefNo"]) + canvas.cell(9, row["storeLocation"]) + canvas.advance() + count++ + totalIn = row["totalStockIn"]?.toString().orEmpty() + totalOut = row["totalStockOut"]?.toString().orEmpty() + totalBal = row["totalCumBalance"]?.toString().orEmpty() + } + closeGroup() + } + canvas.closePage() + canvas.stampPageNumbers() + val out = ByteArrayOutputStream() + doc.save(out) + return out.toByteArray() + } + } + + private class Canvas( + private val doc: PDDocument, + private val font: PDFont, + private val reportDate: String, + private val reportTime: String, + private val periodStart: String, + private val periodEnd: String, + ) { + private val pages = ArrayList() + private var stream: PDPageContentStream? = null + private var textOpen = false + var y = 0f + private set + + init { + newPage() + } + + fun ensureRow() { + if (y < BOTTOM + ROW_H) newPage() + } + + fun advance(dy: Float = ROW_H) { + y -= dy + } + + fun cell(index: Int, value: Any?) { + val col = columns[index] + text(col.x, y, value?.toString().orEmpty(), BODY_SIZE, col.w, col.alignRight) + } + + fun text(x: Float, baseline: Float, raw: String, size: Float, width: Float = 700f, alignRight: Boolean = false) { + val value = fit(raw, size, width - 2f) + if (value.isEmpty()) return + openText() + val drawX = if (alignRight) { + x + width - 2f - widthOf(value, size) + } else { + x + } + stream!!.setFont(font, size) + stream!!.setTextMatrix(Matrix(1f, 0f, 0f, 1f, drawX, baseline)) + stream!!.showText(value) + } + + fun closePage() { + endText() + stream?.close() + stream = null + } + + fun stampPageNumbers() { + val total = pages.size + pages.forEachIndexed { index, page -> + PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs -> + val label = "頁數 ${index + 1} / $total" + cs.beginText() + cs.setFont(font, 9f) + val labelX = PAGE_W - 18f - widthOf(label, 9f) + cs.setTextMatrix(Matrix(1f, 0f, 0f, 1f, labelX, PAGE_H - 28f)) + cs.showText(label) + cs.endText() + } + } + } + + private fun newPage() { + closePage() + val page = PDPage(PDRectangle(PAGE_W, PAGE_H)) + doc.addPage(page) + pages.add(page) + stream = PDPageContentStream(doc, page) + y = PAGE_H - 36f + openText() + text(300f, y, "庫存明細報告", 14f) + y -= 18f + text(18f, y, "報告日期:$reportDate 報告時間:$reportTime", 9f) + val period = if (periodStart.isNotBlank() || periodEnd.isNotBlank()) { + "報告期間:${periodStart.ifBlank { "—" }} 至 ${periodEnd.ifBlank { "—" }}" + } else { + "" + } + if (period.isNotEmpty()) { + text(420f, y, period, 9f) + } + y -= 16f + endText() + val lineY = y + 4f + stream!!.moveTo(11f, lineY) + stream!!.lineTo(PAGE_W - 16f, lineY) + stream!!.stroke() + y -= 12f + openText() + for (col in columns) { + text(col.x, y, col.title, HEADER_SIZE, col.w, col.alignRight) + } + y -= 4f + endText() + val lineY2 = y + stream!!.moveTo(11f, lineY2) + stream!!.lineTo(PAGE_W - 16f, lineY2) + stream!!.stroke() + y -= 14f + } + + private fun openText() { + if (!textOpen) { + stream!!.beginText() + textOpen = true + } + } + + private fun endText() { + if (textOpen) { + stream!!.endText() + textOpen = false + } + } + + private fun fit(raw: String, size: Float, maxWidth: Float): String { + val cleaned = raw.replace('\n', ' ').replace('\r', ' ') + if (cleaned.isEmpty() || fits(cleaned, size, maxWidth)) return cleaned + var end = cleaned.length + while (end > 0 && widthOf(cleaned.substring(0, end), size) > maxWidth) { + end-- + } + return if (end == cleaned.length) cleaned else cleaned.substring(0, end) + } + + private fun fits(text: String, size: Float, maxWidth: Float): Boolean { + var units = 0f + for (ch in text) { + units += if (ch.code < 128) 0.56f else 1f + if (units * size > maxWidth) return false + } + return true + } + + private fun widthOf(text: String, size: Float): Float = + font.getStringWidth(text) / 1000f * size + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index ea39625c..7e57c09c 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -2,8 +2,13 @@ package com.ffii.fpsms.modules.report.service import com.ffii.core.support.JdbcDao import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols import java.time.LocalDate import java.time.format.DateTimeFormatter +import java.util.Locale @Service open class StockLedgerReportService( @@ -23,6 +28,8 @@ open class StockLedgerReportService( * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) * * 只查 [start, end] 期間列,不掃起日以前全歷史。 + * 批號/位置直接跟這筆出入庫的批次,不再先彙整全庫批次。 + * 貨品總量在取出明細後計算,避免對寬結果做 window sort。 */ fun searchStockLedgerReport( stockCategory: String?, @@ -71,213 +78,244 @@ open class StockLedgerReportService( args ) - // 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數 val storeLocationSql = if (!storeLocation.isNullOrBlank()) { args["storeLocation"] = "%$storeLocation%" - "AND lot.storeLocation LIKE :storeLocation" + "AND wh.code LIKE :storeLocation" } else { "" } + // Wide reports are a date range over the whole ledger. Pin that index so MySQL + // does not start from another stock_ledger index and scan extra rows. + val ledgerIndex = if (itemCode.isNullOrBlank()) "FORCE INDEX (idx_sl_deleted_date)" else "" + val sql = """ SELECT - stockSubCategory, + sl.id AS slId, + sl.itemCode AS itemCode, + DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, + CASE + WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' + WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' + AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' + ELSE COALESCE(sl.type, '') + END AS trnRefNoRaw, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty, + COALESCE(sl.balance, 0) AS bal, + COALESCE(sl.balance, 0) - COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, + it.type AS stockSubCategory, + it.code AS itemNo, + it.name AS itemName, + uom.unitOfMeasure AS unitOfMeasure, + COALESCE(il_in.lotNo, il_out.lotNo) AS lotNo, + COALESCE( + DATE_FORMAT(il_in.expiryDate, '%Y-%m-%d'), + DATE_FORMAT(il_out.expiryDate, '%Y-%m-%d'), + '' + ) AS expiryDate, + wh.code AS storeLocation, + COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, + COALESCE(TRIM(jo.code), '') AS jobOrderNo +FROM stock_ledger sl $ledgerIndex + 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_in_loc + ON ill_in_loc.inventoryLotId = il_in.id + AND ill_in_loc.deleted = 0 + LEFT JOIN warehouse wh + ON wh.id = COALESCE(ill_in_loc.warehouseId, ill_out.warehouseId) + AND wh.deleted = 0 + LEFT JOIN items it + ON sl.itemId = it.id + AND it.deleted = 0 + LEFT JOIN ( + SELECT iu.itemId, MIN(uc.udfudesc) AS unitOfMeasure + FROM item_uom iu + JOIN uom_conversion uc ON uc.id = iu.uomId + WHERE iu.stockUnit = 1 + AND iu.deleted = 0 + GROUP BY iu.itemId + ) uom ON uom.itemId = it.id + LEFT JOIN stock_out so + ON sol.stockOutId = so.id + AND so.deleted = 0 + LEFT JOIN pick_order_line pol + ON sol.pickOrderLineId = pol.id + AND pol.deleted = 0 + LEFT JOIN pick_order po_out + ON pol.poId = po_out.id + AND po_out.deleted = 0 + LEFT JOIN job_order jo_po + ON po_out.joId = jo_po.id + AND jo_po.deleted = 0 + LEFT JOIN delivery_order do + ON po_out.doId = do.id + AND do.deleted = 0 + LEFT JOIN stock_in si + ON sil.stockInId = si.id + AND si.deleted = 0 + LEFT JOIN job_order jo + ON sil.jobOrderId = jo.id + AND jo.deleted = 0 + LEFT JOIN purchase_order po + ON sil.purchaseOrderId = po.id + AND po.deleted = 0 +WHERE + sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + AND sl.date >= :reportPeriodStart + AND sl.date < :reportPeriodEndExclusive + $stockCategorySql + $itemCodeSql + $storeLocationSql + AND COALESCE(il_in.id, il_out.id) IS NOT NULL +ORDER BY itemNo, - itemName, - unitOfMeasure, - lotNo, - expiryDate, trnDate, - CASE trnRefNoRaw - WHEN 'OPEN' THEN '開倉' - WHEN 'NOR' THEN '出入倉' - WHEN 'TRF' THEN '轉倉' - WHEN 'ADJ' THEN '調整' - WHEN 'MISS' THEN '遺失' - WHEN 'BAD' THEN '不良品' - WHEN 'TKE' THEN '盤點' - ELSE trnRefNoRaw - END AS trnRefNo, - storeLocation, - orderRefNo, - jobOrderNo, - - openingBalance, - cumStockIn, - cumStockOut, - currentBalance, - lastInDate, - lastOutDate, - reOrderLevel, - reOrderQty, - - CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn, - CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut, - - CASE WHEN cumOpeningBalRaw < 0 - THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')') - ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal, - CASE WHEN bal < 0 - THEN CONCAT('(', FORMAT(-bal, 0), ')') - ELSE FORMAT(bal, 0) END AS cumBalance, - - CASE WHEN totalCumOpeningBalRaw < 0 - THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')') - ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal, - CASE WHEN totalStockInRaw < 0 - THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')') - ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn, - CASE WHEN totalStockOutRaw < 0 - THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')') - ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut, - CASE WHEN totalCumBalanceRaw < 0 - THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')') - ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance - -FROM ( - SELECT - x.*, - FIRST_VALUE(cumOpeningBalRaw) OVER ( - PARTITION BY itemCode ORDER BY trnDateRaw, slId - ) AS totalCumOpeningBalRaw, - SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw, - SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw, - FIRST_VALUE(bal) OVER ( - PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC - ) AS totalCumBalanceRaw - FROM ( - SELECT - sl.id AS slId, - DATE(sl.date) AS trnDateRaw, - DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, - CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' - AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' - ELSE COALESCE(sl.type, '') - END AS trnRefNoRaw, - - sl.itemCode AS itemCode, - COALESCE(sl.inQty, 0) AS inQty, - COALESCE(sl.outQty, 0) AS outQty, - COALESCE(sl.balance, 0) AS bal, - COALESCE(sl.balance, 0) - - COALESCE(sl.inQty, 0) - + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, - - it.type AS stockSubCategory, - it.code AS itemNo, - it.name AS itemName, - uc.udfudesc AS unitOfMeasure, + slId, + lotNo +""".trimIndent() - lot.lotNo AS lotNo, - COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, - lot.storeLocation AS storeLocation, + return attachItemTotals(jdbcDao.queryForList(sql, args)) + } - COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, - COALESCE(TRIM(jo.code), '') AS jobOrderNo, + /** + * Item totals match the old window: first opening, sum in/out, last balance per itemCode. + * Rows stay ordered by item, date, id. + */ + private fun attachItemTotals(rows: List>): List> { + if (rows.isEmpty()) return rows + val totals = HashMap(rows.size.coerceAtMost(4096)) + for (row in rows) { + val key = row["itemCode"]?.toString().orEmpty() + val bucket = totals.getOrPut(key) { LedgerItemTotals() } + val inQty = toQty(row["inQty"]) + val outQty = toQty(row["outQty"]) + val opening = toQty(row["cumOpeningBalRaw"]) + val closing = toQty(row["bal"]) + val date = row["trnDate"]?.toString().orEmpty() + val id = toLongId(row["slId"]) + bucket.inSum = bucket.inSum.add(inQty) + bucket.outSum = bucket.outSum.add(outQty) + if (!bucket.seen || earlier(date, id, bucket.firstDate, bucket.firstId)) { + bucket.opening = opening + bucket.firstDate = date + bucket.firstId = id + } + if (!bucket.seen || later(date, id, bucket.lastDate, bucket.lastId)) { + bucket.closing = closing + bucket.lastDate = date + bucket.lastId = id + } + bucket.seen = true + } - '' AS openingBalance, - '' AS cumStockIn, - '' AS cumStockOut, - '' AS currentBalance, - '' AS lastInDate, - '' AS lastOutDate, - '' AS reOrderLevel, - '' AS reOrderQty - FROM stock_ledger sl - LEFT JOIN stock_in_line sil - ON sl.stockInLineId = sil.id - AND sil.deleted = 0 - LEFT JOIN inventory_lot il_in - ON sil.inventoryLotId = il_in.id - AND il_in.deleted = 0 + val fmt = DecimalFormat("#,##0", DecimalFormatSymbols(Locale.US)) + val out = ArrayList>(rows.size) + for (row in rows) { + val bucket = totals.getValue(row["itemCode"]?.toString().orEmpty()) + val trnRefNoRaw = row["trnRefNoRaw"]?.toString().orEmpty() + out.add( + linkedMapOf( + "stockSubCategory" to text(row["stockSubCategory"]), + "itemNo" to text(row["itemNo"]), + "itemName" to text(row["itemName"]), + "unitOfMeasure" to text(row["unitOfMeasure"]), + "lotNo" to text(row["lotNo"]), + "expiryDate" to text(row["expiryDate"]), + "trnDate" to text(row["trnDate"]), + "trnRefNo" to ledgerTypeLabel(trnRefNoRaw), + "storeLocation" to text(row["storeLocation"]), + "orderRefNo" to text(row["orderRefNo"]), + "jobOrderNo" to text(row["jobOrderNo"]), + "openingBalance" to "", + "cumStockIn" to "", + "cumStockOut" to "", + "currentBalance" to "", + "lastInDate" to "", + "lastOutDate" to "", + "reOrderLevel" to "", + "reOrderQty" to "", + "stockIn" to formatLedgerQty(toQty(row["inQty"]), fmt), + "stockOut" to formatLedgerQty(toQty(row["outQty"]), fmt), + "cumOpeningBal" to formatLedgerQty(toQty(row["cumOpeningBalRaw"]), fmt), + "cumBalance" to formatLedgerQty(toQty(row["bal"]), fmt), + "totalCumOpeningBal" to formatLedgerQty(bucket.opening, fmt), + "totalStockIn" to formatLedgerQty(bucket.inSum, fmt), + "totalStockOut" to formatLedgerQty(bucket.outSum, fmt), + "totalCumBalance" to formatLedgerQty(bucket.closing, fmt), + ) + ) + } + return out + } - LEFT JOIN stock_out_line sol - ON sl.stockOutLineId = sol.id - AND sol.deleted = 0 - LEFT JOIN inventory_lot_line ill_out - ON sol.inventoryLotLineId = ill_out.id - AND ill_out.deleted = 0 - LEFT JOIN inventory_lot il_out - ON ill_out.inventoryLotId = il_out.id - AND il_out.deleted = 0 + private class LedgerItemTotals { + var inSum: BigDecimal = BigDecimal.ZERO + var outSum: BigDecimal = BigDecimal.ZERO + var opening: BigDecimal = BigDecimal.ZERO + var closing: BigDecimal = BigDecimal.ZERO + var firstDate: String = "" + var firstId: Long = 0 + var lastDate: String = "" + var lastId: Long = 0 + var seen: Boolean = false + } - LEFT JOIN ( - SELECT - il.id AS lotId, - il.lotNo AS lotNo, - il.expiryDate AS expiryDate, - MAX(wh.code) AS storeLocation - FROM inventory_lot il - LEFT JOIN inventory_lot_line ill - ON ill.inventoryLotId = il.id - AND ill.deleted = 0 - LEFT JOIN warehouse wh - ON ill.warehouseId = wh.id - AND wh.deleted = 0 - WHERE il.deleted = 0 - GROUP BY - il.id, il.lotNo, il.expiryDate - ) lot - ON lot.lotId = COALESCE(il_in.id, il_out.id) + private fun earlier(date: String, id: Long, otherDate: String, otherId: Long): Boolean = + date < otherDate || (date == otherDate && id < otherId) + + private fun later(date: String, id: Long, otherDate: String, otherId: Long): Boolean = + date > otherDate || (date == otherDate && id > otherId) + + private fun ledgerTypeLabel(raw: String): String = when (raw) { + "OPEN" -> "開倉" + "NOR" -> "出入倉" + "TRF" -> "轉倉" + "ADJ" -> "調整" + "MISS" -> "遺失" + "BAD" -> "不良品" + "TKE" -> "盤點" + else -> raw + } - LEFT JOIN items it - ON sl.itemId = it.id - AND it.deleted = 0 - LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 - AND iu.deleted = 0 - LEFT JOIN uom_conversion uc - ON iu.uomId = uc.id + private fun formatLedgerQty(value: BigDecimal, fmt: DecimalFormat): String { + val rounded = value.setScale(0, RoundingMode.HALF_UP) + val text = fmt.format(rounded.abs()) + return if (rounded.signum() < 0) "($text)" else text + } - LEFT JOIN stock_out so - ON sol.stockOutId = so.id - AND so.deleted = 0 - LEFT JOIN pick_order_line pol - ON sol.pickOrderLineId = pol.id - AND pol.deleted = 0 - LEFT JOIN pick_order po_out - ON pol.poId = po_out.id - AND po_out.deleted = 0 - LEFT JOIN job_order jo_po - ON po_out.joId = jo_po.id - AND jo_po.deleted = 0 - LEFT JOIN delivery_order do - ON po_out.doId = do.id - AND do.deleted = 0 - LEFT JOIN stock_in si - ON sil.stockInId = si.id - AND si.deleted = 0 - LEFT JOIN job_order jo - ON sil.jobOrderId = jo.id - AND jo.deleted = 0 - LEFT JOIN purchase_order po - ON sil.purchaseOrderId = po.id - AND po.deleted = 0 - WHERE - sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - AND sl.date >= :reportPeriodStart - AND sl.date < :reportPeriodEndExclusive - $stockCategorySql - $itemCodeSql - $storeLocationSql - AND lot.lotId IS NOT NULL - ) x -) y -ORDER BY - itemNo, - trnDateRaw, - slId, - lotNo -""".trimIndent() + private fun toQty(value: Any?): BigDecimal = when (value) { + null -> BigDecimal.ZERO + is BigDecimal -> value + is Long -> BigDecimal.valueOf(value) + is Int -> BigDecimal.valueOf(value.toLong()) + is Number -> BigDecimal(value.toString()) + else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO + } - return jdbcDao.queryForList(sql, args) + private fun toLongId(value: Any?): Long = when (value) { + is Number -> value.toLong() + else -> value?.toString()?.toLongOrNull() ?: 0L } + private fun text(value: Any?): String = value?.toString().orEmpty() + /** Full codes use equality; short tokens use prefix LIKE. */ private fun buildItemCodeFilterClause( paramValue: String?, diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt index 7fa4fb43..48a88789 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt @@ -5,8 +5,8 @@ import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens +import com.ffii.fpsms.modules.report.service.StockLedgerPdf import com.ffii.fpsms.modules.report.service.StockLedgerReportService -import com.ffii.fpsms.modules.report.service.ReportService import org.apache.poi.ss.usermodel.BorderStyle import org.apache.poi.ss.usermodel.CellStyle import org.apache.poi.ss.usermodel.DataFormat @@ -24,7 +24,6 @@ import java.io.ByteArrayOutputStream @RestController @RequestMapping("/report") class StockLedgerReportController( - private val reportService: ReportService, private val stockLedgerReportService: StockLedgerReportService, ) { private data class ExcelStyles( @@ -98,27 +97,6 @@ class StockLedgerReportController( reportPeriodStart: String?, reportPeriodEnd: String?, ): ResponseEntity { - val tokenCount = ReportMultiValueTokens.split(itemCode).size - val parameters = mutableMapOf() - - parameters["stockCategory"] = stockCategory ?: "All" - parameters["stockSubCategory"] = stockCategory ?: "All" - parameters["itemNo"] = - when { - itemCode.isNullOrBlank() -> "All" - tokenCount > 30 -> "$tokenCount items" - else -> itemCode - } - parameters["year"] = LocalDate.now().year.toString() - parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) - parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) - - parameters["storeLocation"] = storeLocation ?: "" - parameters["balanceFilterStart"] = "" - parameters["balanceFilterEnd"] = "" - parameters["reportPeriodStart"] = reportPeriodStart ?: "" - parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" - val dbData = stockLedgerReportService.searchStockLedgerReport( stockCategory = stockCategory, itemCode = itemCode, @@ -127,10 +105,12 @@ class StockLedgerReportController( reportPeriodEnd = reportPeriodEnd, ) - val pdfBytes = reportService.createPdfResponse( - "/jasper/StockLedgarReport.jrxml", - parameters, - dbData, + val pdfBytes = StockLedgerPdf.write( + rows = dbData, + reportDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + reportTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), + periodStart = reportPeriodStart?.trim().orEmpty(), + periodEnd = reportPeriodEnd?.trim().orEmpty(), ) val headers = HttpHeaders().apply { diff --git a/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java b/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java index acf0d792..9bced40b 100644 --- a/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java +++ b/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java @@ -3,6 +3,9 @@ package com.ffii.fpsms.modules.settings.web; import java.util.List; import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -14,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController; import com.ffii.core.exception.BadRequestException; import com.ffii.core.exception.NotFoundException; +import com.ffii.fpsms.modules.common.SettingNames; import com.ffii.fpsms.modules.settings.entity.Settings; import com.ffii.fpsms.modules.settings.service.SettingsService; @@ -53,6 +57,9 @@ public class SettingsController{ } private void applyUpdate(String name, UpdateReq body) { + if (SettingNames.STOCK_TAKE_QTY_GAP_WARN_PERCENT.equals(name) && !isAdmin()) { + throw new AccessDeniedException("ADMIN only"); + } Settings entity = this.settingsService.findByName(name) .orElseThrow(NotFoundException::new); if (!this.settingsService.validateType(entity.getType(), body.getValue())) { @@ -63,6 +70,12 @@ public class SettingsController{ this.settingsService.save(entity); } + private static boolean isAdmin() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) return false; + return auth.getAuthorities().stream().anyMatch(a -> "ADMIN".equals(a.getAuthority())); + } + public static class UpdateReq { @NotBlank private String value; diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 33327100..2fdbbb0c 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -805,6 +805,7 @@ open class StockTakeRecordService( holdQty = ill.holdQty, availableQty = availableQty, uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, warehouseCode = warehouse?.code, warehouseName = warehouse?.name, status = ill.status?.name, @@ -1065,6 +1066,7 @@ open class StockTakeRecordService( holdQty = ill.holdQty, availableQty = availableQty, uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, warehouseCode = warehouse?.code, warehouseName = warehouse?.name, status = ill.status?.name, @@ -1175,6 +1177,7 @@ open class StockTakeRecordService( holdQty = ill.holdQty, availableQty = availableQty, uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, warehouseCode = warehouse?.code, warehouseArea = warehouse?.area, warehouseName = warehouse?.name, @@ -1270,6 +1273,7 @@ open class StockTakeRecordService( holdQty = ill.holdQty, availableQty = availableQty, uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, warehouseCode = warehouse?.code, warehouseArea = warehouse?.area, warehouseName = warehouse?.name, @@ -2854,6 +2858,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( holdQty = ill.holdQty, availableQty = availableQty, uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, warehouseCode = warehouse?.code, warehouseName = warehouse?.name, status = ill.status?.name, @@ -2954,6 +2959,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( holdQty = ill.holdQty, availableQty = availableQty, uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, warehouseCode = warehouse?.code, warehouseName = warehouse?.name, status = ill.status?.name, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt index 9fa6f33c..d96bf61f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt @@ -78,6 +78,8 @@ data class InventoryLotDetailResponse( @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") val approverTime: LocalDateTime? = null, val lastSelect: Int? = null, + /** Stock UoM short label (e.g. 包), shown beside the count input. */ + val uomShortDesc: String? = null, ) data class InventoryLotLineListRequest( val warehouseCode: String diff --git a/src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql b/src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql new file mode 100644 index 00000000..d1793c45 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql @@ -0,0 +1,9 @@ +--liquibase formatted sql +--changeset fpsms:20260923_stock_take_qty_gap_warn_percent + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT 'STOCK_TAKE.qtyGapWarnPercent', '50', 'STOCK_TAKE', 'integer' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` WHERE `name` = 'STOCK_TAKE.qtyGapWarnPercent' +); diff --git a/src/main/resources/jasper/StockLedgarReport.jrxml b/src/main/resources/jasper/StockLedgarReport.jrxml index a245642f..a85ee22a 100644 --- a/src/main/resources/jasper/StockLedgarReport.jrxml +++ b/src/main/resources/jasper/StockLedgarReport.jrxml @@ -73,7 +73,7 @@ - + diff --git a/src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt new file mode 100644 index 00000000..03a714b2 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt @@ -0,0 +1,40 @@ +package com.ffii.fpsms.modules.report.service + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class StockLedgerPdfSpeedTest { + @Test + fun writesLargeLedgerWithoutJasperLayout() { + val rows = ArrayList>(40_000) + repeat(40_000) { i -> + val item = "PP%04d".format(i / 40) + rows.add( + linkedMapOf( + "itemNo" to item, + "itemName" to "測試貨品$item", + "unitOfMeasure" to "包", + "trnDate" to "2026-09-01", + "trnRefNo" to "出入倉", + "lotNo" to "LT-$i", + "expiryDate" to "2026-12-31", + "cumOpeningBal" to "1,200", + "stockIn" to "10", + "stockOut" to "0", + "cumBalance" to "1,210", + "orderRefNo" to "DO-20260901-${i % 1000}", + "storeLocation" to "W201", + "totalStockIn" to "400", + "totalStockOut" to "0", + "totalCumBalance" to "1,210", + ) + ) + } + val started = System.nanoTime() + val pdf = StockLedgerPdf.write(rows, "2026-09-23", "23:40:00", "2026-09-01", "2026-09-07") + val seconds = (System.nanoTime() - started) / 1_000_000_000.0 + assertTrue(pdf.size > 1000, "pdf bytes=${pdf.size}") + assertTrue(pdf[0] == '%'.code.toByte()) + assertTrue(seconds < 15.0, "pdf took ${"%.2f".format(seconds)}s for 40000 rows") + } +} From 5254069467f1d22ba7ff6b5fbab24a2bd1ef6550 Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Mon, 28 Sep 2026 15:16:55 +0800 Subject: [PATCH 10/16] [Fix] Price columns now hidden for some report downloads --- .../com/ffii/fpsms/modules/report/web/ReportController.kt | 4 ++++ .../modules/report/web/StockLotOnhandReportController.kt | 5 ++++- src/main/resources/jasper/StockBalanceReport.jrxml | 5 +++++ 3 files changed, 13 insertions(+), 1 deletion(-) 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 816098bd..185d807b 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 @@ -1063,6 +1063,7 @@ class ReportController( return out.toByteArray() } + /** FP-MTMS Version Checklist | Functions Ref. No. 87 | v1.0.0 | 2026-09-28 */ private fun createStockBalanceExcel( dbData: List>, reportDate: String, @@ -1139,6 +1140,9 @@ class ReportController( } val widths = intArrayOf(14, 26, 10, 12, 12, 12, 14, 12, 12, 12, 12, 14) widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + // Keep 單位均價 and 庫存總價值 in the sheet, but hide them for now. + sheet.setColumnHidden(10, true) + sheet.setColumnHidden(11, true) val out = ByteArrayOutputStream() workbook.use { it.write(out) } diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt index 041b6cab..6718b7fb 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt @@ -191,7 +191,7 @@ class StockLotOnhandReportController( return v } - /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.2 | 2026-09-28 */ private fun createExcel( dbData: List>, reportDate: String, @@ -310,6 +310,9 @@ class StockLotOnhandReportController( sheet.createFreezePane(0, headerRowIndex + 1) intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + // Keep 單位均價 and 庫存總價值 in the sheet, but hide them for now. + sheet.setColumnHidden(13, true) + sheet.setColumnHidden(14, true) val out = ByteArrayOutputStream() workbook.write(out) diff --git a/src/main/resources/jasper/StockBalanceReport.jrxml b/src/main/resources/jasper/StockBalanceReport.jrxml index 072560bc..2feb7101 100644 --- a/src/main/resources/jasper/StockBalanceReport.jrxml +++ b/src/main/resources/jasper/StockBalanceReport.jrxml @@ -1,5 +1,6 @@ + @@ -77,6 +78,7 @@ + @@ -163,6 +165,7 @@ + @@ -405,6 +408,7 @@ + @@ -416,6 +420,7 @@ + From 12eac49634b657bf0da8a09b72c7796c56ae8476 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Tue, 29 Sep 2026 22:24:18 +0800 Subject: [PATCH 11/16] added access right LANE for car line --- .../ffii/fpsms/config/security/SecurityConfig.java | 8 ++++++++ .../pickOrder/web/TruckLaneScheduleController.kt | 14 +++++++------- .../01_add_truck_lane_authority.sql | 8 ++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index b18b8477..16550992 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -79,6 +79,7 @@ public class SecurityConfig { * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 * (stockAdjustment/submit and GET /latestRemarks → INVENTORY_ADJUST) + * Route board truck APIs → ADMIN / TRUCK_LANE / TESTING */ @Bean @Order(1) @@ -140,6 +141,13 @@ public class SecurityConfig { .hasAnyAuthority("ADMIN", "M18_SYNC") .requestMatchers(HttpMethod.GET, "/m18/test/product-by-code") .hasAnyAuthority("ADMIN", "M18_SYNC") + /* 車線看板 (/settings/shop/board):ADMIN / TRUCK_LANE / TESTING */ + .requestMatchers("/truck/**") + .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") + .requestMatchers("/truckLaneVersion/**") + .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") + .requestMatchers("/truckLaneSchedule/**") + .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt index 23f9bc7a..a1c20a8d 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt @@ -22,13 +22,13 @@ import java.time.LocalDateTime open class TruckLaneScheduleController @Autowired constructor( private val truckLaneScheduleService: TruckLaneScheduleService, ) { - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping open fun create(@Valid @RequestBody request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { return truckLaneScheduleService.createManual(request) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/planFromRouteExcel") @Throws(ServletRequestBindingException::class) open fun planFromRouteExcel(request: HttpServletRequest): ResponseEntity { @@ -66,19 +66,19 @@ open class TruckLaneScheduleController @Autowired constructor( return truckLaneScheduleService.getById(id) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/{id}/cancel") open fun cancel(@PathVariable id: Long): TruckLaneScheduleResponse { return truckLaneScheduleService.cancel(id) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/{id}/applyNow") open fun applyNow(@PathVariable id: Long): TruckLaneScheduleResponse { return truckLaneScheduleService.applyNow(id) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/{id}/retry-failed") open fun retryFailed( @PathVariable id: Long, @@ -87,7 +87,7 @@ open class TruckLaneScheduleController @Autowired constructor( return truckLaneScheduleService.retryFailed(id, request?.executeAt) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/{id}/reactivate") open fun reactivateCancelled( @PathVariable id: Long, @@ -124,7 +124,7 @@ open class TruckLaneScheduleController @Autowired constructor( } } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/importExcel") @Throws(ServletRequestBindingException::class) open fun importExcel( diff --git a/src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql b/src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql new file mode 100644 index 00000000..b01af9e0 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + +--changeset fpsms:add_truck_lane_authority +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'TRUCK_LANE' +--comment: Route board (/settings/shop/board): ADMIN or TRUCK_LANE (車線) +INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`) +VALUES ('TRUCK_LANE', '車線', 'SHOP', 'Allow maintaining shop route board / truck lanes'); From 2c6648ba14abfb7b3e61ca9fd10d8aa914d8b957 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Thu, 1 Oct 2026 20:15:40 +0800 Subject: [PATCH 12/16] fixing the around 16:30 logout problem; Handle user put DO (m18 PO) sync in m18 sync page PO tab; make the fg/semi fg report count by date rather than by month, created a button to make qc accept faster, added item code/name in search criteria of PO, make 2 new version of PO Item label printout (can set 1 to print the old version in setting) --- .../java/com/ffii/core/utils/QrCodeUtil.kt | 17 +- .../m18/service/M18DeliveryOrderService.kt | 68 +++-- .../m18/service/M18PurchaseOrderService.kt | 54 ++-- .../fpsms/modules/common/SettingNames.java | 3 + .../service/PurchaseOrderService.kt | 15 +- .../web/PurchaseOrderController.kt | 2 + .../SemiFGProductionAnalysisReportService.kt | 117 ++++++++ ...emiFGProductionAnalysisReportController.kt | 249 ++++++++++++++++++ .../stock/service/InventoryLotLineService.kt | 15 +- .../stock/service/PoItemLabelTemplate.kt | 63 +++++ .../stock/service/StockInLineService.kt | 15 +- src/main/resources/application-prod.yml | 5 +- src/main/resources/application.yml | 3 +- .../01_setting.sql | 9 + .../02_allow_version_3.sql | 7 + .../resources/qrCodeLabel/poItemPDF_v2.jrxml | 172 ++++++++++++ .../resources/qrCodeLabel/poItemPDF_v3.jrxml | 172 ++++++++++++ 17 files changed, 932 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt create mode 100644 src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql create mode 100644 src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql create mode 100644 src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml create mode 100644 src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml diff --git a/src/main/java/com/ffii/core/utils/QrCodeUtil.kt b/src/main/java/com/ffii/core/utils/QrCodeUtil.kt index 11f05544..e7b052f5 100644 --- a/src/main/java/com/ffii/core/utils/QrCodeUtil.kt +++ b/src/main/java/com/ffii/core/utils/QrCodeUtil.kt @@ -1,18 +1,21 @@ package com.ffii.core.utils import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType import java.awt.image.BufferedImage -import com.google.zxing.client.j2se.MatrixToImageWriter; -import com.google.zxing.common.BitMatrix; -import com.google.zxing.qrcode.QRCodeWriter; +import com.google.zxing.client.j2se.MatrixToImageWriter +import com.google.zxing.qrcode.QRCodeWriter open class QrCodeUtil { companion object { fun generateQRCodeImage(barcodeText: String?): BufferedImage { - val barcodeWriter = QRCodeWriter() - val bitMatrix: BitMatrix = barcodeWriter.encode(barcodeText, BarcodeFormat.QR_CODE, 250, 250) - val img: BufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix) - return img + return generateQRCodeImage(barcodeText, 250, 4) + } + + fun generateQRCodeImage(barcodeText: String?, size: Int, margin: Int): BufferedImage { + val hints = hashMapOf(EncodeHintType.MARGIN to margin) + val bitMatrix = QRCodeWriter().encode(barcodeText, BarcodeFormat.QR_CODE, size, size, hints) + return MatrixToImageWriter.toBufferedImage(bitMatrix) } } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt index 56f6539e..6bd1d094 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt @@ -89,14 +89,8 @@ open class M18DeliveryOrderService( "" } - // Shop PO - val shopPoBuyers = commonUtils.listToString(listOf(m18Config.BEID_TOA), "beId=equal=", "=or=") - val shopPoSupplier = commonUtils.listToString( - shopService.findM18VendorIdsByCodeRegexp(m18Config.SHOP_PO_SUPPLIER), - "venId=equal=", - "=or=" - ) - var shopPoConds = "(${shopPoBuyers})=and=(${shopPoSupplier})" + // Shop PO — same BE/supplier scope as [buildShopPoScopeConds] + var shopPoConds = buildShopPoScopeConds() if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { shopPoConds += "=and=(${lastDateConds})" @@ -160,9 +154,24 @@ open class M18DeliveryOrderService( ) } + /** + * Shop PO (MTMS DO) BE/supplier scope used by scheduler and by-code sync: + * beId = TOA and venId in [M18Config.SHOP_PO_SUPPLIER] (e.g. P06/P07). + */ + private fun buildShopPoScopeConds(): String { + val shopPoBuyers = commonUtils.listToString(listOf(m18Config.BEID_TOA), "beId=equal=", "=or=") + val shopPoSupplier = commonUtils.listToString( + shopService.findM18VendorIdsByCodeRegexp(m18Config.SHOP_PO_SUPPLIER), + "venId=equal=", + "=or=" + ) + return "(${shopPoBuyers})=and=(${shopPoSupplier})" + } + /** * Sync a single M18 shop PO / delivery order by document [code], same search pattern as - * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. + * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode], including + * the scheduler shop-PO BE/supplier filters (rejects material POs). * * @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync). * No M18-side "加單" filtering is used. @@ -173,15 +182,16 @@ open class M18DeliveryOrderService( isExtraSync: Boolean = false, newOnly: Boolean = false, ): SyncResult { - if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { + val trimmed = code.trim() + if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(trimmed)) { return SyncResult( totalProcessed = 1, totalSuccess = 0, totalFail = 0, - query = "skipped (newOnly=true): delivery_order.code already exists: $code", + query = "skipped (newOnly=true): delivery_order.code already exists: $trimmed", ) } - val conds = "(code=equal=$code)" + val conds = "(code=equal=$trimmed)=and=${buildShopPoScopeConds()}" val searchRequest = M18PurchaseOrderListRequest( stSearch = "po", params = null, @@ -228,6 +238,7 @@ open class M18DeliveryOrderService( val successList = mutableListOf() val skippedList = mutableListOf() + val skippedNotPendingList = mutableListOf() val successDetailList = mutableListOf() val failList = mutableListOf() val failDetailList = mutableListOf() @@ -291,6 +302,23 @@ open class M18DeliveryOrderService( // logger.info("${doRefType}: Finding For Latest M18 Data Log...") val latestDeliveryOrderLog = m18DataLogService.findLatestM18DataLogWithSuccess(deliveryOrder.id, doRefType) + val existingDeliveryOrderForSync = + latestDeliveryOrderLog?.id?.let { deliveryOrderService.findByM18DataLogId(it) } + if (existingDeliveryOrderForSync != null && + existingDeliveryOrderForSync.deleted != true && + existingDeliveryOrderForSync.status != DeliveryOrderStatus.PENDING + ) { + val statusValue = existingDeliveryOrderForSync.status?.value ?: "unknown" + logger.info( + "${doRefType}: Skipping M18 sync — local DO id=${existingDeliveryOrderForSync.id} " + + "code=${existingDeliveryOrderForSync.code} status=$statusValue " + + "(only pending may be overwritten; receiving=已放單). M18 ID: ${deliveryOrder.id}" + ) + skippedNotPendingList.add( + "code=${existingDeliveryOrderForSync.code} status=$statusValue" + ) + return@forEach + } //logger.info(latestDeliveryOrderLog.toString()) // Save to m18_data_log table // logger.info("${doRefType}: Saving for M18 Data Log...") @@ -312,10 +340,9 @@ open class M18DeliveryOrderService( // logger.info("${doRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLog.id}") try { - // Find the delivery_order if exist + // Find the delivery_order if exist (re-use lookup from pending guard above) // logger.info("${doRefType}: Finding exising delivery order...") - val existingDeliveryOrder = - latestDeliveryOrderLog?.id?.let { deliveryOrderService.findByM18DataLogId(it) } + val existingDeliveryOrder = existingDeliveryOrderForSync // logger.info("${doRefType}: Exising delivery order ID: ${existingDeliveryOrder?.id}") // Save to delivery_order table @@ -621,12 +648,17 @@ open class M18DeliveryOrderService( logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") - val skippedSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" + val skippedExistingSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" + val skippedNotPendingSuffix = if (skippedNotPendingList.isNotEmpty()) { + " | skipped (not pending, only pending may be overwritten): ${skippedNotPendingList.joinToString("; ")}" + } else { + "" + } return SyncResult( - totalProcessed = successList.size + failList.size + skippedList.size, + totalProcessed = successList.size + failList.size + skippedList.size + skippedNotPendingList.size, totalSuccess = successList.size, totalFail = failList.size, - query = (deliveryOrdersWithType?.query ?: "") + skippedSuffix, + query = (deliveryOrdersWithType?.query ?: "") + skippedExistingSuffix + skippedNotPendingSuffix, ) } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt index dd488c96..8dffa5ab 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt @@ -97,15 +97,8 @@ open class M18PurchaseOrderService( "" } - // Material PO - val materialPoBuyers = - commonUtils.listToString(listOf(m18Config.BEID_PP, m18Config.BEID_PF), "beId=equal=", "=or=") - val materialPoSupplierNot = commonUtils.listToString( - shopService.findM18VendorIdsByCodeRegexp(m18Config.MATERIAL_PO_SUPPLIER_NOT), - "venId=unequal=", - "=or=" - ) - var materialPoConds = "(${materialPoBuyers})=and=(${materialPoSupplierNot}))" + // Material PO — same BE/supplier scope as [buildMaterialPoScopeConds] + var materialPoConds = buildMaterialPoScopeConds() if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { materialPoConds += "=and=(${lastDateConds})" @@ -233,11 +226,28 @@ open class M18PurchaseOrderService( return if (beId?.toString() == m18Config.BEID_TOA) PurchaseOrderType.SHOP else PurchaseOrderType.MATERIAL } + /** + * Material PO BE/supplier scope used by scheduler and by-code sync: + * beId in (PP, PF) and venId not in [M18Config.MATERIAL_PO_SUPPLIER_NOT] (e.g. P06/P07 shop suppliers). + */ + private fun buildMaterialPoScopeConds(): String { + val materialPoBuyers = + commonUtils.listToString(listOf(m18Config.BEID_PP, m18Config.BEID_PF), "beId=equal=", "=or=") + val materialPoSupplierNot = commonUtils.listToString( + shopService.findM18VendorIdsByCodeRegexp(m18Config.MATERIAL_PO_SUPPLIER_NOT), + "venId=unequal=", + "=or=" + ) + return "(${materialPoBuyers})=and=(${materialPoSupplierNot})" + } + open fun savePurchaseOrderByCode(code: String): SyncResult { - // Follow the scheduler's original approach: - // 1) use /search/search to find the PO id by code - // 2) then sync by PO id (this reuses the same create/update logic) - val conds = "(code=equal=$code)" + // Same approach as scheduler list sync, restricted to one document code: + // 1) /search/search with material-PO BE/supplier scope + code + // 2) sync by PO id (reuses create/update logic) + // Rejects shop POs (TOA / P06|P07) that belong to DO sync instead. + val trimmed = code.trim() + val conds = "(code=equal=$trimmed)=and=${buildMaterialPoScopeConds()}" return saveFromPoListSearch(conds, searchPurchaseOrdersByConds(conds)) } @@ -357,6 +367,7 @@ open class M18PurchaseOrderService( val successDetailList = mutableListOf() val failList = mutableListOf() val failDetailList = mutableListOf() + val skippedNotPendingList = mutableListOf() val affectedItemIds = mutableSetOf() val uomByM18IdCache = mutableMapOf() @@ -398,11 +409,15 @@ open class M18PurchaseOrderService( if (existingPurchaseOrderForSync != null && existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING ) { + val statusValue = existingPurchaseOrderForSync.status?.value ?: "unknown" logger.info( "${poRefType}: Skipping M18 sync — local PO id=${existingPurchaseOrderForSync.id} " + - "code=${existingPurchaseOrderForSync.code} status=${existingPurchaseOrderForSync.status?.value} " + + "code=${existingPurchaseOrderForSync.code} status=$statusValue " + "(only pending may be overwritten). M18 ID: $m18PurchaseOrderId" ) + skippedNotPendingList.add( + "code=${existingPurchaseOrderForSync.code} status=$statusValue" + ) return@forEach } @@ -729,11 +744,18 @@ open class M18PurchaseOrderService( logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------") + val baseQuery = purchaseOrdersWithType?.query ?: "" + val skippedSuffix = if (skippedNotPendingList.isNotEmpty()) { + " | skipped (not pending, only pending may be overwritten): ${skippedNotPendingList.joinToString("; ")}" + } else { + "" + } + return SyncResult( - totalProcessed = successList.size + failList.size, + totalProcessed = successList.size + failList.size + skippedNotPendingList.size, totalSuccess = successList.size, totalFail = failList.size, - query = purchaseOrdersWithType?.query ?: "" + query = baseQuery + skippedSuffix ) } } diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index f5e4bbae..7aad93fc 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -135,4 +135,7 @@ public abstract class SettingNames { /** JSON: last laser TCP send where printer returned receive (job order no., lot, itemId/stockInLineId, etc.) */ public static final String LASER_PRINT_LAST_RECEIVE_SUCCESS = "LASER_PRINT.lastReceiveSuccess"; + /** PO / stock-in item label: 1 = original, 2 = enlarged, 3 = smaller QR with a larger item name. */ + public static final String LABEL_PO_ITEM_VERSION = "LABEL.poItem.version"; + } 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 80a3e833..d90a7daa 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 @@ -211,8 +211,19 @@ open fun getPoSummariesByIds(ids: List): List { sql.append(" group by po.id"); sql.append(" order by po.orderDate desc") sql.append(" ) r") - if (args.containsKey("itemDetail")){ - sql.append(" where r.itemDetail like :itemDetail "); + val outerFilters = mutableListOf() + if (args.containsKey("itemDetail")) { + outerFilters.add("r.itemDetail like :itemDetail") + } + if (args.containsKey("itemCode")) { + outerFilters.add("r.itemCode like :itemCode") + } + if (args.containsKey("itemName")) { + outerFilters.add("r.itemName like :itemName") + } + if (outerFilters.isNotEmpty()) { + sql.append(" where ") + sql.append(outerFilters.joinToString(" AND ")) } return sql.toString() } diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt index 6e475304..24671530 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt @@ -47,6 +47,8 @@ class PurchaseOrderController( val criteriaArgs = CriteriaArgsBuilder.withRequest(request) .addStringLike("supplier") .addStringLike("code") + .addStringLike("itemCode") + .addStringLike("itemName") .addString("status") .addBoolean("escalated") .addDate("orderDate") diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt index bdcd3603..3d11430d 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.report.service import org.springframework.stereotype.Service import com.ffii.core.support.JdbcDao +import java.time.LocalDate @Service class SemiFGProductionAnalysisReportService( @@ -199,6 +200,122 @@ class SemiFGProductionAnalysisReportService( return jdbcDao.queryForList(sql, args) } + /** + * Same filters as [searchSemiFGProductionAnalysisReport], one row per item and production date. + * Quantity is not pivoted; the Excel builder places each date in the requested range as its own column. + */ + fun searchSemiFGProductionAnalysisDaily( + stockCategory: String?, + stockSubCategory: String?, + itemCode: String?, + year: String?, + rangeStart: LocalDate, + rangeEnd: LocalDate, + ): List> { + val args = mutableMapOf() + + val stockCategorySql = if (!itemCode.isNullOrBlank()) { + "" + } else if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { + buildMultiValueExactClause(stockCategory, "it.type", "semiSc", args) + } else { + "" + } + val stockSubCategorySql = buildMultiValueLikeClause(stockSubCategory, "ic.sub", "stockSubCategory", args) + val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "semiItem", args) + + val yearSql = if (!year.isNullOrBlank() && year != "All") { + args["year"] = year + "AND YEAR(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) = :year" + } else { + "" + } + + args["lastOutDateStart"] = rangeStart.toString() + args["lastOutDateEnd"] = rangeEnd.toString() + val lastOutDateStartSql = + "AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) >= DATE(:lastOutDateStart)" + val lastOutDateEndSql = + "AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) <= DATE(:lastOutDateEnd)" + + val sql = """ + WITH qr_agg AS ( + SELECT + qr.stockInLineId, + MAX(CASE WHEN qr.qcPassed = 0 THEN 1 ELSE 0 END) AS qcFailed + FROM qc_result qr + WHERE qr.deleted = 0 + GROUP BY qr.stockInLineId + ), + base AS ( + SELECT + COALESCE(it.code, '') AS itemNo, + COALESCE(it.name, '') AS itemName, + COALESCE(ic.sub, '') AS stockSubCategory, + COALESCE(uc.udfudesc, '') AS unitOfMeasure, + DATE( + CASE + WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate + ELSE si.productionDate + END + ) AS prodDate, + si.id AS stockInLineId, + CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0 + ELSE COALESCE(si.acceptedQty, 0) + END AS linePutAwayQty + FROM stock_in_line si + INNER JOIN items it ON si.itemId = it.id + INNER JOIN bom b ON b.code = it.code AND b.deleted = false + LEFT JOIN inventory_lot il ON il.id = si.inventoryLotId AND il.deleted = false + LEFT JOIN qr_agg ON qr_agg.stockInLineId = si.id + LEFT JOIN item_category ic ON it.categoryId = ic.id + LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true + LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + WHERE si.deleted = false + AND si.productLotNo IS NOT NULL + AND TRIM(si.productLotNo) <> '' + AND ( + CASE + WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate + ELSE si.productionDate + END + ) IS NOT NULL + AND si.jobOrderId IS NOT NULL + AND (si.status IS NULL OR si.status <> 'Pending') + $stockCategorySql + $stockSubCategorySql + $itemCodeSql + $yearSql + $lastOutDateStartSql + $lastOutDateEndSql + ), + dedup AS ( + SELECT + itemNo, + itemName, + stockSubCategory, + unitOfMeasure, + prodDate, + stockInLineId, + MAX(linePutAwayQty) AS linePutAwayQty + FROM base + GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, prodDate, stockInLineId + ) + SELECT + d.itemNo AS itemNo, + MAX(d.itemName) AS itemName, + MAX(d.unitOfMeasure) AS unitOfMeasure, + d.prodDate AS prodDate, + CAST(COALESCE(SUM(d.linePutAwayQty), 0) AS DECIMAL(18,2)) AS qty + FROM dedup d + GROUP BY d.itemNo, d.prodDate + HAVING COALESCE(SUM(d.linePutAwayQty), 0) > 0 + ORDER BY d.itemNo, d.prodDate + """.trimIndent() + + return jdbcDao.queryForList(sql, args) + } + /** * Gets list of item codes (bom.code) with names based on stockCategory filter. * Supports multiple categories separated by comma (e.g., "FG,WIP"). diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt index 4182292e..7089ebd8 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt @@ -7,6 +7,7 @@ import java.io.ByteArrayOutputStream import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit import com.ffii.fpsms.modules.report.service.SemiFGProductionAnalysisReportService import com.ffii.fpsms.modules.report.service.ReportService import org.apache.poi.ss.usermodel.BorderStyle @@ -173,6 +174,243 @@ class SemiFGProductionAnalysisReportController( return ResponseEntity(excelBytes, headers, HttpStatus.OK) } + @PostMapping("/print-semi-fg-production-analysis-excel-daily") + fun exportSemiFGProductionAnalysisReportExcelDaily( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + val start = parseReportDate(r.lastOutDateStart) + val end = parseReportDate(r.lastOutDateEnd) + if (start == null || end == null) { + return jsonError(HttpStatus.BAD_REQUEST, "請先輸入完成生產日期(由、至)") + } + if (end.isBefore(start)) { + return jsonError(HttpStatus.BAD_REQUEST, "完成生產日期「由」不可晚於「至」") + } + val dayCount = ChronoUnit.DAYS.between(start, end) + 1 + if (dayCount > MAX_DAILY_COLUMNS) { + return jsonError(HttpStatus.BAD_REQUEST, "日期範圍不可超過 $MAX_DAILY_COLUMNS 日") + } + + val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisDaily( + r.stockCategory, + r.stockSubCategory, + r.combinedItemCode(), + r.year, + start, + end, + ) + val dates = generateSequence(start) { previous -> + val next = previous.plusDays(1) + if (next.isAfter(end)) null else next + }.toList() + val excelBytes = createSemiFGProductionAnalysisDailyExcel( + dbData = dbData, + reportTitle = "成品/半成品生產分析報告", + dates = dates, + ) + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport_Daily.xlsx") + set("filename", "SemiFGProductionAnalysisReport_Daily.xlsx") + } + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun createSemiFGProductionAnalysisDailyExcel( + dbData: List>, + reportTitle: String, + dates: List, + ): ByteArray { + val workbook = XSSFWorkbook() + val safeSheetName = WorkbookUtil.createSafeSheetName(reportTitle) + val sheet = workbook.createSheet(safeSheetName) + val dateFormat = DateTimeFormatter.ISO_LOCAL_DATE + val columnDateFormat = DateTimeFormatter.ofPattern( + if (dates.first().year == dates.last().year) "d/M" else "d/M/yy", + ) + val fixedColumns = 3 + val totalColumns = fixedColumns + dates.size + 1 + var rowIndex = 0 + + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + } + val titleFont = workbook.createFont().apply { + bold = true + fontHeightInPoints = 16 + } + titleStyle.setFont(titleFont) + + val infoStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + } + + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + wrapText = true + } + val headerFont = workbook.createFont().apply { bold = true } + headerStyle.setFont(headerFont) + + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") + } + + val dashStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val titleRow = sheet.createRow(rowIndex++) + titleRow.heightInPoints = 28f + val titleCell = titleRow.createCell(0) + titleCell.setCellValue(reportTitle) + titleCell.cellStyle = titleStyle + if (totalColumns > 1) { + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + } + + val rangeLabel = "${dates.first().format(dateFormat)} 至 ${dates.last().format(dateFormat)}" + val infoRow = sheet.createRow(rowIndex++) + val infoCell = infoRow.createCell(0) + infoCell.setCellValue( + "報告日期:${LocalDate.now().format(dateFormat)} " + + "報告時間:${LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))} " + + "完成生產日期:$rangeLabel" + ) + infoCell.cellStyle = infoStyle + if (totalColumns > 1) { + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1)) + } + + rowIndex++ + + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headerRow.heightInPoints = 32f + val headers = buildList { + add("貨品編號") + add("貨品名稱") + add("單位") + dates.forEach { add(it.format(columnDateFormat)) } + add("上架總計") + } + headers.forEachIndexed { index, name -> + val cell = headerRow.createCell(index) + cell.setCellValue(name) + cell.cellStyle = headerStyle + } + + val items = pivotDailyRows(dbData) + if (items.isEmpty()) { + val dataRow = sheet.createRow(rowIndex++) + setTextCell(dataRow, 0, "-", textStyle) + setTextCell(dataRow, 1, "-", textStyle) + setTextCell(dataRow, 2, "-", textStyle) + for (col in fixedColumns until totalColumns) { + setDashCell(dataRow, col, dashStyle) + } + } else { + items.forEach { item -> + val dataRow = sheet.createRow(rowIndex++) + setTextCell(dataRow, 0, item.itemNo.ifBlank { "-" }, textStyle) + setTextCell(dataRow, 1, item.itemName.ifBlank { "-" }, textStyle) + setTextCell(dataRow, 2, item.unitOfMeasure.ifBlank { "-" }, textStyle) + var total = 0.0 + dates.forEachIndexed { index, date -> + val qty = item.qtyByDate[date] ?: 0.0 + total += qty + setNumberCell(dataRow, fixedColumns + index, qty, numberStyle, dashStyle) + } + setNumberCell(dataRow, totalColumns - 1, total, numberStyle, dashStyle) + } + } + + sheet.createFreezePane(fixedColumns, headerRowIndex + 1) + sheet.setColumnWidth(0, 18 * 256) + sheet.setColumnWidth(1, 24 * 256) + sheet.setColumnWidth(2, 12 * 256) + for (col in fixedColumns until totalColumns) { + sheet.setColumnWidth(col, 12 * 256) + } + + val output = ByteArrayOutputStream() + workbook.use { it.write(output) } + return output.toByteArray() + } + + private fun pivotDailyRows(dbData: List>): List { + val byItem = linkedMapOf() + dbData.forEach { row -> + val itemNo = row["itemNo"]?.toString()?.trim().orEmpty() + val prodDate = toLocalDate(row["prodDate"]) ?: return@forEach + val qty = parseNumericForExcelChart(row["qty"]) + val item = byItem.getOrPut(itemNo) { + DailyItemQty( + itemNo = itemNo, + itemName = row["itemName"]?.toString().orEmpty(), + unitOfMeasure = row["unitOfMeasure"]?.toString().orEmpty(), + qtyByDate = linkedMapOf(), + ) + } + item.qtyByDate[prodDate] = (item.qtyByDate[prodDate] ?: 0.0) + qty + } + return byItem.values.toList() + } + + private fun parseReportDate(raw: String?): LocalDate? { + if (raw.isNullOrBlank()) return null + val normalized = raw.trim().replace("/", "-") + return runCatching { LocalDate.parse(normalized) }.getOrNull() + ?: runCatching { LocalDate.parse(normalized, DateTimeFormatter.ofPattern("dd-MM-yyyy")) }.getOrNull() + } + + private fun toLocalDate(value: Any?): LocalDate? = when (value) { + is LocalDate -> value + is java.sql.Date -> value.toLocalDate() + is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate() + is java.time.LocalDateTime -> value.toLocalDate() + is java.util.Date -> value.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate() + is String -> parseReportDate(value.take(10)) + else -> null + } + + private fun jsonError(status: HttpStatus, message: String): ResponseEntity { + val escaped = message.replace("\\", "\\\\").replace("\"", "\\\"") + return ResponseEntity.status(status) + .contentType(MediaType.APPLICATION_JSON) + .body("""{"message":"$escaped"}""".toByteArray(Charsets.UTF_8)) + } + private fun createSemiFGProductionAnalysisExcel( dbData: List>, reportTitle: String, @@ -517,4 +755,15 @@ class SemiFGProductionAnalysisReportController( val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory) return ResponseEntity(itemCodesWithCategory, HttpStatus.OK) } + + private data class DailyItemQty( + val itemNo: String, + val itemName: String, + val unitOfMeasure: String, + val qtyByDate: MutableMap, + ) + + companion object { + private const val MAX_DAILY_COLUMNS = 366 + } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index 377e8b48..ec914f52 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -2,7 +2,6 @@ package com.ffii.fpsms.modules.stock.service import com.ffii.core.response.RecordsRes import com.ffii.core.utils.PdfUtils -import com.ffii.core.utils.QrCodeUtil import com.ffii.fpsms.modules.master.entity.ItemUomRespository import com.ffii.fpsms.modules.master.entity.WarehouseRepository import com.ffii.fpsms.modules.stock.entity.InventoryLotLine @@ -44,6 +43,7 @@ import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse import com.ffii.fpsms.modules.jobOrder.service.JobOrderService import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest import com.ffii.fpsms.modules.master.service.PrinterService +import com.ffii.fpsms.modules.settings.service.SettingsService import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineRequest import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest import com.ffii.core.utils.ZebraPrinterUtil @@ -60,6 +60,7 @@ open class InventoryLotLineService( private val stockInLineRepository: StockInLineRepository, private val inventoryRepository: InventoryRepository, private val printerService: PrinterService, + private val settingsService: SettingsService, @Lazy private val jobOrderService: JobOrderService ) { @@ -247,7 +248,8 @@ open class InventoryLotLineService( @Throws(IOException::class) @Transactional open fun exportStockInLineQrcode(request: LotLineToQrcode): Map { - val QRCODE_PDF = "qrCodeLabel/poItemPDF.jrxml" + val labelVersion = PoItemLabelTemplate.version(settingsService) + val QRCODE_PDF = PoItemLabelTemplate.path(labelVersion) val resource = ClassPathResource(QRCODE_PDF) if (!resource.exists()) { println("here") @@ -287,13 +289,18 @@ open class InventoryLotLineService( field["acceptedQty"] = "%.2f".format(info.acceptedQty) val stockItemUom = itemUomRespository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(info.itemId) - field["uom"] = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val fullUom = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val shortUom = stockItemUom?.uom?.udfShortDesc ?: info.uom?.udfShortDesc + field["uom"] = fullUom + val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) + field["qtyLabel"] = qtyLabel + field["qtyDetail"] = qtyDetail field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["lotNo"] = info.lotNo!! field["supplier"] = info.supplier ?: "N/A" - val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) + val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) field["qrCode"] = image fields.add(field) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt new file mode 100644 index 00000000..d0228bf0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt @@ -0,0 +1,63 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.core.utils.QrCodeUtil +import com.ffii.fpsms.modules.common.SettingNames +import com.ffii.fpsms.modules.settings.service.SettingsService +import java.awt.image.BufferedImage + +object PoItemLabelTemplate { + const val V1 = "qrCodeLabel/poItemPDF.jrxml" + const val V2 = "qrCodeLabel/poItemPDF_v2.jrxml" + const val V3 = "qrCodeLabel/poItemPDF_v3.jrxml" + + fun version(settingsService: SettingsService): Int { + val raw = settingsService.findByName(SettingNames.LABEL_PO_ITEM_VERSION) + .map { it.value } + .orElse("1") + ?.trim() + return when (raw) { + "2" -> 2 + "3" -> 3 + else -> 1 + } + } + + fun path(version: Int): String = when (version) { + 2 -> V2 + 3 -> V3 + else -> V1 + } + + fun qrImage(content: String, version: Int): BufferedImage = + if (version == 1) QrCodeUtil.generateQRCodeImage(content) + else QrCodeUtil.generateQRCodeImage(content, 500, 1) + + /** First line is qty + short unit. Second line is the full unit only when it differs. */ + fun qtyLines(qtyRaw: String?, fullRaw: String?, shortRaw: String?): Pair { + val qty = stripTrailingZeros(qtyRaw) + val full = blankUnit(fullRaw) + val short = blankUnit(shortRaw) + val same = short.isEmpty() || full.isEmpty() || short.equals(full, ignoreCase = true) + if (same) { + val unit = short.ifEmpty { full } + val line = when { + unit.isEmpty() -> qty + unit.first().isDigit() -> listOf(qty, unit).filter { it.isNotEmpty() }.joinToString(" ") + else -> qty + unit + } + return line to "" + } + return (qty + short) to full + } + + private fun blankUnit(raw: String?): String { + val text = raw?.trim().orEmpty() + return if (text.isEmpty() || text.equals("N/A", ignoreCase = true)) "" else text + } + + private fun stripTrailingZeros(raw: String?): String { + val text = raw?.trim().orEmpty() + if (!text.contains('.')) return text + return text.replace(Regex("0+$"), "").removeSuffix(".") + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index fa4c1abc..800dacfd 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -3,7 +3,6 @@ package com.ffii.fpsms.modules.stock.service import com.ffii.core.exception.BadRequestException import com.ffii.core.support.AbstractBaseEntityService import com.ffii.core.support.JdbcDao -import com.ffii.core.utils.QrCodeUtil import com.ffii.fpsms.modules.common.CodeGenerator import com.ffii.fpsms.modules.master.entity.ItemsRepository import com.ffii.fpsms.modules.master.entity.QcItemRepository @@ -33,6 +32,7 @@ import com.ffii.fpsms.modules.master.entity.WarehouseRepository import com.ffii.fpsms.modules.master.service.ItemUomService //import com.ffii.fpsms.modules.master.service.BomOutputQtyService import com.ffii.fpsms.modules.master.service.PrinterService +import com.ffii.fpsms.modules.settings.service.SettingsService import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderRepository @@ -98,6 +98,7 @@ open class StockInLineService( private val itemUomRepository: ItemUomRespository, private val itemUomService: ItemUomService, private val printerService: PrinterService, + private val settingsService: SettingsService, private val stockTakeLineRepository: StockTakeLineRepository, private val inventoryLotLineService: InventoryLotLineService, private val deliveryOrderRepository: DeliveryOrderRepository, @@ -1208,7 +1209,8 @@ open class StockInLineService( @Throws(IOException::class) @Transactional open fun exportStockInLineQrcode(request: ExportQrCodeRequest): Map { - val QRCODE_PDF = "qrCodeLabel/poItemPDF.jrxml" + val labelVersion = PoItemLabelTemplate.version(settingsService) + val QRCODE_PDF = PoItemLabelTemplate.path(labelVersion) val resource = ClassPathResource(QRCODE_PDF) if (!resource.exists()) { println("here") @@ -1242,12 +1244,17 @@ open class StockInLineService( field["poCode"] = info.poCode ?: "N/A" field["itemType"] = info.itemType ?: "N/A" field["acceptedQty"] = "%.2f".format(acceptedQty) - field["uom"] = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val fullUom = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val shortUom = stockItemUom?.uom?.udfShortDesc ?: info.uom?.udfShortDesc + field["uom"] = fullUom + val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) + field["qtyLabel"] = qtyLabel + field["qtyDetail"] = qtyDetail field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["lotNo"] = info.lotNo ?: "N/A" field["supplier"] = info.supplier ?: "N/A" - val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) + val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) field["qrCode"] = image fields.add(field) } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index dcd2d6e5..956ed653 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -3,9 +3,10 @@ fpsms: monitoring: enabled: true -# Session length in production; frontend can call /refresh-token before expiry to stay logged in. +# Session length in production; frontend refreshes via /refresh-token before expiry. +# Floor staff often work ~08:00–22:00 (~14h); 16h access covers one shift without mid-day kickout. jwt: - expiration-minutes: 480 # 8 hours access token (was 30 min); increase if users need longer AFK + expiration-minutes: 960 # 16 hours access token (was 480 / 8h — users logged out ~16:30 after morning login) refresh-expiration-days: 7 # Required for stable JWT across restarts; set JWT_SECRET in the deployment environment (long random string). secret: ${JWT_SECRET:fpsms-dev-jwt-signing-secret-change-for-production-use-long-random-JWT_SECRET} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 74ce2bc6..ed67e727 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -113,8 +113,9 @@ spring: dialect: storage_engine: innodb -# JWT: access token expiry and refresh token expiry. Frontend should call /refresh-token before access token expires. +# JWT: access token expiry and refresh token expiry. Frontend calls /refresh-token before access token expires. # Signing key must be STABLE across server restarts (do not use a random key per boot). Override with env JWT_SECRET in production. +# Prod overrides to 16h access (floor shifts ~08:00–22:00); see application-prod.yml. jwt: expiration-minutes: 14400 # access token: 10 days (default); override in application-prod for shorter session refresh-expiration-days: 30 # refresh token validity (days) diff --git a/src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql new file mode 100644 index 00000000..8af0d109 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql @@ -0,0 +1,9 @@ +--liquibase formatted sql +--changeset fpsms:20261001_po_item_label_version + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT 'LABEL.poItem.version', '1', 'LABEL', '1/2' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` WHERE `name` = 'LABEL.poItem.version' +); diff --git a/src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql new file mode 100644 index 00000000..c704aa81 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql +--changeset fpsms:20261001_po_item_label_version_allow_3 + +UPDATE `settings` +SET `type` = '1/2/3' +WHERE `name` = 'LABEL.poItem.version' + AND `type` = '1/2'; diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml new file mode 100644 index 00000000..d4a57f3d --- /dev/null +++ b/src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml new file mode 100644 index 00000000..47c4aec3 --- /dev/null +++ b/src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 41d5df10a5dba895389409998a22850b5c637fd2 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 2 Oct 2026 00:41:04 +0800 Subject: [PATCH 13/16] =?UTF-8?q?make=20the=20stock=20take=20qty=20can=20b?= =?UTF-8?q?e=20edit=20before=20=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/stock/entity/StockTakeRecord.kt | 4 ++ .../stock/service/StockTakeRecordService.kt | 52 ++++++++++++++++++- .../stock/web/model/StockTakeRecordReponse.kt | 6 ++- .../01_add_picker_revise_count.sql | 5 ++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt index 72a03b0b..4e4bc56f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt @@ -81,6 +81,10 @@ open class StockTakeRecord : BaseEntity() { @Column(name = "lastSelect") open var lastSelect: Int? = null + /** How many times the picker has changed an already saved first count. Max 2. */ + @Column(name = "pickerReviseCount", nullable = false) + open var pickerReviseCount: Int? = 0 + @Column(name = "approverTime") open var approverTime: LocalDateTime? = null @Column(name = "date", nullable = false) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 2fdbbb0c..d4d3c308 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -1200,6 +1200,7 @@ open class StockTakeRecordService( stockTakerName = rec.stockTakerName, stockTakeEndTime = rec.stockTakeEndTime, approverTime = rec.approverTime, + pickerReviseCount = rec.pickerReviseCount ?: 0, ) } @@ -1296,6 +1297,7 @@ open class StockTakeRecordService( stockTakerName = stockTakeRecord?.stockTakerName, stockTakeEndTime = stockTakeRecord?.stockTakeEndTime, approverTime = stockTakeRecord?.approverTime, + pickerReviseCount = stockTakeRecord?.pickerReviseCount ?: 0, ) } @@ -1342,12 +1344,59 @@ open class StockTakeRecordService( .subtract(inventoryLotLine.outQty ?: BigDecimal.ZERO) //.subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) + if (request.reviseFirstQty && request.stockTakeRecordId == null) { + throw IllegalArgumentException("Stock take record ID is required") + } + // 3. 新建、更新預建記錄之第一次盤點、或第二次盤點 val stockTakeRecord: StockTakeRecord = if (request.stockTakeRecordId != null) { val existingRecord = stockTakeRecordRepository.findByIdAndDeletedIsFalse(request.stockTakeRecordId) ?: throw IllegalArgumentException("Stock take record not found: ${request.stockTakeRecordId}") + val headerStatus = stockTake.status + val headerStillOpen = headerStatus == StockTakeStatus.PENDING || + headerStatus == StockTakeStatus.STOCKTAKING || + headerStatus == StockTakeStatus.APPROVING + val revisingOpenPass = existingRecord.pickerFirstStockTakeQty != null && + existingRecord.pickerSecondStockTakeQty == null && + existingRecord.approverStockTakeQty == null && + existingRecord.status.equals("pass", ignoreCase = true) && + headerStillOpen when { + // 已盤點的列在區域「已完成」前可改第一次數量。列狀態已完成、或區域已完成,不能改。 + request.reviseFirstQty || revisingOpenPass -> { + if (!headerStillOpen) { + throw IllegalArgumentException("Cannot change counted qty after the section is completed") + } + if (!existingRecord.status.equals("pass", ignoreCase = true)) { + throw IllegalArgumentException("Only counted records can be changed") + } + if (existingRecord.pickerFirstStockTakeQty == null) { + throw IllegalArgumentException("No first count to change") + } + if (existingRecord.pickerSecondStockTakeQty != null || existingRecord.approverStockTakeQty != null) { + throw IllegalArgumentException("Cannot change the first count after a later count exists") + } + val reviseCount = existingRecord.pickerReviseCount ?: 0 + if (reviseCount >= 2) { + throw IllegalArgumentException("This count can only be changed 2 times") + } + val varianceQty = availableQty - request.qty - request.badQty + existingRecord.apply { + this.pickerFirstStockTakeQty = request.qty + this.pickerFirstBadQty = request.badQty + this.bookQty = availableQty + this.varianceQty = varianceQty + this.stockTakerId = stockTakerId + this.stockTakerName = user?.name + this.status = "pass" + this.pickerSecondStockTakeQty = null + this.pickerSecondBadQty = null + this.stockTakeEndTime = null + this.pickerReviseCount = reviseCount + 1 + } + existingRecord + } existingRecord.pickerFirstStockTakeQty == null -> { val isCompled = inventoryLotLine.inQty == inventoryLotLine.outQty val varianceQty = availableQty - request.qty - request.badQty @@ -1367,7 +1416,8 @@ open class StockTakeRecordService( } existingRecord } - existingRecord.pickerSecondStockTakeQty == null -> { + existingRecord.pickerSecondStockTakeQty == null && + existingRecord.status.equals("notMatch", ignoreCase = true) -> { val varianceQty = availableQty - request.qty - request.badQty existingRecord.apply { this.stockTakeRoundId = this.stockTakeRoundId ?: (stockTake.stockTakeRoundId ?: stockTake.id) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt index d96bf61f..791c52ba 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt @@ -80,6 +80,8 @@ data class InventoryLotDetailResponse( val lastSelect: Int? = null, /** Stock UoM short label (e.g. 包), shown beside the count input. */ val uomShortDesc: String? = null, + /** Times the picker has changed this saved first count. */ + val pickerReviseCount: Int? = 0, ) data class InventoryLotLineListRequest( val warehouseCode: String @@ -90,7 +92,9 @@ data class SaveStockTakeRecordRequest( val qty: BigDecimal, // QTY(第一次或第二次,后端判断) val badQty: BigDecimal, // val stockTakerName: String, - val remark: String? = null + val remark: String? = null, + /** true = 區域仍在盤點中時,覆寫已盤點的第一次數量;不寫入第二次盤點 */ + val reviseFirstQty: Boolean = false, ) /** diff --git a/src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql b/src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql new file mode 100644 index 00000000..864f978c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql @@ -0,0 +1,5 @@ +-- liquibase formatted sql +-- changeset fpsms:20261001-picker-revise-count + +ALTER TABLE `fpsmsdb`.`stocktakerecord` +ADD COLUMN `pickerReviseCount` INT NOT NULL DEFAULT 0 AFTER `lastSelect`; From 8a7d0167fddacbccd77fdc28027beae84ceac9a2 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sat, 3 Oct 2026 00:52:15 +0800 Subject: [PATCH 14/16] fixing the schedule_sync_log.query too long error added version 4 of PO item label --- .../com/ffii/fpsms/m18/model/SyncResult.kt | 16 +- .../m18/service/M18DeliveryOrderService.kt | 6 +- .../m18/service/M18PurchaseOrderService.kt | 6 +- .../fpsms/modules/common/SettingNames.java | 2 +- .../scheduler/service/SchedulerService.kt | 13 +- .../service/PurchaseOrderService.kt | 2 +- .../stock/service/PoItemLabelTemplate.kt | 3 + .../stock/service/StockInLineService.kt | 14 +- .../03_allow_version_4.sql | 7 + .../01_widen_query_and_error_message.sql | 8 + .../resources/qrCodeLabel/poItemPDF_v4.jrxml | 172 ++++++++++++++++++ 11 files changed, 226 insertions(+), 23 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql create mode 100644 src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql create mode 100644 src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml diff --git a/src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt b/src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt index 4c4d3340..d0df7074 100644 --- a/src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt +++ b/src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt @@ -1,10 +1,20 @@ package com.ffii.fpsms.m18.model -import java.time.LocalDateTime - data class SyncResult( val totalProcessed: Int, val totalSuccess: Int, val totalFail: Int, val query: String? = null -) \ No newline at end of file +) + +/** + * Bounded suffix for [scheduler_sync_log.query]. Listing every skipped document + * overflows VARCHAR(2000) on a multi-day PO window. + */ +fun skippedNotPendingQuerySuffix(entries: List, previewLimit: Int = 20): String { + if (entries.isEmpty()) return "" + val shown = entries.take(previewLimit) + val hidden = entries.size - shown.size + val more = if (hidden > 0) "; ... +$hidden more" else "" + return " | skipped (not pending, only pending may be overwritten) count=${entries.size}: ${shown.joinToString("; ")}$more" +} \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt index 6bd1d094..34c7f81f 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt @@ -649,11 +649,7 @@ open class M18DeliveryOrderService( logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") val skippedExistingSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" - val skippedNotPendingSuffix = if (skippedNotPendingList.isNotEmpty()) { - " | skipped (not pending, only pending may be overwritten): ${skippedNotPendingList.joinToString("; ")}" - } else { - "" - } + val skippedNotPendingSuffix = skippedNotPendingQuerySuffix(skippedNotPendingList) return SyncResult( totalProcessed = successList.size + failList.size + skippedList.size + skippedNotPendingList.size, totalSuccess = successList.size, diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt index 8dffa5ab..e93bbcd4 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt @@ -745,11 +745,7 @@ open class M18PurchaseOrderService( logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------") val baseQuery = purchaseOrdersWithType?.query ?: "" - val skippedSuffix = if (skippedNotPendingList.isNotEmpty()) { - " | skipped (not pending, only pending may be overwritten): ${skippedNotPendingList.joinToString("; ")}" - } else { - "" - } + val skippedSuffix = skippedNotPendingQuerySuffix(skippedNotPendingList) return SyncResult( totalProcessed = successList.size + failList.size + skippedNotPendingList.size, diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index 7aad93fc..88b8dfee 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -135,7 +135,7 @@ public abstract class SettingNames { /** JSON: last laser TCP send where printer returned receive (job order no., lot, itemId/stockInLineId, etc.) */ public static final String LASER_PRINT_LAST_RECEIVE_SUCCESS = "LASER_PRINT.lastReceiveSuccess"; - /** PO / stock-in item label: 1 = original, 2 = enlarged, 3 = smaller QR with a larger item name. */ + /** PO / stock-in item label: 1 = original, 2 = enlarged, 3 = smaller QR, 4 = version 3 with the version 1 QR size. */ public static final String LABEL_PO_ITEM_VERSION = "LABEL.poItem.version"; } diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index de198a67..7322508c 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -87,6 +87,8 @@ open class SchedulerService( const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *" /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" + /** MySQL TEXT is 65535 bytes; 16000 chars stays under that for utf8mb4. */ + const val SYNC_LOG_TEXT_MAX_CHARS: Int = 16_000 } /** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */ @@ -730,14 +732,21 @@ open class SchedulerService( recordsProcessed = result?.totalProcessed ?: 0, recordsSaved = result?.totalSuccess ?: 0, recordsFailed = result?.totalFail ?: 0, - errorMessage = error, - query = result?.query ?: "", + errorMessage = fitSyncLogText(error), + query = fitSyncLogText(result?.query ?: ""), startTime = start, endTime = LocalDateTime.now() ) schedulerSyncLogRepository.save(log) } + private fun fitSyncLogText(value: String?): String? { + if (value == null || value.length <= SYNC_LOG_TEXT_MAX_CHARS) return value + val marker = "...(truncated, length=${value.length})" + logger.warn("scheduler_sync_log text truncated from {} to {} characters", value.length, SYNC_LOG_TEXT_MAX_CHARS) + return value.take((SYNC_LOG_TEXT_MAX_CHARS - marker.length).coerceAtLeast(0)) + marker + } + open fun getM18Dos2() { logger.info("DO Scheduler 2 - DO") val currentTime = LocalDateTime.now() 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 d90a7daa..039f0228 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 @@ -103,7 +103,7 @@ open fun getPoSummariesByIds(ids: List): List { orderDate = it["orderDate"] as LocalDateTime?, estimatedArrivalDate = it["estimatedArrivalDate"] as LocalDateTime?, supplierName = it["supplierName"] as String?, - escalated = (it["escalated"] as Boolean?) ?: false, + escalated = it["escalated"] == 1L, ) } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt index d0228bf0..f4dcf00e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt @@ -9,6 +9,7 @@ object PoItemLabelTemplate { const val V1 = "qrCodeLabel/poItemPDF.jrxml" const val V2 = "qrCodeLabel/poItemPDF_v2.jrxml" const val V3 = "qrCodeLabel/poItemPDF_v3.jrxml" + const val V4 = "qrCodeLabel/poItemPDF_v4.jrxml" fun version(settingsService: SettingsService): Int { val raw = settingsService.findByName(SettingNames.LABEL_PO_ITEM_VERSION) @@ -18,6 +19,7 @@ object PoItemLabelTemplate { return when (raw) { "2" -> 2 "3" -> 3 + "4" -> 4 else -> 1 } } @@ -25,6 +27,7 @@ object PoItemLabelTemplate { fun path(version: Int): String = when (version) { 2 -> V2 3 -> V3 + 4 -> V4 else -> V1 } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 800dacfd..446fa208 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -650,13 +650,15 @@ open class StockInLineService( open fun updatePurchaseOrderStatus(po : PurchaseOrder) : PurchaseOrder { val pols = polRepository.findAllByPurchaseOrderIdAndDeletedIsFalse(po.id!!) - var newStatus = PurchaseOrderStatus.PENDING - if (pols.isNotEmpty()) { - newStatus = if (pols.any { pol-> pol.status != PurchaseOrderLineStatus.COMPLETED }) { - PurchaseOrderStatus.RECEIVING - } else { + // A line that was never received, or whose stock-in rows were all removed before putaway, is pending. + // Receiving only while some line is still receiving or already completed. + val newStatus = when { + pols.isEmpty() || pols.all { it.status == null || it.status == PurchaseOrderLineStatus.PENDING } -> + PurchaseOrderStatus.PENDING + pols.all { it.status == PurchaseOrderLineStatus.COMPLETED } -> PurchaseOrderStatus.COMPLETED - } + else -> + PurchaseOrderStatus.RECEIVING } po.apply { status = newStatus diff --git a/src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql new file mode 100644 index 00000000..30cafc5c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql +--changeset fpsms:20261002_po_item_label_version_allow_4 + +UPDATE `settings` +SET `type` = '1/2/3/4' +WHERE `name` = 'LABEL.poItem.version' + AND `type` IN ('1/2', '1/2/3'); diff --git a/src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql b/src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql new file mode 100644 index 00000000..6b682cf6 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql +-- changeset fpsms:20261002-scheduler-sync-log-text + +-- PO sync stores the M18 conds plus skipped PO codes. VARCHAR(2000) / VARCHAR(1024) +-- reject that insert (MySQL 1406) and the scheduled task fails after the sync. +ALTER TABLE `fpsmsdb`.`scheduler_sync_log` + MODIFY COLUMN `query` TEXT NULL, + MODIFY COLUMN `errorMessage` TEXT NULL; diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml new file mode 100644 index 00000000..7125b95a --- /dev/null +++ b/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0b064e11fbd70c4d562e55fdc5fe75aa3f438b5b Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 4 Oct 2026 13:59:01 +0800 Subject: [PATCH 15/16] make the enter box num and print delivery note faster --- .../com/ffii/core/utils/BrotherPrinterUtil.kt | 79 ++++- .../com/ffii/core/utils/CanonPrinterUtil.kt | 8 +- .../service/DoWorkbenchMainService.kt | 300 +++++++++++++----- .../entity/PickOrderLineRepository.kt | 12 + .../service/StockLedgerReportService.kt | 228 +++++++++---- .../entity/InventoryLotLineRepository.kt | 13 + .../stock/service/StockInLineService.kt | 4 +- 7 files changed, 493 insertions(+), 151 deletions(-) diff --git a/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt b/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt index 70af932e..6683cbe6 100644 --- a/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt +++ b/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt @@ -3,8 +3,11 @@ package com.ffii.core.utils import org.apache.pdfbox.pdmodel.PDDocument import org.apache.pdfbox.rendering.PDFRenderer import java.awt.image.BufferedImage +import java.awt.image.DataBufferByte +import java.awt.image.MultiPixelPackedSampleModel import java.io.ByteArrayOutputStream import java.io.File +import java.net.InetSocketAddress import java.net.Socket import org.slf4j.LoggerFactory @@ -12,6 +15,7 @@ class BrotherPrinterUtil { companion object { private const val SOCKET_TIMEOUT_MS = 120_000 // 2 min for large jobs; Brother may be slow to accept + private const val CONNECT_TIMEOUT_MS = 5_000 private const val BROTHER_DPI = 200 // Lower DPI = smaller job; some Brother models buffer better at 200 private val log = LoggerFactory.getLogger(BrotherPrinterUtil::class.java) @@ -25,11 +29,14 @@ class BrotherPrinterUtil { log.info("Brother print: file=${pdfFile.name} size=${pdfFile.length()} ip=$printerIp port=$printerPort copies=$printQty") + PrinterLockManager.withLock(printerIp, printerPort) { PDDocument.load(pdfFile).use { document -> val renderer = PDFRenderer(document) val totalPages = document.numberOfPages - Socket(printerIp, printerPort).use { socket -> + Socket().use { socket -> + socket.tcpNoDelay = true + socket.connect(InetSocketAddress(printerIp, printerPort), CONNECT_TIMEOUT_MS) socket.soTimeout = SOCKET_TIMEOUT_MS val os = socket.getOutputStream() @@ -52,6 +59,7 @@ class BrotherPrinterUtil { log.info("Brother print job sent successfully") } } + } } private fun getPjlHeader(qty: Int): ByteArray { @@ -67,6 +75,8 @@ class BrotherPrinterUtil { * Uses given DPI (e.g. 200) to keep job size smaller for printers with limited buffer. */ private fun convertImageToPcl(image: BufferedImage, dpi: Int): ByteArray { + convertPackedBinaryToPcl(image, dpi)?.let { return it } + val out = ByteArrayOutputStream() val width = image.width val height = image.height @@ -75,21 +85,21 @@ class BrotherPrinterUtil { out.write("\u001BE".toByteArray(Charsets.US_ASCII)) // Esc E = reset // PCL: Set resolution and start raster out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) - out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) + out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) for (y in 0 until height) { val rowBytes = (width + 7) / 8 val data = ByteArray(rowBytes) - + for (x in 0 until width) { // Check if pixel is dark (threshold) val color = image.getRGB(x, y) - val isBlack = (color and 0xFF) < 128 + val isBlack = (color and 0xFF) < 128 if (isBlack) { data[x / 8] = (data[x / 8].toInt() or (0x80 ushr (x % 8))).toByte() } } - + // PCL: Transfer Raster Data (Esc * b # W [data]) out.write("\u001B*b${rowBytes}W".toByteArray()) out.write(data) @@ -99,5 +109,64 @@ class BrotherPrinterUtil { out.write("\u001B*rB".toByteArray()) return out.toByteArray() } + + /** + * TYPE_BYTE_BINARY from PDFBox: sample 0 is black, sample 1 is white, MSB first. + * PCL raster uses the opposite polarity (bit 1 = black). getRGB() per pixel is too slow + * for a multi-page A4 note; pack the row from the data buffer instead. + * Returns null when the raster layout does not match, so the caller keeps the slow path. + */ + private fun convertPackedBinaryToPcl(image: BufferedImage, dpi: Int): ByteArray? { + if (image.type != BufferedImage.TYPE_BYTE_BINARY) return null + val raster = image.raster + val sampleModel = raster.sampleModel as? MultiPixelPackedSampleModel ?: return null + if (sampleModel.pixelBitStride != 1 || sampleModel.dataBitOffset != 0) return null + val dataBuffer = raster.dataBuffer as? DataBufferByte ?: return null + if (dataBuffer.numBanks != 1) return null + + val bank = dataBuffer.data + val bankOffset = dataBuffer.offsets[0] + val width = image.width + val height = image.height + if (width <= 0 || height <= 0) return null + val stride = sampleModel.scanlineStride + val rowBytes = (width + 7) / 8 + if (stride < rowBytes) return null + if (bankOffset < 0 || bankOffset + (height - 1) * stride + rowBytes > bank.size) return null + + val check = minOf(8, width) + for (x in 0 until check) { + val srcBit = (bank[bankOffset].toInt() ushr (7 - x)) and 1 + val rgbBlack = (image.getRGB(x, 0) and 0xFF) < 128 + if ((srcBit == 0) != rgbBlack) return null + } + + val out = ByteArrayOutputStream(height * (rowBytes + 12) + 32) + out.write("\u001BE".toByteArray(Charsets.US_ASCII)) + out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) + out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) + + val fullBytes = width / 8 + val remainder = width % 8 + val remainderMask = if (remainder == 0) 0 else (0xFF shl (8 - remainder)) and 0xFF + val row = ByteArray(rowBytes) + + for (y in 0 until height) { + val rowStart = bankOffset + y * stride + var i = 0 + while (i < fullBytes) { + row[i] = (bank[rowStart + i].toInt().inv() and 0xFF).toByte() + i++ + } + if (remainder != 0) { + val src = bank[rowStart + fullBytes].toInt() and 0xFF + row[fullBytes] = (src.inv() and remainderMask).toByte() + } + out.write("\u001B*b${rowBytes}W".toByteArray(Charsets.US_ASCII)) + out.write(row) + } + out.write("\u001B*rB".toByteArray(Charsets.US_ASCII)) + return out.toByteArray() + } } } \ No newline at end of file diff --git a/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt b/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt index 939d58ec..448bb00e 100644 --- a/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt +++ b/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt @@ -4,6 +4,7 @@ import org.apache.pdfbox.pdmodel.PDDocument import org.slf4j.LoggerFactory import java.io.File import java.io.OutputStream +import java.net.InetSocketAddress import java.net.Socket /** @@ -111,7 +112,11 @@ open class CanonPrinterUtil { val copies = printQty ?: 1 repeat(copies) { copyIndex -> val tSend = System.nanoTime() - Socket(printerIp, printerPort).use { socket -> + PrinterLockManager.withLock(printerIp, printerPort) { + Socket().use { socket -> + socket.tcpNoDelay = true + socket.connect(InetSocketAddress(printerIp, printerPort), 5_000) + socket.soTimeout = 120_000 val os: OutputStream = socket.getOutputStream() // Send PJL commands @@ -126,6 +131,7 @@ open class CanonPrinterUtil { os.flush() } + } val sendMs = (System.nanoTime() - tSend) / 1_000_000.0 log.info( "Canon printPdfToCanon: copy {} of {} sendMs={} printer={}:{} pdfBytes={} (high sendMs: check LAN/WiFi, firewall, or printer 9100 queue)", diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index e1563c53..50b2ec58 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -71,6 +71,7 @@ import jakarta.persistence.EntityManager import jakarta.persistence.PersistenceContext import org.springframework.orm.ObjectOptimisticLockingFailureException import com.ffii.core.utils.PdfUtils +import com.ffii.core.utils.PrinterLockManager import com.ffii.core.utils.ZebraPrinterUtil import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry import com.ffii.fpsms.modules.master.service.PrinterService @@ -85,11 +86,11 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDNLabelsReprintReque import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors import org.springframework.core.io.ClassPathResource import net.sf.jasperreports.engine.JasperCompileManager import net.sf.jasperreports.engine.JasperExportManager import net.sf.jasperreports.engine.JasperPrint +import net.sf.jasperreports.engine.JasperReport import java.io.File import java.io.FileNotFoundException import java.util.Locale @@ -142,6 +143,20 @@ open class DoWorkbenchMainService( companion object { private val log = LoggerFactory.getLogger(DoWorkbenchMainService::class.java) + + private const val DELIVERY_NOTE_JRXML = "DeliveryNote/DeliveryNotePDF.jrxml" + private const val CARTON_LABEL_JRXML = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" + + private val deliveryNoteJasperTemplate: JasperReport by lazy { compileJasper(DELIVERY_NOTE_JRXML) } + private val cartonLabelJasperTemplate: JasperReport by lazy { compileJasper(CARTON_LABEL_JRXML) } + + private fun compileJasper(path: String): JasperReport { + val resource = ClassPathResource(path) + if (!resource.exists()) { + throw FileNotFoundException("Report file not found: $path") + } + return resource.inputStream.use { JasperCompileManager.compileReport(it) } + } } private sealed interface WorkbenchScanIllResolve { @@ -2931,6 +2946,7 @@ return MessageResponse( } @Transactional open fun printDeliveryNoteWorkbench(request: PrintDeliveryNoteRequest) { + val started = System.nanoTime() val printer = printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") @@ -2944,17 +2960,38 @@ return MessageResponse( val jasperPrint = pdf["report"] as JasperPrint val tempPdfFile = File.createTempFile("print_job_", ".pdf") + var handedToPrinter = false try { JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty - printer.ip?.let { ip -> - val port = printer.port ?: 9100 - val driver = A4PrintDriverRegistry.getDriver(printer.brand) - driver.print(tempPdfFile, ip, port, printQty) + val ip = printer.ip?.trim().orEmpty() + if (ip.isEmpty()) { + tempPdfFile.delete() + throw IllegalStateException("Printer ${printer.id} has no IP address") } - updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton) - } finally { - //tempPdfFile.delete() + val port = printer.port ?: 9100 + val brand = printer.brand + val doPickOrderId = request.doPickOrderId + val cartonQty = request.numOfCarton + sendToPrinter( + tempPdfFile, + ip, + port, + afterPrint = { saveCartonQtyAfterSuccessfulPrint(doPickOrderId, cartonQty) }, + ) { + A4PrintDriverRegistry.getDriver(brand).print(tempPdfFile, ip, port, printQty) + } + handedToPrinter = true + log.info( + "Workbench print-DN sent buildMs={} doPickOrderId={} printerId={} printerIp={}", + (System.nanoTime() - started) / 1_000_000, + request.doPickOrderId, + request.printerId, + printer.ip ?: "null", + ) + } catch (e: Exception) { + if (!handedToPrinter) tempPdfFile.delete() + throw e } } @@ -3021,14 +3058,7 @@ return MessageResponse( } open fun exportDeliveryNoteWorkbench(request: ExportDeliveryNoteRequest): Map { - val DELIVERYNOTE_PDF = "DeliveryNote/DeliveryNotePDF.jrxml" - val resource = ClassPathResource(DELIVERYNOTE_PDF) - if (!resource.exists()) { - throw FileNotFoundException("Report file not found: $DELIVERYNOTE_PDF") - } - - val inputStream = resource.inputStream - val deliveryNote = JasperCompileManager.compileReport(inputStream) + val deliveryNote = deliveryNoteJasperTemplate val fields = mutableListOf>() val params = mutableMapOf() @@ -3042,18 +3072,9 @@ return MessageResponse( val exportLines = deliveryOrderService.deliveryNoteExportLines(deliveryNoteInfo) - val pickOrderLines = ctx.pickOrderIds.flatMap { pid -> - pickOrderLineRepository.findAllByPickOrderId(pid) - } + val pickOrderLines = loadPickOrderLinesForPrint(ctx.pickOrderIds) val allPickOrderLineIds = pickOrderLines.mapNotNull { it.id } - val stockOutLinesByPickOrderLineId: Map> = - if (allPickOrderLineIds.isNotEmpty()) { - allPickOrderLineIds.associateWith { polId -> - stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) - } - } else { - emptyMap() - } + val stockOutLinesByPickOrderLineId = loadStockOutLinesForPrint(allPickOrderLineIds) val doStoreFloorKey = ctx.header.storeId?.toString()?.trim()?.uppercase(Locale.ROOT) ?.replace("/", "") @@ -3079,11 +3100,7 @@ return MessageResponse( val allIllIds = stockOutLinesByPickOrderLineId.values .flatMap { lines -> lines.mapNotNull { it.inventoryLotLineId } } .distinct() - val illById: Map = if (allIllIds.isNotEmpty()) { - inventoryLotLineRepository.findAllById(allIllIds).associateBy { it.id!! } - } else { - emptyMap() - } + val illById = loadInventoryLotLinesWithWarehouse(allIllIds) val deliveryOrders = deliveryOrderRepository.findAllById(ctx.deliveryOrderIds) val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } @@ -3157,6 +3174,7 @@ return MessageResponse( } @Transactional open fun printDNLabelsWorkbench(request: PrintDNLabelsRequest) { + val started = System.nanoTime() val printer = printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") @@ -3170,26 +3188,48 @@ return MessageResponse( val jasperPrint = pdf["report"] as JasperPrint val tempPdfFile = File.createTempFile("print_job_", ".pdf") + var handedToPrinter = false try { JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty - printer.ip?.let { ip -> - printer.port?.let { port -> - ZebraPrinterUtil.printPdfToZebra( - tempPdfFile, - ip, - port, - printQty, - ZebraPrinterUtil.PrintDirection.ROTATED, - printer.dpi, - ) - } + val ip = printer.ip?.trim().orEmpty() + val port = printer.port + if (ip.isEmpty() || port == null) { + tempPdfFile.delete() + throw IllegalStateException("Label printer ${printer.id} has no IP or port") } - if (request.blankCartonNumber != true) { - updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton) + val dpi = printer.dpi + val saveCarton = request.blankCartonNumber != true + val doPickOrderId = request.doPickOrderId + val cartonQty = request.numOfCarton + sendToPrinter( + tempPdfFile, + ip, + port, + afterPrint = { + if (saveCarton) saveCartonQtyAfterSuccessfulPrint(doPickOrderId, cartonQty) + }, + ) { + ZebraPrinterUtil.printPdfToZebra( + tempPdfFile, + ip, + port, + printQty, + ZebraPrinterUtil.PrintDirection.ROTATED, + dpi, + ) } - } finally { - //tempPdfFile.delete() + handedToPrinter = true + log.info( + "Workbench print-DNLabels sent buildMs={} doPickOrderId={} cartons={} printerIp={}", + (System.nanoTime() - started) / 1_000_000, + request.doPickOrderId, + request.numOfCarton, + printer.ip ?: "null", + ) + } catch (e: Exception) { + if (!handedToPrinter) tempPdfFile.delete() + throw e } } @@ -3210,15 +3250,8 @@ return MessageResponse( } private fun exportDNLabelsWorkbench(request: ExportDNLabelsRequest): Map { - val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" - val resource = ClassPathResource(DNLABELS_PDF) - if (!resource.exists()) { - throw FileNotFoundException("Label file not found: $DNLABELS_PDF") - } - val ctx = getWorkbenchPrintContext(request.doPickOrderId) - val inputStream = resource.inputStream - val cartonLabel = JasperCompileManager.compileReport(inputStream) + val cartonLabel = cartonLabelJasperTemplate val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) }.toMutableList() @@ -3279,15 +3312,8 @@ return MessageResponse( totalCartonsOnShipment = request.totalCartonsOnShipment, ) - val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" - val resource = ClassPathResource(DNLABELS_PDF) - if (!resource.exists()) { - throw FileNotFoundException("Label file not found: $DNLABELS_PDF") - } - val ctx = getWorkbenchPrintContext(request.doPickOrderId) - val inputStream = resource.inputStream - val cartonLabel = JasperCompileManager.compileReport(inputStream) + val cartonLabel = cartonLabelJasperTemplate val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) }.toMutableList() @@ -3327,23 +3353,147 @@ return MessageResponse( val pdf = exportDNLabelsReprintWorkbench(request) val jasperPrint = pdf["report"] as JasperPrint val tempPdfFile = File.createTempFile("print_job_", ".pdf") + var handedToPrinter = false try { JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty - printer.ip?.let { ip -> - printer.port?.let { port -> - ZebraPrinterUtil.printPdfToZebra( - tempPdfFile, - ip, - port, - printQty, - ZebraPrinterUtil.PrintDirection.ROTATED, - printer.dpi, - ) - } + val ip = printer.ip?.trim().orEmpty() + val port = printer.port + if (ip.isEmpty() || port == null) { + tempPdfFile.delete() + throw IllegalStateException("Label printer ${printer.id} has no IP or port") + } + val dpi = printer.dpi + sendToPrinter(tempPdfFile, ip, port) { + ZebraPrinterUtil.printPdfToZebra( + tempPdfFile, + ip, + port, + printQty, + ZebraPrinterUtil.PrintDirection.ROTATED, + dpi, + ) } + handedToPrinter = true + } catch (e: Exception) { + if (!handedToPrinter) tempPdfFile.delete() + throw e + } + } + + /** + * Send on this request so a printer timeout is returned to the tablet. + * Carton qty is saved only after the printer accepts the job. + */ + private fun sendToPrinter( + tempPdfFile: File, + ip: String, + port: Int, + afterPrint: (() -> Unit)? = null, + action: () -> Unit, + ) { + try { + PrinterLockManager.withLock(ip, port) { + action() + } + log.info("Workbench print sent printer={}:{} file={}", ip, port, tempPdfFile.name) + afterPrint?.invoke() + } catch (e: Exception) { + log.error("Workbench print failed printer={}:{} file={}", ip, port, tempPdfFile.name, e) + throw e } finally { - // tempPdfFile.delete() + tempPdfFile.delete() } } + + /** Same column as before (`cartonQty` only). Runs only after the printer accepts the job. */ + private fun saveCartonQtyAfterSuccessfulPrint(deliveryOrderPickOrderId: Long, cartonQty: Int) { + if (cartonQty <= 0) return + val tx = TransactionTemplate(transactionManager).apply { + propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + tx.executeWithoutResult { + updateWorkbenchCartonQty(deliveryOrderPickOrderId, cartonQty) + } + } + + private fun loadPickOrderLinesForPrint(pickOrderIds: List): List { + if (pickOrderIds.isEmpty()) return emptyList() + return pickOrderIds.distinct().chunked(400).flatMap { chunk -> + pickOrderLineRepository.findAllForPrintByPickOrderIdIn(chunk) + } + } + + private fun loadStockOutLinesForPrint(pickOrderLineIds: List): Map> { + if (pickOrderLineIds.isEmpty()) return emptyMap() + val lines = mutableListOf() + pickOrderLineIds.distinct().chunked(400).forEach { chunk -> + val rows = jdbcDao.queryForList( + """ + SELECT + sol.id AS id, + sol.pickOrderLineId AS pickOrderLineId, + sol.qty AS qty, + sol.inventoryLotLineId AS inventoryLotLineId, + sol.status AS status, + il.lotNo AS lotNo + FROM fpsmsdb.stock_out_line sol + LEFT JOIN fpsmsdb.inventory_lot_line ill + ON ill.id = sol.inventoryLotLineId + LEFT JOIN fpsmsdb.inventory_lot il + ON il.id = ill.inventoryLotId + WHERE sol.deleted = 0 + AND sol.pickOrderLineId IN (:ids) + """.trimIndent(), + mapOf("ids" to chunk), + ) + rows.forEach { row -> + val polId = (rowValue(row, "pickOrderLineId") as? Number)?.toLong() ?: return@forEach + val qtyRaw = rowValue(row, "qty") + lines.add( + PrintStockOutLineInfo( + id = (rowValue(row, "id") as? Number)?.toLong() ?: 0L, + pickOrderLineId = polId, + qty = when (qtyRaw) { + is BigDecimal -> qtyRaw + is Number -> BigDecimal(qtyRaw.toString()) + else -> BigDecimal.ZERO + }, + inventoryLotLineId = (rowValue(row, "inventoryLotLineId") as? Number)?.toLong(), + lotNo = rowValue(row, "lotNo")?.toString()?.takeIf { it.isNotBlank() }, + status = rowValue(row, "status")?.toString() ?: "", + ), + ) + } + } + return lines.groupBy { it.pickOrderLineId!! } + } + + private fun loadInventoryLotLinesWithWarehouse(ids: Collection): Map { + if (ids.isEmpty()) return emptyMap() + val loaded = ids.distinct().chunked(400).flatMap { chunk -> + inventoryLotLineRepository.findAllByIdInWithWarehouse(chunk) + } + return loaded.mapNotNull { line -> line.id?.let { id -> id to line } }.toMap() + } + + private fun rowValue(row: Map, key: String): Any? { + val matched = row.keys.find { it.equals(key, ignoreCase = true) } ?: return null + return row[matched] + } + + private data class PrintStockOutLineInfo( + override val id: Long, + override val pickOrderLineId: Long?, + override val qty: BigDecimal, + override val inventoryLotLineId: Long?, + override val lotNo: String?, + override val status: String, + ) : StockOutLineInfo { + override val itemId: Long = 0L + override val itemName: String? = null + override val itemNo: String = "" + override val stockOutId: Long = 0L + override val pickTime: java.time.LocalDateTime? = null + } } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt index bdf8da28..ba35afb9 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.pickOrder.entity import com.ffii.core.support.AbstractRepository import org.springframework.stereotype.Repository +import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -33,6 +34,17 @@ fun findByPickOrderId(pickOrderId: Long): List ) fun findAllByPickOrderIdInAndDeletedFalse(@Param("pickOrderIds") pickOrderIds: List): List + /** Delivery-note print: one query, associations initialized (avoids per-line lazy loads). */ + @EntityGraph(attributePaths = ["item", "pickOrder", "uom"]) + @Query( + """ + SELECT pol FROM PickOrderLine pol + WHERE pol.pickOrder.id IN :pickOrderIds + AND pol.deleted = false + """ + ) + fun findAllForPrintByPickOrderIdIn(@Param("pickOrderIds") pickOrderIds: List): List + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( value = """ diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 7e57c09c..470d720b 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -27,8 +27,8 @@ open class StockLedgerReportService( * - cumBalance = stock_ledger.balance(異動後纍計存量) * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) * - * 只查 [start, end] 期間列,不掃起日以前全歷史。 - * 批號/位置直接跟這筆出入庫的批次,不再先彙整全庫批次。 + * 先用貨品/日期索引切出流水帳,再一對一接批次行。 + * 不依 inventoryLotId 展開該批次的全部儲位,否則一筆入庫會變成多列。 * 貨品總量在取出明細後計算,避免對寬結果做 window sort。 */ fun searchStockLedgerReport( @@ -40,6 +40,7 @@ open class StockLedgerReportService( ): List> { val args = mutableMapOf() + val itemPredicate = ledgerItemPredicate(itemCode, args) val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") ?: LocalDate.now().format(dateFmt)) @@ -47,17 +48,7 @@ open class StockLedgerReportService( val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { reportPeriodStart.replace("/", "-") } else { - val minDateSql = """ - SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - """.trimIndent() - - val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap()).firstOrNull() - (minDateRow?.get("firstDate") as? String) - ?: reportPeriodEnd + earliestLedgerDate(itemPredicate, args) ?: reportPeriodEnd } val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) @@ -71,13 +62,6 @@ open class StockLedgerReportService( args ) - val itemCodeSql = buildItemCodeFilterClause( - itemCode, - "sl.itemCode", - "itemCode", - args - ) - val storeLocationSql = if (!storeLocation.isNullOrBlank()) { args["storeLocation"] = "%$storeLocation%" "AND wh.code LIKE :storeLocation" @@ -85,62 +69,96 @@ open class StockLedgerReportService( "" } - // Wide reports are a date range over the whole ledger. Pin that index so MySQL - // does not start from another stock_ledger index and scan extra rows. - val ledgerIndex = if (itemCode.isNullOrBlank()) "FORCE INDEX (idx_sl_deleted_date)" else "" + val uomItemSql = if (itemPredicate.itemIdParamNames.isEmpty()) { + "" + } else { + "AND iu.itemId IN (${itemPredicate.itemIdParamNames.joinToString(", ") { ":$it" }})" + } + // One item is already ordered by the (itemId, deleted, date, id) index. + val orderBy = if (itemPredicate.singleItem) { + "ledger.dt, ledger.slId" + } else { + "it.code, ledger.dt, ledger.slId" + } + + // NO_MERGE keeps the date/item slice materialized before the lot joins. val sql = """ -SELECT - sl.id AS slId, - sl.itemCode AS itemCode, - DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, +SELECT /*+ NO_MERGE(ledger) */ + ledger.slId AS slId, + ledger.itemCode AS itemCode, + DATE_FORMAT(ledger.dt, '%Y-%m-%d') AS trnDate, CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' + WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'TKE' THEN 'TKE' + WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'ADJ' AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' - ELSE COALESCE(sl.type, '') + ELSE COALESCE(ledger.ledgerType, '') END AS trnRefNoRaw, - COALESCE(sl.inQty, 0) AS inQty, - COALESCE(sl.outQty, 0) AS outQty, - COALESCE(sl.balance, 0) AS bal, - COALESCE(sl.balance, 0) - COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, + COALESCE(ledger.inQty, 0) AS inQty, + COALESCE(ledger.outQty, 0) AS outQty, + COALESCE(ledger.bal, 0) AS bal, + COALESCE(ledger.bal, 0) - COALESCE(ledger.inQty, 0) + COALESCE(ledger.outQty, 0) AS cumOpeningBalRaw, it.type AS stockSubCategory, it.code AS itemNo, it.name AS itemName, uom.unitOfMeasure AS unitOfMeasure, - COALESCE(il_in.lotNo, il_out.lotNo) AS lotNo, - COALESCE( - DATE_FORMAT(il_in.expiryDate, '%Y-%m-%d'), - DATE_FORMAT(il_out.expiryDate, '%Y-%m-%d'), - '' - ) AS expiryDate, + il.lotNo AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, wh.code AS storeLocation, COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, COALESCE(TRIM(jo.code), '') AS jobOrderNo -FROM stock_ledger sl $ledgerIndex +FROM ( + SELECT + sl.id AS slId, + sl.itemCode AS itemCode, + sl.date AS dt, + sl.type AS ledgerType, + sl.inQty AS inQty, + sl.outQty AS outQty, + sl.balance AS bal, + sl.itemId AS itemId, + sl.stockInLineId AS stockInLineId, + sl.stockOutLineId AS stockOutLineId, + sl.inventoryLotLineId AS inventoryLotLineId + FROM stock_ledger sl ${itemPredicate.indexHint} + WHERE sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + AND sl.date >= :reportPeriodStart + AND sl.date < :reportPeriodEndExclusive + ${itemPredicate.sql} +) ledger LEFT JOIN stock_in_line sil - ON sl.stockInLineId = sil.id + ON ledger.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 + ON ledger.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_in_loc - ON ill_in_loc.inventoryLotId = il_in.id - AND ill_in_loc.deleted = 0 + LEFT JOIN inventory_lot_line ill + ON ill.id = ledger.inventoryLotLineId + AND ill.deleted = 0 + LEFT JOIN inventory_lot_line ill_sil + ON ill.id IS NULL + AND ill_sil.id = sil.inventoryLotLineId + AND ill_sil.deleted = 0 + LEFT JOIN inventory_lot_line ill_sol + ON ill.id IS NULL + AND ill_sil.id IS NULL + AND ill_sol.id = sol.inventoryLotLineId + AND ill_sol.deleted = 0 + LEFT JOIN inventory_lot il + ON il.id = COALESCE( + ill.inventoryLotId, + ill_sil.inventoryLotId, + ill_sol.inventoryLotId, + sil.inventoryLotId + ) + AND il.deleted = 0 LEFT JOIN warehouse wh - ON wh.id = COALESCE(ill_in_loc.warehouseId, ill_out.warehouseId) + ON wh.id = COALESCE(ill.warehouseId, ill_sil.warehouseId, ill_sol.warehouseId) AND wh.deleted = 0 LEFT JOIN items it - ON sl.itemId = it.id + ON ledger.itemId = it.id AND it.deleted = 0 LEFT JOIN ( SELECT iu.itemId, MIN(uc.udfudesc) AS unitOfMeasure @@ -148,6 +166,7 @@ FROM stock_ledger sl $ledgerIndex JOIN uom_conversion uc ON uc.id = iu.uomId WHERE iu.stockUnit = 1 AND iu.deleted = 0 + $uomItemSql GROUP BY iu.itemId ) uom ON uom.itemId = it.id LEFT JOIN stock_out so @@ -174,26 +193,97 @@ FROM stock_ledger sl $ledgerIndex LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 -WHERE - sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - AND sl.date >= :reportPeriodStart - AND sl.date < :reportPeriodEndExclusive +WHERE il.id IS NOT NULL $stockCategorySql - $itemCodeSql $storeLocationSql - AND COALESCE(il_in.id, il_out.id) IS NOT NULL ORDER BY - itemNo, - trnDate, - slId, - lotNo + $orderBy """.trimIndent() return attachItemTotals(jdbcDao.queryForList(sql, args)) } + /** + * Exact item codes become itemId so MySQL can use + * idx_ledger_item_deleted_date_id (itemId, deleted, date, id). + */ + private fun ledgerItemPredicate(itemCode: String?, args: MutableMap): LedgerItemPredicate { + val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(itemCode) + if (exact.isEmpty() && fuzzy.isEmpty()) { + return LedgerItemPredicate( + sql = "", + indexHint = "FORCE INDEX (idx_sl_deleted_date)", + singleItem = false, + itemIdParamNames = emptyList(), + ) + } + if (fuzzy.isEmpty()) { + val ids = lookupItemIds(exact) + if (ids.isNotEmpty()) { + val names = ids.mapIndexed { index, id -> + val name = "itemId_$index" + args[name] = id + name + } + return LedgerItemPredicate( + sql = "AND sl.itemId IN (${names.joinToString(", ") { ":$it" }})", + indexHint = "FORCE INDEX (idx_ledger_item_deleted_date_id)", + singleItem = ids.size == 1, + itemIdParamNames = names, + ) + } + } + val oneExactCode = fuzzy.isEmpty() && exact.size == 1 + return LedgerItemPredicate( + sql = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCode", args), + indexHint = if (exact.size + fuzzy.size == 1) "FORCE INDEX (idx_sl_deleted_itemcode_created)" else "", + singleItem = oneExactCode, + itemIdParamNames = emptyList(), + ) + } + + private fun lookupItemIds(codes: List): List { + if (codes.isEmpty()) return emptyList() + val args = HashMap(codes.size) + val names = codes.mapIndexed { index, code -> + val name = "lookupCode_$index" + args[name] = code + ":$name" + } + val sql = """ + SELECT id AS itemId + FROM items + WHERE code IN (${names.joinToString(", ")}) + """.trimIndent() + return jdbcDao.queryForList(sql, args).mapNotNull { row -> + when (val id = row["itemId"]) { + is Number -> id.toLong() + else -> id?.toString()?.toLongOrNull() + } + }.distinct() + } + + /** Blank start date: earliest movement for this filter, not MIN() of the whole ledger. */ + private fun earliestLedgerDate(predicate: LedgerItemPredicate, args: Map): String? { + val sql = """ + SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate + FROM stock_ledger sl ${predicate.indexHint} + WHERE sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + ${predicate.sql} + """.trimIndent() + val row = jdbcDao.queryForList(sql, args).firstOrNull() + return row?.get("firstDate") as? String + } + + private data class LedgerItemPredicate( + val sql: String, + val indexHint: String, + val singleItem: Boolean, + val itemIdParamNames: List, + ) + /** * Item totals match the old window: first opening, sum in/out, last balance per itemCode. * Rows stay ordered by item, date, id. diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 68911e1c..bc738342 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -326,4 +326,17 @@ WHERE ill.id = :id fun findAllByIdInAndDeletedFalseWithRefs( @Param("ids") ids: Collection ): List + + /** Delivery-note route column: warehouse code without a lazy load per lot line. */ + @EntityGraph( + type = EntityGraph.EntityGraphType.FETCH, + attributePaths = ["warehouse"] + ) + @Query( + """ + SELECT ill FROM InventoryLotLine ill + WHERE ill.id IN :ids + """ + ) + fun findAllByIdInWithWarehouse(@Param("ids") ids: Collection): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 446fa208..b86bf0a1 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -446,6 +446,8 @@ open class StockInLineService( } dnNo = request.dnNo receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() + // The item label prints this field under 收貨日期. + productionDate = request.productionDate?.atStartOfDay() ?: receiptDate productLotNo = request.productLotNo this.type = "NOR" status = StockInLineStatus.PENDING.status @@ -1252,7 +1254,7 @@ open class StockInLineService( val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) field["qtyLabel"] = qtyLabel field["qtyDetail"] = qtyDetail - field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" + field["productionDate"] = (info.productionDate ?: info.receiptDate)?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["lotNo"] = info.lotNo ?: "N/A" field["supplier"] = info.supplier ?: "N/A" From d024862c1d4287b4229069c762a1eb6e90c06cf2 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 4 Oct 2026 14:34:27 +0800 Subject: [PATCH 16/16] =?UTF-8?q?=E7=99=BC=E8=B2=A8=E8=88=87=E9=85=8D?= =?UTF-8?q?=E9=80=81>=E5=93=A1=E5=B7=A5=E7=99=BC=E8=B2=A8=E7=B8=BE?= =?UTF-8?q?=E6=95=88=20added=20a=20new=20access=20right=20=E7=B8=BE?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fpsms/config/security/SecurityConfig.java | 5 ++++ .../01_add_performance_authority.sql | 19 ++++++++++++ .../resources/qrCodeLabel/poItemPDF_v4.jrxml | 30 +++++++++---------- 3 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index 16550992..6d9c1478 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -148,6 +148,11 @@ public class SecurityConfig { .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") .requestMatchers("/truckLaneSchedule/**") .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") + /* 員工發貨績效 (/chart/delivery): PERFORMANCE (績效) */ + .requestMatchers(HttpMethod.GET, "/chart/staff-delivery-performance") + .hasAuthority("PERFORMANCE") + .requestMatchers(HttpMethod.GET, "/chart/staff-delivery-performance-handlers") + .hasAuthority("PERFORMANCE") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) diff --git a/src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql b/src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql new file mode 100644 index 00000000..0f803056 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql @@ -0,0 +1,19 @@ +--liquibase formatted sql + +--changeset fpsms:add_performance_authority +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'PERFORMANCE' +--comment: Staff delivery performance on /chart/delivery requires PERFORMANCE (績效) +INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`) +VALUES ('PERFORMANCE', '績效', 'CHART', 'Allow viewing staff delivery performance on /chart/delivery'); + +--changeset fpsms:grant_performance_authority_to_2fi +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM user_authority ua JOIN `user` u ON u.id = ua.userId JOIN authority a ON a.id = ua.authId WHERE u.username = '2fi' AND a.authority = 'PERFORMANCE' +--comment: Default grant 績效 to user 2fi +INSERT IGNORE INTO `user_authority` (`userId`, `authId`) +SELECT u.id, a.id +FROM `user` u +JOIN `authority` a ON a.authority = 'PERFORMANCE' +WHERE u.username = '2fi' + AND u.deleted = 0; diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml index 7125b95a..31e29cd3 100644 --- a/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml +++ b/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml @@ -66,28 +66,28 @@ - + - + - + - + - + @@ -96,14 +96,14 @@ - + - + @@ -114,42 +114,42 @@ - + - + - + - + - + - + - + - +