Browse Source

excels for fg delivery repot, stockin traceability report, stock ledger report, stock balance report

master
Tommy\2Fi-Staff 15 hours ago
parent
commit
b27aba4639
2 changed files with 988 additions and 0 deletions
  1. +645
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
  2. +343
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt

+ 645
- 0
src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt View File

@@ -2,8 +2,21 @@ package com.ffii.fpsms.modules.report.web

import net.sf.jasperreports.engine.*
import net.sf.jasperreports.engine.data.JRBeanCollectionDataSource
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.util.CellRangeAddress
import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.http.*
import org.springframework.web.bind.annotation.*
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.time.LocalDate
import java.time.LocalTime
@@ -16,6 +29,20 @@ import com.ffii.fpsms.modules.report.service.ReportService
class ReportController(
private val reportService: ReportService,
) {
private data class ExcelStyles(
val title: XSSFCellStyle,
val subtitle: XSSFCellStyle,
val header: XSSFCellStyle,
val text: XSSFCellStyle,
val center: XSSFCellStyle,
val number: XSSFCellStyle,
val int: XSSFCellStyle,
val dash: XSSFCellStyle,
val sumQty: XSSFCellStyle,
val sumLabel: XSSFCellStyle,
val sumEmpty: XSSFCellStyle,
val sumHidden: XSSFCellStyle,
)

@GetMapping("/stock-take-rounds")
fun getStockTakeRounds(): List<Map<String, Any>> =
@@ -123,6 +150,36 @@ class ReportController(

return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}

@GetMapping("/print-stock-in-traceability-excel")
fun exportStockInTraceabilityReportExcel(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) lastInDateStart: String?,
@RequestParam(required = false) lastInDateEnd: String?,
): ResponseEntity<ByteArray> {
val dbData = reportService.searchStockInTraceabilityReport(
stockCategory,
itemCode,
lastInDateStart,
lastInDateEnd,
)

val excelBytes = createStockInTraceabilityExcel(
dbData = dbData,
lastInDateStart = lastInDateStart ?: "",
lastInDateEnd = lastInDateEnd ?: "",
)

val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
setContentDispositionFormData("attachment", "StockInTraceabilityReport.xlsx")
set("filename", "StockInTraceabilityReport.xlsx")
}
return ResponseEntity(excelBytes, headers, HttpStatus.OK)
}
@GetMapping("/print-fg-delivery-report")
fun generateFGDeliveryReport(
@RequestParam(required = false) stockCategory: String?,
@@ -169,6 +226,41 @@ class ReportController(
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}

@GetMapping("/print-fg-delivery-report-excel")
fun exportFGDeliveryReportExcel(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) stockSubCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) year: String?,
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?,
): ResponseEntity<ByteArray> {
val dbData = reportService.searchFGDeliveryReport(
stockCategory,
stockSubCategory,
itemCode,
year,
lastOutDateStart,
lastOutDateEnd,
)

val excelBytes = createFGDeliveryExcel(
dbData = dbData,
year = year ?: LocalDate.now().year.toString(),
lastOutDateStart = lastOutDateStart ?: "",
lastOutDateEnd = lastOutDateEnd ?: "",
)

val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
setContentDispositionFormData("attachment", "FGDeliveryReport.xlsx")
set("filename", "FGDeliveryReport.xlsx")
}
return ResponseEntity(excelBytes, headers, HttpStatus.OK)
}
@GetMapping("/print-stock-balance")
fun generateStockBalanceReport(
@RequestParam(required = false) stockCategory: String?,
@@ -236,6 +328,559 @@ class ReportController(
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}

@GetMapping("/print-stock-balance-excel")
fun exportStockBalanceReportExcel(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) stockDate: String?,
@RequestParam(required = false) balanceFilterStart: String?,
@RequestParam(required = false) balanceFilterEnd: String?,
@RequestParam(required = false) storeLocation: String?,
@RequestParam(required = false) lastInDateStart: String?,
@RequestParam(required = false) lastInDateEnd: String?,
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?,
@RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long,
): ResponseEntity<ByteArray> {
val dbData =
if (!stockDate.isNullOrBlank()) {
reportService.searchStockBalanceReportByDate(
stockCategory = stockCategory,
itemCode = itemCode,
stockDate = stockDate,
balanceFilterStart = balanceFilterStart,
balanceFilterEnd = balanceFilterEnd,
storeLocation = storeLocation,
)
} else {
reportService.searchStockBalanceReport(
stockCategory,
itemCode,
balanceFilterStart,
balanceFilterEnd,
storeLocation,
lastInDateStart,
lastInDateEnd,
lastOutDateStart,
lastOutDateEnd,
stockTakeRoundId
)
}

val excelBytes = createStockBalanceExcel(
dbData = dbData,
reportDate = (stockDate ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))),
)

val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
setContentDispositionFormData("attachment", "StockBalanceReport.xlsx")
set("filename", "StockBalanceReport.xlsx")
}
return ResponseEntity(excelBytes, headers, HttpStatus.OK)
}

private fun createCommonWorkbookStyles(workbook: XSSFWorkbook): ExcelStyles {
val titleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
bold = true
fontHeightInPoints = 14
}
setFont(font)
}
val subtitleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
bold = true
fontHeightInPoints = 12
}
setFont(font)
}
val headerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val textStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val centerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val numberStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0.00")
}
val intStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
}
val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val summaryQtyThickBottomStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0.00")
borderTop = BorderStyle.THICK
borderBottom = BorderStyle.THICK
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val summaryLabelThickBottomStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THICK
borderBottom = BorderStyle.THICK
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val summaryEmptyThickBottomStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THICK
borderBottom = BorderStyle.THICK
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val summaryHiddenKeyStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THICK
borderBottom = BorderStyle.THICK
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val font = workbook.createFont().apply { color = IndexedColors.WHITE.index }
setFont(font)
}
return ExcelStyles(
title = titleStyle,
subtitle = subtitleStyle,
header = headerStyle,
text = textStyle,
center = centerStyle,
number = numberStyle,
int = intStyle,
dash = dashStyle,
sumQty = summaryQtyThickBottomStyle,
sumLabel = summaryLabelThickBottomStyle,
sumEmpty = summaryEmptyThickBottomStyle,
sumHidden = summaryHiddenKeyStyle,
)
}

private fun setTextCell(row: Row, col: Int, value: Any?, style: XSSFCellStyle) {
row.createCell(col).apply {
setCellValue(value?.toString() ?: "")
cellStyle = style
}
}

private fun parseSignedNumber(value: Any?): Double? {
val raw = value?.toString()?.trim().orEmpty()
if (raw.isBlank() || raw == "-" || raw.equals("null", ignoreCase = true)) return null
val negative = raw.startsWith("(") && raw.endsWith(")")
val cleaned = raw.removePrefix("(").removeSuffix(")").replace(",", "").replace("%", "").trim()
val n = cleaned.toDoubleOrNull() ?: return null
return if (negative) -n else n
}

private fun setNumberCellFromFormatted(
row: Row,
col: Int,
value: Any?,
numberStyle: XSSFCellStyle,
dashStyle: XSSFCellStyle,
preferInt: Boolean = false,
) {
val cell = row.createCell(col)
val parsed = parseSignedNumber(value)
if (parsed == null) {
cell.setCellValue("-")
cell.cellStyle = dashStyle
return
}
if (parsed < 0) {
val abs = kotlin.math.abs(parsed)
val s = if (preferInt) "%,d".format(abs.toLong()) else "%,.2f".format(abs)
cell.setCellValue("($s)")
cell.cellStyle = dashStyle
return
}
cell.setCellValue(parsed)
cell.cellStyle = numberStyle
}

private fun createStockInTraceabilityExcel(
dbData: List<Map<String, Any>>,
lastInDateStart: String,
lastInDateEnd: String,
): ByteArray {
val workbook = XSSFWorkbook()
val styles = createCommonWorkbookStyles(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.createCell(0).apply {
setCellValue(reportTitle)
cellStyle = styles.title
}
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))

val reportDateTime =
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) +
"(" +
LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) +
")"
val subtitleRow = sheet.createRow(rowIndex++)
subtitleRow.createCell(0).apply {
setCellValue("報告日期:$reportDateTime")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5))
subtitleRow.createCell(6).apply {
val cap = (lastInDateStart.trim().ifBlank { "" }) + " 至 " + (lastInDateEnd.trim().ifBlank { "" })
setCellValue("最後入倉日期:${cap.trim()}")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 6, totalColumns - 1))
sheet.createRow(rowIndex++)

val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headers.forEachIndexed { i, h ->
headerRow.createCell(i).apply {
setCellValue(h)
cellStyle = styles.header
}
}

fun addItemSummaryRow(
itemNo: String,
itemName: String,
uom: String,
totalStockIn: Any?,
_totalSample: Any?,
_totalDefect: Any?,
) {
val r = sheet.createRow(rowIndex++)
r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden }
r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden }
r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden }
for (c in 3 until totalColumns) {
r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty }
}
// Put totals in the three adjacent qty columns to avoid displacing later columns:
// - 上架數量 (col 6)
// - 入庫檢查樣品數量 (col 7)
// - 入庫檢查缺陷數量 (col 8)
r.getCell(5).apply { setCellValue("合計:"); cellStyle = styles.sumLabel }
setNumberCellFromFormatted(r, 6, totalStockIn, styles.sumQty, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 7, _totalSample, styles.sumQty, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 8, _totalDefect, styles.sumQty, styles.dash, preferInt = false)
}

if (dbData.isEmpty()) {
val r = sheet.createRow(rowIndex++)
for (c in 0 until totalColumns) {
r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text }
}
} else {
var currentItemNo: String? = null
var currentItemName = ""
var currentUom = ""
var lastTotals: Triple<Any?, Any?, Any?> = Triple(null, null, null)

dbData.forEach { m ->
val itemNo = m["itemNo"]?.toString().orEmpty()
val itemName = m["itemName"]?.toString().orEmpty()
val uom = m["unitOfMeasure"]?.toString().orEmpty()

if (currentItemNo != null && itemNo != currentItemNo) {
addItemSummaryRow(
currentItemNo!!,
currentItemName,
currentUom,
lastTotals.first,
lastTotals.second,
lastTotals.third,
)
sheet.createRow(rowIndex++)
}

val r = sheet.createRow(rowIndex++)
setTextCell(r, 0, itemNo, styles.text)
setTextCell(r, 1, itemName, styles.text)
setTextCell(r, 2, uom, styles.center)
setTextCell(r, 3, m["dnNo"], styles.text)
setTextCell(r, 4, m["lotNo"], styles.text)
setTextCell(r, 5, m["expiryDate"], styles.center)
setNumberCellFromFormatted(r, 6, m["stockInQty"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 7, m["iqcSampleQty"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 8, m["iqcDefectQty"], styles.number, styles.dash, preferInt = false)
// percentage as text with %
val pct = (m["iqcDefectPercentage"]?.toString()?.trim().orEmpty()).let { if (it.isBlank()) "" else "$it%" }
setTextCell(r, 9, pct, styles.dash)
setTextCell(r, 10, m["iqcResult"], styles.center)
setTextCell(r, 11, m["iqcRemarks"], styles.text)
setTextCell(r, 12, m["storeLocation"], styles.center)
setTextCell(r, 13, m["supplierName"], styles.text)

currentItemNo = itemNo
currentItemName = itemName
currentUom = uom
lastTotals = Triple(m["totalStockInQty"], m["totalIqcSampleQty"], m["totalIqcDefectQty"])
}

addItemSummaryRow(
currentItemNo ?: "",
currentItemName,
currentUom,
lastTotals.first,
lastTotals.second,
lastTotals.third,
)
}

val lastRowIndex = rowIndex - 1
if (lastRowIndex >= headerRowIndex) {
sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0))
}

val widths = intArrayOf(14, 26, 10, 18, 16, 12, 14, 18, 18, 18, 14, 22, 14, 20)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val out = ByteArrayOutputStream()
workbook.use { it.write(out) }
return out.toByteArray()
}

private fun createFGDeliveryExcel(
dbData: List<Map<String, Any>>,
year: String,
lastOutDateStart: String,
lastOutDateEnd: String,
): ByteArray {
val workbook = XSSFWorkbook()
val styles = createCommonWorkbookStyles(workbook)
val reportTitle = "成品出倉報告"
val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle))

val headers = listOf(
"貨品編號", "貨品名稱", "單位",
"送貨日期", "DN編號", "送貨訂單編號", "門店名稱", "數量", "貨車",
)
val totalColumns = headers.size
var rowIndex = 0

val titleRow = sheet.createRow(rowIndex++)
titleRow.createCell(0).apply {
setCellValue(reportTitle)
cellStyle = styles.title
}
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))

val reportDateTime =
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) +
"(" +
LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) +
")"
val subtitleRow = sheet.createRow(rowIndex++)
subtitleRow.createCell(0).apply {
setCellValue("報告日期:$reportDateTime")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 2))
subtitleRow.createCell(3).apply {
setCellValue("成品出倉日期:${lastOutDateStart.trim()} 至 ${lastOutDateEnd.trim()}")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 3, 6))
subtitleRow.createCell(7).apply {
setCellValue("年份:$year")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 7, totalColumns - 1))
sheet.createRow(rowIndex++)

val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headers.forEachIndexed { i, h ->
headerRow.createCell(i).apply {
setCellValue(h)
cellStyle = styles.header
}
}

if (dbData.isEmpty()) {
val r = sheet.createRow(rowIndex++)
for (c in 0 until totalColumns) {
r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text }
}
} else {
dbData.forEach { m ->
val r = sheet.createRow(rowIndex++)
setTextCell(r, 0, m["itemNo"], styles.text)
setTextCell(r, 1, m["itemName"], styles.text)
setTextCell(r, 2, m["unitOfMeasure"], styles.center)
setTextCell(r, 3, m["deliveryDate"], styles.center)
setTextCell(r, 4, m["dnNo"], styles.text)
setTextCell(r, 5, m["deliveryOrderNo"], styles.text)
setTextCell(r, 6, m["customerName"], styles.text)
setNumberCellFromFormatted(r, 7, m["qty"], styles.int, styles.dash, preferInt = true)
setTextCell(r, 8, m["truckNo"], styles.center)
}
}

val lastRowIndex = rowIndex - 1
if (lastRowIndex >= headerRowIndex) {
sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0))
}
val widths = intArrayOf(14, 26, 10, 12, 18, 18, 26, 10, 12)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val out = ByteArrayOutputStream()
workbook.use { it.write(out) }
return out.toByteArray()
}

private fun createStockBalanceExcel(
dbData: List<Map<String, Any>>,
reportDate: String,
): ByteArray {
val workbook = XSSFWorkbook()
val styles = createCommonWorkbookStyles(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.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 subtitleRow = sheet.createRow(rowIndex++)
subtitleRow.createCell(0).apply {
setCellValue("報告日期:$reportDateTime")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1))
sheet.createRow(rowIndex++)

val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headers.forEachIndexed { i, h ->
headerRow.createCell(i).apply {
setCellValue(h)
cellStyle = styles.header
}
}

if (dbData.isEmpty()) {
val r = sheet.createRow(rowIndex++)
for (c in 0 until totalColumns) {
r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text }
}
} else {
dbData.forEach { m ->
val r = sheet.createRow(rowIndex++)
setTextCell(r, 0, m["itemNo"], styles.text)
setTextCell(r, 1, m["itemName"], styles.text)
setTextCell(r, 2, m["unitOfMeasure"], styles.center)
setNumberCellFromFormatted(r, 3, m["totalOpeningBalance"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 4, m["totalCumStockIn"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 5, m["totalCumStockOut"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 6, m["totalMisInputAndLost"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false)
}
}

val lastRowIndex = rowIndex - 1
if (lastRowIndex >= headerRowIndex) {
sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0))
}
val widths = intArrayOf(14, 26, 10, 12, 12, 12, 14, 12, 12, 12, 12, 14)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val out = ByteArrayOutputStream()
workbook.use { it.write(out) }
return out.toByteArray()
}

/**
* GRN (Goods Received Note) report data for Excel export.
* Query by receipt date range and optional item code. Returns JSON { "rows": [ ... ] }.


+ 343
- 0
src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt View File

@@ -6,6 +6,19 @@ import java.time.LocalTime
import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.report.service.StockLedgerReportService
import com.ffii.fpsms.modules.report.service.ReportService
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.CellStyle
import org.apache.poi.ss.usermodel.DataFormat
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.util.CellRangeAddress
import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.ByteArrayOutputStream

@RestController
@RequestMapping("/report")
@@ -13,6 +26,19 @@ class StockLedgerReportController(
private val reportService: ReportService,
private val stockLedgerReportService: StockLedgerReportService,
) {
private data class ExcelStyles(
val title: XSSFCellStyle,
val subtitle: XSSFCellStyle,
val header: XSSFCellStyle,
val text: XSSFCellStyle,
val center: XSSFCellStyle,
val int: XSSFCellStyle,
val dash: XSSFCellStyle,
val sumQty: XSSFCellStyle,
val sumLabel: XSSFCellStyle,
val sumEmpty: XSSFCellStyle,
val sumHidden: XSSFCellStyle,
)

@GetMapping("/print-stock-ledger")
fun generateStockLedgerReport(
@@ -60,4 +86,321 @@ fun generateStockLedgerReport(
}
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}

@GetMapping("/print-stock-ledger-excel")
fun exportStockLedgerReportExcel(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) storeLocation: String?,
@RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?,
@RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?,
): ResponseEntity<ByteArray> {
val dbData = stockLedgerReportService.searchStockLedgerReport(
stockCategory = stockCategory,
itemCode = itemCode,
storeLocation = storeLocation,
reportPeriodStart = reportPeriodStart,
reportPeriodEnd = reportPeriodEnd,
)

val excelBytes = createStockLedgerExcel(
dbData = dbData,
reportPeriodStart = reportPeriodStart ?: "",
reportPeriodEnd = reportPeriodEnd ?: "",
)

val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
setContentDispositionFormData("attachment", "StockLedgerReport.xlsx")
set("filename", "StockLedgerReport.xlsx")
}
return ResponseEntity(excelBytes, headers, HttpStatus.OK)
}

private fun createStyles(workbook: XSSFWorkbook): ExcelStyles {
val titleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
bold = true
fontHeightInPoints = 14
}
setFont(font)
}
val subtitleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
bold = true
fontHeightInPoints = 12
}
setFont(font)
}
val headerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val textStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val centerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val intStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
}
val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val sumQty = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
borderTop = BorderStyle.THICK
borderBottom = BorderStyle.THICK
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val sumLabel = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THICK
borderBottom = BorderStyle.THICK
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val sumEmpty = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THICK
borderBottom = BorderStyle.THICK
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val sumHidden = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THICK
borderBottom = BorderStyle.THICK
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val font = workbook.createFont().apply { color = IndexedColors.WHITE.index }
setFont(font)
}
return ExcelStyles(
title = titleStyle,
subtitle = subtitleStyle,
header = headerStyle,
text = textStyle,
center = centerStyle,
int = intStyle,
dash = dashStyle,
sumQty = sumQty,
sumLabel = sumLabel,
sumEmpty = sumEmpty,
sumHidden = sumHidden,
)
}

private fun setTextCell(row: Row, col: Int, value: Any?, style: XSSFCellStyle) {
row.createCell(col).apply {
setCellValue(value?.toString() ?: "")
cellStyle = style
}
}

private fun parseSignedNumber(value: Any?): Double? {
val raw = value?.toString()?.trim().orEmpty()
if (raw.isBlank() || raw == "-" || raw.equals("null", ignoreCase = true)) return null
val negative = raw.startsWith("(") && raw.endsWith(")")
val cleaned = raw.removePrefix("(").removeSuffix(")").replace(",", "").trim()
val n = cleaned.toDoubleOrNull() ?: return null
return if (negative) -n else n
}

private fun setIntCellFromFormatted(
row: Row,
col: Int,
value: Any?,
intStyle: XSSFCellStyle,
dashStyle: XSSFCellStyle,
) {
val cell = row.createCell(col)
val parsed = parseSignedNumber(value)
if (parsed == null) {
cell.setCellValue("")
cell.cellStyle = dashStyle
return
}
if (parsed < 0) {
val s = "%,d".format(kotlin.math.abs(parsed).toLong())
cell.setCellValue("($s)")
cell.cellStyle = dashStyle
} else {
cell.setCellValue(parsed)
cell.cellStyle = intStyle
}
}

private fun createStockLedgerExcel(
dbData: List<Map<String, Any>>,
reportPeriodStart: String,
reportPeriodEnd: String,
): ByteArray {
val workbook = XSSFWorkbook()
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.createCell(0).apply {
setCellValue(reportTitle)
cellStyle = styles.title
}
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))

val reportDateTime =
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) +
"(" +
LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) +
")"
val subtitleRow = sheet.createRow(rowIndex++)
subtitleRow.createCell(0).apply {
setCellValue("報告日期:$reportDateTime")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4))
subtitleRow.createCell(5).apply {
setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1))
sheet.createRow(rowIndex++)

val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headers.forEachIndexed { i, h ->
headerRow.createCell(i).apply {
setCellValue(h)
cellStyle = styles.header
}
}

fun addItemSummaryRow(itemNo: String, itemName: String, uom: String, totalIn: Any?, totalOut: Any?, totalBal: Any?) {
val r = sheet.createRow(rowIndex++)
r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden }
r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden }
r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden }
for (c in 3 until totalColumns) {
r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty }
}
// totals should align with numeric columns (shift right by 1)
r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel }
setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash)
setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash)
setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash)
}

if (dbData.isEmpty()) {
val r = sheet.createRow(rowIndex++)
for (c in 0 until totalColumns) {
r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text }
}
} else {
var currentItemNo: String? = null
var currentItemName = ""
var currentUom = ""
var lastTotals: Triple<Any?, Any?, Any?> = Triple(null, null, null)

dbData.forEach { m ->
val itemNo = m["itemNo"]?.toString().orEmpty()
val itemName = m["itemName"]?.toString().orEmpty()
val uom = m["unitOfMeasure"]?.toString().orEmpty()

if (currentItemNo != null && itemNo != currentItemNo) {
addItemSummaryRow(currentItemNo!!, currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third)
sheet.createRow(rowIndex++)
}

val r = sheet.createRow(rowIndex++)
setTextCell(r, 0, itemNo, styles.text)
setTextCell(r, 1, itemName, styles.text)
setTextCell(r, 2, uom, styles.center)
setTextCell(r, 3, m["trnDate"], styles.center)
setTextCell(r, 4, m["trnRefNo"], styles.center)
setTextCell(r, 5, m["lotNo"], styles.text)
setTextCell(r, 6, m["expiryDate"], styles.center)
setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash)
setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash)
setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash)
setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash)
setTextCell(r, 11, m["orderRefNo"], styles.text)
setTextCell(r, 12, m["storeLocation"], styles.center)

currentItemNo = itemNo
currentItemName = itemName
currentUom = uom
lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"])
}

addItemSummaryRow(currentItemNo ?: "", currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third)
}

val lastRowIndex = rowIndex - 1
if (lastRowIndex >= headerRowIndex) {
sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0))
}
val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val out = ByteArrayOutputStream()
workbook.use { it.write(out) }
return out.toByteArray()
}
}

Loading…
Cancel
Save