瀏覽代碼

excel version for stocktakevariance report and some update

master
Tommy\2Fi-Staff 1 天之前
父節點
當前提交
3a09afa1bd
共有 3 個檔案被更改,包括 432 行新增4 行删除
  1. +418
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt
  2. +8
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  3. +6
    -2
      src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt

+ 418
- 0
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<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)
}
}


+ 8
- 2
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
)


+ 6
- 2
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(


Loading…
取消
儲存