| @@ -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<QcCategoryWithItemCountAndType> { | |||
| 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<QcCategoryWithQcItemCount> { | |||
| @@ -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 | |||
| @@ -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<Resource> { | |||
| 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<BomExcelCheckProgress> { | |||
| val progress = bomService.getBomExcelCheckProgress(batchId) | |||
| ?: return ResponseEntity.notFound().build() | |||
| return ResponseEntity.ok(progress) | |||
| } | |||
| */ | |||
| @GetMapping | |||
| fun getBoms(): List<Bom> { | |||
| return bomService.findAll() | |||
| @@ -82,15 +107,19 @@ class BomController ( | |||
| @PostMapping("/import-bom") | |||
| fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity<Resource> { | |||
| 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) | |||
| } | |||
| } | |||
| @@ -13,7 +13,24 @@ import org.springframework.web.bind.annotation.* | |||
| class QcItemAllController( | |||
| private val qcItemAllService: QcItemAllService | |||
| ) { | |||
| @GetMapping("/categoriesWithItemCountsAndType") | |||
| fun getAllQcCategoriesWithItemCountsAndType(): List<QcCategoryWithItemCountAndType> { | |||
| 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 { | |||
| @@ -55,15 +55,65 @@ data class BomFormatCheckRequest( | |||
| /** Format-check 回傳:正確檔名列表 + 失敗列表(檔名 + 問題) */ | |||
| data class BomFormatCheckResponse( | |||
| val correctFileNames: List<String>, | |||
| val failList: List<BomFormatFileGroup> | |||
| val failList: List<BomFormatFileGroup>, | |||
| 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<ImportBomItemRequest> | |||
| ) | |||
| 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<BomMaterialDto>, | |||
| val processes: List<BomProcessDto> | |||
| ) | |||
| @@ -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? | |||
| ) | |||