Files
TK-BOM-Project/frontend/src/utils/excelExport.js
hyungi f336b5a4a8
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
feat: 모든 카테고리에 추가요청사항 저장 기능 및 엑셀 내보내기 개선
- 모든 BOM 카테고리(Pipe, Fitting, Flange, Gasket, Bolt, Support)에 추가요청사항 저장/편집 기능 추가
- 저장된 데이터의 카테고리 변경 및 페이지 새로고침 시 지속성 보장
- 백엔드 materials 테이블에 brand, user_requirement 컬럼 추가
- 새로운 /materials/{id}/brand, /materials/{id}/user-requirement PATCH API 엔드포인트 추가
- 모든 카테고리에서 Additional Request 컬럼 너비 확장 (UI 겹침 방지)
- GASKET 카테고리 엑셀 내보내기에 누락된 '추가요청사항' 컬럼 추가
- 엑셀 내보내기 시 저장된 추가요청사항이 우선 반영되도록 개선
- P열 납기일 규칙 유지하며 관리항목 개수 조정
2025-10-17 12:54:17 +09:00

1263 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
};