同 UOM 不轉換 lot expiry 邏輯 PoSearch fix the console.og PoDetail 累計超過 PO 也要 warnundefined
| @@ -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 | ||||
| @@ -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, | ||||
| @@ -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") | ||||
| @@ -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 | ||||
| @@ -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() | |||||
| } | |||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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() | ||||
| @@ -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, | |||||
| ) | ) | ||||
| @@ -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>?, | ||||
| @@ -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()) { | ||||
| @@ -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) | ||||
| @@ -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, | ||||
| @@ -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) | ||||
| @@ -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)) | ||||
| @@ -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 | ||||
| @@ -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 <> '' | ||||
| @@ -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)) | ||||
| @@ -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) | |||||
| } | } | ||||
| } | } | ||||
| @@ -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> | ||||
| /** | /** | ||||
| @@ -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> | ||||
| } | } | ||||
| @@ -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?; | ||||
| @@ -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" | ||||
| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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, | ||||
| @@ -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, | ||||
| @@ -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). | ||||
| @@ -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`; | |||||
| @@ -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`); | |||||
| @@ -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; | |||||
| @@ -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)) | |||||
| } | |||||
| } | |||||