Преглед изворни кода

item averageUnitPrice fix

同 UOM 不轉換

lot expiry 邏輯

PoSearch fix the console.og
PoDetail 累計超過 PO 也要 warn
undefined
CANCERYS\kw093 пре 1 недеља
родитељ
комит
3da2441d29
32 измењених фајлова са 743 додато и 114 уклоњено
  1. +15
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt
  2. +35
    -5
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  3. +195
    -38
      src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt
  4. +42
    -6
      src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt
  5. +35
    -0
      src/main/java/com/ffii/fpsms/modules/master/support/HomeFxRates.kt
  6. +37
    -0
      src/main/java/com/ffii/fpsms/modules/master/support/ItemAveragePriceFx.kt
  7. +13
    -0
      src/main/java/com/ffii/fpsms/modules/master/support/ItemUomQtyConvert.kt
  8. +7
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt
  9. +3
    -1
      src/main/java/com/ffii/fpsms/modules/master/web/models/ItemWithQcResponse.kt
  10. +2
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/NewItemRequest.kt
  11. +16
    -8
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt
  12. +5
    -4
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt
  13. +3
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt
  14. +5
    -0
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrder.kt
  15. +2
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt
  16. +27
    -9
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  17. +31
    -2
      src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt
  18. +12
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt
  19. +4
    -3
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
  20. +8
    -6
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  21. +4
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt
  22. +7
    -2
      src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt
  23. +10
    -23
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  24. +23
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt
  25. +9
    -6
      src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt
  26. +4
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt
  27. +2
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt
  28. +3
    -0
      src/main/resources/application.yml
  29. +12
    -0
      src/main/resources/db/changelog/changes/20260902_item_avg_unit_cost_fx/01_po_exchange_rate_and_item_purchase_fx.sql
  30. +38
    -0
      src/main/resources/db/changelog/changes/20260903_item_home_currency/01_add_home_currency_id.sql
  31. +109
    -0
      src/main/resources/db/changelog/changes/20260907_recalc_item_avg_unit_price/01_recalculate_average_unit_price.sql
  32. +25
    -0
      src/test/kotlin/com/ffii/fpsms/modules/master/support/ItemUomQtyConvertTest.kt

+ 15
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt Прегледај датотеку

@@ -5,6 +5,7 @@ import com.ffii.core.entity.BaseEntity
import com.ffii.fpsms.modules.stock.entity.Inventory
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
import java.math.BigDecimal
import java.time.LocalDateTime

@Entity
@@ -70,6 +71,20 @@ open class Items : BaseEntity<Long>() {
@Column(name = "AverageUnitPrice", nullable = true, length = 255)
open var averageUnitPrice: String? = null

/** Home currency of [averageUnitPrice]; currently always HKD. */
@Column(name = "homeCurrencyId")
open var homeCurrencyId: Long? = null

@Column(name = "purchaseCurrencyId")
open var purchaseCurrencyId: Long? = null

/** Latest POL price/qty in purchase currency (purchase UOM), not M18 up. */
@Column(name = "purchaseUnitPrice", precision = 14, scale = 4)
open var purchaseUnitPrice: BigDecimal? = null

@Column(name = "purchaseFxRate", precision = 14, scale = 4)
open var purchaseFxRate: BigDecimal? = null

@Column(name = "LatestMarketUnitPrice", nullable = true)
open var latestMarketUnitPrice: Double? = null



+ 35
- 5
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt Прегледај датотеку

@@ -12,6 +12,7 @@ import kotlin.jvm.optionals.getOrNull
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import com.ffii.fpsms.modules.master.entity.ItemsRepository
import com.ffii.fpsms.modules.master.support.ItemUomQtyConvert
import com.ffii.fpsms.modules.stock.entity.Inventory
import com.ffii.fpsms.modules.stock.entity.InventoryRepository

@@ -101,9 +102,11 @@ open class ItemUomService(
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 73 | v1.0.0 | 2026-09-07 */
open fun convertPurchaseQtyToStockQty(itemId: Long, purchaseQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty;
val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty;
if (ItemUomQtyConvert.sameUomConversionId(purchaseUnit.uom?.id, stockUnit.uom?.id)) return purchaseQty
val one = BigDecimal.ONE;

// IMPORTANT:
@@ -123,12 +126,14 @@ open class ItemUomService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 73 | v1.0.0 | 2026-09-07
* Convert purchase qty -> stock qty (PO display / acceptedQty calculation)
* with round-down to integer stock qty.
*/
open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty
val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty
if (ItemUomQtyConvert.sameUomConversionId(purchaseUnit.uom?.id, stockUnit.uom?.id)) return purchaseQty
val one = BigDecimal.ONE

// Use high precision for intermediate steps, and round down only at the end.
@@ -145,10 +150,14 @@ open class ItemUomService(
return stockQty.setScale(0, RoundingMode.DOWN)
}

/** Inverse of convertPurchaseQtyToStockQty: stock qty -> purchase qty (for PO-origin StockInLine display). */
/**
* FP-MTMS Version Checklist | Functions Ref. No. 73 | v1.0.0 | 2026-09-07
* Inverse of convertPurchaseQtyToStockQty: stock qty -> purchase qty (for PO-origin StockInLine display).
*/
open fun convertStockQtyToPurchaseQty(itemId: Long, stockQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty
val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty
if (ItemUomQtyConvert.sameUomConversionId(stockUnit.uom?.id, purchaseUnit.uom?.id)) return stockQty
val one = BigDecimal.ONE
val calcScale = 10

@@ -164,12 +173,14 @@ open class ItemUomService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 73 | v1.0.0 | 2026-09-07
* Convert source quantity from a specific UOM to this item's purchase unit quantity.
* Returns source qty when source/purchase unit mapping is not found.
*/
open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return sourceQty
if (ItemUomQtyConvert.sameUomConversionId(uomId, purchaseUnit.uom?.id)) return sourceQty
val one = BigDecimal.ONE
val calcScale = 10

@@ -184,10 +195,12 @@ open class ItemUomService(
return purchaseQty.setScale(0, RoundingMode.UP)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 73 | v1.0.0 | 2026-09-07 */
open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty;

val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO;
if (ItemUomQtyConvert.sameUomConversionId(uomId, stockUnit.uom?.id)) return sourceQty
val one = BigDecimal.ONE;

val calcScale = 10
@@ -204,12 +217,14 @@ open class ItemUomService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 73 | v1.0.0 | 2026-09-07
* Convert source quantity from a specific UOM to this item's stock quantity.
* Same as convertQtyToStockQty but rounds down the final stock qty to integer.
*/
open fun convertQtyToStockQtyRoundDown(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO
if (ItemUomQtyConvert.sameUomConversionId(uomId, stockUnit.uom?.id)) return sourceQty
val one = BigDecimal.ONE
val calcScale = 10

@@ -241,12 +256,14 @@ open class ItemUomService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 73 | v1.0.0 | 2026-09-07
* Convert purchase qty -> stock qty and keep decimal precision.
* Used for PO flows where decimal quantity must be preserved.
*/
open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty
val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty
if (ItemUomQtyConvert.sameUomConversionId(purchaseUnit.uom?.id, stockUnit.uom?.id)) return purchaseQty
val one = BigDecimal.ONE
val calcScale = 10

@@ -262,12 +279,14 @@ open class ItemUomService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 73 | v1.0.0 | 2026-09-07
* Convert qty from a specific UOM -> stock qty and keep decimal precision.
* Used for PO (M18 UOM) conversion where round-down is not allowed.
*/
open fun convertQtyToStockQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO
if (ItemUomQtyConvert.sameUomConversionId(uomId, stockUnit.uom?.id)) return sourceQty
val one = BigDecimal.ONE
val calcScale = 10

@@ -283,11 +302,16 @@ open class ItemUomService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 73 | v1.0.0 | 2026-09-07
* Convert quantity from [uomId] (must exist on `item_uom` for [itemId]) to the item's **base unit** quantity.
* Returns null when no `item_uom` row links the item to that UOM.
*/
open fun convertQtyToBaseQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal? {
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return null
val baseUnit = findBaseUnitByItemId(itemId)
if (ItemUomQtyConvert.sameUomConversionId(uomId, baseUnit?.uom?.id)) {
return sourceQty.stripTrailingZeros()
}
val one = BigDecimal.ONE
val calcScale = 10
return sourceQty
@@ -346,6 +370,7 @@ open class ItemUomService(
}


/** FP-MTMS Version Checklist | Functions Ref. No. 73 | v1.0.0 | 2026-09-07 */
open fun convertUomByItem(request: ConvertUomByItemRequest): ConvertUomByItemResponse {
// Special case: Direct conversion from KG (784) to gram (4) or vice versa
// This handles cases where ItemUom records might not exist
@@ -440,6 +465,15 @@ open class ItemUomService(
?: throw IllegalArgumentException(
"Target ItemUom not found for items.code=$itemCode, targetUnit=${request.targetUnit}"
)
val uomConversion = targetItemUom.uom
?: throw IllegalArgumentException("Target UomConversion not found for target ItemUom")
if (ItemUomQtyConvert.sameUomConversionId(request.uomId, uomConversion.id)) {
return ConvertUomByItemResponse(
newQty = request.qty,
udfudesc = uomConversion.udfudesc,
udfShortDesc = uomConversion.udfShortDesc
)
}
// Convert quantity using ratioN/ratioD via base unit
val one = BigDecimal.ONE
val sourceRatioN = sourceItemUom.ratioN ?: one
@@ -453,10 +487,6 @@ open class ItemUomService(
// Convert base to target: baseQty * ratioD / ratioN
val newQty = baseQty.multiply(targetRatioD).divide(targetRatioN, 2, RoundingMode.UP)
// Get UomConversion from target ItemUom
val uomConversion = targetItemUom.uom
?: throw IllegalArgumentException("Target UomConversion not found for target ItemUom")
return ConvertUomByItemResponse(
newQty = newQty,
udfudesc = uomConversion.udfudesc,


+ 195
- 38
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt Прегледај датотеку

@@ -8,6 +8,7 @@ import com.ffii.fpsms.modules.master.web.models.ItemQc
import com.ffii.fpsms.modules.master.web.models.ItemWithQcResponse
import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.master.web.models.NewItemRequest
import com.ffii.fpsms.modules.master.support.ItemAveragePriceFx
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.io.IOException
@@ -62,13 +63,13 @@ open class ItemsService(
@Lazy private val stockInService: StockInService,
private val stockInRepository: StockInRepository,
private val stockTakeLineService: StockTakeLineService,
@Lazy private val itemUomService: ItemUomService,
private val stockInLineService: StockInLineService,
private val stockInLineRepository: StockInLineRepository,
private val inventoryLotLineRepository: InventoryLotLineRepository,
private val inventoryLotRepository: InventoryLotRepository,
private val bagRepository: BagRepository,
private val itemsQcCategoryMappingRepository: ItemsQcCategoryMappingRepository,
private val itemAveragePriceFx: ItemAveragePriceFx,
): AbstractBaseEntityService<Items, Long, ItemsRepository>(jdbcDao, itemsRepository) {

private val excelImportPath: String = System.getProperty("user.home") + "/Downloads/StockTakeImport/"
@@ -429,47 +430,193 @@ open class ItemsService(
}

/**
* Incremental update of AverageUnitPrice for the given items only.
* Recomputes weighted average (SUM(up*qty)/SUM(qty)) from purchase_order_line
* where po.orderDate >= '2026-01-01', then updates items.AverageUnitPrice.
* Items with no qualifying PO lines get AverageUnitPrice = null.
* FP-MTMS Version Checklist | Functions Ref. No. 72 | v1.0.0 | 2026-09-07
* Weighted average unit cost in **HKD per stock unit**.
* SUM(lineAmtForeign * fx) / SUM(qty converted to stock UOM).
* FX: `local` hardcoded rates, or `m18` per-PO [purchase_order.exchangeRate].
* Items with no qualifying 2026-01-01+ PO lines get AverageUnitPrice = null.
*/
@Transactional
open fun updateAverageUnitPriceForItems(itemIds: Set<Long>) {
if (itemIds.isEmpty()) return
val idList = itemIds.toList()
val args = mapOf("itemIds" to idList)
val avgSql = """
SELECT pol.itemId AS itemId,
ROUND(CAST(SUM(pol.up * pol.qty) / NULLIF(SUM(pol.qty), 0) AS DECIMAL(14,2)), 2) AS avgUp
FROM purchase_order_line pol
JOIN purchase_order po ON po.id = pol.purchaseOrderId AND po.deleted = false
WHERE pol.itemId IN (:itemIds) AND pol.deleted = false
AND po.orderDate >= '2026-01-01'
AND pol.up IS NOT NULL AND pol.qty > 0
GROUP BY pol.itemId
""".trimIndent()
val rows = jdbcDao.queryForList(avgSql, args)
val avgByItem = rows.mapNotNull { row ->
val id = (row["itemId"] as? Number)?.toLong() ?: return@mapNotNull null
val avg = row["avgUp"] as? BigDecimal ?: return@mapNotNull null
id to avg.toPlainString()
}.toMap()
// Use JDBC UPDATE instead of load+save: avoids ObjectOptimisticLockingFailureException when the same
// Items row was already updated earlier in this transaction (e.g. M18 PO sync) or concurrently.
idList.forEach { itemId ->
val avg = avgByItem[itemId]
jdbcDao.executeUpdate(
"""
UPDATE items
SET AverageUnitPrice = :avg,
modified = NOW(),
version = version + 1
WHERE id = :id AND deleted = 0
""".trimIndent(),
mapOf("avg" to avg, "id" to itemId),
)
jdbcDao.executeUpdate(
averageUnitPriceUpdateSql(itemIdFilter = true),
mapOf("itemIds" to itemIds.toList()),
)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 72 | v1.0.0 | 2026-09-07 */
@Transactional
open fun recalculateAllAverageUnitPrices(): Int {
val started = System.currentTimeMillis()
logger.info("recalculateAllAverageUnitPrices start")
val updated = jdbcDao.executeUpdate(
averageUnitPriceUpdateSql(itemIdFilter = false),
emptyMap<String, Any>(),
)
logger.info("recalculateAllAverageUnitPrices updated $updated items in ${System.currentTimeMillis() - started} ms")
return updated
}

/**
* One SQL: UOM conversion via `item_uom` ratios + FX CASE, then UPDATE items.
* Incremental (`itemIdFilter`): all given ids, null avg if no usable 2026+ PO lines.
* Full recalc: only items that have 2026+ PO lines (manual Case 1 prices stay).
*/
private fun averageUnitPriceUpdateSql(itemIdFilter: Boolean): String {
val itemFilter = if (itemIdFilter) "AND pol.itemId IN (:itemIds)" else ""
val updateStmt = if (itemIdFilter) {
"""
UPDATE items i
LEFT JOIN calc ON calc.itemId = i.id
SET
i.AverageUnitPrice = CAST(calc.avgUp AS CHAR),
i.homeCurrencyId = COALESCE(
i.homeCurrencyId,
(SELECT id FROM currency WHERE deleted = 0 AND UPPER(TRIM(code)) = 'HKD' LIMIT 1)
),
i.purchaseCurrencyId = calc.latestCurrencyId,
i.purchaseUnitPrice = calc.latestPurchaseUnitPrice,
i.purchaseFxRate = calc.latestFx,
i.modified = NOW(),
i.version = i.version + 1
WHERE i.deleted = 0
AND i.id IN (:itemIds)
"""
} else {
"""
UPDATE items i
INNER JOIN qualifying q ON q.itemId = i.id
LEFT JOIN calc ON calc.itemId = i.id
SET
i.AverageUnitPrice = CAST(calc.avgUp AS CHAR),
i.homeCurrencyId = COALESCE(
i.homeCurrencyId,
(SELECT id FROM currency WHERE deleted = 0 AND UPPER(TRIM(code)) = 'HKD' LIMIT 1)
),
i.purchaseCurrencyId = calc.latestCurrencyId,
i.purchaseUnitPrice = calc.latestPurchaseUnitPrice,
i.purchaseFxRate = calc.latestFx,
i.modified = NOW(),
i.version = i.version + 1
WHERE i.deleted = 0
"""
}
val fx = itemAveragePriceFx.fxSql("po", "c")
return """
WITH uom_one 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
),
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
),
line_raw AS (
SELECT
pol.itemId,
c.id AS currencyId,
($fx) AS fx,
COALESCE(pol.price, pol.up * COALESCE(pol.qtyM18, pol.qty)) AS lineAmt,
pol.qty AS purchaseQty,
CASE
WHEN so.id IS NULL THEN 0
WHEN pol.qtyM18 IS NOT NULL AND pol.qtyM18 > 0 AND pol.uomIdM18 IS NOT NULL THEN
CASE
WHEN src.id IS NULL THEN pol.qtyM18
WHEN src.uomId = so.uomId THEN pol.qtyM18
ELSE pol.qtyM18
* (COALESCE(src.ratioN, 1) / COALESCE(NULLIF(src.ratioD, 0), 1))
* (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1))
END
ELSE
CASE
WHEN pur.id IS NULL THEN COALESCE(pol.qty, 0)
WHEN pur.uomId = so.uomId THEN COALESCE(pol.qty, 0)
ELSE COALESCE(pol.qty, 0)
* (COALESCE(pur.ratioN, 1) / COALESCE(NULLIF(pur.ratioD, 0), 1))
* (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1))
END
END AS stockQty,
po.orderDate,
pol.id AS polId
FROM purchase_order_line pol
JOIN purchase_order po ON po.id = pol.purchaseOrderId AND po.deleted = 0
LEFT JOIN currency c ON c.id = po.currencyId AND c.deleted = 0
LEFT JOIN uom_one src ON src.itemId = pol.itemId AND src.uomId = pol.uomIdM18
LEFT JOIN uom_one pur ON pur.itemId = pol.itemId AND pur.uomId = pol.uomId
LEFT JOIN stock_one so ON so.itemId = pol.itemId
WHERE pol.deleted = 0
AND po.orderDate >= '2026-01-01'
AND pol.up IS NOT NULL
AND COALESCE(pol.qtyM18, pol.qty, 0) > 0
$itemFilter
),
line_ok AS (
SELECT
itemId, currencyId, fx, lineAmt, purchaseQty, stockQty,
ROW_NUMBER() OVER (PARTITION BY itemId ORDER BY orderDate DESC, polId DESC) AS rn
FROM line_raw
WHERE fx IS NOT NULL AND stockQty > 0 AND lineAmt IS NOT NULL
),
qualifying AS (
SELECT DISTINCT itemId FROM line_raw
),
calc AS (
SELECT
itemId,
ROUND(SUM(lineAmt * fx) / NULLIF(SUM(stockQty), 0), 4) AS avgUp,
MAX(CASE WHEN rn = 1 THEN currencyId END) AS latestCurrencyId,
ROUND(
MAX(CASE WHEN rn = 1 THEN lineAmt / NULLIF(purchaseQty, 0) END),
4
) AS latestPurchaseUnitPrice,
ROUND(MAX(CASE WHEN rn = 1 THEN fx END), 4) AS latestFx
FROM line_ok
GROUP BY itemId
)
$updateStmt
""".trimIndent()
}

private fun stockUnitLabel(itemId: Long): String? {
val rows = jdbcDao.queryForList(
"""
SELECT COALESCE(uc.udfudesc, uc.code, '') AS label
FROM item_uom iu
JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE iu.itemId = :itemId AND iu.deleted = 0 AND iu.stockUnit = 1
LIMIT 1
""".trimIndent(),
mapOf("itemId" to itemId),
)
return rows.firstOrNull()?.get("label")?.toString()?.takeIf { it.isNotBlank() }
}

private fun lookupHkdCurrencyId(): Long? {
val rows = jdbcDao.queryForList(
"""
SELECT id
FROM currency
WHERE deleted = 0 AND UPPER(TRIM(code)) = 'HKD'
LIMIT 1
""".trimIndent(),
emptyMap<String, Any>(),
)
val v = rows.firstOrNull()?.get("id") ?: return null
return if (v is Number) v.toLong() else v.toString().toLongOrNull()
}

/** Column headers in Chinese for market unit price template/import */
@@ -613,6 +760,7 @@ open class ItemsService(
}

// QcCheck included item
/** FP-MTMS Version Checklist | Functions Ref. No. 72 | v1.0.0 | 2026-09-07 */
open fun getItem(id: Long): ItemWithQcResponse {
val list = listOf(1, 2)
val item = itemsRepository.findByIdAndDeletedFalse(id) ?: Items()
@@ -640,7 +788,9 @@ open class ItemsService(
val response = ItemWithQcResponse(
item = item,
qcChecks = qc,
qcType = qcType
qcType = qcType,
averageUnitPriceEditable = true,
stockUnitLabel = item.id?.let { stockUnitLabel(it) },
)
// TODO: Return with QC items
return response
@@ -650,6 +800,7 @@ open class ItemsService(
// // TODO: Return with QC items
// return itemsRepository.findById(id).get()
// }
/** FP-MTMS Version Checklist | Functions Ref. No. 72 | v1.0.0 | 2026-09-07 */
@Throws(IOException::class)
@Transactional
open fun saveItem(request: NewItemRequest): MessageResponse {
@@ -748,6 +899,12 @@ open class ItemsService(
request.slot?.let { slot = it }
request.LocationCode?.let { LocationCode = it }
}
request.averageUnitPrice?.let { raw ->
item.averageUnitPrice = raw.trim().takeIf { it.isNotEmpty() }
}
if (item.homeCurrencyId == null) {
item.homeCurrencyId = lookupHkdCurrencyId()
}
logger.info("saving item: $item")
val savedItem = itemsRepository.saveAndFlush(item)
logger.info("save success")


+ 42
- 6
src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt Прегледај датотеку

@@ -1802,9 +1802,25 @@ open class ProductionScheduleService(
left join purchase_order on purchase_order_line.purchaseOrderId = purchase_order.id
where purchase_order_line.itemId = itm.id and date(purchase_order.estimatedArrivalDate) >= date(now()) and purchase_order.completeDate is null) as purchasedQty,
bm.uomName,
(select ratioD/ratioN from item_uom where purchaseUnit = 1 and itemId = it.id and item_uom.deleted = 0
(select CASE
WHEN item_uom.uomId = (
SELECT ius2.uomId FROM item_uom ius2
WHERE ius2.itemId = item_uom.itemId AND ius2.stockUnit = 1 AND ius2.deleted = 0
LIMIT 1
) THEN 1
ELSE ratioD/ratioN
END from item_uom where purchaseUnit = 1 and itemId = it.id and item_uom.deleted = 0
) as purchaseRatio,
(select ceil(ratioD/ratioN*sum(bm.qty * psl.batchNeed)) from item_uom where purchaseUnit = 1 and itemId = it.id and item_uom.deleted = 0
(select ceil(
CASE
WHEN item_uom.uomId = (
SELECT ius2.uomId FROM item_uom ius2
WHERE ius2.itemId = item_uom.itemId AND ius2.stockUnit = 1 AND ius2.deleted = 0
LIMIT 1
) THEN SUM(bm.qty * psl.batchNeed)
ELSE ratioD/ratioN*SUM(bm.qty * psl.batchNeed)
END
) from item_uom where purchaseUnit = 1 and itemId = it.id and item_uom.deleted = 0
) as purchaseQtyNeed,
(select code from item_uom
left join uom_conversion on item_uom.uomId = uom_conversion.id
@@ -1869,10 +1885,23 @@ open class ProductionScheduleService(
itm.code AS matCode,
itm.name AS matName,
COALESCE(uomM18.code, uomP.code) AS uom,
ceil((iv.onHandQty * (itsm.ratioN / itsm.ratioD)) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))) as onHandQty,
ceil((iv.unavailableQty * (itsm.ratioN / itsm.ratioD)) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))) as unavailableQty,
CASE
WHEN itsm.uomId IS NOT NULL AND itsm.uomId = COALESCE(ium18.uomId, itum.uomId)
THEN ceil(COALESCE(iv.onHandQty, 0))
ELSE ceil((iv.onHandQty * (itsm.ratioN / itsm.ratioD)) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN)))
END as onHandQty,
CASE
WHEN itsm.uomId IS NOT NULL AND itsm.uomId = COALESCE(ium18.uomId, itum.uomId)
THEN ceil(COALESCE(iv.unavailableQty, 0))
ELSE ceil((iv.unavailableQty * (itsm.ratioN / itsm.ratioD)) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN)))
END as unavailableQty,
COALESCE((
SELECT ceil(SUM(pol.qty * (itum.ratioN / itum.ratioD) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))))
SELECT ceil(SUM(
CASE
WHEN itum.uomId IS NOT NULL AND itum.uomId = COALESCE(ium18.uomId, itum.uomId) THEN pol.qty
ELSE pol.qty * (itum.ratioN / itum.ratioD) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))
END
))
FROM purchase_order_line pol
JOIN purchase_order po ON pol.purchaseOrderId = po.id
WHERE pol.itemId = itm.id
@@ -1880,7 +1909,14 @@ open class ProductionScheduleService(
AND po.completeDate IS NULL
), 0) AS purchasedQty,
DATE(ps.produceAt) AS produceDate,
ceil( SUM(bm.baseQty * psl.batchNeed) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN)) ) AS qtyNeeded
ceil(
CASE
WHEN COALESCE(ium18.uomId, itum.uomId) IS NOT NULL
AND COALESCE(ium18.uomId, itum.uomId) = itsm.uomId
THEN SUM(bm.baseQty * psl.batchNeed)
ELSE SUM(bm.baseQty * psl.batchNeed) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))
END
) AS qtyNeeded
FROM production_schedule_line psl
JOIN production_schedule ps ON psl.prodScheduleId = ps.id
JOIN items it ON psl.itemId = it.id


+ 35
- 0
src/main/java/com/ffii/fpsms/modules/master/support/HomeFxRates.kt Прегледај датотеку

@@ -0,0 +1,35 @@
package com.ffii.fpsms.modules.master.support

import java.math.BigDecimal
import java.math.RoundingMode

/**
* V1 (`fx-source=local`) HKD rates. V2 reads [purchase_order.exchangeRate] from M18.
* JPY: 1 HKD = 20.35 JPY.
*/
object HomeFxRates {
const val HOME = "HKD"

private val jpyToHkd: BigDecimal =
BigDecimal.ONE.divide(BigDecimal("20.35"), 10, RoundingMode.HALF_UP)

val toHkd: Map<String, BigDecimal> = mapOf(
"HKD" to BigDecimal.ONE,
"USD" to BigDecimal("7.7823"),
"RMB" to BigDecimal("1.1700"),
"CNY" to BigDecimal("1.1700"),
"JPY" to jpyToHkd,
)

/** SQL fragment: local FX to HKD from a currency-code expression. */
fun localFxSql(currencyCodeExpr: String): String = """
CASE UPPER(TRIM(COALESCE($currencyCodeExpr, '')))
WHEN 'HKD' THEN 1
WHEN 'USD' THEN 7.7823
WHEN 'RMB' THEN 1.1700
WHEN 'CNY' THEN 1.1700
WHEN 'JPY' THEN (1 / 20.35)
ELSE NULL
END
""".trimIndent()
}

+ 37
- 0
src/main/java/com/ffii/fpsms/modules/master/support/ItemAveragePriceFx.kt Прегледај датотеку

@@ -0,0 +1,37 @@
package com.ffii.fpsms.modules.master.support

import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.math.BigDecimal

@Component
open class ItemAveragePriceFx(
@Value("\${fpsms.item-average-price.fx-source:local}")
private val fxSource: String,
) {
fun isM18Source(): Boolean = fxSource.equals("m18", ignoreCase = true)

fun resolveRate(currencyCode: String?, poExchangeRate: BigDecimal?): BigDecimal? {
val code = currencyCode?.trim()?.uppercase().orEmpty()
if (isM18Source()) {
if (poExchangeRate != null) return poExchangeRate
return if (code == HomeFxRates.HOME) BigDecimal.ONE else null
}
if (code.isEmpty()) return null
return HomeFxRates.toHkd[code]
}

/** SQL for FX to HKD; aliases [po] and [c] (currency). */
fun fxSql(po: String = "po", currency: String = "c"): String {
if (isM18Source()) {
return """
CASE
WHEN $po.exchangeRate IS NOT NULL THEN $po.exchangeRate
WHEN UPPER(TRIM(COALESCE($currency.code, ''))) = '${HomeFxRates.HOME}' THEN 1
ELSE NULL
END
""".trimIndent()
}
return HomeFxRates.localFxSql("$currency.code")
}
}

+ 13
- 0
src/main/java/com/ffii/fpsms/modules/master/support/ItemUomQtyConvert.kt Прегледај датотеку

@@ -0,0 +1,13 @@
package com.ffii.fpsms.modules.master.support

/**
* Same-[com.ffii.fpsms.modules.master.entity.UomConversion] skip for qty conversion.
* When source and target share a `uom_conversion.id`, do not apply `item_uom` ratioN/ratioD
* (those ratios can be spec numbers such as 350g, not a 1:1 identity).
*/
object ItemUomQtyConvert {
/** FP-MTMS Version Checklist | Functions Ref. No. 73 | v1.0.0 | 2026-09-07 */
fun sameUomConversionId(sourceUomId: Long?, targetUomId: Long?): Boolean {
return sourceUomId != null && targetUomId != null && sourceUomId == targetUomId
}
}

+ 7
- 0
src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt Прегледај датотеку

@@ -195,6 +195,13 @@ fun getItemsWithDetailsByPage(request: HttpServletRequest): RecordsRes<Map<Strin
return RecordsRes(paginatedList, fullList.size)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 72 | v1.0.0 | 2026-09-07 */
@PostMapping("/averageUnitPrice/recalculate")
fun recalculateAverageUnitPrices(): Map<String, Any> {
val updated = itemsService.recalculateAllAverageUnitPrices()
return mapOf("updatedItemCount" to updated)
}

@GetMapping("/marketUnitPrice/template")
fun downloadMarketUnitPriceTemplate(): ResponseEntity<ByteArray> {
val bytes = itemsService.generateMarketUnitPriceTemplate()


+ 3
- 1
src/main/java/com/ffii/fpsms/modules/master/web/models/ItemWithQcResponse.kt Прегледај датотеку

@@ -16,5 +16,7 @@ data class ItemQc(
data class ItemWithQcResponse(
val item: Items,
val qcChecks: List<ItemQc>,
val qcType: String? = null
val qcType: String? = null,
val averageUnitPriceEditable: Boolean = false,
val stockUnitLabel: String? = null,
)

+ 2
- 0
src/main/java/com/ffii/fpsms/modules/master/web/models/NewItemRequest.kt Прегледај датотеку

@@ -54,6 +54,8 @@ data class NewItemRequest(
val isFee: Boolean?,
val isBag: Boolean?,
val qcType: String?,
/** HKD per stock unit. Only applied on save when the item has no qualifying 2026 PO. */
val averageUnitPrice: String? = null,
// val type: List<NewTypeRequest>?,
// val uom: List<NewUomRequest>?,
// val weightUnit: List<NewWeightUnitRequest>?,


+ 16
- 8
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt Прегледај датотеку

@@ -1814,24 +1814,28 @@ open fun getBadItemOnlyList(): List<PickExecutionIssue> {
return pickExecutionIssueRepository.findBadItemOnlyList(IssueCategory.lot_issue)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */
open fun getExpiryItemList(
expiryDate: LocalDate? = null,
itemCode: String? = null,
itemName: String? = null,
lotNo: String? = null,
): List<ExpiryItemResponse> {
val today = LocalDate.now()
val untilDate = today.plusDays(7)
val normalizedItemCode = itemCode?.trim()?.takeIf { it.isNotEmpty() }
val normalizedItemName = itemName?.trim()?.takeIf { it.isNotEmpty() }
val normalizedLotNo = lotNo?.trim()?.takeIf { it.isNotEmpty() }
val lotLines = inventoryLotLineRepository.findExpiredItems(
today = today,
expiryDate = expiryDate,
untilDate = untilDate,
itemCode = normalizedItemCode,
itemName = normalizedItemName,
lotNo = normalizedLotNo,
)
return lotLines.map { lotLine ->
val lot = lotLine.inventoryLot
val item = lot?.item // Get item from inventoryLot
val expiry = lot?.expiryDate
ExpiryItemResponse(
id = lotLine.id ?: 0L,
itemId = item?.id ?: 0L,
@@ -1840,8 +1844,10 @@ open fun getExpiryItemList(
lotId = lot?.id ?: 0L,
lotNo = lot?.lotNo,
storeLocation = lotLine.warehouse?.code, // Construct from warehouse
expiryDate = lot?.expiryDate,
remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO)
expiryDate = expiry,
remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO),
uomDesc = lotLine.stockUom?.uom?.udfudesc,
canHandle = expiry != null && !expiry.isAfter(today),
)
}
}
@@ -1993,6 +1999,7 @@ open fun submitBadItem(request: SubmitIssueRequest): MessageResponse {
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */
@Transactional(rollbackFor = [Exception::class])
open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse {
try {
@@ -2011,7 +2018,7 @@ open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse {
val lot = lotLine.inventoryLot
val today = LocalDate.now()
if (lot?.expiryDate == null || !lot.expiryDate!!.isBefore(today)) {
if (lot?.expiryDate == null || lot.expiryDate!!.isAfter(today)) {
return MessageResponse(
id = null,
name = "Error",
@@ -2201,6 +2208,7 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse {
}
}
// Fix batchSubmitExpiryItem method (around line 945):
/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */
@Transactional(rollbackFor = [Exception::class])
open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse {
try {
@@ -2209,8 +2217,8 @@ open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageRespon
val lot = it.inventoryLot
val today = LocalDate.now()
lot?.expiryDate != null &&
lot.expiryDate!!.isBefore(today) &&
(it.inQty ?: BigDecimal.ZERO) != (it.outQty ?: BigDecimal.ZERO)
lot.expiryDate!!.let { !it.isAfter(today) } &&
(it.inQty ?: BigDecimal.ZERO) > (it.outQty ?: BigDecimal.ZERO)
}

if (lotLines.isEmpty()) {


+ 5
- 4
src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt Прегледај датотеку

@@ -6,9 +6,7 @@ import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssue
import com.ffii.fpsms.modules.pickOrder.enums.PickExecutionIssueEnum
import com.ffii.fpsms.modules.pickOrder.service.PickExecutionIssueService // 修复导入路径
import com.ffii.fpsms.modules.pickOrder.web.models.*
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.web.bind.annotation.*
import java.time.LocalDate


@RestController
@@ -66,16 +64,17 @@ class PickExecutionIssueController(
return pickExecutionIssueService.getBadItemList(issueCategory)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */
@GetMapping("/issues/expiryItem")
fun getExpiryItemIssues(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) expiryDate: LocalDate?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) itemName: String?,
@RequestParam(required = false) lotNo: String?,
): List<ExpiryItemResponse> {
return pickExecutionIssueService.getExpiryItemList(
expiryDate = expiryDate,
itemCode = itemCode,
itemName = itemName,
lotNo = lotNo,
)
}

@@ -99,11 +98,13 @@ fun batchSubmitBadItem(@RequestBody request: BatchSubmitIssueRequest): MessageRe
return pickExecutionIssueService.batchSubmitBadItem(request)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */
@PostMapping("/submitExpiryItem")
fun submitExpiryItem(@RequestBody request: SubmitExpiryRequest): MessageResponse {
return pickExecutionIssueService.submitExpiryItem(request)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */
@PostMapping("/batchSubmitExpiryItem")
fun batchSubmitExpiryItem(@RequestBody request: BatchSubmitExpiryRequest): MessageResponse {
return pickExecutionIssueService.batchSubmitExpiryItem(request)


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt Прегледај датотеку

@@ -31,6 +31,9 @@ data class ExpiryItemResponse(
val storeLocation: String?,
val expiryDate: LocalDate?,
val remainingQty: BigDecimal,
val uomDesc: String?,
/** True when expiryDate is today or earlier. */
val canHandle: Boolean,
)
data class LotIssueDetailRequest(
val lotId: Long,


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrder.kt Прегледај датотеку

@@ -11,6 +11,7 @@ import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderTypeConverter
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import java.math.BigDecimal
import java.time.LocalDateTime

@Entity
@@ -52,6 +53,10 @@ open class PurchaseOrder : BaseEntity<Long>() {
@JoinColumn(name = "currencyId")
open var currency: Currency? = null

/** MPO FX to HKD. Null in local-rate mode until M18 sends it. */
@Column(name = "exchangeRate", precision = 14, scale = 4)
open var exchangeRate: BigDecimal? = null

@NotNull
@ManyToOne
@JoinColumn(name = "m18DataLogId", nullable = false)


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt Прегледај датотеку

@@ -75,6 +75,8 @@ open class ItemQcFailReportService(
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(
COALESCE(
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN sil.acceptedQty
WHEN iu_purchase.uomId IS NOT NULL AND iu_purchase.uomId = iu.uomId
THEN sil.acceptedQty
WHEN iu_purchase.id IS NOT NULL AND iu.id IS NOT NULL
THEN sil.acceptedQty * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu.ratioN / NULLIF(iu.ratioD, 0))


+ 27
- 9
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt Прегледај датотеку

@@ -1193,9 +1193,12 @@ return result
sl.created AS ledgerCreated,
sl.id AS slId,
(
COALESCE(pol_in.up, 0)
* (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0))
/ (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0))
CASE
WHEN iu_stock.uomId IS NOT NULL AND iu_stock.uomId = iu_purchase.uomId THEN COALESCE(pol_in.up, 0)
ELSE COALESCE(pol_in.up, 0)
* (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0))
/ (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0))
END
) AS stockUnitCost
FROM stock_ledger sl
LEFT JOIN stock_in_line sil ON sl.stockInLineId = sil.id AND sil.deleted = 0
@@ -1457,9 +1460,7 @@ return result
*
* - 期初存量: 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) 算加權平均
* - 單位均價/庫存總價值: 有任一採購單且 AverageUnitPrice 有值才顯示;無 PO(OPEN/ADJ/工單)空白
* - 庫存總價值: 單位均價 * 現存存貨
*/
fun searchStockBalanceReportByDate(
@@ -1495,15 +1496,24 @@ return result
AND sl.itemId IS NOT NULL
AND DATE(sl.date) <= p.d0
),
item_has_po AS (
SELECT DISTINCT pol.itemId
FROM purchase_order_line pol
JOIN purchase_order po ON po.id = pol.purchaseOrderId AND po.deleted = 0
WHERE pol.deleted = 0
AND pol.itemId IS NOT NULL
),
item_scope 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
CAST(NULLIF(TRIM(it.AverageUnitPrice), '') AS DECIMAL(14, 4)) AS avgUnitPriceRaw,
CASE WHEN hp.itemId IS NOT NULL THEN 1 ELSE 0 END AS hasPo
FROM items it
INNER JOIN ledger_item_ids li ON li.itemId = it.id
LEFT JOIN item_has_po hp ON hp.itemId = it.id
WHERE it.deleted = 0
AND it.code IS NOT NULL AND it.code <> ''
$itemCodeSql
@@ -1645,8 +1655,16 @@ return result
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
CASE
WHEN COALESCE(s.hasPo, 0) = 1 AND s.avgUnitPriceRaw IS NOT NULL
THEN FORMAT(ROUND(s.avgUnitPriceRaw, 2), 2)
ELSE ''
END AS avgUnitPrice,
CASE
WHEN COALESCE(s.hasPo, 0) = 1 AND s.avgUnitPriceRaw IS NOT NULL
THEN FORMAT(ROUND(s.avgUnitPriceRaw * COALESCE(cp.currentBalance, 0), 2), 2)
ELSE ''
END 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


+ 31
- 2
src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt Прегледај датотеку

@@ -9,6 +9,8 @@ import java.time.format.DateTimeFormatter
* 庫存批次現況(Stock Balance):永遠今天。
* 現存讀 [inventory_lot_line](available、未過期、in-out > 0)。
* 最後異動:有 inventoryLotLineId 的帳本用 MAX(id);缺的比 SIL/SOL 時間。
* 單位均價:貨品 AverageUnitPrice(HKD/stock UOM)用 item_uom 比率轉成該 lot 單位;
* 同一貨品同一 UOM 共用同一均價。庫存總價值 = 該 UOM 均價 × lot 數量。
* 單位均價/庫存總價值只填 root PO 為 PP/PF 的批(TRF 往回走);其他來源空白。
*/
@Service
@@ -70,6 +72,19 @@ lot_root_origin AS (
WHERE t.rn = 1
)"""

/** One stock-unit item_uom per item (same pick as item average-price SQL). */
private const val STOCK_ONE_CTE_SQL = """
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
)"""

private const val ROOT_STOCK_IN_JOIN_SQL = """
LEFT JOIN lot_root_origin lro
ON lro.lotId = il.id
@@ -133,7 +148,7 @@ lot_root_origin AS (
}
val lotOriginFilterSql = buildLotOriginFilterSql(lotOrigin)
val originJoinSql = ROOT_STOCK_IN_JOIN_SQL
val ctePrefix = "WITH RECURSIVE\n$LOT_ROOT_ORIGIN_CTE_SQL\n"
val ctePrefix = "WITH RECURSIVE\n$LOT_ROOT_ORIGIN_CTE_SQL,\n$STOCK_ONE_CTE_SQL\n"

val liveLots = jdbcDao.queryForList(
"""
@@ -149,7 +164,19 @@ lot_root_origin AS (
COALESCE(wh.area, '') AS areaPart,
COALESCE(wh.slot, '') AS slotPart,
(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS lotQtyRaw,
COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw,
CASE
WHEN iu.id IS NULL OR so.id IS NULL THEN
COALESCE(CAST(NULLIF(TRIM(it.AverageUnitPrice), '') AS DECIMAL(14, 4)), 0)
WHEN iu.uomId = so.uomId THEN
COALESCE(CAST(NULLIF(TRIM(it.AverageUnitPrice), '') AS DECIMAL(14, 4)), 0)
ELSE
ROUND(
COALESCE(CAST(NULLIF(TRIM(it.AverageUnitPrice), '') AS DECIMAL(14, 4)), 0)
* (COALESCE(iu.ratioN, 1) / COALESCE(NULLIF(iu.ratioD, 0), 1))
* (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)),
4
)
END AS avgUnitPriceRaw,
UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) AS rootPoPrefix
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
@@ -162,6 +189,8 @@ lot_root_origin AS (
ON iu.id = ill.stockItemUomId AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON uc.id = iu.uomId
LEFT JOIN stock_one so
ON so.itemId = it.id
$originJoinSql
WHERE ill.deleted = 0
AND it.code IS NOT NULL AND it.code <> ''


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt Прегледај датотеку

@@ -214,6 +214,8 @@ in_agg AS (
ill.id AS inventoryLotLineId,
SUM(CASE WHEN DATE(sil.receiptDate) < :fromDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.uomId IS NOT NULL AND iu_purchase.uomId = iu_stock.uomId
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0))
@@ -222,6 +224,8 @@ in_agg AS (
ELSE 0 END) AS inBefore,
SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN :fromDate AND :toDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.uomId IS NOT NULL AND iu_purchase.uomId = iu_stock.uomId
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0))
@@ -489,6 +493,8 @@ in_agg AS (
ill.id AS inventoryLotLineId,
SUM(CASE WHEN DATE(sil.receiptDate) < rb.fromDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.uomId IS NOT NULL AND iu_purchase.uomId = iu_stock.uomId
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0))
@@ -497,6 +503,8 @@ in_agg AS (
ELSE 0 END) AS inBefore,
SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN rb.fromDate AND rb.toDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.uomId IS NOT NULL AND iu_purchase.uomId = iu_stock.uomId
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0))
@@ -792,6 +800,8 @@ in_agg AS (
lb.inventoryLotLineId,
SUM(CASE WHEN DATE(sil.receiptDate) < lb.fromDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.uomId IS NOT NULL AND iu_purchase.uomId = iu_stock.uomId
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0))
@@ -800,6 +810,8 @@ in_agg AS (
ELSE 0 END) AS inBefore,
SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN lb.fromDate AND lb.toDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.uomId IS NOT NULL AND iu_purchase.uomId = iu_stock.uomId
THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL
THEN COALESCE(sil.acceptedQty, 0) * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu_stock.ratioN / NULLIF(iu_stock.ratioD, 0))


+ 4
- 3
src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt Прегледај датотеку

@@ -540,11 +540,12 @@ class ReportController(
numberStyle: XSSFCellStyle,
dashStyle: XSSFCellStyle,
preferInt: Boolean = false,
emptyAsBlank: Boolean = false,
) {
val cell = row.createCell(col)
val parsed = parseSignedNumber(value)
if (parsed == null) {
cell.setCellValue("-")
cell.setCellValue(if (emptyAsBlank) "" else "-")
cell.cellStyle = dashStyle
return
}
@@ -914,8 +915,8 @@ class ReportController(
setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true)
setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true)
}
}



+ 8
- 6
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt Прегледај датотеку

@@ -220,27 +220,29 @@ WHERE ill.id = :id

@EntityGraph(
type = EntityGraph.EntityGraphType.FETCH,
attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"]
attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse", "stockUom", "stockUom.uom"]
)
@Query("""
SELECT ill FROM InventoryLotLine ill
JOIN ill.inventoryLot il
JOIN il.item i
WHERE il.expiryDate < :today
AND (:expiryDate IS NULL OR il.expiryDate = :expiryDate)
WHERE il.expiryDate IS NOT NULL
AND il.expiryDate <= :untilDate
AND (:itemCode IS NULL OR LOWER(i.code) LIKE LOWER(CONCAT('%', :itemCode, '%')))
AND (:itemName IS NULL OR LOWER(i.name) LIKE LOWER(CONCAT('%', :itemName, '%')))
AND coalesce(ill.inQty, 0) <> coalesce(ill.outQty, 0)
AND (:lotNo IS NULL OR LOWER(il.lotNo) LIKE LOWER(CONCAT('%', :lotNo, '%')))
AND coalesce(ill.inQty, 0) > coalesce(ill.outQty, 0)
AND ill.deleted = false
AND il.deleted = false
AND i.deleted = false
ORDER BY il.expiryDate ASC
""")
/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */
fun findExpiredItems(
@Param("today") today: LocalDate,
@Param("expiryDate") expiryDate: LocalDate?,
@Param("untilDate") untilDate: LocalDate,
@Param("itemCode") itemCode: String?,
@Param("itemName") itemName: String?,
@Param("lotNo") lotNo: String?,
): List<InventoryLotLine>

/**


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt Прегледај датотеку

@@ -103,6 +103,8 @@ fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLe
AND (:lotNo IS NULL OR il.lotNo LIKE CONCAT('%', :lotNo, '%'))
AND (:startDate IS NULL OR il.expiryDate >= :startDate)
AND (:endDateExclusive IS NULL OR il.expiryDate < :endDateExclusive)
AND (:handledStartDate IS NULL OR sl.date >= :handledStartDate)
AND (:handledEndDateExclusive IS NULL OR sl.date < :handledEndDateExclusive)
ORDER BY sl.date DESC, sl.id DESC
""")
fun findExpiryItemHandleRecords(
@@ -111,6 +113,8 @@ fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLe
@Param("lotNo") lotNo: String?,
@Param("startDate") startDate: LocalDate?,
@Param("endDateExclusive") endDateExclusive: LocalDate?,
@Param("handledStartDate") handledStartDate: LocalDate?,
@Param("handledEndDateExclusive") handledEndDateExclusive: LocalDate?,
pageable: Pageable,
): Page<StockLedger>
}

+ 7
- 2
src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt Прегледај датотеку

@@ -70,9 +70,14 @@ interface PutAwayLineForSil {
/** Stock qty (inventory lot line inQty) */
@get:Value("#{target.inQty}")
val stockQty: BigDecimal?;
@get:Value("#{target.inQty " +
@get:Value("#{target.inQty == null ? null : (" +
"(target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.id != null " +
"&& target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.id " +
"== target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.uom?.id) " +
"? target.inQty " +
": (target.inQty " +
"* ((target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD))" +
"/ ((target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioD))}")
"/ ((target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioD))))}")
val qty: BigDecimal?;
@get:Value("#{target.warehouse?.code} - #{target.warehouse?.name}")
val warehouse: String?;


+ 10
- 23
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Прегледај датотеку

@@ -503,12 +503,11 @@ open class StockInLineService(
val stockItemUom = itemUomRepository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(
itemId = request.itemId
)
val purchaseItemUom = itemUomRepository.findByItemIdAndPurchaseUnitIsTrueAndDeletedIsFalse(request.itemId)
// PO-origin: frontend sends qty in stock; non-PO: treat as purchase and convert to stock
val convertedBaseQty = if (stockInLine.purchaseOrderLine != null || stockInLine.jobOrder != null) {
line.qty
} else if (request.stockTakeLineId == null && stockItemUom != null && purchaseItemUom != null) {
(line.qty) * (purchaseItemUom.ratioN!! / purchaseItemUom.ratioD!!) / (stockItemUom.ratioN!! / stockItemUom.ratioD!!)
} else if (request.stockTakeLineId == null) {
itemUomService.convertPurchaseQtyToStockQtyPrecise(request.itemId, line.qty)
} else {
(line.qty)
}
@@ -1050,21 +1049,14 @@ open class StockInLineService(
// ✅ 每次上架都寫一筆 stock_ledger(inQty = 本次上架的庫存數量)
val _tDeltaCompute = System.nanoTime()
val putAwayDeltaStockQty = (request.inventoryLotLines ?: listOf()).sumOf { line ->
val stockItemUom = itemUomRepository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(
itemId = request.itemId
)
val purchaseItemUom = itemUomRepository.findByItemIdAndPurchaseUnitIsTrueAndDeletedIsFalse(request.itemId)

val convertedBaseQty = if (stockInLine.purchaseOrderLine != null || stockInLine.jobOrder != null) {
if (stockInLine.purchaseOrderLine != null || stockInLine.jobOrder != null) {
// PO and Job Order: qty is already stock qty
line.qty
} else if (request.stockTakeLineId == null && stockItemUom != null && purchaseItemUom != null) {
// Legacy: treat as purchase qty, convert to stock qty
(line.qty) * (purchaseItemUom.ratioN!! / purchaseItemUom.ratioD!!) / (stockItemUom.ratioN!! / stockItemUom.ratioD!!)
} else if (request.stockTakeLineId == null) {
itemUomService.convertPurchaseQtyToStockQtyPrecise(request.itemId, line.qty)
} else {
line.qty
}
convertedBaseQty
}
_logStep("compute_putAwayDeltaStockQty(+uom lookups per line)", _tDeltaCompute)
if (putAwayDeltaStockQty > BigDecimal.ZERO) {
@@ -1080,14 +1072,12 @@ open class StockInLineService(
val requiredStockQty = if (stockInLine.purchaseOrderLine != null) {
stockInLine.acceptedQty ?: BigDecimal.ZERO
} else {
val purchaseItemUom = itemUomRepository.findByItemIdAndPurchaseUnitIsTrueAndDeletedIsFalse(request.itemId)
val stockItemUom = itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId)
val ratio = if (request.stockTakeLineId == null && stockItemUom != null && purchaseItemUom != null) {
(purchaseItemUom.ratioN!! / purchaseItemUom.ratioD!!) / (stockItemUom.ratioN!! / stockItemUom.ratioD!!)
val acceptQty = request.acceptQty ?: request.acceptedQty ?: BigDecimal.ZERO
if (request.stockTakeLineId == null) {
itemUomService.convertPurchaseQtyToStockQtyPrecise(request.itemId, acceptQty)
} else {
BigDecimal.ONE
acceptQty
}
(request.acceptQty ?: request.acceptedQty)?.times(ratio) ?: BigDecimal.ZERO
}
_logStep("compute_putAwayStockQty_and_requiredStockQty(+uom lookups)", _tQtyCalc)

@@ -1227,14 +1217,11 @@ open class StockInLineService(
val stockItemUom = itemUomRepository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(
info.itemId
)
val purchaseItemUom = itemUomRepository.findByItemIdAndPurchaseUnitIsTrueAndDeletedIsFalse(info.itemId)
// PO-origin: acceptedQty is already stock qty; non-PO: convert purchase -> stock for display
val acceptedQty = if (info.purchaseOrderLineId != null) {
info.acceptedQty
} else if (stockItemUom != null && purchaseItemUom != null) {
(info.acceptedQty) * (purchaseItemUom.ratioN!! / purchaseItemUom.ratioD!!) / (stockItemUom.ratioN!! / stockItemUom.ratioD!!)
} else {
(info.acceptedQty)
itemUomService.convertPurchaseQtyToStockQtyPrecise(info.itemId, info.acceptedQty)
}
// field["itemId"] = info.itemId
field["itemName"] = info.itemName ?: "N/A"


+ 23
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt Прегледај датотеку

@@ -86,8 +86,20 @@ open class StockIssueService(
return searchHandleRecords(request, "Bad")
}

/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */
open fun getExpiryItemHandleRecords(request: SearchStockIssueRecordRequest): RecordsRes<StockIssueHandleRecordResponse> {
val (startDate, endDateExclusive) = resolveDateRange(request.startDate, request.endDate)
val hasHandledDateFilter =
request.handledStartDate != null || request.handledEndDate != null
val (startDate, endDateExclusive) =
if (hasHandledDateFilter && request.startDate == null && request.endDate == null) {
Pair(null, null)
} else {
resolveDateRange(request.startDate, request.endDate)
}
val (handledStartDate, handledEndDateExclusive) = resolveOptionalDateRange(
request.handledStartDate,
request.handledEndDate,
)
val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() }
val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() }
val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() }
@@ -103,6 +115,8 @@ open class StockIssueService(
lotNo = lotNo,
startDate = startDate,
endDateExclusive = endDateExclusive,
handledStartDate = handledStartDate,
handledEndDateExclusive = handledEndDateExclusive,
pageable = pageable,
)

@@ -144,6 +158,14 @@ open class StockIssueService(
return Pair(start, endInclusive.plusDays(1))
}

/** Inclusive start/end; no default window when both are empty. */
private fun resolveOptionalDateRange(startDate: LocalDate?, endDate: LocalDate?): Pair<LocalDate?, LocalDate?> {
if (startDate == null && endDate == null) return Pair(null, null)
val start = startDate ?: endDate
val endInclusive = endDate ?: startDate
return Pair(start, endInclusive?.plusDays(1))
}

private fun toRecordResponse(ledger: StockLedger): StockIssueHandleRecordResponse {
val stockOutLine = ledger.stockOutLine
val lotLine = stockOutLine?.inventoryLotLine


+ 9
- 6
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt Прегледај датотеку

@@ -89,12 +89,15 @@ open class TraceInboundLoader(
SELECT
sil_pa.purchaseOrderLineId,
SUM(
COALESCE(ill.inQty, 0)
* COALESCE(iu_s.ratioN, 1) / NULLIF(COALESCE(iu_s.ratioD, 1), 0)
/ NULLIF(
COALESCE(iu_p.ratioN, 1) / NULLIF(COALESCE(iu_p.ratioD, 1), 0),
0
)
CASE
WHEN iu_s.uomId IS NOT NULL AND iu_s.uomId = iu_p.uomId THEN COALESCE(ill.inQty, 0)
ELSE COALESCE(ill.inQty, 0)
* COALESCE(iu_s.ratioN, 1) / NULLIF(COALESCE(iu_s.ratioD, 1), 0)
/ NULLIF(
COALESCE(iu_p.ratioN, 1) / NULLIF(COALESCE(iu_p.ratioD, 1), 0),
0
)
END
) AS putAwayQty
FROM stock_in_line sil_pa
INNER JOIN inventory_lot_line ill ON sil_pa.inventoryLotLineId = ill.id AND ill.deleted = 0


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt Прегледај датотеку

@@ -48,6 +48,8 @@ class StockIssueController(
fun getExpiryItemRecords(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) handledStartDate: LocalDate?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) handledEndDate: LocalDate?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) itemName: String?,
@RequestParam(required = false) lotNo: String?,
@@ -58,6 +60,8 @@ class StockIssueController(
SearchStockIssueRecordRequest(
startDate = startDate,
endDate = endDate,
handledStartDate = handledStartDate,
handledEndDate = handledEndDate,
itemCode = itemCode,
itemName = itemName,
lotNo = lotNo,


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt Прегледај датотеку

@@ -13,6 +13,8 @@ data class HandleBadItemRequest(
data class SearchStockIssueRecordRequest(
val startDate: LocalDate? = null,
val endDate: LocalDate? = null,
val handledStartDate: LocalDate? = null,
val handledEndDate: LocalDate? = null,
val itemCode: String? = null,
val itemName: String? = null,
val lotNo: String? = null,


+ 3
- 0
src/main/resources/application.yml Прегледај датотеку

@@ -68,6 +68,9 @@ scheduler:

# Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日).
fpsms:
# local = hardcoded HKD rates (testing). m18 = per-PO exchangeRate from MPO sync.
item-average-price:
fx-source: local
purchase-stock-in-alert:
lookback-days: 7
# Device + printer monitoring: enable only on production profile (application-prod*.yml).


+ 12
- 0
src/main/resources/db/changelog/changes/20260902_item_avg_unit_cost_fx/01_po_exchange_rate_and_item_purchase_fx.sql Прегледај датотеку

@@ -0,0 +1,12 @@
--liquibase formatted sql

--changeset fpsms:po_exchange_rate_and_item_purchase_fx
--comment: Per-PO FX rate (M18 V2) + item latest purchase price snapshot for average unit cost

ALTER TABLE `purchase_order`
ADD COLUMN `exchangeRate` DECIMAL(14, 4) NULL COMMENT 'MPO FX to HKD; null in local-rate mode' AFTER `currencyId`;

ALTER TABLE `items`
ADD COLUMN `purchaseCurrencyId` BIGINT NULL COMMENT 'Latest MPO currency' AFTER `AverageUnitPrice`,
ADD COLUMN `purchaseUnitPrice` DECIMAL(14, 4) NULL COMMENT 'Latest MPO unit price in purchase currency' AFTER `purchaseCurrencyId`,
ADD COLUMN `purchaseFxRate` DECIMAL(14, 4) NULL COMMENT 'Latest MPO FX to HKD' AFTER `purchaseUnitPrice`;

+ 38
- 0
src/main/resources/db/changelog/changes/20260903_item_home_currency/01_add_home_currency_id.sql Прегледај датотеку

@@ -0,0 +1,38 @@
--liquibase formatted sql

--changeset fpsms:item_home_currency_id
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'items' AND column_name = 'homeCurrencyId'
--comment: Home currency for AverageUnitPrice; INT to match currency.id

ALTER TABLE `items`
ADD COLUMN `homeCurrencyId` INT NULL COMMENT 'Home currency for AverageUnitPrice (HKD)' AFTER `AverageUnitPrice`;

--changeset fpsms:item_home_currency_id_to_int
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'items' AND column_name = 'homeCurrencyId' AND data_type = 'bigint'
--comment: Existing BIGINT column cannot FK to currency.id INT

ALTER TABLE `items`
MODIFY COLUMN `homeCurrencyId` INT NULL COMMENT 'Home currency for AverageUnitPrice (HKD)';

--changeset fpsms:item_home_currency_id_backfill
--comment: Backfill home currency to HKD

UPDATE `items`
SET `homeCurrencyId` = (
SELECT `id`
FROM `currency`
WHERE `deleted` = 0
AND UPPER(TRIM(`code`)) = 'HKD'
LIMIT 1
)
WHERE `homeCurrencyId` IS NULL;

--changeset fpsms:item_home_currency_id_fk
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema = DATABASE() AND table_name = 'items' AND constraint_name = 'FK_ITEMS_ON_HOMECURRENCYID'
--comment: FK items.homeCurrencyId -> currency.id

ALTER TABLE `items`
ADD CONSTRAINT `FK_ITEMS_ON_HOMECURRENCYID` FOREIGN KEY (`homeCurrencyId`) REFERENCES `currency` (`id`);

+ 109
- 0
src/main/resources/db/changelog/changes/20260907_recalc_item_avg_unit_price/01_recalculate_average_unit_price.sql Прегледај датотеку

@@ -0,0 +1,109 @@
--liquibase formatted sql

--changeset fpsms:recalculate_item_average_unit_price splitStatements:false
--comment: One-time backfill AverageUnitPrice (same as POST /items/averageUnitPrice/recalculate, local FX). Items without 2026-01-01+ POL are not updated.

WITH uom_one 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
),
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
),
line_raw AS (
SELECT
pol.itemId,
c.id AS currencyId,
CASE UPPER(TRIM(COALESCE(c.code, '')))
WHEN 'HKD' THEN 1
WHEN 'USD' THEN 7.7823
WHEN 'RMB' THEN 1.1700
WHEN 'CNY' THEN 1.1700
WHEN 'JPY' THEN (1 / 20.35)
ELSE NULL
END AS fx,
COALESCE(pol.price, pol.up * COALESCE(pol.qtyM18, pol.qty)) AS lineAmt,
pol.qty AS purchaseQty,
CASE
WHEN so.id IS NULL THEN 0
WHEN pol.qtyM18 IS NOT NULL AND pol.qtyM18 > 0 AND pol.uomIdM18 IS NOT NULL THEN
CASE
WHEN src.id IS NULL THEN pol.qtyM18
WHEN src.uomId = so.uomId THEN pol.qtyM18
ELSE pol.qtyM18
* (COALESCE(src.ratioN, 1) / COALESCE(NULLIF(src.ratioD, 0), 1))
* (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1))
END
ELSE
CASE
WHEN pur.id IS NULL THEN COALESCE(pol.qty, 0)
WHEN pur.uomId = so.uomId THEN COALESCE(pol.qty, 0)
ELSE COALESCE(pol.qty, 0)
* (COALESCE(pur.ratioN, 1) / COALESCE(NULLIF(pur.ratioD, 0), 1))
* (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1))
END
END AS stockQty,
po.orderDate,
pol.id AS polId
FROM purchase_order_line pol
JOIN purchase_order po ON po.id = pol.purchaseOrderId AND po.deleted = 0
LEFT JOIN currency c ON c.id = po.currencyId AND c.deleted = 0
LEFT JOIN uom_one src ON src.itemId = pol.itemId AND src.uomId = pol.uomIdM18
LEFT JOIN uom_one pur ON pur.itemId = pol.itemId AND pur.uomId = pol.uomId
LEFT JOIN stock_one so ON so.itemId = pol.itemId
WHERE pol.deleted = 0
AND po.orderDate >= '2026-01-01'
AND pol.up IS NOT NULL
AND COALESCE(pol.qtyM18, pol.qty, 0) > 0
),
line_ok AS (
SELECT
itemId, currencyId, fx, lineAmt, purchaseQty, stockQty,
ROW_NUMBER() OVER (PARTITION BY itemId ORDER BY orderDate DESC, polId DESC) AS rn
FROM line_raw
WHERE fx IS NOT NULL AND stockQty > 0 AND lineAmt IS NOT NULL
),
qualifying AS (
SELECT DISTINCT itemId FROM line_raw
),
calc AS (
SELECT
itemId,
ROUND(SUM(lineAmt * fx) / NULLIF(SUM(stockQty), 0), 4) AS avgUp,
MAX(CASE WHEN rn = 1 THEN currencyId END) AS latestCurrencyId,
ROUND(
MAX(CASE WHEN rn = 1 THEN lineAmt / NULLIF(purchaseQty, 0) END),
4
) AS latestPurchaseUnitPrice,
ROUND(MAX(CASE WHEN rn = 1 THEN fx END), 4) AS latestFx
FROM line_ok
GROUP BY itemId
)
UPDATE items i
INNER JOIN qualifying q ON q.itemId = i.id
LEFT JOIN calc ON calc.itemId = i.id
SET
i.AverageUnitPrice = CAST(calc.avgUp AS CHAR),
i.homeCurrencyId = COALESCE(
i.homeCurrencyId,
(SELECT id FROM currency WHERE deleted = 0 AND UPPER(TRIM(code)) = 'HKD' LIMIT 1)
),
i.purchaseCurrencyId = calc.latestCurrencyId,
i.purchaseUnitPrice = calc.latestPurchaseUnitPrice,
i.purchaseFxRate = calc.latestFx,
i.modified = NOW(),
i.version = i.version + 1
WHERE i.deleted = 0;

+ 25
- 0
src/test/kotlin/com/ffii/fpsms/modules/master/support/ItemUomQtyConvertTest.kt Прегледај датотеку

@@ -0,0 +1,25 @@
package com.ffii.fpsms.modules.master.support

import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class ItemUomQtyConvertTest {

@Test
fun sameUom_skipsWhenBothIdsEqual() {
assertTrue(ItemUomQtyConvert.sameUomConversionId(10L, 10L))
}

@Test
fun differentUom_doesNotSkip() {
assertFalse(ItemUomQtyConvert.sameUomConversionId(10L, 20L))
}

@Test
fun nullIds_doNotSkip() {
assertFalse(ItemUomQtyConvert.sameUomConversionId(null, 10L))
assertFalse(ItemUomQtyConvert.sameUomConversionId(10L, null))
assertFalse(ItemUomQtyConvert.sameUomConversionId(null, null))
}
}

Loading…
Откажи
Сачувај