diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt new file mode 100644 index 0000000..5f32c7a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt @@ -0,0 +1,418 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Service + +@Service +open class StockTakeVarianceReportService( + private val jdbcDao: JdbcDao, +) { + + /** + * Stock Take Variance 報表查詢 + * + * 條件設計與 Stock Ledger 報表類似: + * - stockCategory : items.type,可逗號分隔(精確匹配) + * - itemCode : items.code,可逗號分隔(LIKE) + * - storeLocation : warehouse.code,模糊匹配 + * - lastInDate* : 依 inventory_lot.stockInDate 過濾 + * + * 資料來源以 stocktakerecord 為主,欄位對應 `StockTakeVarianceReport.jrxml`: + * - currentBookBalance : bookQty + * - stockTakeQty : 以 approverStockTakeQty > pickerSecondStockTakeQty > pickerFirstStockTakeQty 為準 + * - variance : stockTakeQty - bookQty + * - variancePercentage : variance / bookQty(%),bookQty 為 0 則輸出空字串 + * + * 期初 / 累計存入 / 累計存出(openingBalance / cumStockIn / cumStockOut): + * - 開始存貨 openingBalance:僅統計 stock_ledger.type = 'OPEN' 的 inQty - outQty + * - 累計存入量 cumStockIn:僅包含 type IN ('NOR', 'Miss', 'Bad') 的 inQty + * - 累計存出量 cumStockOut:僅包含 type IN ('NOR', 'Miss', 'Bad') 的 outQty + * - 調整 Adj:不計入 cumStockIn / cumStockOut,但因為 ledger.balance 已含 Adj, + * currentBookBalance 直接取該 item 最後一筆的 balance。 + * + * totalOpeningBalance / totalCumStockIn / totalCumStockOut 目前先維持為空字串; + * totalCurrentBalance 則為各 item 的 currentBookBalance 加總(但在本報表中只用到明細)。 + */ + fun searchStockTakeVarianceReport( + stockCategory: String?, + itemCode: String?, + storeLocation: String?, + lastInDateStart: String?, + lastInDateEnd: String?, + ): List> { + val args = mutableMapOf() + + // items.type 精確多選 + val stockCategorySql = buildMultiValueExactClause( + stockCategory, + "it.type", + "stockCategory", + args + ) + + // itemCode 模糊多選(用 items.code) + val itemCodeSql = buildMultiValueLikeClause( + itemCode, + "it.code", + "itemCode", + args + ) + + // 倉庫位置:用 warehouse.code 模糊 + val storeLocationSql = if (!storeLocation.isNullOrBlank()) { + args["storeLocation"] = "%$storeLocation%" + "AND wh.code LIKE :storeLocation" + } else { + "" + } + + // 最後入倉日區間:以 sl_agg.lastInDate(來自 stock_in_line.receiptDate)過濾 + val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { + args["lastInDateStart"] = lastInDateStart + "AND sl_agg.lastInDate >= :lastInDateStart" + } else "" + + val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) { + args["lastInDateEnd"] = lastInDateEnd + "AND sl_agg.lastInDate < :lastInDateEnd" + } else "" + + val sql = """ + SELECT + /* ====== 基本資料 ====== */ + COALESCE(it.type, '') AS stockSubCategory, + COALESCE(it.code, '') AS itemNo, + COALESCE( + CONCAT( + it.name, + CASE + WHEN it.description IS NULL OR it.description = '' THEN '' + ELSE CONCAT('\n', it.description) + END + ), + '' + ) AS itemName, + COALESCE(uc.udfudesc, '') AS unitOfMeasure, + COALESCE(il.lotNo, '') AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + + /* ====== 庫存相關:依 stock_ledger 聚合 ====== */ + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( + COALESCE(sl_agg.openingBalance, 0), 2 + ))) AS openingBalance, + + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( + COALESCE(sl_agg.cumStockIn, 0), 2 + ))) AS cumStockIn, + + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( + COALESCE(sl_agg.cumStockOut, 0), 2 + ))) AS cumStockOut, + + -- 現存存量 = 期初 + 累計存入量 - 累計存出量 + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( + COALESCE(sl_agg.openingBalance, 0) + + COALESCE(sl_agg.cumStockIn, 0) + - COALESCE(sl_agg.cumStockOut, 0), 2 + ))) AS currentBookBalance, + + /* 小計欄位(貨品總量):以 itemNo 為單位彙總所有批號 */ + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( + COALESCE( + SUM(sl_agg.cumStockIn) OVER (PARTITION BY it.code), + 0 + ), + 2 + ))) AS totalCumStockIn, + + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( + COALESCE( + SUM(sl_agg.openingBalance) OVER (PARTITION BY it.code), + 0 + ), + 2 + ))) AS totalOpeningBalance, + + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( + COALESCE( + SUM(sl_agg.cumStockOut) OVER (PARTITION BY it.code), + 0 + ), + 2 + ))) AS totalCumStockOut, + + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( + COALESCE( + SUM( + sl_agg.openingBalance + + sl_agg.cumStockIn + - sl_agg.cumStockOut + ) OVER (PARTITION BY it.code), + 0 + ), + 2 + ))) AS totalCurrentBalance, + + /* 最後入/出倉日期:來自 stock_in_line.receiptDate / stock_out_line.endTime */ + COALESCE(DATE_FORMAT(sl_agg.lastInDate, '%Y-%m-%d'), '') AS lastInDate, + COALESCE(DATE_FORMAT(sl_agg.lastOutDate, '%Y-%m-%d'), '') AS lastOutDate, + + /* 倉庫位置:使用 warehouse.code */ + COALESCE(wh.code, '') AS storeLocation, + + /* 取貨量(Stock Take Qty [b]):若無 stocktake 則為空字串,有則 = bookQty + varianceQty */ + CASE + WHEN str.id IS NULL THEN '' + ELSE TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( + COALESCE(str.bookQty, 0) + COALESCE(str.varianceQty, 0), + 2 + ))) + END AS stockTakeQty, + + /* 差異量 = 盤點量[b] - 現存存量[a];若無 stocktake 則為空字串 */ + CASE + WHEN str.id IS NULL THEN '' + ELSE TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( + COALESCE( + ( + COALESCE(str.bookQty, 0) + COALESCE(str.varianceQty, 0) + ) - ( + COALESCE(sl_agg.openingBalance, 0) + + COALESCE(sl_agg.cumStockIn, 0) + - COALESCE(sl_agg.cumStockOut, 0) + ), + 0 + ), + 2 + ))) + END AS variance, + + /* 差異百分比:((b - a)/a),若無 stocktake 或 a=0 則為空字串 */ + CASE + WHEN str.id IS NULL THEN '' + WHEN COALESCE( + COALESCE(sl_agg.openingBalance, 0) + + COALESCE(sl_agg.cumStockIn, 0) + - COALESCE(sl_agg.cumStockOut, 0), + 0 + ) = 0 THEN '' + ELSE CONCAT( + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( + ( + ( + COALESCE(str.bookQty, 0) + COALESCE(str.varianceQty, 0) + ) - ( + COALESCE(sl_agg.openingBalance, 0) + + COALESCE(sl_agg.cumStockIn, 0) + - COALESCE(sl_agg.cumStockOut, 0) + ) + ) / NULLIF( + COALESCE( + COALESCE(sl_agg.openingBalance, 0) + + COALESCE(sl_agg.cumStockIn, 0) + - COALESCE(sl_agg.cumStockOut, 0), + 0 + ), + 0 + ) * 100, + 2 + ))), + '%' + ) + END AS variancePercentage + + FROM inventory_lot il + LEFT JOIN inventory_lot_line ill + ON ill.inventoryLotId = il.id + AND ill.deleted = 0 + + LEFT JOIN items it + ON il.itemId = it.id + AND it.deleted = 0 + + LEFT JOIN warehouse wh + ON ill.warehouseId = wh.id + AND wh.deleted = 0 + + /* 盤點記錄:以 lotId + warehouseId 對應,若沒有盤點則為 NULL */ + LEFT JOIN stocktakerecord str + ON str.lotId = il.id + AND str.warehouseId = ill.warehouseId + AND str.deleted = 0 + + LEFT JOIN item_uom iu + ON it.id = iu.itemId + AND iu.stockUnit = 1 + AND iu.deleted = 0 + + LEFT JOIN uom_conversion uc + ON iu.uomId = uc.id + + /* ====== 從 stock_ledger 按 lot + warehouse 聚合 ====== */ + LEFT JOIN ( + SELECT + COALESCE(il_in.id, il_out.id) AS lotId, + COALESCE(wh_in.id, wh_out.id) AS warehouseId, + + /* 期初存貨:只有 OPEN 的 inQty - outQty */ + SUM( + CASE + WHEN sl.type = 'OPEN' + THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) + ELSE 0 + END + ) AS openingBalance, + + /* 累計存入量:只算 NOR / Miss / Bad 的 inQty */ + SUM( + CASE + WHEN sl.type IN ('NOR', 'Miss', 'Bad') + THEN COALESCE(sl.inQty, 0) + ELSE 0 + END + ) AS cumStockIn, + + /* 累計存出量:只算 NOR / Miss / Bad 的 outQty */ + SUM( + CASE + WHEN sl.type IN ('NOR', 'Miss', 'Bad') + THEN COALESCE(sl.outQty, 0) + ELSE 0 + END + ) AS cumStockOut, + /* ADJ 調整量(取貨量):只算 Adj 的 outQty */ + SUM( + CASE + WHEN sl.type = 'Adj' + THEN COALESCE(sl.outQty, 0) + ELSE 0 + END + ) AS adjOutQty, + + /* 最後入倉日期:最近一次有 inQty 的收貨日 */ + MAX( + CASE + WHEN sl.inQty IS NOT NULL AND sil.receiptDate IS NOT NULL + THEN DATE(sil.receiptDate) + ELSE NULL + END + ) AS lastInDate, + + /* 最後出倉日期:最近一次有 outQty 的出庫完成時間 */ + MAX( + CASE + WHEN sl.outQty IS NOT NULL AND sol.endTime IS NOT NULL + THEN DATE(sol.endTime) + ELSE NULL + END + ) AS lastOutDate, + + /* 現存存量:此處只作為差異計算基礎 = 期初 + 累計入 - 累計出 */ + SUM( + CASE + WHEN sl.type = 'OPEN' + THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) + WHEN sl.type IN ('NOR', 'Miss', 'Bad') + THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) + ELSE 0 + END + ) AS currentBookBalance + + FROM stock_ledger sl + /* IN 方向:透過 stock_in_line → inventory_lot / inventory_lot_line → warehouse */ + LEFT JOIN stock_in_line sil + ON sl.stockInLineId = sil.id + AND sil.deleted = 0 + LEFT JOIN inventory_lot il_in + ON sil.inventoryLotId = il_in.id + AND il_in.deleted = 0 + LEFT JOIN inventory_lot_line ill_in + ON sil.inventoryLotLineId = ill_in.id + AND ill_in.deleted = 0 + LEFT JOIN warehouse wh_in + ON ill_in.warehouseId = wh_in.id + AND wh_in.deleted = 0 + + /* OUT 方向:透過 stock_out_line → inventory_lot_line → inventory_lot → warehouse */ + LEFT JOIN stock_out_line sol + ON sl.stockOutLineId = sol.id + AND sol.deleted = 0 + LEFT JOIN inventory_lot_line ill_out + ON sol.inventoryLotLineId = ill_out.id + AND ill_out.deleted = 0 + LEFT JOIN inventory_lot il_out + ON ill_out.inventoryLotId = il_out.id + AND il_out.deleted = 0 + LEFT JOIN warehouse wh_out + ON ill_out.warehouseId = wh_out.id + AND wh_out.deleted = 0 + + WHERE + sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + + GROUP BY + COALESCE(il_in.id, il_out.id), + COALESCE(wh_in.id, wh_out.id) + ) sl_agg + ON sl_agg.lotId = il.id + AND sl_agg.warehouseId = ill.warehouseId + + WHERE + il.deleted = 0 + $stockCategorySql + $itemCodeSql + $storeLocationSql + $lastInDateStartSql + $lastInDateEndSql + + ORDER BY + it.type, + it.code, + il.lotNo, + wh.code + """.trimIndent() + + return jdbcDao.queryForList(sql, args) + } + + /** LIKE 多值工具方法 */ + private fun buildMultiValueLikeClause( + paramValue: String?, + columnName: String, + paramPrefix: String, + args: MutableMap + ): String { + if (paramValue.isNullOrBlank()) return "" + val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (values.isEmpty()) return "" + + val conditions = values.mapIndexed { index, value -> + val paramName = "${paramPrefix}_$index" + args[paramName] = "%$value%" + "$columnName LIKE :$paramName" + } + return "AND (${conditions.joinToString(" OR ")})" + } + + /** = 多值工具方法 */ + private fun buildMultiValueExactClause( + paramValue: String?, + columnName: String, + paramPrefix: String, + args: MutableMap + ): String { + if (paramValue.isNullOrBlank()) return "" + val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (values.isEmpty()) return "" + + val conditions = values.mapIndexed { index, value -> + val paramName = "${paramPrefix}_$index" + args[paramName] = value + "$columnName = :$paramName" + } + return "AND (${conditions.joinToString(" OR ")})" + } +} + 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 new file mode 100644 index 0000000..d8e6414 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt @@ -0,0 +1,81 @@ +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.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +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.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +@RestController +@RequestMapping("/report") +class StockTakeVarianceReportController( + private val reportService: ReportService, + private val stockTakeVarianceReportService: StockTakeVarianceReportService, +) { + + /** + * 產生 Stock Take Variance 報表 PDF + * + * 查詢條件與 Stock Ledger 報表保持一致: + * - stockCategory + * - itemCode + * - storeLocation + * - lastInDateStart / lastInDateEnd + */ + @GetMapping("/print-stock-take-variance") + fun generateStockTakeVarianceReport( + @RequestParam(required = false) stockCategory: String?, + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) storeLocation: String?, + @RequestParam(name = "lastInDateStart", required = false) lastInDateStart: String?, + @RequestParam(name = "lastInDateEnd", required = false) lastInDateEnd: String?, + ): ResponseEntity { + val parameters = mutableMapOf() + + parameters["stockCategory"] = stockCategory ?: "All" + parameters["stockSubCategory"] = stockCategory ?: "All" + parameters["itemNo"] = itemCode ?: "All" + parameters["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["storeLocation"] = storeLocation ?: "" + parameters["balanceFilterStart"] = "" + parameters["balanceFilterEnd"] = "" + + parameters["lastInDateStart"] = lastInDateStart ?: "" + parameters["lastInDateEnd"] = lastInDateEnd ?: "" + parameters["lastOutDateStart"] = "" + parameters["lastOutDateEnd"] = "" + + val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReport( + stockCategory = stockCategory, + itemCode = itemCode, + storeLocation = storeLocation, + lastInDateStart = lastInDateStart, + lastInDateEnd = lastInDateEnd, + ) + + val pdfBytes = reportService.createPdfResponse( + "/jasper/StockTakeVarianceReport.jrxml", + parameters, + dbData + ) + + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_PDF + setContentDispositionFormData("attachment", "StockTakeVarianceReport.pdf") + set("filename", "StockTakeVarianceReport.pdf") + } + return ResponseEntity(pdfBytes, headers, HttpStatus.OK) + } +} + diff --git a/src/main/resources/jasper/StockTakeVarianceReport.jrxml b/src/main/resources/jasper/StockTakeVarianceReport.jrxml new file mode 100644 index 0000000..30f192d --- /dev/null +++ b/src/main/resources/jasper/StockTakeVarianceReport.jrxml @@ -0,0 +1,655 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +