From ac9b20a2d9430842d5af30c89dc53812ae062bce Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Fri, 11 Sep 2026 16:01:04 +0800 Subject: [PATCH] change search --- .../com/ffii/core/support/ErrorHandler.java | 4 +- .../modules/report/service/ReportService.kt | 326 +++++++++++------- .../service/StockLedgerReportService.kt | 24 +- .../report/web/StockLedgerReportController.kt | 4 +- .../stock/entity/InventoryRepository.kt | 69 ++++ .../projection/InventoryStockUomSearchRow.kt | 2 +- .../modules/stock/service/InventoryService.kt | 38 +- .../modules/stock/web/InventoryController.kt | 4 +- .../resources/jasper/StockBalanceReport.jrxml | 2 +- .../resources/jasper/StockLedgarReport.jrxml | 2 +- 10 files changed, 329 insertions(+), 146 deletions(-) diff --git a/src/main/java/com/ffii/core/support/ErrorHandler.java b/src/main/java/com/ffii/core/support/ErrorHandler.java index c8f5c10..14b5aed 100644 --- a/src/main/java/com/ffii/core/support/ErrorHandler.java +++ b/src/main/java/com/ffii/core/support/ErrorHandler.java @@ -38,7 +38,9 @@ public class ErrorHandler extends ResponseEntityExceptionHandler { public ResponseEntity error500(final Exception ex) { UUID traceId = UUID.randomUUID(); logger.error("traceId: " + traceId, ex); - return new ResponseEntity<>(new ErrorRes(traceId.toString()), HttpStatus.INTERNAL_SERVER_ERROR); + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + return new ResponseEntity<>(new ErrorRes(traceId.toString()), headers, HttpStatus.INTERNAL_SERVER_ERROR); } } diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 392733c..62093bb 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -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.deliveryOrder.service.DoFloorSupplierSettingsService import java.math.BigDecimal +import java.time.LocalDate import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter import net.sf.jasperreports.export.SimpleExporterInput import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput +import org.slf4j.LoggerFactory @Service open class ReportService( @@ -23,6 +25,7 @@ open class ReportService( private val shopRepository: ShopRepository, private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { + private val log = LoggerFactory.getLogger(javaClass) /** * 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( @@ -1514,105 +1519,82 @@ return result ): List> { val args = mutableMapOf() 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 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()) { 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 "" - val baseSql = """ - WITH params AS ( + WITH day0 AS ( 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 - 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 $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 ( SELECT sl.itemId, + COALESCE(sl.uomId, inv.stockUomId) AS uomId, SUM( CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') @@ -1651,14 +1633,82 @@ return result THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) ELSE 0 END - ) AS variance + ) AS variance, + SUM(COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS dayNet 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 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 '' AS stockSubCategory, @@ -1672,31 +1722,50 @@ return result '' 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, - 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 defectiveGoods, '' 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 $storeLocationSql """.trimIndent() @@ -1713,10 +1782,8 @@ return result 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 @@ -1725,8 +1792,31 @@ return result """.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. */ diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 55c546b..67e706e 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -13,7 +13,7 @@ open class StockLedgerReportService( private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") /** - * FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 + * FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.1 | 2026-09-11 * Stock Ledger 報表查詢 * * - stockSubCategory = items.type @@ -138,12 +138,12 @@ FROM ( SELECT x.*, FIRST_VALUE(cumOpeningBalRaw) OVER ( - PARTITION BY itemCode ORDER BY trnDateRaw, slId + PARTITION BY itemCode, unitOfMeasure ORDER BY trnDateRaw, slId ) AS totalCumOpeningBalRaw, - SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw, - SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw, + SUM(inQty) OVER (PARTITION BY itemCode, unitOfMeasure) AS totalStockInRaw, + SUM(outQty) OVER (PARTITION BY itemCode, unitOfMeasure) AS totalStockOutRaw, FIRST_VALUE(bal) OVER ( - PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC + PARTITION BY itemCode, unitOfMeasure ORDER BY trnDateRaw DESC, slId DESC ) AS totalCumBalanceRaw FROM ( SELECT @@ -168,7 +168,10 @@ FROM ( it.type AS stockSubCategory, it.code AS itemNo, it.name AS itemName, - uc.udfudesc AS unitOfMeasure, + COALESCE( + uc_lot.udfudesc, + uc_stock.udfudesc + ) AS unitOfMeasure, lot.lotNo AS lotNo, COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, @@ -225,12 +228,16 @@ FROM ( LEFT JOIN items it ON sl.itemId = it.id AND it.deleted = 0 + LEFT JOIN uom_conversion uc_lot + ON uc_lot.id = sl.uomId + AND uc_lot.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 uom_conversion uc_stock + ON iu.uomId = uc_stock.id + AND uc_stock.deleted = 0 LEFT JOIN stock_out so ON sol.stockOutId = so.id @@ -270,6 +277,7 @@ FROM ( ) y ORDER BY itemNo, + unitOfMeasure, trnDateRaw, slId, lotNo diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt index 5345032..93a257f 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt @@ -40,7 +40,7 @@ class StockLedgerReportController( val sumHidden: CellStyle, ) - /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.1 | 2026-09-11 */ @GetMapping("/print-stock-ledger") fun generateStockLedgerReport( @RequestParam(required = false) stockCategory: String?, @@ -86,7 +86,7 @@ class StockLedgerReportController( return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } - /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.1 | 2026-09-11 */ @GetMapping("/print-stock-ledger-excel") fun exportStockLedgerReportExcel( @RequestParam(required = false) stockCategory: String?, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt index f45f6ba..3c6f167 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt @@ -3,9 +3,11 @@ package com.ffii.fpsms.modules.stock.entity import com.ffii.core.support.AbstractRepository import com.ffii.fpsms.modules.master.entity.Items import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo +import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRow import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository import java.io.Serializable import java.util.Optional @@ -98,6 +100,73 @@ interface InventoryRepository: AbstractRepository { pageable: Pageable, ): Page + /** + * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.2 | 2026-09-11 + * Item search by code/name: one row per item + stockUomId qty bucket (inventory table), + * so the same item code with different stock UoMs (e.g. MJ0785) appears as separate rows. + */ + @Query( + value = """ + SELECT + CAST(i.id AS long) AS id, + CAST(item.id AS long) AS itemId, + item.code AS itemCode, + item.name AS itemName, + item.type AS itemType, + i.onHandQty AS onHandQty, + i.onHoldQty AS onHoldQty, + i.unavailableQty AS unavailableQty, + (COALESCE(i.onHandQty, 0) - COALESCE(i.onHoldQty, 0) - COALESCE(i.unavailableQty, 0)) AS availableQty, + CAST(stockUom.id AS long) AS uomId, + stockUom.code AS uomCode, + stockUom.udfudesc AS uomUdfudesc, + stockUom.udfShortDesc AS uomShortDesc, + (COALESCE(i.onHandQty, 0) - COALESCE(i.onHoldQty, 0) - COALESCE(i.unavailableQty, 0)) AS qtyPerSmallestUnit, + item.latestMarketUnitPrice AS latestMarketUnitPrice, + item.latestMupUpdatedDate AS latestMupUpdatedDate + FROM Inventory i + JOIN i.item item + JOIN i.stockUom stockUom + WHERE i.deleted = false + AND item.deleted = false + AND (:code IS NULL OR :code = '' OR LOWER(item.code) LIKE LOWER(CONCAT('%', :code, '%'))) + AND (:name IS NULL OR :name = '' OR LOWER(item.name) LIKE LOWER(CONCAT('%', :name, '%'))) + AND (:type IS NULL OR :type = '' OR item.type = :type) + AND NOT EXISTS ( + SELECT 1 FROM Inventory i2 + WHERE i2.item.id = item.id + AND i2.deleted = false + AND i2.stockUom.id = stockUom.id + AND i2.id > i.id + ) + ORDER BY item.code ASC, stockUom.id ASC + """, + countQuery = """ + SELECT COUNT(i) + FROM Inventory i + JOIN i.item item + JOIN i.stockUom stockUom + WHERE i.deleted = false + AND item.deleted = false + AND (:code IS NULL OR :code = '' OR LOWER(item.code) LIKE LOWER(CONCAT('%', :code, '%'))) + AND (:name IS NULL OR :name = '' OR LOWER(item.name) LIKE LOWER(CONCAT('%', :name, '%'))) + AND (:type IS NULL OR :type = '' OR item.type = :type) + AND NOT EXISTS ( + SELECT 1 FROM Inventory i2 + WHERE i2.item.id = item.id + AND i2.deleted = false + AND i2.stockUom.id = stockUom.id + AND i2.id > i.id + ) + """, + ) + fun searchInventoryByItemAndStockUom( + @Param("code") code: String, + @Param("name") name: String, + @Param("type") type: String, + pageable: Pageable, + ): Page + fun findInventoryInfoByItemIdInAndDeletedIsFalse(itemIds: List): List fun findInventoryInfoByItemInAndDeletedIsFalse(items: List): List diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt index e48f4af..1eaeda2 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt @@ -4,7 +4,7 @@ import java.math.BigDecimal import java.time.LocalDateTime /** - * Inventory search page: one row per item + lot-line stock UoM. + * Inventory search page: one row per item + stock UoM (inventory.stockUomId or lot-line UoM). */ interface InventoryStockUomSearchRow { val id: Long? diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt index da108a7..3782e4a 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt @@ -90,8 +90,11 @@ open class InventoryService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 - * Inventory search page: one row per item + lot-line stock UoM. + * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.2 | 2026-09-11 + * Inventory search page: one row per item + stock UoM. + * Code/name search uses inventory qty buckets (stockUomId) so items like MJ0785 + * with more than one UoM appear as separate rows, including 0-qty buckets. + * Location / lot-no search still aggregates available lot lines in that scope. */ open fun searchInventoriesByLatestInventory( request: SearchInventoryRequest, @@ -107,16 +110,27 @@ open class InventoryService( } val hasWarehouseFilter = warehouseIds != null - val response = inventoryLotLineRepository.searchInventoryGroupedByStockUom( - code = code, - name = name, - type = type, - status = InventoryLotLineStatus.AVAILABLE, - hasWarehouseFilter = hasWarehouseFilter, - warehouseIds = warehouseIds ?: listOf(-1L), - lotNo = lotNo, - pageable = pageable, - ) + val useInventoryQtyBuckets = + !hasWarehouseFilter && lotNo == null && (code.isNotEmpty() || name.isNotEmpty()) + val response = if (useInventoryQtyBuckets) { + inventoryRepository.searchInventoryByItemAndStockUom( + code = code, + name = name, + type = type, + pageable = pageable, + ) + } else { + inventoryLotLineRepository.searchInventoryGroupedByStockUom( + code = code, + name = name, + type = type, + status = InventoryLotLineStatus.AVAILABLE, + hasWarehouseFilter = hasWarehouseFilter, + warehouseIds = warehouseIds ?: listOf(-1L), + lotNo = lotNo, + pageable = pageable, + ) + } return RecordsRes(response.content, response.totalElements.toInt()) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt index c868176..6064097 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt @@ -23,8 +23,8 @@ class InventoryController( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 - * Inventory search page: one row per item + lot-line stock UoM, with optional location filters. + * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.2 | 2026-09-11 + * Inventory search page: one row per item + stock UoM, with optional location filters. */ @GetMapping("/searchLatest/getRecordByPage") fun searchInventoriesByLatestInventory( diff --git a/src/main/resources/jasper/StockBalanceReport.jrxml b/src/main/resources/jasper/StockBalanceReport.jrxml index 072560b..b31e17a 100644 --- a/src/main/resources/jasper/StockBalanceReport.jrxml +++ b/src/main/resources/jasper/StockBalanceReport.jrxml @@ -71,7 +71,7 @@ - + diff --git a/src/main/resources/jasper/StockLedgarReport.jrxml b/src/main/resources/jasper/StockLedgarReport.jrxml index a245642..3f445a6 100644 --- a/src/main/resources/jasper/StockLedgarReport.jrxml +++ b/src/main/resources/jasper/StockLedgarReport.jrxml @@ -74,7 +74,7 @@ - +