| @@ -10,9 +10,21 @@ import org.springframework.web.bind.annotation.GetMapping | |||
| import org.springframework.web.bind.annotation.RequestMapping | |||
| import org.springframework.web.bind.annotation.RequestParam | |||
| import org.springframework.web.bind.annotation.RestController | |||
| import org.apache.poi.ss.usermodel.BorderStyle | |||
| import org.apache.poi.ss.usermodel.CellStyle | |||
| 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.usermodel.DataFormat | |||
| import org.apache.poi.ss.util.CellRangeAddress | |||
| import org.apache.poi.ss.util.WorkbookUtil | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import java.time.LocalDate | |||
| import java.time.LocalTime | |||
| import java.time.format.DateTimeFormatter | |||
| import java.io.ByteArrayOutputStream | |||
| @RestController | |||
| @RequestMapping("/report") | |||
| @@ -72,5 +84,299 @@ class MaterialStockOutTraceabilityReportController( | |||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | |||
| } | |||
| @GetMapping("/print-material-stock-out-traceability-excel") | |||
| fun exportMaterialStockOutTraceabilityReportExcel( | |||
| @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?, | |||
| @RequestParam(required = false) handler: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val parameters = mutableMapOf<String, Any>() | |||
| // Set report header parameters (keep consistent with PDF endpoint) | |||
| parameters["stockCategory"] = stockCategory ?: "All" | |||
| parameters["stockSubCategory"] = stockSubCategory ?: "All" | |||
| parameters["itemNo"] = itemCode ?: "All" | |||
| parameters["year"] = year ?: LocalDate.now().year.toString() | |||
| parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) | |||
| parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | |||
| parameters["lastOutDateStart"] = lastOutDateStart ?: "" | |||
| parameters["lastOutDateEnd"] = lastOutDateEnd ?: "" | |||
| parameters["deliveryPeriodStart"] = "" | |||
| parameters["deliveryPeriodEnd"] = "" | |||
| val dbData = materialStockOutTraceabilityReportService.searchMaterialStockOutTraceabilityReport( | |||
| stockCategory, | |||
| stockSubCategory, | |||
| itemCode, | |||
| year, | |||
| lastOutDateStart, | |||
| lastOutDateEnd, | |||
| handler, | |||
| ) | |||
| val excelBytes = createMaterialStockOutTraceabilityExcel( | |||
| dbData = dbData, | |||
| year = year, | |||
| lastOutDateStart = lastOutDateStart, | |||
| lastOutDateEnd = lastOutDateEnd, | |||
| ) | |||
| val headers = HttpHeaders().apply { | |||
| contentType = MediaType.parseMediaType( | |||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" | |||
| ) | |||
| setContentDispositionFormData( | |||
| "attachment", | |||
| "MaterialStockOutTraceabilityReport.xlsx", | |||
| ) | |||
| set("filename", "MaterialStockOutTraceabilityReport.xlsx") | |||
| } | |||
| return ResponseEntity(excelBytes, headers, HttpStatus.OK) | |||
| } | |||
| private fun createMaterialStockOutTraceabilityExcel( | |||
| dbData: List<Map<String, Any>>, | |||
| year: String?, | |||
| lastOutDateStart: String?, | |||
| lastOutDateEnd: String?, | |||
| ): ByteArray { | |||
| val workbook = XSSFWorkbook() | |||
| val reportTitle = "物料出倉追蹤報告" | |||
| val safeSheetName = WorkbookUtil.createSafeSheetName(reportTitle) | |||
| val sheet = workbook.createSheet(safeSheetName) | |||
| val totalColumns = 11 | |||
| var rowIndex = 0 | |||
| 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 font = workbook.createFont().apply { bold = true } | |||
| setFont(font) | |||
| } | |||
| 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 | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| dataFormat = df.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 headers = listOf( | |||
| "貨品編號", | |||
| "貨品名稱", | |||
| "單位", | |||
| "工單編號", | |||
| "提料單號", | |||
| "批號", | |||
| "到期日", | |||
| "出貨數量", | |||
| "提料人", | |||
| "存貨位置", | |||
| "提料備註", | |||
| ) | |||
| // Header row | |||
| val headerRow = sheet.createRow(rowIndex++) | |||
| headers.forEachIndexed { i, h -> | |||
| val cell = headerRow.createCell(i) | |||
| cell.setCellValue(h) | |||
| cell.cellStyle = headerStyle | |||
| } | |||
| val summaryQtyThickBottomStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0") | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THICK | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| val font = workbook.createFont().apply { bold = true } | |||
| setFont(font) | |||
| } | |||
| val summaryEmptyThickBottomStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THICK | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| val summaryLabelInExpiryColStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| val font = workbook.createFont().apply { bold = true } | |||
| setFont(font) | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THICK | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| fun addItemSummaryRow(totalQty: Double, itemCode: String, itemName: String) { | |||
| val summaryRow = sheet.createRow(rowIndex++) | |||
| summaryRow.createCell(0).apply { | |||
| setCellValue(itemCode) | |||
| cellStyle = summaryEmptyThickBottomStyle | |||
| } | |||
| summaryRow.createCell(1).apply { | |||
| setCellValue(itemName) | |||
| cellStyle = summaryEmptyThickBottomStyle | |||
| } | |||
| for (col in 2..5) { | |||
| summaryRow.createCell(col).apply { | |||
| setCellValue("") | |||
| cellStyle = summaryEmptyThickBottomStyle | |||
| } | |||
| } | |||
| summaryRow.createCell(6).apply { | |||
| setCellValue("總出倉量 :") | |||
| cellStyle = summaryLabelInExpiryColStyle | |||
| } | |||
| summaryRow.createCell(7).apply { | |||
| setCellValue(totalQty) | |||
| cellStyle = summaryQtyThickBottomStyle | |||
| } | |||
| for (col in 8 until totalColumns) { | |||
| summaryRow.createCell(col).apply { | |||
| setCellValue("") | |||
| cellStyle = summaryEmptyThickBottomStyle | |||
| } | |||
| } | |||
| } | |||
| fun addBlankSeparatorRow() { | |||
| sheet.createRow(rowIndex++) | |||
| } | |||
| if (dbData.isEmpty()) { | |||
| val dataRow = sheet.createRow(rowIndex++) | |||
| for (col in 0 until totalColumns) { | |||
| val cell = dataRow.createCell(col) | |||
| cell.setCellValue("-") | |||
| cell.cellStyle = textStyle | |||
| } | |||
| addItemSummaryRow(0.0, "", "") | |||
| } else { | |||
| var currentItemNo: String? = null | |||
| var currentItemName: String = "" | |||
| var currentItemTotalQty: Double = 0.0 | |||
| dbData.forEach { rowMap -> | |||
| val itemNo = rowMap["itemNo"]?.toString() ?: "" | |||
| if (currentItemNo != null && itemNo != currentItemNo) { | |||
| addItemSummaryRow(currentItemTotalQty, currentItemNo!!, currentItemName) | |||
| addBlankSeparatorRow() | |||
| currentItemTotalQty = 0.0 | |||
| } | |||
| val dataRow = sheet.createRow(rowIndex++) | |||
| setTextCell(dataRow, 0, rowMap["itemNo"], textStyle) | |||
| setTextCell(dataRow, 1, rowMap["itemName"], textStyle) | |||
| setTextCell(dataRow, 2, rowMap["unitOfMeasure"], textStyle) | |||
| setTextCell(dataRow, 3, rowMap["jobOrderNo"], textStyle) | |||
| setTextCell(dataRow, 4, rowMap["stockReqNo"], textStyle) | |||
| setTextCell(dataRow, 5, rowMap["lotNo"], textStyle) | |||
| setTextCell(dataRow, 6, rowMap["expiryDate"], textStyle) | |||
| setNumberCell(dataRow, 7, rowMap["stockOutQty"], numberStyle, dashStyle) | |||
| setTextCell(dataRow, 8, rowMap["handler"], textStyle) | |||
| setTextCell(dataRow, 9, rowMap["storeLocation"], textStyle) | |||
| setTextCell(dataRow, 10, rowMap["pickRemark"], textStyle) | |||
| currentItemNo = itemNo | |||
| currentItemName = rowMap["itemName"]?.toString() ?: "" | |||
| currentItemTotalQty += parseNullableNumber(rowMap["stockOutQty"]) ?: 0.0 | |||
| } | |||
| // last item | |||
| addItemSummaryRow( | |||
| currentItemTotalQty, | |||
| currentItemNo ?: "", | |||
| currentItemName, | |||
| ) | |||
| } | |||
| val lastRowIndex = rowIndex - 1 | |||
| if (lastRowIndex >= 0) { | |||
| sheet.setAutoFilter(CellRangeAddress(0, lastRowIndex, 0, 0)) | |||
| } | |||
| val widths = intArrayOf(18, 26, 10, 20, 18, 14, 14, 12, 18, 16, 18) | |||
| widths.forEachIndexed { idx, w -> sheet.setColumnWidth(idx, w * 256) } | |||
| val output = ByteArrayOutputStream() | |||
| workbook.use { it.write(output) } | |||
| return output.toByteArray() | |||
| } | |||
| private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { | |||
| val cell = row.createCell(col) | |||
| cell.setCellValue(value?.toString() ?: "") | |||
| cell.cellStyle = style | |||
| } | |||
| private fun parseNullableNumber(value: Any?): Double? { | |||
| val s = value?.toString()?.replace(",", "")?.trim() | |||
| if (s.isNullOrBlank()) return null | |||
| if (s == "-" || s == "null") return null | |||
| return s.toDoubleOrNull() | |||
| } | |||
| private fun setNumberCell( | |||
| row: Row, | |||
| col: Int, | |||
| value: Any?, | |||
| numberStyle: CellStyle, | |||
| dashStyle: CellStyle, | |||
| ) { | |||
| val cell = row.createCell(col) | |||
| val parsed = parseNullableNumber(value) | |||
| if (parsed == null) { | |||
| cell.setCellValue("-") | |||
| cell.cellStyle = dashStyle | |||
| } else { | |||
| cell.setCellValue(parsed) | |||
| cell.cellStyle = numberStyle | |||
| } | |||
| } | |||
| } | |||