diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt index e43a217..edd98e5 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt +++ b/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() { @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 diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index 275b80c..854bb3b 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/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, diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt index 452bd0a..c1ff545 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt +++ b/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(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) { 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(), + ) + 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(), + ) + 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") diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt index 648b174..845f70a 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt +++ b/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 diff --git a/src/main/java/com/ffii/fpsms/modules/master/support/HomeFxRates.kt b/src/main/java/com/ffii/fpsms/modules/master/support/HomeFxRates.kt new file mode 100644 index 0000000..2b5dcee --- /dev/null +++ b/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 = 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() +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/support/ItemAveragePriceFx.kt b/src/main/java/com/ffii/fpsms/modules/master/support/ItemAveragePriceFx.kt new file mode 100644 index 0000000..ade74eb --- /dev/null +++ b/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") + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/support/ItemUomQtyConvert.kt b/src/main/java/com/ffii/fpsms/modules/master/support/ItemUomQtyConvert.kt new file mode 100644 index 0000000..5056b3e --- /dev/null +++ b/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 + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt index 5b83ade..0bdbd39 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt @@ -195,6 +195,13 @@ fun getItemsWithDetailsByPage(request: HttpServletRequest): RecordsRes { + val updated = itemsService.recalculateAllAverageUnitPrices() + return mapOf("updatedItemCount" to updated) + } + @GetMapping("/marketUnitPrice/template") fun downloadMarketUnitPriceTemplate(): ResponseEntity { val bytes = itemsService.generateMarketUnitPriceTemplate() diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemWithQcResponse.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemWithQcResponse.kt index 9921053..61f9670 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemWithQcResponse.kt +++ b/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, - val qcType: String? = null + val qcType: String? = null, + val averageUnitPriceEditable: Boolean = false, + val stockUnitLabel: String? = null, ) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/NewItemRequest.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/NewItemRequest.kt index 59aec0e..b0edcb3 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/NewItemRequest.kt +++ b/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?, // val uom: List?, // val weightUnit: List?, diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index f165f96..d88f2dd 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -1814,24 +1814,28 @@ open fun getBadItemOnlyList(): List { 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 { 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()) { diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt index 3447764..ff34742 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/PickExecutionIssueController.kt +++ b/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 { 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) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt index d1f67f4..049dd15 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt +++ b/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, diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrder.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrder.kt index 05e9254..1da676c 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrder.kt +++ b/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() { @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) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt index fc2d1b6..98c4b46 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt +++ b/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)) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 845af94..0afb4aa 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -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 diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt index aecdd1f..3f54285 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt +++ b/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 <> '' diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt index 9b19025..87f35cb 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt +++ b/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)) diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index fc24362..dfcc5ba 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/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) } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 630b6d6..3725512 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/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 /** diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt index 1f4a276..f5b3027 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt +++ b/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 } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt index ed4fb6c..3f52e79 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt +++ b/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?; diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index fa4c1ab..a208a6a 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/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" diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt index bceb4dc..3cb41d8 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt +++ b/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 { - 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 { + 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 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt b/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt index b397494..958366d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/trace/TraceInboundLoader.kt +++ b/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 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt index 33425fb..795123f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt +++ b/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, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt index 91d7930..b8430bf 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt +++ b/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, diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 74ce2bc..77300d1 100644 --- a/src/main/resources/application.yml +++ b/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). diff --git a/src/main/resources/db/changelog/changes/20260902_item_avg_unit_cost_fx/01_po_exchange_rate_and_item_purchase_fx.sql b/src/main/resources/db/changelog/changes/20260902_item_avg_unit_cost_fx/01_po_exchange_rate_and_item_purchase_fx.sql new file mode 100644 index 0000000..98ef2f8 --- /dev/null +++ b/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`; diff --git a/src/main/resources/db/changelog/changes/20260903_item_home_currency/01_add_home_currency_id.sql b/src/main/resources/db/changelog/changes/20260903_item_home_currency/01_add_home_currency_id.sql new file mode 100644 index 0000000..a250238 --- /dev/null +++ b/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`); diff --git a/src/main/resources/db/changelog/changes/20260907_recalc_item_avg_unit_price/01_recalculate_average_unit_price.sql b/src/main/resources/db/changelog/changes/20260907_recalc_item_avg_unit_price/01_recalculate_average_unit_price.sql new file mode 100644 index 0000000..1fa4d85 --- /dev/null +++ b/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; diff --git a/src/test/kotlin/com/ffii/fpsms/modules/master/support/ItemUomQtyConvertTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/master/support/ItemUomQtyConvertTest.kt new file mode 100644 index 0000000..1b5e9d7 --- /dev/null +++ b/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)) + } +}