Browse Source

item averageUnitPrice fix

同 UOM 不轉換

lot expiry 邏輯

PoSearch fix the console.og
PoDetail 累計超過 PO 也要 warn
undefined
CANCERYS\kw093 1 week ago
parent
commit
3da2441d29
32 changed files with 743 additions and 114 deletions
  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 View File

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


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




+ 35
- 5
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt View File

@@ -12,6 +12,7 @@ import kotlin.jvm.optionals.getOrNull
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import com.ffii.fpsms.modules.master.entity.ItemsRepository 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.Inventory
import com.ffii.fpsms.modules.stock.entity.InventoryRepository 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 { open fun convertPurchaseQtyToStockQty(itemId: Long, purchaseQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty; val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty;
val stockUnit = findStockUnitByItemId(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 one = BigDecimal.ONE;


// IMPORTANT: // 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) * Convert purchase qty -> stock qty (PO display / acceptedQty calculation)
* with round-down to integer stock qty. * with round-down to integer stock qty.
*/ */
open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal { open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty
val stockUnit = findStockUnitByItemId(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 one = BigDecimal.ONE


// Use high precision for intermediate steps, and round down only at the end. // 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) 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 { open fun convertStockQtyToPurchaseQty(itemId: Long, stockQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty
val stockUnit = findStockUnitByItemId(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 one = BigDecimal.ONE
val calcScale = 10 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. * 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. * Returns source qty when source/purchase unit mapping is not found.
*/ */
open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return sourceQty val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return sourceQty
if (ItemUomQtyConvert.sameUomConversionId(uomId, purchaseUnit.uom?.id)) return sourceQty
val one = BigDecimal.ONE val one = BigDecimal.ONE
val calcScale = 10 val calcScale = 10


@@ -184,10 +195,12 @@ open class ItemUomService(
return purchaseQty.setScale(0, RoundingMode.UP) 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 { open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty; val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty;


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


val calcScale = 10 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. * 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. * Same as convertQtyToStockQty but rounds down the final stock qty to integer.
*/ */
open fun convertQtyToStockQtyRoundDown(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { open fun convertQtyToStockQtyRoundDown(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO
if (ItemUomQtyConvert.sameUomConversionId(uomId, stockUnit.uom?.id)) return sourceQty
val one = BigDecimal.ONE val one = BigDecimal.ONE
val calcScale = 10 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. * Convert purchase qty -> stock qty and keep decimal precision.
* Used for PO flows where decimal quantity must be preserved. * Used for PO flows where decimal quantity must be preserved.
*/ */
open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal { open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty
val stockUnit = findStockUnitByItemId(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 one = BigDecimal.ONE
val calcScale = 10 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. * Convert qty from a specific UOM -> stock qty and keep decimal precision.
* Used for PO (M18 UOM) conversion where round-down is not allowed. * Used for PO (M18 UOM) conversion where round-down is not allowed.
*/ */
open fun convertQtyToStockQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { open fun convertQtyToStockQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO
if (ItemUomQtyConvert.sameUomConversionId(uomId, stockUnit.uom?.id)) return sourceQty
val one = BigDecimal.ONE val one = BigDecimal.ONE
val calcScale = 10 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. * 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. * Returns null when no `item_uom` row links the item to that UOM.
*/ */
open fun convertQtyToBaseQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal? { open fun convertQtyToBaseQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal? {
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return null 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 one = BigDecimal.ONE
val calcScale = 10 val calcScale = 10
return sourceQty 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 { open fun convertUomByItem(request: ConvertUomByItemRequest): ConvertUomByItemResponse {
// Special case: Direct conversion from KG (784) to gram (4) or vice versa // Special case: Direct conversion from KG (784) to gram (4) or vice versa
// This handles cases where ItemUom records might not exist // This handles cases where ItemUom records might not exist
@@ -440,6 +465,15 @@ open class ItemUomService(
?: throw IllegalArgumentException( ?: throw IllegalArgumentException(
"Target ItemUom not found for items.code=$itemCode, targetUnit=${request.targetUnit}" "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 // Convert quantity using ratioN/ratioD via base unit
val one = BigDecimal.ONE val one = BigDecimal.ONE
val sourceRatioN = sourceItemUom.ratioN ?: one val sourceRatioN = sourceItemUom.ratioN ?: one
@@ -453,10 +487,6 @@ open class ItemUomService(
// Convert base to target: baseQty * ratioD / ratioN // Convert base to target: baseQty * ratioD / ratioN
val newQty = baseQty.multiply(targetRatioD).divide(targetRatioN, 2, RoundingMode.UP) 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( return ConvertUomByItemResponse(
newQty = newQty, newQty = newQty,
udfudesc = uomConversion.udfudesc, udfudesc = uomConversion.udfudesc,


+ 195
- 38
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt View File

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


private val excelImportPath: String = System.getProperty("user.home") + "/Downloads/StockTakeImport/" 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 @Transactional
open fun updateAverageUnitPriceForItems(itemIds: Set<Long>) { open fun updateAverageUnitPriceForItems(itemIds: Set<Long>) {
if (itemIds.isEmpty()) return 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 */ /** Column headers in Chinese for market unit price template/import */
@@ -613,6 +760,7 @@ open class ItemsService(
} }


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


+ 42
- 6
src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt View File

@@ -1802,9 +1802,25 @@ open class ProductionScheduleService(
left join purchase_order on purchase_order_line.purchaseOrderId = purchase_order.id 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, where purchase_order_line.itemId = itm.id and date(purchase_order.estimatedArrivalDate) >= date(now()) and purchase_order.completeDate is null) as purchasedQty,
bm.uomName, 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, ) 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, ) as purchaseQtyNeed,
(select code from item_uom (select code from item_uom
left join uom_conversion on item_uom.uomId = uom_conversion.id left join uom_conversion on item_uom.uomId = uom_conversion.id
@@ -1869,10 +1885,23 @@ open class ProductionScheduleService(
itm.code AS matCode, itm.code AS matCode,
itm.name AS matName, itm.name AS matName,
COALESCE(uomM18.code, uomP.code) AS uom, 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(( 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 FROM purchase_order_line pol
JOIN purchase_order po ON pol.purchaseOrderId = po.id JOIN purchase_order po ON pol.purchaseOrderId = po.id
WHERE pol.itemId = itm.id WHERE pol.itemId = itm.id
@@ -1880,7 +1909,14 @@ open class ProductionScheduleService(
AND po.completeDate IS NULL AND po.completeDate IS NULL
), 0) AS purchasedQty, ), 0) AS purchasedQty,
DATE(ps.produceAt) AS produceDate, 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 FROM production_schedule_line psl
JOIN production_schedule ps ON psl.prodScheduleId = ps.id JOIN production_schedule ps ON psl.prodScheduleId = ps.id
JOIN items it ON psl.itemId = it.id JOIN items it ON psl.itemId = it.id


+ 35
- 0
src/main/java/com/ffii/fpsms/modules/master/support/HomeFxRates.kt View File

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -195,6 +195,13 @@ fun getItemsWithDetailsByPage(request: HttpServletRequest): RecordsRes<Map<Strin
return RecordsRes(paginatedList, fullList.size) 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") @GetMapping("/marketUnitPrice/template")
fun downloadMarketUnitPriceTemplate(): ResponseEntity<ByteArray> { fun downloadMarketUnitPriceTemplate(): ResponseEntity<ByteArray> {
val bytes = itemsService.generateMarketUnitPriceTemplate() val bytes = itemsService.generateMarketUnitPriceTemplate()


+ 3
- 1
src/main/java/com/ffii/fpsms/modules/master/web/models/ItemWithQcResponse.kt View File

@@ -16,5 +16,7 @@ data class ItemQc(
data class ItemWithQcResponse( data class ItemWithQcResponse(
val item: Items, val item: Items,
val qcChecks: List<ItemQc>, 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 View File

@@ -54,6 +54,8 @@ data class NewItemRequest(
val isFee: Boolean?, val isFee: Boolean?,
val isBag: Boolean?, val isBag: Boolean?,
val qcType: String?, 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 type: List<NewTypeRequest>?,
// val uom: List<NewUomRequest>?, // val uom: List<NewUomRequest>?,
// val weightUnit: List<NewWeightUnitRequest>?, // val weightUnit: List<NewWeightUnitRequest>?,


+ 16
- 8
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt View File

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


/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */
open fun getExpiryItemList( open fun getExpiryItemList(
expiryDate: LocalDate? = null,
itemCode: String? = null, itemCode: String? = null,
itemName: String? = null, itemName: String? = null,
lotNo: String? = null,
): List<ExpiryItemResponse> { ): List<ExpiryItemResponse> {
val today = LocalDate.now() val today = LocalDate.now()
val untilDate = today.plusDays(7)
val normalizedItemCode = itemCode?.trim()?.takeIf { it.isNotEmpty() } val normalizedItemCode = itemCode?.trim()?.takeIf { it.isNotEmpty() }
val normalizedItemName = itemName?.trim()?.takeIf { it.isNotEmpty() } val normalizedItemName = itemName?.trim()?.takeIf { it.isNotEmpty() }
val normalizedLotNo = lotNo?.trim()?.takeIf { it.isNotEmpty() }
val lotLines = inventoryLotLineRepository.findExpiredItems( val lotLines = inventoryLotLineRepository.findExpiredItems(
today = today,
expiryDate = expiryDate,
untilDate = untilDate,
itemCode = normalizedItemCode, itemCode = normalizedItemCode,
itemName = normalizedItemName, itemName = normalizedItemName,
lotNo = normalizedLotNo,
) )
return lotLines.map { lotLine -> return lotLines.map { lotLine ->
val lot = lotLine.inventoryLot val lot = lotLine.inventoryLot
val item = lot?.item // Get item from inventoryLot val item = lot?.item // Get item from inventoryLot
val expiry = lot?.expiryDate
ExpiryItemResponse( ExpiryItemResponse(
id = lotLine.id ?: 0L, id = lotLine.id ?: 0L,
itemId = item?.id ?: 0L, itemId = item?.id ?: 0L,
@@ -1840,8 +1844,10 @@ open fun getExpiryItemList(
lotId = lot?.id ?: 0L, lotId = lot?.id ?: 0L,
lotNo = lot?.lotNo, lotNo = lot?.lotNo,
storeLocation = lotLine.warehouse?.code, // Construct from warehouse 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]) @Transactional(rollbackFor = [Exception::class])
open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse { open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse {
try { try {
@@ -2011,7 +2018,7 @@ open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse {
val lot = lotLine.inventoryLot val lot = lotLine.inventoryLot
val today = LocalDate.now() val today = LocalDate.now()
if (lot?.expiryDate == null || !lot.expiryDate!!.isBefore(today)) {
if (lot?.expiryDate == null || lot.expiryDate!!.isAfter(today)) {
return MessageResponse( return MessageResponse(
id = null, id = null,
name = "Error", name = "Error",
@@ -2201,6 +2208,7 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse {
} }
} }
// Fix batchSubmitExpiryItem method (around line 945): // Fix batchSubmitExpiryItem method (around line 945):
/** FP-MTMS Version Checklist | Functions Ref. No. 75 | v1.0.0 | 2026-09-07 */
@Transactional(rollbackFor = [Exception::class]) @Transactional(rollbackFor = [Exception::class])
open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse { open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse {
try { try {
@@ -2209,8 +2217,8 @@ open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageRespon
val lot = it.inventoryLot val lot = it.inventoryLot
val today = LocalDate.now() val today = LocalDate.now()
lot?.expiryDate != null && 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()) { if (lotLines.isEmpty()) {


+ 5
- 4
src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt View File

@@ -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.enums.PickExecutionIssueEnum
import com.ffii.fpsms.modules.pickOrder.service.PickExecutionIssueService // 修复导入路径 import com.ffii.fpsms.modules.pickOrder.service.PickExecutionIssueService // 修复导入路径
import com.ffii.fpsms.modules.pickOrder.web.models.* import com.ffii.fpsms.modules.pickOrder.web.models.*
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import java.time.LocalDate




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


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


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


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


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


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt View File

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


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrder.kt View File

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


@Entity @Entity
@@ -52,6 +53,10 @@ open class PurchaseOrder : BaseEntity<Long>() {
@JoinColumn(name = "currencyId") @JoinColumn(name = "currencyId")
open var currency: Currency? = null 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 @NotNull
@ManyToOne @ManyToOne
@JoinColumn(name = "m18DataLogId", nullable = false) @JoinColumn(name = "m18DataLogId", nullable = false)


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt View File

@@ -75,6 +75,8 @@ open class ItemQcFailReportService(
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(
COALESCE( COALESCE(
CASE WHEN sil.purchaseOrderLineId IS NOT NULL 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 THEN sil.acceptedQty
WHEN iu_purchase.id IS NOT NULL AND iu.id IS NOT NULL 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)) 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 View File

@@ -1193,9 +1193,12 @@ return result
sl.created AS ledgerCreated, sl.created AS ledgerCreated,
sl.id AS slId, 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 ) AS stockUnitCost
FROM stock_ledger sl FROM stock_ledger sl
LEFT JOIN stock_in_line sil ON sl.stockInLineId = sil.id AND sil.deleted = 0 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 - 1 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序)
* - 現存存貨: stockDate 的最後一筆 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( fun searchStockBalanceReportByDate(
@@ -1495,15 +1496,24 @@ return result
AND sl.itemId IS NOT NULL AND sl.itemId IS NOT NULL
AND DATE(sl.date) <= p.d0 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 ( item_scope AS (
SELECT SELECT
it.id AS itemId, it.id AS itemId,
it.code AS itemNo, it.code AS itemNo,
it.name AS itemName, it.name AS itemName,
it.type AS itemType, 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 FROM items it
INNER JOIN ledger_item_ids li ON li.itemId = it.id 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 WHERE it.deleted = 0
AND it.code IS NOT NULL AND it.code <> '' AND it.code IS NOT NULL AND it.code <> ''
$itemCodeSql $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.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.variance, 0) < 0 THEN CONCAT('(', FORMAT(-lm.variance, 0), ')') ELSE FORMAT(COALESCE(lm.variance, 0), 0) END AS totalVariance,
CASE WHEN COALESCE(lm.defectiveGoods, 0) < 0 THEN CONCAT('(', FORMAT(-lm.defectiveGoods, 0), ')') ELSE FORMAT(COALESCE(lm.defectiveGoods, 0), 0) END AS totalDefectiveGoods, CASE WHEN 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 FROM item_scope s
LEFT JOIN opening_per_item op ON op.itemId = s.itemId LEFT JOIN opening_per_item op ON op.itemId = s.itemId
LEFT JOIN current_per_item cp ON cp.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 View File

@@ -9,6 +9,8 @@ import java.time.format.DateTimeFormatter
* 庫存批次現況(Stock Balance):永遠今天。 * 庫存批次現況(Stock Balance):永遠今天。
* 現存讀 [inventory_lot_line](available、未過期、in-out > 0)。 * 現存讀 [inventory_lot_line](available、未過期、in-out > 0)。
* 最後異動:有 inventoryLotLineId 的帳本用 MAX(id);缺的比 SIL/SOL 時間。 * 最後異動:有 inventoryLotLineId 的帳本用 MAX(id);缺的比 SIL/SOL 時間。
* 單位均價:貨品 AverageUnitPrice(HKD/stock UOM)用 item_uom 比率轉成該 lot 單位;
* 同一貨品同一 UOM 共用同一均價。庫存總價值 = 該 UOM 均價 × lot 數量。
* 單位均價/庫存總價值只填 root PO 為 PP/PF 的批(TRF 往回走);其他來源空白。 * 單位均價/庫存總價值只填 root PO 為 PP/PF 的批(TRF 往回走);其他來源空白。
*/ */
@Service @Service
@@ -70,6 +72,19 @@ lot_root_origin AS (
WHERE t.rn = 1 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 = """ private const val ROOT_STOCK_IN_JOIN_SQL = """
LEFT JOIN lot_root_origin lro LEFT JOIN lot_root_origin lro
ON lro.lotId = il.id ON lro.lotId = il.id
@@ -133,7 +148,7 @@ lot_root_origin AS (
} }
val lotOriginFilterSql = buildLotOriginFilterSql(lotOrigin) val lotOriginFilterSql = buildLotOriginFilterSql(lotOrigin)
val originJoinSql = ROOT_STOCK_IN_JOIN_SQL 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( val liveLots = jdbcDao.queryForList(
""" """
@@ -149,7 +164,19 @@ lot_root_origin AS (
COALESCE(wh.area, '') AS areaPart, COALESCE(wh.area, '') AS areaPart,
COALESCE(wh.slot, '') AS slotPart, COALESCE(wh.slot, '') AS slotPart,
(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS lotQtyRaw, (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 UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) AS rootPoPrefix
FROM inventory_lot_line ill FROM inventory_lot_line ill
INNER JOIN inventory_lot il INNER JOIN inventory_lot il
@@ -162,6 +189,8 @@ lot_root_origin AS (
ON iu.id = ill.stockItemUomId AND iu.deleted = 0 ON iu.id = ill.stockItemUomId AND iu.deleted = 0
LEFT JOIN uom_conversion uc LEFT JOIN uom_conversion uc
ON uc.id = iu.uomId ON uc.id = iu.uomId
LEFT JOIN stock_one so
ON so.itemId = it.id
$originJoinSql $originJoinSql
WHERE ill.deleted = 0 WHERE ill.deleted = 0
AND it.code IS NOT NULL AND it.code <> '' AND it.code IS NOT NULL AND it.code <> ''


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt View File

@@ -214,6 +214,8 @@ in_agg AS (
ill.id AS inventoryLotLineId, ill.id AS inventoryLotLineId,
SUM(CASE WHEN DATE(sil.receiptDate) < :fromDate THEN SUM(CASE WHEN DATE(sil.receiptDate) < :fromDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL 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) THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL 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)) 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, ELSE 0 END) AS inBefore,
SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN :fromDate AND :toDate THEN SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN :fromDate AND :toDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL 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) THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL 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)) 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, ill.id AS inventoryLotLineId,
SUM(CASE WHEN DATE(sil.receiptDate) < rb.fromDate THEN SUM(CASE WHEN DATE(sil.receiptDate) < rb.fromDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL 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) THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL 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)) 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, ELSE 0 END) AS inBefore,
SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN rb.fromDate AND rb.toDate THEN SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN rb.fromDate AND rb.toDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL 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) THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL 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)) 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, lb.inventoryLotLineId,
SUM(CASE WHEN DATE(sil.receiptDate) < lb.fromDate THEN SUM(CASE WHEN DATE(sil.receiptDate) < lb.fromDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL 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) THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL 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)) 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, ELSE 0 END) AS inBefore,
SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN lb.fromDate AND lb.toDate THEN SUM(CASE WHEN DATE(sil.receiptDate) BETWEEN lb.fromDate AND lb.toDate THEN
CASE WHEN sil.purchaseOrderLineId IS NOT NULL 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) THEN COALESCE(sil.acceptedQty, 0)
WHEN iu_purchase.id IS NOT NULL AND iu_stock.id IS NOT NULL 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)) 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 View File

@@ -540,11 +540,12 @@ class ReportController(
numberStyle: XSSFCellStyle, numberStyle: XSSFCellStyle,
dashStyle: XSSFCellStyle, dashStyle: XSSFCellStyle,
preferInt: Boolean = false, preferInt: Boolean = false,
emptyAsBlank: Boolean = false,
) { ) {
val cell = row.createCell(col) val cell = row.createCell(col)
val parsed = parseSignedNumber(value) val parsed = parseSignedNumber(value)
if (parsed == null) { if (parsed == null) {
cell.setCellValue("-")
cell.setCellValue(if (emptyAsBlank) "" else "-")
cell.cellStyle = dashStyle cell.cellStyle = dashStyle
return return
} }
@@ -914,8 +915,8 @@ class ReportController(
setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.int, styles.dash, preferInt = true) setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], 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, 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 View File

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


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


/** /**


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt View File

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

+ 7
- 2
src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt View File

@@ -70,9 +70,14 @@ interface PutAwayLineForSil {
/** Stock qty (inventory lot line inQty) */ /** Stock qty (inventory lot line inQty) */
@get:Value("#{target.inQty}") @get:Value("#{target.inQty}")
val stockQty: BigDecimal?; 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.^[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?; val qty: BigDecimal?;
@get:Value("#{target.warehouse?.code} - #{target.warehouse?.name}") @get:Value("#{target.warehouse?.code} - #{target.warehouse?.name}")
val warehouse: String?; val warehouse: String?;


+ 10
- 23
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt View File

@@ -503,12 +503,11 @@ open class StockInLineService(
val stockItemUom = itemUomRepository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse( val stockItemUom = itemUomRepository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(
itemId = request.itemId 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 // 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) { val convertedBaseQty = if (stockInLine.purchaseOrderLine != null || stockInLine.jobOrder != null) {
line.qty 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 { } else {
(line.qty) (line.qty)
} }
@@ -1050,21 +1049,14 @@ open class StockInLineService(
// ✅ 每次上架都寫一筆 stock_ledger(inQty = 本次上架的庫存數量) // ✅ 每次上架都寫一筆 stock_ledger(inQty = 本次上架的庫存數量)
val _tDeltaCompute = System.nanoTime() val _tDeltaCompute = System.nanoTime()
val putAwayDeltaStockQty = (request.inventoryLotLines ?: listOf()).sumOf { line -> 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 // PO and Job Order: qty is already stock qty
line.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 { } else {
line.qty line.qty
} }
convertedBaseQty
} }
_logStep("compute_putAwayDeltaStockQty(+uom lookups per line)", _tDeltaCompute) _logStep("compute_putAwayDeltaStockQty(+uom lookups per line)", _tDeltaCompute)
if (putAwayDeltaStockQty > BigDecimal.ZERO) { if (putAwayDeltaStockQty > BigDecimal.ZERO) {
@@ -1080,14 +1072,12 @@ open class StockInLineService(
val requiredStockQty = if (stockInLine.purchaseOrderLine != null) { val requiredStockQty = if (stockInLine.purchaseOrderLine != null) {
stockInLine.acceptedQty ?: BigDecimal.ZERO stockInLine.acceptedQty ?: BigDecimal.ZERO
} else { } 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 { } else {
BigDecimal.ONE
acceptQty
} }
(request.acceptQty ?: request.acceptedQty)?.times(ratio) ?: BigDecimal.ZERO
} }
_logStep("compute_putAwayStockQty_and_requiredStockQty(+uom lookups)", _tQtyCalc) _logStep("compute_putAwayStockQty_and_requiredStockQty(+uom lookups)", _tQtyCalc)


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


+ 23
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt View File

@@ -86,8 +86,20 @@ open class StockIssueService(
return searchHandleRecords(request, "Bad") 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> { 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 itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() }
val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() } val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() }
val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() }
@@ -103,6 +115,8 @@ open class StockIssueService(
lotNo = lotNo, lotNo = lotNo,
startDate = startDate, startDate = startDate,
endDateExclusive = endDateExclusive, endDateExclusive = endDateExclusive,
handledStartDate = handledStartDate,
handledEndDateExclusive = handledEndDateExclusive,
pageable = pageable, pageable = pageable,
) )


@@ -144,6 +158,14 @@ open class StockIssueService(
return Pair(start, endInclusive.plusDays(1)) 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 { private fun toRecordResponse(ledger: StockLedger): StockIssueHandleRecordResponse {
val stockOutLine = ledger.stockOutLine val stockOutLine = ledger.stockOutLine
val lotLine = stockOutLine?.inventoryLotLine val lotLine = stockOutLine?.inventoryLotLine


+ 9
- 6
src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt View File

@@ -89,12 +89,15 @@ open class TraceInboundLoader(
SELECT SELECT
sil_pa.purchaseOrderLineId, sil_pa.purchaseOrderLineId,
SUM( 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 ) AS putAwayQty
FROM stock_in_line sil_pa FROM stock_in_line sil_pa
INNER JOIN inventory_lot_line ill ON sil_pa.inventoryLotLineId = ill.id AND ill.deleted = 0 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 View File

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


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt View File

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


+ 3
- 0
src/main/resources/application.yml View File

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


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

@@ -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 View File

@@ -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 View File

@@ -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 View File

@@ -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…
Cancel
Save