| @@ -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 | |||
| */ | |||
| @@ -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<String, JasperReport>() | |||
| 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<String, Any>, dataList: List<Map<String, Any>>): 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<String, Any>, dataList: List<Map<String, Any>>): 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) | |||
| } | |||
| @@ -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<Map<String, Any>>, | |||
| 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<PDPage>() | |||
| 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 | |||
| } | |||
| } | |||
| @@ -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<Map<String, Any>>): List<Map<String, Any>> { | |||
| if (rows.isEmpty()) return rows | |||
| val totals = HashMap<String, LedgerItemTotals>(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<Map<String, Any>>(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?, | |||
| @@ -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<ByteArray> { | |||
| val tokenCount = ReportMultiValueTokens.split(itemCode).size | |||
| val parameters = mutableMapOf<String, Any>() | |||
| 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 { | |||
| @@ -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; | |||
| @@ -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, | |||
| @@ -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 | |||
| @@ -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' | |||
| ); | |||
| @@ -73,7 +73,7 @@ | |||
| <field name="cumBalance" class="java.lang.String"/> | |||
| <field name="jobOrderNo" class="java.lang.String"/> | |||
| <field name="orderRefNo" class="java.lang.String"/> | |||
| <group name="Group1" keepTogether="true"> | |||
| <group name="Group1" keepTogether="false"> | |||
| <groupExpression><![CDATA[$F{itemNo}]]></groupExpression> | |||
| <groupHeader> | |||
| <band height="18"> | |||
| @@ -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<Map<String, Any>>(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") | |||
| } | |||
| } | |||