From 3a09afa1bd2837c7ca6ad79137f483a5b1f90ce3 Mon Sep 17 00:00:00 2001 From: "Tommy\\2Fi-Staff" Date: Mon, 13 Apr 2026 22:52:33 +0800 Subject: [PATCH] excel version for stocktakevariance report and some update --- .../web/StockTakeVarianceReportController.kt | 418 ++++++++++++++++++ .../stock/service/InventoryLotLineService.kt | 10 +- .../modules/stock/web/model/LotLineInfo.kt | 8 +- 3 files changed, 432 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt index 825455b..a5c4a08 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt @@ -2,6 +2,17 @@ package com.ffii.fpsms.modules.report.web import com.ffii.fpsms.modules.report.service.ReportService import com.ffii.fpsms.modules.report.service.StockTakeVarianceReportService +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.CellStyle +import org.apache.poi.ss.usermodel.DataFormat +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.util.CellRangeAddress +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.MediaType @@ -10,6 +21,7 @@ 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 java.io.ByteArrayOutputStream import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -89,6 +101,41 @@ class StockTakeVarianceReportController( return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } + @GetMapping("/print-stock-take-variance-excel") + fun exportStockTakeVarianceReportExcel( + @RequestParam(required = false) stockCategory: String?, + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) storeLocation: String?, + @RequestParam(required = false) stockTakeDateStart: String?, + @RequestParam(required = false) stockTakeDateEnd: String?, + ): ResponseEntity { + val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReport( + stockCategory = stockCategory, + itemCode = itemCode, + storeLocation = storeLocation, + stockTakeDateStart = stockTakeDateStart, + stockTakeDateEnd = stockTakeDateEnd, + ) + + val excelBytes = createStockTakeVarianceExcel( + dbData = dbData, + stockTakeCaption = buildStockTakeCaption(stockTakeDateStart, stockTakeDateEnd, ""), + ) + + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + setContentDispositionFormData( + "attachment", + "StockTakeVarianceReport.xlsx", + ) + set("filename", "StockTakeVarianceReport.xlsx") + } + + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + /** * Stock Take Variance 報表 V2:依盤點輪次 + 可選物料編號;僅含已有已完成盤點紀錄之列;審核時間為 approver 之日期時間。 */ @@ -144,5 +191,376 @@ class StockTakeVarianceReportController( } return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } + + @GetMapping("/print-stock-take-variance-v2-excel") + fun exportStockTakeVarianceReportV2Excel( + @RequestParam stockTakeRoundId: Long, + @RequestParam(required = false) itemCode: String?, + ): ResponseEntity { + val cap = stockTakeVarianceReportService.getStockTakeRoundCaption(stockTakeRoundId) + val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( + stockTakeRoundId = stockTakeRoundId, + itemCode = itemCode, + ) + + val excelBytes = createStockTakeVarianceExcel( + dbData = dbData, + stockTakeCaption = buildStockTakeCaption("", "", cap), + ) + + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + setContentDispositionFormData( + "attachment", + "StockTakeVarianceReport.xlsx", + ) + set("filename", "StockTakeVarianceReport.xlsx") + } + + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun buildStockTakeCaption( + stockTakeDateStart: String?, + stockTakeDateEnd: String?, + stockTakeFilterCaption: String?, + ): String { + val cap = stockTakeFilterCaption?.trim().orEmpty() + if (cap.isNotEmpty()) return cap + + val s = stockTakeDateStart?.trim().orEmpty() + val e = stockTakeDateEnd?.trim().orEmpty() + return if (s.isEmpty() && e.isEmpty()) "" else "$s 至 $e" + } + + private fun createStockTakeVarianceExcel( + dbData: List>, + stockTakeCaption: String, + ): ByteArray { + val workbook = XSSFWorkbook() + val reportTitle = "庫存盤點報告" + val safeSheetName = WorkbookUtil.createSafeSheetName(reportTitle) + val sheet = workbook.createSheet(safeSheetName) + + val headers = listOf( + "貨品編號", + "貨品名稱", + "單位", + "批號", + "到期日", + "存貨位置", + "盤點前存量", + "盤點數", + "盤盈虧", + "盤盈虧百分比", + "審核時間", + ) + val totalColumns = headers.size + var rowIndex = 0 + + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 14 + } + setFont(font) + } + + val subtitleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 12 + } + setFont(font) + } + + 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 centerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + 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 summaryQtyThickBottomStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + val df: DataFormat = workbook.createDataFormat() + dataFormat = df.getFormat("#,##0") + borderTop = BorderStyle.THICK + borderBottom = BorderStyle.THICK + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val font = workbook.createFont().apply { bold = true } + setFont(font) + } + + val summaryLabelThickBottomStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THICK + 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.THICK + borderBottom = BorderStyle.THICK + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val summaryHiddenKeyStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THICK + borderBottom = BorderStyle.THICK + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val font = workbook.createFont().apply { color = IndexedColors.WHITE.index } + setFont(font) + } + + // Title + val titleRow = sheet.createRow(rowIndex++) + titleRow.createCell(0).apply { + setCellValue(reportTitle) + cellStyle = titleStyle + } + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + + // Subtitle: 報告日期 + 盤點條件 + val reportDateTime = + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + "(" + + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + + ")" + val subtitleRow = sheet.createRow(rowIndex++) + subtitleRow.createCell(0).apply { + setCellValue("報告日期:$reportDateTime") + cellStyle = subtitleStyle + } + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 2)) + subtitleRow.createCell(3).apply { + setCellValue(if (stockTakeCaption.isBlank()) "" else "盤點日期:$stockTakeCaption") + cellStyle = subtitleStyle + } + sheet.addMergedRegion(CellRangeAddress(1, 1, 3, totalColumns - 1)) + + sheet.createRow(rowIndex++) + + // Column header row + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headers.forEachIndexed { i, h -> + headerRow.createCell(i).apply { + setCellValue(h) + cellStyle = headerStyle + } + } + + fun addItemSummaryRow(totalQty: Double, _itemNo: String, _itemName: String) { + val r = sheet.createRow(rowIndex++) + // keep keys for filtering/grouping, visually hidden + r.createCell(0).apply { + setCellValue(_itemNo) + cellStyle = summaryHiddenKeyStyle + } + r.createCell(1).apply { + setCellValue(_itemName) + cellStyle = summaryHiddenKeyStyle + } + r.createCell(2).apply { + setCellValue("") + cellStyle = summaryEmptyThickBottomStyle + } + // empties until label + for (c in 3 until totalColumns) { + r.createCell(c).apply { + setCellValue("") + cellStyle = summaryEmptyThickBottomStyle + } + } + + // place label/value aligned to "盤點前存量"欄附近 + // columns: 0 itemNo, 1 itemName, 2 uom, 3 lotNo, 4 expiry, 5 location, 6 currentBookBalance... + val labelCol = 5 + val valueCol = 6 + r.getCell(labelCol).apply { + setCellValue("貨品總量:") + cellStyle = summaryLabelThickBottomStyle + } + r.getCell(valueCol).apply { + setCellValue(totalQty) + cellStyle = summaryQtyThickBottomStyle + } + } + + fun addBlankSeparatorRow() { + sheet.createRow(rowIndex++) + } + + if (dbData.isEmpty()) { + val dataRow = sheet.createRow(rowIndex++) + for (col in 0 until totalColumns) { + dataRow.createCell(col).apply { + setCellValue("-") + cellStyle = textStyle + } + } + addItemSummaryRow(0.0, "", "") + } else { + var currentItemNo: String? = null + var currentItemName = "" + var currentUom = "" + var currentItemTotalQty = 0.0 + + dbData.forEach { rowMap -> + val itemNo = rowMap["itemNo"]?.toString().orEmpty() + val itemName = rowMap["itemName"]?.toString().orEmpty() + val uom = rowMap["unitOfMeasure"]?.toString().orEmpty() + + if (currentItemNo != null && itemNo != currentItemNo) { + addItemSummaryRow(currentItemTotalQty, currentItemNo!!, currentItemName) + addBlankSeparatorRow() + currentItemTotalQty = 0.0 + } + + val r = sheet.createRow(rowIndex++) + setTextCell(r, 0, itemNo, textStyle) + setTextCell(r, 1, itemName, textStyle) + setTextCell(r, 2, uom, centerStyle) + setTextCell(r, 3, rowMap["lotNo"], textStyle) + setTextCell(r, 4, rowMap["expiryDate"], centerStyle) + setTextCell(r, 5, rowMap["storeLocation"], centerStyle) + setNumberCellFromFormatted(r, 6, rowMap["currentBookBalance"], numberStyle, dashStyle) + setNumberCellFromFormatted(r, 7, rowMap["stockTakeQty"], numberStyle, dashStyle) + setNumberCellFromFormatted(r, 8, rowMap["variance"], numberStyle, dashStyle) + setTextCell(r, 9, rowMap["variancePercentage"], dashStyle) + setTextCell(r, 10, rowMap["stockTakeDate"], centerStyle) + + currentItemNo = itemNo + currentItemName = itemName + currentUom = uom + currentItemTotalQty += parseSignedNumber(rowMap["currentBookBalance"]) ?: 0.0 + } + + addItemSummaryRow(currentItemTotalQty, currentItemNo ?: "", currentItemName) + } + + val lastRowIndex = rowIndex - 1 + if (lastRowIndex >= headerRowIndex) { + sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) + } + + val widths = intArrayOf(14, 26, 10, 18, 14, 14, 14, 12, 12, 14, 22) + 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) { + row.createCell(col).apply { + setCellValue(value?.toString() ?: "") + cellStyle = style + } + } + + private fun parseSignedNumber(value: Any?): Double? { + val raw = value?.toString()?.trim().orEmpty() + if (raw.isBlank() || raw == "-" || raw.equals("null", ignoreCase = true)) return null + + // formats used in SQL: "1,234" or "(1,234)" for negative + val negative = raw.startsWith("(") && raw.endsWith(")") + val cleaned = raw.removePrefix("(").removeSuffix(")").replace(",", "").trim() + val n = cleaned.toDoubleOrNull() ?: return null + return if (negative) -n else n + } + + private fun setNumberCellFromFormatted( + row: Row, + col: Int, + value: Any?, + numberStyle: CellStyle, + dashStyle: CellStyle, + ) { + val cell = row.createCell(col) + val parsed = parseSignedNumber(value) + if (parsed == null) { + cell.setCellValue("-") + cell.cellStyle = dashStyle + return + } + + if (parsed < 0) { + // keep Excel numeric for sorting/filtering; show parentheses via format is non-trivial, so use text + cell.setCellValue("(${formatAbsInt(parsed)})") + cell.cellStyle = dashStyle + } else { + cell.setCellValue(parsed) + cell.cellStyle = numberStyle + } + } + + private fun formatAbsInt(v: Double): String { + val abs = kotlin.math.abs(v).toLong() + return "%,d".format(abs) + } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index 1a4d916..eceaaab 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -425,6 +425,8 @@ open fun analyzeQrCode(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { val lot = lotLine.inventoryLot ?: return@mapNotNull null val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null val uomDesc = lotLine.stockUom?.uom?.udfudesc ?: return@mapNotNull null + val whCode = lotLine.warehouse?.code + val whName = lotLine.warehouse?.name val inQty = lotLine.inQty ?: BigDecimal.ZERO val outQty = lotLine.outQty ?: BigDecimal.ZERO @@ -436,7 +438,9 @@ open fun analyzeQrCode(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { lotNo = lotNo, inventoryLotLineId = lotLine.id!!, availableQty = remainingQty, - uom = uomDesc + uom = uomDesc, + warehouseCode = whCode, + warehouseName = whName ) else null } @@ -454,7 +458,9 @@ open fun analyzeQrCode(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse { stockInLineId = request.stockInLineId, lotNo = scannedLotNo, inventoryLotLineId = scannedInventoryLotLine.id - ?: throw IllegalStateException("inventoryLotLineId missing on scanned lot line") + ?: throw IllegalStateException("inventoryLotLineId missing on scanned lot line"), + warehouseCode = scannedInventoryLotLine.warehouse?.code, + warehouseName = scannedInventoryLotLine.warehouse?.name ), sameItemLots = sameItemLots ) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt index 7cc686a..f904aa5 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt @@ -23,14 +23,18 @@ data class QrCodeAnalysisRequest( data class ScannedLotInfo( val stockInLineId: Long, val lotNo: String, - val inventoryLotLineId: Long + val inventoryLotLineId: Long, + val warehouseCode: String? = null, + val warehouseName: String? = null ) data class SameItemLotInfo( val lotNo: String, val inventoryLotLineId: Long, val availableQty: BigDecimal, - val uom: String + val uom: String, + val warehouseCode: String? = null, + val warehouseName: String? = null ) data class QrCodeAnalysisResponse(