From 71477649a564cc43f55c8f28adaa8b66576d9ddd Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 31 Aug 2026 14:35:27 +0800 Subject: [PATCH] Add stock lot on-hand report Excel with last-move and PP/PF price. Live lots use ledger last move when lot line is set; otherwise SIL/SOL time. Unit price and stock value only for PP/PF origin. Co-authored-by: Cursor --- .../service/StockLotOnhandReportService.kt | 419 ++++++++++++++++++ .../web/StockLotOnhandReportController.kt | 321 ++++++++++++++ 2 files changed, 740 insertions(+) create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt new file mode 100644 index 0000000..aecdd1f --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt @@ -0,0 +1,419 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * 庫存批次現況(Stock Balance):永遠今天。 + * 現存讀 [inventory_lot_line](available、未過期、in-out > 0)。 + * 最後異動:有 inventoryLotLineId 的帳本用 MAX(id);缺的比 SIL/SOL 時間。 + * 單位均價/庫存總價值只填 root PO 為 PP/PF 的批(TRF 往回走);其他來源空白。 + */ +@Service +open class StockLotOnhandReportService( + private val jdbcDao: JdbcDao, +) { + private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + companion object { + /** Same TRF walk as stock-take variance: root stock-in / PO after transfers. */ + private const val LOT_ROOT_ORIGIN_CTE_SQL = """ +lot_trace AS ( + SELECT + il.id AS lotId, + il.stockInLineId AS silId, + 0 AS depth + FROM inventory_lot il + WHERE il.deleted = 0 + AND il.stockInLineId IS NOT NULL + UNION ALL + SELECT + lt.lotId AS lotId, + il_src.stockInLineId AS silId, + lt.depth + 1 AS depth + FROM lot_trace lt + INNER JOIN stock_in_line sil + ON sil.id = lt.silId + AND sil.deleted = 0 + INNER JOIN stock_transfer_record tr + ON tr.id = sil.stockTransferId + AND tr.deleted = 0 + INNER JOIN stock_out_line sol + ON sol.id = tr.stockOutLineId + AND sol.deleted = 0 + INNER JOIN inventory_lot_line ill_src + ON ill_src.id = sol.inventoryLotLineId + AND ill_src.deleted = 0 + INNER JOIN inventory_lot il_src + ON il_src.id = ill_src.inventoryLotId + AND il_src.deleted = 0 + WHERE lt.depth < 8 + AND il_src.stockInLineId IS NOT NULL + AND ( + UPPER(TRIM(COALESCE(sil.type, ''))) = 'TRF' + OR sil.stockTransferId IS NOT NULL + ) +), +lot_root_origin AS ( + SELECT + lotId, + silId AS rootSilId + FROM ( + SELECT + lotId, + silId, + ROW_NUMBER() OVER (PARTITION BY lotId ORDER BY depth DESC) AS rn + FROM lot_trace + ) t + WHERE t.rn = 1 +)""" + + private const val ROOT_STOCK_IN_JOIN_SQL = """ + LEFT JOIN lot_root_origin lro + ON lro.lotId = il.id + LEFT JOIN stock_in_line root_sil + ON root_sil.id = lro.rootSilId AND root_sil.deleted = 0 + LEFT JOIN purchase_order root_po + ON root_po.id = root_sil.purchaseOrderId AND root_po.deleted = 0 +""" + } + + data class SearchResult( + val rows: List>, + val stockDate: String, + ) + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + fun search( + itemCode: String?, + storeId: String?, + warehouse: String?, + area: String?, + slot: String?, + lotNo: String?, + stockTakeSectionDescription: String?, + lotOrigin: String?, + ): SearchResult { + val asOfDateStr = LocalDate.now().format(dateFmt) + + val masterArgs = HashMap() + val itemCodeSqlIt = buildMultiValueLikeClause(itemCode, "it.code", "itemCodeIt", masterArgs) + val lotNoSql = buildMultiValueLikeClause(lotNo, "il.lotNo", "lotNo", masterArgs) + val storeIdSql = if (!storeId.isNullOrBlank() && storeId.trim() != "All") { + masterArgs["storeId"] = storeId.trim() + "AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')" + } else { + "" + } + val warehouseSql = if (!warehouse.isNullOrBlank() && warehouse.trim() != "All") { + buildMultiValueLikeClause(warehouse, "wh.warehouse", "warehousePart", masterArgs) + } else { + "" + } + val areaSql = if (!area.isNullOrBlank() && area.trim() != "All") { + buildMultiValueLikeClause(area, "wh.area", "areaPart", masterArgs) + } else { + "" + } + val slotSql = if (!slot.isNullOrBlank() && slot.trim() != "All") { + buildMultiValueLikeClause(slot, "wh.slot", "slotPart", masterArgs) + } else { + "" + } + val sectionDescSql = if ( + !stockTakeSectionDescription.isNullOrBlank() && + stockTakeSectionDescription.trim() != "All" + ) { + masterArgs["stockTakeSectionDescription"] = stockTakeSectionDescription.trim() + "AND COALESCE(wh.stockTakeSectionDescription, '') = :stockTakeSectionDescription" + } else { + "" + } + val lotOriginFilterSql = buildLotOriginFilterSql(lotOrigin) + val originJoinSql = ROOT_STOCK_IN_JOIN_SQL + val ctePrefix = "WITH RECURSIVE\n$LOT_ROOT_ORIGIN_CTE_SQL\n" + + val liveLots = jdbcDao.queryForList( + """ + ${ctePrefix}SELECT + ill.id AS inventoryLotLineId, + COALESCE(it.code, '') AS itemNo, + COALESCE(it.name, '') AS itemName, + COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, + COALESCE(il.lotNo, '') AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + COALESCE(wh.store_id, '') AS storeId, + COALESCE(wh.warehouse, '') AS warehousePart, + COALESCE(wh.area, '') AS areaPart, + COALESCE(wh.slot, '') AS slotPart, + (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS lotQtyRaw, + COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw, + UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) AS rootPoPrefix + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND il.deleted = 0 + INNER JOIN items it + ON it.id = il.itemId AND it.deleted = 0 + INNER JOIN warehouse wh + ON wh.id = ill.warehouseId AND wh.deleted = 0 + LEFT JOIN item_uom iu + ON iu.id = ill.stockItemUomId AND iu.deleted = 0 + LEFT JOIN uom_conversion uc + ON uc.id = iu.uomId + $originJoinSql + WHERE ill.deleted = 0 + AND it.code IS NOT NULL AND it.code <> '' + AND LOWER(COALESCE(ill.status, '')) = 'available' + AND (il.expiryDate IS NULL OR il.expiryDate >= CURRENT_DATE) + AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) > 0 + $itemCodeSqlIt + $storeIdSql + $warehouseSql + $areaSql + $slotSql + $sectionDescSql + $lotNoSql + $lotOriginFilterSql + """.trimIndent(), + masterArgs, + ) + + val lotIds = liveLots.map { toLong(it["inventoryLotLineId"]) }.filter { it > 0 } + val lastTrnByLot = HashMap>(lotIds.size * 2) + loadLastTrnFromLedger(lotIds, lastTrnByLot) + val missing = lotIds.distinct().filter { it !in lastTrnByLot } + if (missing.isNotEmpty()) { + val silSolHits = HashMap(missing.size * 2) + fillLastTrnFromLotHeaderSil(missing, silSolHits) + fillLastTrnFromSilLine(missing, silSolHits) + fillLastTrnFromSol(missing, silSolHits) + for ((lotId, hit) in silSolHits) { + lastTrnByLot[lotId] = hit.date to hit.kind + } + } + + val rows = assembleRows(liveLots, lastTrnByLot) + return SearchResult(rows = rows, stockDate = asOfDateStr) + } + + private data class SilSolHit(val ts: String, val date: String, val kind: String) + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun loadLastTrnFromLedger(lotIds: List, out: MutableMap>) { + if (lotIds.isEmpty()) return + // idx_ledger_lot_date_id is (lot, date, id) — bad for MAX(id); force the lot-only index + // (InnoDB secondary includes PK id, so MAX(id) per lot is an index tail lookup). + for (chunk in lotIds.distinct().chunked(2000)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sl.inventoryLotLineId, + DATE_FORMAT(sl.date, '%Y-%m-%d') AS lastTrnDate, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty + FROM stock_ledger sl + INNER JOIN ( + SELECT inventoryLotLineId, MAX(id) AS maxId + FROM stock_ledger FORCE INDEX (idx_ledger_inventoryLotLineId) + WHERE deleted = 0 + AND inventoryLotLineId IN (:lotIds) + GROUP BY inventoryLotLineId + ) t ON t.maxId = sl.id + """.trimIndent(), + mapOf("lotIds" to chunk), + ) + for (r in rows) { + val lotId = toLong(r["inventoryLotLineId"]) + if (lotId <= 0) continue + val date = r["lastTrnDate"]?.toString().orEmpty() + out[lotId] = date to lastTrnType(toDouble(r["inQty"]), toDouble(r["outQty"])) + } + } + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun fillLastTrnFromLotHeaderSil(missing: List, hits: MutableMap): Int { + var n = 0 + for (chunk in missing.chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + ill.id AS lotLineId, + DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d %H:%i:%s') AS lastTs, + DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d') AS lastTrnDate + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND il.deleted = 0 + INNER JOIN stock_in_line sil + ON sil.id = il.stockInLineId AND sil.deleted = 0 + WHERE ill.deleted = 0 + AND ill.id IN (:lotIds) + """.trimIndent(), + mapOf("lotIds" to chunk), + ) + n += rows.size + applySilSolHits(rows, hits, "入庫") + } + return n + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun fillLastTrnFromSilLine(missing: List, hits: MutableMap): Int { + var n = 0 + for (chunk in missing.chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sil.inventoryLotLineId AS lotLineId, + DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d %H:%i:%s') AS lastTs, + DATE_FORMAT(COALESCE(sil.receiptDate, sil.modified, sil.created), '%Y-%m-%d') AS lastTrnDate + FROM stock_in_line sil + WHERE sil.deleted = 0 + AND sil.inventoryLotLineId IN (:lotIds) + """.trimIndent(), + mapOf("lotIds" to chunk), + ) + n += rows.size + applySilSolHits(rows, hits, "入庫") + } + return n + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun fillLastTrnFromSol(missing: List, hits: MutableMap): Int { + var n = 0 + for (chunk in missing.chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sol.inventoryLotLineId AS lotLineId, + DATE_FORMAT(COALESCE(sol.pickTime, sol.modified, sol.created), '%Y-%m-%d %H:%i:%s') AS lastTs, + DATE_FORMAT(COALESCE(sol.pickTime, sol.modified, sol.created), '%Y-%m-%d') AS lastTrnDate + FROM stock_out_line sol + WHERE sol.deleted = 0 + AND sol.inventoryLotLineId IN (:lotIds) + """.trimIndent(), + mapOf("lotIds" to chunk), + ) + n += rows.size + applySilSolHits(rows, hits, "出庫") + } + return n + } + + private fun applySilSolHits(rows: List>, hits: MutableMap, kind: String) { + for (r in rows) { + val lotId = toLong(r["lotLineId"]) + val ts = r["lastTs"]?.toString().orEmpty() + if (lotId <= 0 || ts.isBlank()) continue + val date = r["lastTrnDate"]?.toString().orEmpty() + val prev = hits[lotId] + val newer = prev == null || + ts > prev.ts || + (ts == prev.ts && kind == "出庫" && prev.kind != "出庫") + if (newer) hits[lotId] = SilSolHit(ts, date, kind) + } + } + + private fun lastTrnType(inQty: Double, outQty: Double): String = + when { + outQty > 0 && inQty <= 0 -> "出庫" + inQty > 0 && outQty <= 0 -> "入庫" + inQty > 0 && outQty > 0 -> if (outQty >= inQty) "出庫" else "入庫" + else -> "" + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + private fun assembleRows( + liveLots: List>, + lastTrnByLot: Map>, + ): List> { + val tot = HashMap() + for (r in liveLots) { + val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" + tot[key] = (tot[key] ?: 0.0) + toDouble(r["lotQtyRaw"]) + } + val out = ArrayList>(liveLots.size) + for (r in liveLots) { + val lotId = toLong(r["inventoryLotLineId"]) + val lotQty = toDouble(r["lotQtyRaw"]) + val avg = toDouble(r["avgUnitPriceRaw"]) + val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" + val last = lastTrnByLot[lotId] + val origin = r["rootPoPrefix"]?.toString().orEmpty().uppercase() + val showPrice = origin == "PP" || origin == "PF" + val row = HashMap(22) + row["inventoryLotLineId"] = lotId.toString() + row["itemNo"] = r["itemNo"] ?: "" + row["itemName"] = r["itemName"] ?: "" + row["unitOfMeasure"] = r["unitOfMeasure"] ?: "" + row["lotNo"] = r["lotNo"] ?: "" + row["expiryDate"] = r["expiryDate"] ?: "" + row["storeId"] = r["storeId"] ?: "" + row["warehousePart"] = r["warehousePart"] ?: "" + row["areaPart"] = r["areaPart"] ?: "" + row["slotPart"] = r["slotPart"] ?: "" + row["lotQtyRaw"] = lotQty + row["totalQtyRaw"] = tot[key] ?: 0.0 + row["avgUnitPriceRaw"] = if (showPrice) avg else "" + row["stockValueRaw"] = if (showPrice) avg * lotQty else "" + row["lastTrnDate"] = last?.first ?: "" + row["lastTrnType"] = last?.second ?: "" + out.add(row) + } + out.sortWith( + compareBy> { it["itemNo"]?.toString().orEmpty() } + .thenBy { it["unitOfMeasure"]?.toString().orEmpty() } + .thenBy { it["lotNo"]?.toString().orEmpty() } + .thenBy { it["storeId"]?.toString().orEmpty() } + .thenBy { it["warehousePart"]?.toString().orEmpty() } + .thenBy { it["areaPart"]?.toString().orEmpty() } + .thenBy { it["slotPart"]?.toString().orEmpty() } + .thenBy { it["inventoryLotLineId"]?.toString().orEmpty() }, + ) + return out + } + + private fun toDouble(v: Any?): Double { + if (v == null) return 0.0 + if (v is Number) return v.toDouble() + return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0 + } + + private fun toLong(v: Any?): Long { + if (v == null) return 0L + if (v is Number) return v.toLong() + return v.toString().toLongOrNull() ?: 0L + } + + /** PP/PF = root PO code prefix after TRF walk; other = not PP/PF (ADJ, TRF of other origin, JO, OPEN, …). */ + private fun buildLotOriginFilterSql(lotOrigin: String?): String { + val v = lotOrigin?.trim().orEmpty() + if (v.isBlank() || v.equals("All", ignoreCase = true)) return "" + return when (v.lowercase()) { + "pp" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PP'" + "pf" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PF'" + "other" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) NOT IN ('PP', 'PF')" + else -> "" + } + } + + private fun buildMultiValueLikeClause( + paramValue: String?, + columnName: String, + paramPrefix: String, + args: MutableMap, + ): String { + if (paramValue.isNullOrBlank()) return "" + val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (values.isEmpty()) return "" + val conditions = values.mapIndexed { index, value -> + val paramName = "${paramPrefix}_$index" + args[paramName] = "%$value%" + "$columnName LIKE :$paramName" + } + return "AND (${conditions.joinToString(" OR ")})" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt new file mode 100644 index 0000000..7aac550 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt @@ -0,0 +1,321 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.StockLotOnhandReportService +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.CellStyle +import org.apache.poi.ss.usermodel.DataFormat +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.streaming.SXSSFWorkbook +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.io.ByteArrayOutputStream +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** + * 庫存批次現況報告 Stock Balance (Excel only, always today) + * Excel: /report/print-stock-lot-onhand-excel + */ +@RestController +@RequestMapping("/report") +class StockLotOnhandReportController( + private val stockLotOnhandReportService: StockLotOnhandReportService, +) { + private data class ExcelStyles( + val title: CellStyle, + val subtitle: CellStyle, + val header: CellStyle, + val text: CellStyle, + val center: CellStyle, + val number: CellStyle, + ) + + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.0 | 2026-08-31 */ + @GetMapping("/print-stock-lot-onhand-excel") + fun exportExcel( + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) warehouse: String?, + @RequestParam(required = false) area: String?, + @RequestParam(required = false) slot: String?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) stockTakeSectionDescription: String?, + @RequestParam(required = false) lotOrigin: String?, + ): ResponseEntity { + val result = stockLotOnhandReportService.search( + itemCode = itemCode, + storeId = storeId, + warehouse = warehouse, + area = area, + slot = slot, + lotNo = lotNo, + stockTakeSectionDescription = stockTakeSectionDescription, + lotOrigin = lotOrigin, + ) + if (result.rows.isEmpty()) { + return ResponseEntity(HttpStatus.NO_CONTENT) + } + val excelBytes = createExcel( + dbData = result.rows, + reportDate = result.stockDate, + itemCode = itemCode, + storeId = storeId, + warehouse = warehouse, + area = area, + slot = slot, + lotNo = lotNo, + stockTakeSectionDescription = stockTakeSectionDescription, + lotOrigin = lotOrigin, + ) + + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + setContentDispositionFormData("attachment", "StockLotOnhandReport.xlsx") + set("filename", "StockLotOnhandReport.xlsx") + } + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun createStyles(workbook: Workbook): ExcelStyles { + val df: DataFormat = workbook.createDataFormat() + val numberFormat = df.getFormat("#,##0.00;(#,##0.00)") + fun borders(style: CellStyle) { + style.borderTop = BorderStyle.THIN + style.borderBottom = BorderStyle.THIN + style.borderLeft = BorderStyle.THIN + style.borderRight = BorderStyle.THIN + style.verticalAlignment = VerticalAlignment.CENTER + } + fun fill(style: CellStyle, color: IndexedColors) { + style.fillForegroundColor = color.index + style.fillPattern = FillPatternType.SOLID_FOREGROUND + } + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 16 + } + setFont(font) + } + val subtitleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + wrapText = true + val font = workbook.createFont().apply { + fontHeightInPoints = 10 + } + setFont(font) + } + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + wrapText = true + borders(this) + fill(this, IndexedColors.DARK_TEAL) + val font = workbook.createFont().apply { + bold = true + color = IndexedColors.WHITE.index + fontHeightInPoints = 10 + } + setFont(font) + } + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + borders(this) + } + val centerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + borders(this) + } + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + borders(this) + dataFormat = numberFormat + } + return ExcelStyles( + title = titleStyle, + subtitle = subtitleStyle, + header = headerStyle, + text = textStyle, + center = centerStyle, + number = numberStyle, + ) + } + + private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { + row.createCell(col).apply { + setCellValue(value?.toString() ?: "") + cellStyle = style + } + } + + private fun setNumberCell(row: Row, col: Int, value: Any?, numberStyle: CellStyle) { + val cell = row.createCell(col) + val n = when (value) { + null -> null + is Number -> value.toDouble() + else -> { + val raw = value.toString().trim() + if (raw.isEmpty() || raw == "-") null else raw.replace(",", "").toDoubleOrNull() + } + } + if (n == null) { + cell.setCellValue("") + cell.cellStyle = numberStyle + return + } + cell.setCellValue(n) + cell.cellStyle = numberStyle + } + + private fun displayFilter(raw: String?): String { + val v = raw?.trim().orEmpty() + if (v.isEmpty() || v.equals("All", ignoreCase = true)) return "全部" + if (v.equals("other", ignoreCase = true)) return "其他" + return v + } + + private fun createExcel( + dbData: List>, + reportDate: String, + itemCode: String?, + storeId: String?, + warehouse: String?, + area: String?, + slot: String?, + lotNo: String?, + stockTakeSectionDescription: String?, + lotOrigin: String?, + ): ByteArray { + val workbook = SXSSFWorkbook(100) + workbook.setCompressTempFiles(true) + try { + val styles = createStyles(workbook) + val reportTitle = "庫存批次現況報告" + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) + val headers = listOf( + "貨品編號", "貨品名稱", "貨品單位結餘", "單位", + "批號", "到期日", + "樓層", "倉庫", "區域", "儲位", + "最後異動日", "最後異動(入庫/出庫)", + "批號結餘", "單位均價", "庫存總價值", + ) + val totalColumns = headers.size + var rowIndex = 0 + + val titleRow = sheet.createRow(rowIndex++) + titleRow.heightInPoints = 24f + titleRow.createCell(0).apply { + setCellValue(reportTitle) + cellStyle = styles.title + } + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + + val reportDateTime = + reportDate + + " (" + + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + + ")" + val dateRow = sheet.createRow(rowIndex++) + dateRow.heightInPoints = 18f + dateRow.createCell(0).apply { + setCellValue("報告日期(現況):$reportDateTime") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1)) + + val criteriaText = listOf( + "貨品編號=${displayFilter(itemCode)}", + "樓層=${displayFilter(storeId)}", + "倉庫=${displayFilter(warehouse)}", + "區域=${displayFilter(area)}", + "儲位=${displayFilter(slot)}", + "盤點區域說明=${displayFilter(stockTakeSectionDescription)}", + "批號=${displayFilter(lotNo)}", + "來源=${displayFilter(lotOrigin)}", + ).joinToString(" ") + val criteriaRow = sheet.createRow(rowIndex++) + criteriaRow.heightInPoints = 32f + criteriaRow.createCell(0).apply { + setCellValue("搜尋條件:$criteriaText") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(2, 2, 0, totalColumns - 1)) + + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headerRow.heightInPoints = 22f + headers.forEachIndexed { i, h -> + headerRow.createCell(i).apply { + setCellValue(h) + cellStyle = styles.header + } + } + + if (dbData.isEmpty()) { + val emptyRowIndex = rowIndex + val r = sheet.createRow(rowIndex++) + r.heightInPoints = 22f + r.createCell(0).apply { + setCellValue("查無資料") + cellStyle = styles.center + } + for (c in 1 until totalColumns) { + r.createCell(c).cellStyle = styles.center + } + sheet.addMergedRegion(CellRangeAddress(emptyRowIndex, emptyRowIndex, 0, totalColumns - 1)) + } else { + dbData.forEach { m -> + val r = sheet.createRow(rowIndex++) + r.heightInPoints = 18f + setTextCell(r, 0, m["itemNo"], styles.text) + setTextCell(r, 1, m["itemName"], styles.text) + setNumberCell(r, 2, m["totalQtyRaw"], styles.number) + setTextCell(r, 3, m["unitOfMeasure"], styles.center) + setTextCell(r, 4, m["lotNo"], styles.text) + setTextCell(r, 5, m["expiryDate"], styles.center) + setTextCell(r, 6, m["storeId"], styles.center) + setTextCell(r, 7, m["warehousePart"], styles.center) + setTextCell(r, 8, m["areaPart"], styles.center) + setTextCell(r, 9, m["slotPart"], styles.center) + setTextCell(r, 10, m["lastTrnDate"], styles.center) + setTextCell(r, 11, m["lastTrnType"], styles.center) + setNumberCell(r, 12, m["lotQtyRaw"], styles.number) + setNumberCell(r, 13, m["avgUnitPriceRaw"], styles.number) + setNumberCell(r, 14, m["stockValueRaw"], styles.number) + } + } + + val lastRowIndex = rowIndex - 1 + if (lastRowIndex >= headerRowIndex) { + sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, totalColumns - 1)) + } + sheet.createFreezePane(0, headerRowIndex + 1) + intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) + .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + + val out = ByteArrayOutputStream() + workbook.write(out) + return out.toByteArray() + } finally { + workbook.dispose() + workbook.close() + } + } +}