From a27213e0e57f63b37112a95403e541647f07bbae Mon Sep 17 00:00:00 2001 From: hyungi Date: Thu, 16 Oct 2025 15:51:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B0=80=EC=8A=A4=EC=BC=93=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가스켓 카테고리 정렬 오류 수정 (FilterableHeader props 추가) - 가스켓 엑셀 내보내기 개선: * 품목명을 BOM 페이지 타입과 동일하게 표시 (SPIRAL WOUND GASKET 등) * 재질을 재질1/재질2로 분리 (SS304/GRAPHITE → 재질1: SS304/GRAPHITE, 재질2: /SS304/SS304) * originalDescription에서 4개 재질 패턴 우선 추출 * P열 납기일 규칙 준수 - 프로젝트 비활성화 기능 수정 (localStorage 영구 저장) - 모든 카테고리 정렬 함수 안전성 강화 --- frontend/src/App.jsx | 20 +- .../bom/materials/BoltMaterialsView.jsx | 27 +- .../bom/materials/GasketMaterialsView.jsx | 315 ++++++++++++++---- .../bom/materials/SupportMaterialsView.jsx | 27 +- .../bom/materials/ValveMaterialsView.jsx | 27 +- .../bom/shared/FilterableHeader.jsx | 2 +- frontend/src/utils/excelExport.js | 151 +++++---- 7 files changed, 430 insertions(+), 139 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8104c9b..a99bf71 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -34,7 +34,25 @@ function App() { const [newProjectName, setNewProjectName] = useState(''); const [newClientName, setNewClientName] = useState(''); const [pendingSignupCount, setPendingSignupCount] = useState(0); - const [inactiveProjects, setInactiveProjects] = useState(new Set()); + const [inactiveProjects, setInactiveProjects] = useState(() => { + // localStorage에서 비활성화된 프로젝트 목록 로드 + try { + const saved = localStorage.getItem('inactiveProjects'); + return saved ? new Set(JSON.parse(saved)) : new Set(); + } catch (error) { + console.error('비활성화 프로젝트 목록 로드 실패:', error); + return new Set(); + } + }); + + // 비활성화 프로젝트 목록이 변경될 때마다 localStorage에 저장 + useEffect(() => { + try { + localStorage.setItem('inactiveProjects', JSON.stringify(Array.from(inactiveProjects))); + } catch (error) { + console.error('비활성화 프로젝트 목록 저장 실패:', error); + } + }, [inactiveProjects]); // 승인 대기 중인 회원가입 수 조회 const loadPendingSignups = async () => { diff --git a/frontend/src/components/bom/materials/BoltMaterialsView.jsx b/frontend/src/components/bom/materials/BoltMaterialsView.jsx index c3b6a7b..f1006fb 100644 --- a/frontend/src/components/bom/materials/BoltMaterialsView.jsx +++ b/frontend/src/components/bom/materials/BoltMaterialsView.jsx @@ -149,17 +149,34 @@ const BoltMaterialsView = ({ }); }); - if (sortConfig.key) { + if (sortConfig && sortConfig.key) { filtered.sort((a, b) => { const aInfo = parseBoltInfo(a); const bInfo = parseBoltInfo(b); - const aValue = aInfo[sortConfig.key] || ''; - const bValue = bInfo[sortConfig.key] || ''; + + if (!aInfo || !bInfo) return 0; + + const aValue = aInfo[sortConfig.key]; + const bValue = bInfo[sortConfig.key]; + + // 값이 없는 경우 처리 + if (aValue === undefined && bValue === undefined) return 0; + if (aValue === undefined) return 1; + if (bValue === undefined) return -1; + + // 숫자인 경우 숫자로 비교 + if (typeof aValue === 'number' && typeof bValue === 'number') { + return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue; + } + + // 문자열로 비교 + const aStr = String(aValue).toLowerCase(); + const bStr = String(bValue).toLowerCase(); if (sortConfig.direction === 'asc') { - return aValue > bValue ? 1 : -1; + return aStr.localeCompare(bStr); } else { - return aValue < bValue ? 1 : -1; + return bStr.localeCompare(aStr); } }); } diff --git a/frontend/src/components/bom/materials/GasketMaterialsView.jsx b/frontend/src/components/bom/materials/GasketMaterialsView.jsx index 69d115c..6a7d583 100644 --- a/frontend/src/components/bom/materials/GasketMaterialsView.jsx +++ b/frontend/src/components/bom/materials/GasketMaterialsView.jsx @@ -10,7 +10,9 @@ const GasketMaterialsView = ({ userRequirements, setUserRequirements, purchasedMaterials, + onPurchasedMaterialsUpdate, fileId, + jobNo, user }) => { const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); @@ -21,27 +23,57 @@ const GasketMaterialsView = ({ const qty = Math.round(material.quantity || 0); const purchaseQty = Math.ceil(qty * 1.05 / 5) * 5; // 5% 여유율 + 5의 배수 - // original_description에서 재질 정보 파싱 (기존 NewMaterialsPage와 동일) const description = material.original_description || ''; - let materialStructure = '-'; // H/F/I/O 부분 - let materialDetail = '-'; // SS304/GRAPHITE/CS/CS 부분 - // H/F/I/O와 재질 상세 정보 추출 - const materialMatch = description.match(/H\/F\/I\/O\s+(.+?)(?:,|$)/); - if (materialMatch) { - materialStructure = 'H/F/I/O'; - materialDetail = materialMatch[1].trim(); - // 두께 정보 제거 (별도 추출) - materialDetail = materialDetail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim(); + // 가스켓 타입 풀네임 매핑 + const gasketTypeMap = { + 'SWG': 'SPIRAL WOUND GASKET', + 'RTJ': 'RING TYPE JOINT', + 'FF': 'FULL FACE GASKET', + 'RF': 'RAISED FACE GASKET', + 'SHEET': 'SHEET GASKET', + 'O-RING': 'O-RING GASKET' + }; + + // 타입 추출 및 풀네임 변환 + let gasketType = '-'; + const typeMatch = description.match(/\b(SWG|RTJ|FF|RF|SHEET|O-RING)\b/i); + if (typeMatch) { + const shortType = typeMatch[1].toUpperCase(); + gasketType = gasketTypeMap[shortType] || shortType; } - // 압력 정보 추출 + // 크기 정보 추출 (예: 1 1/2") + let size = material.size_spec || material.size_inch || '-'; + if (size === '-') { + const sizeMatch = description.match(/(\d+(?:\s+\d+\/\d+)?(?:\.\d+)?)\s*"/); + if (sizeMatch) { + size = sizeMatch[1] + '"'; + } + } + + // 압력등급 추출 let pressure = '-'; - const pressureMatch = description.match(/(\d+LB)/); + const pressureMatch = description.match(/(\d+LB)/i); if (pressureMatch) { pressure = pressureMatch[1]; } + // 구조 정보 추출 (H/F/I/O) + let structure = '-'; + if (description.includes('H/F/I/O')) { + structure = 'H/F/I/O'; + } + + // 재질 상세 정보 추출 (SS304/GRAPHITE/SS304/SS304) + let material_detail = '-'; + const materialMatch = description.match(/H\/F\/I\/O\s+([^,]+)/); + if (materialMatch) { + material_detail = materialMatch[1].trim(); + // 두께 정보 제거 + material_detail = material_detail.replace(/,?\s*\d+(?:\.\d+)?mm$/, '').trim(); + } + // 두께 정보 추출 let thickness = '-'; const thicknessMatch = description.match(/(\d+(?:\.\d+)?)\s*mm/i); @@ -50,17 +82,14 @@ const GasketMaterialsView = ({ } return { - type: 'GASKET', - subtype: 'SWG', // 항상 SWG로 표시 - size: material.size_spec || '-', + type: gasketType, // 풀네임으로 표시 (SPIRAL WOUND GASKET) + size: size, pressure: pressure, - schedule: thickness, // 두께를 schedule 열에 표시 - materialStructure: materialStructure, - materialDetail: materialDetail, + structure: structure, // H/F/I/O + material: material_detail, // SS304/GRAPHITE/SS304/SS304 thickness: thickness, - grade: materialDetail, // 재질 상세를 grade로 표시 - quantity: purchaseQty, - unit: '개', + userRequirements: material.user_requirements?.join(', ') || '-', + purchaseQuantity: purchaseQty, isGasket: true }; }; @@ -85,17 +114,34 @@ const GasketMaterialsView = ({ }); }); - if (sortConfig.key) { + if (sortConfig && sortConfig.key) { filtered.sort((a, b) => { const aInfo = parseGasketInfo(a); const bInfo = parseGasketInfo(b); - const aValue = aInfo[sortConfig.key] || ''; - const bValue = bInfo[sortConfig.key] || ''; + + if (!aInfo || !bInfo) return 0; + + const aValue = aInfo[sortConfig.key]; + const bValue = bInfo[sortConfig.key]; + + // 값이 없는 경우 처리 + if (aValue === undefined && bValue === undefined) return 0; + if (aValue === undefined) return 1; + if (bValue === undefined) return -1; + + // 숫자인 경우 숫자로 비교 + if (typeof aValue === 'number' && typeof bValue === 'number') { + return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue; + } + + // 문자열로 비교 + const aStr = String(aValue).toLowerCase(); + const bStr = String(bValue).toLowerCase(); if (sortConfig.direction === 'asc') { - return aValue > bValue ? 1 : -1; + return aStr.localeCompare(bStr); } else { - return aValue < bValue ? 1 : -1; + return bStr.localeCompare(aStr); } }); } @@ -147,28 +193,79 @@ const GasketMaterialsView = ({ })); try { - await api.post('/files/save-excel', { + console.log('🔄 가스켓 엑셀 내보내기 시작 - 새로운 방식'); + + // 1. 먼저 클라이언트에서 엑셀 파일 생성 + console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료'); + const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, { + category: 'GASKET', + filename: excelFileName, + uploadDate: new Date().toLocaleDateString() + }); + console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes'); + + // 2. 구매신청 생성 + const allMaterialIds = selectedMaterialsData.map(m => m.id); + const response = await api.post('/purchase-request/create', { file_id: fileId, + job_no: jobNo, category: 'GASKET', - materials: dataWithRequirements, - filename: excelFileName, - user_id: user?.id + material_ids: allMaterialIds, + materials_data: dataWithRequirements.map(m => ({ + material_id: m.id, + description: m.original_description, + category: m.classified_category, + size: m.size_inch || m.size_spec, + schedule: m.schedule, + material_grade: m.material_grade || m.full_material_grade, + quantity: m.quantity, + unit: m.unit, + user_requirement: userRequirements[m.id] || '' + })) }); - exportMaterialsToExcel(dataWithRequirements, excelFileName, { - category: 'GASKET', - filename: excelFileName, - uploadDate: new Date().toLocaleDateString() - }); + if (response.data.success) { + console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`); + + // 3. 생성된 엑셀 파일을 서버에 업로드 + console.log('📤 서버에 엑셀 파일 업로드 중...'); + const formData = new FormData(); + formData.append('excel_file', excelBlob, excelFileName); + formData.append('request_id', response.data.request_id); + formData.append('category', 'GASKET'); + + const uploadResponse = await api.post('/purchase-request/upload-excel', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + console.log('✅ 엑셀 업로드 완료:', uploadResponse.data); - alert('엑셀 파일이 생성되고 서버에 저장되었습니다.'); + if (onPurchasedMaterialsUpdate) { + onPurchasedMaterialsUpdate(allMaterialIds); + } + } + + // 4. 클라이언트 다운로드 + 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); + + alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`); } catch (error) { - console.error('엑셀 저장 실패:', error); + console.error('엑셀 저장 또는 구매신청 실패:', error); + // 실패 시에도 클라이언트 다운로드는 진행 exportMaterialsToExcel(dataWithRequirements, excelFileName, { category: 'GASKET', filename: excelFileName, uploadDate: new Date().toLocaleDateString() }); + alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.'); } }; @@ -236,20 +333,23 @@ const GasketMaterialsView = ({
+
{/* 헤더 */}
- Type - Size - Pressure - Thickness - Material Grade - Quantity -
Unit
-
User Requirement
+ + Type + + + Size + + + Pressure + + + Structure + + + Material + + + Thickness + +
User Requirements
+
Additional Request
+ + Purchase Quantity +
- {/* 데이터 행들 */} -
+ {/* 데이터 행들 */} {filteredMaterials.map((material, index) => { const info = parseGasketInfo(material); const isSelected = selectedMaterials.has(material.id); @@ -284,7 +461,7 @@ const GasketMaterialsView = ({ key={material.id} style={{ display: 'grid', - gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px', + gridTemplateColumns: '50px 250px 120px 100px 120px 200px 100px 180px 150px 120px', gap: '16px', padding: '16px', borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none', @@ -314,8 +491,8 @@ const GasketMaterialsView = ({ }} />
-
- {info.subtype} +
+ {info.type} {isPurchased && ( )}
-
+
{info.size}
-
+
{info.pressure}
-
- {info.schedule} +
+ {info.structure}
-
- {info.grade} +
+ {info.material}
-
- {info.quantity} +
+ {info.thickness}
-
- {info.unit} +
+ {info.userRequirements}
+
+ {info.purchaseQuantity.toLocaleString()} +
); })} diff --git a/frontend/src/components/bom/materials/SupportMaterialsView.jsx b/frontend/src/components/bom/materials/SupportMaterialsView.jsx index 0b3a6a2..b98fd81 100644 --- a/frontend/src/components/bom/materials/SupportMaterialsView.jsx +++ b/frontend/src/components/bom/materials/SupportMaterialsView.jsx @@ -66,17 +66,34 @@ const SupportMaterialsView = ({ }); }); - if (sortConfig.key) { + if (sortConfig && sortConfig.key) { filtered.sort((a, b) => { const aInfo = parseSupportInfo(a); const bInfo = parseSupportInfo(b); - const aValue = aInfo[sortConfig.key] || ''; - const bValue = bInfo[sortConfig.key] || ''; + + if (!aInfo || !bInfo) return 0; + + const aValue = aInfo[sortConfig.key]; + const bValue = bInfo[sortConfig.key]; + + // 값이 없는 경우 처리 + if (aValue === undefined && bValue === undefined) return 0; + if (aValue === undefined) return 1; + if (bValue === undefined) return -1; + + // 숫자인 경우 숫자로 비교 + if (typeof aValue === 'number' && typeof bValue === 'number') { + return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue; + } + + // 문자열로 비교 + const aStr = String(aValue).toLowerCase(); + const bStr = String(bValue).toLowerCase(); if (sortConfig.direction === 'asc') { - return aValue > bValue ? 1 : -1; + return aStr.localeCompare(bStr); } else { - return aValue < bValue ? 1 : -1; + return bStr.localeCompare(aStr); } }); } diff --git a/frontend/src/components/bom/materials/ValveMaterialsView.jsx b/frontend/src/components/bom/materials/ValveMaterialsView.jsx index ce2edeb..699ba66 100644 --- a/frontend/src/components/bom/materials/ValveMaterialsView.jsx +++ b/frontend/src/components/bom/materials/ValveMaterialsView.jsx @@ -94,17 +94,34 @@ const ValveMaterialsView = ({ }); }); - if (sortConfig.key) { + if (sortConfig && sortConfig.key) { filtered.sort((a, b) => { const aInfo = parseValveInfo(a); const bInfo = parseValveInfo(b); - const aValue = aInfo[sortConfig.key] || ''; - const bValue = bInfo[sortConfig.key] || ''; + + if (!aInfo || !bInfo) return 0; + + const aValue = aInfo[sortConfig.key]; + const bValue = bInfo[sortConfig.key]; + + // 값이 없는 경우 처리 + if (aValue === undefined && bValue === undefined) return 0; + if (aValue === undefined) return 1; + if (bValue === undefined) return -1; + + // 숫자인 경우 숫자로 비교 + if (typeof aValue === 'number' && typeof bValue === 'number') { + return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue; + } + + // 문자열로 비교 + const aStr = String(aValue).toLowerCase(); + const bStr = String(bValue).toLowerCase(); if (sortConfig.direction === 'asc') { - return aValue > bValue ? 1 : -1; + return aStr.localeCompare(bStr); } else { - return aValue < bValue ? 1 : -1; + return bStr.localeCompare(aStr); } }); } diff --git a/frontend/src/components/bom/shared/FilterableHeader.jsx b/frontend/src/components/bom/shared/FilterableHeader.jsx index a0a28dc..37d84e2 100644 --- a/frontend/src/components/bom/shared/FilterableHeader.jsx +++ b/frontend/src/components/bom/shared/FilterableHeader.jsx @@ -19,7 +19,7 @@ const FilterableHeader = ({ style={{ cursor: 'pointer', flex: 1 }} > {children} - {sortConfig.key === sortKey && ( + {sortConfig && sortConfig.key === sortKey && ( {sortConfig.direction === 'asc' ? '↑' : '↓'} diff --git a/frontend/src/utils/excelExport.js b/frontend/src/utils/excelExport.js index 75140d0..f491e5f 100644 --- a/frontend/src/utils/excelExport.js +++ b/frontend/src/utils/excelExport.js @@ -422,37 +422,43 @@ const formatMaterialForExcel = (material, includeComparison = false) => { } } } else if (category === 'GASKET') { - // 가스켓 상세 타입 표시 + // BOM 페이지의 타입을 따라가도록 - 프론트엔드 parseGasketInfo와 동일한 로직 const gasketDetails = material.gasket_details || {}; const gasketType = gasketDetails.gasket_type || ''; - const gasketSubtype = gasketDetails.gasket_subtype || ''; - if (gasketType === 'SPIRAL_WOUND') { - itemName = '스파이럴 워운드 가스켓'; - } else if (gasketType === 'RING_JOINT') { - itemName = '링 조인트 가스켓'; - } else if (gasketType === 'FULL_FACE') { - itemName = '풀 페이스 가스켓'; - } else if (gasketType === 'RAISED_FACE') { - itemName = '레이즈드 페이스 가스켓'; - } else if (gasketSubtype && gasketSubtype !== gasketType) { - itemName = gasketSubtype; - } else if (gasketType) { - itemName = gasketType; + // 가스켓 타입 매핑 (프론트엔드와 동일) + const gasketTypeMap = { + 'SWG': 'SPIRAL WOUND GASKET', + 'SPIRAL_WOUND': 'SPIRAL WOUND GASKET', + 'RTJ': 'RING TYPE JOINT GASKET', + 'RING_JOINT': 'RING TYPE JOINT GASKET', + 'FF': 'FULL FACE GASKET', + 'FULL_FACE': 'FULL FACE GASKET', + 'RF': 'RAISED FACE GASKET', + 'RAISED_FACE': 'RAISED FACE GASKET' + }; + + // Description에서 가스켓 타입 추출 + const descUpper = cleanDescription.toUpperCase(); + let extractedType = ''; + + if (descUpper.includes('SWG') || descUpper.includes('SPIRAL')) { + extractedType = 'SWG'; + } else if (descUpper.includes('RTJ') || descUpper.includes('RING')) { + extractedType = 'RTJ'; + } else if (descUpper.includes('FF') || descUpper.includes('FULL FACE')) { + extractedType = 'FF'; + } else if (descUpper.includes('RF') || descUpper.includes('RAISED')) { + extractedType = 'RF'; + } + + // 풀네임으로 변환 + if (gasketType && gasketTypeMap[gasketType]) { + itemName = gasketTypeMap[gasketType]; + } else if (extractedType && gasketTypeMap[extractedType]) { + itemName = gasketTypeMap[extractedType]; } else { - // gasket_details가 없으면 description에서 추출 - const desc = cleanDescription.toUpperCase(); - if (desc.includes('SWG') || desc.includes('SPIRAL')) { - itemName = '스파이럴 워운드 가스켓'; - } else if (desc.includes('RTJ') || desc.includes('RING')) { - itemName = '링 조인트 가스켓'; - } else if (desc.includes('FF') || desc.includes('FULL FACE')) { - itemName = '풀 페이스 가스켓'; - } else if (desc.includes('RF') || desc.includes('RAISED')) { - itemName = '레이즈드 페이스 가스켓'; - } else { - itemName = '가스켓'; - } + itemName = 'GASKET'; } } else if (category === 'BOLT') { // 볼트 상세 타입 표시 @@ -660,28 +666,33 @@ const formatMaterialForExcel = (material, includeComparison = false) => { detailInfo = surfaceTreatments.join(', ') || '-'; } else if (category === 'GASKET') { - // 실제 재질 구성 정보 (SS304/GRAPHITE/SS304/SS304) - if (material.gasket_details) { - const materialType = material.gasket_details.material_type || ''; - const fillerMaterial = material.gasket_details.filler_material || ''; - - if (materialType && fillerMaterial) { - // DB에서 가져온 정보로 구성 - gasketMaterial = `${materialType}/${fillerMaterial}`; - } - } - - // gasket_details가 없거나 불완전하면 description에서 추출 - if (!gasketMaterial) { - // SS304/GRAPHITE/SS304/SS304 패턴 추출 - const fullMaterialMatch = cleanDescription.match(/SS304\/GRAPHITE\/SS304\/SS304/i); - if (fullMaterialMatch) { - gasketMaterial = 'SS304/GRAPHITE/SS304/SS304'; + // 실제 재질 구성 정보 - description에서 우선 추출 + // SS304/GRAPHITE/SS304/SS304 패턴 먼저 찾기 + const fullMaterialMatch = cleanDescription.match(/SS304\/GRAPHITE\/SS304\/SS304/i); + if (fullMaterialMatch) { + gasketMaterial = 'SS304/GRAPHITE/SS304/SS304'; + } else { + // 4개 재질 패턴 (다양한 재질 조합) + const fourMaterialMatch = cleanDescription.match(/(SS\d+|304|316|CS)\/(GRAPHITE|PTFE|VITON|EPDM)\/(SS\d+|304|316|CS)\/(SS\d+|304|316|CS)/i); + if (fourMaterialMatch) { + gasketMaterial = `${fourMaterialMatch[1]}/${fourMaterialMatch[2]}/${fourMaterialMatch[3]}/${fourMaterialMatch[4]}`; } else { - // 간단한 패턴 (SS304/GRAPHITE) - const simpleMaterialMatch = cleanDescription.match(/(SS\d+|304|316)\s*[\/+]\s*(GRAPHITE|PTFE|VITON|EPDM)/i); - if (simpleMaterialMatch) { - gasketMaterial = `${simpleMaterialMatch[1]}/${simpleMaterialMatch[2]}`; + // DB에서 가져온 정보로 구성 (fallback) + if (material.gasket_details) { + const materialType = material.gasket_details.material_type || ''; + const fillerMaterial = material.gasket_details.filler_material || ''; + + if (materialType && fillerMaterial) { + gasketMaterial = `${materialType}/${fillerMaterial}`; + } + } + + // 마지막으로 간단한 패턴 + if (!gasketMaterial) { + const simpleMaterialMatch = cleanDescription.match(/(SS\d+|304|316)\s*[\/+]\s*(GRAPHITE|PTFE|VITON|EPDM)/i); + if (simpleMaterialMatch) { + gasketMaterial = `${simpleMaterialMatch[1]}/${simpleMaterialMatch[2]}`; + } } } } @@ -794,17 +805,41 @@ const formatMaterialForExcel = (material, includeComparison = false) => { base['관리항목4'] = ''; // N열 base['관리항목5'] = ''; // O열 } else if (category === 'GASKET') { - // 가스켓 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨 + // 가스켓 전용 컬럼 (F~O) - 재질을 2개로 분리 + // 재질 분리 로직: SS304/GRAPHITE/SS304/SS304 → 재질1: SS304/GRAPHITE, 재질2: /SS304/SS304 + let material1 = '-'; + let material2 = '-'; + + if (gasketMaterial && gasketMaterial.includes('/')) { + const materialParts = gasketMaterial.split('/'); + + if (materialParts.length >= 4) { + // 4개 재질인 경우: SS304/GRAPHITE/SS304/SS304 + material1 = `${materialParts[0]}/${materialParts[1]}`; + material2 = `/${materialParts[2]}/${materialParts[3]}`; + } else if (materialParts.length === 3) { + // 3개 재질인 경우: SS304/GRAPHITE/SS304 + material1 = `${materialParts[0]}/${materialParts[1]}`; + material2 = `/${materialParts[2]}`; + } else if (materialParts.length === 2) { + // 2개 재질인 경우: SS304/GRAPHITE + material1 = gasketMaterial; + material2 = '-'; + } + } else if (gasketMaterial) { + material1 = gasketMaterial; + } + base['크기'] = material.size_spec || '-'; // F열 base['압력등급'] = pressure; // G열 - base['구조'] = grade; // H열: H/F/I/O, SWG 등 (타입 정보 제거) - base['재질'] = gasketMaterial || '-'; // I열: SS304/GRAPHITE/SS304/SS304 - base['두께'] = gasketThickness || '-'; // J열: 4.5mm - base['사용자요구'] = material.user_requirement || ''; // K열 - base['관리항목1'] = ''; // L열 - base['관리항목2'] = ''; // M열 - base['관리항목3'] = ''; // N열 - base['관리항목4'] = ''; // O열 + base['구조'] = grade; // H열: H/F/I/O, SWG 등 + base['재질1'] = material1; // I열: SS304/GRAPHITE + base['재질2'] = material2; // J열: SS304/SS304 + base['두께'] = gasketThickness || '-'; // K열: 4.5mm + base['사용자요구'] = material.user_requirement || ''; // L열 + base['관리항목1'] = ''; // M열 + base['관리항목2'] = ''; // N열 + base['관리항목3'] = ''; // O열 } else if (category === 'BOLT') { // 볼트 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨 base['크기'] = material.size_spec || '-'; // F열 @@ -1077,7 +1112,7 @@ export const createExcelBlob = async (materials, filename, options = {}) => { 'FITTING': ['크기', '압력등급', '스케줄', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'FLANGE': ['크기', '페이싱', '압력등급', '스케줄', '재질', '추가요구사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'VALVE': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], - 'GASKET': ['크기', '압력등급', '구조', '재질', '두께', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], + 'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '관리항목1', '관리항목2', '관리항목3'], 'BOLT': ['크기', '압력등급', '길이', '재질', '추가요구', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'SUPPORT': ['크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], };