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 8c3064f..8d367c7 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 @@ -1,5 +1,5 @@ package com.ffii.fpsms.modules.master.service - +import com.ffii.core.exception.NotFoundException import com.ffii.fpsms.modules.master.entity.* import com.ffii.fpsms.modules.master.web.models.* import org.apache.poi.ss.usermodel.CellType @@ -773,9 +773,18 @@ open class BomService( tempCellVal.contains("損耗率") -> request.scrapRate = if (leftTargetValueCell?.cellType == CellType.NUMERIC) leftTargetValueCell.numericCellValue.toInt() else 0 - tempCellVal.contains("生產先後次序") -> request.timeSequence = if (leftTargetValueCell?.cellType == CellType.NUMERIC) - leftTargetValueCell.numericCellValue.toInt() - else 0 + tempCellVal.contains("生產時段先後數值") -> { + val timeSeq = if (leftTargetValueCell?.cellType == CellType.NUMERIC) { + leftTargetValueCell.numericCellValue.toInt() + } else { + 0 + } + + println("timeSequence cellType=${leftTargetValueCell?.cellType}, raw=${leftTargetValueCell?.toString()}, value=$timeSeq") + + request.timeSequence = timeSeq + } + tempCellVal.contains("複雜度") -> request.complexity = if (leftTargetValueCell?.cellType == CellType.NUMERIC) leftTargetValueCell.numericCellValue.toInt() else 0 @@ -793,25 +802,51 @@ open class BomService( } private fun generateNextTMPEquipmentCode(): String { - // 查询所有未删除的 EquipmentDetail + // 讀出所有未刪除的 EquipmentDetail val allEquipmentDetails = equipmentDetailRepository.findAllByDeletedFalse() - - // 过滤出以 "TMP" 开头的 equipmentCode,并提取数字部分 - val tmpNumbers = allEquipmentDetails + + // 過濾出符合新格式的 code:3 個大寫字母 + 2 位數字,例如 AAA01 + val indices = allEquipmentDetails .mapNotNull { it.equipmentCode } - .filter { it.startsWith("TMP") } + .map { it.trim() } + .filter { Regex("^[A-Z]{3}\\d{2}$").matches(it) } .mapNotNull { code -> - // 提取 TMP 后面的数字部分(例如:TMP01 -> 1, TMP02 -> 2) - val numberPart = code.substring(3).trim() - numberPart.toIntOrNull() + // 只拿前三個字母轉成序號 + val letters = code.substring(0, 3) + lettersToIndex(letters) } - - // 找到最大编号,如果没有则从 0 开始 - val maxNumber = tmpNumbers.maxOrNull() ?: 0 - - // 递增并格式化为两位数(TMP01, TMP02, ...) - val nextNumber = maxNumber + 1 - return "TMP${nextNumber.toString().padStart(2, '0')}" + + // 找到目前最大的 index,沒有就從 -1 開始,下一個就是 0 -> "AAA" + val maxIndex = indices.maxOrNull() ?: -1 + val nextIndex = maxIndex + 1 + + // 把 index 轉回三個大寫字母(0 -> AAA, 1 -> AAB, ...) + val nextLetters = indexToLetters(nextIndex) + + // 尾巴兩位數固定 "01" + return nextLetters + "01" + } + + // 把 3 個大寫字母轉成序號(AAA=0, AAB=1, ..., ZZZ=17575) + private fun lettersToIndex(letters: String): Int? { + if (letters.length != 3) return null + val chars = letters.toCharArray() + if (chars.any { it !in 'A'..'Z' }) return null + + return ((chars[0] - 'A') * 26 * 26) + + ((chars[1] - 'A') * 26) + + (chars[2] - 'A') + } + + // 把序號轉回 3 個大寫字母(0 -> AAA, 1 -> AAB, ...) + private fun indexToLetters(index: Int): String { + val i = if (index < 0) 0 else index + + val c3 = ('A'.code + (i % 26)).toChar() + val c2 = ('A'.code + ((i / 26) % 26)).toChar() + val c1 = ('A'.code + ((i / (26 * 26)) % 26)).toChar() + + return String(charArrayOf(c1, c2, c3)) } private fun bomGetOrCreateEquipment(equipmentName: String): Equipment { var equipment = equipmentRepository.findByNameAndDeletedIsFalse(equipmentName) @@ -1156,22 +1191,25 @@ open class BomService( 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) + //val logExcel = ClassPathResource("excelTemplate/bom_excel_issue_log.xlsx") + //val templateInputStream = logExcel.inputStream + //val workbook: Workbook = XSSFWorkbook(templateInputStream) + //val logSheet: Sheet = workbook.getSheetAt(0) 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 + val isAlsoWip = items.find { it.fileName == filename }?.isAlsoWip == true + val isDrink= items.find { it.fileName == filename }?.isDrink == 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 sheet = workbook2.getSheet("食物成品 ") + ?: workbook2.getSheet("食物成品") + ?: workbook2.getSheetAt(0) val code = readBomCodeFromSheet(sheet) var oldBomId: Long? = null code?.let { c -> @@ -1181,6 +1219,8 @@ open class BomService( } } val bom = importExcelBomBasicInfo(sheet) + bom.isDrink = isDrink + bomRepository.saveAndFlush(bom) importExcelBomProcess(bom, sheet) importExcelBomMaterial(bom, sheet) if (isAlsoWip) { @@ -1196,8 +1236,8 @@ open class BomService( } printBomMaterialImportIssues() val outputStream = ByteArrayOutputStream() - workbook.write(outputStream) - workbook.close() + //workbook.write(outputStream) + //workbook.close() try { Files.walk(batchDir).use { stream -> stream.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } } } catch (_: Exception) { } @@ -1230,6 +1270,7 @@ open class BomService( complexity = fgBom.complexity allergicSubstances = fgBom.allergicSubstances uom = fgBom.uom + isDrink = fgBom.isDrink } wipBom.baseScore = calculateBaseScore(wipBom) bomRepository.saveAndFlush(wipBom) @@ -1252,6 +1293,7 @@ open class BomService( complexity = fgBom.complexity allergicSubstances = fgBom.allergicSubstances uom = fgBom.uom + isDrink = fgBom.isDrink } wipBom.baseScore = calculateBaseScore(wipBom) bomRepository.saveAndFlush(wipBom) @@ -1351,6 +1393,11 @@ open class BomService( println("========================================================\n") } + fun loadBomFormatIssueLog(batchId: String, issueLogFileId: String): Path? { + val dir = getBatchDir(batchId) + val path = dir.resolve("bom_format_issue_$issueLogFileId.xlsx") + return if (Files.exists(path)) path else null + } open fun checkBomExcelFormat(batchId: String): BomFormatCheckResponse { val batchDir = getBatchDir(batchId) if (!Files.isDirectory(batchDir)) return BomFormatCheckResponse(correctFileNames = emptyList(), failList = emptyList()) @@ -1361,8 +1408,10 @@ open class BomService( try { FileInputStream(path.toFile()).use { input -> val workbook: Workbook = XSSFWorkbook(input) - val sheet: Sheet = workbook.getSheetAt(0) - validateBasicInfoLikeImport(sheet, fileName, issues) + val sheet = workbook.getSheet("食物成品 ") + ?: workbook.getSheet("食物成品") + ?: workbook.getSheetAt(0) // 這裡不需要再接 elvis,因為不會是 null + validateBasicInfoSection(fileName, sheet, issues) validateProcessLikeImport(sheet, fileName, issues) validateMaterialLikeImport(sheet, fileName, issues) workbook.close() @@ -1379,6 +1428,7 @@ open class BomService( } } } + val allFileNames = Files.list(batchDir).use { stream -> stream .filter { p -> p.toString().lowercase().endsWith(".xlsx") } @@ -1386,8 +1436,7 @@ open class BomService( .sorted() .toList() } - - // 2) 先把 issues 做成 failList(跟你现在一样) + val failList = issues .groupBy { it.fileName } .map { (fileName, list) -> @@ -1397,22 +1446,46 @@ open class BomService( ) } .sortedBy { it.fileName } - - // 3) 正确档名 = 全部档名 - 有 issues 的档名 + + val logWb: Workbook = XSSFWorkbook() + val logSheet: Sheet = logWb.createSheet("Issues") + + // 建一行表頭 + val header = logSheet.createRow(0) + header.createCell(0).setCellValue("File Name") + header.createCell(1).setCellValue("Problem") + + // 從第 2 列開始寫問題 + var rowIdx = 1 + issues.sortedWith(compareBy({ it.fileName }, { it.problem })).forEach { issue -> + val row = logSheet.createRow(rowIdx) + row.createCell(0).setCellValue(issue.fileName) + row.createCell(1).setCellValue(issue.problem) + rowIdx++ + } + + val issueLogId = UUID.randomUUID().toString() + println("issueLogId: $issueLogId") + val outPath = batchDir.resolve("bom_format_issue_$issueLogId.xlsx") + Files.newOutputStream(outPath).use { os -> logWb.write(os) } + logWb.close() + val filesWithIssues = issues.asSequence().map { it.fileName }.toSet() val correctFileNames = allFileNames.filter { it !in filesWithIssues } - - return BomFormatCheckResponse(correctFileNames = correctFileNames, failList = failList) + + return BomFormatCheckResponse( + correctFileNames = correctFileNames, + failList = failList, + issueLogFileId = issueLogId + ) } 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() + if (trimmed.contains(",")) return false // 新增:不允許逗號 + val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機 + return regex.matches(trimmed) } /** Cell 是否為非空字串(含 STRING / FORMULA 結果為字串) */ @@ -1432,6 +1505,9 @@ open class BomService( fileName: String, issues: MutableList ) { + val byProductItemCache = mutableMapOf() + val uomCache = mutableMapOf() + val itemStockUomCache = mutableMapOf() var startRowIndex = 30 var endRowIndex = 70 var startColumnIndex = 0 @@ -1509,7 +1585,10 @@ open class BomService( if (str.isEmpty()) { issues += BomFormatIssue(fileName, "工序區:第${rowNum}行『使用設備』不可為空") } else if (!isValidEquipmentType(str)) { - issues += BomFormatIssue(fileName, "工序區:第${rowNum}行『使用設備』格式須為 不合用、不適用 或 XXXXX-YYYY") + issues += BomFormatIssue( + fileName, + "工序區:第${rowNum}行『使用設備』格式須為 不合用、不適用 或 XXX-YYY(只允許一個 '-',不含 '/')" + ) } } 5 -> { @@ -1519,187 +1598,144 @@ open class BomService( } } // 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 + 8 -> { + val rowNum = startRowIndex + 1 + + // 1) 如果 cell 為 null 或 BLANK,直接當作空,完全不查 DB + if (tempCell == null || tempCell.cellType == CellType.BLANK) { + // 允許空,不新增 issue,不查 DB + } else if (tempCell.cellType == CellType.STRING) { + // 2) 只有 STRING 類型才處理,FORMULA 一律當作沒填,避免奇怪情況 + val fullName = tempCell.stringCellValue.trim() + + // 3) 去掉前後空白後,長度不足 6 直接略過,不查 DB + if (fullName.length >= 6) { + val byProductItemCode = fullName.take(6) + + // 4) 先查快取,避免重複 hit DB + val exists = byProductItemCache.getOrPut(byProductItemCode) { + val item = itemsRepository.findByCodeAndDeletedFalse(byProductItemCode) + item != null + } + + if (!exists) { + issues += BomFormatIssue( + fileName, + "工序區:第${rowNum}行『副產品名稱』($fullName) 對應品項($byProductItemCode) 在 Items 資料表找不到" + ) + } } - } catch (e: Exception) { - issues += BomFormatIssue( - fileName, - "基本資料:『編號』欄位缺值或型別錯誤(${e.javaClass.simpleName})" - ) + // fullName.length < 6 就當作「沒填有效副產品」,不檢查、不報錯 + } else { + // 其他型別 (NUMERIC / FORMULA 等) 先全部當作「沒填」,不檢查 } } - } - - "產品名稱" -> { - 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, "基本資料:『產品名稱』欄位缺值或型別錯誤") + 10 -> { + val rowNum = startRowIndex + 1 + + // 1) 副產品單位這格本身為 null / BLANK:當作沒填,直接跳過 + if (tempCell == null || tempCell.cellType == CellType.BLANK) { + // 允許空,不查 DB + } else if (tempCell.cellType == CellType.STRING) { + val uomCode = tempCell.stringCellValue.trim() + + + + // 2) 從同一列第 8 欄拿副產品名稱,推 itemCode(前 6 碼) + val byProductNameCell = tempRow?.getCell(8) + val byProductName = if ( + byProductNameCell != null && + byProductNameCell.cellType == CellType.STRING + ) { + byProductNameCell.stringCellValue.trim() } 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, "基本資料:『種類』欄位缺值或型別錯誤") + val byProductItemCode = + if (byProductName.length >= 6) byProductName.take(6) else "" + + // 如果連 itemCode 都湊不出來,就只檢查 UOM 是否存在,不做 stock unit 檢查 + val itemExists: Boolean + val itemId: Long? + + if (byProductItemCode.isNotEmpty()) { + itemExists = byProductItemCache.getOrPut(byProductItemCode) { + val item = itemsRepository.findByCodeAndDeletedFalse(byProductItemCode) + if (item != null) { + itemStockUomCache[item.id!!] = null // 先佔一個 key,等會兒查 stockUom + true + } else { + false + } + } + + itemId = if (itemExists) { + // 再查一次拿 itemId(也可以把整個 item 快取起來,看你現有結構) + itemsRepository.findByCodeAndDeletedFalse(byProductItemCode)?.id + } else null + + if (!itemExists) { + issues += BomFormatIssue( + fileName, + "工序區:第${rowNum}行 副產品品項($byProductItemCode) 在 Items 資料表找不到" + ) + } } else { - descValueOk = true + itemExists = false + itemId = null } - } 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 + + // 3) 檢查 UOM 是否存在(用快取) + val uomExists = uomCache.getOrPut(uomCode) { + uomConversionRepository.findByCodeAndDeletedFalse(uomCode) != null } - } catch (e: Exception) { - issues += BomFormatIssue( - fileName, - "基本資料:『單位』欄位缺值或型別錯誤(${e.javaClass.simpleName})" - ) + if (!uomExists) { + issues += BomFormatIssue( + fileName, + "工序區:第${rowNum}行『副產品單位』($uomCode) 在 UOM 資料表找不到" + ) + } + + // 4) 如果 item 與 uom 都存在,再做「是不是 stock unit」檢查 + if (itemId != null && uomExists) { + val stockUomId = itemStockUomCache.getOrPut(itemId) { + val stockItemUom = itemUomService.findStockUnitByItemId(itemId) + stockItemUom?.uom?.id // 可能為 null + } + + if (stockUomId == null) { + issues += BomFormatIssue( + fileName, + "工序區:第${rowNum}行 副產品品項($byProductItemCode) 未設定 Stock Unit" + ) + } else { + // 再查一次 uom 拿 id 比對(也可以在上面快取 uomId) + val uom = uomConversionRepository.findByCodeAndDeletedFalse(uomCode) + if (uom == null || uom.id != stockUomId) { + issues += BomFormatIssue( + fileName, + "工序區:第${rowNum}行 副產品單位($uomCode) 並非品項($byProductItemCode) 的 Stock Unit" + ) + } + } + } + } else { + // 非 STRING(NUMERIC / FORMULA 等)暫時都當作沒填,避免怪型別拖慢 } } + else -> { } } } + + if (startColumnIndex < endColumnIndex) { + startColumnIndex++ + } else if (startRowIndex < endRowIndex) { + startRowIndex++ + startColumnIndex = 0 + } } - - // 這裡 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, @@ -1811,7 +1847,108 @@ private fun validateMaterialLikeImport( } } } - + if (startColumnIndex == endColumnIndex) { + // 這一列所有欄位型別都檢查完後,做 DB / 轉換檢查 + + val codeCell = tempRow?.getCell(0) + val uomCell = tempRow?.getCell(3) + val saleQtyCell = tempRow?.getCell(6) + val salesUnitCell = tempRow?.getCell(7) + + val rowNum = bomMatRowIdx + 1 + + // 1) Item 是否存在 + val itemCode = codeCell?.stringCellValue?.trim().orEmpty() + if (itemCode.isNotEmpty()) { + val item = itemsRepository.findByCodeAndDeletedFalse(itemCode) + if (item == null) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowNum}行『材料編號』($itemCode) 在 Items 資料表找不到" + ) + } + } + + // 2) 使用單位 UOM 是否存在 + val useUomCode = uomCell?.stringCellValue?.trim().orEmpty() + if (useUomCode.isNotEmpty()) { + val useUom = uomConversionRepository.findByCodeAndDeletedFalse(useUomCode) + if (useUom == null) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowNum}行『使用單位』($useUomCode) 在 UOM 資料表找不到" + ) + } + } + + // 3) 銷售單位 UOM 是否存在,以及轉換是否可行(對應 saveBomMaterial 的邏輯) + val saleQty = when { + saleQtyCell == null || !isNumericLike(saleQtyCell) -> null + saleQtyCell.cellType == CellType.NUMERIC -> + saleQtyCell.numericCellValue.toBigDecimal() + saleQtyCell.cellType == CellType.FORMULA && + saleQtyCell.cachedFormulaResultType == CellType.NUMERIC -> + saleQtyCell.numericCellValue.toBigDecimal() + else -> saleQtyCell.stringCellValue.trim().toBigDecimalOrNull() + } + val salesUnitCode = salesUnitCell?.stringCellValue?.trim().orEmpty() + + if (itemCode.isNotEmpty() && saleQty != null && salesUnitCode.isNotEmpty()) { + val item = itemsRepository.findByCodeAndDeletedFalse(itemCode) + val salesUnit = uomConversionRepository.findByCodeAndDeletedFalse(salesUnitCode) + + if (item == null) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowNum}行 Item($itemCode) 不存在,無法檢查 UOM 轉換" + ) + } else if (salesUnit == null) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowNum}行 銷售單位($salesUnitCode) 在 UOM 資料表找不到" + ) + } else { + // 模擬 saveBomMaterial 的轉換檢查(只做 dry-run,不存資料) + try { + val saleItemUom = itemUomService.findSalesUnitByItemId(item.id!!) + val itemSaleUnit = saleItemUom?.uom + if (itemSaleUnit != null && salesUnit.id != itemSaleUnit.id) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowNum}行 Excel 銷售單位(${salesUnit.code}) 與品項銷售單位(${itemSaleUnit.code}) 不一致" + ) + } + val baseItemUom = itemUomService.findBaseUnitByItemId(item.id!!) + if (baseItemUom == null) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowNum}行 Item($itemCode) 未設定 Base Unit,無法由銷售單位轉換" + ) + } else { + itemUomService.convertUomByItem( + ConvertUomByItemRequest( + itemId = item.id!!, + qty = saleQty, + uomId = salesUnit.id!!, + targetUnit = "baseUnit" + ) + ) + // 若呼叫成功,視為 OK;若拋例外,catch 起來記問題 + } + } catch (e: IllegalArgumentException) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowNum}行 由銷售單位轉換 Base Unit 失敗:${e.message ?: "IllegalArgumentException"}" + ) + } catch (e: Exception) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowNum}行 由銷售單位轉換 Base Unit 發生錯誤:${e.javaClass.simpleName} - ${e.message ?: "Unknown error"}" + ) + } + } + } + } if (startColumnIndex < endColumnIndex) { startColumnIndex++ } else if (bomMatRowIdx < endRowIndex) { @@ -1851,107 +1988,336 @@ private fun validateMaterialLikeImport( * 檢查基本資料區(編號 / 產品名稱 / 份量 / 單位) * 版面支援:「標題在左、值在右」或「標題在上、值在下」。 */ - 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() - ) { + private fun validateBasicInfoSection( + fileName: String, + sheet: Sheet, + issues: MutableList +) { + // 留一點 buffer,把 A11 甚至再下面幾行都掃進來 + val maxRow = 15 + val maxCol = 15 + + var codeHeaderFound = false + var nameHeaderFound = false + var qtyHeaderFound = false + var uomHeaderFound = false + var timeSeqHeaderFound = false + var complexityHeaderFound = false + var AllergicSubstancesHeaderFound = false + var ColorDepthHeaderFound = false + var FloatingHeaderFound = false + var ConcentrationHeaderFound = false + + var codeValueOk = false + var nameValueOk = false + var qtyValueOk = false + var uomValueOk = false + var timeSeqValueOk = false + var complexityValueOk = false + var AllergicSubstancesValueOk = false + var ColorDepthValueOk = false + var FloatingValueOk = false + var ConcentrationValueOk = false + println("=== Debug sheet content for $fileName ===") +for (r in 0..20) { + val row = sheet.getRow(r) ?: continue + for (c in 0..20) { + val cell = row.getCell(c) ?: continue + val value = when { + cell.cellType == CellType.STRING -> "'${cell.stringCellValue}'" + cell.cellType == CellType.NUMERIC -> cell.numericCellValue.toString() + cell.cellType == CellType.FORMULA -> { + val cached = when (cell.cachedFormulaResultType) { + CellType.STRING -> "'${cell.stringCellValue}'" + CellType.NUMERIC -> cell.numericCellValue.toString() + else -> "?" + } + "FORMULA -> $cached" + } + else -> cell.cellType.toString() + } + if (value.isNotBlank() && value != "BLANK") { + println("($r, $c) = $value") + } + } +} +println("=====================================") + 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 codeCell = sheet.getRow(1)?.getCell(0) + if (codeCell == null || codeCell.cellType == CellType.BLANK) { + issues += BomFormatIssue(fileName, "基本資料:『編號』欄位缺值或型別錯誤") + } else { + val v = when (codeCell.cellType) { + CellType.STRING -> codeCell.stringCellValue.trim() + CellType.FORMULA -> + if (codeCell.cachedFormulaResultType == CellType.STRING) + codeCell.stringCellValue.trim() + else "" + + else -> "" + } + if (v.isEmpty()) { issues += BomFormatIssue(fileName, "基本資料:『編號』欄位缺值或型別錯誤") } else { codeValueOk = true } } - - "產品名稱" -> { - nameHeaderFound = true - val r5Cell = sheet.getRow(4)?.getCell(17) - if (r5Cell == null || r5Cell.cellType == CellType.BLANK) { + } + + text == "產品名稱" -> { + 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 { - 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, "基本資料:『產品名稱』欄位缺值或型別錯誤") + nameValueOk = true + } + } + } + + text == "份量 (Qty)" -> { + qtyHeaderFound = true + val valueCell = getBasicInfoValueCell(sheet, rowIdx, colIdx) + if (valueCell == null || valueCell.cellType != CellType.NUMERIC) { + issues += BomFormatIssue(fileName, "基本資料:『份量 (Qty)』應為數值") + } else { + qtyValueOk = true + } + } + + text == "單位" -> { + uomHeaderFound = true + val uomCell = sheet.getRow(3)?.getCell(6) + if (uomCell == null || uomCell.cellType == CellType.BLANK) { + issues += BomFormatIssue(fileName, "基本資料:『單位』欄位缺值或型別錯誤") + } else { + val uomCode = when (uomCell.cellType) { + CellType.STRING -> uomCell.stringCellValue.trim() + CellType.FORMULA -> + if (uomCell.cachedFormulaResultType == CellType.STRING) + uomCell.stringCellValue.trim() + else "" + + else -> "" + } + if (uomCode.isEmpty()) { + issues += BomFormatIssue(fileName, "基本資料:『單位』欄位缺值或型別錯誤") + } else { + val uom = uomConversionRepository.findByCodeAndDeletedFalse(uomCode) + if (uom == null) { + issues += BomFormatIssue(fileName, "基本資料:『單位』($uomCode) 在 UOM 資料表中找不到") } else { - nameValueOk = true + uomValueOk = true } } } - - "份量 (Qty)" -> { - qtyHeaderFound = true - val valueCell = getBasicInfoValueCell(sheet, rowIdx, colIdx) - if (valueCell == null || valueCell.cellType != CellType.NUMERIC) { - issues += BomFormatIssue(fileName, "基本資料:『份量 (Qty)』應為數值") + } + text == "種類" -> { + val cell = sheet.getRow(1)?.getCell(9) // J2: row index=1, col index=9 + val value = when { + cell == null || cell.cellType == CellType.BLANK -> "" + cell.cellType == CellType.STRING -> cell.stringCellValue.trim() + cell.cellType == CellType.FORMULA && + cell.cachedFormulaResultType == CellType.STRING -> + cell.stringCellValue.trim() + else -> "" + } + if (value.isEmpty() || (value != "FG" && value != "WIP")) { + issues += BomFormatIssue(fileName, "基本資料:『種類』必須為 FG 或 WIP") + } + } + text.contains("過敏原") -> { + AllergicSubstancesHeaderFound = true + val valueCell = getBasicInfoValueCell(sheet, rowIdx, colIdx) + if (valueCell == null || valueCell.cellType == CellType.BLANK) { + // 允許空值,當作 0 交給 import 時處理,不報錯 + } else if (!isNumericLike(valueCell)) { + issues += BomFormatIssue(fileName, "基本資料:『過敏原』應為數值 0 或 5") + } else { + val v = when (valueCell.cellType) { + CellType.NUMERIC -> valueCell.numericCellValue.toInt() + CellType.FORMULA -> valueCell.numericCellValue.toInt() + CellType.STRING -> valueCell.stringCellValue.trim().toIntOrNull() ?: Int.MIN_VALUE + else -> Int.MIN_VALUE + } + if (v !in listOf(0, 5)) { + issues += BomFormatIssue(fileName, "基本資料:『過敏原』應為數值 0 或 5") } else { - qtyValueOk = true + AllergicSubstancesValueOk = true } } - - "單位" -> { - uomHeaderFound = true - val valueCell = getBasicInfoValueCell(sheet, rowIdx, colIdx) - if (valueCell == null || - valueCell.cellType != CellType.STRING || - valueCell.stringCellValue.trim().isEmpty() - ) { - issues += BomFormatIssue(fileName, "基本資料:『單位』欄位缺值或型別錯誤") + } + + text.contains("顔色深淺度") -> { + ColorDepthHeaderFound = true + val valueCell = getBasicInfoValueCell(sheet, rowIdx, colIdx) + if (valueCell == null || valueCell.cellType == CellType.BLANK) { + // 允許空 + } else if (!isNumericLike(valueCell)) { + issues += BomFormatIssue(fileName, "基本資料:『顔色深淺度』必須為 1~5 的數值") + } else { + val v = when (valueCell.cellType) { + CellType.NUMERIC -> valueCell.numericCellValue.toInt() + CellType.FORMULA -> valueCell.numericCellValue.toInt() + CellType.STRING -> valueCell.stringCellValue.trim().toIntOrNull() ?: Int.MIN_VALUE + else -> Int.MIN_VALUE + } + if (v !in 1..5) { + issues += BomFormatIssue(fileName, "基本資料:『顔色深淺度』必須為 1~5 的數值") } else { - uomValueOk = true + ColorDepthValueOk = true + } + } + } + + + text.contains("浮沉") -> { + FloatingHeaderFound = true + val valueCell = getBasicInfoValueCell(sheet, rowIdx, colIdx) + if (valueCell == null || valueCell.cellType == CellType.BLANK) { + // 允許空 + } else if (!isNumericLike(valueCell)) { + issues += BomFormatIssue(fileName, "基本資料:『浮沉』必須為 0、3 或 5") + } else { + val v = when (valueCell.cellType) { + CellType.NUMERIC -> valueCell.numericCellValue.toInt() + CellType.FORMULA -> valueCell.numericCellValue.toInt() + CellType.STRING -> valueCell.stringCellValue.trim().toIntOrNull() ?: Int.MIN_VALUE + else -> Int.MIN_VALUE + } + if (v !in listOf(0, 3, 5)) { + issues += BomFormatIssue(fileName, "基本資料:『浮沉』必須為 0、3 或 5") + } else { + FloatingValueOk = true + } + } + } + + text.contains("濃淡程度") -> { + ConcentrationHeaderFound = true + val valueCell = getBasicInfoValueCell(sheet, rowIdx, colIdx) + if (valueCell == null || valueCell.cellType == CellType.BLANK) { + // 允許空 + } else if (!isNumericLike(valueCell)) { + issues += BomFormatIssue(fileName, "基本資料:『濃淡程度』必須為 0、3 或 5") + } else { + val v = when (valueCell.cellType) { + CellType.NUMERIC -> valueCell.numericCellValue.toInt() + CellType.FORMULA -> valueCell.numericCellValue.toInt() + CellType.STRING -> valueCell.stringCellValue.trim().toIntOrNull() ?: Int.MIN_VALUE + else -> Int.MIN_VALUE + } + if (v !in listOf(0, 3, 5)) { + issues += BomFormatIssue(fileName, "基本資料:『濃淡程度』必須為 0、3 或 5") + } else { + ConcentrationValueOk = true + } + } + } + // 生產時段先後數值:文字可能有多一點說明,用 contains 比較保險 + text.contains("先後") -> { + timeSeqHeaderFound = true + val valueCell = getBasicInfoValueCell(sheet, rowIdx, colIdx) + if (valueCell == null || valueCell.cellType == CellType.BLANK) { + // 允許空 + } else if (!isNumericLike(valueCell)) { + issues += BomFormatIssue(fileName, "基本資料:『生產時段先後數值』必須為 0、1 或 5") + } else { + val v = when (valueCell.cellType) { + CellType.NUMERIC -> valueCell.numericCellValue.toInt() + CellType.FORMULA -> valueCell.numericCellValue.toInt() + CellType.STRING -> valueCell.stringCellValue.trim().toIntOrNull() ?: Int.MIN_VALUE + else -> Int.MIN_VALUE + } + if (v !in listOf(0, 1, 5)) { + issues += BomFormatIssue(fileName, "基本資料:『生產時段先後數值』必須為 0、1 或 5") + } else { + timeSeqValueOk = true + } + } + } + + // 生產複雜度:同樣用 contains,位置可能在 A11 + text.contains("生產複雜度") -> { + complexityHeaderFound = true + val valueCell = getBasicInfoValueCell(sheet, rowIdx, colIdx) + if (valueCell == null || valueCell.cellType == CellType.BLANK) { + // 允許空 + } else if (!isNumericLike(valueCell)) { + issues += BomFormatIssue(fileName, "基本資料:『生產複雜度』必須為 0、3、5 或 10") + } else { + val v = when (valueCell.cellType) { + CellType.NUMERIC -> valueCell.numericCellValue.toInt() + CellType.FORMULA -> valueCell.numericCellValue.toInt() + CellType.STRING -> valueCell.stringCellValue.trim().toIntOrNull() ?: Int.MIN_VALUE + else -> Int.MIN_VALUE + } + if (v !in listOf(0, 3, 5, 10)) { + issues += BomFormatIssue(fileName, "基本資料:『生產複雜度』必須為 0、3、5 或 10") + } else { + complexityValueOk = 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),可以視需要加條件判斷 } + + if (!codeHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『編號』欄位") + } + if (!nameHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『產品名稱』欄位") + } + if (!qtyHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『份量 (Qty)』欄位") + } + if (!uomHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『單位』欄位") + } + if (!AllergicSubstancesHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『過敏原』欄位") + } + if (!ColorDepthHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『顔色深淺度』欄位") + } + if (!FloatingHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『浮沉』欄位") + } + if (!ConcentrationHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『濃淡程度』欄位") + } + if (!timeSeqHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『生產時段先後數值』欄位") + } + if (!complexityHeaderFound) { + issues += BomFormatIssue(fileName, "基本資料:找不到『生產複雜度』欄位") + } +} + // ===================== 新增:判斷「看起來像數字」的 helper ===================== @@ -2148,5 +2514,59 @@ private fun validateMaterialLikeImport( rowIdx++ } } + open fun getBomDetail(id: Long): BomDetailResponse { + // 1) 正確處理找不到 BOM 的情況(用 NotFoundException 或你專案慣例) + val bom = bomRepository.findByIdAndDeletedIsFalse(id) + ?: throw NotFoundException() + + // 2) 不要用不存在的 deleted 欄位過濾,直接 map 即可 + val materials = bom.bomMaterials + .map { m -> + BomMaterialDto( + itemCode = m.item?.code, + itemName = m.item?.name ?: m.itemName, + baseQty = m.baseQty, + baseUom = m.baseUnitName, + stockQty = m.stockQty, + stockUom = m.stockUnitName, + salesQty = m.saleQty, + salesUom = m.salesUnitCode + ) + } + val processes = bom.bomProcesses + .sortedBy { it.seqNo } + .map { p -> + BomProcessDto( + seqNo = p.seqNo, + processName = p.process?.name, + processDescription = p.description, + equipmentName = p.equipment?.name, + durationInMinute = p.durationInMinute, + prepTimeInMinute = p.prepTimeInMinute, + postProdTimeInMinute = p.postProdTimeInMinute + ) + } + + // 3) 其餘維持不變即可 + return BomDetailResponse( + id = bom.id!!, + itemCode = bom.item?.code, + itemName = bom.item?.name, + isDark = (bom.isDark ?: 0) != 0, + isFloat = bom.isFloat, + isDense = bom.isDense, + isDrink = bom.isDrink, + scrapRate = bom.scrapRate, + allergicSubstances = bom.allergicSubstances, + timeSequence = bom.timeSequence, + complexity = bom.complexity, + baseScore = bom.baseScore?.toInt(), + description = bom.description, + outputQty = bom.outputQty, + outputQtyUom = bom.outputQtyUom, + materials = materials, + processes = processes + ) + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/QcItemAllService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/QcItemAllService.kt index b6b2e4d..e9cfc5c 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/QcItemAllService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/QcItemAllService.kt @@ -7,7 +7,7 @@ import com.ffii.fpsms.modules.qc.entity.projection.QcItemInfo import jakarta.validation.Valid import org.springframework.stereotype.Service import org.springframework.web.bind.annotation.RequestBody - +import com.ffii.core.exception.ConflictException @Service open class QcItemAllService( private val qcCategoryRepository: QcCategoryRepository, @@ -48,6 +48,75 @@ open class QcItemAllService( ) } } + // Get all qc categories with item counts and type (type from first mapping if any) + open fun getAllQcCategoriesWithItemCountsAndType(): List { + val categories = qcCategoryRepository.findAllByDeletedIsFalse() + val allMappings = itemsQcCategoryMappingRepository.findAll() + val countMap = allMappings + .filter { it.qcCategoryId != null } + .groupBy { it.qcCategoryId!! } + .mapValues { it.value.size.toLong() } + val typeMap = allMappings + .filter { it.qcCategoryId != null && it.type != null } + .groupBy { it.qcCategoryId!! } + .mapValues { it.value.first().type } + return categories.map { category -> + val rawType = typeMap[category.id] + val normalizedType = if (rawType == "[object Object]") null else rawType + QcCategoryWithItemCountAndType( + id = category.id!!, + code = category.code, + name = category.name, + description = category.description, + itemCount = countMap[category.id] ?: 0L, + type = normalizedType + ) + } + } + + // Get type for one category (from any mapping; null if no mappings). For 映射詳情 dropdown default. + open fun getCategoryType(qcCategoryId: Long): String? { + val list = itemsQcCategoryMappingRepository.findAllByQcCategoryId(qcCategoryId) + val rawType = list.firstOrNull()?.type + return if (rawType == "[object Object]") null else rawType + } + + // Batch update type for all mappings of a category. 方案 A: no mappings -> no-op. + open fun updateCategoryType(qcCategoryId: Long, type: String) { + val mappings = itemsQcCategoryMappingRepository.findAllByQcCategoryId(qcCategoryId) + if (mappings.isEmpty()) return // 方案 A:沒有映射就直接結束 + + // 先取得目前這個 Category,用來組錯誤訊息 + val currentCategory = qcCategoryRepository.findById(qcCategoryId).orElse(null) + val currentCategoryName = currentCategory?.name ?: currentCategory?.code ?: qcCategoryId.toString() + + // 檢查:這些 itemId + 新 type 在別的 category 有沒有出現 + mappings.forEach { mapping -> + val itemId = mapping.itemId ?: return@forEach + + // 找出「同一個 item + type」的其他映射 + val conflict = itemsQcCategoryMappingRepository.findByItemIdAndType(itemId, type) + if (conflict != null && conflict.qcCategoryId != qcCategoryId) { + // 取出物料與衝突的 category 名稱 + val item = itemsRepository.findById(itemId).orElse(null) + val itemCode = item?.code ?: itemId.toString() + + val conflictCategoryId = conflict.qcCategoryId + val conflictCategory = conflictCategoryId?.let { qcCategoryRepository.findById(it).orElse(null) } + val conflictCategoryName = + conflictCategory?.name ?: conflictCategory?.code ?: conflictCategoryId.toString() + + // 用 ConflictException 讓 ErrorHandler 回 409 + { "error": msg } + val msg = "物料 $itemCode 已經以 $type 映射在模板 $conflictCategoryName," + + "不能再把模板 $currentCategoryName 改為 $type" + throw ConflictException(msg) + } + } + + // 檢查通過才真正更新 + mappings.forEach { it.type = type } + itemsQcCategoryMappingRepository.saveAll(mappings) + } // Get all qc categories with qc item counts (batch operation for performance) open fun getAllQcCategoriesWithQcItemCounts(): List { @@ -109,6 +178,13 @@ open class QcItemAllService( open fun saveItemQcCategoryMapping(itemId: Long, qcCategoryId: Long, type: String): ItemQcCategoryMappingInfo { // Check if mapping already exists val existing = itemsQcCategoryMappingRepository.findByItemIdAndQcCategoryIdAndType(itemId, qcCategoryId, type) + val existingByItem = itemsQcCategoryMappingRepository.findAllByItemId(itemId) + val sameTypeOtherCategory = existingByItem.firstOrNull { it.type == type && it.qcCategoryId != qcCategoryId } + if (sameTypeOtherCategory != null) { + val category = qcCategoryRepository.findById(sameTypeOtherCategory.qcCategoryId!!).orElse(null) + val msg = "Item already has type \"$type\" linked to QcCategory: ${category?.name ?: sameTypeOtherCategory.qcCategoryId}. One item can only have each type in one QcCategory." + throw ConflictException(msg) + } val mapping = existing ?: ItemsQcCategoryMapping() mapping.itemId = itemId 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 b744d4e..f5fdd68 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 @@ -25,7 +25,12 @@ 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 com.ffii.fpsms.modules.master.web.models.BomDetailResponse +import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress import java.util.logging.Logger +import java.nio.file.Files +import org.springframework.core.io.FileSystemResource + @RestController @RequestMapping("/bom") class BomController ( @@ -64,11 +69,31 @@ class BomController ( log.info("import-bom/upload: using getParts(), count={}", parts.size) return bomService.uploadBomFilesFromParts(parts) } + @GetMapping("/import-bom/format-issue-log") +fun downloadBomFormatIssueLog( + @RequestParam batchId: String, + @RequestParam issueLogFileId: String +): ResponseEntity { + val path = bomService.loadBomFormatIssueLog(batchId, issueLogFileId) + ?: return ResponseEntity.notFound().build() + val resource: Resource = FileSystemResource(path.toFile()) + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"bom_format_issue.xlsx\"") + .body(resource) +} @PostMapping("/import-bom/format-check") fun checkBomFormat(@RequestBody request: BomFormatCheckRequest): BomFormatCheckResponse { return bomService.checkBomExcelFormat(request.batchId) } + /* + @GetMapping("/import-bom/format-check/progress") + fun getBomFormatCheckProgress(@RequestParam batchId: String): ResponseEntity { + val progress = bomService.getBomExcelCheckProgress(batchId) + ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(progress) + } + */ @GetMapping fun getBoms(): List { return bomService.findAll() @@ -82,15 +107,19 @@ class BomController ( @PostMapping("/import-bom") fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity { val reportResult = bomService.importBOM(payload.batchId, payload.items) - val filename = "bom_excel_issue_log_${LocalDate.now()}.xlsx" + // val filename = "bom_excel_issue_log_${LocalDate.now()}.xlsx" return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"") - .header(HttpHeaders.CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + // .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"") + //.header(HttpHeaders.CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") .body(ByteArrayResource(reportResult)) } // @PostMapping("/export-problematic-bom") // fun exportProblematicBom() { // return bomService.importBOM() // } +@GetMapping("/{id}/detail") +fun getBomDetail(@PathVariable id: Long): BomDetailResponse { + return bomService.getBomDetail(id) +} } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/QcItemAllController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/QcItemAllController.kt index 4a4a155..8582af4 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/QcItemAllController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/QcItemAllController.kt @@ -13,7 +13,24 @@ import org.springframework.web.bind.annotation.* class QcItemAllController( private val qcItemAllService: QcItemAllService ) { - + @GetMapping("/categoriesWithItemCountsAndType") + fun getAllQcCategoriesWithItemCountsAndType(): List { + return qcItemAllService.getAllQcCategoriesWithItemCountsAndType() + } + + @GetMapping("/categoryType/{qcCategoryId}") +fun getCategoryType(@PathVariable qcCategoryId: Long): CategoryTypeResponse { + val type = qcItemAllService.getCategoryType(qcCategoryId) + return CategoryTypeResponse(type = type) +} + + @PutMapping("/categoryType") + fun updateCategoryType( + @RequestParam qcCategoryId: Long, + @RequestParam type: String + ) { + qcItemAllService.updateCategoryType(qcCategoryId, type) + } // Get item count by qc category @GetMapping("/itemCount/{qcCategoryId}") fun getItemCountByQcCategory(@PathVariable qcCategoryId: Long): Long { 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 8138fc7..3e939bf 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 @@ -55,15 +55,65 @@ data class BomFormatCheckRequest( /** Format-check 回傳:正確檔名列表 + 失敗列表(檔名 + 問題) */ data class BomFormatCheckResponse( val correctFileNames: List, - val failList: List + val failList: List, + val issueLogFileId: String? = null // 或直接是 URL ) data class ImportBomItemRequest( val fileName: String, - val isAlsoWip: Boolean = false + val isAlsoWip: Boolean = false, + val isDrink: Boolean = false ) data class ImportBomRequestPayload( val batchId: String, val items: List +) + +data class BomMaterialDto( + val itemCode: String?, + val itemName: String?, + val baseQty: BigDecimal?, + val baseUom: String?, + val stockQty: BigDecimal?, + val stockUom: String?, + val salesQty: BigDecimal?, + val salesUom: String? + +) + +data class BomProcessDto( + val seqNo: Long?, + val processName: String?, + val processDescription: String?, + val equipmentName: String?, + val durationInMinute: Int?, + val prepTimeInMinute: Int?, + val postProdTimeInMinute: Int? +) +data class BomExcelCheckProgress( + val batchId: String, + @Volatile var totalFiles: Int, + @Volatile var processedFiles: Int, + @Volatile var currentFileName: String?, // 正在處理哪一個 + @Volatile var lastUpdateTime: Long // 最後一次更新時間 +) +data class BomDetailResponse( + val id: Long, + val itemCode: String?, + val itemName: String?, + val isDark: Boolean?, + val isFloat: Int?, + val isDense: Int?, + val isDrink: Boolean?, + val scrapRate: Int?, + val allergicSubstances: Int?, + val timeSequence: Int?, + val complexity: Int?, + val baseScore: Int?, + val description: String?, + val outputQty: BigDecimal?, + val outputQtyUom: String?, + val materials: List, + val processes: List ) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/QcCategoryWithItemCountAndType.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/QcCategoryWithItemCountAndType.kt new file mode 100644 index 0000000..493ba54 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/QcCategoryWithItemCountAndType.kt @@ -0,0 +1,13 @@ +package com.ffii.fpsms.modules.master.web.models + +data class QcCategoryWithItemCountAndType( + val id: Long, + val code: String?, + val name: String?, + val description: String?, + val itemCount: Long, + val type: String? // items_qc_category_mapping.type from any one mapping of this category +) +data class CategoryTypeResponse( + val type: String? +) \ No newline at end of file