diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterial.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterial.kt index 04650d7..8ec8fbd 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterial.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterial.kt @@ -37,13 +37,22 @@ open class BomMaterial : BaseEntity() { @Column(name = "uomName", length = 100) open var uomName: String? = null + @Column(name = "saleQty", precision = 14, scale = 2) + open var saleQty: BigDecimal? = null + @ManyToOne @JoinColumn(name = "salesUnitId") open var salesUnit: UomConversion? = null @Column(name = "salesUnitCode") open var salesUnitCode: String? = null - + @Column(name = "baseQty", precision = 14, scale = 2) + open var baseQty: BigDecimal? = null + @Column(name = "baseUnit", nullable = false) + open var baseUnit: Integer? = null + @Column(name = "baseUnitName", length = 100) + open var baseUnitName: String? = null + @NotNull @ManyToOne(optional = false) @JoinColumn(name = "bomId", nullable = false) 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 81579fa..3e53450 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 @@ -20,6 +20,9 @@ import com.ffii.fpsms.modules.master.entity.EquipmentDetailRepository import com.ffii.fpsms.modules.settings.entity.BomWeightingScoreRepository import java.math.BigDecimal import java.math.RoundingMode +import com.ffii.fpsms.modules.master.service.ItemUomService +import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest +import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemResponse @Service @@ -36,6 +39,7 @@ open class BomService( private val processRepository: ProcessRepository, private val equipmentDetailRepository: EquipmentDetailRepository, private val bomWeightingScoreRepository: BomWeightingScoreRepository, + private val itemUomService: ItemUomService, ) { open fun findAll(): List { return bomRepository.findAll() @@ -123,20 +127,189 @@ open class BomService( } private fun saveBomMaterial(req: ImportBomMatRequest): BomMaterial { -// val uom = uomConversionRepository.findByCodeAndDeletedFalse() -// println("printing") -// println(req) -// println(req.item) - val bomMaterial = req.bom?.id?.let { bId -> req.item?.id?.let { iId -> bomMaterialRepository.findByBomIdAndItemId(bId, iId) } } ?: BomMaterial() + val bomMaterial = req.bom?.id?.let { bId -> + req.item?.id?.let { iId -> + bomMaterialRepository.findByBomIdAndItemId(bId, iId) + } + } ?: BomMaterial() + + val item = req.item + val bomId = req.bom?.id + val bomCode = req.bom?.code + + // ===== 新逻辑:使用 Excel column 6 的 saleQty 和 column 7 的 sales unit ===== + val excelSaleQty = req.saleQty // Excel 第6栏的 saleQty + val excelSalesUnitCode = req.salesUnitCode // Excel 第7栏的 sales unit code(字符串) + val excelSalesUnit = req.salesUnit // Excel 第7栏查找的 uom_conversion(可能为 null) + + var saleQty: BigDecimal? = excelSaleQty + var saleUnitId: Long? = null + var saleUnitCode: String? = null + + var baseQty: BigDecimal? = null + var baseUnit: Integer? = null + var baseUnitName: String? = null + + if (item?.id != null) { + val itemId = item.id!! + try { + // ---- 1) 获取 item 的真实 stock unit ---- + val stockItemUom = itemUomService.findStockUnitByItemId(itemId) + val itemStockUnit = stockItemUom?.uom + + // saleUnitId: 使用 item 的 stock unit uom id + saleUnitId = itemStockUnit?.id + + // saleUnitCode: 从 Excel 数据查找 uom_conversion,如果失败则使用 Excel 数据本身 + if (excelSalesUnit != null) { + // Excel 找到了 uom_conversion + saleUnitCode = excelSalesUnit.udfudesc + + // 检查 Excel sales unit 与 item stock unit 是否匹配 + if (itemStockUnit != null && excelSalesUnit.id != itemStockUnit.id) { + bomMaterialImportIssues.add( + BomMaterialImportIssue( + bomId = bomId, + bomCode = bomCode, + itemId = item.id, + itemCode = item.code, + itemName = item.name, + reason = "Excel sales unit (${excelSalesUnit.code}/${excelSalesUnit.udfudesc}) does not match item stock unit (${itemStockUnit.code}/${itemStockUnit.udfudesc})", + srcQty = excelSaleQty, + srcUomCode = excelSalesUnit.code + ) + ) + } + } else { + // Excel 没有找到 uom_conversion,使用 Excel 数据本身 + saleUnitCode = excelSalesUnitCode ?: itemStockUnit?.udfudesc + + if (excelSalesUnitCode != null) { + bomMaterialImportIssues.add( + BomMaterialImportIssue( + bomId = bomId, + bomCode = bomCode, + itemId = item.id, + itemCode = item.code, + itemName = item.name, + reason = "Excel sales unit code '$excelSalesUnitCode' not found in uom_conversion, using Excel data as-is", + srcQty = excelSaleQty, + srcUomCode = excelSalesUnitCode + ) + ) + } + } + + // ---- 2) 从 saleQty + item 的真实 stock unit 转换为 baseQty ---- + if (excelSaleQty != null && itemStockUnit != null) { + val baseResult = itemUomService.convertUomByItem( + ConvertUomByItemRequest( + itemId = itemId, + qty = excelSaleQty, + uomId = itemStockUnit.id!!, + targetUnit = "baseUnit" + ) + ) + baseQty = baseResult.newQty + } + + // ---- 3) 获取 item 的真实 base unit ---- + val baseItemUom = itemUomService.findBaseUnitByItemId(itemId) + baseUnit = baseItemUom?.uom?.id?.toInt()?.let { Integer.valueOf(it) } as? Integer + baseUnitName = baseItemUom?.uom?.udfudesc + + // 如果 baseQty 转换失败,记录问题 + if (baseQty == null && excelSaleQty != null && itemStockUnit != null) { + bomMaterialImportIssues.add( + BomMaterialImportIssue( + bomId = bomId, + bomCode = bomCode, + itemId = item.id, + itemCode = item.code, + itemName = item.name, + reason = "Cannot convert saleQty to baseQty: conversion failed", + srcQty = excelSaleQty, + srcUomCode = itemStockUnit.code + ) + ) + } else if (excelSaleQty != null && itemStockUnit == null) { + bomMaterialImportIssues.add( + BomMaterialImportIssue( + bomId = bomId, + bomCode = bomCode, + itemId = item.id, + itemCode = item.code, + itemName = item.name, + reason = "Cannot convert saleQty to baseQty: item stock unit not found", + srcQty = excelSaleQty, + srcUomCode = null + ) + ) + } + + } catch (e: IllegalArgumentException) { + bomMaterialImportIssues.add( + BomMaterialImportIssue( + bomId = bomId, + bomCode = bomCode, + itemId = item.id, + itemCode = item.code, + itemName = item.name, + reason = e.message ?: "UOM conversion error", + srcQty = excelSaleQty, + srcUomCode = excelSalesUnitCode + ) + ) + println("【BOM Import Warning】bomCode=$bomCode, item=${item.code} 转 UOM 失败: ${e.message}") + } catch (e: Exception) { + bomMaterialImportIssues.add( + BomMaterialImportIssue( + bomId = bomId, + bomCode = bomCode, + itemId = item.id, + itemCode = item.code, + itemName = item.name, + reason = "Unknown error: ${e.message}", + srcQty = excelSaleQty, + srcUomCode = excelSalesUnitCode + ) + ) + println("【BOM Import Error】bomCode=$bomCode, item=${item.code}: ${e.message}") + } + } else { + // item 缺失 + bomMaterialImportIssues.add( + BomMaterialImportIssue( + bomId = bomId, + bomCode = bomCode, + itemId = item?.id, + itemCode = item?.code, + itemName = item?.name, + reason = "Missing item for conversion", + srcQty = excelSaleQty, + srcUomCode = excelSalesUnitCode + ) + ) + } + + // ===== 写回 BomMaterial ===== bomMaterial.apply { this.item = req.item - this.itemName = req.item!!.name + this.itemName = req.item?.name this.isConsumable = req.isConsumable - this.qty = req.qty - this.salesUnit = req.salesUnit - this.salesUnitCode = req.salesUnitCode - this.uom = req.uom + this.qty = req.qty // BOM 原始用量(column 2,保留) + this.uom = req.uom // BOM 原始 UOM(column 3,保留) this.uomName = req.uomName + + // 新逻辑:使用 Excel column 6 的 saleQty 和 item stock unit + this.saleQty = saleQty + this.salesUnit = item?.id?.let { itemUomService.findStockUnitByItemId(it)?.uom } + this.salesUnitCode = saleUnitCode + + this.baseQty = baseQty + this.baseUnit = baseUnit + this.baseUnitName = baseUnitName + this.bom = req.bom } return bomMaterialRepository.saveAndFlush(bomMaterial) @@ -209,16 +382,21 @@ open class BomService( val uom = uomConversionRepository.findByCodeAndDeletedFalse(tempCell.stringCellValue.trim()) bomMatRequest.uom = uom bomMatRequest.uomName = uom?.udfudesc + } - /* + 6 -> { - bomMatRequest.qty = tempCell.numericCellValue.toBigDecimal() + bomMatRequest.saleQty = tempCell.numericCellValue.toBigDecimal() } - */ + 7 -> { + val salesUnitCodeStr = tempCell.stringCellValue.trim() + val normalizedCode = if (salesUnitCodeStr.equals("Litter", ignoreCase = true)) "L" else salesUnitCodeStr val salesUnit = uomConversionRepository.findByCodeAndDeletedFalse(tempCell.stringCellValue.trim()) + bomMatRequest.salesUnit = salesUnit - bomMatRequest.salesUnitCode = salesUnit?.udfudesc + // bomMatRequest.salesUnitCode = salesUnit?.udfudesc + bomMatRequest.salesUnitCode = salesUnitCodeStr } @@ -684,102 +862,87 @@ open class BomService( } open fun importBOM(): ByteArray { -// val folderPath = "bomImport" -// val folder = File(folderPath) + bomMaterialImportIssues.clear() // 清空问题列表 + val resolver = PathMatchingResourcePatternResolver() -// val excels = resolver.getResources("bomImport/*.xlsx") - //val excels = resolver.getResources("file:C:/Users/Kelvin YAU/Downloads/bom/*.xlsx") - val excels = resolver.getResources("file:C:/Users/Kelvin YAU/Downloads/datasetup/bom/*.xlsx") - // val excels = resolver.getResources("file:C:/Users/kw093/Downloads/bom/bom/*.xlsx") -// val excels = resolver.getResources("file:C:/Users/2Fi/Desktop/Third Wave of BOM Excel/*.xlsx") + val excels = resolver.getResources("file:C:/Users/kw093/Downloads/bom/bom/*.xlsx") println("size: ${excels.size}") val logExcel = ClassPathResource("excelTemplate/bom_excel_issue_log.xlsx") val templateInputStream = logExcel.inputStream val workbook: Workbook = XSSFWorkbook(templateInputStream) val logSheet: Sheet = workbook.getSheetAt(0) var rowIndex = 0 - - /////// for outputing issue log //////////// -// excels.forEachIndexed { index, resource -> -//// get sheet -// println(resource.filename) -// val templateInputStream1 = resource.inputStream -// val workbook1: Workbook = XSSFWorkbook(templateInputStream1) -// val sheet: Sheet = workbook1.getSheetAt(0) -// println("sheetName") -// println(sheet.sheetName) -// val successPath: Path = Paths.get("C:/Users/2Fi/Desktop/success/${resource.filename}") -// val failPath: Path = Paths.get("C:/Users/2Fi/Desktop/fail/${resource.filename}") -// val bom = try { -// importExcelBomBasicInfo(sheet) // Updating BOM table -// } catch (e: Exception) { -// println("Error in importExcelBomBasicInfo: ${e.message}") -// rowIndex++ -// val row = logSheet.createRow(rowIndex) -// row.createCell(0).setCellValue(resource.filename) -// row.createCell(1).setCellValue(false) -// row.createCell(2).setCellValue(e.message) -// Files.copy(templateInputStream, failPath, StandardCopyOption.REPLACE_EXISTING) -// throw -// return@forEachIndexed -//// throw e // Rethrow to handle in the outer try-catch -// } -// try { -// importExcelBomProcess(bom, sheet) -// } catch (e: Exception) { -// println("Error in importExcelBomProcess: ${e.message}") -// rowIndex++ -// val row = logSheet.createRow(rowIndex) -// row.createCell(0).setCellValue(resource.filename) -// row.createCell(1).setCellValue(false) -// row.createCell(2).setCellValue(e.message) -// Files.copy(templateInputStream, failPath, StandardCopyOption.REPLACE_EXISTING) -// throw -// return@forEachIndexed -//// throw e // Rethrow to handle in the outer try-catch -// } -// try { -// importExcelBomMaterial(bom, sheet) -// } catch (e: Exception) { -// println("Error in importExcelBomMaterial: ${e.message}") -// rowIndex++ -// val row = logSheet.createRow(rowIndex) -// row.createCell(0).setCellValue(resource.filename) -// row.createCell(1).setCellValue(false) -// row.createCell(2).setCellValue(e.message) -// Files.copy(templateInputStream, failPath, StandardCopyOption.REPLACE_EXISTING) -// throw -// return@forEachIndexed -//// throw e // Rethrow to handle in the outer try-catch -// } -// rowIndex++ -// val row = logSheet.createRow(rowIndex) -// row.createCell(0).setCellValue(resource.filename) -// row.createCell(1).setCellValue(true) -// Files.copy(templateInputStream, successPath, StandardCopyOption.REPLACE_EXISTING) -// } -// val outputStream = ByteArrayOutputStream() -// workbook.write(outputStream) -// workbook.close() -// return outputStream.toByteArray() - //////////////////////////////////////////////////////////////////////////////////////////// + for (resource in excels) { - //get sheet - println(resource.filename) - val templateInputStream2 = resource.inputStream - val workbook2: Workbook = XSSFWorkbook(templateInputStream2) - val sheet: Sheet = workbook2.getSheetAt(0) - val bom = importExcelBomBasicInfo(sheet) // updating bom table - // import bom process / create new process - importExcelBomProcess(bom, sheet) - // import bom material - importExcelBomMaterial(bom, sheet) - -// break + val filename = resource.filename ?: "unknown" + + + try { + //get sheet + println("处理文件: $filename") + val templateInputStream2 = resource.inputStream + val workbook2: Workbook = XSSFWorkbook(templateInputStream2) + val sheet: Sheet = workbook2.getSheetAt(0) + val bom = importExcelBomBasicInfo(sheet) // updating bom table + // import bom process / create new process + importExcelBomProcess(bom, sheet) + // import bom material + importExcelBomMaterial(bom, sheet) + workbook2.close() + println("成功处理文件: $filename") + } catch (e: org.apache.poi.openxml4j.exceptions.NotOfficeXmlFileException) { + println("错误:文件 '$filename' 不是有效的 Excel 文件,跳过") + println(" 错误信息: ${e.message}") + } catch (e: java.util.zip.ZipException) { + println("错误:文件 '$filename' 格式错误(可能是临时文件或损坏),跳过") + println(" 错误信息: ${e.message}") + } catch (e: Exception) { + println("错误:处理文件 '$filename' 时发生异常") + println(" 错误类型: ${e.javaClass.simpleName}") + println(" 错误信息: ${e.message}") + e.printStackTrace() + } } - val outputStream = ByteArrayOutputStream() - workbook.write(outputStream) - workbook.close() - return outputStream.toByteArray() + + printBomMaterialImportIssues() // 打印问题汇总 + + val outputStream = ByteArrayOutputStream() + workbook.write(outputStream) + workbook.close() + return outputStream.toByteArray() + } + private fun printBomMaterialImportIssues() { + println("\n========== BOM Material UOM Conversion Issues ==========") + println("Total issues: ${bomMaterialImportIssues.size}") + if (bomMaterialImportIssues.isEmpty()) { + println("No issues.") + println("========================================================\n") + return + } + + // 按 BOM 分组统计 + val issuesByBom = bomMaterialImportIssues.groupBy { + it.bomCode?.takeIf { it.isNotEmpty() } ?: "BOM-id-${it.bomId}" + } + + println("\n按 BOM 分组统计:") + issuesByBom.forEach { (bomKey, issues) -> + println(" $bomKey: ${issues.size} 个问题") + } + + println("\n详细问题列表:") + bomMaterialImportIssues + .sortedWith(compareBy({ it.bomCode ?: "BOM-id-${it.bomId}" }, { it.itemCode })) + .forEach { issue -> + val bomIdentifier = issue.bomCode?.takeIf { it.isNotEmpty() } + ?: "BOM-id-${issue.bomId}" + println( + "BOM=$bomIdentifier, " + + "Item=${issue.itemCode ?: "null"} (${issue.itemName ?: "null"}), " + + "src=${issue.srcQty} ${issue.srcUomCode} -> reason=${issue.reason}" + ) + } + + println("========================================================\n") } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index 083e76c..46a4f2c 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -164,6 +164,7 @@ open class ItemUomService( ) } } + if (request.uomId == 784L) { val targetItemUom = findTargetItemUom(request.itemId, request.targetUnit) val targetUomId = targetItemUom?.uom?.id @@ -182,9 +183,43 @@ open class ItemUomService( ) } } + // Find source ItemUom by itemId and uomId - val sourceItemUom = itemUomRespository.findFirstByItemIdAndUomIdAndDeletedIsFalse(request.itemId, request.uomId) - ?: throw IllegalArgumentException("Source ItemUom not found for itemId=${request.itemId}, uomId=${request.uomId}") + var sourceItemUom = itemUomRespository.findFirstByItemIdAndUomIdAndDeletedIsFalse(request.itemId, request.uomId) + + // Special handling: If source is KG (784) but ItemUom doesn't exist, try indirect conversion via baseUnit + if (sourceItemUom == null && request.uomId == 784L) { + val baseUnitItemUom = findBaseUnitByItemId(request.itemId) + val baseUnitUomId = baseUnitItemUom?.uom?.id + + // If base unit is gram (id=4), convert KG -> G first, then G -> target + if (baseUnitUomId == 4L) { + // Step 1: Convert KG to G (baseUnit) + val baseQty = request.qty.multiply(BigDecimal(1000)) + + // Step 2: Convert from baseUnit to targetUnit + val targetItemUom = findTargetItemUom(request.itemId, request.targetUnit) + ?: throw IllegalArgumentException("Target ItemUom not found for itemId=${request.itemId}, targetUnit=${request.targetUnit}") + + val targetRatioN = targetItemUom.ratioN ?: BigDecimal.ONE + val targetRatioD = targetItemUom.ratioD ?: BigDecimal.ONE + + // Convert base (G) to target: baseQty * ratioD / ratioN + val newQty = baseQty.multiply(targetRatioD).divide(targetRatioN, 2, RoundingMode.UP) + + val uomConversion = targetItemUom.uom + ?: throw IllegalArgumentException("Target UomConversion not found for target ItemUom") + + return ConvertUomByItemResponse( + newQty = newQty, + udfudesc = uomConversion.udfudesc, + udfShortDesc = uomConversion.udfShortDesc + ) + } + } + + // If still no source ItemUom found, throw error + sourceItemUom ?: throw IllegalArgumentException("Source ItemUom not found for itemId=${request.itemId}, uomId=${request.uomId}") // Find target ItemUom by itemId and targetUnit val targetItemUom = findTargetItemUom(request.itemId, request.targetUnit) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomRequest.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomRequest.kt index 569aecb..4bf0dc5 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomRequest.kt @@ -49,6 +49,7 @@ data class ImportBomMatRequest ( var isConsumable: Boolean? = false, var qty: BigDecimal? = null, var uom: UomConversion? = null, + var saleQty: BigDecimal? = null, var salesUnit: UomConversion? = null, var salesUnitCode: String? = null, var uomName: String? = null, @@ -68,4 +69,4 @@ data class ImportBomProcessRequest( data class ImportBomProcessMaterialRequest( var bomProcess: BomProcess? = null, var bomMaterial: BomMaterial? = null, -) \ No newline at end of file +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomResponse.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomResponse.kt index 3397fbe..8f5efc8 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomResponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/SaveBomResponse.kt @@ -11,4 +11,16 @@ data class SaveBomResponse ( val outputQtyUom: String? = null, val yield: BigDecimal? = null, val uomName: String? = null, -) \ No newline at end of file +) +data class BomMaterialImportIssue( + val bomId: Long?, + val bomCode: String?, + val itemId: Long?, + val itemCode: String?, + val itemName: String?, + val reason: String, + val srcQty: BigDecimal?, + val srcUomCode: String?, +) + + val bomMaterialImportIssues = mutableListOf() \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20260202_Enson/01_add_bom_materail.sql b/src/main/resources/db/changelog/changes/20260202_Enson/01_add_bom_materail.sql new file mode 100644 index 0000000..ec3d0c4 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260202_Enson/01_add_bom_materail.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql +-- changeset KelvinY:add_baseScore_to_bom + +ALTER TABLE `fpsmsdb`.`bom_material` +ADD COLUMN `saleQty` DECIMAL(14, 2) NULL DEFAULT NULL AFTER `uomName`, +ADD COLUMN `baseQty` DECIMAL(14, 2) NULL DEFAULT NULL AFTER `salesUnitCode`, +ADD COLUMN `baseUnit` INTEGER NULL DEFAULT NULL AFTER `baseQty`, +ADD COLUMN `baseUnitName` VARCHAR(255) NULL DEFAULT NULL AFTER `baseUnit`;