Tommy\2Fi-Staff 1 день назад
Родитель
Сommit
6f60da9040
2 измененных файлов: 538 добавлений и 144 удалений
  1. +511
    -130
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  2. +27
    -14
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt

+ 511
- 130
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt Просмотреть файл

@@ -286,16 +286,18 @@ return result
return jdbcDao.queryForList(sql, emptyMap<String, Any>()).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<Map<String, Any>> {
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<Map<String, Any>> {
val args = mutableMapOf<String, Any>()

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<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
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<String>()
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.
*/


+ 27
- 14
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<ByteArray> {
val parameters = mutableMapOf<String, Any>()
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",


Загрузка…
Отмена
Сохранить