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 extractValveConnectionType = (description) => { const descUpper = description.toUpperCase(); if (descUpper.includes('SW X THRD') || descUpper.includes('SW×THRD')) { return 'SW×THRD'; } else if (descUpper.includes('FLG') || descUpper.includes('FLANGE')) { return 'FLG'; } else if (descUpper.includes('SW') || descUpper.includes('SOCKET')) { return 'SW'; } else if (descUpper.includes('THRD') || descUpper.includes('THREAD')) { return 'THRD'; } else if (descUpper.includes('BW') || descUpper.includes('BUTT WELD')) { return 'BW'; } else { return '-'; } }; /** * 벨브 추가 정보 추출 함수 */ const extractValveAdditionalInfo = (description) => { const descUpper = description.toUpperCase(); let additionalInfo = ''; const additionalPatterns = [ '3-WAY', '3WAY', 'THREE WAY', 'DOUL PLATE', 'DOUBLE PLATE', 'DUAL PLATE', 'DOUBLE DISC', 'DUAL DISC', 'SWING', 'LIFT', 'TILTING', 'WAFER', 'LUG', 'FLANGED', 'FULL BORE', 'REDUCED BORE', 'FIRE SAFE', 'ANTI STATIC' ]; for (const pattern of additionalPatterns) { if (descUpper.includes(pattern)) { if (additionalInfo) { additionalInfo += ', '; } additionalInfo += pattern; } } return additionalInfo || '-'; }; /** * 자재 데이터를 엑셀용 형태로 변환 */ 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 = ''; let detailInfo = ''; let gasketMaterial = ''; let gasketThickness = ''; if (category === 'PIPE') { // 파이프 상세 타입 표시 개선 const pipeDetails = material.pipe_details || {}; const manufacturingMethod = pipeDetails.manufacturing_method || ''; const endPreparation = pipeDetails.end_preparation || ''; // 제조방법만으로 상세 타입 생성 (끝단처리 정보 제거) if (manufacturingMethod) { itemName = `${manufacturingMethod} PIPE`; } else { // description에서 제조방법 추출 시도 const desc = cleanDescription.toUpperCase(); if (desc.includes('SEAMLESS')) { itemName = 'SEAMLESS PIPE'; } else if (desc.includes('WELDED')) { itemName = 'WELDED PIPE'; } else if (desc.includes('ERW')) { itemName = 'ERW PIPE'; } else if (desc.includes('SMLS')) { itemName = 'SEAMLESS PIPE'; } else { itemName = 'PIPE'; } } } else if (category === 'FITTING') { // 피팅 상세 타입 표시 - 프론트엔드 표시와 동일한 로직 사용 const fittingDetails = material.fitting_details || {}; const classificationDetails = material.classification_details || {}; const fittingTypeInfo = classificationDetails.fitting_type || {}; const fittingType = fittingTypeInfo.type || fittingDetails.fitting_type || ''; const fittingSubtype = fittingTypeInfo.subtype || fittingDetails.fitting_subtype || ''; // 프론트엔드와 동일한 displayType 로직 사용 let displayType = ''; if (fittingType === 'OLET') { // OLET 풀네임 표시 switch (fittingSubtype) { case 'SOCKOLET': displayType = 'SOCK-O-LET'; break; case 'WELDOLET': displayType = 'WELD-O-LET'; break; case 'ELLOLET': displayType = 'ELL-O-LET'; break; case 'THREADOLET': displayType = 'THREAD-O-LET'; break; case 'ELBOLET': displayType = 'ELB-O-LET'; break; case 'NIPOLET': displayType = 'NIP-O-LET'; break; case 'COUPOLET': displayType = 'COUP-O-LET'; break; default: // Description에서 직접 추출 const descUpper = cleanDescription.toUpperCase(); if (descUpper.includes('SOCK-O-LET') || descUpper.includes('SOCKOLET')) { displayType = 'SOCK-O-LET'; } else if (descUpper.includes('WELD-O-LET') || descUpper.includes('WELDOLET')) { displayType = 'WELD-O-LET'; } else if (descUpper.includes('ELL-O-LET') || descUpper.includes('ELLOLET')) { displayType = 'ELL-O-LET'; } else if (descUpper.includes('THREAD-O-LET') || descUpper.includes('THREADOLET')) { displayType = 'THREAD-O-LET'; } else if (descUpper.includes('ELB-O-LET') || descUpper.includes('ELBOLET')) { displayType = 'ELB-O-LET'; } else if (descUpper.includes('NIP-O-LET') || descUpper.includes('NIPOLET')) { displayType = 'NIP-O-LET'; } else if (descUpper.includes('COUP-O-LET') || descUpper.includes('COUPOLET')) { displayType = 'COUP-O-LET'; } else { displayType = 'OLET'; } } } else if (fittingType === 'TEE' && fittingSubtype === 'REDUCING') { displayType = 'TEE REDUCING'; } else if (fittingType === 'REDUCER' && fittingSubtype === 'CONCENTRIC') { displayType = 'REDUCER CONC'; } else if (fittingType === 'REDUCER' && fittingSubtype === 'ECCENTRIC') { displayType = 'REDUCER ECC'; } else if (cleanDescription.toUpperCase().includes('TEE RED')) { displayType = 'TEE REDUCING'; } else if (cleanDescription.toUpperCase().includes('RED CONC')) { displayType = 'REDUCER CONC'; } else if (cleanDescription.toUpperCase().includes('RED ECC')) { displayType = 'REDUCER ECC'; } else if (cleanDescription.toUpperCase().includes('CAP')) { if (cleanDescription.includes('NPT(F)')) { displayType = 'CAP NPT(F)'; } else if (cleanDescription.includes('SW')) { displayType = 'CAP SW'; } else if (cleanDescription.includes('BW')) { displayType = 'CAP BW'; } else { displayType = 'CAP'; } } else if (cleanDescription.toUpperCase().includes('PLUG')) { if (cleanDescription.toUpperCase().includes('HEX')) { if (cleanDescription.includes('NPT(M)')) { displayType = 'HEX PLUG NPT(M)'; } else { displayType = 'HEX PLUG'; } } else if (cleanDescription.includes('NPT(M)')) { displayType = 'PLUG NPT(M)'; } else if (cleanDescription.includes('NPT')) { displayType = 'PLUG NPT'; } else { displayType = 'PLUG'; } } else if (fittingType === 'NIPPLE') { const length = fittingDetails.length_mm || fittingDetails.avg_length_mm; let nippleType = 'NIPPLE'; if (length) nippleType += ` ${length}mm`; displayType = nippleType; } else if (fittingType === 'ELBOW') { // 엘보 상세 정보 표시 let elbowDetails = []; // 각도 정보 if (fittingSubtype.includes('90DEG') || cleanDescription.includes('90')) { elbowDetails.push('90°'); } else if (fittingSubtype.includes('45DEG') || cleanDescription.includes('45')) { elbowDetails.push('45°'); } // 반경 정보 if (fittingSubtype.includes('LONG_RADIUS') || cleanDescription.toUpperCase().includes('LR')) { elbowDetails.push('LR'); } else if (fittingSubtype.includes('SHORT_RADIUS') || cleanDescription.toUpperCase().includes('SR')) { elbowDetails.push('SR'); } displayType = elbowDetails.length > 0 ? `ELBOW ${elbowDetails.join(' ')}` : 'ELBOW'; } else { displayType = fittingType || 'FITTING'; } itemName = displayType; } else if (category === 'FLANGE') { // 플랜지 상세 타입 표시 - 프론트엔드 표시와 동일한 로직 사용 const flangeDetails = material.flange_details || {}; const rawFlangeType = flangeDetails.flange_type || ''; const rawFacingType = flangeDetails.facing_type || ''; // 플랜지 타입 풀네임 매핑 (영어) const flangeTypeMap = { 'WN': 'WELD NECK FLANGE', 'WELD_NECK': 'WELD NECK FLANGE', 'SO': 'SLIP ON FLANGE', 'SLIP_ON': 'SLIP ON FLANGE', 'SW': 'SOCKET WELD FLANGE', 'SOCKET_WELD': 'SOCKET WELD FLANGE', 'THREADED': 'THREADED FLANGE', 'THD': 'THREADED FLANGE', 'BLIND': 'BLIND FLANGE', 'LAP_JOINT': 'LAP JOINT FLANGE', 'LJ': 'LAP JOINT FLANGE', 'REDUCING': 'REDUCING FLANGE', 'ORIFICE': 'ORIFICE FLANGE', 'SPECTACLE': 'SPECTACLE BLIND', 'SPECTACLE_BLIND': 'SPECTACLE BLIND', 'PADDLE': 'PADDLE BLIND', 'PADDLE_BLIND': 'PADDLE BLIND', 'SPACER': 'SPACER', 'SWIVEL': 'SWIVEL FLANGE', 'DRIP_RING': 'DRIP RING', 'NOZZLE': 'NOZZLE FLANGE' }; // rawFlangeType에서 facing 정보 분리 (예: "WN RF" -> "WN") let cleanFlangeType = rawFlangeType; if (rawFlangeType.includes(' RF')) { cleanFlangeType = rawFlangeType.replace(' RF', '').trim(); } else if (rawFlangeType.includes(' FF')) { cleanFlangeType = rawFlangeType.replace(' FF', '').trim(); } else if (rawFlangeType.includes(' RTJ')) { cleanFlangeType = rawFlangeType.replace(' RTJ', '').trim(); } let displayType = flangeTypeMap[cleanFlangeType] || ''; // Description에서 추출 (매핑되지 않은 경우) if (!displayType) { const desc = cleanDescription.toUpperCase(); if (desc.includes('ORIFICE')) { displayType = 'ORIFICE FLANGE'; } else if (desc.includes('SPECTACLE')) { displayType = 'SPECTACLE BLIND'; } else if (desc.includes('PADDLE')) { displayType = 'PADDLE BLIND'; } else if (desc.includes('SPACER')) { displayType = 'SPACER'; } else if (desc.includes('REDUCING') || desc.includes('RED')) { displayType = 'REDUCING FLANGE'; } else if (desc.includes('BLIND')) { displayType = 'BLIND FLANGE'; } else if (desc.includes('WN RF') || desc.includes('WN-RF')) { displayType = 'WELD NECK FLANGE'; } else if (desc.includes('WN FF') || desc.includes('WN-FF')) { displayType = 'WELD NECK FLANGE'; } else if (desc.includes('WN RTJ') || desc.includes('WN-RTJ')) { displayType = 'WELD NECK FLANGE'; } else if (desc.includes('WN')) { displayType = 'WELD NECK FLANGE'; } else if (desc.includes('SO RF') || desc.includes('SO-RF')) { displayType = 'SLIP ON FLANGE'; } else if (desc.includes('SO FF') || desc.includes('SO-FF')) { displayType = 'SLIP ON FLANGE'; } else if (desc.includes('SO')) { displayType = 'SLIP ON FLANGE'; } else if (desc.includes('SW')) { displayType = 'SOCKET WELD FLANGE'; } else { displayType = 'FLANGE'; } } itemName = displayType; } else if (category === 'VALVE') { // 밸브 상세 타입 표시 const valveDetails = material.valve_details || {}; const valveType = valveDetails.valve_type || ''; if (valveType === 'GATE') { itemName = '게이트 밸브'; } else if (valveType === 'BALL') { itemName = '볼 밸브'; } else if (valveType === 'GLOBE') { itemName = '글로브 밸브'; } else if (valveType === 'CHECK') { itemName = '체크 밸브'; } else if (valveType === 'BUTTERFLY') { itemName = '버터플라이 밸브'; } else if (valveType === 'NEEDLE') { itemName = '니들 밸브'; } else if (valveType === 'RELIEF') { itemName = '릴리프 밸브'; } else { // description에서 추출 (BOM 페이지와 동일한 로직) const desc = cleanDescription.toUpperCase(); if (desc.includes('SIGHT GLASS') || desc.includes('사이트글라스')) { itemName = 'SIGHT GLASS'; } else if (desc.includes('STRAINER') || desc.includes('스트레이너')) { itemName = 'STRAINER'; } else if (desc.includes('GATE') || desc.includes('게이트')) { itemName = 'GATE VALVE'; } else if (desc.includes('BALL') || desc.includes('볼')) { itemName = 'BALL VALVE'; } else if (desc.includes('CHECK') || desc.includes('체크')) { itemName = 'CHECK VALVE'; } else if (desc.includes('GLOBE') || desc.includes('글로브')) { itemName = 'GLOBE VALVE'; } else if (desc.includes('BUTTERFLY') || desc.includes('버터플라이')) { itemName = 'BUTTERFLY VALVE'; } else if (desc.includes('NEEDLE') || desc.includes('니들')) { itemName = 'NEEDLE VALVE'; } else if (desc.includes('RELIEF') || desc.includes('릴리프')) { itemName = 'RELIEF VALVE'; } else { itemName = 'VALVE'; } } } else if (category === 'GASKET') { // BOM 페이지의 타입을 따라가도록 - 프론트엔드 parseGasketInfo와 동일한 로직 const gasketDetails = material.gasket_details || {}; const gasketType = gasketDetails.gasket_type || ''; // 가스켓 타입 매핑 (프론트엔드와 동일) 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 { itemName = 'GASKET'; } } else if (category === 'BOLT') { // 볼트 상세 타입 표시 const boltDetails = material.bolt_details || {}; const boltType = boltDetails.bolt_type || ''; // BOM 페이지의 타입을 따라가도록 - 프론트엔드 parseBoltInfo와 동일한 로직 let boltSubtype = 'BOLT_GENERAL'; if (boltType && boltType !== 'UNKNOWN') { boltSubtype = boltType; } else { // 원본 설명에서 특수 볼트 타입 추출 (프론트엔드와 동일) const desc = cleanDescription.toUpperCase(); if (desc.includes('PSV')) { boltSubtype = 'PSV_BOLT'; } else if (desc.includes('LT')) { boltSubtype = 'LT_BOLT'; } else if (desc.includes('CK')) { boltSubtype = 'CK_BOLT'; } } // BOM 페이지와 동일한 타입명 사용 itemName = boltSubtype; } else if (category === 'SUPPORT' || category === 'U_BOLT') { // 서포트 상세 타입 표시 const desc = cleanDescription.toUpperCase(); if (desc.includes('URETHANE') || desc.includes('BLOCK SHOE')) { itemName = 'URETHANE BLOCK SHOE'; // 우레탄 블럭슈의 경우 두께 정보는 품목명에 포함하지 않음 (재질 열에서 처리) } else if (desc.includes('CLAMP')) { // 클램프 타입 상세 분류 (CL-1, CL-2, CL-3 등) const clampMatch = desc.match(/CL[-\s]*(\d+)/i); if (clampMatch) { itemName = `CLAMP CL-${clampMatch[1]}`; } else { itemName = 'CLAMP CL-1'; // 기본값 } } else if (desc.includes('U-BOLT') || desc.includes('U BOLT')) { itemName = 'U-BOLT'; } else if (desc.includes('HANGER')) { itemName = 'HANGER'; } else if (desc.includes('SPRING')) { itemName = 'SPRING HANGER'; } else if (desc.includes('GUIDE')) { itemName = 'GUIDE'; } else if (desc.includes('ANCHOR')) { itemName = 'ANCHOR'; } 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\s*LG/i, // 70MM LG 형태 /L\s*(\d+(?:\.\d+)?)\s*MM/i, /LENGTH\s*(\d+(?:\.\d+)?)\s*MM/i, /(\d+(?:\.\d+)?)\s*MM\s*LONG/i, /X\s*(\d+(?:\.\d+)?)\s*MM/i, // M8 X 20MM 형태 /,\s*(\d+(?:\.\d+)?)\s*LG/i, // ", 145.0000 LG" 형태 (PSV, LT 볼트용) /,\s*(\d+(?:\.\d+)?)\s+(?:CK|PSV|LT)/i, // ", 140 CK" 형태 (PSV 볼트용) /PSV\s+(\d+(?:\.\d+)?)/i, // PSV 140 형태 (PSV 볼트 전용) /(\d+(?:\.\d+)?)\s+PSV/i, // 140 PSV 형태 (PSV 볼트 전용) /(\d+(?:\.\d+)?)\s*CK/i, // 140CK 형태 (체크밸브용 볼트) /(\d+(?:\.\d+)?)\s*LT/i, // 140LT 형태 (저온용 볼트) /(\d+(?:\.\d+)?)\s*mm/i, // 50mm /(\d+(?:\.\d+)?)\s*MM/i, // 50MM /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(); } } // 카테고리별 상세 정보 추출 (이미 위에서 선언됨) 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') { // 실제 재질 구성 정보 - 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 { // 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]}`; } } } } // 두께 정보 별도 추출 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; } } else if (category === 'BOLT') { // BOLT의 경우 플랜지당 볼트 세트 수를 고려한 수량 계산 (BOM 페이지와 동일) const qty = Math.round(material.quantity || 0); // 플랜지당 볼트 세트 수 추출 (예: (8), (4)) const boltDetails = material.bolt_details || {}; let boltsPerFlange = boltDetails.dimensions?.bolts_per_flange || 1; // 백엔드에서 정보가 없으면 원본 설명에서 직접 추출 if (boltsPerFlange === 1) { const description = material.original_description || ''; const flangePattern = description.match(/\((\d+)\)/); if (flangePattern) { boltsPerFlange = parseInt(flangePattern[1]); } } // 실제 필요한 볼트 수 = 플랜지 수 × 플랜지당 볼트 세트 수 const totalBoltsNeeded = qty * boltsPerFlange; const safetyQty = Math.ceil(totalBoltsNeeded * 1.05); // 5% 여유율 const purchaseQty = Math.ceil(safetyQty / 4) * 4; // 4의 배수 quantity = purchaseQty; } // 새로운 엑셀 양식: A~E 고정, F~O 카테고리별, P 납기일 const base = { 'TAGNO': '', // A열: 비워둠 '품목명': itemName, // B열: 카테고리별 상세 타입 '수량': quantity, // C열: 수량 '통화구분': 'KRW', // D열: 기본값 '단가': 1 // E열: 일괄 1로 설정 }; // 모든 카테고리에서 단위 컬럼 제거 (수량만 사용) // F~O열: 카테고리별 전용 컬럼 구성 (10개 컬럼) if (category === 'PIPE') { // 파이프 전용 컬럼 (F~O) - 끝단처리, 압력등급 제거 base['크기'] = material.size_spec || '-'; // F열 base['스케줄'] = schedule; // G열 base['재질'] = grade; // H열 base['제조방법'] = material.pipe_details?.manufacturing_method || '-'; // I열 base['사용자요구'] = material.user_requirements?.join(', ') || ''; // J열 (분류기에서 추출) base['추가요청사항'] = material.user_requirement || ''; // K열 (사용자 입력) base['관리항목1'] = ''; // L열 base['관리항목2'] = ''; // M열 base['관리항목3'] = ''; // N열 base['관리항목4'] = ''; // O열 } else if (category === 'FITTING') { // 피팅 전용 컬럼 (F~O) - 새로운 구조 base['크기'] = material.size_spec || '-'; // F열 base['압력등급'] = pressure; // G열 base['스케줄'] = schedule; // H열 base['재질'] = grade; // I열 base['사용자요구'] = material.user_requirements?.join(', ') || ''; // J열 (분류기에서 추출) base['추가요청사항'] = material.user_requirement || ''; // K열 (사용자 입력) base['관리항목1'] = ''; // L열 base['관리항목2'] = ''; // M열 base['관리항목3'] = ''; // N열 base['관리항목4'] = ''; // O열 } else if (category === 'FLANGE') { // 플랜지 전용 컬럼 (F~O) - 크기 이후에 페이싱, 압력, 스케줄, Material Grade, 추가요구사항 순서 base['크기'] = material.size_spec || '-'; // F열 // 페이싱 정보 (G열) const rawFacingType = material.flange_details?.facing_type || ''; const facingTypeMap = { 'RF': 'RAISED FACE', 'RAISED_FACE': 'RAISED FACE', 'FF': 'FLAT FACE', 'FLAT_FACE': 'FLAT FACE', 'RTJ': 'RING TYPE JOINT', 'RING_TYPE_JOINT': 'RING TYPE JOINT' }; base['페이싱'] = facingTypeMap[rawFacingType] || rawFacingType || '-'; // G열 base['압력등급'] = pressure; // H열 base['스케줄'] = schedule; // I열 base['재질'] = grade; // J열 base['추가요구사항'] = material.user_requirement || ''; // K열 base['관리항목1'] = ''; // L열 base['관리항목2'] = ''; // M열 base['관리항목3'] = ''; // N열 base['관리항목4'] = ''; // O열 } else if (category === 'VALVE') { // 밸브 전용 컬럼 (F~O) - 새로운 구조 base['크기'] = material.size_spec || material.main_nom || '-'; // F열 base['압력등급'] = pressure; // G열 base['브랜드'] = material.brand || '-'; // H열 (사용자 입력) base['추가정보'] = extractValveAdditionalInfo(cleanDescription); // I열 (3-WAY, DOUL PLATE 등) base['연결방식'] = material.connection_type || extractValveConnectionType(cleanDescription); // J열 base['추가요청사항'] = material.user_requirement || ''; // K열 base['관리항목1'] = ''; // L열 base['관리항목2'] = ''; // M열 base['관리항목3'] = ''; // N열 base['관리항목4'] = ''; // O열 } else if (category === 'GASKET') { // 가스켓 전용 컬럼 (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['재질1'] = material1; // I열: SS304/GRAPHITE base['재질2'] = material2; // J열: SS304/SS304 base['두께'] = gasketThickness || '-'; // K열: 4.5mm base['사용자요구'] = detailInfo; // L열: 분류기에서 추출된 요구사항 base['추가요청사항'] = material.user_requirement || ''; // M열: 사용자 입력 요구사항 base['관리항목1'] = ''; // N열 base['관리항목2'] = ''; // O열 } else if (category === 'BOLT') { // 볼트 전용 컬럼 (F~O) - User Requirements와 Additional Request 분리 base['크기'] = material.size_spec || '-'; // F열 base['압력등급'] = pressure; // G열 base['길이'] = schedule; // H열: 볼트는 길이 정보 base['재질'] = grade; // I열 base['사용자요구'] = detailInfo || '-'; // J열: ELEC.GALV 등 (분류기 추출) base['추가요청사항'] = material.user_requirement || ''; // K열: 사용자 입력 base['관리항목1'] = ''; // L열 base['관리항목2'] = ''; // M열 base['관리항목3'] = ''; // N열 base['관리항목4'] = ''; // O열 } else if (category === 'SUPPORT') { // 서포트 전용 컬럼 (F~O) - 압력등급, 상세내역 제거 base['크기'] = material.size_spec || material.main_nom || '-'; // F열 base['재질'] = grade; // G열 base['사용자요구'] = material.user_requirements?.join(', ') || ''; // 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열 base['압력등급'] = pressure; // G열 base['스케줄'] = schedule; // H열 base['재질'] = grade; // I열 base['상세내역'] = detailInfo || '-'; // J열 base['사용자요구'] = material.user_requirement || ''; // K열 base['관리항목1'] = ''; // L열 base['관리항목2'] = ''; // M열 base['관리항목3'] = ''; // N열 base['관리항목4'] = ''; // O열 } // P열: 납기일 (고정) 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; } }; // 엑셀 Blob 생성 함수 (서버 업로드용) export const createExcelBlob = async (materials, filename, options = {}) => { try { console.log('📊 createExcelBlob 시작:', materials.length, '개 자료'); // 기존 exportMaterialsToExcel 로직을 사용하되 Blob만 반환 const formattedData = materials.map(material => formatMaterialForExcel(material, options.category)); // 헤더 추출 및 순서 정의 (모든 카테고리에서 단위 제거) const allHeaders = Array.from(new Set(formattedData.flatMap(Object.keys))); const fixedHeaders = ['TAGNO', '품목명', '수량', '통화구분', '단가']; const categorySpecificHeadersOrder = { 'PIPE': ['크기', '스케줄', '재질', '제조방법', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'FITTING': ['크기', '압력등급', '스케줄', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'FLANGE': ['크기', '페이싱', '압력등급', '스케줄', '재질', '추가요구사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'VALVE': ['크기', '압력등급', '브랜드', '추가정보', '연결방식', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'GASKET': ['크기', '압력등급', '구조', '재질1', '재질2', '두께', '사용자요구', '추가요청사항', '관리항목1', '관리항목2'], 'BOLT': ['크기', '압력등급', '길이', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4'], 'SUPPORT': ['크기', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '관리항목5', '관리항목6'], }; const deliveryDateHeader = '납기일(YYYY-MM-DD)'; let orderedHeaders = [...fixedHeaders]; if (categorySpecificHeadersOrder[options.category]) { orderedHeaders = orderedHeaders.concat(categorySpecificHeadersOrder[options.category]); } orderedHeaders.push(deliveryDateHeader); // 데이터 정렬 const finalData = formattedData.map(row => { const newRow = {}; orderedHeaders.forEach(header => { newRow[header] = row[header] !== undefined ? row[header] : ''; }); return newRow; }); // XLSX 워크북 생성 const workbook = XLSX.utils.book_new(); const worksheet = XLSX.utils.json_to_sheet(finalData, { header: orderedHeaders }); // 컬럼 너비 자동 조정 const colWidths = orderedHeaders.map(header => ({ wch: Math.max( header.toString().length, ...finalData.map(row => (row[header] ? row[header].toString().length : 0)) ) + 2 })); worksheet['!cols'] = colWidths; XLSX.utils.book_append_sheet(workbook, worksheet, options.category || 'Materials'); // Blob 생성 const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); console.log('✅ createExcelBlob 완료:', blob.size, 'bytes'); return blob; } catch (error) { console.error('엑셀 Blob 생성 실패:', error); throw error; } };