Explorar el Código

excel version for stocktakevariance report and some update

master
Tommy\2Fi-Staff hace 2 días
padre
commit
3a09afa1bd
Se han modificado 3 ficheros con 432 adiciones y 4 borrados
  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 Ver fichero

@@ -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.ReportService
import com.ffii.fpsms.modules.report.service.StockTakeVarianceReportService 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.HttpHeaders
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType 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.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 java.io.ByteArrayOutputStream
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
@@ -89,6 +101,41 @@ class StockTakeVarianceReportController(
return ResponseEntity(pdfBytes, headers, HttpStatus.OK) 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 之日期時間。 * Stock Take Variance 報表 V2:依盤點輪次 + 可選物料編號;僅含已有已完成盤點紀錄之列;審核時間為 approver 之日期時間。
*/ */
@@ -144,5 +191,376 @@ class StockTakeVarianceReportController(
} }
return ResponseEntity(pdfBytes, headers, HttpStatus.OK) 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 Ver fichero

@@ -425,6 +425,8 @@ open fun analyzeQrCode(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse {
val lot = lotLine.inventoryLot ?: return@mapNotNull null val lot = lotLine.inventoryLot ?: return@mapNotNull null
val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null val lotNo = lot.stockInLine?.lotNo ?: return@mapNotNull null
val uomDesc = lotLine.stockUom?.uom?.udfudesc ?: 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 inQty = lotLine.inQty ?: BigDecimal.ZERO
val outQty = lotLine.outQty ?: BigDecimal.ZERO val outQty = lotLine.outQty ?: BigDecimal.ZERO
@@ -436,7 +438,9 @@ open fun analyzeQrCode(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse {
lotNo = lotNo, lotNo = lotNo,
inventoryLotLineId = lotLine.id!!, inventoryLotLineId = lotLine.id!!,
availableQty = remainingQty, availableQty = remainingQty,
uom = uomDesc
uom = uomDesc,
warehouseCode = whCode,
warehouseName = whName
) )
else null else null
} }
@@ -454,7 +458,9 @@ open fun analyzeQrCode(request: QrCodeAnalysisRequest): QrCodeAnalysisResponse {
stockInLineId = request.stockInLineId, stockInLineId = request.stockInLineId,
lotNo = scannedLotNo, lotNo = scannedLotNo,
inventoryLotLineId = scannedInventoryLotLine.id 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 sameItemLots = sameItemLots
) )


+ 6
- 2
src/main/java/com/ffii/fpsms/modules/stock/web/model/LotLineInfo.kt Ver fichero

@@ -23,14 +23,18 @@ data class QrCodeAnalysisRequest(
data class ScannedLotInfo( data class ScannedLotInfo(
val stockInLineId: Long, val stockInLineId: Long,
val lotNo: String, val lotNo: String,
val inventoryLotLineId: Long
val inventoryLotLineId: Long,
val warehouseCode: String? = null,
val warehouseName: String? = null
) )


data class SameItemLotInfo( data class SameItemLotInfo(
val lotNo: String, val lotNo: String,
val inventoryLotLineId: Long, val inventoryLotLineId: Long,
val availableQty: BigDecimal, val availableQty: BigDecimal,
val uom: String
val uom: String,
val warehouseCode: String? = null,
val warehouseName: String? = null
) )


data class QrCodeAnalysisResponse( data class QrCodeAnalysisResponse(


Cargando…
Cancelar
Guardar