Files
TK-BOM-Project/frontend/src/utils/excelExport.js
hyungi a5bfeec9aa
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
feat: 엑셀 다운로드 방식 개선 - BOM에서 생성한 엑셀을 구매관리에서 다운로드
- 파이프, 피팅, 플랜지, 밸브 카테고리에 새로운 엑셀 업로드 로직 적용
- createExcelBlob 함수로 클라이언트에서 엑셀 생성 후 서버 업로드
- /purchase-request/upload-excel API로 엑셀 파일 서버 저장
- 구매관리 페이지에서 원본 엑셀 파일 다운로드 가능
- 가스켓, 볼트, 서포트는 추후 개선 시 적용 예정

배포 버전: index-5e5aa4a4.js
2025-10-16 14:53:22 +09:00

1135 lines
43 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 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에서 추출
const desc = cleanDescription.toUpperCase();
if (desc.includes('GATE')) {
itemName = '게이트 밸브';
} else if (desc.includes('BALL')) {
itemName = '볼 밸브';
} else if (desc.includes('GLOBE')) {
itemName = '글로브 밸브';
} else if (desc.includes('CHECK')) {
itemName = '체크 밸브';
} else if (desc.includes('BUTTERFLY')) {
itemName = '버터플라이 밸브';
} else if (desc.includes('NEEDLE')) {
itemName = '니들 밸브';
} else if (desc.includes('RELIEF')) {
itemName = '릴리프 밸브';
} else {
itemName = '밸브';
}
}
} else if (category === 'GASKET') {
// 가스켓 상세 타입 표시
const gasketDetails = material.gasket_details || {};
const gasketType = gasketDetails.gasket_type || '';
const gasketSubtype = gasketDetails.gasket_subtype || '';
if (gasketType === 'SPIRAL_WOUND') {
itemName = '스파이럴 워운드 가스켓';
} else if (gasketType === 'RING_JOINT') {
itemName = '링 조인트 가스켓';
} else if (gasketType === 'FULL_FACE') {
itemName = '풀 페이스 가스켓';
} else if (gasketType === 'RAISED_FACE') {
itemName = '레이즈드 페이스 가스켓';
} else if (gasketSubtype && gasketSubtype !== gasketType) {
itemName = gasketSubtype;
} else if (gasketType) {
itemName = gasketType;
} else {
// gasket_details가 없으면 description에서 추출
const desc = cleanDescription.toUpperCase();
if (desc.includes('SWG') || desc.includes('SPIRAL')) {
itemName = '스파이럴 워운드 가스켓';
} else if (desc.includes('RTJ') || desc.includes('RING')) {
itemName = '링 조인트 가스켓';
} else if (desc.includes('FF') || desc.includes('FULL FACE')) {
itemName = '풀 페이스 가스켓';
} else if (desc.includes('RF') || desc.includes('RAISED')) {
itemName = '레이즈드 페이스 가스켓';
} else {
itemName = '가스켓';
}
}
} else if (category === 'BOLT') {
// 볼트 상세 타입 표시
const boltDetails = material.bolt_details || {};
const boltType = boltDetails.bolt_type || '';
if (boltType === 'HEX_BOLT') {
itemName = '육각 볼트';
} else if (boltType === 'STUD_BOLT') {
itemName = '스터드 볼트';
} else if (boltType === 'U_BOLT') {
itemName = '유볼트';
} else if (boltType === 'FLANGE_BOLT') {
itemName = '플랜지 볼트';
} else if (boltType === 'PSV_BOLT') {
itemName = 'PSV 볼트';
} else if (boltType === 'LT_BOLT') {
itemName = '저온용 볼트';
} else if (boltType === 'CK_BOLT') {
itemName = '체크밸브용 볼트';
} else {
// description에서 추출
const desc = cleanDescription.toUpperCase();
if (desc.includes('PSV')) {
itemName = 'PSV 볼트';
} else if (desc.includes('LT')) {
itemName = '저온용 볼트';
} else if (desc.includes('CK')) {
itemName = '체크밸브용 볼트';
} else if (desc.includes('STUD')) {
itemName = '스터드 볼트';
} else if (desc.includes('U-BOLT') || desc.includes('U BOLT')) {
itemName = '유볼트';
} else {
itemName = '볼트';
}
}
} else if (category === 'SUPPORT' || category === 'U_BOLT') {
// 서포트 상세 타입 표시
const desc = cleanDescription.toUpperCase();
if (desc.includes('URETHANE') || desc.includes('BLOCK SHOE')) {
itemName = '우레탄 블록 슈';
} else if (desc.includes('CLAMP')) {
itemName = '클램프';
} else if (desc.includes('U-BOLT') || desc.includes('U BOLT')) {
itemName = '유볼트';
} else if (desc.includes('HANGER')) {
itemName = '행거';
} else if (desc.includes('SPRING')) {
itemName = '스프링 서포트';
} else {
itemName = '서포트';
}
} 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();
}
}
// 카테고리별 상세 정보 추출 (이미 위에서 선언됨)
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;
}
}
// 새로운 엑셀 양식: 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 || '-'; // F열
base['압력등급'] = pressure; // G열
base['재질'] = grade; // H열
base['상세내역'] = detailInfo || '-'; // I열
base['사용자요구'] = material.user_requirement || ''; // J열
base['관리항목1'] = ''; // K열
base['관리항목2'] = ''; // L열
base['관리항목3'] = ''; // M열
base['관리항목4'] = ''; // N열
base['관리항목5'] = ''; // O열
} else if (category === 'GASKET') {
// 가스켓 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨
base['크기'] = material.size_spec || '-'; // F열
base['압력등급'] = pressure; // G열
base['구조'] = grade; // H열: H/F/I/O, SWG 등 (타입 정보 제거)
base['재질'] = gasketMaterial || '-'; // I열: SS304/GRAPHITE/SS304/SS304
base['두께'] = gasketThickness || '-'; // J열: 4.5mm
base['사용자요구'] = material.user_requirement || ''; // K열
base['관리항목1'] = ''; // L열
base['관리항목2'] = ''; // M열
base['관리항목3'] = ''; // N열
base['관리항목4'] = ''; // O열
} else if (category === 'BOLT') {
// 볼트 전용 컬럼 (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열
} 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 {
// 기존 exportMaterialsToExcel과 동일한 로직이지만 Blob만 반환
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Materials');
// 헤더 설정
const headers = [
'TAGNO', '품목명', '수량', '통화구분', '단가'
];
// 카테고리별 추가 헤더
if (options.category === 'PIPE') {
headers.push('크기', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '납기일(YYYY-MM-DD)');
} else if (options.category === 'FITTING') {
headers.push('크기', '압력등급', '스케줄', '재질', '사용자요구', '추가요청사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '납기일(YYYY-MM-DD)');
} else if (options.category === 'FLANGE') {
headers.push('크기', '페이싱', '압력등급', '스케줄', '재질', '추가요구사항', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '납기일(YYYY-MM-DD)');
} else {
// 기본 헤더
headers.push('크기', '압력등급', '재질', '상세내역', '사용자요구', '관리항목1', '관리항목2', '관리항목3', '관리항목4', '납기일(YYYY-MM-DD)');
}
// 헤더 추가
worksheet.addRow(headers);
// 헤더 스타일링
const headerRow = worksheet.getRow(1);
headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' } };
headerRow.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF366092' }
};
headerRow.alignment = { horizontal: 'center', vertical: 'middle' };
// 데이터 추가
materials.forEach(material => {
const formattedData = formatMaterialForExcel(material, options.category || 'UNKNOWN');
const rowData = headers.map(header => {
const key = Object.keys(formattedData).find(k =>
formattedData[k] !== undefined &&
(header.includes(k) || k.includes(header.replace(/[()]/g, '').split(' ')[0]))
);
return key ? formattedData[key] : '';
});
worksheet.addRow(rowData);
});
// 컬럼 너비 자동 조정
worksheet.columns.forEach(column => {
let maxLength = 0;
column.eachCell({ includeEmpty: true }, (cell) => {
const columnLength = cell.value ? cell.value.toString().length : 10;
if (columnLength > maxLength) {
maxLength = columnLength;
}
});
column.width = Math.min(Math.max(maxLength + 2, 10), 50);
});
// Blob 생성
const excelBuffer = await workbook.xlsx.writeBuffer();
return new Blob([excelBuffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
} catch (error) {
console.error('엑셀 Blob 생성 실패:', error);
throw error;
}
};