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 index c4cdbec..2d673f6 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt @@ -296,6 +296,263 @@ ORDER BY return result } + /** + * V2:依盤點輪次 + 可選物料編號;僅輸出該輪已有 `stocktakerecord` 且 `status='completed'` 的列(未建立盤點紀錄的批號不列出)。 + * 期初/累計區間取該輪紀錄之 MIN(`date`)~MAX(`date`)(與 V1 之 in/out 聚合邏輯一致,但以 rb 帶入)。 + * 「審核時間」欄位:`approverTime` 之本地日期時間字串;無則退回盤點日 `date`。 + */ + fun searchStockTakeVarianceReportV2( + stockTakeRoundId: Long, + itemCode: String?, + ): List> { + val countSql = """ + SELECT COUNT(*) AS c FROM stocktakerecord s + WHERE s.deleted = 0 + AND s.stockTakeRoundId = :stockTakeRoundId + AND s.status = 'completed' + """.trimIndent() + val cntRow = jdbcDao.queryForList( + countSql, + mapOf("stockTakeRoundId" to stockTakeRoundId) + ).firstOrNull() + val cnt = (cntRow?.get("c") as? Number)?.toLong() ?: 0L + if (cnt == 0L) return emptyList() + + val args = mutableMapOf() + args["stockTakeRoundId"] = stockTakeRoundId + val itemCodeSql = buildMultiValueLikeClause( + itemCode, + "it.code", + "itemCode", + args + ) + + val sql = """ +WITH rb AS ( + SELECT + COALESCE(MIN(s.date), CURRENT_DATE) AS fromDate, + COALESCE(MAX(s.date), CURRENT_DATE) AS toDate + FROM stocktakerecord s + WHERE s.deleted = 0 + AND s.stockTakeRoundId = :stockTakeRoundId + AND s.status = 'completed' +), +latest_str AS ( + SELECT + str.lotId, + str.warehouseId, + str.bookQty, + str.varianceQty, + str.approverStockTakeQty, + str.date AS strDate, + str.id, + str.approverTime + FROM stocktakerecord str + WHERE str.deleted = 0 + AND str.stockTakeRoundId = :stockTakeRoundId + AND str.status = 'completed' +), +in_agg AS ( + SELECT + ill.id AS inventoryLotLineId, + SUM(CASE WHEN DATE(sil.receiptDate) < rb.fromDate THEN + CASE WHEN sil.purchaseOrderLineId IS NOT NULL + THEN COALESCE(sil.acceptedQty, 0) + WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL + THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0)) + ELSE COALESCE(sil.acceptedQty, 0) + END + ELSE 0 END) AS inBefore, + SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN rb.fromDate AND rb.toDate THEN + CASE WHEN sil.purchaseOrderLineId IS NOT NULL + THEN COALESCE(sil.acceptedQty, 0) + WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL + THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0)) + ELSE COALESCE(sil.acceptedQty, 0) + END + ELSE 0 END) AS inDuring, + MAX(CASE WHEN sil.receiptDate IS NOT NULL THEN DATE(sil.receiptDate) END) AS lastInDate + FROM inventory_lot_line ill + CROSS JOIN rb + INNER JOIN inventory_lot il + ON ill.inventoryLotId = il.id + AND il.deleted = 0 + INNER JOIN items it + ON il.itemId = it.id + AND it.deleted = 0 + LEFT JOIN stock_in_line sil + ON sil.inventoryLotLineId = ill.id + AND sil.deleted = 0 + AND sil.status = 'completed' + LEFT JOIN item_uom iu_purchase + ON it.id = iu_purchase.itemId + AND iu_purchase.purchaseUnit = 1 + AND iu_purchase.deleted = 0 + LEFT JOIN item_uom iu_stock + ON it.id = iu_stock.itemId + AND iu_stock.stockUnit = 1 + AND iu_stock.deleted = 0 + WHERE ill.deleted = 0 + GROUP BY ill.id +), +out_agg AS ( + SELECT + ill.id AS inventoryLotLineId, + SUM(CASE WHEN DATE(sol.endTime) < rb.fromDate THEN COALESCE(sol.qty, 0) ELSE 0 END) AS outBefore, + SUM(CASE WHEN DATE(sol.endTime) BETWEEN rb.fromDate AND rb.toDate THEN COALESCE(sol.qty, 0) ELSE 0 END) AS outDuring, + MAX(CASE WHEN sol.endTime IS NOT NULL THEN DATE(sol.endTime) END) AS lastOutDate + FROM inventory_lot_line ill + CROSS JOIN rb + LEFT JOIN stock_out_line sol + ON sol.inventoryLotLineId = ill.id + AND sol.deleted = 0 + AND sol.status = 'completed' + WHERE ill.deleted = 0 + GROUP BY ill.id +), +in_out AS ( + SELECT + i.inventoryLotLineId, + COALESCE(i.inBefore, 0) AS inBefore, + COALESCE(o.outBefore, 0) AS outBefore, + COALESCE(i.inDuring, 0) AS inDuring, + COALESCE(o.outDuring, 0) AS outDuring, + i.lastInDate, + o.lastOutDate + FROM in_agg i + LEFT JOIN out_agg o ON o.inventoryLotLineId = i.inventoryLotLineId +), +data AS ( + SELECT + it.type AS stockSubCategory, + it.code AS itemNo, + it.name AS itemName, + uc.udfudesc AS unitOfMeasure, + + il.lotNo AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + wh.code AS storeLocation, + + (COALESCE(io.inBefore, 0) - COALESCE(io.outBefore, 0)) AS openingQty, + COALESCE(io.inDuring, 0) AS inQty, + COALESCE(io.outDuring, 0) AS outQty, + ((COALESCE(io.inBefore, 0) - COALESCE(io.outBefore, 0)) + COALESCE(io.inDuring, 0) - COALESCE(io.outDuring, 0)) AS currentQty, + + io.lastInDate AS lastInDateRaw, + io.lastOutDate AS lastOutDateRaw, + + ls.bookQty AS stkBookQty, + ls.approverStockTakeQty AS stkApproverQty, + ls.varianceQty AS stkVarianceQty, + ls.strDate AS stockTakeDateRaw, + ls.approverTime AS approvalDateTimeRaw + FROM latest_str ls + INNER JOIN inventory_lot il + ON ls.lotId = il.id + AND il.deleted = 0 + INNER JOIN inventory_lot_line ill + ON ill.inventoryLotId = il.id + AND ill.warehouseId = ls.warehouseId + AND ill.deleted = 0 + INNER JOIN items it + ON il.itemId = it.id + AND it.deleted = 0 + INNER JOIN warehouse wh + ON wh.id = ls.warehouseId + AND wh.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 + + LEFT JOIN in_out io + ON io.inventoryLotLineId = ill.id + + WHERE 1=1 + $itemCodeSql +) + +SELECT + stockSubCategory, + itemNo, + itemName, + unitOfMeasure, + lotNo, + expiryDate, + storeLocation, + + CASE WHEN COALESCE(openingQty, 0) < 0 THEN CONCAT('(', FORMAT(-openingQty, 0), ')') ELSE FORMAT(COALESCE(openingQty, 0), 0) END AS openingBalance, + CASE WHEN COALESCE(inQty, 0) < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(COALESCE(inQty, 0), 0) END AS cumStockIn, + CASE WHEN COALESCE(outQty, 0) < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(COALESCE(outQty, 0), 0) END AS cumStockOut, + CASE WHEN COALESCE(stkBookQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkBookQty, 0), ')') ELSE FORMAT(COALESCE(stkBookQty, 0), 0) END AS currentBookBalance, + + COALESCE(DATE_FORMAT(lastInDateRaw, '%Y-%m-%d'), '') AS lastInDate, + COALESCE(DATE_FORMAT(lastOutDateRaw, '%Y-%m-%d'), '') AS lastOutDate, + COALESCE( + DATE_FORMAT(approvalDateTimeRaw, '%Y-%m-%d %H:%i:%s'), + COALESCE(DATE_FORMAT(stockTakeDateRaw, '%Y-%m-%d'), '') + ) AS stockTakeDate, + + CASE + WHEN stkApproverQty IS NULL THEN '0' + WHEN COALESCE(stkApproverQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkApproverQty, 0), ')') + ELSE FORMAT(COALESCE(stkApproverQty, 0), 0) + END AS stockTakeQty, + + CASE + WHEN stkVarianceQty IS NULL THEN '0' + WHEN COALESCE(stkVarianceQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkVarianceQty, 0), ')') + ELSE FORMAT(COALESCE(stkVarianceQty, 0), 0) + END AS variance, + + CASE + WHEN stkVarianceQty IS NULL THEN '0%' + WHEN COALESCE(stkBookQty, 0) = 0 THEN '0%' + WHEN (COALESCE(stkVarianceQty, 0) / stkBookQty) * 100 < 0 THEN CONCAT('(', FORMAT(-(COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%)') + ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%') + END AS variancePercentage, + + CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalOpeningBalance, + CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockIn, + CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockOut, + CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCurrentBalance + +FROM data +ORDER BY + itemNo, + lotNo, + storeLocation +""".trimIndent() + + return jdbcDao.queryForList(sql, args) + } + + /** 報表表頭:盤點輪次說明(與 /report/stock-take-rounds 選項格式一致) */ + fun getStockTakeRoundCaption(stockTakeRoundId: Long): String { + val sql = """ + SELECT CONCAT( + 'Round ', + CAST(st.stockTakeRoundId AS CHAR), + ' (', + DATE_FORMAT(MIN(st.planStart), '%Y-%m-%d'), + ')' + ) AS cap + FROM stock_take st + WHERE st.deleted = 0 + AND st.stockTakeRoundId = :stockTakeRoundId + GROUP BY st.stockTakeRoundId + """.trimIndent() + val row = jdbcDao.queryForList( + sql, + mapOf("stockTakeRoundId" to stockTakeRoundId) + ).firstOrNull() + val cap = row?.get("cap") as? String + return if (!cap.isNullOrBlank()) cap else "Round $stockTakeRoundId" + } + /** LIKE 多值工具方法 */ private fun buildMultiValueLikeClause( paramValue: String?, 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 4bd9159..825455b 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 @@ -73,6 +73,64 @@ class StockTakeVarianceReportController( parameters["stockTakeDate"] = stockTakeDateDisplay + parameters["stockTakeFilterCaption"] = "" + + 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) + } + + /** + * Stock Take Variance 報表 V2:依盤點輪次 + 可選物料編號;僅含已有已完成盤點紀錄之列;審核時間為 approver 之日期時間。 + */ + @GetMapping("/print-stock-take-variance-v2") + fun generateStockTakeVarianceReportV2( + @RequestParam stockTakeRoundId: Long, + @RequestParam(required = false) itemCode: String?, + ): ResponseEntity { + val parameters = mutableMapOf() + + parameters["stockCategory"] = "All" + parameters["stockSubCategory"] = "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"] = "" + parameters["balanceFilterStart"] = "" + parameters["balanceFilterEnd"] = "" + + parameters["stockTakeDateStart"] = "" + parameters["stockTakeDateEnd"] = "" + parameters["lastInDateEnd"] = "" + parameters["lastOutDateEnd"] = "" + + parameters["stockTakeFilterCaption"] = + stockTakeVarianceReportService.getStockTakeRoundCaption(stockTakeRoundId) + parameters["stockTakeConditionLabel"] = "盤點輪次:" + + val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( + stockTakeRoundId = stockTakeRoundId, + itemCode = itemCode, + ) + val stockTakeDateDisplay = dbData + .mapNotNull { it["stockTakeDate"] as? String } + .filter { it.isNotBlank() } + .maxOrNull() + ?: "" + + parameters["stockTakeDate"] = stockTakeDateDisplay + val pdfBytes = reportService.createPdfResponse( "/jasper/StockTakeVarianceReport.jrxml", parameters, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTake.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTake.kt index 9f8e17e..8a2fac3 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTake.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTake.kt @@ -43,7 +43,7 @@ open class StockTake: BaseEntity() { @Column(name = "stockTakeSection", length = 255) open var stockTakeSection: String? = null - /** 同一輪盤點(多 section 多筆 stock_take)共用此 id,通常等於該輪第一筆 stock_take 的主鍵 */ + /** 同一輪盤點(多 section 多筆 stock_take)共用此 id;由批次建立時依 MAX+1 遞增,不必等於任一筆主鍵 */ @Column(name = "stockTakeRoundId") open var stockTakeRoundId: Long? = null } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRepository.kt index 18199d8..da09eae 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRepository.kt @@ -15,4 +15,10 @@ interface StockTakeRepository : AbstractRepository { select st.code from StockTake st where st.code like :prefix% order by st.code desc limit 1 """) fun findLatestCodeByPrefix(prefix: String): String? + + /** 未刪除列中 stockTakeRoundId 的最大值;全為 null 或無資料時為 0 */ + @Query( + "SELECT COALESCE(MAX(st.stockTakeRoundId), 0) FROM StockTake st WHERE st.deleted = false" + ) + fun findMaxStockTakeRoundId(): Long } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt index 922c851..55a8089 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt @@ -247,53 +247,12 @@ class StockTakeService( .mapNotNull { it.stockTakeSection } .distinct() .filter { !it.isBlank() } - - // 2. 获取所有 stock_take 记录(按 stockTakeSection 分组) - val allStockTakes = stockTakeRepository.findAll() - .filter { !it.deleted } - .groupBy { it.stockTakeSection } - /* - // 3. 为每个 stockTakeSection 检查并创建 - distinctSections.forEach { section -> - val stockTakesForSection = allStockTakes[section] ?: emptyList() - - // 检查:如果该 section 的所有记录都是 COMPLETED,才创建新的 - val allCompleted = stockTakesForSection.isEmpty() || - stockTakesForSection.all { it.status == StockTakeStatus.COMPLETED } - - if (allCompleted) { - try { - val now = LocalDateTime.now() - val code = assignStockTakeNo() - - val saveStockTakeReq = SaveStockTakeRequest( - code = code, - planStart = now, - planEnd = now.plusDays(1), - actualStart = null, - actualEnd = null, - status = StockTakeStatus.PENDING.value, - remarks = null, - stockTakeSection = section - ) - - val savedStockTake = saveStockTake(saveStockTakeReq) - result[section] = "Created: ${savedStockTake.code}" - logger.info("Created stock take for section $section: ${savedStockTake.code}") - } catch (e: Exception) { - result[section] = "Error: ${e.message}" - logger.error("Error creating stock take for section $section: ${e.message}") - } - } else { - result[section] = "Skipped: Has non-completed records" - logger.info("Skipped section $section: Has non-completed records") - } - } - */ + // 移除 null section 处理逻辑,因为 warehouse 表中没有 null 的 stockTakeSection val batchPlanStart = LocalDateTime.now() val batchPlanEnd = batchPlanStart.plusDays(1) - var roundId: Long? = null + // 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4) + val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 distinctSections.forEach { section -> try { val code = assignStockTakeNo() @@ -309,11 +268,6 @@ class StockTakeService( stockTakeRoundId = roundId ) val savedStockTake = saveStockTake(saveStockTakeReq) - if (roundId == null) { - roundId = savedStockTake.id - savedStockTake.stockTakeRoundId = roundId - stockTakeRepository.save(savedStockTake) - } result[section] = "Created: ${savedStockTake.code}" logger.info("Created stock take for section $section: ${savedStockTake.code}, roundId=$roundId") } catch (e: Exception) { diff --git a/src/main/resources/jasper/StockTakeVarianceReport.jrxml b/src/main/resources/jasper/StockTakeVarianceReport.jrxml index bd69230..73a97a8 100644 --- a/src/main/resources/jasper/StockTakeVarianceReport.jrxml +++ b/src/main/resources/jasper/StockTakeVarianceReport.jrxml @@ -35,6 +35,12 @@ + + + + + + @@ -164,7 +170,7 @@ - + @@ -184,16 +190,16 @@ - - + + - - + + @@ -249,7 +255,7 @@ - + @@ -271,7 +277,7 @@ - +