| @@ -7,7 +7,7 @@ import com.ffii.fpsms.modules.qc.entity.projection.QcItemInfo | |||||
| import jakarta.validation.Valid | import jakarta.validation.Valid | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import org.springframework.web.bind.annotation.RequestBody | import org.springframework.web.bind.annotation.RequestBody | ||||
| import com.ffii.core.exception.ConflictException | |||||
| @Service | @Service | ||||
| open class QcItemAllService( | open class QcItemAllService( | ||||
| private val qcCategoryRepository: QcCategoryRepository, | 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) | // Get all qc categories with qc item counts (batch operation for performance) | ||||
| open fun getAllQcCategoriesWithQcItemCounts(): List<QcCategoryWithQcItemCount> { | open fun getAllQcCategoriesWithQcItemCounts(): List<QcCategoryWithQcItemCount> { | ||||
| @@ -109,6 +178,13 @@ open class QcItemAllService( | |||||
| open fun saveItemQcCategoryMapping(itemId: Long, qcCategoryId: Long, type: String): ItemQcCategoryMappingInfo { | open fun saveItemQcCategoryMapping(itemId: Long, qcCategoryId: Long, type: String): ItemQcCategoryMappingInfo { | ||||
| // Check if mapping already exists | // Check if mapping already exists | ||||
| val existing = itemsQcCategoryMappingRepository.findByItemIdAndQcCategoryIdAndType(itemId, qcCategoryId, type) | 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() | val mapping = existing ?: ItemsQcCategoryMapping() | ||||
| mapping.itemId = itemId | 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.BomUploadResponse | ||||
| import com.ffii.fpsms.modules.master.web.models.BomFormatCheckRequest | 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.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.util.logging.Logger | ||||
| import java.nio.file.Files | |||||
| import org.springframework.core.io.FileSystemResource | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/bom") | @RequestMapping("/bom") | ||||
| class BomController ( | class BomController ( | ||||
| @@ -64,11 +69,31 @@ class BomController ( | |||||
| log.info("import-bom/upload: using getParts(), count={}", parts.size) | log.info("import-bom/upload: using getParts(), count={}", parts.size) | ||||
| return bomService.uploadBomFilesFromParts(parts) | 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") | @PostMapping("/import-bom/format-check") | ||||
| fun checkBomFormat(@RequestBody request: BomFormatCheckRequest): BomFormatCheckResponse { | fun checkBomFormat(@RequestBody request: BomFormatCheckRequest): BomFormatCheckResponse { | ||||
| return bomService.checkBomExcelFormat(request.batchId) | 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 | @GetMapping | ||||
| fun getBoms(): List<Bom> { | fun getBoms(): List<Bom> { | ||||
| return bomService.findAll() | return bomService.findAll() | ||||
| @@ -82,15 +107,19 @@ class BomController ( | |||||
| @PostMapping("/import-bom") | @PostMapping("/import-bom") | ||||
| fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity<Resource> { | fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity<Resource> { | ||||
| val reportResult = bomService.importBOM(payload.batchId, payload.items) | 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() | 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)) | .body(ByteArrayResource(reportResult)) | ||||
| } | } | ||||
| // @PostMapping("/export-problematic-bom") | // @PostMapping("/export-problematic-bom") | ||||
| // fun exportProblematicBom() { | // fun exportProblematicBom() { | ||||
| // return bomService.importBOM() | // 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( | class QcItemAllController( | ||||
| private val qcItemAllService: QcItemAllService | 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 | // Get item count by qc category | ||||
| @GetMapping("/itemCount/{qcCategoryId}") | @GetMapping("/itemCount/{qcCategoryId}") | ||||
| fun getItemCountByQcCategory(@PathVariable qcCategoryId: Long): Long { | fun getItemCountByQcCategory(@PathVariable qcCategoryId: Long): Long { | ||||
| @@ -55,15 +55,65 @@ data class BomFormatCheckRequest( | |||||
| /** Format-check 回傳:正確檔名列表 + 失敗列表(檔名 + 問題) */ | /** Format-check 回傳:正確檔名列表 + 失敗列表(檔名 + 問題) */ | ||||
| data class BomFormatCheckResponse( | data class BomFormatCheckResponse( | ||||
| val correctFileNames: List<String>, | val correctFileNames: List<String>, | ||||
| val failList: List<BomFormatFileGroup> | |||||
| val failList: List<BomFormatFileGroup>, | |||||
| val issueLogFileId: String? = null // 或直接是 URL | |||||
| ) | ) | ||||
| data class ImportBomItemRequest( | data class ImportBomItemRequest( | ||||
| val fileName: String, | val fileName: String, | ||||
| val isAlsoWip: Boolean = false | |||||
| val isAlsoWip: Boolean = false, | |||||
| val isDrink: Boolean = false | |||||
| ) | ) | ||||
| data class ImportBomRequestPayload( | data class ImportBomRequestPayload( | ||||
| val batchId: String, | val batchId: String, | ||||
| val items: List<ImportBomItemRequest> | 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? | |||||
| ) | |||||