From 586bb098054c5f7c1e3ebc5a6c23b672bc81af18 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Thu, 24 Sep 2026 00:23:36 +0800 Subject: [PATCH] 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") + } +}