From b8dc276393ede6c100fd58ddfde6a78a2057f9cf Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 10 Sep 2026 11:58:02 +0800 Subject: [PATCH] bom excel export --- .../modules/master/service/BomService.kt | 199 ++++++++++++++++-- .../ItemAveragePurchasePriceService.kt | 106 ++++++++++ .../master/service/StandardUomMatrix.kt | 87 ++++++++ .../modules/master/support/HomeFxRates.kt | 35 +++ .../master/support/ItemAveragePriceFx.kt | 37 ++++ src/main/resources/application.yml | 3 + 6 files changed, 444 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/ItemAveragePurchasePriceService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/support/HomeFxRates.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/support/ItemAveragePriceFx.kt diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index 3a1bbd3..b55c926 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -71,6 +71,7 @@ open class BomService( private val bomMaterialQtyService: BomMaterialQtyService, private val bomOutputQtyService: BomOutputQtyService, private val bomRecipeUomOptionsService: BomRecipeUomOptionsService, + private val itemAveragePurchasePriceService: ItemAveragePurchasePriceService, @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, ) { companion object { @@ -1928,7 +1929,7 @@ open class BomService( try { val sheet = resolveImportBomSheet(workbook) fillBomDetailOntoBlankTemplate(workbook, detail) - recalculateMaterialDerivedColumns(sheet) + recalculateMaterialDerivedColumns(sheet, usePoAveragePurchasePrice = true) refreshOutputUomFormulaDependents(sheet) evaluateBlankTemplateFormulaDependents(sheet) val bytes = ByteArrayOutputStream().use { out -> @@ -2406,11 +2407,36 @@ open class BomService( * 轉用 (matrix from Excel 轉用單位), 銷售 / 採購 (system convert, not Excel). * * Columns: C使用份量 D使用單位 E轉用份量 F轉用單位 G銷售份量 H銷售單位 I採購單價 J採購單位 + * Food cost: U (=J), V/X (=I 採購成本), Y/Z (=C/D), AA/AB (D→AC rate/qty), AC (=F), + * AD (=HKD/AC via item_uom 1 purchase→base→AC; code-parse fallback) + * + * @param usePoAveragePurchasePrice when true (BOM 明細匯出), I/V/X = on-the-fly HKD/purchase UOM + * from 2026+ PO lines; blank if not calculable. + * when false (修正匯出), I/V/X = items.latestMarketUnitPrice. */ - private fun recalculateMaterialDerivedColumns(sheet: Sheet) { + private fun recalculateMaterialDerivedColumns( + sheet: Sheet, + usePoAveragePurchasePrice: Boolean = false, + ) { val headerRowIndex = findMaterialHeaderRowIndex(sheet) ?: return - var rowIdx = headerRowIndex + 1 val maxRowIndex = 200 + val poAvgByItemId: Map = if (usePoAveragePurchasePrice) { + val itemIds = mutableSetOf() + var scanIdx = headerRowIndex + 1 + while (scanIdx < maxRowIndex) { + val row = sheet.getRow(scanIdx) ?: break + val firstCell = row.getCell(0) + if (firstCell == null || firstCell.cellType == CellType.BLANK) break + val code = readStringCellValue(firstCell) + code?.let { itemsRepository.findByCodeAndDeletedFalse(it)?.id }?.let { itemIds.add(it) } + scanIdx++ + } + itemAveragePurchasePriceService.computeHkdPerPurchase(itemIds) + } else { + emptyMap() + } + + var rowIdx = headerRowIndex + 1 while (rowIdx < maxRowIndex) { val row = sheet.getRow(rowIdx) ?: break val firstCell = row.getCell(0) @@ -2424,18 +2450,6 @@ open class BomService( null } val recipeUomCode = readStringCellValue(row.getCell(3)) - val transferUomCode = readStringCellValue(row.getCell(5)) - - // 轉用: convert 配方數量 → Excel 轉用單位 via matrix; else null both - val fromMatrix = StandardUomMatrix.toMatrixUnit(recipeUomCode) - val toMatrix = StandardUomMatrix.toMatrixUnit(transferUomCode) - if (recipeQty != null && fromMatrix != null && toMatrix != null) { - val converted = StandardUomMatrix.convert(recipeQty, fromMatrix, toMatrix) - getOrCreateCell(sheet, rowIdx, 4).setCellValue(converted.toDouble()) - } else { - clearCellValue(sheet, rowIdx, 4) - clearCellValue(sheet, rowIdx, 5) - } val item = itemCode?.let { itemsRepository.findByCodeAndDeletedFalse(it) } val itemId = item?.id @@ -2447,40 +2461,179 @@ open class BomService( } else { null } - if (derived?.saleQty != null) { + val salesCode = if (derived?.saleQty != null) { getOrCreateCell(sheet, rowIdx, 6).setCellValue(derived.saleQty!!.toDouble()) - val salesCode = derived.salesUnit?.code?.trim()?.takeIf { it.isNotEmpty() } + val code = derived.salesUnit?.code?.trim()?.takeIf { it.isNotEmpty() } ?: derived.salesUnitCode?.trim()?.takeIf { it.isNotEmpty() } - if (salesCode != null) { - getOrCreateCell(sheet, rowIdx, 7).setCellValue(salesCode) + if (code != null) { + getOrCreateCell(sheet, rowIdx, 7).setCellValue(code) } else { clearCellValue(sheet, rowIdx, 7) } + code } else { clearCellValue(sheet, rowIdx, 6) clearCellValue(sheet, rowIdx, 7) + null } - // 採購: unit + latest market unit price from master (do not read Excel) + // Food cost Y/Z = C/D 使用份量/單位 + if (recipeQty != null) { + getOrCreateCell(sheet, rowIdx, 24).setCellValue(recipeQty.toDouble()) + } else { + clearCellValue(sheet, rowIdx, 24) + } + if (!recipeUomCode.isNullOrBlank()) { + getOrCreateCell(sheet, rowIdx, 25).setCellValue(recipeUomCode) + } else { + clearCellValue(sheet, rowIdx, 25) + } + + // 轉用 F/AC:銷售單位尾巴 matrix(KG/L/ML…);E/AB:配方→matrix;AA:1 D→AC rate + val salesPack = StandardUomMatrix.resolvePackMatrix(derived?.salesUnit) + ?: StandardUomMatrix.parsePackMatrixFromCode(salesCode) + if (salesPack != null) { + val transferUnit = salesPack.first + getOrCreateCell(sheet, rowIdx, 5).setCellValue(transferUnit.code) + getOrCreateCell(sheet, rowIdx, 28).setCellValue(transferUnit.code) // AC = F + val fromMatrix = StandardUomMatrix.toMatrixUnit(recipeUomCode) + val transferQty = when { + recipeQty != null && fromMatrix != null -> + StandardUomMatrix.convert(recipeQty, fromMatrix, transferUnit) + derived?.saleQty != null && salesPack.second > BigDecimal.ZERO -> + derived.saleQty!!.multiply(salesPack.second).setScale(4, RoundingMode.HALF_UP) + else -> null + } + if (transferQty != null) { + getOrCreateCell(sheet, rowIdx, 4).setCellValue(transferQty.toDouble()) + getOrCreateCell(sheet, rowIdx, 27).setCellValue(transferQty.toDouble()) // AB = E + } else { + clearCellValue(sheet, rowIdx, 4) + clearCellValue(sheet, rowIdx, 27) + } + if (fromMatrix != null) { + val rate = StandardUomMatrix.convert(BigDecimal.ONE, fromMatrix, transferUnit) + getOrCreateCell(sheet, rowIdx, 26).setCellValue(rate.toDouble()) // AA + } else { + clearCellValue(sheet, rowIdx, 26) + } + } else { + clearCellValue(sheet, rowIdx, 4) + clearCellValue(sheet, rowIdx, 5) + clearCellValue(sheet, rowIdx, 26) + clearCellValue(sheet, rowIdx, 27) + clearCellValue(sheet, rowIdx, 28) + } + + // 採購單位 J + Food cost U Unit Code(與 J 相同) val purchaseUom = itemId?.let { itemUomService.findPurchaseUnitByItemId(it) }?.uom val purchaseCode = purchaseUom?.code?.trim()?.takeIf { it.isNotEmpty() } ?: purchaseUom?.udfudesc?.trim()?.takeIf { it.isNotEmpty() } if (purchaseCode != null) { getOrCreateCell(sheet, rowIdx, 9).setCellValue(purchaseCode) + getOrCreateCell(sheet, rowIdx, 20).setCellValue(purchaseCode) } else { clearCellValue(sheet, rowIdx, 9) + clearCellValue(sheet, rowIdx, 20) + } + // I/V/X:採購單位成本;AD:HKD/AC(轉用)單位 + val purchasePrice: BigDecimal? = if (usePoAveragePurchasePrice) { + itemId?.let { poAvgByItemId[it] } + } else { + item?.latestMarketUnitPrice?.let { BigDecimal.valueOf(it) } } - val marketPrice = item?.latestMarketUnitPrice - if (marketPrice != null) { - getOrCreateCell(sheet, rowIdx, 8).setCellValue(marketPrice) + if (purchasePrice != null) { + val price = purchasePrice.toDouble() + getOrCreateCell(sheet, rowIdx, 8).setCellValue(price) + getOrCreateCell(sheet, rowIdx, 21).setCellValue(price) // V + getOrCreateCell(sheet, rowIdx, 23).setCellValue(price) // X } else { clearCellValue(sheet, rowIdx, 8) + clearCellValue(sheet, rowIdx, 21) + clearCellValue(sheet, rowIdx, 23) + } + val transferUnit = salesPack?.first + val adPrice = if (purchasePrice != null && transferUnit != null) { + resolveHkdPerTransferUnit( + itemId = itemId, + purchasePriceHkd = purchasePrice, + purchaseUom = purchaseUom, + salesUom = derived?.salesUnit, + transferUnit = transferUnit, + ) + } else { + null + } + if (adPrice != null) { + getOrCreateCell(sheet, rowIdx, 29).setCellValue(adPrice.toDouble()) + } else { + clearCellValue(sheet, rowIdx, 29) } rowIdx++ } } + /** + * AD = purchase HKD / (AC qty in 1 purchase unit). + * Prefer [item_uom] ratio → base → matrix [transferUnit]; fallback to UOM code pack parse. + */ + private fun resolveHkdPerTransferUnit( + itemId: Long?, + purchasePriceHkd: BigDecimal, + purchaseUom: UomConversion?, + salesUom: UomConversion?, + transferUnit: StandardUomMatrix.MatrixUnit, + ): BigDecimal? { + val qtyInTransfer = (itemId?.let { + resolveTransferQtyViaItemUom(it, purchaseUom, salesUom, transferUnit) + } ?: resolveTransferQtyViaUomCode(purchaseUom, salesUom, transferUnit)) + ?: return null + if (qtyInTransfer.compareTo(BigDecimal.ZERO) <= 0) return null + return purchasePriceHkd.divide(qtyInTransfer, 6, RoundingMode.HALF_UP) + } + + /** 1 purchase (or sales) UOM → base via item_uom ratioN/ratioD → [transferUnit] qty. */ + private fun resolveTransferQtyViaItemUom( + itemId: Long, + purchaseUom: UomConversion?, + salesUom: UomConversion?, + transferUnit: StandardUomMatrix.MatrixUnit, + ): BigDecimal? { + val sourceUom = purchaseUom ?: salesUom ?: return null + val uomId = sourceUom.id ?: return null + val baseQty = itemUomService.convertQtyToBaseQtyPrecise(itemId, uomId, BigDecimal.ONE) + ?: return null + if (baseQty.compareTo(BigDecimal.ZERO) <= 0) return null + val baseUom = itemUomService.findBaseUnitByItemId(itemId)?.uom ?: return null + val baseMatrix = StandardUomMatrix.toMatrixUnit(baseUom) ?: return null + return convertMatrixPrecise(baseQty, baseMatrix, transferUnit) + } + + private fun resolveTransferQtyViaUomCode( + purchaseUom: UomConversion?, + salesUom: UomConversion?, + transferUnit: StandardUomMatrix.MatrixUnit, + ): BigDecimal? { + val pack = StandardUomMatrix.resolvePackMatrix(purchaseUom) + ?: StandardUomMatrix.resolvePackMatrix(salesUom) + ?: return null + return if (pack.first == transferUnit) { + pack.second + } else { + convertMatrixPrecise(pack.second, pack.first, transferUnit) + } + } + + private fun convertMatrixPrecise( + qty: BigDecimal, + from: StandardUomMatrix.MatrixUnit, + to: StandardUomMatrix.MatrixUnit, + ): BigDecimal { + if (from == to) return qty + return StandardUomMatrix.convert(qty, from, to) + } + private fun setBasicInfoStringByHeader(sheet: Sheet, headerText: String, value: String) { for (rowIdx in 0..15) { for (colIdx in 0..15) { diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemAveragePurchasePriceService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemAveragePurchasePriceService.kt new file mode 100644 index 0000000..b3872fb --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemAveragePurchasePriceService.kt @@ -0,0 +1,106 @@ +package com.ffii.fpsms.modules.master.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.master.support.ItemAveragePriceFx +import org.springframework.stereotype.Service +import java.math.BigDecimal + +/** + * On-the-fly PO weighted average in **HKD per purchase UOM** + * (item's purchaseUnit=1), without writing [items.AverageUnitPrice]. + * + * SUM(lineAmtForeign * fx) / SUM(qty converted to purchase UOM), PO orderDate >= 2026-01-01. + * Items with no purchase unit or no qualifying PO lines are omitted. + */ +@Service +open class ItemAveragePurchasePriceService( + private val jdbcDao: JdbcDao, + private val itemAveragePriceFx: ItemAveragePriceFx, +) { + open fun computeHkdPerPurchase(itemIds: Collection): Map { + if (itemIds.isEmpty()) return emptyMap() + val ids = itemIds.distinct() + val fx = itemAveragePriceFx.fxSql("po", "c") + val sql = """ + 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 + ), + purchase_one AS ( + SELECT iu.* + FROM item_uom iu + INNER JOIN ( + SELECT itemId, MIN(id) AS id + FROM item_uom + WHERE deleted = 0 AND purchaseUnit = 1 + GROUP BY itemId + ) x ON x.id = iu.id + ), + line_raw AS ( + SELECT + pol.itemId, + ($fx) AS fx, + COALESCE(pol.price, pol.up * COALESCE(pol.qtyM18, pol.qty)) AS lineAmt, + CASE + WHEN pou.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 = pou.uomId THEN pol.qtyM18 + ELSE pol.qtyM18 + * (COALESCE(src.ratioN, 1) / COALESCE(NULLIF(src.ratioD, 0), 1)) + * (COALESCE(pou.ratioD, 1) / COALESCE(NULLIF(pou.ratioN, 0), 1)) + END + ELSE + CASE + WHEN pur.id IS NULL THEN COALESCE(pol.qty, 0) + WHEN pur.uomId = pou.uomId THEN COALESCE(pol.qty, 0) + ELSE COALESCE(pol.qty, 0) + * (COALESCE(pur.ratioN, 1) / COALESCE(NULLIF(pur.ratioD, 0), 1)) + * (COALESCE(pou.ratioD, 1) / COALESCE(NULLIF(pou.ratioN, 0), 1)) + END + END AS purchaseQty + 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 purchase_one pou ON pou.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 + AND pol.itemId IN (:itemIds) + ), + line_ok AS ( + SELECT itemId, fx, lineAmt, purchaseQty + FROM line_raw + WHERE fx IS NOT NULL AND purchaseQty > 0 AND lineAmt IS NOT NULL + ), + calc AS ( + SELECT + itemId, + ROUND(SUM(lineAmt * fx) / NULLIF(SUM(purchaseQty), 0), 4) AS avgUp + FROM line_ok + GROUP BY itemId + ) + SELECT itemId, avgUp FROM calc + """.trimIndent() + val rows = jdbcDao.queryForList(sql, mapOf("itemIds" to ids)) + return rows.mapNotNull { row -> + val id = (row["itemId"] as? Number)?.toLong() ?: return@mapNotNull null + val avg = when (val v = row["avgUp"]) { + is BigDecimal -> v + is Number -> BigDecimal.valueOf(v.toDouble()) + else -> return@mapNotNull null + } + id to avg + }.toMap() + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/StandardUomMatrix.kt b/src/main/java/com/ffii/fpsms/modules/master/service/StandardUomMatrix.kt index a6f716e..e6ffcab 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/StandardUomMatrix.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/StandardUomMatrix.kt @@ -48,6 +48,93 @@ object StandardUomMatrix { fun toMatrixUnit(uom: UomConversion?): MatrixUnit? = toMatrixUnit(uom?.code) + /** + * Sales/purchase pack code → (matrix unit, content qty **per 1 of that UOM**). + * Nested packs multiply all levels, e.g. CTN8PACK1.8KG → (KG, 14.4), not 1.8. + * Prefers unit1..unit4 on [UomConversion], then parses [UomConversion.code]. + */ + fun resolvePackMatrix(uom: UomConversion?): Pair? { + if (uom == null) return null + toMatrixUnit(uom.code)?.let { return it to BigDecimal.ONE } + resolvePackMatrixFromUnitLevels( + listOf( + uom.unit1 to uom.unit1Qty, + uom.unit2 to uom.unit2Qty, + uom.unit3 to uom.unit3Qty, + uom.unit4 to uom.unit4Qty, + ), + )?.let { return it } + // sizeInGram is total grams in one of this UOM (when populated by UomConversionService) + val sizeG = uom.sizeInGram?.takeIf { it > 0 }?.let { BigDecimal.valueOf(it) } + if (sizeG != null) { + parsePackMatrixFromCode(uom.code)?.let { (unit, _) -> + val gramsPer = gramsPerUnit[unit] ?: return@let null + return unit to sizeG.divide(gramsPer, 10, RoundingMode.HALF_UP) + } + } + return parsePackMatrixFromCode(uom.code) + } + + /** + * Multiply container × … × matrix content. + * e.g. CTN(1) × PACK(8) × KG(1.8) → (KG, 14.4); PACK(1) × KG(40) → (KG, 40). + */ + private fun resolvePackMatrixFromUnitLevels( + levels: List>, + ): Pair? { + var matrixIdx = -1 + var matrixUnit: MatrixUnit? = null + for ((i, level) in levels.withIndex()) { + val name = level.first?.trim()?.takeIf { it.isNotEmpty() } ?: continue + val m = toMatrixUnit(name) ?: continue + matrixIdx = i + matrixUnit = m + } + val unit = matrixUnit ?: return null + if (matrixIdx < 0) return null + var qty = BigDecimal.ONE + for (i in 0..matrixIdx) { + val name = levels[i].first?.trim().orEmpty() + if (name.isEmpty() && i < matrixIdx) continue + val q = levels[i].second?.takeIf { it > 0 }?.let { BigDecimal.valueOf(it) } + ?: BigDecimal.ONE + qty = qty.multiply(q) + } + return unit to qty + } + + /** + * Parse nested codes: CTN8PACK1.8KG → (KG, 14.4); PACK40KG → (KG, 40); BTL800ML → (ML, 800). + */ + fun parsePackMatrixFromCode(code: String?): Pair? { + val raw = code?.trim()?.uppercase().orEmpty() + if (raw.isEmpty()) return null + toMatrixUnit(raw)?.let { return it to BigDecimal.ONE } + val tokens = Regex("""(\d+(?:\.\d+)?)?([A-Za-z]+)""") + .findAll(raw) + .map { m -> + val num = m.groupValues[1].toBigDecimalOrNull() + val name = m.groupValues[2] + name to num + } + .toList() + if (tokens.isEmpty()) return null + var matrixIdx = -1 + var matrixUnit: MatrixUnit? = null + for ((i, token) in tokens.withIndex()) { + val m = toMatrixUnit(token.first) ?: continue + matrixIdx = i + matrixUnit = m + } + val unit = matrixUnit ?: return null + if (matrixIdx < 0) return null + var qty = BigDecimal.ONE + for (i in 0..matrixIdx) { + qty = qty.multiply(tokens[i].second ?: BigDecimal.ONE) + } + return unit to qty + } + fun convert(qty: BigDecimal, from: MatrixUnit, to: MatrixUnit): BigDecimal { if (from == to) return qty.setScale(2, RoundingMode.HALF_UP) val fromGrams = gramsPerUnit[from] diff --git a/src/main/java/com/ffii/fpsms/modules/master/support/HomeFxRates.kt b/src/main/java/com/ffii/fpsms/modules/master/support/HomeFxRates.kt new file mode 100644 index 0000000..2b5dcee --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/support/HomeFxRates.kt @@ -0,0 +1,35 @@ +package com.ffii.fpsms.modules.master.support + +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * V1 (`fx-source=local`) HKD rates. V2 reads [purchase_order.exchangeRate] from M18. + * JPY: 1 HKD = 20.35 JPY. + */ +object HomeFxRates { + const val HOME = "HKD" + + private val jpyToHkd: BigDecimal = + BigDecimal.ONE.divide(BigDecimal("20.35"), 10, RoundingMode.HALF_UP) + + val toHkd: Map = mapOf( + "HKD" to BigDecimal.ONE, + "USD" to BigDecimal("7.7823"), + "RMB" to BigDecimal("1.1700"), + "CNY" to BigDecimal("1.1700"), + "JPY" to jpyToHkd, + ) + + /** SQL fragment: local FX to HKD from a currency-code expression. */ + fun localFxSql(currencyCodeExpr: String): String = """ + CASE UPPER(TRIM(COALESCE($currencyCodeExpr, ''))) + WHEN 'HKD' THEN 1 + WHEN 'USD' THEN 7.7823 + WHEN 'RMB' THEN 1.1700 + WHEN 'CNY' THEN 1.1700 + WHEN 'JPY' THEN (1 / 20.35) + ELSE NULL + END + """.trimIndent() +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/support/ItemAveragePriceFx.kt b/src/main/java/com/ffii/fpsms/modules/master/support/ItemAveragePriceFx.kt new file mode 100644 index 0000000..ade74eb --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/support/ItemAveragePriceFx.kt @@ -0,0 +1,37 @@ +package com.ffii.fpsms.modules.master.support + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.math.BigDecimal + +@Component +open class ItemAveragePriceFx( + @Value("\${fpsms.item-average-price.fx-source:local}") + private val fxSource: String, +) { + fun isM18Source(): Boolean = fxSource.equals("m18", ignoreCase = true) + + fun resolveRate(currencyCode: String?, poExchangeRate: BigDecimal?): BigDecimal? { + val code = currencyCode?.trim()?.uppercase().orEmpty() + if (isM18Source()) { + if (poExchangeRate != null) return poExchangeRate + return if (code == HomeFxRates.HOME) BigDecimal.ONE else null + } + if (code.isEmpty()) return null + return HomeFxRates.toHkd[code] + } + + /** SQL for FX to HKD; aliases [po] and [c] (currency). */ + fun fxSql(po: String = "po", currency: String = "c"): String { + if (isM18Source()) { + return """ + CASE + WHEN $po.exchangeRate IS NOT NULL THEN $po.exchangeRate + WHEN UPPER(TRIM(COALESCE($currency.code, ''))) = '${HomeFxRates.HOME}' THEN 1 + ELSE NULL + END + """.trimIndent() + } + return HomeFxRates.localFxSql("$currency.code") + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 74ce2bc..77300d1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -68,6 +68,9 @@ scheduler: # Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日). fpsms: + # local = hardcoded HKD rates (testing). m18 = per-PO exchangeRate from MPO sync. + item-average-price: + fx-source: local purchase-stock-in-alert: lookback-days: 7 # Device + printer monitoring: enable only on production profile (application-prod*.yml).