Harry Groves 1 неделю назад
Родитель
Сommit
ac9b20a2d9
10 измененных файлов: 329 добавлений и 146 удалений
  1. +3
    -1
      src/main/java/com/ffii/core/support/ErrorHandler.java
  2. +208
    -118
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  3. +16
    -8
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt
  4. +2
    -2
      src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt
  5. +69
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt
  6. +1
    -1
      src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt
  7. +26
    -12
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt
  8. +2
    -2
      src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt
  9. +1
    -1
      src/main/resources/jasper/StockBalanceReport.jrxml
  10. +1
    -1
      src/main/resources/jasper/StockLedgarReport.jrxml

+ 3
- 1
src/main/java/com/ffii/core/support/ErrorHandler.java Просмотреть файл

@@ -38,7 +38,9 @@ public class ErrorHandler extends ResponseEntityExceptionHandler {
public ResponseEntity<ErrorRes> 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);
}

}

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


+ 16
- 8
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


+ 2
- 2
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?,


+ 69
- 0
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<Inventory, Long> {
pageable: Pageable,
): Page<InventoryInfo>

/**
* 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<InventoryStockUomSearchRow>

fun findInventoryInfoByItemIdInAndDeletedIsFalse(itemIds: List<Serializable>): List<InventoryInfo>

fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo>


+ 1
- 1
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?


+ 26
- 12
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())
}


+ 2
- 2
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(


+ 1
- 1
src/main/resources/jasper/StockBalanceReport.jrxml Просмотреть файл

@@ -71,7 +71,7 @@
<field name="defectiveGoods" class="java.lang.String"/>
<field name="variance" class="java.lang.String"/>
<group name="Group1" keepTogether="true">
<groupExpression><![CDATA[$F{itemNo}]]></groupExpression>
<groupExpression><![CDATA[$F{itemNo} + "|" + $F{unitOfMeasure}]]></groupExpression>
<groupHeader>
<band height="24">
<textField>


+ 1
- 1
src/main/resources/jasper/StockLedgarReport.jrxml Просмотреть файл

@@ -74,7 +74,7 @@
<field name="jobOrderNo" class="java.lang.String"/>
<field name="orderRefNo" class="java.lang.String"/>
<group name="Group1" keepTogether="true">
<groupExpression><![CDATA[$F{itemNo}]]></groupExpression>
<groupExpression><![CDATA[$F{itemNo} + "|" + $F{unitOfMeasure}]]></groupExpression>
<groupHeader>
<band height="18">
<textField textAdjust="StretchHeight">


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