diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index 4060d63..4e45548 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -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> = @@ -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 { + 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 { + 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 { + 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>, + 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 = 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>, + 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>, + 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": [ ... ] }. diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt index 27ea738..bb43295 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt @@ -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 { + 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>, + 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 = 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() + } } \ No newline at end of file