| @@ -3,11 +3,33 @@ package com.ffii.fpsms.modules.report.web | |||||
| import net.sf.jasperreports.engine.* | import net.sf.jasperreports.engine.* | ||||
| import org.springframework.http.* | import org.springframework.http.* | ||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| 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 java.io.ByteArrayOutputStream | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import com.ffii.fpsms.modules.report.service.StockItemConsumptionTrendReportService | import com.ffii.fpsms.modules.report.service.StockItemConsumptionTrendReportService | ||||
| import com.ffii.fpsms.modules.report.service.ReportService | import com.ffii.fpsms.modules.report.service.ReportService | ||||
| import kotlin.math.roundToLong | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/report") | @RequestMapping("/report") | ||||
| @@ -69,6 +91,41 @@ class StockItemConsumptionTrendReportController( | |||||
| } | } | ||||
| } | } | ||||
| @GetMapping("/print-stock-item-consumption-trend-excel") | |||||
| fun exportStockItemConsumptionTrendReportExcel( | |||||
| @RequestParam(required = false) stockCategory: 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 = stockItemConsumptionTrendReportService.searchStockItemConsumptionTrendReport( | |||||
| stockCategory = stockCategory, | |||||
| itemCode = itemCode, | |||||
| year = year, | |||||
| lastOutDateStart = lastOutDateStart, | |||||
| lastOutDateEnd = lastOutDateEnd | |||||
| ) | |||||
| val excelBytes = createStockItemConsumptionTrendExcel( | |||||
| dbData = dbData, | |||||
| reportTitle = "庫存材料消耗趨勢報告", | |||||
| year = year, | |||||
| lastOutDateStart = lastOutDateStart, | |||||
| lastOutDateEnd = lastOutDateEnd | |||||
| ) | |||||
| val headers = HttpHeaders().apply { | |||||
| contentType = MediaType.parseMediaType( | |||||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" | |||||
| ) | |||||
| setContentDispositionFormData("attachment", "StockItemConsumptionTrendReport.xlsx") | |||||
| set("filename", "StockItemConsumptionTrendReport.xlsx") | |||||
| } | |||||
| return ResponseEntity(excelBytes, headers, HttpStatus.OK) | |||||
| } | |||||
| @GetMapping("/stock-item-code-prefixes") | @GetMapping("/stock-item-code-prefixes") | ||||
| fun getStockItemCodePrefixes( | fun getStockItemCodePrefixes( | ||||
| @RequestParam(required = false) stockCategory: String? | @RequestParam(required = false) stockCategory: String? | ||||
| @@ -76,4 +133,358 @@ class StockItemConsumptionTrendReportController( | |||||
| val prefixes = stockItemConsumptionTrendReportService.getStockItemCodePrefixes(stockCategory) | val prefixes = stockItemConsumptionTrendReportService.getStockItemCodePrefixes(stockCategory) | ||||
| return ResponseEntity(prefixes, HttpStatus.OK) | return ResponseEntity(prefixes, HttpStatus.OK) | ||||
| } | } | ||||
| private fun createStockItemConsumptionTrendExcel( | |||||
| dbData: List<Map<String, Any>>, | |||||
| reportTitle: String, | |||||
| year: String?, | |||||
| lastOutDateStart: String?, | |||||
| lastOutDateEnd: 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 | |||||
| } | |||||
| // Title | |||||
| 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)) | |||||
| // Info row (date/time/year) | |||||
| 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)) | |||||
| // Date range row | |||||
| rowIndex++ // spacer | |||||
| val rangeRow = sheet.createRow(rowIndex++) | |||||
| rangeRow.heightInPoints = 20f | |||||
| val rangeCell = rangeRow.createCell(0) | |||||
| val rangeText = buildString { | |||||
| append("材料消耗日期:") | |||||
| append(lastOutDateStart ?: "") | |||||
| append(" 至 ") | |||||
| append(lastOutDateEnd ?: "") | |||||
| } | |||||
| rangeCell.setCellValue(rangeText) | |||||
| rangeCell.cellStyle = infoStyle | |||||
| sheet.addMergedRegion(CellRangeAddress(rangeRow.rowNum, rangeRow.rowNum, 0, totalColumns - 1)) | |||||
| // Column headers | |||||
| val headers = listOf( | |||||
| "貨品編號", | |||||
| "貨品名稱", | |||||
| "單位", | |||||
| "一月", | |||||
| "二月", | |||||
| "三月", | |||||
| "四月", | |||||
| "五月", | |||||
| "六月", | |||||
| "七月", | |||||
| "八月", | |||||
| "九月", | |||||
| "十月", | |||||
| "十一月", | |||||
| "十二月", | |||||
| "總和" | |||||
| ) | |||||
| rowIndex++ // spacer row | |||||
| 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) } | |||||
| addStockItemConsumptionChartSheets(workbook, dbData) | |||||
| val output = ByteArrayOutputStream() | |||||
| workbook.use { it.write(output) } | |||||
| return output.toByteArray() | |||||
| } | |||||
| private fun addStockItemConsumptionChartSheets( | |||||
| workbook: XSSFWorkbook, | |||||
| dbData: List<Map<String, Any>> | |||||
| ) { | |||||
| 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 == "-") 0.0 else 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 | |||||
| } | |||||
| } | } | ||||