import * as XLSX from 'xlsx'; import { saveAs } from 'file-saver'; import { calculatePurchaseQuantity } from './purchaseCalculator'; /** * 자재 목록을 카테고리별로 그룹화 */ const groupMaterialsByCategory = (materials) => { const groups = {}; materials.forEach(material => { const category = material.classified_category || material.category || 'UNCATEGORIZED'; if (!groups[category]) { groups[category] = []; } groups[category].push(material); }); return groups; }; /** * 동일한 자재끼리 합치기 (자재 설명 + 사이즈 기준) * 엑셀 내보내기용 특별 처리: * - PIPE: 끝단 정보 제거 (BOE-POE, POE-TOE 등) * - NIPPLE: 길이별 구분 (75mm, 100mm 등) */ const consolidateMaterials = (materials, isComparison = false) => { const consolidated = {}; materials.forEach(material => { const category = material.classified_category || material.category || 'UNCATEGORIZED'; let description = material.original_description || material.description || ''; const sizeSpec = material.size_spec || ''; // 파이프 끝단 정보 제거 (엑셀 내보내기용) if (category === 'PIPE') { description = description .replace(/\s+(BOE-POE|POE-TOE|BOE-TOE|BOE-BBE|BBE-POE|BBE-BBE|POE-POE|TOE-TOE)\s*$/i, '') .replace(/\s+(BOE|POE|TOE|BBE)\s*-\s*(BOE|POE|TOE|BBE)\s*$/i, '') .trim(); } // 니플의 경우 길이 정보를 그룹화 키에 포함 let lengthInfo = ''; if (category === 'FITTING' && description.toLowerCase().includes('nipple')) { const lengthMatch = description.match(/(\d+)\s*mm/i); if (lengthMatch) { lengthInfo = `_${lengthMatch[1]}mm`; } } // 그룹화 키: 카테고리 + 정제된자재설명 + 사이즈 + 길이정보 const groupKey = `${category}|${description}|${sizeSpec}${lengthInfo}`; if (!consolidated[groupKey]) { consolidated[groupKey] = { ...material, // 정제된 설명으로 덮어쓰기 original_description: description, description: description, quantity: 0, totalLength: 0, // 파이프용 itemCount: 0, // 파이프 개수 lineNumbers: [], // 라인 번호들 // 비교 모드용 previous_quantity: 0, current_quantity: 0, quantity_change: 0 }; } const group = consolidated[groupKey]; group.quantity += material.quantity || 0; // 비교 모드인 경우 이전/현재 수량도 합산 if (isComparison) { group.previous_quantity += material.previous_quantity || 0; group.current_quantity += material.current_quantity || 0; group.quantity_change += material.quantity_change || 0; } // 파이프인 경우 길이 계산 if (category === 'PIPE') { const lengthMm = material.pipe_details?.length_mm || 0; const lengthM = lengthMm / 1000; if (isComparison) { // 비교 모드에서는 이전/현재 길이 계산 group.totalLength += lengthM * (material.current_quantity || material.quantity || 0); group.itemCount += material.current_quantity || material.quantity || 0; // 이전 길이도 저장 if (!group.previousTotalLength) group.previousTotalLength = 0; group.previousTotalLength += lengthM * (material.previous_quantity || 0); } else { group.totalLength += lengthM * (material.quantity || 0); group.itemCount += material.quantity || 0; } } // 라인 번호 수집 if (material.line_number) { group.lineNumbers.push(material.line_number); } }); // 라인 번호를 문자열로 변환 Object.values(consolidated).forEach(group => { group.line_number = group.lineNumbers.length > 0 ? group.lineNumbers.join(', ') : '-'; }); return Object.values(consolidated); }; /** * 자재 데이터를 엑셀용 형태로 변환 */ const formatMaterialForExcel = (material, includeComparison = false) => { const category = material.classified_category || material.category || '-'; // 엑셀용 자재 설명 정제 let cleanDescription = material.original_description || material.description || '-'; // 파이프 끝단 정보 제거 if (category === 'PIPE') { cleanDescription = cleanDescription .replace(/\s+(BOE-POE|POE-TOE|BOE-TOE|BOE-BBE|BBE-POE|BBE-BBE|POE-POE|TOE-TOE)\s*$/i, '') .replace(/\s+(BOE|POE|TOE|BBE)\s*-\s*(BOE|POE|TOE|BBE)\s*$/i, '') .trim(); } // 니플의 경우 길이 정보 명시적 추가 if (category === 'FITTING' && cleanDescription.toLowerCase().includes('nipple')) { if (material.fitting_details && material.fitting_details.length_mm) { const lengthMm = Math.round(material.fitting_details.length_mm); if (!cleanDescription.match(/\d+\s*mm/i)) { cleanDescription += ` ${lengthMm}mm`; } } else { const lengthMatch = material.original_description?.match(/(\d+)\s*mm/i); if (lengthMatch && !cleanDescription.match(/\d+\s*mm/i)) { cleanDescription += ` ${lengthMatch[1]}mm`; } } } // 구매 수량 계산 const purchaseInfo = calculatePurchaseQuantity(material); // 품목명 생성 (카테고리별 상세 처리) let itemName = ''; if (category === 'PIPE') { itemName = material.pipe_details?.manufacturing_method || 'PIPE'; } else if (category === 'FITTING') { itemName = material.fitting_details?.fitting_type || 'FITTING'; } else if (category === 'FLANGE') { // 플랜지 타입 추출 const desc = cleanDescription.toUpperCase(); console.log('🔍 FLANGE 품목명 추출:', cleanDescription); if (material.flange_details) { console.log(' flange_details:', material.flange_details); const flangeType = material.flange_details.flange_type || ''; const originalFlangeType = material.flange_details.original_flange_type || ''; const facingType = material.flange_details.facing_type || ''; // 특수 플랜지 타입 우선 if (desc.includes('ORIFICE')) { itemName = 'ORIFICE FLANGE'; } else if (desc.includes('SPECTACLE')) { itemName = 'SPECTACLE BLIND'; } else if (desc.includes('PADDLE')) { itemName = 'PADDLE BLIND'; } else if (desc.includes('SPACER')) { itemName = 'SPACER'; } else if (desc.includes('BLIND')) { itemName = 'BLIND FLANGE'; } else { // 일반 플랜지: flange_type 사용 (WN RF, SO RF 등) itemName = flangeType || 'FLANGE'; } } else { console.log(' flange_details 없음, description에서 추출'); // flange_details가 없으면 description에서 추출 if (desc.includes('ORIFICE')) { itemName = 'ORIFICE FLANGE'; } else if (desc.includes('SPECTACLE') || desc.includes('SPEC')) { itemName = 'SPECTACLE BLIND'; } else if (desc.includes('PADDLE')) { itemName = 'PADDLE BLIND'; } else if (desc.includes('SPACER')) { itemName = 'SPACER'; } else if (desc.includes('BLIND')) { itemName = 'BLIND FLANGE'; } else if (desc.includes(' SW') || desc.includes(',SW') || desc.includes(', SW')) { itemName = 'FLANGE SW'; } else if (desc.includes(' BW') || desc.includes(',BW') || desc.includes(', BW')) { itemName = 'FLANGE BW'; } else if (desc.includes('RTJ')) { itemName = 'FLANGE RTJ'; } else if (desc.includes(' FF') || desc.includes('FULL FACE')) { itemName = 'FLANGE FF'; } else if (desc.includes(' RF') || desc.includes('RAISED')) { itemName = 'FLANGE RF'; } else { itemName = 'FLANGE'; } } console.log(' → 품목명:', itemName); } else if (category === 'VALVE') { itemName = 'VALVE'; } else if (category === 'GASKET') { // 가스켓 상세 타입 추출 if (material.gasket_details) { const gasketType = material.gasket_details.gasket_type || ''; const gasketSubtype = material.gasket_details.gasket_subtype || ''; if (gasketSubtype && gasketSubtype !== gasketType) { itemName = gasketSubtype; } else if (gasketType) { itemName = gasketType; } else { itemName = 'GASKET'; } } else { // gasket_details가 없으면 description에서 추출 if (cleanDescription.includes('SWG') || cleanDescription.includes('SPIRAL')) { itemName = 'SWG'; } else if (cleanDescription.includes('RTJ') || cleanDescription.includes('RING')) { itemName = 'RTJ'; } else if (cleanDescription.includes('FF') || cleanDescription.includes('FULL FACE')) { itemName = 'FF'; } else if (cleanDescription.includes('RF') || cleanDescription.includes('RAISED')) { itemName = 'RF'; } else { itemName = 'GASKET'; } } } else if (category === 'BOLT') { itemName = 'BOLT'; } else if (category === 'SUPPORT' || category === 'U_BOLT') { // SUPPORT 카테고리: 타입별 구분 const desc = cleanDescription.toUpperCase(); if (desc.includes('URETHANE') || desc.includes('BLOCK SHOE')) { itemName = 'URETHANE BLOCK SHOE'; } else if (desc.includes('CLAMP')) { itemName = 'CLAMP'; } else if (desc.includes('U-BOLT') || desc.includes('U BOLT')) { itemName = 'U-BOLT'; } else { itemName = 'SUPPORT'; } } else { itemName = category || 'UNKNOWN'; } // 압력 등급 추출 (카테고리별 처리) let pressure = '-'; if (category === 'GASKET') { // 가스켓의 경우 gasket_details에서 압력등급 추출 if (material.gasket_details && material.gasket_details.pressure_rating) { pressure = material.gasket_details.pressure_rating; } else { // gasket_details가 없으면 description에서 추출 const pressureMatch = cleanDescription.match(/(\d+)LB/i); if (pressureMatch) { pressure = `${pressureMatch[1]}LB`; } } } else { // 다른 카테고리는 기존 방식 const pressureMatch = cleanDescription.match(/(\d+)LB/i); if (pressureMatch) { pressure = `${pressureMatch[1]}LB`; } } // 스케줄/길이 추출 (카테고리별 처리) let schedule = '-'; if (category === 'BOLT') { // 볼트의 경우 길이 정보 추출 const lengthPatterns = [ /(\d+(?:\.\d+)?)\s*LG/i, // 145.0000 LG, 75 LG /(\d+(?:\.\d+)?)\s*mm/i, // 50mm /(\d+(?:\.\d+)?)\s*MM/i, // 50MM /,\s*(\d+(?:\.\d+)?)\s*LG/i, // ", 145.0000 LG" 형태 /LG[,\s]*(\d+(?:\.\d+)?)/i // LG, 75 형태 ]; for (const pattern of lengthPatterns) { const match = cleanDescription.match(pattern); if (match) { let lengthValue = match[1]; // 소수점 제거 (145.0000 → 145) if (lengthValue.includes('.') && lengthValue.endsWith('.0000')) { lengthValue = lengthValue.split('.')[0]; } else if (lengthValue.includes('.') && /\.0+$/.test(lengthValue)) { lengthValue = lengthValue.split('.')[0]; } schedule = `${lengthValue}mm`; break; } } } else { // 다른 카테고리는 스케줄 추출 const scheduleMatch = cleanDescription.match(/SCH\s*(\d+[A-Z]*(?:\s*[xX×]\s*SCH\s*\d+[A-Z]*)?)/i); if (scheduleMatch) { schedule = scheduleMatch[0]; } } // 재질 추출 (카테고리별 처리) let grade = '-'; if (category === 'GASKET') { // 가스켓의 경우 재질 필드에는 H/F/I/O 타입 정보 표시 const hfioMatch = cleanDescription.match(/H\/F\/I\/O/i); if (hfioMatch) { grade = 'H/F/I/O'; } else { // 다른 가스켓 타입들 if (cleanDescription.includes('SWG') || cleanDescription.includes('SPIRAL')) { grade = 'SWG'; } else if (cleanDescription.includes('RTJ') || cleanDescription.includes('RING')) { grade = 'RTJ'; } else if (cleanDescription.includes('FF') || cleanDescription.includes('FULL FACE')) { grade = 'FF'; } else if (cleanDescription.includes('RF') || cleanDescription.includes('RAISED')) { grade = 'RF'; } else { grade = 'GASKET'; } } } else if (category === 'BOLT') { // 볼트 전용 재질 추출 (복합 ASTM 패턴 지원) const boltGradePatterns = [ // 복합 ASTM 패턴 (A193/A194 GR B7/2H) /(ASTM\s+A\d+\/A\d+\s+GR\s+[A-Z0-9\/]+)/i, // 단일 ASTM 패턴 (ASTM A193 GR B7) /(ASTM\s+A\d+\s+GR\s+[A-Z0-9]+)/i, // ASTM 번호만 (ASTM A193/A194) /(ASTM\s+A\d+(?:\/A\d+)?)/i, // 일반 ASTM 패턴 /(ASTM\s+[A-Z0-9\s\/]+(?:TP\d+|GR\s*[A-Z0-9\/]+|WP\d+)?)/i ]; for (const pattern of boltGradePatterns) { const match = cleanDescription.match(pattern); if (match) { grade = match[1].trim(); break; } } // ASTM이 없는 경우 기본 재질 패턴 시도 if (grade === '-') { const basicGradeMatch = cleanDescription.match(/(A\d+(?:\/A\d+)?\s+(?:GR\s+)?[A-Z0-9\/]+)/i); if (basicGradeMatch) { grade = basicGradeMatch[1].trim(); } } // 백엔드에서 제공하는 재질 정보 우선 사용 if (material.full_material_grade && material.full_material_grade !== '-') { grade = material.full_material_grade; } else if (material.material_grade && material.material_grade !== '-') { grade = material.material_grade; } } else { // 기존 ASTM 패턴 (다른 카테고리용) const gradeMatch = cleanDescription.match(/(ASTM\s+[A-Z0-9\s]+(?:TP\d+|GR\s*[A-Z0-9]+|WP\d+)?)/i); if (gradeMatch) { grade = gradeMatch[1].trim(); } } // 카테고리별 상세 정보 추출 let detailInfo = ''; let gasketMaterial = ''; let gasketThickness = ''; if (category === 'BOLT') { // 볼트의 경우 표면처리 정보 추출 const surfaceTreatments = []; // 원본 설명에서 표면처리 패턴 확인 const surfacePatterns = { 'ELEC.GALV': 'ELEC.GALV', 'ELEC GALV': 'ELEC.GALV', 'GALVANIZED': 'GALVANIZED', 'GALV': 'GALV', 'HOT DIP GALV': 'HDG', 'HDG': 'HDG', 'ZINC PLATED': 'ZINC PLATED', 'ZINC': 'ZINC', 'PLAIN': 'PLAIN' }; for (const [pattern, treatment] of Object.entries(surfacePatterns)) { if (cleanDescription.includes(pattern)) { surfaceTreatments.push(treatment); break; // 첫 번째 매치만 사용 } } 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'; } 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]}`; } } } // 두께 정보 별도 추출 if (material.gasket_details && material.gasket_details.thickness) { gasketThickness = `${material.gasket_details.thickness}mm`; } else { // description에서 두께 추출 (4.5mm 패턴) const thicknessMatch = cleanDescription.match(/(\d+\.?\d*)\s*mm/i); if (thicknessMatch) { gasketThickness = `${thicknessMatch[1]}mm`; } } // 기타 상세 정보 (Fire Safe 등) const otherDetails = []; if (material.gasket_details && material.gasket_details.fire_safe) { otherDetails.push('Fire Safe'); } detailInfo = otherDetails.join(', '); } // 수량 계산 (PIPE는 본 단위) let quantity = purchaseInfo.purchaseQuantity || material.quantity || 0; if (category === 'PIPE') { // PIPE의 경우 본 단위로 계산 if (material.total_length) { // 총 길이를 6000mm로 나누어 본 수 계산 quantity = Math.ceil(material.total_length / 6000); } else if (material.pipe_details && material.pipe_details.total_length_mm) { quantity = Math.ceil(material.pipe_details.total_length_mm / 6000); } else if (material.pipe_count) { quantity = material.pipe_count; } } // 새로운 엑셀 양식에 맞춘 데이터 구조 const base = { 'TAGNO': '', // 비워둠 '품목명': itemName, '수량': quantity, '통화구분': 'KRW', // 기본값 '단가': 1, // 일괄 1로 설정 '크기': material.size_spec || '-', '압력등급': pressure }; // 카테고리별 전용 컬럼 구성 if (category === 'GASKET') { // 가스켓 전용 컬럼 순서 base['타입/구조'] = grade; // H/F/I/O, SWG 등 (스케줄 대신) base['재질'] = gasketMaterial || '-'; // SS304/GRAPHITE/SS304/SS304 base['두께'] = gasketThickness || '-'; // 4.5mm base['사용자요구'] = material.user_requirement || ''; base['관리항목8'] = ''; // 빈칸 base['관리항목9'] = ''; // 빈칸 base['관리항목10'] = ''; // 빈칸 base['납기일(YYYY-MM-DD)'] = new Date().toISOString().split('T')[0]; // 가장 마지막 } else if (category === 'BOLT') { // 볼트 전용 컬럼 순서 (스케줄 → 길이) base['길이'] = schedule; // 볼트는 길이 정보 base['재질'] = grade; base['추가요구'] = detailInfo || '-'; // 상세내역 → 추가요구로 변경 base['사용자요구'] = material.user_requirement || ''; base['관리항목1'] = ''; // 빈칸 base['관리항목7'] = ''; // 빈칸 base['관리항목8'] = ''; // 빈칸 base['관리항목9'] = ''; // 빈칸 base['관리항목10'] = ''; // 빈칸 base['납기일(YYYY-MM-DD)'] = new Date().toISOString().split('T')[0]; // 가장 마지막 } else { // 다른 카테고리는 기존 방식 base['스케줄'] = schedule; base['재질'] = grade; base['상세내역'] = detailInfo || '-'; base['사용자요구'] = material.user_requirement || ''; base['관리항목1'] = ''; // 빈칸 base['관리항목7'] = ''; // 빈칸 base['관리항목8'] = ''; // 빈칸 base['관리항목9'] = ''; // 빈칸 base['관리항목10'] = ''; // 빈칸 base['납기일(YYYY-MM-DD)'] = new Date().toISOString().split('T')[0]; // 가장 마지막 } // 비교 모드인 경우 추가 정보 if (includeComparison) { if (material.previous_quantity !== undefined) { const prevPurchaseInfo = calculatePurchaseQuantity({ ...material, quantity: material.previous_quantity, totalLength: material.previousTotalLength || 0 }); base['이전수량'] = prevPurchaseInfo.purchaseQuantity || 0; base['수량변경'] = (purchaseInfo.purchaseQuantity - prevPurchaseInfo.purchaseQuantity); } base['변경유형'] = material.change_type || ( material.previous_quantity !== undefined ? '수량 변경' : material.quantity_change === undefined ? '신규' : '변경' ); } return base; }; /** * 일반 자재 목록 엑셀 내보내기 */ export const exportMaterialsToExcel = (materials, filename, additionalInfo = {}) => { try { console.log('🔧 exportMaterialsToExcel 시작:', materials.length, '개 자재'); // 카테고리별로 그룹화 const categoryGroups = groupMaterialsByCategory(materials); console.log('📁 카테고리별 그룹:', Object.keys(categoryGroups).map(k => `${k}: ${categoryGroups[k].length}개`)); // 전체 자재 합치기 (먼저 계산) const consolidatedMaterials = consolidateMaterials(materials); console.log('📦 합쳐진 자재:', consolidatedMaterials.length, '개'); // 새 워크북 생성 const workbook = XLSX.utils.book_new(); // 카테고리별 시트 생성 (합쳐진 자재) Object.entries(categoryGroups).forEach(([category, items]) => { console.log(`📄 ${category} 시트 생성 중... (${items.length}개 자재)`); const consolidatedItems = consolidateMaterials(items); console.log(` → 합쳐진 결과: ${consolidatedItems.length}개`); const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material)); console.log(` → 포맷 완료: ${formattedItems.length}개`); if (formattedItems.length > 0) { const categorySheet = XLSX.utils.json_to_sheet(formattedItems); // 헤더 스타일링 (연하늘색 배경) const range = XLSX.utils.decode_range(categorySheet['!ref']); // 헤더 행에 스타일 적용 (첫 번째 행) for (let col = range.s.c; col <= range.e.c; col++) { const cellRef = XLSX.utils.encode_cell({ r: 0, c: col }); if (categorySheet[cellRef]) { // 기존 셀 값 유지하면서 스타일만 추가 const cellValue = categorySheet[cellRef].v; const cellType = categorySheet[cellRef].t; categorySheet[cellRef] = { v: cellValue, t: cellType || 's', s: { fill: { patternType: "solid", fgColor: { rgb: "B3D9FF" } }, font: { bold: true, color: { rgb: "000000" }, sz: 12, name: "맑은 고딕" }, alignment: { horizontal: "center", vertical: "center" }, border: { top: { style: "thin", color: { rgb: "666666" } }, bottom: { style: "thin", color: { rgb: "666666" } }, left: { style: "thin", color: { rgb: "666666" } }, right: { style: "thin", color: { rgb: "666666" } } } } }; } } // 컬럼 너비 자동 조정 const colWidths = []; if (formattedItems.length > 0) { const headers = Object.keys(formattedItems[0]); headers.forEach((header, index) => { let maxWidth = header.length; // 헤더 길이 // 각 행의 데이터 길이 확인 formattedItems.forEach(item => { const cellValue = String(item[header] || ''); maxWidth = Math.max(maxWidth, cellValue.length); }); // 최소 10, 최대 50으로 제한 colWidths[index] = { wch: Math.min(Math.max(maxWidth + 2, 10), 50) }; }); } categorySheet['!cols'] = colWidths; // 시트명에서 특수문자 제거 (엑셀 시트명 규칙) const sheetName = category.replace(/[\\\/\*\?\[\]]/g, '_').substring(0, 31); XLSX.utils.book_append_sheet(workbook, categorySheet, sheetName); } }); // 워크북 속성 설정 (스타일 지원) workbook.Props = { Title: "자재 목록", Subject: "TK-MP-Project 자재 관리", Author: "TK-MP System", CreatedDate: new Date() }; // 파일 저장 (스타일 포함) const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array', cellStyles: true // 스타일 활성화 }); const data = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const finalFilename = `${filename}_${new Date().toISOString().split('T')[0]}.xlsx`; saveAs(data, finalFilename); return true; } catch (error) { console.error('엑셀 내보내기 실패:', error); alert('엑셀 파일 생성에 실패했습니다: ' + error.message); return false; } }; /** * 리비전 비교 결과 엑셀 내보내기 */ export const exportComparisonToExcel = (comparisonData, filename, additionalInfo = {}) => { try { const { summary, new_items = [], modified_items = [], removed_items = [] } = comparisonData; // 새 워크북 생성 const workbook = XLSX.utils.book_new(); // 요약 시트 const summaryData = [ ['리비전 비교 정보', ''], ['Job No', additionalInfo.jobNo || ''], ['현재 리비전', additionalInfo.currentRevision || ''], ['이전 리비전', additionalInfo.previousRevision || ''], ['비교일', new Date().toLocaleDateString()], ['', ''], ['비교 결과 요약', ''], ['구분', '건수'], ['총 현재 자재', summary?.total_current_items || 0], ['총 이전 자재', summary?.total_previous_items || 0], ['신규 자재', summary?.new_items_count || 0], ['변경 자재', summary?.modified_items_count || 0], ['삭제 자재', summary?.removed_items_count || 0] ]; const summarySheet = XLSX.utils.aoa_to_sheet(summaryData); XLSX.utils.book_append_sheet(workbook, summarySheet, '비교 요약'); // 신규 자재 시트 (카테고리별, 합쳐진 자재) if (new_items.length > 0) { const newItemsGroups = groupMaterialsByCategory(new_items); Object.entries(newItemsGroups).forEach(([category, items]) => { const consolidatedItems = consolidateMaterials(items, true); const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material, true)); const sheet = XLSX.utils.json_to_sheet(formattedItems); const sheetName = `신규_${category.replace(/[\\\/\*\?\[\]]/g, '_')}`.substring(0, 31); XLSX.utils.book_append_sheet(workbook, sheet, sheetName); }); } // 변경 자재 시트 (카테고리별, 합쳐진 자재) if (modified_items.length > 0) { const modifiedItemsGroups = groupMaterialsByCategory(modified_items); Object.entries(modifiedItemsGroups).forEach(([category, items]) => { const consolidatedItems = consolidateMaterials(items, true); const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material, true)); const sheet = XLSX.utils.json_to_sheet(formattedItems); const sheetName = `변경_${category.replace(/[\\\/\*\?\[\]]/g, '_')}`.substring(0, 31); XLSX.utils.book_append_sheet(workbook, sheet, sheetName); }); } // 삭제 자재 시트 (카테고리별, 합쳐진 자재) if (removed_items.length > 0) { const removedItemsGroups = groupMaterialsByCategory(removed_items); Object.entries(removedItemsGroups).forEach(([category, items]) => { const consolidatedItems = consolidateMaterials(items, true); const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material, true)); const sheet = XLSX.utils.json_to_sheet(formattedItems); const sheetName = `삭제_${category.replace(/[\\\/\*\?\[\]]/g, '_')}`.substring(0, 31); XLSX.utils.book_append_sheet(workbook, sheet, sheetName); }); } // 파일 저장 const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); const data = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const finalFilename = `${filename}_비교결과_${new Date().toISOString().split('T')[0]}.xlsx`; saveAs(data, finalFilename); return true; } catch (error) { console.error('엑셀 내보내기 실패:', error); alert('엑셀 파일 생성에 실패했습니다: ' + error.message); return false; } };