From e985eb91eb3a41856b8ef0e1a6311b35a34f1e29 Mon Sep 17 00:00:00 2001 From: "B.E.N.S.O.N" Date: Fri, 20 Mar 2026 12:18:53 +0800 Subject: [PATCH] SemiFGProductionAnalysisReport Excel Version --- .gitignore | 1 + .../SemiFGProductionAnalysisReportService.kt | 2 +- ...emiFGProductionAnalysisReportController.kt | 390 +++++++++++++++++- 3 files changed, 389 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index c2065bc..ffae7df 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ out/ ### VS Code ### .vscode/ +package-lock.json diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt index 088452d..8cfb8fc 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt @@ -99,7 +99,7 @@ class SemiFGProductionAnalysisReportService( // Filter by itemCode - match bom.code (user input should match bom.code, which then matches stock_ledger.itemCode) val itemCodeSql = buildMultiValueExactClause(itemCode, "b.code", "itemCode", args) - val yearSql = if (!year.isNullOrBlank()) { + val yearSql = if (!year.isNullOrBlank() && year != "All") { args["year"] = year "AND YEAR(sl.modified) = :year" } else { diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt index 9832d31..31475a5 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt @@ -3,11 +3,33 @@ package com.ffii.fpsms.modules.report.web import net.sf.jasperreports.engine.* import org.springframework.http.* import org.springframework.web.bind.annotation.* +import java.io.ByteArrayOutputStream import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter import com.ffii.fpsms.modules.report.service.SemiFGProductionAnalysisReportService import com.ffii.fpsms.modules.report.service.ReportService +import org.apache.poi.ss.usermodel.BorderStyle +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.util.WorkbookUtil +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.DataValidationConstraint +import org.apache.poi.ss.usermodel.DataValidationHelper +import org.apache.poi.ss.usermodel.SheetVisibility +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.CellRangeAddressList +import org.apache.poi.ss.util.CellReference +import org.apache.poi.xddf.usermodel.chart.AxisPosition +import org.apache.poi.xddf.usermodel.chart.BarDirection +import org.apache.poi.xddf.usermodel.chart.ChartTypes +import org.apache.poi.xddf.usermodel.chart.LegendPosition +import org.apache.poi.xddf.usermodel.chart.XDDFBarChartData +import org.apache.poi.xddf.usermodel.chart.XDDFDataSourcesFactory +import org.apache.poi.xssf.usermodel.XSSFClientAnchor +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import kotlin.math.roundToLong @RestController @RequestMapping("/report") @@ -50,20 +72,382 @@ class SemiFGProductionAnalysisReportController( ) val pdfBytes = reportService.createPdfResponse( - "/jasper/SemiFGProductionAnalysisReport.jrxml", - parameters, + "/jasper/SemiFGProductionAnalysisReport.jrxml", + parameters, dbData ) val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_PDF setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport.pdf") - set("filename", "SemiFGProductionAnalysisReport.pdf") + set("filename", "SemiFGProductionAnalysisReport.pdf") } return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } + @GetMapping("/print-semi-fg-production-analysis-excel") + fun exportSemiFGProductionAnalysisReportExcel( + @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 = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisReport( + stockCategory, + stockSubCategory, + itemCode, + year, + lastOutDateStart, + lastOutDateEnd + ) + + val excelBytes = createSemiFGProductionAnalysisExcel( + dbData = dbData, + reportTitle = "成品/半成品生產分析報告", + year = year + ) + + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport.xlsx") + set("filename", "SemiFGProductionAnalysisReport.xlsx") + } + + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun createSemiFGProductionAnalysisExcel( + dbData: List>, + reportTitle: String, + year: String? + ): ByteArray { + val workbook = XSSFWorkbook() + val safeSheetName = WorkbookUtil.createSafeSheetName(reportTitle) + val sheet = workbook.createSheet(safeSheetName) + val totalColumns = 16 + var rowIndex = 0 + + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + } + val titleFont = workbook.createFont().apply { + bold = true + fontHeightInPoints = 16 + } + titleStyle.setFont(titleFont) + + val infoStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + } + + val headerStyle = workbook.createCellStyle().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 headerFont = workbook.createFont().apply { bold = true } + headerStyle.setFont(headerFont) + + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") + } + + val dashStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val titleRow = sheet.createRow(rowIndex++) + titleRow.heightInPoints = 28f + val titleCell = titleRow.createCell(0) + titleCell.setCellValue(reportTitle) + titleCell.cellStyle = titleStyle + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + + val infoRow = sheet.createRow(rowIndex++) + val reportDateCell = infoRow.createCell(0) + reportDateCell.setCellValue("報告日期:${LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))}") + reportDateCell.cellStyle = infoStyle + val reportTimeCell = infoRow.createCell(6) + reportTimeCell.setCellValue("報告時間:${LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))}") + reportTimeCell.cellStyle = infoStyle + val reportYearCell = infoRow.createCell(11) + reportYearCell.setCellValue("年份:${year?.takeIf { it.isNotBlank() && it != "All" } ?: "All"}") + reportYearCell.cellStyle = infoStyle + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5)) + sheet.addMergedRegion(CellRangeAddress(1, 1, 6, 10)) + sheet.addMergedRegion(CellRangeAddress(1, 1, 11, 15)) + + rowIndex++ // spacer row + + val headers = listOf( + "貨品編號", + "貨品名稱", + "單位", + "一月", + "二月", + "三月", + "四月", + "五月", + "六月", + "七月", + "八月", + "九月", + "十月", + "十一月", + "十二月", + "總和" + ) + + val headerRow = sheet.createRow(rowIndex++) + headers.forEachIndexed { index, name -> + val cell = headerRow.createCell(index) + cell.setCellValue(name) + cell.cellStyle = headerStyle + } + + if (dbData.isEmpty()) { + val dataRow = sheet.createRow(rowIndex++) + setTextCell(dataRow, 0, "-", textStyle) + setTextCell(dataRow, 1, "-", textStyle) + setTextCell(dataRow, 2, "-", textStyle) + for (col in 3 until totalColumns) { + setDashCell(dataRow, col, dashStyle) + } + } else { + dbData.forEach { row -> + val dataRow = sheet.createRow(rowIndex++) + setTextCell(dataRow, 0, row["itemNo"]?.toString()?.ifBlank { "-" } ?: "-", textStyle) + setTextCell(dataRow, 1, row["itemName"]?.toString()?.ifBlank { "-" } ?: "-", textStyle) + setTextCell(dataRow, 2, row["unitOfMeasure"]?.toString()?.ifBlank { "-" } ?: "-", textStyle) + setNumberCell(dataRow, 3, row["qtyJan"], numberStyle, dashStyle) + setNumberCell(dataRow, 4, row["qtyFeb"], numberStyle, dashStyle) + setNumberCell(dataRow, 5, row["qtyMar"], numberStyle, dashStyle) + setNumberCell(dataRow, 6, row["qtyApr"], numberStyle, dashStyle) + setNumberCell(dataRow, 7, row["qtyMay"], numberStyle, dashStyle) + setNumberCell(dataRow, 8, row["qtyJun"], numberStyle, dashStyle) + setNumberCell(dataRow, 9, row["qtyJul"], numberStyle, dashStyle) + setNumberCell(dataRow, 10, row["qtyAug"], numberStyle, dashStyle) + setNumberCell(dataRow, 11, row["qtySep"], numberStyle, dashStyle) + setNumberCell(dataRow, 12, row["qtyOct"], numberStyle, dashStyle) + setNumberCell(dataRow, 13, row["qtyNov"], numberStyle, dashStyle) + setNumberCell(dataRow, 14, row["qtyDec"], numberStyle, dashStyle) + setNumberCell(dataRow, 15, row["totalProductionQty"], numberStyle, dashStyle) + } + } + + val widths = intArrayOf(18, 24, 12, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 12) + widths.forEachIndexed { idx, width -> sheet.setColumnWidth(idx, width * 256) } + + addSemiFGProductionChartSheets(workbook, dbData) + + val output = ByteArrayOutputStream() + workbook.use { it.write(output) } + return output.toByteArray() + } + + /** + * Adds a visible chart sheet with: + * - Cell B1 dropdown to choose 貨品編號 or 「全部」(sum of all items) + * - Formulas that pull monthly qty from a very-hidden data sheet + * - Column bar chart bound to those formula cells (updates when selection changes) + */ + private fun addSemiFGProductionChartSheets(workbook: XSSFWorkbook, dbData: List>) { + val monthKeys = listOf( + "qtyJan", "qtyFeb", "qtyMar", "qtyApr", "qtyMay", "qtyJun", + "qtyJul", "qtyAug", "qtySep", "qtyOct", "qtyNov", "qtyDec", + ) + val monthLabels = listOf( + "一月", "二月", "三月", "四月", "五月", "六月", + "七月", "八月", "九月", "十月", "十一月", "十二月", + ) + + val dataSheetName = WorkbookUtil.createSafeSheetName("圖表數據") + val dataSheet = workbook.createSheet(dataSheetName) + + val headerRow = dataSheet.createRow(0) + headerRow.createCell(0).setCellValue("貨品編號") + monthLabels.forEachIndexed { i, label -> + headerRow.createCell(1 + i).setCellValue(label) + } + + dbData.forEachIndexed { idx, row -> + val r = dataSheet.createRow(1 + idx) + r.createCell(0).setCellValue(row["itemNo"]?.toString() ?: "") + monthKeys.forEachIndexed { mi, key -> + r.createCell(1 + mi).setCellValue(parseNumericForExcelChart(row[key])) + } + } + + val zColIndex = CellReference.convertColStringToIndex("Z") + val zHeader = dataSheet.getRow(0) ?: dataSheet.createRow(0) + zHeader.createCell(zColIndex).setCellValue("全部") + dbData.forEachIndexed { idx, row -> + val r = dataSheet.getRow(1 + idx) ?: dataSheet.createRow(1 + idx) + r.createCell(zColIndex).setCellValue(row["itemNo"]?.toString() ?: "") + } + + val lastDataRow1Based = 1 + dbData.size + val zListEnd1Based = 1 + dbData.size + + val chartSheetName = WorkbookUtil.createSafeSheetName("生產圖表") + val chartSheet = workbook.createSheet(chartSheetName) + + val labelR = chartSheet.createRow(0) + labelR.createCell(0).setCellValue("選擇貨品編號") + val filterCell = labelR.createCell(1) + val defaultItemNo = dbData.firstOrNull()?.get("itemNo")?.toString()?.trim() + filterCell.setCellValue(defaultItemNo?.takeIf { it.isNotBlank() } ?: "全部") + + val dvHelper: DataValidationHelper = chartSheet.dataValidationHelper + val listFormula = "'$dataSheetName'!\$Z\$1:\$Z\$$zListEnd1Based" + val constraint: DataValidationConstraint = dvHelper.createFormulaListConstraint(listFormula) + val regions = CellRangeAddressList(0, 0, 1, 1) + val validation = dvHelper.createValidation(constraint, regions) + validation.showErrorBox = true + validation.createErrorBox("無效選擇", "請從清單中選擇貨品編號或「全部」。") + validation.showPromptBox = true + validation.createPromptBox("篩選", "選擇貨品編號檢視該品項各月數量;選擇「全部」顯示所有品項加總。") + chartSheet.addValidationData(validation) + + val catRowIndex = 3 + val valRowIndex = 4 + val firstMonthCol = 1 + val lastMonthCol = 12 + + val catRow = chartSheet.createRow(catRowIndex) + val valRow = chartSheet.createRow(valRowIndex) + for (i in 0 until 12) { + catRow.createCell(firstMonthCol + i).setCellValue(monthLabels[i]) + val colLetter = CellReference.convertNumToColString(firstMonthCol + i) + val formula = if (lastDataRow1Based < 2) { + "0" + } else { + "IF(TRIM(\$B\$1)=\"全部\",SUM('$dataSheetName'!$colLetter\$2:$colLetter\$$lastDataRow1Based),IFERROR(INDEX('$dataSheetName'!$colLetter\$2:$colLetter\$$lastDataRow1Based,MATCH(TRIM(\$B\$1),'$dataSheetName'!\$A\$2:\$A\$$lastDataRow1Based,0)),0))" + } + valRow.createCell(firstMonthCol + i).setCellFormula(formula) + } + catRow.createCell(lastMonthCol + 1).setCellValue("總和") + valRow.createCell(lastMonthCol + 1).setCellFormula("SUM(B5:M5)") + + val drawing = chartSheet.createDrawingPatriarch() + val anchor = XSSFClientAnchor(0, 0, 0, 0, 1, 6, 14, 28) + val chart = drawing.createChart(anchor) + chart.setTitleText("各月生產數量(依選擇之貨品編號)") + chart.setTitleOverlay(false) + + val legend = chart.getOrAddLegend() + legend.position = LegendPosition.BOTTOM + + val bottomAxis = chart.createCategoryAxis(AxisPosition.BOTTOM) + bottomAxis.setTitle("月份") + val leftAxis = chart.createValueAxis(AxisPosition.LEFT) + leftAxis.setTitle("生產數量") + + val catRangeAddr = CellRangeAddress(catRowIndex, catRowIndex, firstMonthCol, lastMonthCol) + val valRangeAddr = CellRangeAddress(valRowIndex, valRowIndex, firstMonthCol, lastMonthCol) + val categories = XDDFDataSourcesFactory.fromStringCellRange(chartSheet, catRangeAddr) + val values = XDDFDataSourcesFactory.fromNumericCellRange(chartSheet, valRangeAddr) + + val barData = chart.createData(ChartTypes.BAR, bottomAxis, leftAxis) as XDDFBarChartData + barData.barDirection = BarDirection.COL + val series = barData.addSeries(categories, values) + series.setTitle("生產數量", null) + chart.plot(barData) + + chartSheet.setColumnWidth(0, 18 * 256) + chartSheet.setColumnWidth(1, 10 * 256) + chartSheet.setColumnWidth(lastMonthCol + 1, 12 * 256) + + workbook.setSheetVisibility(workbook.getSheetIndex(dataSheet), SheetVisibility.VERY_HIDDEN) + workbook.setSheetOrder(chartSheetName, 1) + } + + private fun parseNumericForExcelChart(value: Any?): Double { + if (value == null) return 0.0 + return when (value) { + is Number -> value.toDouble() + is String -> { + val s = value.replace(",", "").trim() + if (s.isEmpty() || s == "-") return 0.0 + s.toDoubleOrNull() ?: 0.0 + } + else -> value.toString().replace(",", "").trim().toDoubleOrNull() ?: 0.0 + } + } + + private fun setTextCell( + row: org.apache.poi.ss.usermodel.Row, + col: Int, + value: Any?, + style: org.apache.poi.ss.usermodel.CellStyle + ) { + val cell = row.createCell(col) + cell.setCellValue(value?.toString() ?: "") + cell.cellStyle = style + } + + private fun setNumberCell( + row: org.apache.poi.ss.usermodel.Row, + col: Int, + value: Any?, + style: org.apache.poi.ss.usermodel.CellStyle, + dashStyle: org.apache.poi.ss.usermodel.CellStyle + ) { + val cell = row.createCell(col) + val parsed = when (value) { + is Number -> value.toDouble() + is String -> value.replace(",", "").toDoubleOrNull() ?: 0.0 + else -> value?.toString()?.replace(",", "")?.toDoubleOrNull() ?: 0.0 + } + if (parsed == 0.0) { + cell.setCellValue("-") + cell.cellStyle = dashStyle + } else { + cell.setCellValue(parsed.roundToLong().toDouble()) + cell.cellStyle = style + } + } + + private fun setDashCell( + row: org.apache.poi.ss.usermodel.Row, + col: Int, + style: org.apache.poi.ss.usermodel.CellStyle + ) { + val cell = row.createCell(col) + cell.setCellValue("-") + cell.cellStyle = style + } + @GetMapping("/semi-fg-item-codes") fun getSemiFGItemCodes( @RequestParam(required = false) stockCategory: String?