庫存明細報告 improve speed 送貨訂單與倉存單位不符 excel 預計到貨→預計送貨fix負數倉
| @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service | |||||
| open class ItemQcFailReportService( | open class ItemQcFailReportService( | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| ) { | ) { | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ | |||||
| fun searchItemQcFailReport( | fun searchItemQcFailReport( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| itemCode: String?, | itemCode: String?, | ||||
| @@ -33,16 +34,19 @@ open class ItemQcFailReportService( | |||||
| val qcItemScopeSql = buildQcItemScopeClause(measurable, other) | val qcItemScopeSql = buildQcItemScopeClause(measurable, other) | ||||
| val measuredValueSql = buildMeasuredValueClause(measurable, scope) | val measuredValueSql = buildMeasuredValueClause(measurable, scope) | ||||
| // Date filter = QC result created time (true QC finish), not receiptDate / put-away. | |||||
| // JO EPQC: receiptDate is often SIL create day; put-away ≈ QC via auto put-away. | |||||
| // PO IQC: put-away can be days after QC — must not use ill.created for this filter. | |||||
| val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { | val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { | ||||
| val formattedDate = lastInDateStart.replace("/", "-") | val formattedDate = lastInDateStart.replace("/", "-") | ||||
| args["lastInDateStart"] = formattedDate | args["lastInDateStart"] = formattedDate | ||||
| "AND DATE(sil.receiptDate) >= DATE(:lastInDateStart)" | |||||
| "AND DATE(qr.created) >= DATE(:lastInDateStart)" | |||||
| } else "" | } else "" | ||||
| val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) { | val lastInDateEndSql = if (!lastInDateEnd.isNullOrBlank()) { | ||||
| val formattedDate = lastInDateEnd.replace("/", "-") | val formattedDate = lastInDateEnd.replace("/", "-") | ||||
| args["lastInDateEnd"] = formattedDate | args["lastInDateEnd"] = formattedDate | ||||
| "AND DATE(sil.receiptDate) <= DATE(:lastInDateEnd)" | |||||
| "AND DATE(qr.created) <= DATE(:lastInDateEnd)" | |||||
| } else "" | } else "" | ||||
| val sql = """ | val sql = """ | ||||
| @@ -4,17 +4,25 @@ import com.ffii.core.support.JdbcDao | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| @Service | @Service | ||||
| open class StockLedgerReportService( | open class StockLedgerReportService( | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| ) { | ) { | ||||
| private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") | |||||
| /** | /** | ||||
| * FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 | |||||
| * Stock Ledger 報表查詢 | * Stock Ledger 報表查詢 | ||||
| * | * | ||||
| * - stockSubCategory = items.type | * - stockSubCategory = items.type | ||||
| * - trnDate = stock_ledger.date | * - trnDate = stock_ledger.date | ||||
| * - trnRefNo = stock_ledger.type | * - trnRefNo = stock_ledger.type | ||||
| * - cumBalance = stock_ledger.balance(異動後纍計存量) | |||||
| * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) | |||||
| * | |||||
| * 只查 [start, end] 期間列,不掃起日以前全歷史。 | |||||
| */ | */ | ||||
| fun searchStockLedgerReport( | fun searchStockLedgerReport( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| @@ -23,18 +31,15 @@ open class StockLedgerReportService( | |||||
| reportPeriodStart: String?, | reportPeriodStart: String?, | ||||
| reportPeriodEnd: String?, | reportPeriodEnd: String?, | ||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| // 1) 先決定 reportPeriodEnd:如果有填 end,就用使用者的;否則用今天 | |||||
| val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") | val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") | ||||
| ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) | |||||
| // 2) 如果有填 start,就用使用者的;否則從 DB 查最早一筆日期 | |||||
| ?: LocalDate.now().format(dateFmt)) | |||||
| val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { | val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { | ||||
| reportPeriodStart.replace("/", "-") | reportPeriodStart.replace("/", "-") | ||||
| } else { | } else { | ||||
| // 用簡單 SQL 查全表最早一筆日期(可加上類似 stockCategory/itemCode 過濾) | |||||
| val minDateSql = """ | val minDateSql = """ | ||||
| SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate | SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate | ||||
| FROM stock_ledger sl | FROM stock_ledger sl | ||||
| @@ -42,188 +47,39 @@ open class StockLedgerReportService( | |||||
| AND sl.itemCode IS NOT NULL | AND sl.itemCode IS NOT NULL | ||||
| AND sl.itemCode <> '' | AND sl.itemCode <> '' | ||||
| """.trimIndent() | """.trimIndent() | ||||
| val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap<String, Any>()).firstOrNull() | val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap<String, Any>()).firstOrNull() | ||||
| (minDateRow?.get("firstDate") as? String) | (minDateRow?.get("firstDate") as? String) | ||||
| ?: reportPeriodEnd // 如果表是空的,就退回用今天 | |||||
| ?: reportPeriodEnd | |||||
| } | } | ||||
| // 3) 把 from/to 塞到 args,供後面 SQL 使用 | |||||
| val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) | |||||
| args["reportPeriodStart"] = reportPeriodStart | args["reportPeriodStart"] = reportPeriodStart | ||||
| args["reportPeriodEnd"] = reportPeriodEnd | |||||
| // 4) 之後再用你原來的 stockCategorySql / itemCodeSql / storeLocationSql | |||||
| args["reportPeriodEndExclusive"] = endExclusive | |||||
| val stockCategorySql = buildMultiValueExactClause( | val stockCategorySql = buildMultiValueExactClause( | ||||
| stockCategory, | stockCategory, | ||||
| "it.type", | "it.type", | ||||
| "stockCategory", | "stockCategory", | ||||
| args | args | ||||
| ) | ) | ||||
| val itemCodeSql = buildMultiValueLikeClause( | val itemCodeSql = buildMultiValueLikeClause( | ||||
| itemCode, | itemCode, | ||||
| "sl.itemCode", | "sl.itemCode", | ||||
| "itemCode", | "itemCode", | ||||
| args | args | ||||
| ) | ) | ||||
| // 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數 | |||||
| val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | ||||
| args["storeLocation"] = "%$storeLocation%" | args["storeLocation"] = "%$storeLocation%" | ||||
| "AND (wh_in.code LIKE :storeLocation OR wh_out.code LIKE :storeLocation)" | |||||
| "AND lot.storeLocation LIKE :storeLocation" | |||||
| } else { | } else { | ||||
| "" | "" | ||||
| } | } | ||||
| val reportPeriodEndSql = "AND DATE(sl.date) <= :reportPeriodEnd" | |||||
| val sql = """ | val sql = """ | ||||
| WITH base AS ( | |||||
| SELECT | |||||
| sl.id AS slId, | |||||
| DATE(sl.date) AS trnDateRaw, | |||||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, | |||||
| CASE | |||||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' | |||||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' | |||||
| AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' | |||||
| ELSE COALESCE(sl.type, '') | |||||
| END AS trnRefNoRaw, | |||||
| sl.itemId AS itemId, | |||||
| sl.itemCode AS itemCode, | |||||
| COALESCE(sl.inQty, 0) AS inQty, | |||||
| COALESCE(sl.outQty, 0) AS outQty, | |||||
| (COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS delta, | |||||
| it.type AS stockSubCategory, | |||||
| it.code AS itemNo, | |||||
| it.name AS itemName, | |||||
| uc.udfudesc AS unitOfMeasure, | |||||
| lot.lotNo AS lotNo, | |||||
| COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, | |||||
| lot.storeLocation AS storeLocation, | |||||
| COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, | |||||
| COALESCE(TRIM(jo.code), '') AS jobOrderNo, | |||||
| '' AS openingBalance, | |||||
| '' AS cumStockIn, | |||||
| '' AS cumStockOut, | |||||
| '' AS currentBalance, | |||||
| '' AS lastInDate, | |||||
| '' AS lastOutDate, | |||||
| '' AS reOrderLevel, | |||||
| '' AS reOrderQty | |||||
| 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, | |||||
| il.lotNo AS lotNo, | |||||
| il.expiryDate AS expiryDate, | |||||
| 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, il.lotNo, il.expiryDate | |||||
| ) lot | |||||
| ON lot.lotId = COALESCE(il_in.id, il_out.id) | |||||
| LEFT JOIN items it | |||||
| ON sl.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 | |||||
| LEFT JOIN uom_conversion uc | |||||
| ON iu.uomId = uc.id | |||||
| -- 這兩個 alias 是為了配合你上面 storeLocationSql 的 wh_in / wh_out | |||||
| LEFT JOIN inventory_lot_line ill_in | |||||
| ON il_in.id = ill_in.inventoryLotId | |||||
| AND ill_in.deleted = 0 | |||||
| LEFT JOIN warehouse wh_in | |||||
| ON ill_in.warehouseId = wh_in.id | |||||
| AND wh_in.deleted = 0 | |||||
| LEFT JOIN warehouse wh_out | |||||
| ON ill_out.warehouseId = wh_out.id | |||||
| AND wh_out.deleted = 0 | |||||
| LEFT JOIN stock_out so | |||||
| ON sol.stockOutId = so.id | |||||
| AND so.deleted = 0 | |||||
| LEFT JOIN pick_order_line pol | |||||
| ON sol.pickOrderLineId = pol.id | |||||
| AND pol.deleted = 0 | |||||
| LEFT JOIN pick_order po_out | |||||
| ON pol.poId = po_out.id | |||||
| AND po_out.deleted = 0 | |||||
| LEFT JOIN job_order jo_po | |||||
| ON po_out.joId = jo_po.id | |||||
| AND jo_po.deleted = 0 | |||||
| LEFT JOIN delivery_order do | |||||
| ON po_out.doId = do.id | |||||
| AND do.deleted = 0 | |||||
| LEFT JOIN stock_in si | |||||
| ON sil.stockInId = si.id | |||||
| AND si.deleted = 0 | |||||
| LEFT JOIN job_order jo | |||||
| ON sil.jobOrderId = jo.id | |||||
| AND jo.deleted = 0 | |||||
| LEFT JOIN purchase_order po | |||||
| ON sil.purchaseOrderId = po.id | |||||
| AND po.deleted = 0 | |||||
| WHERE | |||||
| sl.deleted = 0 | |||||
| AND sl.itemCode IS NOT NULL | |||||
| AND sl.itemCode <> '' | |||||
| AND DATE(sl.date) <= :reportPeriodEnd | |||||
| $stockCategorySql | |||||
| $itemCodeSql | |||||
| $storeLocationSql | |||||
| AND lot.lotId IS NOT NULL | |||||
| ), | |||||
| opening AS ( | |||||
| SELECT | |||||
| itemCode, | |||||
| COALESCE(SUM(delta), 0) AS openingBeforeStart | |||||
| FROM base | |||||
| WHERE trnDateRaw < :reportPeriodStart | |||||
| GROUP BY itemCode | |||||
| ), | |||||
| period AS ( | |||||
| SELECT | |||||
| b.*, | |||||
| COALESCE(o.openingBeforeStart, 0) AS openingBeforeStart | |||||
| FROM base b | |||||
| LEFT JOIN opening o | |||||
| ON o.itemCode = b.itemCode | |||||
| WHERE b.trnDateRaw BETWEEN :reportPeriodStart AND :reportPeriodEnd | |||||
| ) | |||||
| SELECT | SELECT | ||||
| stockSubCategory, | stockSubCategory, | ||||
| itemNo, | itemNo, | ||||
| @@ -231,7 +87,7 @@ SELECT | |||||
| unitOfMeasure, | unitOfMeasure, | ||||
| lotNo, | lotNo, | ||||
| expiryDate, | expiryDate, | ||||
| trnDate, | |||||
| trnDate, | |||||
| CASE trnRefNoRaw | CASE trnRefNoRaw | ||||
| WHEN 'OPEN' THEN '開倉' | WHEN 'OPEN' THEN '開倉' | ||||
| WHEN 'NOR' THEN '出入倉' | WHEN 'NOR' THEN '出入倉' | ||||
| @@ -255,39 +111,171 @@ SELECT | |||||
| reOrderLevel, | reOrderLevel, | ||||
| reOrderQty, | reOrderQty, | ||||
| -- jrxml 需要 String;負數括號顯示,無小數 | |||||
| CASE WHEN COALESCE(inQty, 0) < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(COALESCE(inQty, 0), 0) END AS stockIn, | |||||
| CASE WHEN COALESCE(outQty, 0) < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(COALESCE(outQty, 0), 0) END AS stockOut, | |||||
| -- 累計存量(跨 lot:只用 itemCode 分區) | |||||
| CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)) < 0 | |||||
| THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId)), 0), ')') | |||||
| ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId), 0) END AS cumBalance, | |||||
| -- 累計期初存量 = 本行累計 - 本行異動 | |||||
| CASE WHEN (openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta) < 0 | |||||
| THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta), 0), ')') | |||||
| ELSE FORMAT(openingBeforeStart + SUM(delta) OVER (PARTITION BY itemCode ORDER BY trnDateRaw, slId) - delta, 0) END AS cumOpeningBal, | |||||
| -- footer totals(同樣輸出 String) | |||||
| CASE WHEN COALESCE(openingBeforeStart, 0) < 0 THEN CONCAT('(', FORMAT(-openingBeforeStart, 0), ')') ELSE FORMAT(COALESCE(openingBeforeStart, 0), 0) END AS totalCumOpeningBal, | |||||
| CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockIn, | |||||
| CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemCode), 0) END AS totalStockOut, | |||||
| CASE WHEN (openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)) < 0 | |||||
| THEN CONCAT('(', FORMAT(-(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode)), 0), ')') | |||||
| ELSE FORMAT(openingBeforeStart + SUM(inQty) OVER (PARTITION BY itemCode) - SUM(outQty) OVER (PARTITION BY itemCode), 0) END AS totalCumBalance | |||||
| FROM period | |||||
| CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn, | |||||
| CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut, | |||||
| CASE WHEN cumOpeningBalRaw < 0 | |||||
| THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')') | |||||
| ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal, | |||||
| CASE WHEN bal < 0 | |||||
| THEN CONCAT('(', FORMAT(-bal, 0), ')') | |||||
| ELSE FORMAT(bal, 0) END AS cumBalance, | |||||
| CASE WHEN totalCumOpeningBalRaw < 0 | |||||
| THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')') | |||||
| ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal, | |||||
| CASE WHEN totalStockInRaw < 0 | |||||
| THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')') | |||||
| ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn, | |||||
| CASE WHEN totalStockOutRaw < 0 | |||||
| THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')') | |||||
| ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut, | |||||
| CASE WHEN totalCumBalanceRaw < 0 | |||||
| THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')') | |||||
| ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance | |||||
| FROM ( | |||||
| SELECT | |||||
| x.*, | |||||
| FIRST_VALUE(cumOpeningBalRaw) OVER ( | |||||
| PARTITION BY itemCode ORDER BY trnDateRaw, slId | |||||
| ) AS totalCumOpeningBalRaw, | |||||
| SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw, | |||||
| SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw, | |||||
| FIRST_VALUE(bal) OVER ( | |||||
| PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC | |||||
| ) AS totalCumBalanceRaw | |||||
| FROM ( | |||||
| SELECT | |||||
| sl.id AS slId, | |||||
| DATE(sl.date) AS trnDateRaw, | |||||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, | |||||
| CASE | |||||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' | |||||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' | |||||
| AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' | |||||
| ELSE COALESCE(sl.type, '') | |||||
| END AS trnRefNoRaw, | |||||
| sl.itemCode AS itemCode, | |||||
| COALESCE(sl.inQty, 0) AS inQty, | |||||
| COALESCE(sl.outQty, 0) AS outQty, | |||||
| COALESCE(sl.balance, 0) AS bal, | |||||
| COALESCE(sl.balance, 0) | |||||
| - COALESCE(sl.inQty, 0) | |||||
| + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, | |||||
| it.type AS stockSubCategory, | |||||
| it.code AS itemNo, | |||||
| it.name AS itemName, | |||||
| uc.udfudesc AS unitOfMeasure, | |||||
| lot.lotNo AS lotNo, | |||||
| COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, | |||||
| lot.storeLocation AS storeLocation, | |||||
| COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, | |||||
| COALESCE(TRIM(jo.code), '') AS jobOrderNo, | |||||
| '' AS openingBalance, | |||||
| '' AS cumStockIn, | |||||
| '' AS cumStockOut, | |||||
| '' AS currentBalance, | |||||
| '' AS lastInDate, | |||||
| '' AS lastOutDate, | |||||
| '' AS reOrderLevel, | |||||
| '' AS reOrderQty | |||||
| 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, | |||||
| il.lotNo AS lotNo, | |||||
| il.expiryDate AS expiryDate, | |||||
| 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 | |||||
| WHERE il.deleted = 0 | |||||
| GROUP BY | |||||
| il.id, il.lotNo, il.expiryDate | |||||
| ) lot | |||||
| ON lot.lotId = COALESCE(il_in.id, il_out.id) | |||||
| LEFT JOIN items it | |||||
| ON sl.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 | |||||
| LEFT JOIN uom_conversion uc | |||||
| ON iu.uomId = uc.id | |||||
| LEFT JOIN stock_out so | |||||
| ON sol.stockOutId = so.id | |||||
| AND so.deleted = 0 | |||||
| LEFT JOIN pick_order_line pol | |||||
| ON sol.pickOrderLineId = pol.id | |||||
| AND pol.deleted = 0 | |||||
| LEFT JOIN pick_order po_out | |||||
| ON pol.poId = po_out.id | |||||
| AND po_out.deleted = 0 | |||||
| LEFT JOIN job_order jo_po | |||||
| ON po_out.joId = jo_po.id | |||||
| AND jo_po.deleted = 0 | |||||
| LEFT JOIN delivery_order do | |||||
| ON po_out.doId = do.id | |||||
| AND do.deleted = 0 | |||||
| LEFT JOIN stock_in si | |||||
| ON sil.stockInId = si.id | |||||
| AND si.deleted = 0 | |||||
| LEFT JOIN job_order jo | |||||
| ON sil.jobOrderId = jo.id | |||||
| AND jo.deleted = 0 | |||||
| LEFT JOIN purchase_order po | |||||
| ON sil.purchaseOrderId = po.id | |||||
| AND po.deleted = 0 | |||||
| WHERE | |||||
| sl.deleted = 0 | |||||
| AND sl.itemCode IS NOT NULL | |||||
| AND sl.itemCode <> '' | |||||
| AND sl.date >= :reportPeriodStart | |||||
| AND sl.date < :reportPeriodEndExclusive | |||||
| $stockCategorySql | |||||
| $itemCodeSql | |||||
| $storeLocationSql | |||||
| AND lot.lotId IS NOT NULL | |||||
| ) x | |||||
| ) y | |||||
| ORDER BY | ORDER BY | ||||
| itemNo, | itemNo, | ||||
| trnDateRaw, | trnDateRaw, | ||||
| slId, | slId, | ||||
| lotNo | lotNo | ||||
| """.trimIndent() | """.trimIndent() | ||||
| val result = jdbcDao.queryForList(sql, args) | |||||
| return result | |||||
| return jdbcDao.queryForList(sql, args) | |||||
| } | } | ||||
| /** LIKE 多值工具方法 */ | /** LIKE 多值工具方法 */ | ||||
| @@ -327,4 +315,4 @@ ORDER BY | |||||
| } | } | ||||
| return "AND (${conditions.joinToString(" OR ")})" | return "AND (${conditions.joinToString(" OR ")})" | ||||
| } | } | ||||
| } | |||||
| } | |||||
| @@ -23,7 +23,7 @@ import java.io.ByteArrayOutputStream | |||||
| class DoInventoryUomMismatchReportController( | class DoInventoryUomMismatchReportController( | ||||
| private val doInventoryUomMismatchReportService: DoInventoryUomMismatchReportService, | private val doInventoryUomMismatchReportService: DoInventoryUomMismatchReportService, | ||||
| ) { | ) { | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.0 | 2026-08-10 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ | |||||
| @GetMapping("/print-do-inventory-uom-mismatch-excel") | @GetMapping("/print-do-inventory-uom-mismatch-excel") | ||||
| fun exportExcel( | fun exportExcel( | ||||
| @RequestParam(required = false) deliveryDate: String?, | @RequestParam(required = false) deliveryDate: String?, | ||||
| @@ -69,7 +69,7 @@ class DoInventoryUomMismatchReportController( | |||||
| } | } | ||||
| val columns = listOf( | val columns = listOf( | ||||
| "deliveryDate" to "預計到貨日期", | |||||
| "deliveryDate" to "預計送貨日期", | |||||
| "doCode" to "送貨單號", | "doCode" to "送貨單號", | ||||
| "supplierCode" to "供應商編號", | "supplierCode" to "供應商編號", | ||||
| "supplierName" to "供應商名稱", | "supplierName" to "供應商名稱", | ||||
| @@ -21,6 +21,7 @@ class ItemQcFailReportController( | |||||
| private val itemQcFailReportService: ItemQcFailReportService, | private val itemQcFailReportService: ItemQcFailReportService, | ||||
| ) { | ) { | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ | |||||
| @GetMapping("/print-item-qc-fail") | @GetMapping("/print-item-qc-fail") | ||||
| fun generateItemQcFailReport( | fun generateItemQcFailReport( | ||||
| @RequestParam(required = false) stockCategory: String?, | @RequestParam(required = false) stockCategory: String?, | ||||
| @@ -29,7 +30,7 @@ class ItemQcFailReportController( | |||||
| @RequestParam(required = false) lastInDateEnd: String?, | @RequestParam(required = false) lastInDateEnd: String?, | ||||
| @RequestParam(required = false) qcType: String?, | @RequestParam(required = false) qcType: String?, | ||||
| @RequestParam(required = false, defaultValue = "true") includeMeasurable: String?, | @RequestParam(required = false, defaultValue = "true") includeMeasurable: String?, | ||||
| @RequestParam(required = false, defaultValue = "false") includeOther: String?, | |||||
| @RequestParam(required = false, defaultValue = "true") includeOther: String?, | |||||
| @RequestParam(required = false, defaultValue = "all") measurableScope: String?, | @RequestParam(required = false, defaultValue = "all") measurableScope: String?, | ||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val dbData = itemQcFailReportService.searchItemQcFailReport( | val dbData = itemQcFailReportService.searchItemQcFailReport( | ||||
| @@ -70,6 +71,7 @@ class ItemQcFailReportController( | |||||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ | |||||
| @GetMapping("/print-item-qc-fail-excel") | @GetMapping("/print-item-qc-fail-excel") | ||||
| fun exportItemQcFailReportExcel( | fun exportItemQcFailReportExcel( | ||||
| @RequestParam(required = false) stockCategory: String?, | @RequestParam(required = false) stockCategory: String?, | ||||
| @@ -78,7 +80,7 @@ class ItemQcFailReportController( | |||||
| @RequestParam(required = false) lastInDateEnd: String?, | @RequestParam(required = false) lastInDateEnd: String?, | ||||
| @RequestParam(required = false) qcType: String?, | @RequestParam(required = false) qcType: String?, | ||||
| @RequestParam(required = false, defaultValue = "true") includeMeasurable: String?, | @RequestParam(required = false, defaultValue = "true") includeMeasurable: String?, | ||||
| @RequestParam(required = false, defaultValue = "false") includeOther: String?, | |||||
| @RequestParam(required = false, defaultValue = "true") includeOther: String?, | |||||
| @RequestParam(required = false, defaultValue = "all") measurableScope: String?, | @RequestParam(required = false, defaultValue = "all") measurableScope: String?, | ||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val dbData = itemQcFailReportService.searchItemQcFailReport( | val dbData = itemQcFailReportService.searchItemQcFailReport( | ||||
| @@ -297,7 +299,7 @@ class ItemQcFailReportController( | |||||
| "不合格數量", | "不合格數量", | ||||
| "實測值", | "實測值", | ||||
| "備註", | "備註", | ||||
| "訂單/工單" | |||||
| "訂單/工單", | |||||
| ) | ) | ||||
| run { | run { | ||||
| @@ -321,12 +323,16 @@ class ItemQcFailReportController( | |||||
| fun writeNumber(col: Int, value: Any?) { | fun writeNumber(col: Int, value: Any?) { | ||||
| val raw = value?.toString()?.trim() ?: "" | val raw = value?.toString()?.trim() ?: "" | ||||
| val cleaned = raw.removeSuffix(".") | |||||
| // SQL FORMAT() may emit thousand separators (e.g. "1,021") — strip before parse. | |||||
| val cleaned = raw | |||||
| .replace(",", "") | |||||
| .replace(" ", "") | |||||
| .removeSuffix(".") | |||||
| val bd = cleaned.toBigDecimalOrNull() | val bd = cleaned.toBigDecimalOrNull() | ||||
| val cell = row.createCell(col) | val cell = row.createCell(col) | ||||
| if (bd == null) { | if (bd == null) { | ||||
| cell.setCellValue(cleaned) | |||||
| cell.setCellValue(raw) | |||||
| cell.cellStyle = textStyle | cell.cellStyle = textStyle | ||||
| } else { | } else { | ||||
| val stripped = bd.stripTrailingZeros() | val stripped = bd.stripTrailingZeros() | ||||
| @@ -14,10 +14,10 @@ import org.apache.poi.ss.usermodel.HorizontalAlignment | |||||
| import org.apache.poi.ss.usermodel.IndexedColors | import org.apache.poi.ss.usermodel.IndexedColors | ||||
| import org.apache.poi.ss.usermodel.Row | import org.apache.poi.ss.usermodel.Row | ||||
| import org.apache.poi.ss.usermodel.VerticalAlignment | import org.apache.poi.ss.usermodel.VerticalAlignment | ||||
| import org.apache.poi.ss.usermodel.Workbook | |||||
| import org.apache.poi.ss.util.CellRangeAddress | import org.apache.poi.ss.util.CellRangeAddress | ||||
| import org.apache.poi.ss.util.WorkbookUtil | import org.apache.poi.ss.util.WorkbookUtil | ||||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | |||||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||||
| import org.apache.poi.xssf.streaming.SXSSFWorkbook | |||||
| import java.io.ByteArrayOutputStream | import java.io.ByteArrayOutputStream | ||||
| @RestController | @RestController | ||||
| @@ -27,66 +27,66 @@ class StockLedgerReportController( | |||||
| private val stockLedgerReportService: StockLedgerReportService, | private val stockLedgerReportService: StockLedgerReportService, | ||||
| ) { | ) { | ||||
| private data class ExcelStyles( | private data class ExcelStyles( | ||||
| val title: XSSFCellStyle, | |||||
| val subtitle: XSSFCellStyle, | |||||
| val header: XSSFCellStyle, | |||||
| val text: XSSFCellStyle, | |||||
| val center: XSSFCellStyle, | |||||
| val int: XSSFCellStyle, | |||||
| val dash: XSSFCellStyle, | |||||
| val sumQty: XSSFCellStyle, | |||||
| val sumLabel: XSSFCellStyle, | |||||
| val sumEmpty: XSSFCellStyle, | |||||
| val sumHidden: XSSFCellStyle, | |||||
| val title: CellStyle, | |||||
| val subtitle: CellStyle, | |||||
| val header: CellStyle, | |||||
| val text: CellStyle, | |||||
| val center: CellStyle, | |||||
| val int: CellStyle, | |||||
| val dash: CellStyle, | |||||
| val sumQty: CellStyle, | |||||
| val sumLabel: CellStyle, | |||||
| val sumEmpty: CellStyle, | |||||
| val sumHidden: CellStyle, | |||||
| ) | ) | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ | |||||
| @GetMapping("/print-stock-ledger") | @GetMapping("/print-stock-ledger") | ||||
| fun generateStockLedgerReport( | |||||
| @RequestParam(required = false) stockCategory: String?, | |||||
| @RequestParam(required = false) itemCode: String?, | |||||
| @RequestParam(required = false) storeLocation: String?, | |||||
| // URL 參數名仍然是 lastInDateStart / lastInDateEnd | |||||
| @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, | |||||
| @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val parameters = mutableMapOf<String, Any>() | |||||
| parameters["stockCategory"] = stockCategory ?: "All" | |||||
| parameters["stockSubCategory"] = stockCategory ?: "All" | |||||
| parameters["itemNo"] = itemCode ?: "All" | |||||
| parameters["year"] = LocalDate.now().year.toString() | |||||
| parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) | |||||
| parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | |||||
| fun generateStockLedgerReport( | |||||
| @RequestParam(required = false) stockCategory: String?, | |||||
| @RequestParam(required = false) itemCode: String?, | |||||
| @RequestParam(required = false) storeLocation: String?, | |||||
| @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, | |||||
| @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val parameters = mutableMapOf<String, Any>() | |||||
| parameters["storeLocation"] = storeLocation ?: "" | |||||
| parameters["balanceFilterStart"] = "" | |||||
| parameters["balanceFilterEnd"] = "" | |||||
| parameters["reportPeriodStart"] = reportPeriodStart ?: "" | |||||
| parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" | |||||
| parameters["stockCategory"] = stockCategory ?: "All" | |||||
| parameters["stockSubCategory"] = stockCategory ?: "All" | |||||
| parameters["itemNo"] = itemCode ?: "All" | |||||
| parameters["year"] = LocalDate.now().year.toString() | |||||
| parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) | |||||
| parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | |||||
| parameters["storeLocation"] = storeLocation ?: "" | |||||
| parameters["balanceFilterStart"] = "" | |||||
| parameters["balanceFilterEnd"] = "" | |||||
| parameters["reportPeriodStart"] = reportPeriodStart ?: "" | |||||
| parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" | |||||
| val dbData = stockLedgerReportService.searchStockLedgerReport( | |||||
| stockCategory = stockCategory, | |||||
| itemCode = itemCode, | |||||
| storeLocation = storeLocation, | |||||
| reportPeriodStart = reportPeriodStart, | |||||
| reportPeriodEnd = reportPeriodEnd, | |||||
| ) | |||||
| val dbData = stockLedgerReportService.searchStockLedgerReport( | |||||
| stockCategory = stockCategory, | |||||
| itemCode = itemCode, | |||||
| storeLocation = storeLocation, | |||||
| reportPeriodStart = reportPeriodStart, | |||||
| reportPeriodEnd = reportPeriodEnd, | |||||
| ) | |||||
| val pdfBytes = reportService.createPdfResponse( | |||||
| "/jasper/StockLedgarReport.jrxml", | |||||
| parameters, | |||||
| dbData | |||||
| ) | |||||
| val pdfBytes = reportService.createPdfResponse( | |||||
| "/jasper/StockLedgarReport.jrxml", | |||||
| parameters, | |||||
| dbData, | |||||
| ) | |||||
| val headers = HttpHeaders().apply { | |||||
| contentType = MediaType.APPLICATION_PDF | |||||
| setContentDispositionFormData("attachment", "StockLedgerReport.pdf") | |||||
| set("filename", "StockLedgerReport.pdf") | |||||
| val headers = HttpHeaders().apply { | |||||
| contentType = MediaType.APPLICATION_PDF | |||||
| setContentDispositionFormData("attachment", "StockLedgerReport.pdf") | |||||
| set("filename", "StockLedgerReport.pdf") | |||||
| } | |||||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | |||||
| } | } | ||||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | |||||
| } | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ | |||||
| @GetMapping("/print-stock-ledger-excel") | @GetMapping("/print-stock-ledger-excel") | ||||
| fun exportStockLedgerReportExcel( | fun exportStockLedgerReportExcel( | ||||
| @RequestParam(required = false) stockCategory: String?, | @RequestParam(required = false) stockCategory: String?, | ||||
| @@ -119,8 +119,8 @@ fun generateStockLedgerReport( | |||||
| return ResponseEntity(excelBytes, headers, HttpStatus.OK) | return ResponseEntity(excelBytes, headers, HttpStatus.OK) | ||||
| } | } | ||||
| private fun createStyles(workbook: XSSFWorkbook): ExcelStyles { | |||||
| val titleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| private fun createStyles(workbook: Workbook): ExcelStyles { | |||||
| val titleStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.CENTER | alignment = HorizontalAlignment.CENTER | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val font = workbook.createFont().apply { | val font = workbook.createFont().apply { | ||||
| @@ -129,7 +129,7 @@ fun generateStockLedgerReport( | |||||
| } | } | ||||
| setFont(font) | setFont(font) | ||||
| } | } | ||||
| val subtitleStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val subtitleStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.LEFT | alignment = HorizontalAlignment.LEFT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val font = workbook.createFont().apply { | val font = workbook.createFont().apply { | ||||
| @@ -138,7 +138,7 @@ fun generateStockLedgerReport( | |||||
| } | } | ||||
| setFont(font) | setFont(font) | ||||
| } | } | ||||
| val headerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val headerStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.CENTER | alignment = HorizontalAlignment.CENTER | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | ||||
| @@ -150,7 +150,7 @@ fun generateStockLedgerReport( | |||||
| val font = workbook.createFont().apply { bold = true } | val font = workbook.createFont().apply { bold = true } | ||||
| setFont(font) | setFont(font) | ||||
| } | } | ||||
| val textStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val textStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.LEFT | alignment = HorizontalAlignment.LEFT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THIN | borderTop = BorderStyle.THIN | ||||
| @@ -158,7 +158,7 @@ fun generateStockLedgerReport( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| } | } | ||||
| val centerStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val centerStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.CENTER | alignment = HorizontalAlignment.CENTER | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THIN | borderTop = BorderStyle.THIN | ||||
| @@ -166,7 +166,7 @@ fun generateStockLedgerReport( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| } | } | ||||
| val intStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val intStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THIN | borderTop = BorderStyle.THIN | ||||
| @@ -176,7 +176,7 @@ fun generateStockLedgerReport( | |||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | dataFormat = df.getFormat("#,##0") | ||||
| } | } | ||||
| val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val dashStyle = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THIN | borderTop = BorderStyle.THIN | ||||
| @@ -184,7 +184,7 @@ fun generateStockLedgerReport( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| } | } | ||||
| val sumQty = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val sumQty = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| @@ -196,7 +196,7 @@ fun generateStockLedgerReport( | |||||
| val font = workbook.createFont().apply { bold = true } | val font = workbook.createFont().apply { bold = true } | ||||
| setFont(font) | setFont(font) | ||||
| } | } | ||||
| val sumLabel = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val sumLabel = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THICK | borderTop = BorderStyle.THICK | ||||
| @@ -206,7 +206,7 @@ fun generateStockLedgerReport( | |||||
| val font = workbook.createFont().apply { bold = true } | val font = workbook.createFont().apply { bold = true } | ||||
| setFont(font) | setFont(font) | ||||
| } | } | ||||
| val sumEmpty = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val sumEmpty = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.LEFT | alignment = HorizontalAlignment.LEFT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THICK | borderTop = BorderStyle.THICK | ||||
| @@ -214,7 +214,7 @@ fun generateStockLedgerReport( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| } | } | ||||
| val sumHidden = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||||
| val sumHidden = workbook.createCellStyle().apply { | |||||
| alignment = HorizontalAlignment.LEFT | alignment = HorizontalAlignment.LEFT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| borderTop = BorderStyle.THICK | borderTop = BorderStyle.THICK | ||||
| @@ -239,7 +239,7 @@ fun generateStockLedgerReport( | |||||
| ) | ) | ||||
| } | } | ||||
| private fun setTextCell(row: Row, col: Int, value: Any?, style: XSSFCellStyle) { | |||||
| private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { | |||||
| row.createCell(col).apply { | row.createCell(col).apply { | ||||
| setCellValue(value?.toString() ?: "") | setCellValue(value?.toString() ?: "") | ||||
| cellStyle = style | cellStyle = style | ||||
| @@ -259,8 +259,8 @@ fun generateStockLedgerReport( | |||||
| row: Row, | row: Row, | ||||
| col: Int, | col: Int, | ||||
| value: Any?, | value: Any?, | ||||
| intStyle: XSSFCellStyle, | |||||
| dashStyle: XSSFCellStyle, | |||||
| intStyle: CellStyle, | |||||
| dashStyle: CellStyle, | |||||
| ) { | ) { | ||||
| val cell = row.createCell(col) | val cell = row.createCell(col) | ||||
| val parsed = parseSignedNumber(value) | val parsed = parseSignedNumber(value) | ||||
| @@ -279,131 +279,160 @@ fun generateStockLedgerReport( | |||||
| } | } | ||||
| } | } | ||||
| /** | |||||
| * SXSSF keeps only a sliding window of rows in memory to avoid OOM on large exports. | |||||
| */ | |||||
| private fun createStockLedgerExcel( | private fun createStockLedgerExcel( | ||||
| dbData: List<Map<String, Any>>, | dbData: List<Map<String, Any>>, | ||||
| reportPeriodStart: String, | reportPeriodStart: String, | ||||
| reportPeriodEnd: String, | reportPeriodEnd: String, | ||||
| ): ByteArray { | ): ByteArray { | ||||
| val workbook = XSSFWorkbook() | |||||
| val styles = createStyles(workbook) | |||||
| val reportTitle = "庫存明細報告" | |||||
| val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) | |||||
| val headers = listOf( | |||||
| "貨品編號", "貨品名稱", "單位", | |||||
| "出入賬日期", "類型", "批號", "到期日", | |||||
| "纍計期初存量", "入庫", "出庫", "纍計存量", | |||||
| "參考編號", "存貨位置", | |||||
| ) | |||||
| val totalColumns = headers.size | |||||
| var rowIndex = 0 | |||||
| val workbook = SXSSFWorkbook(100) | |||||
| workbook.setCompressTempFiles(true) | |||||
| try { | |||||
| val styles = createStyles(workbook) | |||||
| val reportTitle = "庫存明細報告" | |||||
| val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) | |||||
| val titleRow = sheet.createRow(rowIndex++) | |||||
| titleRow.createCell(0).apply { | |||||
| setCellValue(reportTitle) | |||||
| cellStyle = styles.title | |||||
| } | |||||
| sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) | |||||
| val reportDateTime = | |||||
| LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + | |||||
| "(" + | |||||
| LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + | |||||
| ")" | |||||
| val subtitleRow = sheet.createRow(rowIndex++) | |||||
| subtitleRow.createCell(0).apply { | |||||
| setCellValue("報告日期:$reportDateTime") | |||||
| cellStyle = styles.subtitle | |||||
| } | |||||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4)) | |||||
| subtitleRow.createCell(5).apply { | |||||
| setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}") | |||||
| cellStyle = styles.subtitle | |||||
| } | |||||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1)) | |||||
| sheet.createRow(rowIndex++) | |||||
| val headers = listOf( | |||||
| "貨品編號", "貨品名稱", "單位", | |||||
| "出入賬日期", "類型", "批號", "到期日", | |||||
| "纍計期初存量", "入庫", "出庫", "纍計存量", | |||||
| "參考編號", "存貨位置", | |||||
| ) | |||||
| val totalColumns = headers.size | |||||
| var rowIndex = 0 | |||||
| val headerRowIndex = rowIndex | |||||
| val headerRow = sheet.createRow(rowIndex++) | |||||
| headers.forEachIndexed { i, h -> | |||||
| headerRow.createCell(i).apply { | |||||
| setCellValue(h) | |||||
| cellStyle = styles.header | |||||
| val titleRow = sheet.createRow(rowIndex++) | |||||
| titleRow.createCell(0).apply { | |||||
| setCellValue(reportTitle) | |||||
| cellStyle = styles.title | |||||
| } | } | ||||
| } | |||||
| sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) | |||||
| fun addItemSummaryRow(itemNo: String, itemName: String, uom: String, totalIn: Any?, totalOut: Any?, totalBal: Any?) { | |||||
| val r = sheet.createRow(rowIndex++) | |||||
| r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden } | |||||
| r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden } | |||||
| r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden } | |||||
| for (c in 3 until totalColumns) { | |||||
| r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty } | |||||
| val reportDateTime = | |||||
| LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + | |||||
| "(" + | |||||
| LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + | |||||
| ")" | |||||
| val subtitleRow = sheet.createRow(rowIndex++) | |||||
| subtitleRow.createCell(0).apply { | |||||
| setCellValue("報告日期:$reportDateTime") | |||||
| cellStyle = styles.subtitle | |||||
| } | } | ||||
| // totals should align with numeric columns (shift right by 1) | |||||
| r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel } | |||||
| setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash) | |||||
| setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash) | |||||
| setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash) | |||||
| } | |||||
| if (dbData.isEmpty()) { | |||||
| val r = sheet.createRow(rowIndex++) | |||||
| for (c in 0 until totalColumns) { | |||||
| r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } | |||||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4)) | |||||
| subtitleRow.createCell(5).apply { | |||||
| setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}") | |||||
| cellStyle = styles.subtitle | |||||
| } | } | ||||
| } else { | |||||
| var currentItemNo: String? = null | |||||
| var currentItemName = "" | |||||
| var currentUom = "" | |||||
| var lastTotals: Triple<Any?, Any?, Any?> = Triple(null, null, null) | |||||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1)) | |||||
| sheet.createRow(rowIndex++) | |||||
| dbData.forEach { m -> | |||||
| val itemNo = m["itemNo"]?.toString().orEmpty() | |||||
| val itemName = m["itemName"]?.toString().orEmpty() | |||||
| val uom = m["unitOfMeasure"]?.toString().orEmpty() | |||||
| val headerRowIndex = rowIndex | |||||
| val headerRow = sheet.createRow(rowIndex++) | |||||
| headers.forEachIndexed { i, h -> | |||||
| headerRow.createCell(i).apply { | |||||
| setCellValue(h) | |||||
| cellStyle = styles.header | |||||
| } | |||||
| } | |||||
| if (currentItemNo != null && itemNo != currentItemNo) { | |||||
| addItemSummaryRow(currentItemNo!!, currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third) | |||||
| sheet.createRow(rowIndex++) | |||||
| fun addItemSummaryRow( | |||||
| itemNo: String, | |||||
| itemName: String, | |||||
| uom: String, | |||||
| totalIn: Any?, | |||||
| totalOut: Any?, | |||||
| totalBal: Any?, | |||||
| ) { | |||||
| val r = sheet.createRow(rowIndex++) | |||||
| r.createCell(0).apply { setCellValue(itemNo); cellStyle = styles.sumHidden } | |||||
| r.createCell(1).apply { setCellValue(itemName); cellStyle = styles.sumHidden } | |||||
| r.createCell(2).apply { setCellValue(uom); cellStyle = styles.sumHidden } | |||||
| for (c in 3 until totalColumns) { | |||||
| r.createCell(c).apply { setCellValue(""); cellStyle = styles.sumEmpty } | |||||
| } | } | ||||
| r.getCell(7).apply { setCellValue("貨品總量:"); cellStyle = styles.sumLabel } | |||||
| setIntCellFromFormatted(r, 8, totalIn, styles.sumQty, styles.dash) | |||||
| setIntCellFromFormatted(r, 9, totalOut, styles.sumQty, styles.dash) | |||||
| setIntCellFromFormatted(r, 10, totalBal, styles.sumQty, styles.dash) | |||||
| } | |||||
| if (dbData.isEmpty()) { | |||||
| val r = sheet.createRow(rowIndex++) | val r = sheet.createRow(rowIndex++) | ||||
| setTextCell(r, 0, itemNo, styles.text) | |||||
| setTextCell(r, 1, itemName, styles.text) | |||||
| setTextCell(r, 2, uom, styles.center) | |||||
| setTextCell(r, 3, m["trnDate"], styles.center) | |||||
| val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t -> | |||||
| if (t.equals("Expiry", ignoreCase = true)) "過期" else t | |||||
| for (c in 0 until totalColumns) { | |||||
| r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } | |||||
| } | } | ||||
| setTextCell(r, 4, typeText, styles.center) | |||||
| setTextCell(r, 5, m["lotNo"], styles.text) | |||||
| setTextCell(r, 6, m["expiryDate"], styles.center) | |||||
| setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash) | |||||
| setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash) | |||||
| setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash) | |||||
| setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash) | |||||
| setTextCell(r, 11, m["orderRefNo"], styles.text) | |||||
| setTextCell(r, 12, m["storeLocation"], styles.center) | |||||
| } else { | |||||
| var currentItemNo: String? = null | |||||
| var currentItemName = "" | |||||
| var currentUom = "" | |||||
| var lastTotals: Triple<Any?, Any?, Any?> = Triple(null, null, null) | |||||
| dbData.forEach { m -> | |||||
| val itemNo = m["itemNo"]?.toString().orEmpty() | |||||
| val itemName = m["itemName"]?.toString().orEmpty() | |||||
| val uom = m["unitOfMeasure"]?.toString().orEmpty() | |||||
| currentItemNo = itemNo | |||||
| currentItemName = itemName | |||||
| currentUom = uom | |||||
| lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"]) | |||||
| if (currentItemNo != null && itemNo != currentItemNo) { | |||||
| addItemSummaryRow( | |||||
| currentItemNo!!, | |||||
| currentItemName, | |||||
| currentUom, | |||||
| lastTotals.first, | |||||
| lastTotals.second, | |||||
| lastTotals.third, | |||||
| ) | |||||
| sheet.createRow(rowIndex++) | |||||
| } | |||||
| val r = sheet.createRow(rowIndex++) | |||||
| setTextCell(r, 0, itemNo, styles.text) | |||||
| setTextCell(r, 1, itemName, styles.text) | |||||
| setTextCell(r, 2, uom, styles.center) | |||||
| setTextCell(r, 3, m["trnDate"], styles.center) | |||||
| val typeText = m["trnRefNo"]?.toString()?.trim().orEmpty().let { t -> | |||||
| if (t.equals("Expiry", ignoreCase = true)) "過期" else t | |||||
| } | |||||
| setTextCell(r, 4, typeText, styles.center) | |||||
| setTextCell(r, 5, m["lotNo"], styles.text) | |||||
| setTextCell(r, 6, m["expiryDate"], styles.center) | |||||
| setIntCellFromFormatted(r, 7, m["cumOpeningBal"], styles.int, styles.dash) | |||||
| setIntCellFromFormatted(r, 8, m["stockIn"], styles.int, styles.dash) | |||||
| setIntCellFromFormatted(r, 9, m["stockOut"], styles.int, styles.dash) | |||||
| setIntCellFromFormatted(r, 10, m["cumBalance"], styles.int, styles.dash) | |||||
| setTextCell(r, 11, m["orderRefNo"], styles.text) | |||||
| setTextCell(r, 12, m["storeLocation"], styles.center) | |||||
| currentItemNo = itemNo | |||||
| currentItemName = itemName | |||||
| currentUom = uom | |||||
| lastTotals = Triple(m["totalStockIn"], m["totalStockOut"], m["totalCumBalance"]) | |||||
| } | |||||
| addItemSummaryRow( | |||||
| currentItemNo ?: "", | |||||
| currentItemName, | |||||
| currentUom, | |||||
| lastTotals.first, | |||||
| lastTotals.second, | |||||
| lastTotals.third, | |||||
| ) | |||||
| } | } | ||||
| addItemSummaryRow(currentItemNo ?: "", currentItemName, currentUom, lastTotals.first, lastTotals.second, lastTotals.third) | |||||
| } | |||||
| val lastRowIndex = rowIndex - 1 | |||||
| if (lastRowIndex >= headerRowIndex) { | |||||
| sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) | |||||
| } | |||||
| val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12) | |||||
| widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | |||||
| val lastRowIndex = rowIndex - 1 | |||||
| if (lastRowIndex >= headerRowIndex) { | |||||
| sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) | |||||
| val out = ByteArrayOutputStream() | |||||
| workbook.write(out) | |||||
| return out.toByteArray() | |||||
| } finally { | |||||
| workbook.dispose() | |||||
| workbook.close() | |||||
| } | } | ||||
| val widths = intArrayOf(14, 26, 10, 12, 10, 16, 12, 14, 10, 10, 12, 18, 12) | |||||
| widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | |||||
| val out = ByteArrayOutputStream() | |||||
| workbook.use { it.write(out) } | |||||
| return out.toByteArray() | |||||
| } | } | ||||
| } | |||||
| } | |||||
| @@ -79,7 +79,7 @@ | |||||
| <band height="18"> | <band height="18"> | ||||
| <textField textAdjust="StretchHeight"> | <textField textAdjust="StretchHeight"> | ||||
| <reportElement isPrintRepeatedValues="false" x="11" y="0" width="579" height="18" uuid="5b2d2e95-26eb-4e8c-93ba-99eaed3490df"/> | <reportElement isPrintRepeatedValues="false" x="11" y="0" width="579" height="18" uuid="5b2d2e95-26eb-4e8c-93ba-99eaed3490df"/> | ||||
| <textElement textAlignment="Left" verticalAlignment="Top" markup="styled"> | |||||
| <textElement textAlignment="Left" verticalAlignment="Top" markup="none"> | |||||
| <font fontName="微軟正黑體" isBold="true"/> | <font fontName="微軟正黑體" isBold="true"/> | ||||
| </textElement> | </textElement> | ||||
| <textFieldExpression><![CDATA[$F{itemNo}+" "+$F{itemName}+" "+$F{unitOfMeasure}]]></textFieldExpression> | <textFieldExpression><![CDATA[$F{itemNo}+" "+$F{itemName}+" "+$F{unitOfMeasure}]]></textFieldExpression> | ||||