Przeglądaj źródła

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 <[email protected]>
fix負數倉
CANCERYS\kw093 2 tygodni temu
rodzic
commit
71477649a5
2 zmienionych plików z 740 dodań i 0 usunięć
  1. +419
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt
  2. +321
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt

+ 419
- 0
src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt Wyświetl plik

@@ -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<Map<String, Any>>,
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<String, Any>()
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<Long, Pair<String, String>>(lotIds.size * 2)
loadLastTrnFromLedger(lotIds, lastTrnByLot)
val missing = lotIds.distinct().filter { it !in lastTrnByLot }
if (missing.isNotEmpty()) {
val silSolHits = HashMap<Long, SilSolHit>(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<Long>, out: MutableMap<Long, Pair<String, String>>) {
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<Long>, hits: MutableMap<Long, SilSolHit>): 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<Long>, hits: MutableMap<Long, SilSolHit>): 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<Long>, hits: MutableMap<Long, SilSolHit>): 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<Map<String, Any>>, hits: MutableMap<Long, SilSolHit>, 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<Map<String, Any>>,
lastTrnByLot: Map<Long, Pair<String, String>>,
): List<Map<String, Any>> {
val tot = HashMap<String, Double>()
for (r in liveLots) {
val key = "${r["itemNo"]}|${r["unitOfMeasure"]}"
tot[key] = (tot[key] ?: 0.0) + toDouble(r["lotQtyRaw"])
}
val out = ArrayList<Map<String, Any>>(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<String, Any>(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<Map<String, Any>> { 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, Any>,
): 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 ")})"
}
}

+ 321
- 0
src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt Wyświetl plik

@@ -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<ByteArray> {
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<ByteArray>(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<Map<String, Any>>,
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()
}
}
}

Ładowanie…
Anuluj
Zapisz