From 6f60da90400c997ed0c1d295516e1e2bb5207812 Mon Sep 17 00:00:00 2001 From: "Tommy\\2Fi-Staff" Date: Mon, 30 Mar 2026 19:06:06 +0800 Subject: [PATCH] no message --- .../modules/report/service/ReportService.kt | 641 ++++++++++++++---- .../modules/report/web/ReportController.kt | 41 +- 2 files changed, 538 insertions(+), 144 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index e9cf4d1..b86bf04 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -286,16 +286,18 @@ return result return jdbcDao.queryForList(sql, emptyMap()).map { row -> (row["handler"]?.toString() ?: "").trim() }.filter { it.isNotBlank() } } + /** + * Dropdown for stock balance report: [value] and [label] both identify the round; + * label shows stockTakeRoundId and plan-start date (same round may span multiple sections). + */ fun getStockTakeRoundOptions(): List> { val sql = """ SELECT CAST(st.stockTakeRoundId AS CHAR) AS value, CONCAT( - 'Round ', - st.stockTakeRoundId, - ' (', - DATE_FORMAT(MIN(st.planStart), '%Y-%m-%d'), - ')' + CAST(st.stockTakeRoundId AS CHAR), + ' — ', + DATE_FORMAT(MIN(st.planStart), '%Y-%m-%d') ) AS label FROM stock_take st WHERE st.deleted = 0 @@ -764,10 +766,10 @@ return result args["receiptDateEnd"] = formatted "AND DATE(sil.receiptDate) <= DATE(:receiptDateEnd)" } else "" - val itemSql = buildMultiValueCodeOrNameLikeClause(itemCode, "it.code", "it.name", "listedItem", args) - val supplierSql = buildMultiValueCodeOrNameLikeClause(supplier, "sp.code", "sp.name", "listedSupp", args) - val poCodeSql = buildMultiValueLikeClause(poCode, "po.code", "listedPo", args) - val grnExistsSql = buildMultiValueGrnExistsClause(grnCode, "listedGrn", args) + val itemSql = buildMultiValueCodeOrNameLikeClause(itemCode, "it.code", "it.name", "grnItem", args) + val supplierSql = buildMultiValueCodeOrNameLikeClause(supplier, "sp.code", "sp.name", "grnSupp", args) + val poCodeSql = buildMultiValueLikeClause(poCode, "po.code", "grnPo", args) + val grnExistsSql = buildMultiValueGrnExistsClause(grnCode, "grnG", args) val sql = """ SELECT @@ -892,10 +894,10 @@ return result args["receiptDateEnd"] = formatted "AND DATE(sil.receiptDate) <= DATE(:receiptDateEnd)" } else "" - val itemSql = buildMultiValueCodeOrNameLikeClause(itemCode, "it.code", "it.name", "listedItem", args) - val supplierSql = buildMultiValueCodeOrNameLikeClause(supplier, "sp.code", "sp.name", "listedSupp", args) - val poCodeSql = buildMultiValueLikeClause(poCode, "po.code", "listedPo", args) - val grnExistsSql = buildMultiValueGrnExistsClause(grnCode, "listedGrn", args) + val itemSql = buildMultiValueCodeOrNameLikeClause(itemCode, "it.code", "it.name", "grnListedPoItem", args) + val supplierSql = buildMultiValueCodeOrNameLikeClause(supplier, "sp.code", "sp.name", "grnListedPoSupp", args) + val poCodeSql = buildMultiValueLikeClause(poCode, "po.code", "grnListedPoPo", args) + val grnExistsSql = buildMultiValueGrnExistsClause(grnCode, "grnListedPoG", args) val lineSubquery = """ SELECT @@ -1040,9 +1042,11 @@ return result /** * Queries the database for Stock Balance Report data (one summarized row per item). - * Uses stock_ledger with report period (fromDate/toDate): opening = before fromDate, cum in/out = in period, current = up to toDate. - * Price per stock unit = (pol.qty * pol.up) / sil.acceptedQty (PO unit price is per purchase unit; acceptedQty is in stock unit). - * Total balance = sum over movements of (inQty * price_per_stock_in - outQty * price_per_stock_out) per item. + * Lots in scope: distinct [StockTakeRecord.lotId] for [stockTakeRoundId]. + * Opening balance: latest stock_ledger.balance from type TKE after round cutoff + * (earliest stocktakerecord.stockTakeStartTime in the round, per item); 0 when no TKE. + * Cumulative in/out and variance buckets: classified by [StockLedger.type] (TKE, MISS, BAD, ADJ, etc.). + * Price per stock unit uses PO line and UOM ratios; total balance = sum of (in value - out value) per item. * 現存存貨 = totalCurrentBalance; avg unit price = total_balance / 現存存貨 (0 when current stock is 0). */ fun searchStockBalanceReport( @@ -1056,60 +1060,9 @@ return result lastOutDateStart: String?, lastOutDateEnd: String?, stockTakeRoundId: Long, - reportPeriodStart: String? = null, - reportPeriodEnd: String? = null ): List> { val args = mutableMapOf() - - fun toLocalDate(value: Any?): java.time.LocalDate? = when (value) { - is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate() - is java.time.LocalDateTime -> value.toLocalDate() - is java.time.LocalDate -> value - else -> null - } - - val (resolvedFromDate, resolvedToDate) = run { - // Fallback to existing date-range behavior (year-start -> today) when stock take round can't be resolved. - val fallbackFrom = - reportPeriodStart?.replace("/", "-")?.takeIf { it.isNotBlank() } - ?: java.time.LocalDate.now().withDayOfYear(1).toString() - val fallbackTo = - reportPeriodEnd?.replace("/", "-")?.takeIf { it.isNotBlank() } - ?: java.time.LocalDate.now().toString() - - val currentPlanStartAny = jdbcDao.queryForList( - """ - SELECT MIN(planStart) AS planStart - FROM stock_take - WHERE deleted = 0 - AND stockTakeRoundId = :stockTakeRoundId - """.trimIndent(), - mapOf("stockTakeRoundId" to stockTakeRoundId) - ).firstOrNull()?.get("planStart") - - val currentPlanStartDate = toLocalDate(currentPlanStartAny) - if (currentPlanStartDate == null) { - fallbackFrom to fallbackTo - } else { - val nextPlanStartAny = jdbcDao.queryForList( - """ - SELECT MIN(planStart) AS planStart - FROM stock_take - WHERE deleted = 0 - AND planStart > :currentPlanStart - """.trimIndent(), - mapOf("currentPlanStart" to currentPlanStartAny) - ).firstOrNull()?.get("planStart") - - val nextPlanStartDate = toLocalDate(nextPlanStartAny) - val from = currentPlanStartDate.toString() - val to = nextPlanStartDate?.minusDays(1)?.toString() ?: java.time.LocalDate.now().toString() - from to to - } - } - - args["fromDate"] = resolvedFromDate - args["toDate"] = resolvedToDate + args["stockTakeRoundId"] = stockTakeRoundId val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args) val itemCodeSql = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCode", args) @@ -1119,6 +1072,186 @@ return result } else "" val sql = """ + WITH chosen_round AS ( + SELECT + CASE + WHEN :stockTakeRoundId IS NULL OR :stockTakeRoundId = 0 THEN ( + SELECT st.stockTakeRoundId + FROM stock_take st + WHERE st.deleted = 0 + AND st.stockTakeRoundId IS NOT NULL + ORDER BY st.planStart DESC + LIMIT 1 + ) + ELSE :stockTakeRoundId + END AS roundId + ), + round_time AS ( + SELECT + cr.roundId AS roundId, + MIN(st.planStart) AS roundStart + FROM chosen_round cr + INNER JOIN stock_take st ON st.stockTakeRoundId = cr.roundId AND st.deleted = 0 + GROUP BY cr.roundId + ), + next_round_time AS ( + SELECT + rt.roundId AS roundId, + COALESCE( + ( + SELECT MIN(st2.planStart) + FROM stock_take st2 + WHERE st2.deleted = 0 + AND st2.planStart > rt.roundStart + ), + NOW() + ) AS nextRoundStart + FROM round_time rt + ), + prev_round_time AS ( + SELECT + rt.roundId AS roundId, + COALESCE( + ( + SELECT MAX(st0.planStart) + FROM stock_take st0 + WHERE st0.deleted = 0 + AND st0.planStart < rt.roundStart + ), + TIMESTAMP('1970-01-01 00:00:00') + ) AS prevRoundStart + FROM round_time rt + ), + ledger_window AS ( + SELECT + sl.itemCode AS itemCode, + sl.itemId AS itemId, + sl.id AS slId, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty, + COALESCE(sl.balance, 0) AS ledgerBalance, + UPPER(TRIM(COALESCE(sl.type, ''))) AS normType, + sl.date AS ledgerDate, + sl.created AS ledgerCreated, + COALESCE(lot_wh.storeLocation, '') AS storeLocation, + pol_in.id AS polInId, + pol_in.up AS polInUp, + pol_out.id AS polOutId, + pol_out.up AS polOutUp, + iu_stock.id AS iuStockId, + iu_stock.ratioN AS iuStockRatioN, + iu_stock.ratioD AS iuStockRatioD, + iu_purchase.id AS iuPurchaseId, + iu_purchase.ratioN AS iuPurchaseRatioN, + iu_purchase.ratioD AS iuPurchaseRatioD + FROM stock_ledger sl + 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 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 ( + SELECT il.id AS lotId, MAX(wh.code) AS storeLocation + FROM inventory_lot il + LEFT JOIN inventory_lot_line ill ON ill.inventoryLotId = il.id AND ill.deleted = 0 + LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 + GROUP BY il.id + ) lot_wh ON lot_wh.lotId = COALESCE(il_in.id, il_out.id) + LEFT JOIN purchase_order_line pol_in ON sil.purchaseOrderLineId = pol_in.id AND pol_in.deleted = 0 + LEFT JOIN stock_in_line sil_out ON il_out.stockInLineId = sil_out.id AND sil_out.deleted = 0 + LEFT JOIN purchase_order_line pol_out ON sil_out.purchaseOrderLineId = pol_out.id AND pol_out.deleted = 0 + LEFT JOIN item_uom iu_stock ON sl.itemId = iu_stock.itemId AND iu_stock.stockUnit = 1 AND iu_stock.deleted = 0 + LEFT JOIN item_uom iu_purchase ON sl.itemId = iu_purchase.itemId AND iu_purchase.purchaseUnit = 1 AND iu_purchase.deleted = 0 + INNER JOIN round_time rt ON 1 = 1 + INNER JOIN next_round_time nrt ON 1 = 1 + WHERE sl.deleted = 0 + AND sl.itemCode IS NOT NULL AND sl.itemCode <> '' + AND sl.created >= rt.roundStart + AND sl.created < nrt.nextRoundStart + $itemCodeSql + ), + latest_tke_in_window_per_item AS ( + SELECT + ranked.itemId, + ranked.tkeCreated, + ranked.tkeId, + ranked.tkeBalance + FROM ( + SELECT + lw.itemId, + lw.ledgerCreated AS tkeCreated, + lw.slId AS tkeId, + lw.ledgerBalance AS tkeBalance, + ROW_NUMBER() OVER ( + PARTITION BY lw.itemId + ORDER BY lw.ledgerCreated DESC, lw.slId DESC + ) AS rn + FROM ledger_window lw + WHERE lw.normType = 'TKE' + ) ranked + WHERE ranked.rn = 1 + ), + prev_window_cost_base AS ( + SELECT + sl.itemId, + sl.itemCode, + sl.created AS ledgerCreated, + sl.id AS slId, + ( + COALESCE(pol_in.up, 0) + * (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0)) + / (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) + ) AS stockUnitCost + FROM stock_ledger sl + LEFT JOIN stock_in_line sil ON sl.stockInLineId = sil.id AND sil.deleted = 0 + LEFT JOIN purchase_order_line pol_in ON sil.purchaseOrderLineId = pol_in.id AND pol_in.deleted = 0 + LEFT JOIN item_uom iu_stock ON sl.itemId = iu_stock.itemId AND iu_stock.stockUnit = 1 AND iu_stock.deleted = 0 + LEFT JOIN item_uom iu_purchase ON sl.itemId = iu_purchase.itemId AND iu_purchase.purchaseUnit = 1 AND iu_purchase.deleted = 0 + INNER JOIN round_time rt ON 1 = 1 + INNER JOIN prev_round_time prt ON 1 = 1 + WHERE sl.deleted = 0 + AND sl.itemCode IS NOT NULL AND sl.itemCode <> '' + AND sl.created >= prt.prevRoundStart + AND sl.created < rt.roundStart + AND COALESCE(sl.inQty, 0) > 0 + AND pol_in.id IS NOT NULL + AND iu_stock.id IS NOT NULL + AND iu_purchase.id IS NOT NULL + AND COALESCE(iu_purchase.ratioN, 0) > 0 + AND UPPER(TRIM(COALESCE(sl.type, ''))) NOT IN ('TKE', 'OPEN', 'ADJ') + ), + prev_window_cost_per_item AS ( + SELECT + ranked.itemId, + ranked.stockUnitCost + FROM ( + SELECT + b.itemId, + b.stockUnitCost, + ROW_NUMBER() OVER ( + PARTITION BY b.itemId + ORDER BY b.ledgerCreated DESC, b.slId DESC + ) AS rn + FROM prev_window_cost_base b + WHERE COALESCE(b.stockUnitCost, 0) > 0 + ) ranked + WHERE ranked.rn = 1 + ), + ledger_flagged AS ( + SELECT + lw.*, + COALESCE(lt.tkeBalance, 0) AS openingBalancePerItem, + COALESCE(pwc.stockUnitCost, 0) AS prevWindowStockUnitCost, + CASE + WHEN lt.tkeCreated IS NULL THEN 1 + WHEN lw.ledgerCreated > lt.tkeCreated THEN 1 + WHEN lw.ledgerCreated = lt.tkeCreated AND lw.slId > lt.tkeId THEN 1 + ELSE 0 + END AS isMovementRow + FROM ledger_window lw + LEFT JOIN latest_tke_in_window_per_item lt ON lt.itemId = lw.itemId + LEFT JOIN prev_window_cost_per_item pwc ON pwc.itemId = lw.itemId + ) SELECT '' as stockSubCategory, COALESCE(item_agg.itemNo, '') as itemNo, @@ -1174,80 +1307,103 @@ return result SUM(agg.total_out_value) AS total_out_value FROM ( SELECT - sl.itemCode, - sl.itemId, - COALESCE(il_in.id, il_out.id) AS lotId, + lf.itemCode, + lf.itemId, + MAX(lf.openingBalancePerItem) AS openingBalance, SUM( CASE - WHEN DATE(sl.date) <= :fromDate - THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) + WHEN lf.isMovementRow = 1 + AND lf.normType NOT IN ('OPEN', 'TKE') + THEN COALESCE(lf.inQty, 0) ELSE 0 END - ) AS openingBalance, + ) AS cumStockIn, SUM( CASE - WHEN DATE(sl.date) > :fromDate - AND DATE(sl.date) <= :toDate - AND UPPER(TRIM(COALESCE(sl.type, ''))) <> 'TKE' - AND sil.stockTakeLineId IS NULL - THEN COALESCE(sl.inQty, 0) + WHEN COALESCE(lf.outQty, 0) > 0 + AND lf.isMovementRow = 1 + AND lf.normType NOT IN ( + 'OPEN', 'MISS', 'BAD', 'TKE', 'ADJ', 'STOCKTAKE' + ) + THEN COALESCE(lf.outQty, 0) ELSE 0 END - ) AS cumStockIn, + ) AS cumStockOut, + MAX(lf.openingBalancePerItem) + SUM( + CASE + WHEN lf.isMovementRow = 1 + THEN COALESCE(lf.inQty, 0) - COALESCE(lf.outQty, 0) + ELSE 0 + END + ) AS currentBalance, + MAX(CASE WHEN lf.isMovementRow = 1 AND COALESCE(lf.inQty, 0) > 0 THEN lf.ledgerDate END) AS lastInDate, + MAX(CASE WHEN lf.isMovementRow = 1 AND COALESCE(lf.outQty, 0) > 0 THEN lf.ledgerDate END) AS lastOutDate, + MAX(lf.storeLocation) AS storeLocation, SUM( CASE - WHEN DATE(sl.date) > :fromDate - AND DATE(sl.date) <= :toDate - AND COALESCE(sl.outQty, 0) > 0 - AND UPPER(TRIM(COALESCE(sl.type, ''))) <> 'TKE' - AND NOT ( - LOWER(TRIM(COALESCE(sl.type, ''))) = 'stocktake' - OR ( - LOWER(TRIM(COALESCE(sl.type, ''))) = 'adj' - AND (sol.stockTransferId IS NULL OR sol.id IS NULL) - ) - OR LOWER(TRIM(COALESCE(sl.type, ''))) = 'miss' - OR LOWER(TRIM(COALESCE(sol.type, ''))) = 'miss' - OR LOWER(TRIM(COALESCE(sl.type, ''))) = 'bad' - OR LOWER(TRIM(COALESCE(sol.type, ''))) = 'bad' - ) - THEN COALESCE(sl.outQty, 0) + WHEN lf.isMovementRow = 1 + AND COALESCE(lf.outQty, 0) > 0 + AND lf.normType = 'MISS' + THEN COALESCE(lf.outQty, 0) ELSE 0 END - ) AS cumStockOut, - SUM(CASE WHEN DATE(sl.date) <= :toDate THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) ELSE 0 END) AS currentBalance, - MAX(CASE WHEN COALESCE(sl.inQty, 0) > 0 THEN sl.date END) AS lastInDate, - MAX(CASE WHEN COALESCE(sl.outQty, 0) > 0 THEN sl.date END) AS lastOutDate, - MAX(lot_wh.storeLocation) AS storeLocation, - SUM(CASE WHEN DATE(sl.date) BETWEEN :fromDate AND :toDate AND COALESCE(sl.inQty, 0) > 0 AND (LOWER(TRIM(COALESCE(sl.type, ''))) = 'miss' OR LOWER(TRIM(COALESCE(sol.type, ''))) = 'miss') THEN sl.outQty ELSE 0 END) AS cumStockOutMiss, - SUM(CASE WHEN DATE(sl.date) BETWEEN :fromDate AND :toDate AND COALESCE(sl.outQty, 0) > 0 AND (LOWER(TRIM(COALESCE(sl.type, ''))) = 'bad' OR LOWER(TRIM(COALESCE(sol.type, ''))) = 'bad') THEN sl.outQty ELSE 0 END) AS cumStockOutBad, - SUM(CASE WHEN DATE(sl.date) BETWEEN :fromDate AND :toDate AND COALESCE(sl.outQty, 0) > 0 AND LOWER(TRIM(COALESCE(sl.type, ''))) = 'adj' AND (sol.stockTransferId IS NULL OR sol.id IS NULL) THEN sl.outQty ELSE 0 END) AS cumStockOutAdjStockTake, - SUM(COALESCE(sl.inQty, 0) * CASE WHEN pol_in.id IS NOT NULL AND iu_stock.id IS NOT NULL AND iu_purchase.id IS NOT NULL AND COALESCE(iu_purchase.ratioN, 0) > 0 THEN COALESCE(pol_in.up, 0) * (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0)) / (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) ELSE 0 END) AS total_in_value, - SUM(COALESCE(sl.outQty, 0) * CASE WHEN pol_out.id IS NOT NULL AND iu_stock.id IS NOT NULL AND iu_purchase.id IS NOT NULL AND COALESCE(iu_purchase.ratioN, 0) > 0 THEN COALESCE(pol_out.up, 0) * (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0)) / (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) ELSE 0 END) AS total_out_value - FROM stock_ledger sl - 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 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 ( - SELECT il.id AS lotId, MAX(wh.code) AS storeLocation - FROM inventory_lot il - LEFT JOIN inventory_lot_line ill ON ill.inventoryLotId = il.id AND ill.deleted = 0 - LEFT JOIN warehouse wh ON ill.warehouseId = wh.id AND wh.deleted = 0 - GROUP BY il.id - ) lot_wh ON lot_wh.lotId = COALESCE(il_in.id, il_out.id) - LEFT JOIN purchase_order_line pol_in ON sil.purchaseOrderLineId = pol_in.id AND pol_in.deleted = 0 - LEFT JOIN stock_in_line sil_out ON il_out.stockInLineId = sil_out.id AND sil_out.deleted = 0 - LEFT JOIN purchase_order_line pol_out ON sil_out.purchaseOrderLineId = pol_out.id AND pol_out.deleted = 0 - LEFT JOIN item_uom iu_stock ON sl.itemId = iu_stock.itemId AND iu_stock.stockUnit = 1 AND iu_stock.deleted = 0 - LEFT JOIN item_uom iu_purchase ON sl.itemId = iu_purchase.itemId AND iu_purchase.purchaseUnit = 1 AND iu_purchase.deleted = 0 - WHERE sl.deleted = 0 - AND sl.itemCode IS NOT NULL AND sl.itemCode <> '' - AND DATE(sl.date) <= :toDate - AND COALESCE(il_in.id, il_out.id) IS NOT NULL - $itemCodeSql - GROUP BY sl.itemCode, sl.itemId, COALESCE(il_in.id, il_out.id) + ) AS cumStockOutMiss, + SUM( + CASE + WHEN lf.isMovementRow = 1 + AND COALESCE(lf.outQty, 0) > 0 + AND lf.normType = 'BAD' + THEN COALESCE(lf.outQty, 0) + ELSE 0 + END + ) AS cumStockOutBad, + SUM( + CASE + WHEN lf.isMovementRow = 1 + AND COALESCE(lf.outQty, 0) > 0 + AND lf.normType IN ('TKE', 'ADJ', 'STOCKTAKE') + THEN COALESCE(lf.outQty, 0) + ELSE 0 + END + ) AS cumStockOutAdjStockTake, + SUM( + CASE + WHEN lf.isMovementRow = 1 + THEN + COALESCE(lf.inQty, 0) * CASE + WHEN lf.normType = 'TKE' THEN COALESCE(lf.prevWindowStockUnitCost, 0) + WHEN lf.polInId IS NOT NULL + AND lf.iuStockId IS NOT NULL + AND lf.iuPurchaseId IS NOT NULL + AND COALESCE(lf.iuPurchaseRatioN, 0) > 0 + THEN COALESCE(lf.polInUp, 0) + * (lf.iuStockRatioN / NULLIF(lf.iuStockRatioD, 0)) + / (lf.iuPurchaseRatioN / NULLIF(lf.iuPurchaseRatioD, 0)) + ELSE 0 + END + ELSE 0 + END + ) AS total_in_value, + SUM( + CASE + WHEN lf.isMovementRow = 1 + THEN + COALESCE(lf.outQty, 0) * CASE + WHEN lf.normType = 'TKE' THEN COALESCE(lf.prevWindowStockUnitCost, 0) + WHEN lf.polOutId IS NOT NULL + AND lf.iuStockId IS NOT NULL + AND lf.iuPurchaseId IS NOT NULL + AND COALESCE(lf.iuPurchaseRatioN, 0) > 0 + THEN COALESCE(lf.polOutUp, 0) + * (lf.iuStockRatioN / NULLIF(lf.iuStockRatioD, 0)) + / (lf.iuPurchaseRatioN / NULLIF(lf.iuPurchaseRatioD, 0)) + ELSE 0 + END + ELSE 0 + END + ) AS total_out_value + FROM ledger_flagged lf + GROUP BY lf.itemCode, lf.itemId ) agg LEFT JOIN items it ON agg.itemId = it.id AND it.deleted = 0 LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = 1 AND iu.deleted = 0 @@ -1300,6 +1456,231 @@ return result } return jdbcDao.queryForList("$finalSql ORDER BY itemNo", args) } + + /** + * Stock Balance Report (date-driven). + * + * - 期初存量: stockDate - 1 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序) + * - 現存存貨: stockDate 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序) + * - 單位均價: 依 item 判斷是否 BOM + * - BOM: 用 delivery_order_line (prefer up, else price/qty, else price) 算加權平均 + * - 非 BOM: 用 purchase_order_line (prefer up, else price/qty, else price) 算加權平均 + * - 庫存總價值: 單位均價 * 現存存貨 + */ + fun searchStockBalanceReportByDate( + stockCategory: String?, + itemCode: String?, + stockDate: String, + balanceFilterStart: String?, + balanceFilterEnd: String?, + storeLocation: String?, + ): List> { + val args = mutableMapOf() + val formattedStockDate = stockDate.replace("/", "-") + args["stockDate"] = formattedStockDate + + val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args) + val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) + val storeLocationSql = if (!storeLocation.isNullOrBlank()) { + args["storeLocation"] = "%$storeLocation%" + "AND COALESCE(store_location.storeLocation, '') LIKE :storeLocation" + } else "" + + val baseSql = """ + WITH params AS ( + SELECT + DATE(:stockDate) AS d0, + DATE_SUB(DATE(:stockDate), INTERVAL 1 DAY) AS d1 + ), + ledger_item_ids AS ( + SELECT DISTINCT sl.itemId + FROM stock_ledger sl + INNER JOIN params p ON 1=1 + WHERE sl.deleted = 0 + AND sl.itemId IS NOT NULL + AND DATE(sl.date) <= p.d0 + ), + item_scope AS ( + SELECT it.id AS itemId, it.code AS itemNo, it.name AS itemName, it.type AS itemType + FROM items it + INNER JOIN ledger_item_ids li ON li.itemId = it.id + WHERE it.deleted = 0 + AND it.code IS NOT NULL AND it.code <> '' + $itemCodeSql + $stockCategorySql + ), + store_location AS ( + SELECT + sl.itemId, + MAX(wh.code) AS storeLocation + FROM stock_ledger sl + 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 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 inventory_lot_line ill_any ON ill_any.inventoryLotId = COALESCE(il_in.id, il_out.id) AND ill_any.deleted = 0 + LEFT JOIN warehouse wh ON ill_any.warehouseId = wh.id AND wh.deleted = 0 + WHERE sl.deleted = 0 + GROUP BY sl.itemId + ), + opening_ranked AS ( + SELECT + sl.itemId, + COALESCE(sl.balance, 0) AS openingBalance, + ROW_NUMBER() OVER (PARTITION BY sl.itemId ORDER BY sl.date DESC, sl.id DESC) AS rn + FROM stock_ledger sl + INNER JOIN params p ON 1=1 + WHERE sl.deleted = 0 + AND sl.itemId IS NOT NULL + AND DATE(sl.date) <= p.d1 + ), + opening_per_item AS ( + SELECT itemId, openingBalance + FROM opening_ranked + WHERE rn = 1 + ), + current_ranked AS ( + SELECT + sl.itemId, + COALESCE(sl.balance, 0) AS currentBalance, + ROW_NUMBER() OVER (PARTITION BY sl.itemId ORDER BY sl.date DESC, sl.id DESC) AS rn + FROM stock_ledger sl + INNER JOIN params p ON 1=1 + WHERE sl.deleted = 0 + AND sl.itemId IS NOT NULL + AND DATE(sl.date) <= p.d0 + ), + current_per_item AS ( + SELECT itemId, currentBalance + FROM current_ranked + WHERE rn = 1 + ), + last_in_out AS ( + SELECT + sl.itemId, + MAX(CASE WHEN COALESCE(sl.inQty, 0) > 0 THEN sl.date END) AS lastInDate, + MAX(CASE WHEN COALESCE(sl.outQty, 0) > 0 THEN sl.date END) AS lastOutDate + FROM stock_ledger sl + INNER JOIN params p ON 1=1 + WHERE sl.deleted = 0 + AND sl.itemId IS NOT NULL + AND DATE(sl.date) <= p.d0 + GROUP BY sl.itemId + ), + is_bom_item AS ( + SELECT + it.id AS itemId, + CASE WHEN EXISTS ( + SELECT 1 FROM bom b + WHERE b.deleted = 0 AND b.itemId = it.id + ) THEN 1 ELSE 0 END AS isBom + FROM items it + WHERE it.deleted = 0 + ), + bom_price AS ( + SELECT + dol.itemId, + SUM(COALESCE(dol.qty, 0) * COALESCE(dol.up, (dol.price / NULLIF(dol.qty, 0)), dol.price, 0)) AS amtSum, + SUM(COALESCE(dol.qty, 0)) AS qtySum + FROM delivery_order_line dol + WHERE dol.deleted = 0 + AND dol.itemId IS NOT NULL + AND COALESCE(dol.qty, 0) > 0 + GROUP BY dol.itemId + ), + non_bom_price AS ( + SELECT + pol.itemId, + SUM(COALESCE(pol.qty, 0) * COALESCE(pol.up, (pol.price / NULLIF(pol.qty, 0)), pol.price, 0)) AS amtSum, + SUM(COALESCE(pol.qty, 0)) AS qtySum + FROM purchase_order_line pol + WHERE pol.deleted = 0 + AND pol.itemId IS NOT NULL + AND COALESCE(pol.qty, 0) > 0 + GROUP BY pol.itemId + ), + avg_price_per_item AS ( + SELECT + s.itemId, + CASE + WHEN COALESCE(b.isBom, 0) = 1 THEN + CASE WHEN COALESCE(bp.qtySum, 0) > 0 THEN (bp.amtSum / bp.qtySum) ELSE 0 END + ELSE + CASE WHEN COALESCE(np.qtySum, 0) > 0 THEN (np.amtSum / np.qtySum) ELSE 0 END + END AS avgUnitPriceRaw + FROM item_scope s + LEFT JOIN is_bom_item b ON b.itemId = s.itemId + LEFT JOIN bom_price bp ON bp.itemId = s.itemId + LEFT JOIN non_bom_price np ON np.itemId = s.itemId + ) + SELECT + '' AS stockSubCategory, + COALESCE(s.itemNo, '') AS itemNo, + COALESCE(s.itemName, '') AS itemName, + COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, + '' AS lotNo, + '' AS expiryDate, + '' AS openingBalance, + '' AS cumStockIn, + '' AS cumStockOut, + '' AS currentBalance, + '' AS reOrderQty, + COALESCE(store_location.storeLocation, '') AS storeLocation, + COALESCE(DATE_FORMAT(lio.lastInDate, '%Y-%m-%d'), '') AS lastInDate, + COALESCE(DATE_FORMAT(lio.lastOutDate, '%Y-%m-%d'), '') AS lastOutDate, + COALESCE(op.openingBalance, 0) AS openingBalanceRaw, + COALESCE(cp.currentBalance, 0) AS currentBalanceRaw, + CASE WHEN COALESCE(op.openingBalance, 0) < 0 THEN CONCAT('(', FORMAT(-op.openingBalance, 0), ')') ELSE FORMAT(COALESCE(op.openingBalance, 0), 0) END AS totalOpeningBalance, + '0' AS totalCumStockIn, + '0' AS totalCumStockOut, + CASE WHEN COALESCE(cp.currentBalance, 0) < 0 THEN CONCAT('(', FORMAT(-cp.currentBalance, 0), ')') ELSE FORMAT(COALESCE(cp.currentBalance, 0), 0) END AS totalCurrentBalance, + '' AS misInputAndLost, + '' AS defectiveGoods, + '' AS variance, + '0' AS totalMisInputAndLost, + '0' AS totalVariance, + '0' AS totalDefectiveGoods, + FORMAT(ROUND(COALESCE(ap.avgUnitPriceRaw, 0), 2), 2) AS avgUnitPrice, + FORMAT(ROUND(COALESCE(ap.avgUnitPriceRaw, 0) * COALESCE(cp.currentBalance, 0), 2), 2) AS totalStockBalance + FROM item_scope s + LEFT JOIN opening_per_item op ON op.itemId = s.itemId + LEFT JOIN current_per_item cp ON cp.itemId = s.itemId + LEFT JOIN last_in_out lio ON lio.itemId = s.itemId + LEFT JOIN avg_price_per_item ap ON ap.itemId = s.itemId + LEFT JOIN item_uom iu ON iu.itemId = s.itemId AND iu.stockUnit = 1 AND iu.deleted = 0 + LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + LEFT JOIN store_location ON store_location.itemId = s.itemId + WHERE 1=1 + $storeLocationSql + """.trimIndent() + + val filters = mutableListOf() + if (!balanceFilterStart.isNullOrBlank()) { + args["balanceFilterStart"] = balanceFilterStart.toDoubleOrNull() ?: 0.0 + filters.add("COALESCE(currentBalanceRaw, 0) >= :balanceFilterStart") + } + if (!balanceFilterEnd.isNullOrBlank()) { + args["balanceFilterEnd"] = balanceFilterEnd.toDoubleOrNull() ?: 0.0 + filters.add("COALESCE(currentBalanceRaw, 0) <= :balanceFilterEnd") + } + + val finalSql = + if (filters.isEmpty()) { + // no numeric filter: just order by itemNo + baseSql + } else { + // wrap to filter on raw numeric current balance + """ + SELECT * FROM ( + $baseSql + ) base + WHERE ${filters.joinToString(" AND ")} + """.trimIndent() + } + + return jdbcDao.queryForList("$finalSql ORDER BY itemNo", args) + } /** * Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template. */ diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index 57238d2..4060d63 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -173,6 +173,7 @@ class ReportController( fun generateStockBalanceReport( @RequestParam(required = false) stockCategory: String?, @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) stockDate: String?, @RequestParam(required = false) balanceFilterStart: String?, @RequestParam(required = false) balanceFilterEnd: String?, @RequestParam(required = false) storeLocation: String?, @@ -180,12 +181,12 @@ class ReportController( @RequestParam(required = false) lastInDateEnd: String?, @RequestParam(required = false) lastOutDateStart: String?, @RequestParam(required = false) lastOutDateEnd: String?, - @RequestParam stockTakeRoundId: Long + @RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long, ): ResponseEntity { val parameters = mutableMapOf() parameters["stockCategory"] = stockCategory ?: "All" parameters["itemNo"] = itemCode ?: "All" - parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + parameters["reportDate"] = (stockDate ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) parameters["storeLocation"] = storeLocation ?: "" parameters["balanceFilterStart"] = balanceFilterStart ?: "" @@ -195,18 +196,30 @@ class ReportController( parameters["lastOutDateStart"] = lastOutDateStart ?: "" parameters["lastOutDateEnd"] = lastOutDateEnd ?: "" - val dbData = reportService.searchStockBalanceReport( - stockCategory, - itemCode, - balanceFilterStart, - balanceFilterEnd, - storeLocation, - lastInDateStart, - lastInDateEnd, - lastOutDateStart, - lastOutDateEnd, - stockTakeRoundId - ) + val dbData = + if (!stockDate.isNullOrBlank()) { + reportService.searchStockBalanceReportByDate( + stockCategory = stockCategory, + itemCode = itemCode, + stockDate = stockDate, + balanceFilterStart = balanceFilterStart, + balanceFilterEnd = balanceFilterEnd, + storeLocation = storeLocation, + ) + } else { + reportService.searchStockBalanceReport( + stockCategory, + itemCode, + balanceFilterStart, + balanceFilterEnd, + storeLocation, + lastInDateStart, + lastInDateEnd, + lastOutDateStart, + lastOutDateEnd, + stockTakeRoundId + ) + } val pdfBytes = reportService.createPdfResponse( "/jasper/StockBalanceReport.jrxml",