|
|
|
@@ -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<ByteArray> { |
|
|
|
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<ByteArray> { |
|
|
|
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<Map<String, Any>>, |
|
|
|
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) |
|
|
|
} |
|
|
|
} |
|
|
|
|