| @@ -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. | * 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"; | 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 | * Mail settings | ||||
| */ | */ | ||||
| @@ -4,7 +4,7 @@ import org.springframework.stereotype.Service | |||||
| import net.sf.jasperreports.engine.* | import net.sf.jasperreports.engine.* | ||||
| import net.sf.jasperreports.engine.data.JRMapCollectionDataSource | import net.sf.jasperreports.engine.data.JRMapCollectionDataSource | ||||
| import java.io.ByteArrayOutputStream | import java.io.ByteArrayOutputStream | ||||
| import java.io.InputStream | |||||
| import java.util.concurrent.ConcurrentHashMap | |||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import com.ffii.fpsms.m18.M18GrnRules | import com.ffii.fpsms.m18.M18GrnRules | ||||
| import com.ffii.fpsms.modules.master.entity.ShopRepository | import com.ffii.fpsms.modules.master.entity.ShopRepository | ||||
| @@ -28,6 +28,15 @@ open class ReportService( | |||||
| private val shopRepository: ShopRepository, | private val shopRepository: ShopRepository, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | 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. | * 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. | * 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 { | 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 dataSource = JRMapCollectionDataSource(dataList) | ||||
| val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | ||||
| val out = ByteArrayOutputStream() | val out = ByteArrayOutputStream() | ||||
| @@ -1748,13 +1755,8 @@ return result | |||||
| * Compiles and fills a Jasper Report, returning the PDF as a ByteArray. | * 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 { | 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 dataSource = JRMapCollectionDataSource(dataList) | ||||
| val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | ||||
| return JasperExportManager.exportReportToPdf(jasperPrint) | 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 com.ffii.core.support.JdbcDao | ||||
| import org.springframework.stereotype.Service | 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.LocalDate | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import java.util.Locale | |||||
| @Service | @Service | ||||
| open class StockLedgerReportService( | open class StockLedgerReportService( | ||||
| @@ -23,6 +28,8 @@ open class StockLedgerReportService( | |||||
| * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) | * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) | ||||
| * | * | ||||
| * 只查 [start, end] 期間列,不掃起日以前全歷史。 | * 只查 [start, end] 期間列,不掃起日以前全歷史。 | ||||
| * 批號/位置直接跟這筆出入庫的批次,不再先彙整全庫批次。 | |||||
| * 貨品總量在取出明細後計算,避免對寬結果做 window sort。 | |||||
| */ | */ | ||||
| fun searchStockLedgerReport( | fun searchStockLedgerReport( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| @@ -71,213 +78,244 @@ open class StockLedgerReportService( | |||||
| args | args | ||||
| ) | ) | ||||
| // 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數 | |||||
| val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | ||||
| args["storeLocation"] = "%$storeLocation%" | args["storeLocation"] = "%$storeLocation%" | ||||
| "AND lot.storeLocation LIKE :storeLocation" | |||||
| "AND wh.code LIKE :storeLocation" | |||||
| } else { | } 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 = """ | val sql = """ | ||||
| SELECT | 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, | itemNo, | ||||
| itemName, | |||||
| unitOfMeasure, | |||||
| lotNo, | |||||
| expiryDate, | |||||
| trnDate, | 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. */ | /** Full codes use equality; short tokens use prefix LIKE. */ | ||||
| private fun buildItemCodeFilterClause( | private fun buildItemCodeFilterClause( | ||||
| paramValue: String?, | paramValue: String?, | ||||
| @@ -5,8 +5,8 @@ import java.time.LocalDate | |||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens | 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.StockLedgerReportService | ||||
| import com.ffii.fpsms.modules.report.service.ReportService | |||||
| import org.apache.poi.ss.usermodel.BorderStyle | import org.apache.poi.ss.usermodel.BorderStyle | ||||
| import org.apache.poi.ss.usermodel.CellStyle | import org.apache.poi.ss.usermodel.CellStyle | ||||
| import org.apache.poi.ss.usermodel.DataFormat | import org.apache.poi.ss.usermodel.DataFormat | ||||
| @@ -24,7 +24,6 @@ import java.io.ByteArrayOutputStream | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/report") | @RequestMapping("/report") | ||||
| class StockLedgerReportController( | class StockLedgerReportController( | ||||
| private val reportService: ReportService, | |||||
| private val stockLedgerReportService: StockLedgerReportService, | private val stockLedgerReportService: StockLedgerReportService, | ||||
| ) { | ) { | ||||
| private data class ExcelStyles( | private data class ExcelStyles( | ||||
| @@ -98,27 +97,6 @@ class StockLedgerReportController( | |||||
| reportPeriodStart: String?, | reportPeriodStart: String?, | ||||
| reportPeriodEnd: String?, | reportPeriodEnd: String?, | ||||
| ): ResponseEntity<ByteArray> { | ): 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( | val dbData = stockLedgerReportService.searchStockLedgerReport( | ||||
| stockCategory = stockCategory, | stockCategory = stockCategory, | ||||
| itemCode = itemCode, | itemCode = itemCode, | ||||
| @@ -127,10 +105,12 @@ class StockLedgerReportController( | |||||
| reportPeriodEnd = reportPeriodEnd, | 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 { | val headers = HttpHeaders().apply { | ||||
| @@ -3,6 +3,9 @@ package com.ffii.fpsms.modules.settings.web; | |||||
| import java.util.List; | import java.util.List; | ||||
| import org.springframework.http.HttpStatus; | 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.GetMapping; | ||||
| import org.springframework.web.bind.annotation.PatchMapping; | import org.springframework.web.bind.annotation.PatchMapping; | ||||
| import org.springframework.web.bind.annotation.PostMapping; | 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.BadRequestException; | ||||
| import com.ffii.core.exception.NotFoundException; | 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.entity.Settings; | ||||
| import com.ffii.fpsms.modules.settings.service.SettingsService; | import com.ffii.fpsms.modules.settings.service.SettingsService; | ||||
| @@ -53,6 +57,9 @@ public class SettingsController{ | |||||
| } | } | ||||
| private void applyUpdate(String name, UpdateReq body) { | 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) | Settings entity = this.settingsService.findByName(name) | ||||
| .orElseThrow(NotFoundException::new); | .orElseThrow(NotFoundException::new); | ||||
| if (!this.settingsService.validateType(entity.getType(), body.getValue())) { | if (!this.settingsService.validateType(entity.getType(), body.getValue())) { | ||||
| @@ -63,6 +70,12 @@ public class SettingsController{ | |||||
| this.settingsService.save(entity); | 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 { | public static class UpdateReq { | ||||
| @NotBlank | @NotBlank | ||||
| private String value; | private String value; | ||||
| @@ -805,6 +805,7 @@ open class StockTakeRecordService( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| status = ill.status?.name, | status = ill.status?.name, | ||||
| @@ -1065,6 +1066,7 @@ open class StockTakeRecordService( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| status = ill.status?.name, | status = ill.status?.name, | ||||
| @@ -1175,6 +1177,7 @@ open class StockTakeRecordService( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseArea = warehouse?.area, | warehouseArea = warehouse?.area, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| @@ -1270,6 +1273,7 @@ open class StockTakeRecordService( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseArea = warehouse?.area, | warehouseArea = warehouse?.area, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| @@ -2854,6 +2858,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| status = ill.status?.name, | status = ill.status?.name, | ||||
| @@ -2954,6 +2959,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| status = ill.status?.name, | status = ill.status?.name, | ||||
| @@ -78,6 +78,8 @@ data class InventoryLotDetailResponse( | |||||
| @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") | @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") | ||||
| val approverTime: LocalDateTime? = null, | val approverTime: LocalDateTime? = null, | ||||
| val lastSelect: Int? = null, | val lastSelect: Int? = null, | ||||
| /** Stock UoM short label (e.g. 包), shown beside the count input. */ | |||||
| val uomShortDesc: String? = null, | |||||
| ) | ) | ||||
| data class InventoryLotLineListRequest( | data class InventoryLotLineListRequest( | ||||
| val warehouseCode: String | 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="cumBalance" class="java.lang.String"/> | ||||
| <field name="jobOrderNo" class="java.lang.String"/> | <field name="jobOrderNo" class="java.lang.String"/> | ||||
| <field name="orderRefNo" 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> | <groupExpression><![CDATA[$F{itemNo}]]></groupExpression> | ||||
| <groupHeader> | <groupHeader> | ||||
| <band height="18"> | <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") | |||||
| } | |||||
| } | |||||