@@ -12,9 +12,11 @@ import com.ffii.fpsms.modules.master.enums.ShopType
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService
import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService
import java.math.BigDecimal
import java.math.BigDecimal
import java.time.LocalDate
import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter
import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter
import net.sf.jasperreports.export.SimpleExporterInput
import net.sf.jasperreports.export.SimpleExporterInput
import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput
import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput
import org.slf4j.LoggerFactory
@Service
@Service
open class ReportService(
open class ReportService(
@@ -23,6 +25,7 @@ open class ReportService(
private val shopRepository: ShopRepository,
private val shopRepository: ShopRepository,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {
) {
private val log = LoggerFactory.getLogger(javaClass)
/**
/**
* Queries the database for inventory data based on dates and optional item type.
* Queries the database for inventory data based on dates and optional item type.
*/
*/
@@ -1495,13 +1498,15 @@ return result
}
}
/**
/**
* Stock Balance Report (date-driven).
* FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.2 | 2026-09-11
* Stock Balance Report (date-driven). One download row per item + stock UoM.
*
*
* - 期初存量: 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) 算加權平均
* Qty comes from [stock_lot_day] (that day's close) instead of scanning all stock_ledger:
* - Rows: all inventory item+UoM buckets (including zeros) plus lots on stockDate / stockDate-1
* - 期初存量: stock_lot_day.closing on stockDate-1
* - 現存存貨: historical = closing on stockDate; today = inventory.onHandQty
* - 當日入/出/MISS/BAD/TKE: that calendar day's stock_ledger only
* - 單位均價: items.AverageUnitPrice converted from stock unit to this UoM
* - 庫存總價值: 單位均價 * 現存存貨
* - 庫存總價值: 單位均價 * 現存存貨
*/
*/
fun searchStockBalanceReportByDate(
fun searchStockBalanceReportByDate(
@@ -1514,105 +1519,82 @@ return result
): List<Map<String, Any>> {
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val args = mutableMapOf<String, Any>()
val formattedStockDate = stockDate.replace("/", "-")
val formattedStockDate = stockDate.replace("/", "-")
args["stockDate"] = formattedStockDate
val stockLocalDate = LocalDate.parse(formattedStockDate)
val useLiveInventory = !stockLocalDate.isBefore(LocalDate.now())
val liveFlag = if (useLiveInventory) "1" else "0"
args["d0"] = stockLocalDate
args["d1"] = stockLocalDate.minusDays(1)
args["d0Start"] = stockLocalDate.atStartOfDay()
args["d0EndExclusive"] = stockLocalDate.plusDays(1).atStartOfDay()
val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args)
val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args)
val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args)
val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args)
val itemCodeSqlDay = buildMultiValueLikeClause(itemCode, "d.itemCode", "itemCodeDay", args)
val itemCodeSqlSl = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCodeSl", args)
val storeLocationSql = if (!storeLocation.isNullOrBlank()) {
val storeLocationSql = if (!storeLocation.isNullOrBlank()) {
args["storeLocation"] = "%$storeLocation%"
args["storeLocation"] = "%$storeLocation%"
"AND COALESCE(store_location.storeLocation, '') LIKE :storeLocation"
"""
AND EXISTS (
SELECT 1
FROM inventory_lot_line ill
INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0
INNER JOIN warehouse wh ON wh.id = ill.warehouseId AND wh.deleted = 0
LEFT JOIN item_uom iu_loc ON iu_loc.id = ill.stockItemUomId AND iu_loc.deleted = 0
WHERE ill.deleted = 0
AND il.itemId = q.itemId
AND iu_loc.uomId = q.uomId
AND wh.code LIKE :storeLocation
)
""".trimIndent()
} else ""
} else ""
val baseSql = """
val baseSql = """
WITH params AS (
WITH day0 AS (
SELECT
SELECT
DATE(:stockDate) AS d0,
DATE_SUB(DATE(:stockDate), INTERVAL 1 DAY) AS d1
d.itemId,
COALESCE(d.uomId, iu.uomId) AS uomId,
SUM(COALESCE(d.opening, 0)) AS openingBalance,
SUM(COALESCE(d.closing, 0)) AS currentBalance
FROM stock_lot_day d
LEFT JOIN inventory_lot_line ill ON ill.id = d.inventoryLotLineId
LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId
WHERE IFNULL(d.deleted, 0) = 0
AND d.date = :d0
AND d.itemId IS NOT NULL
AND COALESCE(d.uomId, iu.uomId) IS NOT NULL
$itemCodeSqlDay
GROUP BY d.itemId, COALESCE(d.uomId, iu.uomId)
),
),
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
day1 AS (
SELECT
d.itemId,
COALESCE(d.uomId, iu.uomId) AS uomId,
SUM(COALESCE(d.closing, 0)) AS openingBalance
FROM stock_lot_day d
LEFT JOIN inventory_lot_line ill ON ill.id = d.inventoryLotLineId
LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId
WHERE IFNULL(d.deleted, 0) = 0
AND d.date = :d1
AND d.itemId IS NOT NULL
AND COALESCE(d.uomId, iu.uomId) IS NOT NULL
$itemCodeSqlDay
GROUP BY d.itemId, COALESCE(d.uomId, iu.uomId)
),
),
item_scope AS (
inventory_per_uom AS (
SELECT
SELECT
it.id AS itemId,
it.code AS itemNo,
it.name AS itemName,
it.type AS itemType,
COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw
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 <> ''
iv.itemId,
iv.stockUomId AS uomId,
COALESCE(iv.onHandQty, 0) AS currentBalance
FROM inventory iv
INNER JOIN items it ON it.id = iv.itemId AND it.deleted = 0
WHERE iv.deleted = 0
AND iv.stockUomId IS NOT NULL
$itemCodeSql
$itemCodeSql
$stockCategorySql
$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
),
ledger_moves AS (
ledger_moves AS (
SELECT
SELECT
sl.itemId,
sl.itemId,
COALESCE(sl.uomId, inv.stockUomId) AS uomId,
SUM(
SUM(
CASE
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ')
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ')
@@ -1651,14 +1633,82 @@ return result
THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)
THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)
ELSE 0
ELSE 0
END
END
) AS variance
) AS variance,
SUM(COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS dayNet
FROM stock_ledger sl
FROM stock_ledger sl
INNER JOIN params p ON 1=1
LEFT JOIN inventory inv ON inv.id = sl.inventoryId AND inv.deleted = 0
WHERE sl.deleted = 0
WHERE sl.deleted = 0
AND sl.itemId IS NOT NULL
AND sl.itemId IS NOT NULL
AND DATE(sl.date) > p.d1
AND DATE(sl.date) <= p.d0
GROUP BY sl.itemId
AND sl.date >= :d0Start
AND sl.date < :d0EndExclusive
AND COALESCE(sl.uomId, inv.stockUomId) IS NOT NULL
$itemCodeSqlSl
GROUP BY sl.itemId, COALESCE(sl.uomId, inv.stockUomId)
),
uom_scope AS (
SELECT itemId, uomId FROM day0
UNION
SELECT itemId, uomId FROM day1
UNION
SELECT itemId, uomId FROM inventory_per_uom
UNION
SELECT itemId, uomId FROM ledger_moves
),
item_scope AS (
SELECT
it.id AS itemId,
it.code AS itemNo,
it.name AS itemName,
COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw
FROM items it
WHERE it.deleted = 0
AND it.code IS NOT NULL AND it.code <> ''
$itemCodeSql
$stockCategorySql
),
stock_one AS (
SELECT iu.*
FROM item_uom iu
INNER JOIN (
SELECT itemId, MIN(id) AS id
FROM item_uom
WHERE deleted = 0 AND stockUnit = 1
GROUP BY itemId
) x ON x.id = iu.id
),
row_uom AS (
SELECT iu.*
FROM item_uom iu
INNER JOIN (
SELECT itemId, uomId, MIN(id) AS id
FROM item_uom
WHERE deleted = 0
GROUP BY itemId, uomId
) x ON x.id = iu.id
),
qty_per_uom AS (
SELECT
us.itemId,
us.uomId,
COALESCE(
CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE COALESCE(d0.openingBalance, d1.openingBalance) END,
0
) AS openingBalance,
COALESCE(
CASE WHEN $liveFlag = 1 THEN invc.currentBalance ELSE d0.currentBalance END,
COALESCE(d0.openingBalance, d1.openingBalance, 0) + COALESCE(lm.dayNet, 0),
0
) AS currentBalance,
COALESCE(lm.cumStockIn, 0) AS cumStockIn,
COALESCE(lm.cumStockOut, 0) AS cumStockOut,
COALESCE(lm.misInputAndLost, 0) AS misInputAndLost,
COALESCE(lm.variance, 0) AS variance,
COALESCE(lm.defectiveGoods, 0) AS defectiveGoods
FROM uom_scope us
LEFT JOIN day0 d0 ON d0.itemId = us.itemId AND d0.uomId = us.uomId
LEFT JOIN day1 d1 ON d1.itemId = us.itemId AND d1.uomId = us.uomId
LEFT JOIN inventory_per_uom invc ON invc.itemId = us.itemId AND invc.uomId = us.uomId
LEFT JOIN ledger_moves lm ON lm.itemId = us.itemId AND lm.uomId = us.uomId
)
)
SELECT
SELECT
'' AS stockSubCategory,
'' AS stockSubCategory,
@@ -1672,31 +1722,50 @@ return result
'' AS cumStockOut,
'' AS cumStockOut,
'' AS currentBalance,
'' AS currentBalance,
'' AS reOrderQty,
'' 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,
CASE WHEN COALESCE(lm.cumStockIn, 0) < 0 THEN CONCAT('(', FORMAT(-lm.cumStockIn, 0), ')') ELSE FORMAT(COALESCE(lm.cumStockIn, 0) , 0) END AS totalCumStockIn,
CASE WHEN COALESCE(lm.cumStockOut, 0) < 0 THEN CONCAT('(', FORMAT(-lm.cumStockOut, 0), ')') ELSE FORMAT(COALESCE(lm.cumStockOut, 0) , 0) END 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 storeLocation,
'' AS lastInDate,
'' AS lastOutDate,
q.openingBalance AS openingBalanceRaw,
q.currentBalance AS currentBalanceRaw,
CASE WHEN q.openingBalance < 0 THEN CONCAT('(', FORMAT(-q.openingBalance, 0), ')') ELSE FORMAT(q.openingBalance , 0) END AS totalOpeningBalance,
CASE WHEN q.cumStockIn < 0 THEN CONCAT('(', FORMAT(-q.cumStockIn, 0), ')') ELSE FORMAT(q.cumStockIn , 0) END AS totalCumStockIn,
CASE WHEN q.cumStockOut < 0 THEN CONCAT('(', FORMAT(-q.cumStockOut, 0), ')') ELSE FORMAT(q.cumStockOut , 0) END AS totalCumStockOut,
CASE WHEN q.currentBalance < 0 THEN CONCAT('(', FORMAT(-q.currentBalance, 0), ')') ELSE FORMAT(q.currentBalance , 0) END AS totalCurrentBalance,
'' AS misInputAndLost,
'' AS misInputAndLost,
'' AS defectiveGoods,
'' AS defectiveGoods,
'' AS variance,
'' AS variance,
CASE WHEN COALESCE(lm.misInputAndLost, 0) < 0 THEN CONCAT('(', FORMAT(-lm.misInputAndLost, 0), ')') ELSE FORMAT(COALESCE(lm.misInputAndLost, 0), 0) END AS totalMisInputAndLost,
CASE WHEN COALESCE(lm.variance, 0) < 0 THEN CONCAT('(', FORMAT(-lm.variance, 0), ')') ELSE FORMAT(COALESCE(lm.variance, 0), 0) END AS totalVariance,
CASE WHEN COALESCE(lm.defectiveGoods, 0) < 0 THEN CONCAT('(', FORMAT(-lm.defectiveGoods, 0), ')') ELSE FORMAT(COALESCE(lm.defectiveGoods, 0), 0) END AS totalDefectiveGoods,
FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0), 2), 2) AS avgUnitPrice,
FORMAT(ROUND(COALESCE(s.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 ledger_moves lm ON lm.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
CASE WHEN q.misInputAndLost < 0 THEN CONCAT('(', FORMAT(-q.misInputAndLost, 0), ')') ELSE FORMAT(q.misInputAndLost, 0) END AS totalMisInputAndLost,
CASE WHEN q.variance < 0 THEN CONCAT('(', FORMAT(-q.variance, 0), ')') ELSE FORMAT(q.variance, 0) END AS totalVariance,
CASE WHEN q.defectiveGoods < 0 THEN CONCAT('(', FORMAT(-q.defectiveGoods, 0), ')') ELSE FORMAT(q.defectiveGoods, 0) END AS totalDefectiveGoods,
FORMAT(ROUND(COALESCE(
CASE
WHEN iu_row.id IS NULL OR so.id IS NULL OR iu_row.uomId = so.uomId THEN
s.avgUnitPriceRaw
ELSE
ROUND(
s.avgUnitPriceRaw
* (COALESCE(iu_row.ratioN, 1) / COALESCE(NULLIF(iu_row.ratioD, 0), 1))
* (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)),
4
)
END, 0), 2), 2) AS avgUnitPrice,
FORMAT(ROUND(COALESCE(
CASE
WHEN iu_row.id IS NULL OR so.id IS NULL OR iu_row.uomId = so.uomId THEN
s.avgUnitPriceRaw
ELSE
ROUND(
s.avgUnitPriceRaw
* (COALESCE(iu_row.ratioN, 1) / COALESCE(NULLIF(iu_row.ratioD, 0), 1))
* (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)),
4
)
END, 0) * COALESCE(q.currentBalance, 0), 2), 2) AS totalStockBalance
FROM qty_per_uom q
INNER JOIN item_scope s ON s.itemId = q.itemId
LEFT JOIN uom_conversion uc ON uc.id = q.uomId
LEFT JOIN row_uom iu_row ON iu_row.itemId = q.itemId AND iu_row.uomId = q.uomId
LEFT JOIN stock_one so ON so.itemId = q.itemId
WHERE 1=1
WHERE 1=1
$storeLocationSql
$storeLocationSql
""".trimIndent()
""".trimIndent()
@@ -1713,10 +1782,8 @@ return result
val finalSql =
val finalSql =
if (filters.isEmpty()) {
if (filters.isEmpty()) {
// no numeric filter: just order by itemNo
baseSql
baseSql
} else {
} else {
// wrap to filter on raw numeric current balance
"""
"""
SELECT * FROM (
SELECT * FROM (
$baseSql
$baseSql
@@ -1725,8 +1792,31 @@ return result
""".trimIndent()
""".trimIndent()
}
}
return jdbcDao.queryForList("$finalSql ORDER BY itemNo", args)
val t0 = System.nanoTime()
val rows = try {
jdbcDao.queryForList("$finalSql ORDER BY itemNo, unitOfMeasure", args)
} catch (e: Exception) {
log.error(
"stock-balance-by-date FAILED date={} liveInventory={} itemCode={}: {}",
stockLocalDate,
useLiveInventory,
itemCode ?: "",
e.message,
e,
)
throw e
}
log.info(
"stock-balance-by-date date={} liveInventory={} itemCode={} rows={} {}ms",
stockLocalDate,
useLiveInventory,
itemCode ?: "",
rows.size,
(System.nanoTime() - t0) / 1_000_000,
)
return rows
}
}
/**
/**
* Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template.
* Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template.
*/
*/