|
|
|
@@ -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<Long, BigDecimal> = if (usePoAveragePurchasePrice) { |
|
|
|
val itemIds = mutableSetOf<Long>() |
|
|
|
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) { |
|
|
|
|