| @@ -10,9 +10,21 @@ import org.springframework.web.bind.annotation.GetMapping | |||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RequestParam | import org.springframework.web.bind.annotation.RequestParam | ||||
| import org.springframework.web.bind.annotation.RestController | 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.LocalDate | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import java.io.ByteArrayOutputStream | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/report") | @RequestMapping("/report") | ||||
| @@ -72,5 +84,299 @@ class MaterialStockOutTraceabilityReportController( | |||||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | 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 | |||||
| } | |||||
| } | |||||
| } | } | ||||