diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt index 607e27a..dd9f89a 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt @@ -18,7 +18,7 @@ interface JobOrderRepository : AbstractRepository { fun findLatestCodeByPrefix(prefix: String): String? fun findJobOrderInfoByCodeContainsAndBomNameContainsAndDeletedIsFalseOrderByIdDesc(code: String, bomName: String, pageable: Pageable): Page - + fun findByBom_Id(bomId: Long): List @Query( nativeQuery = true, value = """ diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt index dd4dc1e..601ce5e 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt @@ -1757,7 +1757,7 @@ private fun normalizeFloor(raw: String): String { val num = cleaned.replace(Regex("[^0-9]"), "") return if (num.isNotEmpty()) "${num}F" else cleaned } -open fun getAllJoPickOrders(): List { +open fun getAllJoPickOrders(isDrink: Boolean?): List { println("=== getAllJoPickOrders ===") return try { @@ -1808,7 +1808,8 @@ open fun getAllJoPickOrders(): List { val bom = jobOrder.bom - + // 按 isDrink 过滤:null 表示不过滤(全部) +if (isDrink != null && bom?.isDrink != isDrink) return@mapNotNull null println("BOM found: ${bom?.id}") val item = bom?.item @@ -1860,7 +1861,7 @@ open fun getAllJoPickOrders(): List { } println("Returning ${jobOrderPickOrders.size} released job order pick orders") - jobOrderPickOrders + jobOrderPickOrders.sortedByDescending { it.id } } catch (e: Exception) { println("❌ Error in getAllJoPickOrders: ${e.message}") e.printStackTrace() diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt index b5fc659..577dca7 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt @@ -22,6 +22,7 @@ import com.ffii.fpsms.modules.pickOrder.service.PickOrderService import com.ffii.fpsms.modules.pickOrder.web.models.SavePickOrderLineRequest import com.ffii.fpsms.modules.pickOrder.web.models.SavePickOrderRequest import com.ffii.fpsms.modules.user.service.UserService + import com.google.gson.reflect.TypeToken import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service @@ -69,6 +70,8 @@ import java.time.LocalDateTime import com.ffii.fpsms.modules.master.entity.BomMaterialRepository import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest +import com.ffii.fpsms.modules.stock.service.StockInLineService +import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest @Service open class JobOrderService( val jobOrderRepository: JobOrderRepository, @@ -87,6 +90,7 @@ open class JobOrderService( val jobTypeRepository: JobTypeRepository, val inventoryRepository: InventoryRepository, val stockInLineRepository: StockInLineRepository, + val stockInLineService: StockInLineService, val productProcessRepository: ProductProcessRepository, val jobOrderBomMaterialRepository: JobOrderBomMaterialRepository, val bomMaterialRepository: BomMaterialRepository, @@ -437,7 +441,21 @@ open class JobOrderService( } val savedJo = jobOrderRepository.saveAndFlush(jo); - + savedJo.bom?.item?.id?.let { fgItemId -> + stockInLineService.create( + SaveStockInLineRequest( + itemId = fgItemId, + acceptedQty = BigDecimal.ZERO, + acceptQty = null, + jobOrderId = savedJo.id, + status = "pending", + expiryDate = null, + productLotNo = null, + productionDate = null, + receiptDate = null, + ) + ) + } return MessageResponse( id = savedJo.id, name = null, diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt index a1e209c..a1c8fd5 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt @@ -243,8 +243,8 @@ fun recordSecondScanIssue( return joPickOrderService.getCompletedJobOrderPickOrderLotDetailsForCompletedPick(pickOrderId) } @GetMapping("/AllJoPickOrder") - fun getAllJoPickOrder(): List { - return joPickOrderService.getAllJoPickOrders() + fun getAllJoPickOrder(@RequestParam(required = false) isDrink: Boolean?): List { + return joPickOrderService.getAllJoPickOrders(isDrink) } @GetMapping("/all-lots-hierarchical-by-pick-order/{pickOrderId}") diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt index d8ecfb5..f230bc3 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt @@ -22,6 +22,8 @@ open class Bom : BaseEntity() { @Column open var isDense: Int? = null @Column + open var isDrink: Boolean? = false + @Column open var scrapRate: Int? = null @Column open var allergicSubstances: Int? = null @@ -31,7 +33,7 @@ open class Bom : BaseEntity() { @Column open var complexity: Int? = null @JsonBackReference - @OneToOne + @ManyToOne @JoinColumn(name = "itemId") open var item: Items? = null diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomProcessMaterialRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomProcessMaterialRepository.kt index 0fd0cf6..5bb7fad 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomProcessMaterialRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomProcessMaterialRepository.kt @@ -5,5 +5,6 @@ import org.springframework.stereotype.Repository @Repository interface BomProcessMaterialRepository : AbstractRepository { - fun findByBomProcessIdAndBomMaterialId(bomProcessId: Long, bomMaterialId: Long): BomProcessMaterial?; + fun findByBomProcessIdAndBomMaterialId(bomProcessId: Long, bomMaterialId: Long): BomProcessMaterial? + fun findByBomProcess_IdIn(bomProcessIds: List): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt index 5b48070..b4ce099 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt @@ -16,6 +16,6 @@ interface BomRepository : AbstractRepository { fun findByItemIdAndDeletedIsFalse(itemId: Serializable): Bom? fun findBomComboByDeletedIsFalse(): List - + fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List fun findByCodeAndDeletedIsFalse(code: String): Bom? } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt index beb70f9..b0fe8c2 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt @@ -76,8 +76,8 @@ open class Items : BaseEntity() { open var inventories: MutableList = mutableListOf() @JsonManagedReference - @OneToOne(mappedBy = "item", cascade = [CascadeType.ALL], orphanRemoval = true) - open var bom: Bom? = null + @OneToMany(mappedBy = "item", cascade = [CascadeType.ALL], orphanRemoval = true) + open var boms: MutableList = mutableListOf() @ManyToOne @JoinColumn(name = "categoryId") 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 092c4c9..8c3064f 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 @@ -12,10 +12,12 @@ import org.springframework.core.io.support.PathMatchingResourcePatternResolver import org.springframework.http.ResponseEntity import org.springframework.stereotype.Service import java.io.ByteArrayOutputStream +import java.time.LocalDateTime import java.nio.file.Path import java.nio.file.Paths -import java.nio.file.Files; +import java.nio.file.Files import java.nio.file.StandardCopyOption +import java.io.FileInputStream import com.ffii.fpsms.modules.master.entity.EquipmentDetailRepository import com.ffii.fpsms.modules.settings.entity.BomWeightingScoreRepository import java.math.BigDecimal @@ -23,7 +25,14 @@ 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 - +import com.ffii.fpsms.modules.master.web.models.ItemType +import org.springframework.beans.factory.annotation.Value +import org.springframework.web.multipart.MultipartFile +import jakarta.servlet.http.Part +import java.util.UUID +import java.util.Comparator +import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository +import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository @Service open class BomService( @@ -40,7 +49,57 @@ open class BomService( private val equipmentDetailRepository: EquipmentDetailRepository, private val bomWeightingScoreRepository: BomWeightingScoreRepository, private val itemUomService: ItemUomService, + private val jobOrderRepository: JobOrderRepository, + private val productProcessRepository: ProductProcessRepository, + @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, ) { + open fun uploadBomFiles(files: List): BomUploadResponse { + val batchId = UUID.randomUUID().toString() + val batchDir = Paths.get(bomImportTempDir, batchId).toAbsolutePath() + Files.createDirectories(batchDir) + val fileNames = mutableListOf() + val usedNames = mutableSetOf() + for (file in files) { + if (file.isEmpty) continue + val originalFilename = file.originalFilename ?: "unknown_${fileNames.size}.xlsx" + var safeName = originalFilename.takeIf { it.endsWith(".xlsx", ignoreCase = true) } ?: "$originalFilename.xlsx" + while (usedNames.contains(safeName)) { + val base = safeName.dropLast(5) // ".xlsx" + val n = fileNames.count { it.startsWith(base) && it.endsWith(".xlsx") } + 1 + safeName = "${base}_$n.xlsx" + } + usedNames.add(safeName) + val dest = batchDir.resolve(safeName) + file.transferTo(dest.toFile()) + fileNames.add(safeName) + } + return BomUploadResponse(batchId = batchId, fileNames = fileNames) + } + + open fun uploadBomFilesFromParts(parts: List): BomUploadResponse { + val batchId = UUID.randomUUID().toString() + val batchDir = Paths.get(bomImportTempDir, batchId).toAbsolutePath() + Files.createDirectories(batchDir) + val fileNames = mutableListOf() + val usedNames = mutableSetOf() + for (part in parts) { + val originalFilename = part.submittedFileName?.takeIf { it.isNotBlank() } ?: "unknown_${fileNames.size}.xlsx" + var safeName = originalFilename.takeIf { it.endsWith(".xlsx", ignoreCase = true) } ?: "$originalFilename.xlsx" + while (usedNames.contains(safeName)) { + val base = safeName.dropLast(5) + val n = fileNames.count { it.startsWith(base) && it.endsWith(".xlsx") } + 1 + safeName = "${base}_$n.xlsx" + } + usedNames.add(safeName) + val dest = batchDir.resolve(safeName) + part.inputStream.use { Files.copy(it, dest, StandardCopyOption.REPLACE_EXISTING) } + fileNames.add(safeName) + } + return BomUploadResponse(batchId = batchId, fileNames = fileNames) + } + + private fun getBatchDir(batchId: String): java.nio.file.Path = + Paths.get(bomImportTempDir, batchId).toAbsolutePath() open fun findAll(): List { return bomRepository.findAll() } @@ -54,7 +113,9 @@ open class BomService( } open fun findByItemId(itemId: Long): Bom? { - return bomRepository.findByItemIdAndDeletedIsFalse(itemId) + return bomRepository.findAllByItemIdAndDeletedIsFalse(itemId) + .minByOrNull { if (it.description == "FG") 0 else 1 } + ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() } open fun saveBom(request: SaveBomRequest): SaveBomResponse { @@ -517,7 +578,7 @@ open class BomService( val tempRow = sheet.getRow(startRowIndex) val tempCell = tempRow.getCell(startColumnIndex) if (tempCell != null && tempCell.cellType == CellType.STRING && tempCell.stringCellValue.trim() == "材料編號") { - println("last: $startRowIndex") + //println("last: $startRowIndex") startRowIndex++ break } @@ -526,7 +587,7 @@ open class BomService( var bomMatRequest = ImportBomMatRequest( bom = bom ) - println("starting new loop") + // println("starting new loop") while (startRowIndex != endRowIndex || startColumnIndex != endColumnIndex) { val tempRow = sheet.getRow(startRowIndex) val tempCell = tempRow.getCell(startColumnIndex) @@ -588,8 +649,8 @@ open class BomService( } } - println("startRowIndex: $startRowIndex") - println("endRowIndex: $endRowIndex") + //println("startRowIndex: $startRowIndex") + //println("endRowIndex: $endRowIndex") // println("first condition: ${startColumnIndex < endColumnIndex}") // println("second condition: ${startRowIndex < endRowIndex}") @@ -640,7 +701,15 @@ open class BomService( "編號" -> { request.code = topTargetValueCell.stringCellValue.trim() } - "產品名稱" -> request.name = topTargetValueCell.stringCellValue.trim() + "產品名稱" -> { + val r5Cell = sheet.getRow(4)?.getCell(17) + request.name = when { + r5Cell == null || r5Cell.cellType == CellType.BLANK -> "" + r5Cell.cellType == CellType.STRING -> r5Cell.stringCellValue.trim() + r5Cell.cellType == CellType.FORMULA && r5Cell.cachedFormulaResultType == CellType.STRING -> r5Cell.stringCellValue.trim() + else -> "" + } + } "種類" -> request.description = topTargetValueCell.stringCellValue.trim() "份量 (Qty)" -> request.outputQty = topTargetValueCell.numericCellValue.toBigDecimal() "單位" -> request.outputQtyUom = topTargetValueCell.stringCellValue.trim() @@ -1032,56 +1101,222 @@ open class BomService( } - open fun importBOM(): ByteArray { - bomMaterialImportIssues.clear() // 清空问题列表 - - val resolver = PathMatchingResourcePatternResolver() - val excels = resolver.getResources("file:C:/Users/kw093/Downloads/bom/bom/*.xlsx") - println("size: ${excels.size}") + /** Reads BOM code (編號) from sheet without saving. */ + private fun readBomCodeFromSheet(sheet: Sheet): String? { + for (r in 0..9) { + for (c in 0..9) { + val cell = sheet.getRow(r)?.getCell(c) ?: continue + if (cell.cellType != CellType.STRING) continue + if (cell.stringCellValue.trim() != "編號") continue + val valueRow = sheet.getRow(r + 1) ?: return null + val valueCell = valueRow.getCell(c) ?: return null + return when { + valueCell.cellType == CellType.STRING -> valueCell.stringCellValue.trim().takeIf { it.isNotEmpty() } + valueCell.cellType == CellType.FORMULA && valueCell.cachedFormulaResultType == CellType.STRING -> valueCell.stringCellValue.trim().takeIf { it.isNotEmpty() } + else -> null + } + } + } + return null + } + + private fun softDeleteBomAndRelated(bomId: Long) { + val bom = bomRepository.findById(bomId).orElse(null) ?: return + bom.deleted = true + bomRepository.saveAndFlush(bom) + bomMaterialRepository.findAllByBomIdAndDeletedIsFalse(bomId).forEach { m -> + m.deleted = true + bomMaterialRepository.saveAndFlush(m) + } + val processes = bomProcessRepository.findByBomId(bomId) + val processIds = processes.mapNotNull { it.id } + if (processIds.isNotEmpty()) { + bomProcessMaterialRepository.findByBomProcess_IdIn(processIds).forEach { pm -> + pm.deleted = true + bomProcessMaterialRepository.saveAndFlush(pm) + } + } + processes.forEach { p -> + p.deleted = true + bomProcessRepository.saveAndFlush(p) + } + } + private fun updateJobOrderAndProductProcessBomReference(oldBomId: Long, newBomId: Long) { + val newBom = bomRepository.findById(newBomId).orElse(null) ?: return + jobOrderRepository.findByBom_Id(oldBomId).forEach { jo -> + jo.bom = newBom + jobOrderRepository.saveAndFlush(jo) + } + productProcessRepository.findByBom_Id(oldBomId).forEach { pp -> + pp.bom = newBom + productProcessRepository.saveAndFlush(pp) + } + } + open fun importBOM(batchId: String, items: List): ByteArray { + bomMaterialImportIssues.clear() + val batchDir = getBatchDir(batchId) + if (!Files.isDirectory(batchDir)) return ByteArray(0) 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 (resource in excels) { - 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 itemFileNames = items.map { it.fileName }.toSet() + Files.list(batchDir).use { stream -> + stream.filter { p -> p.toString().lowercase().endsWith(".xlsx") } + .filter { itemFileNames.contains(it.fileName.toString()) } + .forEach { path -> + val filename = path.fileName.toString() + val isAlsoWip = items.find { it.fileName == filename }?.isAlsoWip == true + println("importBOM: filename='$filename', isAlsoWip=$isAlsoWip") + try { + FileInputStream(path.toFile()).use { input -> + val workbook2: Workbook = XSSFWorkbook(input) + val sheet: Sheet = workbook2.getSheetAt(0) + val code = readBomCodeFromSheet(sheet) + var oldBomId: Long? = null + code?.let { c -> + bomRepository.findByCodeAndDeletedIsFalse(c)?.id?.let { existingId -> + softDeleteBomAndRelated(existingId) + oldBomId = existingId + } + } + val bom = importExcelBomBasicInfo(sheet) + importExcelBomProcess(bom, sheet) + importExcelBomMaterial(bom, sheet) + if (isAlsoWip) { + createWipCopyFromFgBom(bom) + } + oldBomId?.let { updateJobOrderAndProductProcessBomReference(it, bom.id!!) } + workbook2.close() + } + } catch (e: Exception) { + println("错误:处理文件 '$filename' 时发生异常: ${e.message}") + } + } } - - printBomMaterialImportIssues() // 打印问题汇总 - + printBomMaterialImportIssues() val outputStream = ByteArrayOutputStream() workbook.write(outputStream) workbook.close() + try { + Files.walk(batchDir).use { stream -> stream.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } } + } catch (_: Exception) { } return outputStream.toByteArray() } + + private fun createWipBomFromFg(fgBom: Bom) { + val wipCode = "${fgBom.code}-WIP" + val wipItem = itemsRepository.findByCodeAndDeletedFalse(wipCode) + ?: itemsRepository.saveAndFlush(Items().apply { + code = wipCode + name = fgBom.name + description = fgBom.description + type = ItemType.SFG.type + m18LastModifyDate = LocalDateTime.now() + }) + val wipBom = Bom().apply { + code = wipCode + name = fgBom.name + description = fgBom.description + item = wipItem + outputQty = fgBom.outputQty + outputQtyUom = fgBom.outputQtyUom + yield = fgBom.yield + isDark = fgBom.isDark + isFloat = fgBom.isFloat + isDense = fgBom.isDense + scrapRate = fgBom.scrapRate + timeSequence = fgBom.timeSequence + complexity = fgBom.complexity + allergicSubstances = fgBom.allergicSubstances + uom = fgBom.uom + } + wipBom.baseScore = calculateBaseScore(wipBom) + bomRepository.saveAndFlush(wipBom) + } + /** 方案 A:複製 FG BOM 為一筆相同 code、相同 item、description=WIP 的 BOM,並複製 materials 與 processes。 */ + private fun createWipCopyFromFgBom(fgBom: Bom) { + val wipBom = Bom().apply { + code = fgBom.code + name = fgBom.name + description = "WIP" + item = fgBom.item + outputQty = fgBom.outputQty + outputQtyUom = fgBom.outputQtyUom + yield = fgBom.yield + isDark = fgBom.isDark + isFloat = fgBom.isFloat + isDense = fgBom.isDense + scrapRate = fgBom.scrapRate + timeSequence = fgBom.timeSequence + complexity = fgBom.complexity + allergicSubstances = fgBom.allergicSubstances + uom = fgBom.uom + } + wipBom.baseScore = calculateBaseScore(wipBom) + bomRepository.saveAndFlush(wipBom) + + val fgBomId = fgBom.id ?: return + val oldMaterials = bomMaterialRepository.findAllByBomIdAndDeletedIsFalse(fgBomId) + val oldToNewMaterial = mutableMapOf() + for (m in oldMaterials) { + val newM = BomMaterial().apply { + item = m.item + itemName = m.itemName + isConsumable = m.isConsumable + qty = m.qty + uom = m.uom + uomName = m.uomName + saleQty = m.saleQty + salesUnit = m.salesUnit + salesUnitCode = m.salesUnitCode + baseQty = m.baseQty + baseUnit = m.baseUnit + baseUnitName = m.baseUnitName + stockQty = m.stockQty + stockUnit = m.stockUnit + stockUnitName = m.stockUnitName + bom = wipBom + m18Id = m.m18Id + m18LastModifyDate = m.m18LastModifyDate ?: LocalDateTime.now() + remarks = m.remarks + } + bomMaterialRepository.saveAndFlush(newM) + m.id?.let { oldToNewMaterial[it] = newM } + } + + val oldProcesses = bomProcessRepository.findAllByBomIdAndDeletedFalse(fgBomId) + val oldToNewProcess = mutableMapOf() + for (p in oldProcesses) { + val newP = BomProcess().apply { + process = p.process + equipment = p.equipment + description = p.description + seqNo = p.seqNo + durationInMinute = p.durationInMinute + prepTimeInMinute = p.prepTimeInMinute + postProdTimeInMinute = p.postProdTimeInMinute + bom = wipBom + } + bomProcessRepository.saveAndFlush(newP) + p.id?.let { oldToNewProcess[it] = newP } + } + + val oldProcessIds = oldProcesses.mapNotNull { it.id } + if (oldProcessIds.isNotEmpty()) { + val processMaterials = bomProcessMaterialRepository.findByBomProcess_IdIn(oldProcessIds) + for (pm in processMaterials) { + val newProcess = pm.bomProcess?.id?.let { oldToNewProcess[it] } ?: continue + val oldMatId = pm.bomMaterial?.id ?: continue + val newMaterial = oldToNewMaterial[oldMatId] ?: continue + val newPm = BomProcessMaterial().apply { + bomProcess = newProcess + bomMaterial = newMaterial + } + bomProcessMaterialRepository.saveAndFlush(newPm) + } + } + } private fun printBomMaterialImportIssues() { println("\n========== BOM Material UOM Conversion Issues ==========") println("Total issues: ${bomMaterialImportIssues.size}") @@ -1116,4 +1351,802 @@ open class BomService( println("========================================================\n") } + open fun checkBomExcelFormat(batchId: String): BomFormatCheckResponse { + val batchDir = getBatchDir(batchId) + if (!Files.isDirectory(batchDir)) return BomFormatCheckResponse(correctFileNames = emptyList(), failList = emptyList()) + val issues = mutableListOf() + Files.list(batchDir).use { stream -> + stream.filter { p -> p.toString().lowercase().endsWith(".xlsx") }.forEach { path -> + val fileName = path.fileName.toString() + try { + FileInputStream(path.toFile()).use { input -> + val workbook: Workbook = XSSFWorkbook(input) + val sheet: Sheet = workbook.getSheetAt(0) + validateBasicInfoLikeImport(sheet, fileName, issues) + validateProcessLikeImport(sheet, fileName, issues) + validateMaterialLikeImport(sheet, fileName, issues) + workbook.close() + } + } catch (e: org.apache.poi.openxml4j.exceptions.NotOfficeXmlFileException) { + issues += BomFormatIssue(fileName, "檔案不是有效的 Excel 檔") + } catch (e: java.util.zip.ZipException) { + issues += BomFormatIssue(fileName, "檔案格式錯誤或損毀") + } catch (e: Exception) { + issues += BomFormatIssue( + fileName, + "檢查時發生例外:${e.javaClass.simpleName} - ${e.message ?: "Unknown error"}" + ) + } + } + } + val allFileNames = Files.list(batchDir).use { stream -> + stream + .filter { p -> p.toString().lowercase().endsWith(".xlsx") } + .map { p -> p.fileName.toString() } + .sorted() + .toList() + } + + // 2) 先把 issues 做成 failList(跟你现在一样) + val failList = issues + .groupBy { it.fileName } + .map { (fileName, list) -> + BomFormatFileGroup( + fileName = fileName, + problems = list.map { it.problem }.distinct().sorted() + ) + } + .sortedBy { it.fileName } + + // 3) 正确档名 = 全部档名 - 有 issues 的档名 + val filesWithIssues = issues.asSequence().map { it.fileName }.toSet() + val correctFileNames = allFileNames.filter { it !in filesWithIssues } + + return BomFormatCheckResponse(correctFileNames = correctFileNames, failList = failList) + } + private fun isValidEquipmentType(value: String): Boolean { + val trimmed = value.trim() + if (trimmed.isEmpty()) return false + if (trimmed == "不合用" || trimmed == "不適用") return true + val dashIdx = trimmed.indexOf('-') + if (dashIdx <= 0 || dashIdx == trimmed.length - 1) return false + val left = trimmed.substring(0, dashIdx).trim() + val right = trimmed.substring(dashIdx + 1).trim() + return left.isNotEmpty() && right.isNotEmpty() + } + + /** Cell 是否為非空字串(含 STRING / FORMULA 結果為字串) */ + private fun isNonEmptyStringCell(cell: org.apache.poi.ss.usermodel.Cell?): Boolean { + if (cell == null || cell.cellType == CellType.BLANK) return false + return when (cell.cellType) { + CellType.STRING -> cell.stringCellValue.trim().isNotEmpty() + CellType.FORMULA -> when (cell.cachedFormulaResultType) { + CellType.STRING -> cell.stringCellValue.trim().isNotEmpty() + else -> false + } + else -> false + } + } + private fun validateProcessLikeImport( + sheet: Sheet, + fileName: String, + issues: MutableList + ) { + var startRowIndex = 30 + var endRowIndex = 70 + var startColumnIndex = 0 + val endColumnIndex = 11 + + // 1) 找到 "工序" header + var headerFound = false + while (startRowIndex < endRowIndex) { + val tempRow = sheet.getRow(startRowIndex) + val tempCell = tempRow?.getCell(startColumnIndex) + if (tempCell != null && + tempCell.cellType == CellType.STRING && + tempCell.stringCellValue.trim() == "工序" + ) { + headerFound = true + startRowIndex += 2 + break + } + startRowIndex++ + } + + if (!headerFound) { + issues += BomFormatIssue(fileName, "工序區:找不到『工序』表頭") + return + } + + var searchRowIndex = startRowIndex + val maxSearchRow = 50 + while (searchRowIndex < maxSearchRow) { + val tempRow = sheet.getRow(searchRowIndex) + val tempCell = tempRow?.getCell(0) + if (tempCell == null || tempCell.cellType == CellType.BLANK) { + endRowIndex = searchRowIndex + break + } + searchRowIndex++ + } + + while (startRowIndex != endRowIndex || startColumnIndex != endColumnIndex) { + val tempRow = sheet.getRow(startRowIndex) + val tempCell = tempRow?.getCell(startColumnIndex) + + if (startColumnIndex == 0 && + (tempCell == null || tempCell.cellType == CellType.BLANK) + ) { + break + } else if (tempCell != null) { + val rowNum = startRowIndex + 1 + when (startColumnIndex) { + 0 -> { + // Process Sequence — 必填,數值 + if (!isNumericLike(tempCell)) { + issues += BomFormatIssue(fileName, "工序區:第${rowNum}行『編號(次序)』不可為空且須為數值") + } + } + 1 -> { + // Process Name — 必填,非空字串 + if (!isNonEmptyStringCell(tempCell)) { + issues += BomFormatIssue(fileName, "工序區:第${rowNum}行『名稱』不可為空") + } + } + 2 -> { + // Process Brief Description — 必填,非空字串 + if (!isNonEmptyStringCell(tempCell)) { + issues += BomFormatIssue(fileName, "工序區:第${rowNum}行『描述』不可為空") + } + } + 3 -> { + // Equipment Type — 必填,格式 不合用/不適用/XXXXX-YYYY + val str = when (tempCell.cellType) { + CellType.STRING -> tempCell.stringCellValue.trim() + CellType.FORMULA -> if (tempCell.cachedFormulaResultType == CellType.STRING) tempCell.stringCellValue.trim() else "" + else -> "" + } + if (str.isEmpty()) { + issues += BomFormatIssue(fileName, "工序區:第${rowNum}行『使用設備』不可為空") + } else if (!isValidEquipmentType(str)) { + issues += BomFormatIssue(fileName, "工序區:第${rowNum}行『使用設備』格式須為 不合用、不適用 或 XXXXX-YYYY") + } + } + 5 -> { + // 步驟時間 (mins) — 必填,數值 + if (!isNumericLike(tempCell)) { + issues += BomFormatIssue(fileName, "工序區:第${rowNum}行『步驟時間(mins)』不可為空且須為數值") + } + } + // 4, 6~11 可 null,不檢查 + else -> { } + } + } + + if (startColumnIndex < endColumnIndex) { + startColumnIndex++ + } else if (startRowIndex < endRowIndex) { + startRowIndex++ + startColumnIndex = 0 + } + } + } +private fun validateBasicInfoLikeImport( + sheet: Sheet, + fileName: String, + issues: MutableList +) { + var startRowIndex = 0 + val endRowIndex = 10 + var startColumnIndex = 0 + val endColumnIndex = 9 + + // 紀錄有沒有看過這幾個 header & 有沒有成功取到值 + var codeHeaderFound = false + var nameHeaderFound = false + var descHeaderFound = false + var qtyHeaderFound = false + var uomHeaderFound = false + + var codeValueOk = false + var nameValueOk = false + var descValueOk = false + var qtyValueOk = false + var uomValueOk = false + + while (startRowIndex != endRowIndex || startColumnIndex != endColumnIndex) { + val tempRow = sheet.getRow(startRowIndex) + val tempCell = tempRow?.getCell(startColumnIndex) + + if (tempCell != null && tempCell.cellType == CellType.STRING) { + val tempCellVal = tempCell.stringCellValue.trim() + + // 跟 importExcelBomBasicInfo 一樣:值在 header 正下方 + val topTargetValueRow = sheet.getRow(startRowIndex + 1) + val topTargetValueCell = topTargetValueRow?.getCell(startColumnIndex) + + when (tempCellVal) { + "編號" -> { + codeHeaderFound = true + if (topTargetValueCell == null) { + issues += BomFormatIssue(fileName, "基本資料:『編號』欄位缺值或型別錯誤") + } else { + try { + val v = topTargetValueCell.stringCellValue.trim() + if (v.isEmpty()) { + issues += BomFormatIssue(fileName, "基本資料:『編號』欄位缺值或型別錯誤") + } else { + codeValueOk = true + } + } catch (e: Exception) { + issues += BomFormatIssue( + fileName, + "基本資料:『編號』欄位缺值或型別錯誤(${e.javaClass.simpleName})" + ) + } + } + } + + "產品名稱" -> { + nameHeaderFound = true + val r5Cell = sheet.getRow(4)?.getCell(17) + if (r5Cell == null || r5Cell.cellType == CellType.BLANK) { + issues += BomFormatIssue(fileName, "基本資料:『產品名稱』欄位缺值或型別錯誤") + } else { + try { + val v = when (r5Cell.cellType) { + CellType.STRING -> r5Cell.stringCellValue.trim() + CellType.FORMULA -> if (r5Cell.cachedFormulaResultType == CellType.STRING) r5Cell.stringCellValue.trim() else "" + else -> "" + } + if (v.isEmpty()) { + issues += BomFormatIssue(fileName, "基本資料:『產品名稱』欄位缺值或型別錯誤") + } else { + nameValueOk = true + } + } catch (e: Exception) { + issues += BomFormatIssue( + fileName, + "基本資料:『產品名稱』欄位缺值或型別錯誤(${e.javaClass.simpleName})" + ) + } + } + } + + "種類" -> { + descHeaderFound = true + if (topTargetValueCell == null) { + issues += BomFormatIssue(fileName, "基本資料:『種類』欄位缺值或型別錯誤") + } else { + try { + val v = topTargetValueCell.stringCellValue.trim() + if (v.isEmpty()) { + issues += BomFormatIssue(fileName, "基本資料:『種類』欄位缺值或型別錯誤") + } else { + descValueOk = true + } + } catch (e: Exception) { + issues += BomFormatIssue( + fileName, + "基本資料:『種類』欄位缺值或型別錯誤(${e.javaClass.simpleName})" + ) + } + } + } + + "份量 (Qty)" -> { + qtyHeaderFound = true + if (topTargetValueCell == null) { + issues += BomFormatIssue(fileName, "基本資料:『份量 (Qty)』欄位缺值或型別錯誤") + } else { + try { + // 跟原來一樣,直接 numericCellValue + topTargetValueCell.numericCellValue.toBigDecimal() + qtyValueOk = true + } catch (e: Exception) { + issues += BomFormatIssue( + fileName, + "基本資料:『份量 (Qty)』欄位缺值或型別錯誤(${e.javaClass.simpleName})" + ) + } + } + } + + "單位" -> { + uomHeaderFound = true + if (topTargetValueCell == null) { + issues += BomFormatIssue(fileName, "基本資料:『單位』欄位缺值或型別錯誤") + } else { + try { + val v = topTargetValueCell.stringCellValue.trim() + if (v.isEmpty()) { + issues += BomFormatIssue(fileName, "基本資料:『單位』欄位缺值或型別錯誤") + } else { + uomValueOk = true + } + } catch (e: Exception) { + issues += BomFormatIssue( + fileName, + "基本資料:『單位』欄位缺值或型別錯誤(${e.javaClass.simpleName})" + ) + } + } + } + } + } + + // 這裡 loop 移動邏輯保持和 importExcelBomBasicInfo 一致 + if (startRowIndex < endRowIndex) { + startRowIndex++ + } else if (startColumnIndex < endColumnIndex) { + startColumnIndex++ + startRowIndex = 0 + } + } + + // header 根本沒出現的情況 + if (!codeHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『編號』欄位") + } + if (!nameHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『產品名稱』欄位") + } + if (!qtyHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『份量 (Qty)』欄位") + } + if (!uomHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『單位』欄位") + } + + // 如果你覺得「有 header 但 value 也錯」就夠了,也可以不另外用 ValueOk flag 去做總結 +} +private fun validateMaterialLikeImport( + sheet: Sheet, + fileName: String, + issues: MutableList +) { + var startRowIndex = 10 + val endRowIndex = 30 + var startColumnIndex = 0 + val endColumnIndex = 10 + + var headerFound = false + while (startRowIndex < endRowIndex) { + val tempRow = sheet.getRow(startRowIndex) + val tempCell = tempRow?.getCell(startColumnIndex) + if (tempCell != null && + tempCell.cellType == CellType.STRING && + tempCell.stringCellValue.trim() == "材料編號" + ) { + startRowIndex++ + headerFound = true + break + } + startRowIndex++ + } + + if (!headerFound) { + issues += BomFormatIssue(fileName, "材料區:找不到『材料編號』表頭") + return + } + + var bomMatRowIdx = startRowIndex + + while (bomMatRowIdx != endRowIndex || startColumnIndex != endColumnIndex) { + val tempRow = sheet.getRow(bomMatRowIdx) + val tempCell = tempRow?.getCell(startColumnIndex) + + if (startColumnIndex == 0 && + (tempCell == null || tempCell.cellType == CellType.BLANK) + ) { + break + } + + val rowNum = bomMatRowIdx + 1 + when (startColumnIndex) { + 0 -> { + // 材料編號 — 必填,非空字串 + if (tempCell == null || !isNonEmptyStringCell(tempCell)) { + issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『材料編號』(欄1)不可為空") + } + } + 1 -> { + // 材料 — 必填,非空字串 + if (tempCell == null || !isNonEmptyStringCell(tempCell)) { + issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『材料』(欄2)不可為空") + } + } + 2 -> { + // 使用份量 — 必填,數值 + if (tempCell == null || !isNumericLike(tempCell)) { + issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『使用份量』(欄3)不可為空且須為數值") + } + } + 3 -> { + // 使用單位 — 必填,非空字串 + if (tempCell == null || !isNonEmptyStringCell(tempCell)) { + issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『使用單位』(欄4)不可為空") + } + } + 4 -> { + // 轉用單位份量 — 必填,數值 + if (tempCell == null || !isNumericLike(tempCell)) { + issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『轉用單位份量』(欄5)不可為空且須為數值") + } + } + 5 -> { + // 轉用單位 — 必填,非空字串 + if (tempCell == null || !isNonEmptyStringCell(tempCell)) { + issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『轉用單位』(欄6)不可為空") + } + } + 6 -> { + // 份量(銷售單位) — 必填,數值 + if (tempCell == null || !isNumericLike(tempCell)) { + issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『份量(銷售單位)』(欄7)不可為空且須為數值") + } + } + 7 -> { + // 銷售單位 — 必填,非空字串 + if (tempCell == null || !isNonEmptyStringCell(tempCell)) { + issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『銷售單位』(欄8)不可為空") + } + } + 8 -> { + // 採購單價 — 必填,數值 + if (tempCell == null || !isNumericLike(tempCell)) { + issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『採購單價』(欄9)不可為空且須為數值") + } + } + 9 -> { + // 採購單位 — 必填,非空字串 + if (tempCell == null || !isNonEmptyStringCell(tempCell)) { + issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『採購單位』(欄10)不可為空") + } + } + 10 -> { + // 加入步驟 — 必填,數值 + if (tempCell == null || !isNumericLike(tempCell)) { + issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『加入步驟』(欄11)不可為空且須為數值") + } + } + } + + if (startColumnIndex < endColumnIndex) { + startColumnIndex++ + } else if (bomMatRowIdx < endRowIndex) { + bomMatRowIdx++ + startColumnIndex = 0 + } + } +} + // ===================== 新增:Basic Info 區塊檢查 ===================== + + /** + * 取得 basic info 對應 value 的 cell: + * 1. 先看「同一列右邊一格」 + * 2. 若沒有,再看「下一列同一欄」 + */ + private fun getBasicInfoValueCell( + sheet: Sheet, + rowIdx: Int, + colIdx: Int + ): org.apache.poi.ss.usermodel.Cell? { + val row = sheet.getRow(rowIdx) + val rightCell = row?.getCell(colIdx + 1) + if (rightCell != null && rightCell.cellType != CellType.BLANK) { + return rightCell + } + + val belowRow = sheet.getRow(rowIdx + 1) + val belowCell = belowRow?.getCell(colIdx) + if (belowCell != null && belowCell.cellType != CellType.BLANK) { + return belowCell + } + + return null + } + + /** + * 檢查基本資料區(編號 / 產品名稱 / 份量 / 單位) + * 版面支援:「標題在左、值在右」或「標題在上、值在下」。 + */ + private fun validateBasicInfoSection( + fileName: String, + sheet: Sheet, + issues: MutableList + ) { + val maxRow = 10 + val maxCol = 10 + + var codeHeaderFound = false + var nameHeaderFound = false + var qtyHeaderFound = false + var uomHeaderFound = false + + var codeValueOk = false + var nameValueOk = false + var qtyValueOk = false + var uomValueOk = false + + for (rowIdx in 0 until maxRow) { + val row = sheet.getRow(rowIdx) ?: continue + for (colIdx in 0 until maxCol) { + val cell = row.getCell(colIdx) ?: continue + if (cell.cellType != CellType.STRING) continue + + val text = cell.stringCellValue.trim() + + when (text) { + "編號" -> { + codeHeaderFound = true + val valueCell = getBasicInfoValueCell(sheet, rowIdx, colIdx) + if (valueCell == null || + valueCell.cellType != CellType.STRING || + valueCell.stringCellValue.trim().isEmpty() + ) { + issues += BomFormatIssue(fileName, "基本資料:『編號』欄位缺值或型別錯誤") + } else { + codeValueOk = true + } + } + + "產品名稱" -> { + nameHeaderFound = true + val r5Cell = sheet.getRow(4)?.getCell(17) + if (r5Cell == null || r5Cell.cellType == CellType.BLANK) { + issues += BomFormatIssue(fileName, "基本資料:『產品名稱』欄位缺值或型別錯誤") + } else { + val v = when (r5Cell.cellType) { + CellType.STRING -> r5Cell.stringCellValue.trim() + CellType.FORMULA -> if (r5Cell.cachedFormulaResultType == CellType.STRING) r5Cell.stringCellValue.trim() else "" + else -> "" + } + if (v.isEmpty()) { + issues += BomFormatIssue(fileName, "基本資料:『產品名稱』欄位缺值或型別錯誤") + } else { + nameValueOk = true + } + } + } + + "份量 (Qty)" -> { + qtyHeaderFound = true + val valueCell = getBasicInfoValueCell(sheet, rowIdx, colIdx) + if (valueCell == null || valueCell.cellType != CellType.NUMERIC) { + issues += BomFormatIssue(fileName, "基本資料:『份量 (Qty)』應為數值") + } else { + qtyValueOk = true + } + } + + "單位" -> { + uomHeaderFound = true + val valueCell = getBasicInfoValueCell(sheet, rowIdx, colIdx) + if (valueCell == null || + valueCell.cellType != CellType.STRING || + valueCell.stringCellValue.trim().isEmpty() + ) { + issues += BomFormatIssue(fileName, "基本資料:『單位』欄位缺值或型別錯誤") + } else { + uomValueOk = true + } + } + } + } + } + + // Header 根本不存在的情況 + if (!codeHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『編號』欄位") + } + if (!nameHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『產品名稱』欄位") + } + if (!qtyHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『份量 (Qty)』欄位") + } + if (!uomHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『單位』欄位") + } + + // 若你不想重覆報錯(既有 header,又已經報過 value error),可以視需要加條件判斷 + } + + // ===================== 新增:判斷「看起來像數字」的 helper ===================== + + private fun isNumericLike(cell: org.apache.poi.ss.usermodel.Cell): Boolean { + return when (cell.cellType) { + CellType.NUMERIC -> true + CellType.FORMULA -> + cell.cachedFormulaResultType == CellType.NUMERIC + CellType.STRING -> + cell.stringCellValue.trim().toDoubleOrNull() != null + else -> false + } + } + + // ===================== 新增:材料區檢查 ===================== + + private fun validateMaterialSection( + fileName: String, + sheet: Sheet, + issues: MutableList + ) { + // 找「材料編號」所在列(你模板左下那一段) + var headerRowIdx = -1 + val startCol = 0 + val maxRowSearch = 40 + + for (rowIdx in 0 until maxRowSearch) { + val row = sheet.getRow(rowIdx) ?: continue + val cell = row.getCell(startCol) + if (cell != null && + cell.cellType == CellType.STRING && + cell.stringCellValue.trim().startsWith("材料編號") + ) { + headerRowIdx = rowIdx + break + } + } + + if (headerRowIdx == -1) { + issues += BomFormatIssue(fileName, "材料區:找不到『材料編號』表頭") + return + } + + var rowIdx = headerRowIdx + 1 + val endRowIdx = 200 + + while (rowIdx < endRowIdx) { + val row = sheet.getRow(rowIdx) ?: break + val firstCell = row.getCell(startCol) + + // 第一欄空白視為材料列表結束 + if (firstCell == null || firstCell.cellType == CellType.BLANK) { + break + } + + // col0: 材料編號(字串、必填) + val codeCell = row.getCell(0) + if (codeCell == null || + codeCell.cellType != CellType.STRING || + codeCell.stringCellValue.trim().isEmpty() + ) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowIdx + 1}行『材料編號』(欄 1) 應為非空字串" + ) + } + + // col2: 使用份量(數值、必填)——依照你現有 import 的欄位位置 + val qtyCell = row.getCell(2) + if (qtyCell == null || !isNumericLike(qtyCell)) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowIdx + 1}行『使用份量』(欄 3) 應為數值" + ) + } + + // col3: 使用單位(字串、必填) + val uomCell = row.getCell(3) + if (uomCell == null || + uomCell.cellType != CellType.STRING || + uomCell.stringCellValue.trim().isEmpty() + ) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowIdx + 1}行『使用單位』(欄 4) 應為非空字串" + ) + } + + // col6: 銷售數量(若有值必須是數值) + val saleQtyCell = row.getCell(6) + if (saleQtyCell != null && saleQtyCell.cellType != CellType.BLANK) { + if (!isNumericLike(saleQtyCell)) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowIdx + 1}行『銷售數量』(欄 7) 若有值必須是數值" + ) + } + } + + // col7: 銷售單位(若有值必須是字串) + val salesUnitCell = row.getCell(7) + if (salesUnitCell != null && salesUnitCell.cellType != CellType.BLANK) { + if (salesUnitCell.cellType != CellType.STRING || + salesUnitCell.stringCellValue.trim().isEmpty() + ) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowIdx + 1}行『銷售單位』(欄 8) 若有值必須是非空字串" + ) + } + } + + rowIdx++ + } + } + + // ===================== 新增:工序區檢查 ===================== + + private fun validateProcessSection( + fileName: String, + sheet: Sheet, + issues: MutableList + ) { + var rowIdx = 30 + val maxRowSearch = 100 + var headerRowIdx = -1 + + // 找到「工序」表頭所在列(和你現有 importProcess 的邏輯類似) + while (rowIdx < maxRowSearch) { + val row = sheet.getRow(rowIdx) ?: break + val cell = row.getCell(0) + if (cell != null && + cell.cellType == CellType.STRING && + cell.stringCellValue.trim() == "工序" + ) { + headerRowIdx = rowIdx + break + } + rowIdx++ + } + + if (headerRowIdx == -1) { + issues += BomFormatIssue(fileName, "工序區:找不到『工序』表頭") + return + } + + // 跳過 header + 欄名一行(依你原來 import 是 +2) + rowIdx = headerRowIdx + 2 + val endRowIdx = headerRowIdx + 70 + + while (rowIdx < endRowIdx) { + val row = sheet.getRow(rowIdx) ?: break + val firstCell = row.getCell(0) + + if (firstCell == null || firstCell.cellType == CellType.BLANK) { + break + } + + // col0: 工序次序(數值) + if (!isNumericLike(firstCell)) { + issues += BomFormatIssue( + fileName, + "工序區:第${rowIdx + 1}行『工序次序』(欄 1) 應為數值" + ) + } + + // col1: 工序名稱(字串) + val processNameCell = row.getCell(1) + if (processNameCell == null || + processNameCell.cellType != CellType.STRING || + processNameCell.stringCellValue.trim().isEmpty() + ) { + issues += BomFormatIssue( + fileName, + "工序區:第${rowIdx + 1}行『工序名稱』(欄 2) 應為非空字串" + ) + } + + // col5: 時間(允許 numeric 或 formula 或 string; 這裡只做型別大致檢查) + val durationCell = row.getCell(5) + if (durationCell != null && durationCell.cellType != CellType.BLANK) { + val ok = durationCell.cellType == CellType.NUMERIC || + (durationCell.cellType == CellType.FORMULA && + durationCell.cachedFormulaResultType == CellType.NUMERIC) || + durationCell.cellType == CellType.STRING + if (!ok) { + issues += BomFormatIssue( + fileName, + "工序區:第${rowIdx + 1}行『時間』(欄 6) 只允許數值 / 公式 / 文字格式" + ) + } + } + + rowIdx++ + } + } + } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt index 72b0189..b744d4e 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt @@ -11,15 +11,64 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.multipart.MultipartHttpServletRequest +import org.slf4j.LoggerFactory import java.time.LocalDate - +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.Part +import com.ffii.fpsms.modules.master.web.models.BomFormatCheckResponse +import com.ffii.fpsms.modules.master.web.models.BomUploadResponse +import com.ffii.fpsms.modules.master.web.models.BomFormatCheckRequest +import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload +import java.util.logging.Logger @RestController @RequestMapping("/bom") class BomController ( val bomService: BomService, private val bomRepository: BomRepository, ) { + private val log = LoggerFactory.getLogger(BomController::class.java) + + @PostMapping("/import-bom/upload") + fun uploadBomFiles(request: HttpServletRequest): BomUploadResponse { + + val multipartRequest = request as? MultipartHttpServletRequest + if (multipartRequest != null) { + val files = multipartRequest.getFiles("files").toList().filterNot { it.isEmpty } + if (files.isNotEmpty()) { + log.info("import-bom/upload: using getFiles(\"files\"), count={}", files.size) + return bomService.uploadBomFiles(files) + } + // 先嘗試 Spring 包裝的 MultipartHttpServletRequest(與其他 Controller 一致) + log.info("import-bom/upload: received files count={}", files.size) + files.forEachIndexed { i, f -> + log.info("import-bom/upload: file[{}] originalFilename={}", i, f.originalFilename) + } + } + // 否則用 Servlet getParts()(Postman 等可能不會被包裝成 MultipartHttpServletRequest) + val allParts = try { + request.parts + } catch (e: Exception) { + log.info("import-bom/upload: request.parts failed, content-type may not be multipart: {}", e.message) + return BomUploadResponse(batchId = "", fileNames = emptyList()) + } + val parts = allParts.filter { it.name == "files" } + if (parts.isEmpty()) { + log.info("import-bom/upload: no parts with name \"files\". All part names: {}", allParts.map { it.name }) + return BomUploadResponse(batchId = "", fileNames = emptyList()) + } + log.info("import-bom/upload: using getParts(), count={}", parts.size) + return bomService.uploadBomFilesFromParts(parts) + } + + @PostMapping("/import-bom/format-check") + fun checkBomFormat(@RequestBody request: BomFormatCheckRequest): BomFormatCheckResponse { + return bomService.checkBomExcelFormat(request.batchId) + } @GetMapping fun getBoms(): List { return bomService.findAll() @@ -31,8 +80,8 @@ class BomController ( } @PostMapping("/import-bom") - fun importBom(): ResponseEntity { - val reportResult = bomService.importBOM() + fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity { + val reportResult = bomService.importBOM(payload.batchId, payload.items) val filename = "bom_excel_issue_log_${LocalDate.now()}.xlsx" return ResponseEntity.ok() diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt index 414fe7b..8138fc7 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt @@ -27,4 +27,43 @@ data class ItemUomRequest( val ratioD: BigDecimal?, val ratioN: BigDecimal?, val deleted: Boolean?, +) +data class BomFormatIssue( + val fileName: String, // 檔名 + val problem: String // 問題描述(用來 group 的 key) +) + +// 回傳給前端用:問題 → 檔名列表 +data class BomFormatProblemGroup( + val problem: String, + val fileNames: List +) +data class BomFormatFileGroup( + val fileName: String, + val problems: List +) + +data class BomUploadResponse( + val batchId: String, + val fileNames: List +) + +data class BomFormatCheckRequest( + val batchId: String +) + +/** Format-check 回傳:正確檔名列表 + 失敗列表(檔名 + 問題) */ +data class BomFormatCheckResponse( + val correctFileNames: List, + val failList: List +) + +data class ImportBomItemRequest( + val fileName: String, + val isAlsoWip: Boolean = false +) + +data class ImportBomRequestPayload( + val batchId: String, + val items: List ) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt index 0a4f1de..cd38182 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt @@ -12,5 +12,5 @@ interface ProductProcessRepository : JpaRepository, JpaSpe fun findByProductProcessCodeStartingWith(prefix: String): List fun findByIdAndDeletedIsFalse(id: Long): Optional fun findAllByDeletedIsFalse(): List - + fun findByBom_Id(bomId: Long): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index f4dc8eb..362cd05 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -52,7 +52,7 @@ import com.ffii.fpsms.modules.master.web.models.* import java.math.RoundingMode import java.time.LocalDate import java.time.format.DateTimeFormatter - +import com.ffii.fpsms.modules.stock.entity.StockInLineRepository @Service @Transactional open class ProductProcessService( @@ -78,7 +78,8 @@ open class ProductProcessService( private val joPickOrderRepository: JoPickOrderRepository, private val itemUomRepository: ItemUomRespository, private val uomConversionRepository: UomConversionRepository, - private val itemUomService: ItemUomService + private val itemUomService: ItemUomService, + private val stockInLineRepository: StockInLineRepository ) { open fun findAll(pageable: Pageable): Page { @@ -1446,10 +1447,13 @@ open class ProductProcessService( val jobOrder = jobOrderRepository.findById(response.jobOrderId ?: 0L).orElse(null) val stockInLineStatus = jobOrder?.stockInLines?.firstOrNull()?.status stockInLineStatus != "completed" - && stockInLineStatus != "escalated" + // && stockInLineStatus != "escalated" // 你已經註解掉,會把 escalated 顯示出來 && stockInLineStatus != "rejected" && jobOrder?.status != JobOrderStatus.PLANNING - } + }.sortedWith( + compareByDescending { it.date ?: LocalDate.MIN } + .thenBy { it.productionPriority ?: Int.MAX_VALUE } + ) } open fun updateProductProcessLineStartTime(productProcessLineId: Long): MessageResponse { @@ -1567,20 +1571,36 @@ open class ProductProcessService( jobOrder.status = JobOrderStatus.STORING jobOrderRepository.save(jobOrder) - stockInLineService.create( - SaveStockInLineRequest( - itemId = productProcess?.item?.id ?: 0L, - acceptedQty = jobOrder?.reqQty ?: BigDecimal.ZERO, - productLotNo = jobOrder?.code, - productionDate = LocalDate.now(), - jobOrderId = jobOrder.id, - acceptQty = jobOrder?.reqQty ?: BigDecimal.ZERO, - //acceptQty = null, - expiryDate = null, - status = "pending", + val existingSil = jobOrder.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } + if (existingSil != null) { + stockInLineService.update( + SaveStockInLineRequest( + id = existingSil.id, + itemId = existingSil.item?.id ?: productProcess?.item?.id ?: 0L, + acceptedQty = jobOrder.reqQty ?: BigDecimal.ZERO, + acceptQty = jobOrder.reqQty ?: BigDecimal.ZERO, + productLotNo = jobOrder.code, + productionDate = LocalDate.now(), + jobOrderId = jobOrder.id, + expiryDate = null, + status = "pending", + receiptDate = null, + ) ) - ) - + } else { + stockInLineService.create( + SaveStockInLineRequest( + itemId = productProcess?.item?.id ?: 0L, + acceptedQty = jobOrder?.reqQty ?: BigDecimal.ZERO, + productLotNo = jobOrder?.code, + productionDate = LocalDate.now(), + jobOrderId = jobOrder.id, + acceptQty = jobOrder?.reqQty ?: BigDecimal.ZERO, + expiryDate = null, + status = "pending", + ) + ) + } } } return MessageResponse( @@ -1609,19 +1629,38 @@ open class ProductProcessService( if (jobOrder != null) { jobOrder.status = JobOrderStatus.STORING jobOrderRepository.save(jobOrder) - - stockInLineService.create( - SaveStockInLineRequest( - itemId = productProcess?.item?.id ?: 0L, - acceptedQty = jobOrder?.reqQty ?: BigDecimal.ZERO, - productLotNo = jobOrder?.code, - productionDate = LocalDate.now(), - jobOrderId = jobOrder.id, - acceptQty = jobOrder?.reqQty ?: BigDecimal.ZERO, - expiryDate = null, - status = "", + + val existingSil = jobOrder.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } + if (existingSil != null) { + stockInLineService.update( + SaveStockInLineRequest( + id = existingSil.id, + itemId = existingSil.item?.id ?: productProcess?.item?.id ?: 0L, + acceptedQty = jobOrder.reqQty ?: BigDecimal.ZERO, + acceptQty = jobOrder.reqQty ?: BigDecimal.ZERO, + productLotNo = jobOrder.code, + productionDate = LocalDate.now(), + jobOrderId = jobOrder.id, + expiryDate = null, + status = "pending", + receiptDate = null, + ) ) - ) + } else { + stockInLineService.create( + SaveStockInLineRequest( + itemId = productProcess?.item?.id ?: 0L, + acceptedQty = jobOrder?.reqQty ?: BigDecimal.ZERO, + productLotNo = jobOrder?.code, + productionDate = LocalDate.now(), + jobOrderId = jobOrder.id, + acceptQty = jobOrder?.reqQty ?: BigDecimal.ZERO, + expiryDate = null, + status = "", + ) + ) + } + } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/EscalationLogInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/EscalationLogInfo.kt index 05d85f5..3b07533 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/EscalationLogInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/EscalationLogInfo.kt @@ -12,7 +12,10 @@ interface EscalationLogInfo { @get:Value("#{target.stockInLine?.purchaseOrderLine?.id}") val polId: Long? - + @get:Value("#{target.stockInLine?.jobOrder?.id}") + val jobOrderId: Long? + @get:Value("#{target.stockInLine?.jobOrder?.code}") + val jobOrderCode: String? @get:Value("#{target.stockInLine?.stockIn?.code}") val poCode: String? diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt index 3f9c3c7..dcccea1 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt @@ -55,4 +55,5 @@ fun searchStockInLines( //AND sil.type IS NOT NULL @Query("SELECT sil FROM StockInLine sil WHERE sil.item.id IN :itemIds AND sil.deleted = false") fun findAllByItemIdInAndDeletedFalse(itemIds: List): List +fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): StockInLine? } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index b29de1a..5a0c888 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -210,6 +210,9 @@ open class StockInLineService( this.type = "Nor" status = StockInLineStatus.PENDING.status } + if (jo != null) { + stockInLine.lotNo = assignLotNo() + } val savedSIL = saveAndFlush(stockInLine) if (pol != null) { //logger.info("[create] Stock-in line created with PO, running PO/GRN update for stockInLine id=${savedSIL.id}") @@ -238,7 +241,7 @@ open class StockInLineService( ) val inventoryCount = jdbcDao.queryForInt(INVENTORY_COUNT.toString(), args) // val newLotNo = CodeGenerator.generateCode(prefix = "MPO", itemId = stockInLine.item!!.id!!, count = inventoryCount) - val newLotNo = assignLotNo() + val newLotNo = stockInLine.lotNo ?: assignLotNo() inventoryLot.apply { this.item = stockInLine.item this.stockInLine = stockInLine diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 94fc5fb..2ec9b4c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,6 +27,10 @@ spring: logging: config: 'classpath:log4j2.yml' +bom: + import: + temp-dir: ${java.io.tmpdir}/fpsms-bom-import + m18: config: grant-type: password diff --git a/src/main/resources/db/changelog/changes/20260307_Enson/02_add_isDrink.sql b/src/main/resources/db/changelog/changes/20260307_Enson/02_add_isDrink.sql new file mode 100644 index 0000000..7d10f05 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260307_Enson/02_add_isDrink.sql @@ -0,0 +1,10 @@ +-- liquibase formatted sql +-- changeset Enson:add_isDrink_to_bom + + + + ALTER TABLE `fpsmsdb`.`bom` + ADD COLUMN `isDrink` TINYINT(1) NULL DEFAULT false AFTER `isDense`; + + + \ No newline at end of file