| @@ -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,16 @@ return result | |||||
| } | } | ||||
| /** | /** | ||||
| * Stock Balance Report (date-driven). | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.1 | 2026-09-16 | |||||
| * Stock Balance Report (date-driven). One summarized row per item. | |||||
| * | * | ||||
| * - 期初存量: 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] (daily lot close) instead of scanning all stock_ledger: | |||||
| * - 期初存量: historical = that day's opening; today = yesterday's closing | |||||
| * - 現存存貨: historical = that day's closing; today = inventory.onHandQty | |||||
| * (today is not closed yet). If the day row is missing, fall back to previous | |||||
| * close + that day's ledger net. | |||||
| * - 當日入/出/MISS/BAD/TKE: that calendar day's stock_ledger only | |||||
| * - 單位均價: items.AverageUnitPrice | |||||
| * - 庫存總價值: 單位均價 * 現存存貨 | * - 庫存總價值: 單位均價 * 現存存貨 | ||||
| */ | */ | ||||
| fun searchStockBalanceReportByDate( | fun searchStockBalanceReportByDate( | ||||
| @@ -1514,101 +1520,80 @@ 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 | |||||
| WHERE ill.deleted = 0 | |||||
| AND il.itemId = q.itemId | |||||
| AND wh.code LIKE :storeLocation | |||||
| ) | |||||
| """.trimIndent() | |||||
| } else "" | } else "" | ||||
| val forceLedgerIndex = if (itemCode.isNullOrBlank()) { | |||||
| "FORCE INDEX (idx_sl_deleted_date)" | |||||
| } else { | |||||
| "" | |||||
| } | |||||
| val inventoryScopeUnion = if (useLiveInventory) { | |||||
| """ | |||||
| UNION | |||||
| SELECT itemId FROM inventory_per_item WHERE currentBalance <> 0 | |||||
| """.trimIndent() | |||||
| } 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, | |||||
| SUM(COALESCE(d.opening, 0)) AS openingBalance, | |||||
| SUM(COALESCE(d.closing, 0)) AS currentBalance | |||||
| FROM stock_lot_day d | |||||
| WHERE IFNULL(d.deleted, 0) = 0 | |||||
| AND d.date = :d0 | |||||
| AND d.itemId IS NOT NULL | |||||
| $itemCodeSqlDay | |||||
| GROUP BY d.itemId | |||||
| ), | ), | ||||
| 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, | |||||
| SUM(COALESCE(d.closing, 0)) AS openingBalance | |||||
| FROM stock_lot_day d | |||||
| WHERE IFNULL(d.deleted, 0) = 0 | |||||
| AND d.date = :d1 | |||||
| AND d.itemId IS NOT NULL | |||||
| $itemCodeSqlDay | |||||
| GROUP BY d.itemId | |||||
| ), | ), | ||||
| item_scope AS ( | |||||
| inventory_per_item 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, | |||||
| SUM(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 | |||||
| $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 | |||||
| GROUP BY iv.itemId | |||||
| ), | ), | ||||
| ledger_moves AS ( | ledger_moves AS ( | ||||
| SELECT | SELECT | ||||
| @@ -1651,14 +1636,74 @@ 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 | |||||
| FROM stock_ledger sl | |||||
| INNER JOIN params p ON 1=1 | |||||
| ) AS variance, | |||||
| SUM(COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS dayNet | |||||
| FROM stock_ledger sl $forceLedgerIndex | |||||
| 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 | |||||
| AND sl.date >= :d0Start | |||||
| AND sl.date < :d0EndExclusive | |||||
| $itemCodeSqlSl | |||||
| GROUP BY sl.itemId | GROUP BY sl.itemId | ||||
| ), | |||||
| item_ids AS ( | |||||
| SELECT itemId FROM day0 | |||||
| UNION | |||||
| SELECT itemId FROM day1 | |||||
| UNION | |||||
| SELECT itemId FROM ledger_moves | |||||
| $inventoryScopeUnion | |||||
| ), | |||||
| 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 | |||||
| INNER JOIN item_ids ids ON ids.itemId = it.id | |||||
| 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 | |||||
| ), | |||||
| qty_per_item AS ( | |||||
| SELECT | |||||
| ids.itemId, | |||||
| COALESCE( | |||||
| CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END, | |||||
| d1.openingBalance, | |||||
| 0 | |||||
| ) AS openingBalance, | |||||
| COALESCE( | |||||
| CASE WHEN $liveFlag = 1 THEN invc.currentBalance ELSE d0.currentBalance END, | |||||
| COALESCE( | |||||
| CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END, | |||||
| 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 item_ids ids | |||||
| LEFT JOIN day0 d0 ON d0.itemId = ids.itemId | |||||
| LEFT JOIN day1 d1 ON d1.itemId = ids.itemId | |||||
| LEFT JOIN inventory_per_item invc ON invc.itemId = ids.itemId | |||||
| LEFT JOIN ledger_moves lm ON lm.itemId = ids.itemId | |||||
| ) | ) | ||||
| SELECT | SELECT | ||||
| '' AS stockSubCategory, | '' AS stockSubCategory, | ||||
| @@ -1672,31 +1717,27 @@ 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, | |||||
| 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(s.avgUnitPriceRaw, 0), 2), 2) AS avgUnitPrice, | 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 | |||||
| FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0) * COALESCE(q.currentBalance, 0), 2), 2) AS totalStockBalance | |||||
| FROM qty_per_item q | |||||
| INNER JOIN item_scope s ON s.itemId = q.itemId | |||||
| LEFT JOIN stock_one so ON so.itemId = q.itemId | |||||
| LEFT JOIN uom_conversion uc ON so.uomId = uc.id | |||||
| WHERE 1=1 | WHERE 1=1 | ||||
| $storeLocationSql | $storeLocationSql | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -1713,10 +1754,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,7 +1764,17 @@ return result | |||||
| """.trimIndent() | """.trimIndent() | ||||
| } | } | ||||
| return jdbcDao.queryForList("$finalSql ORDER BY itemNo", args) | |||||
| val t0 = System.nanoTime() | |||||
| val rows = jdbcDao.queryForList("$finalSql ORDER BY itemNo", args) | |||||
| 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. | ||||