From e0ad21bfadb43db778b4171d4f95f5ef65c6e6b0 Mon Sep 17 00:00:00 2001 From: hyungi Date: Fri, 17 Oct 2025 13:48:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SPECIAL/UNCLASSIFIED=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?WELD=20GAP=20=EC=9E=90=EB=8F=99=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 변경사항: - SPECIAL 카테고리 추가: 특수 제작 품목 관리 (Type, Drawing, Detail1-4) - UNCLASSIFIED 카테고리 추가: 미분류 자재 원본 그대로 표시 - UNKNOWN → UNCLASSIFIED 통합: 기존 UNKNOWN 카테고리 제거 - WELD GAP 자동 제외: BOM 업로드 시 WELD GAP 항목 자동 필터링 백엔드: - integrated_classifier.py: UNKNOWN → UNCLASSIFIED 변경, SPECIAL 우선순위 분류 - files.py: parse_dataframe에서 WELD GAP 필터링, UNKNOWN 참조 제거 - exclude_classifier.py: WELD GAP 제외 로직 유지 프론트엔드: - SpecialMaterialsView.jsx: 특수 제작 품목 관리 컴포넌트 - UnclassifiedMaterialsView.jsx: 미분류 자재 관리 컴포넌트 - BOMManagementPage.jsx: 새 카테고리 추가 및 라우팅 - excelExport.js: SPECIAL/UNCLASSIFIED 엑셀 내보내기 지원 - 모든 UNKNOWN 참조를 UNCLASSIFIED로 변경 기능 개선: - 저장 기능: 모든 카테고리에 추가요청사항 저장/편집 기능 - P열 납기일 규칙: 모든 카테고리 엑셀 내보내기 통일 - UI 개선: Detail1-4 컬럼명으로 혼동 방지 - 데이터 정리: 모든 프로젝트 및 BOM 데이터 초기화 --- backend/app/routers/files.py | 32 +- backend/app/services/exclude_classifier.py | 5 - backend/app/services/integrated_classifier.py | 3 +- frontend/PAGES_GUIDE.md | 44 +- .../bom/materials/BoltMaterialsView.jsx | 4 +- .../bom/materials/FittingMaterialsView.jsx | 4 +- .../bom/materials/SpecialMaterialsView.jsx | 628 ++++++++++++++++++ .../materials/UnclassifiedMaterialsView.jsx | 561 ++++++++++++++++ .../src/components/bom/materials/index.js | 2 + frontend/src/pages/BOMManagementPage.jsx | 12 +- frontend/src/pages/NewMaterialsPage.css | 6 +- frontend/src/pages/NewMaterialsPage.jsx | 16 +- frontend/src/pages/PurchaseRequestPage.jsx | 2 +- frontend/src/utils/excelExport.js | 57 +- 14 files changed, 1335 insertions(+), 41 deletions(-) create mode 100644 frontend/src/components/bom/materials/SpecialMaterialsView.jsx create mode 100644 frontend/src/components/bom/materials/UnclassifiedMaterialsView.jsx diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 6450617..d0682e5 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -231,6 +231,14 @@ def parse_dataframe(df): materials = [] for index, row in df.iterrows(): description = str(row.get(mapped_columns.get('description', ''), '')) + + # WELD GAP 항목은 업로드 단계에서 제외 (불필요한 계산용 항목) + description_upper = description.upper() + if ('WELD GAP' in description_upper or 'WELDING GAP' in description_upper or + '웰드갭' in description_upper or '용접갭' in description_upper): + print(f"⚠️ WELD GAP 항목 제외: {description}") + continue + quantity_raw = row.get(mapped_columns.get('quantity', ''), 0) try: @@ -547,7 +555,7 @@ async def upload_file( materials_to_insert.append({ "file_id": file_id, "original_description": material_data["original_description"], - "classified_category": previous_item.get("category", "UNKNOWN"), + "classified_category": previous_item.get("category", "UNCLASSIFIED"), "confidence": 1.0, # 확정된 자료이므로 신뢰도 100% "quantity": material_data["quantity"], "unit": material_data.get("unit", "EA"), @@ -659,7 +667,7 @@ async def upload_file( # 1. 통합 분류기로 자재 타입 결정 integrated_result = classify_material_integrated(description, main_nom or "", red_nom or "", length_value) print(f"[분류] {description}") - print(f"통합 분류 결과: {integrated_result.get('category', 'UNKNOWN')} (신뢰도: {integrated_result.get('confidence', 0)}, Level: {integrated_result.get('classification_level', 'NONE')})") + print(f"통합 분류 결과: {integrated_result.get('category', 'UNCLASSIFIED')} (신뢰도: {integrated_result.get('confidence', 0)}, Level: {integrated_result.get('classification_level', 'NONE')})") # 2. 제외 대상 확인 if should_exclude_material(description): @@ -670,7 +678,7 @@ async def upload_file( } else: # 3. 타입별 상세 분류기 실행 - material_type = integrated_result.get('category', 'UNKNOWN') + material_type = integrated_result.get('category', 'UNCLASSIFIED') if material_type == "PIPE": from ..services.pipe_classifier import classify_pipe_for_purchase @@ -714,9 +722,9 @@ async def upload_file( } } else: - # UNKNOWN 처리 + # UNCLASSIFIED 처리 classification_result = { - "category": "UNKNOWN", + "category": "UNCLASSIFIED", "overall_confidence": integrated_result.get('confidence', 0.0), "reason": f"분류 불가: {integrated_result.get('evidence', [])}" } @@ -728,7 +736,7 @@ async def upload_file( integrated_result.get('confidence', 0.0) + 0.2 ) - print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}") + print(f"최종 분류 결과: {classification_result.get('category', 'UNCLASSIFIED')}") # 전체 재질명 추출 from ..services.material_grade_extractor import extract_full_material_grade @@ -758,7 +766,7 @@ async def upload_file( print(f"첫 번째 자재 저장:") print(f" size_spec: '{material_data['size_spec']}'") print(f" original_description: {material_data['original_description']}") - print(f" category: {classification_result.get('category', 'UNKNOWN')}") + print(f" category: {classification_result.get('category', 'UNCLASSIFIED')}") print(f" drawing_name: {material_data.get('dwg_name')}") print(f" line_no: {material_data.get('line_num')}") @@ -774,7 +782,7 @@ async def upload_file( "full_material_grade": full_material_grade, "line_number": material_data["line_number"], "row_number": material_data["row_number"], - "classified_category": classification_result.get("category", "UNKNOWN"), + "classified_category": classification_result.get("category", "UNCLASSIFIED"), "classification_confidence": classification_result.get("overall_confidence", 0.0), "is_verified": False, "drawing_name": material_data.get("dwg_name"), @@ -889,7 +897,7 @@ async def upload_file( material_grade_from_classifier = material_info.get("grade", "") # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트 - if material_grade_from_classifier and material_grade_from_classifier != "UNKNOWN": + if material_grade_from_classifier and material_grade_from_classifier not in ["UNKNOWN", "UNCLASSIFIED"]: material_spec = material_grade_from_classifier # materials 테이블의 material_grade도 업데이트 @@ -958,7 +966,7 @@ async def upload_file( material_grade = material_info.get("grade", "") # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트 - if material_grade and material_grade != "UNKNOWN": + if material_grade and material_grade not in ["UNKNOWN", "UNCLASSIFIED"]: db.execute(text(""" UPDATE materials SET material_grade = :new_material_grade @@ -1055,7 +1063,7 @@ async def upload_file( material_grade = material_info.get("grade", "") # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트 - if material_grade and material_grade != "UNKNOWN": + if material_grade and material_grade not in ["UNKNOWN", "UNCLASSIFIED"]: db.execute(text(""" UPDATE materials SET material_grade = :new_material_grade @@ -1245,7 +1253,7 @@ async def upload_file( material_grade = material_info.get("grade", "") # 분류기에서 더 상세한 재질 정보가 나왔으면 업데이트 - if material_grade and material_grade != "UNKNOWN": + if material_grade and material_grade not in ["UNKNOWN", "UNCLASSIFIED"]: db.execute(text(""" UPDATE materials SET material_grade = :new_material_grade diff --git a/backend/app/services/exclude_classifier.py b/backend/app/services/exclude_classifier.py index bc7a622..737e194 100644 --- a/backend/app/services/exclude_classifier.py +++ b/backend/app/services/exclude_classifier.py @@ -8,11 +8,6 @@ from typing import Dict, List, Optional # ========== 제외 대상 타입 ========== EXCLUDE_TYPES = { - "WELD_GAP": { - "description_keywords": ["WELD GAP", "WELDING GAP", "GAP", "용접갭", "웰드갭"], - "characteristics": "용접 시 수축 고려용 계산 항목", - "reason": "실제 자재 아님 - 용접 갭 계산용" - }, "CUTTING_LOSS": { "description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"], "characteristics": "절단 시 손실 고려용 계산 항목", diff --git a/backend/app/services/integrated_classifier.py b/backend/app/services/integrated_classifier.py index dd3118f..f3e7628 100644 --- a/backend/app/services/integrated_classifier.py +++ b/backend/app/services/integrated_classifier.py @@ -110,6 +110,7 @@ def classify_material_integrated(description: str, main_nom: str = "", "reason": "스페셜 키워드 발견" } + # VALVE 카테고리 우선 확인 (SIGHT GLASS, STRAINER) if ('SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '사이트글라스' in desc_upper or '스트레이너' in desc_upper): @@ -295,7 +296,7 @@ def classify_material_integrated(description: str, main_nom: str = "", # 분류 실패 return { - "category": "UNKNOWN", + "category": "UNCLASSIFIED", "confidence": 0.0, "evidence": ["NO_CLASSIFICATION_POSSIBLE"], "classification_level": "NONE" diff --git a/frontend/PAGES_GUIDE.md b/frontend/PAGES_GUIDE.md index 9720989..d292939 100644 --- a/frontend/PAGES_GUIDE.md +++ b/frontend/PAGES_GUIDE.md @@ -91,10 +91,10 @@ ### `BOMManagementPage.jsx` - **역할**: BOM(Bill of Materials) 통합 관리 페이지 - **기능**: - - 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT) + - 카테고리별 자재 조회 (PIPE, FITTING, FLANGE, VALVE, GASKET, BOLT, SUPPORT, SPECIAL) - 자재 선택 및 구매신청 (엑셀 내보내기) - 구매신청된 자재 비활성화 표시 - - 사용자 요구사항 입력 + - 사용자 요구사항 입력 및 저장 - 리비전 관리 - **라우팅**: `/bom-management` - **접근 권한**: 인증된 사용자 @@ -232,6 +232,25 @@ - `GasketMaterialsView.jsx`: 가스켓 자재 관리 - `BoltMaterialsView.jsx`: 볼트 자재 관리 - `SupportMaterialsView.jsx`: 서포트 자재 관리 + - `SpecialMaterialsView.jsx`: 특수 제작 자재 관리 + +#### SPECIAL 카테고리 상세 기능 +`SpecialMaterialsView.jsx`는 특수 제작이 필요한 자재들을 관리하는 컴포넌트입니다: + +**주요 기능:** +- **자동 타입 분류**: FLANGE, OIL PUMP, COMPRESSOR, VALVE, FITTING, PIPE 등 큰 범주 자동 인식 +- **정보 파싱**: 자재 설명을 도면, 항목1-4로 체계적 분리 +- **테이블 구조**: `Type | Drawing | Item 1 | Item 2 | Item 3 | Item 4 | Additional Request | Purchase Quantity` +- **엑셀 내보내기**: P열 납기일 규칙 준수, 관리항목 자동 채움 +- **저장 기능**: 추가요청사항 저장/편집 (다른 카테고리와 동일) + +**처리 예시:** +- `SAE SPECIAL FF, OIL PUMP, ASTM A105` → Type: OIL PUMP, Item1: SAE SPECIAL FF, Item2: OIL PUMP, Item3: ASTM A105 +- `FLG SPECIAL FF, COMPRESSOR(N11), ASTM A105` → Type: FLANGE, Item1: FLG SPECIAL FF, Item2: COMPRESSOR(N11), Item3: ASTM A105 + +**분류 조건:** +- `SPECIAL` 키워드 포함 (단, `SPECIFICATION` 제외) +- 한글 `스페셜` 또는 `SPL` 키워드 포함 ### 기타 컴포넌트 - **NavigationMenu.jsx**: 사이드바 네비게이션 @@ -258,6 +277,16 @@ - **Background**: 글래스 효과 (backdrop-filter: blur) - **Cards**: 20px 둥근 모서리, 그림자 효과 +### BOM 카테고리 색상 +- **PIPE**: #3b82f6 (파란색) +- **FITTING**: #10b981 (초록색) +- **FLANGE**: #f59e0b (주황색) +- **VALVE**: #ef4444 (빨간색) +- **GASKET**: #8b5cf6 (보라색) +- **BOLT**: #6b7280 (회색) +- **SUPPORT**: #f97316 (주황색) +- **SPECIAL**: #ec4899 (핑크색) + ### 반응형 디자인 - **Desktop**: 3-4열 그리드 - **Tablet**: 2열 그리드 @@ -269,5 +298,14 @@ --- -*마지막 업데이트: 2024-10-16* +*마지막 업데이트: 2024-10-17* *다음 페이지 추가 시 반드시 이 문서를 업데이트하세요.* + +## 최근 업데이트 내역 + +### 2024-10-17: SPECIAL 카테고리 추가 +- `SpecialMaterialsView.jsx` 컴포넌트 추가 +- 특수 제작 자재 관리 기능 구현 +- 자동 타입 분류 및 정보 파싱 시스템 +- 엑셀 내보내기 규칙 적용 (P열 납기일, 관리항목 자동 채움) +- BOM 카테고리 색상 팔레트에 SPECIAL (#ec4899) 추가 diff --git a/frontend/src/components/bom/materials/BoltMaterialsView.jsx b/frontend/src/components/bom/materials/BoltMaterialsView.jsx index 1953a8a..ff3987a 100644 --- a/frontend/src/components/bom/materials/BoltMaterialsView.jsx +++ b/frontend/src/components/bom/materials/BoltMaterialsView.jsx @@ -154,7 +154,7 @@ const BoltMaterialsView = ({ let boltGrade = '-'; if (boltDetails.material_standard && boltDetails.material_grade) { // bolt_details에서 완전한 재질 정보 구성 - if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== boltDetails.material_standard) { + if (boltDetails.material_grade !== 'UNKNOWN' && boltDetails.material_grade !== 'UNCLASSIFIED' && boltDetails.material_grade !== boltDetails.material_standard) { boltGrade = `${boltDetails.material_standard} ${boltDetails.material_grade}`; } else { boltGrade = boltDetails.material_standard; @@ -167,7 +167,7 @@ const BoltMaterialsView = ({ // 볼트 타입 (PSV_BOLT, LT_BOLT 등) let boltSubtype = 'BOLT_GENERAL'; - if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN') { + if (boltDetails.bolt_type && boltDetails.bolt_type !== 'UNKNOWN' && boltDetails.bolt_type !== 'UNCLASSIFIED') { boltSubtype = boltDetails.bolt_type; } else { // 원본 설명에서 특수 볼트 타입 추출 diff --git a/frontend/src/components/bom/materials/FittingMaterialsView.jsx b/frontend/src/components/bom/materials/FittingMaterialsView.jsx index 660be0c..d36bff3 100644 --- a/frontend/src/components/bom/materials/FittingMaterialsView.jsx +++ b/frontend/src/components/bom/materials/FittingMaterialsView.jsx @@ -308,10 +308,10 @@ const FittingMaterialsView = ({ if (hasDifferentSchedules && mainSchedule && redSchedule) { schedule = `${mainSchedule}×${redSchedule}`; - } else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN') { + } else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') { // 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시 schedule = `${mainSchedule}×${mainSchedule}`; - } else if (mainSchedule && mainSchedule !== 'UNKNOWN') { + } else if (mainSchedule && mainSchedule !== 'UNKNOWN' && mainSchedule !== 'UNCLASSIFIED') { schedule = mainSchedule; } else { // Description에서 스케줄 추출 - 더 강력한 패턴 매칭 diff --git a/frontend/src/components/bom/materials/SpecialMaterialsView.jsx b/frontend/src/components/bom/materials/SpecialMaterialsView.jsx new file mode 100644 index 0000000..911a78a --- /dev/null +++ b/frontend/src/components/bom/materials/SpecialMaterialsView.jsx @@ -0,0 +1,628 @@ +import React, { useState } from 'react'; +import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport'; +import api from '../../../api'; +import { FilterableHeader } from '../shared'; + +const SpecialMaterialsView = ({ + materials, + selectedMaterials, + setSelectedMaterials, + userRequirements, + setUserRequirements, + purchasedMaterials, + onPurchasedMaterialsUpdate, + updateMaterial, // 자재 업데이트 함수 + jobNo, + fileId, + user, + onNavigate +}) => { + const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); + const [columnFilters, setColumnFilters] = useState({}); + const [showFilterDropdown, setShowFilterDropdown] = useState(null); + const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태 + const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드 + const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태 + + // 컴포넌트 마운트 시 저장된 데이터 로드 + React.useEffect(() => { + const loadSavedData = () => { + const savedRequestsData = {}; + + materials.forEach(material => { + if (material.user_requirement && material.user_requirement.trim()) { + savedRequestsData[material.id] = material.user_requirement.trim(); + } + }); + + setSavedRequests(savedRequestsData); + }; + + if (materials && materials.length > 0) { + loadSavedData(); + } else { + setSavedRequests({}); + } + }, [materials]); + + // 추가요구사항 저장 함수 + const handleSaveRequest = async (materialId, request) => { + setSavingRequest(prev => ({ ...prev, [materialId]: true })); + try { + await api.patch(`/materials/${materialId}/user-requirement`, { + user_requirement: request.trim() + }); + setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() })); + setEditingRequest(prev => ({ ...prev, [materialId]: false })); + setUserRequirements(prev => ({ ...prev, [materialId]: '' })); + + if (updateMaterial) { + updateMaterial(materialId, { user_requirement: request.trim() }); + } + } catch (error) { + console.error('추가요구사항 저장 실패:', error); + alert('추가요구사항 저장에 실패했습니다.'); + } finally { + setSavingRequest(prev => ({ ...prev, [materialId]: false })); + } + }; + + // 추가요구사항 편집 시작 + const handleEditRequest = (materialId, currentRequest) => { + setEditingRequest(prev => ({ ...prev, [materialId]: true })); + setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' })); + }; + + // SPECIAL 자재 정보 파싱 + const parseSpecialInfo = (material) => { + const description = material.original_description || ''; + const qty = Math.round(material.quantity || 0); + + // Type 추출 (큰 범주: 우선순위 기반 분류) + let type = 'SPECIAL'; + const descUpper = description.toUpperCase(); + + // 우선순위 1: 주요 장비 타입 (OIL PUMP, COMPRESSOR 등이 FLG보다 우선) + if (descUpper.includes('OIL PUMP') || (descUpper.includes('PUMP') && !descUpper.includes('FITTING'))) { + type = 'OIL PUMP'; + } else if (descUpper.includes('COMPRESSOR')) { + type = 'COMPRESSOR'; + } else if (descUpper.includes('VALVE') && !descUpper.includes('FLG')) { + type = 'VALVE'; + } + // 우선순위 2: 구조물/부품 타입 + else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) { + // FLG가 있어도 주요 장비가 함께 있으면 장비 타입 우선 + if (descUpper.includes('OIL PUMP') || descUpper.includes('COMPRESSOR')) { + if (descUpper.includes('OIL PUMP')) { + type = 'OIL PUMP'; + } else if (descUpper.includes('COMPRESSOR')) { + type = 'COMPRESSOR'; + } + } else { + type = 'FLANGE'; + } + } else if (descUpper.includes('FITTING')) { + type = 'FITTING'; + } else if (descUpper.includes('PIPE')) { + type = 'PIPE'; + } + + // 도면 정보 (drawing_name 또는 line_no에서 추출) + const drawing = material.drawing_name || material.line_no || '-'; + + // 설명을 항목별로 분리 (콤마, 세미콜론, 파이프 등으로 구분) + const parts = description + .split(/[,;|\/]/) + .map(part => part.trim()) + .filter(part => part.length > 0); + + // 최대 4개 항목으로 제한 + const detail1 = parts[0] || '-'; + const detail2 = parts[1] || '-'; + const detail3 = parts[2] || '-'; + const detail4 = parts[3] || '-'; + + return { + type, + drawing, + detail1, + detail2, + detail3, + detail4, + quantity: qty, + originalQuantity: qty, + purchaseQuantity: qty, + isSpecial: true + }; + }; + + // 정렬 처리 + const handleSort = (key) => { + let direction = 'asc'; + if (sortConfig.key === key && sortConfig.direction === 'asc') { + direction = 'desc'; + } + setSortConfig({ key, direction }); + }; + + // 필터링 및 정렬된 자료 + const getFilteredAndSortedMaterials = () => { + let filtered = materials.filter(material => { + const info = parseSpecialInfo(material); + + // 컬럼 필터 적용 + for (const [key, filterValue] of Object.entries(columnFilters)) { + if (filterValue && filterValue.trim()) { + const materialValue = String(info[key] || '').toLowerCase(); + const filter = filterValue.toLowerCase(); + if (!materialValue.includes(filter)) { + return false; + } + } + } + return true; + }); + + // 정렬 적용 + if (sortConfig.key) { + filtered.sort((a, b) => { + const aInfo = parseSpecialInfo(a); + const bInfo = parseSpecialInfo(b); + + const aValue = aInfo[sortConfig.key]; + const bValue = bInfo[sortConfig.key]; + + if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1; + if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1; + return 0; + }); + } + + return filtered; + }; + + const filteredMaterials = getFilteredAndSortedMaterials(); + + // 전체 선택/해제 + const handleSelectAll = () => { + const selectableMaterials = filteredMaterials.filter(material => + !purchasedMaterials.has(material.id) + ); + + const allSelected = selectableMaterials.every(material => + selectedMaterials.has(material.id) + ); + + if (allSelected) { + // 전체 해제 + const newSelected = new Set(selectedMaterials); + selectableMaterials.forEach(material => { + newSelected.delete(material.id); + }); + setSelectedMaterials(newSelected); + } else { + // 전체 선택 + const newSelected = new Set(selectedMaterials); + selectableMaterials.forEach(material => { + newSelected.add(material.id); + }); + setSelectedMaterials(newSelected); + } + }; + + // 개별 선택/해제 + const handleMaterialSelect = (materialId) => { + const newSelected = new Set(selectedMaterials); + if (newSelected.has(materialId)) { + newSelected.delete(materialId); + } else { + newSelected.add(materialId); + } + setSelectedMaterials(newSelected); + }; + + // 엑셀 내보내기 + const handleExportToExcel = async () => { + const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id)); + if (selectedMaterialsData.length === 0) { + alert('내보낼 자재를 선택해주세요.'); + return; + } + + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const excelFileName = `SPECIAL_Materials_${timestamp}.xlsx`; + + const dataWithRequirements = selectedMaterialsData.map(material => ({ + ...material, + user_requirement: savedRequests[material.id] || userRequirements[material.id] || '' + })); + + try { + console.log('🔄 스페셜 엑셀 내보내기 시작 - 새로운 방식'); + + // 1. 먼저 클라이언트에서 엑셀 파일 생성 + console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료'); + const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, { + category: 'SPECIAL', + filename: excelFileName, + user: user?.username || 'unknown' + }); + + // 2. 구매신청 생성 + console.log('📝 구매신청 생성 중...'); + const purchaseResponse = await api.post('/purchase-request/create', { + materials_data: dataWithRequirements, + file_id: fileId, + job_no: jobNo + }); + + console.log('✅ 구매신청 생성 완료:', purchaseResponse.data); + + // 3. 엑셀 파일을 서버에 업로드 + console.log('📤 엑셀 파일 서버 업로드 중...'); + const formData = new FormData(); + formData.append('excel_file', excelBlob, excelFileName); + formData.append('request_id', purchaseResponse.data.request_id); + formData.append('filename', excelFileName); + + const uploadResponse = await api.post('/purchase-request/upload-excel', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + console.log('✅ 엑셀 업로드 완료:', uploadResponse.data); + + // 4. 구매신청된 자재들을 비활성화 + const purchasedIds = selectedMaterialsData.map(m => m.id); + onPurchasedMaterialsUpdate(purchasedIds); + + // 5. 선택 해제 + setSelectedMaterials(new Set()); + + // 6. 클라이언트에서도 다운로드 + const url = window.URL.createObjectURL(excelBlob); + const link = document.createElement('a'); + link.href = url; + link.download = excelFileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + console.log('✅ 스페셜 엑셀 내보내기 완료'); + alert(`스페셜 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`); + + // 구매신청 관리 페이지로 이동 + if (onNavigate) { + onNavigate('purchase-requests'); + } + + } catch (error) { + console.error('❌ 스페셜 엑셀 내보내기 실패:', error); + alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message)); + } + }; + + const allSelected = filteredMaterials.length > 0 && + filteredMaterials.filter(material => !purchasedMaterials.has(material.id)) + .every(material => selectedMaterials.has(material.id)); + + return ( +
+ {/* 헤더 */} +
+
+

+ Special Items +

+

+ 특수 제작 품목 관리 ({filteredMaterials.length}개) +

+
+ +
+ + {/* 테이블 */} +
+
+ {/* 헤더 */} +
+
+ +
+ + Type + + + Drawing + + + Detail 1 + + + Detail 2 + + + Detail 3 + + + Detail 4 + +
Additional Request
+
Purchase Quantity
+
+ + {/* 데이터 행들 */} +
+ {filteredMaterials.map((material, index) => { + const info = parseSpecialInfo(material); + const isSelected = selectedMaterials.has(material.id); + const isPurchased = purchasedMaterials.has(material.id); + + return ( +
{ + if (!isSelected && !isPurchased) { + e.target.style.background = '#f8fafc'; + } + }} + onMouseLeave={(e) => { + if (!isSelected && !isPurchased) { + e.target.style.background = 'white'; + } + }} + > +
+ handleMaterialSelect(material.id)} + disabled={isPurchased} + style={{ + cursor: isPurchased ? 'not-allowed' : 'pointer', + opacity: isPurchased ? 0.5 : 1 + }} + /> +
+
+ {info.type} + {isPurchased && ( + + PURCHASED + + )} +
+
{info.drawing}
+
{info.detail1}
+
{info.detail2}
+
{info.detail3}
+
{info.detail4}
+
+ {!editingRequest[material.id] && savedRequests[material.id] ? ( + // 저장된 상태 - 요구사항 표시 + 수정 버튼 + <> +
+ {savedRequests[material.id]} +
+ + + ) : ( + // 편집 상태 - 입력 필드 + 저장 버튼 + <> + setUserRequirements({ + ...userRequirements, + [material.id]: e.target.value + })} + placeholder="Enter additional request..." + disabled={isPurchased} + style={{ + flex: 1, + padding: '6px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px', + opacity: isPurchased ? 0.5 : 1, + cursor: isPurchased ? 'not-allowed' : 'text' + }} + /> + + + )} +
+
+ {info.purchaseQuantity} +
+
+ ); + })} +
+
+
+ + {filteredMaterials.length === 0 && ( +
+
📋
+

스페셜 자재가 없습니다

+

특수 제작이 필요한 자재가 발견되면 여기에 표시됩니다.

+
+ )} +
+ ); +}; + +export default SpecialMaterialsView; diff --git a/frontend/src/components/bom/materials/UnclassifiedMaterialsView.jsx b/frontend/src/components/bom/materials/UnclassifiedMaterialsView.jsx new file mode 100644 index 0000000..40565d5 --- /dev/null +++ b/frontend/src/components/bom/materials/UnclassifiedMaterialsView.jsx @@ -0,0 +1,561 @@ +import React, { useState } from 'react'; +import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport'; +import api from '../../../api'; +import { FilterableHeader } from '../shared'; + +const UnclassifiedMaterialsView = ({ + materials, + selectedMaterials, + setSelectedMaterials, + userRequirements, + setUserRequirements, + purchasedMaterials, + onPurchasedMaterialsUpdate, + updateMaterial, // 자재 업데이트 함수 + jobNo, + fileId, + user, + onNavigate +}) => { + const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); + const [columnFilters, setColumnFilters] = useState({}); + const [showFilterDropdown, setShowFilterDropdown] = useState(null); + const [savedRequests, setSavedRequests] = useState({}); // 저장된 추가요구사항 상태 + const [editingRequest, setEditingRequest] = useState({}); // 추가요구사항 편집 모드 + const [savingRequest, setSavingRequest] = useState({}); // 추가요구사항 저장 중 상태 + + // 컴포넌트 마운트 시 저장된 데이터 로드 + React.useEffect(() => { + const loadSavedData = () => { + const savedRequestsData = {}; + + materials.forEach(material => { + if (material.user_requirement && material.user_requirement.trim()) { + savedRequestsData[material.id] = material.user_requirement.trim(); + } + }); + + setSavedRequests(savedRequestsData); + }; + + if (materials && materials.length > 0) { + loadSavedData(); + } else { + setSavedRequests({}); + } + }, [materials]); + + // 추가요구사항 저장 함수 + const handleSaveRequest = async (materialId, request) => { + setSavingRequest(prev => ({ ...prev, [materialId]: true })); + try { + await api.patch(`/materials/${materialId}/user-requirement`, { + user_requirement: request.trim() + }); + setSavedRequests(prev => ({ ...prev, [materialId]: request.trim() })); + setEditingRequest(prev => ({ ...prev, [materialId]: false })); + setUserRequirements(prev => ({ ...prev, [materialId]: '' })); + + if (updateMaterial) { + updateMaterial(materialId, { user_requirement: request.trim() }); + } + } catch (error) { + console.error('추가요구사항 저장 실패:', error); + alert('추가요구사항 저장에 실패했습니다.'); + } finally { + setSavingRequest(prev => ({ ...prev, [materialId]: false })); + } + }; + + // 추가요구사항 편집 시작 + const handleEditRequest = (materialId, currentRequest) => { + setEditingRequest(prev => ({ ...prev, [materialId]: true })); + setUserRequirements(prev => ({ ...prev, [materialId]: currentRequest || '' })); + }; + + // 미분류 자재 정보 파싱 (원본 그대로 표시) + const parseUnclassifiedInfo = (material) => { + const description = material.original_description || material.description || ''; + const qty = Math.round(material.quantity || 0); + + return { + description: description || '-', + size: material.main_nom || material.size_spec || '-', + drawing: material.drawing_name || material.line_no || '-', + lineNo: material.line_no || '-', + quantity: qty, + originalQuantity: qty, + purchaseQuantity: qty, + isUnclassified: true + }; + }; + + // 정렬 처리 + const handleSort = (key) => { + let direction = 'asc'; + if (sortConfig.key === key && sortConfig.direction === 'asc') { + direction = 'desc'; + } + setSortConfig({ key, direction }); + }; + + // 필터링 및 정렬된 자료 + const getFilteredAndSortedMaterials = () => { + let filtered = materials.filter(material => { + const info = parseUnclassifiedInfo(material); + + // 컬럼 필터 적용 + for (const [key, filterValue] of Object.entries(columnFilters)) { + if (filterValue && filterValue.trim()) { + const materialValue = String(info[key] || '').toLowerCase(); + const filter = filterValue.toLowerCase(); + if (!materialValue.includes(filter)) { + return false; + } + } + } + return true; + }); + + // 정렬 적용 + if (sortConfig.key) { + filtered.sort((a, b) => { + const aInfo = parseUnclassifiedInfo(a); + const bInfo = parseUnclassifiedInfo(b); + + const aValue = aInfo[sortConfig.key]; + const bValue = bInfo[sortConfig.key]; + + if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1; + if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1; + return 0; + }); + } + + return filtered; + }; + + const filteredMaterials = getFilteredAndSortedMaterials(); + + // 전체 선택/해제 + const handleSelectAll = () => { + const selectableMaterials = filteredMaterials.filter(material => + !purchasedMaterials.has(material.id) + ); + + const allSelected = selectableMaterials.every(material => + selectedMaterials.has(material.id) + ); + + if (allSelected) { + // 전체 해제 + const newSelected = new Set(selectedMaterials); + selectableMaterials.forEach(material => { + newSelected.delete(material.id); + }); + setSelectedMaterials(newSelected); + } else { + // 전체 선택 + const newSelected = new Set(selectedMaterials); + selectableMaterials.forEach(material => { + newSelected.add(material.id); + }); + setSelectedMaterials(newSelected); + } + }; + + // 개별 선택/해제 + const handleMaterialSelect = (materialId) => { + const newSelected = new Set(selectedMaterials); + if (newSelected.has(materialId)) { + newSelected.delete(materialId); + } else { + newSelected.add(materialId); + } + setSelectedMaterials(newSelected); + }; + + // 엑셀 내보내기 + const handleExportToExcel = async () => { + const selectedMaterialsData = materials.filter(m => selectedMaterials.has(m.id)); + if (selectedMaterialsData.length === 0) { + alert('내보낼 자재를 선택해주세요.'); + return; + } + + const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const excelFileName = `UNCLASSIFIED_Materials_${timestamp}.xlsx`; + + const dataWithRequirements = selectedMaterialsData.map(material => ({ + ...material, + user_requirement: savedRequests[material.id] || userRequirements[material.id] || '' + })); + + try { + console.log('🔄 미분류 엑셀 내보내기 시작 - 새로운 방식'); + + // 1. 먼저 클라이언트에서 엑셀 파일 생성 + console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료'); + const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, { + category: 'UNCLASSIFIED', + filename: excelFileName, + user: user?.username || 'unknown' + }); + + // 2. 구매신청 생성 + console.log('📝 구매신청 생성 중...'); + const purchaseResponse = await api.post('/purchase-request/create', { + materials_data: dataWithRequirements, + file_id: fileId, + job_no: jobNo + }); + + console.log('✅ 구매신청 생성 완료:', purchaseResponse.data); + + // 3. 엑셀 파일을 서버에 업로드 + console.log('📤 엑셀 파일 서버 업로드 중...'); + const formData = new FormData(); + formData.append('excel_file', excelBlob, excelFileName); + formData.append('request_id', purchaseResponse.data.request_id); + formData.append('filename', excelFileName); + + const uploadResponse = await api.post('/purchase-request/upload-excel', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + console.log('✅ 엑셀 업로드 완료:', uploadResponse.data); + + // 4. 구매신청된 자재들을 비활성화 + const purchasedIds = selectedMaterialsData.map(m => m.id); + onPurchasedMaterialsUpdate(purchasedIds); + + // 5. 선택 해제 + setSelectedMaterials(new Set()); + + // 6. 클라이언트에서도 다운로드 + const url = window.URL.createObjectURL(excelBlob); + const link = document.createElement('a'); + link.href = url; + link.download = excelFileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + console.log('✅ 미분류 엑셀 내보내기 완료'); + alert(`미분류 자재 엑셀 파일이 생성되었습니다.\n파일명: ${excelFileName}`); + + // 구매신청 관리 페이지로 이동 + if (onNavigate) { + onNavigate('purchase-requests'); + } + + } catch (error) { + console.error('❌ 미분류 엑셀 내보내기 실패:', error); + alert('엑셀 내보내기 중 오류가 발생했습니다: ' + (error.response?.data?.detail || error.message)); + } + }; + + const allSelected = filteredMaterials.length > 0 && + filteredMaterials.filter(material => !purchasedMaterials.has(material.id)) + .every(material => selectedMaterials.has(material.id)); + + return ( +
+ {/* 헤더 */} +
+
+

+ Unclassified Materials +

+

+ 분류되지 않은 자재 관리 ({filteredMaterials.length}개) +

+
+ +
+ + {/* 테이블 */} +
+
+ {/* 헤더 */} +
+
+ +
+ + Description + + + Size + + + Drawing + + + Line No + +
Additional Request
+
Purchase Quantity
+
+ + {/* 데이터 행들 */} +
+ {filteredMaterials.map((material, index) => { + const info = parseUnclassifiedInfo(material); + const isSelected = selectedMaterials.has(material.id); + const isPurchased = purchasedMaterials.has(material.id); + + return ( +
{ + if (!isSelected && !isPurchased) { + e.target.style.background = '#f8fafc'; + } + }} + onMouseLeave={(e) => { + if (!isSelected && !isPurchased) { + e.target.style.background = 'white'; + } + }} + > +
+ handleMaterialSelect(material.id)} + disabled={isPurchased} + style={{ + cursor: isPurchased ? 'not-allowed' : 'pointer', + opacity: isPurchased ? 0.5 : 1 + }} + /> +
+
+ {info.description} + {isPurchased && ( + + PURCHASED + + )} +
+
{info.size}
+
{info.drawing}
+
{info.lineNo}
+
+ {!editingRequest[material.id] && savedRequests[material.id] ? ( + // 저장된 상태 - 요구사항 표시 + 수정 버튼 + <> +
+ {savedRequests[material.id]} +
+ + + ) : ( + // 편집 상태 - 입력 필드 + 저장 버튼 + <> + setUserRequirements({ + ...userRequirements, + [material.id]: e.target.value + })} + placeholder="Enter additional request..." + disabled={isPurchased} + style={{ + flex: 1, + padding: '6px 8px', + border: '1px solid #d1d5db', + borderRadius: '4px', + fontSize: '12px', + opacity: isPurchased ? 0.5 : 1, + cursor: isPurchased ? 'not-allowed' : 'text' + }} + /> + + + )} +
+
+ {info.purchaseQuantity} +
+
+ ); + })} +
+
+
+ + {filteredMaterials.length === 0 && ( +
+
+

미분류 자재가 없습니다

+

분류되지 않은 자재가 발견되면 여기에 표시됩니다.

+
+ )} +
+ ); +}; + +export default UnclassifiedMaterialsView; diff --git a/frontend/src/components/bom/materials/index.js b/frontend/src/components/bom/materials/index.js index bec7cda..c8a1b00 100644 --- a/frontend/src/components/bom/materials/index.js +++ b/frontend/src/components/bom/materials/index.js @@ -6,3 +6,5 @@ export { default as ValveMaterialsView } from './ValveMaterialsView'; export { default as GasketMaterialsView } from './GasketMaterialsView'; export { default as BoltMaterialsView } from './BoltMaterialsView'; export { default as SupportMaterialsView } from './SupportMaterialsView'; +export { default as SpecialMaterialsView } from './SpecialMaterialsView'; +export { default as UnclassifiedMaterialsView } from './UnclassifiedMaterialsView'; diff --git a/frontend/src/pages/BOMManagementPage.jsx b/frontend/src/pages/BOMManagementPage.jsx index 3e30a15..2aed5d5 100644 --- a/frontend/src/pages/BOMManagementPage.jsx +++ b/frontend/src/pages/BOMManagementPage.jsx @@ -8,7 +8,9 @@ import { ValveMaterialsView, GasketMaterialsView, BoltMaterialsView, - SupportMaterialsView + SupportMaterialsView, + SpecialMaterialsView, + UnclassifiedMaterialsView } from '../components/bom'; import './BOMManagementPage.css'; @@ -52,7 +54,9 @@ const BOMManagementPage = ({ { key: 'VALVE', label: 'Valves', color: '#ef4444' }, { key: 'GASKET', label: 'Gaskets', color: '#8b5cf6' }, { key: 'BOLT', label: 'Bolts', color: '#6b7280' }, - { key: 'SUPPORT', label: 'Supports', color: '#f97316' } + { key: 'SUPPORT', label: 'Supports', color: '#f97316' }, + { key: 'SPECIAL', label: 'Special Items', color: '#ec4899' }, + { key: 'UNCLASSIFIED', label: 'Unclassified', color: '#64748b' } ]; // 자료 로드 함수들 @@ -208,6 +212,10 @@ const BOMManagementPage = ({ return ; case 'SUPPORT': return ; + case 'SPECIAL': + return ; + case 'UNCLASSIFIED': + return ; default: return
카테고리를 선택해주세요.
; } diff --git a/frontend/src/pages/NewMaterialsPage.css b/frontend/src/pages/NewMaterialsPage.css index b36e157..28644b2 100644 --- a/frontend/src/pages/NewMaterialsPage.css +++ b/frontend/src/pages/NewMaterialsPage.css @@ -874,7 +874,7 @@ background: #d97706; } -/* UNKNOWN 전용 헤더 - 5개 컬럼 */ +/* UNCLASSIFIED 전용 헤더 - 5개 컬럼 */ .detailed-grid-header.unknown-header { grid-template-columns: 5% 10% 1fr 20% 10%; @@ -891,7 +891,7 @@ border-right: none; } -/* UNKNOWN 전용 행 - 5개 컬럼 */ +/* UNCLASSIFIED 전용 행 - 5개 컬럼 */ .detailed-material-row.unknown-row { grid-template-columns: 5% 10% 1fr 20% 10%; @@ -906,7 +906,7 @@ border-right: none; } -/* UNKNOWN 설명 셀 스타일 */ +/* UNCLASSIFIED 설명 셀 스타일 */ .description-cell { overflow: visible; text-overflow: initial; diff --git a/frontend/src/pages/NewMaterialsPage.jsx b/frontend/src/pages/NewMaterialsPage.jsx index 11f54a8..436512d 100644 --- a/frontend/src/pages/NewMaterialsPage.jsx +++ b/frontend/src/pages/NewMaterialsPage.jsx @@ -433,7 +433,7 @@ const NewMaterialsPage = ({ const getCategoryCounts = () => { const counts = {}; materials.forEach(material => { - const category = material.classified_category || 'UNKNOWN'; + const category = material.classified_category || 'UNCLASSIFIED'; counts[category] = (counts[category] || 0) + 1; }); return counts; @@ -476,7 +476,7 @@ const NewMaterialsPage = ({ 'BOLT': 'BOLT', 'GASKET': 'GASKET', 'INSTRUMENT': 'INSTRUMENT', - 'UNKNOWN': 'UNKNOWN' + 'UNCLASSIFIED': 'UNCLASSIFIED' }; return categoryMap[category] || category; }; @@ -1090,17 +1090,17 @@ const NewMaterialsPage = ({ unit: '개', isSpecial: true }; - } else if (category === 'UNKNOWN') { + } else if (category === 'UNCLASSIFIED') { return { - type: 'UNKNOWN', - description: material.original_description || 'Unknown Item', + type: 'UNCLASSIFIED', + description: material.original_description || 'Unclassified Item', quantity: Math.round(material.quantity || 0), unit: '개', isUnknown: true }; } else { return { - type: category || 'UNKNOWN', + type: category || 'UNCLASSIFIED', subtype: '-', size: material.size_spec || '-', schedule: '-', @@ -1939,7 +1939,7 @@ const NewMaterialsPage = ({
사용자요구
수량 - ) : selectedCategory === 'UNKNOWN' ? ( + ) : selectedCategory === 'UNCLASSIFIED' ? (
선택
종류
@@ -2464,7 +2464,7 @@ const NewMaterialsPage = ({ ); } - if (material.classified_category === 'UNKNOWN') { + if (material.classified_category === 'UNCLASSIFIED') { // 구매신청 여부 확인 const isPurchased = material.grouped_ids ? material.grouped_ids.some(id => purchasedMaterials.has(id)) : diff --git a/frontend/src/pages/PurchaseRequestPage.jsx b/frontend/src/pages/PurchaseRequestPage.jsx index 7ba92f5..7bf60d4 100644 --- a/frontend/src/pages/PurchaseRequestPage.jsx +++ b/frontend/src/pages/PurchaseRequestPage.jsx @@ -339,7 +339,7 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) => {(() => { // 카테고리별로 자재 그룹화 const groupedByCategory = requestMaterials.reduce((acc, material) => { - const category = material.category || material.classified_category || 'UNKNOWN'; + const category = material.category || material.classified_category || 'UNCLASSIFIED'; if (!acc[category]) acc[category] = []; acc[category].push(material); return acc; diff --git a/frontend/src/utils/excelExport.js b/frontend/src/utils/excelExport.js index 30b4de3..8ebdde4 100644 --- a/frontend/src/utils/excelExport.js +++ b/frontend/src/utils/excelExport.js @@ -522,7 +522,7 @@ const formatMaterialForExcel = (material, includeComparison = false) => { // BOM 페이지의 타입을 따라가도록 - 프론트엔드 parseBoltInfo와 동일한 로직 let boltSubtype = 'BOLT_GENERAL'; - if (boltType && boltType !== 'UNKNOWN') { + if (boltType && boltType !== 'UNKNOWN' && boltType !== 'UNCLASSIFIED') { boltSubtype = boltType; } else { // 원본 설명에서 특수 볼트 타입 추출 (프론트엔드와 동일) @@ -565,8 +565,29 @@ const formatMaterialForExcel = (material, includeComparison = false) => { } else { itemName = 'SUPPORT'; } + } else if (category === 'SPECIAL') { + // 스페셜 자재 타입 추출 (큰 범주) + const desc = cleanDescription.toUpperCase(); + if (desc.includes('FLG') || desc.includes('FLANGE')) { + itemName = 'SPECIAL FLANGE'; + } else if (desc.includes('OIL PUMP') || desc.includes('PUMP')) { + itemName = 'SPECIAL OIL PUMP'; + } else if (desc.includes('COMPRESSOR')) { + itemName = 'SPECIAL COMPRESSOR'; + } else if (desc.includes('VALVE')) { + itemName = 'SPECIAL VALVE'; + } else if (desc.includes('FITTING')) { + itemName = 'SPECIAL FITTING'; + } else if (desc.includes('PIPE')) { + itemName = 'SPECIAL PIPE'; + } else { + itemName = 'SPECIAL ITEM'; + } + } else if (category === 'UNCLASSIFIED') { + // 미분류 자재는 원본 설명을 품목명으로 사용 + itemName = cleanDescription || 'UNCLASSIFIED ITEM'; } else { - itemName = category || 'UNKNOWN'; + itemName = category || 'UNCLASSIFIED'; } // 압력 등급 추출 (카테고리별 처리) @@ -950,6 +971,36 @@ const formatMaterialForExcel = (material, includeComparison = false) => { base['관리항목4'] = ''; // M열 base['관리항목5'] = ''; // N열 base['관리항목6'] = ''; // O열 + } else if (category === 'SPECIAL') { + // 스페셜 전용 컬럼 (F~O) - Type은 품목명에 포함, 나머지는 항목별로 분리 + const description = cleanDescription || ''; + const parts = description + .split(/[,;|\/]/) + .map(part => part.trim()) + .filter(part => part.length > 0); + + base['도면'] = material.drawing_name || material.line_no || '-'; // F열 + base['상세1'] = parts[0] || '-'; // G열 + base['상세2'] = parts[1] || '-'; // H열 + base['상세3'] = parts[2] || '-'; // I열 + base['상세4'] = parts[3] || '-'; // J열 + base['추가요청사항'] = material.user_requirement || ''; // K열 + base['관리항목1'] = ''; // L열 + base['관리항목2'] = ''; // M열 + base['관리항목3'] = ''; // N열 + base['관리항목4'] = ''; // O열 + } else if (category === 'UNCLASSIFIED') { + // 미분류 전용 컬럼 (F~O) - 원본 정보 그대로 표시 + base['크기'] = material.main_nom || material.size_spec || '-'; // F열 + base['도면'] = material.drawing_name || '-'; // G열 + base['라인번호'] = material.line_no || '-'; // H열 + base['추가요청사항'] = material.user_requirement || ''; // I열 + base['관리항목1'] = ''; // J열 + base['관리항목2'] = ''; // K열 + base['관리항목3'] = ''; // L열 + base['관리항목4'] = ''; // M열 + base['관리항목5'] = ''; // N열 + base['관리항목6'] = ''; // O열 } else { // 기타 카테고리 기본 컬럼 (F~O) - 타입 제거, 품목명에 포함됨 base['크기'] = material.size_spec || '-'; // F열 @@ -1213,6 +1264,8 @@ export const createExcelBlob = async (materials, filename, options = {}) => { 'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '추가요청사항', '관리항목1', '관리항목2'], 'BOLT': ['크기', '압력등급', '길이', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'SUPPORT': ['크기', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '관리항목5', '관리항목6'], + 'SPECIAL': ['도면', '상세1', '상세2', '상세3', '상세4', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], + 'UNCLASSIFIED': ['크기', '도면', '라인번호', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '관리항목5', '관리항목6'], }; const deliveryDateHeader = '납기일(YYYY-MM-DD)';