| @@ -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 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.LocalTime | |||
| import java.time.format.DateTimeFormatter | |||
| import com.ffii.fpsms.modules.report.service.StockItemConsumptionTrendReportService | |||
| import com.ffii.fpsms.modules.report.service.ReportService | |||
| import kotlin.math.roundToLong | |||
| @RestController | |||
| @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") | |||
| fun getStockItemCodePrefixes( | |||
| @RequestParam(required = false) stockCategory: String? | |||
| @@ -76,4 +133,358 @@ class StockItemConsumptionTrendReportController( | |||
| val prefixes = stockItemConsumptionTrendReportService.getStockItemCodePrefixes(stockCategory) | |||
| 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 | |||
| } | |||
| } | |||