Files
TK-BOM-Project/frontend/src/utils/excelExport.js
hyungi b9442928da 🔧 자재 분류기 개선 및 ELL 키워드 분류 문제 해결 (테스트 필요 - 안되는거 같음)
분류기 개선사항:
1. ELL-O-LET vs 일반 엘보 분류 개선
   - OLET 우선순위 확인 로직 추가
   - ELL 키워드 충돌 문제 해결

2. 엘보 서브타입 강화
   - 90DEG_LONG_RADIUS, 90DEG_SHORT_RADIUS 등 조합형 추가
   - 더 구체적인 키워드 패턴 지원

3. 레듀스 플랜지 분류 개선
   - REDUCING FLANGE가 FITTING이 아닌 FLANGE로 분류되도록 수정
   - 특별 우선순위 로직 추가

4. 90 ELL SW 분류 문제 해결
   - fitting_keywords에 ELL 키워드 추가
   - ELBOW description_keywords에 ELL, 90 ELL, 45 ELL 추가

기술적 개선:
- 키워드 우선순위 체계 강화
- 구체적인 패턴 매칭 개선
- 분류 신뢰도 향상

플랜지 카테고리 개선:
- 타입 풀네임 표시 (WN → WELD NECK FLANGE)
- 끝단처리 별도 컬럼 추가 (RF → RAISED FACE)
- 엑셀 내보내기 구조 개선 (P열 납기일, 관리항목 4개)
2025-10-15 17:43:10 +09:00

974 lines
36 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 && endPreparation) {
itemName = `${manufacturingMethod} PIPE (${endPreparation})`;
} else if (manufacturingMethod) {
itemName = `${manufacturingMethod} PIPE`;
} else if (endPreparation) {
itemName = `PIPE (${endPreparation})`;
} 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') {
// 피팅 상세 타입 표시 (OLET 등 풀네임)
const fittingDetails = material.fitting_details || {};
const fittingType = fittingDetails.fitting_type || '';
const fittingSubtype = fittingDetails.fitting_subtype || '';
if (fittingType === 'OLET') {
// OLET 풀네임 표시
switch (fittingSubtype) {
case 'SOCKOLET':
itemName = 'SOCK-O-LET';
break;
case 'WELDOLET':
itemName = 'WELD-O-LET';
break;
case 'ELLOLET':
itemName = 'ELL-O-LET';
break;
case 'THREADOLET':
itemName = 'THREAD-O-LET';
break;
case 'ELBOLET':
itemName = 'ELB-O-LET';
break;
case 'NIPOLET':
itemName = 'NIP-O-LET';
break;
case 'COUPOLET':
itemName = 'COUP-O-LET';
break;
default:
itemName = 'OLET';
}
} else if (fittingType === 'ELBOW') {
// 엘보 각도 표시
const angle = fittingSubtype === '90DEG' ? '90도' : fittingSubtype === '45DEG' ? '45도' : '';
itemName = `엘보 ${angle}`.trim();
} else if (fittingType === 'TEE') {
// 티 타입 표시
const teeType = fittingSubtype === 'EQUAL' ? '등경' : fittingSubtype === 'REDUCING' ? '축소' : '';
itemName = `${teeType}`.trim();
} else if (fittingType === 'REDUCER') {
// 리듀서 타입 표시
const reducerType = fittingSubtype === 'CONCENTRIC' ? '동심' : fittingSubtype === 'ECCENTRIC' ? '편심' : '';
itemName = `리듀서 ${reducerType}`.trim();
} else {
itemName = fittingType || 'FITTING';
}
} else if (category === 'FLANGE') {
// 플랜지 상세 타입 표시
const flangeDetails = material.flange_details || {};
const flangeType = flangeDetails.flange_type || '';
const facingType = flangeDetails.facing_type || '';
if (flangeType === 'WELD_NECK') {
itemName = '웰드넥 플랜지';
} else if (flangeType === 'SLIP_ON') {
itemName = '슬립온 플랜지';
} else if (flangeType === 'SOCKET_WELD') {
itemName = '소켓웰드 플랜지';
} else if (flangeType === 'BLIND') {
itemName = '블라인드 플랜지';
} else if (flangeType === 'REDUCING') {
itemName = '축소 플랜지';
} else {
// 특수 플랜지는 구분
const desc = cleanDescription.toUpperCase();
if (desc.includes('ORIFICE')) {
itemName = '오리피스 플랜지';
} else if (desc.includes('SPECTACLE')) {
itemName = '스펙터클 블라인드';
} else if (desc.includes('PADDLE')) {
itemName = '패들 블라인드';
} else if (desc.includes('SPACER')) {
itemName = '스페이서';
} else {
itemName = '플랜지';
}
}
// 상세내역에 플랜지 타입 정보 저장
if (flangeDetails.flange_type) {
detailInfo = `${flangeType} ${facingType}`.trim();
} else {
// description에서 추출
const flangeTypeMatch = cleanDescription.match(/FLG\s+([^,]+?)(?=\s*SCH|\s*,\s*\d+LB|$)/i);
if (flangeTypeMatch) {
detailInfo = flangeTypeMatch[1].trim();
}
}
} 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['압력등급'] = pressure; // G열
base['스케줄'] = schedule; // H열
base['재질'] = grade; // I열
base['제조방법'] = material.pipe_details?.manufacturing_method || '-'; // J열
base['끝단처리'] = material.pipe_details?.end_preparation || '-'; // K열
base['사용자요구'] = material.user_requirement || ''; // L열
base['관리항목1'] = ''; // M열
base['관리항목2'] = ''; // N열
base['관리항목3'] = ''; // O열
} else if (category === 'FITTING') {
// 피팅 전용 컬럼 (F~O)
base['크기'] = material.size_spec || '-'; // F열
base['압력등급'] = pressure; // G열
base['타입'] = material.fitting_details?.fitting_type || '-'; // 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 if (category === 'FLANGE') {
// 플랜지 타입 풀네임 매핑 (영어)
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',
'BL': 'BLIND FLANGE',
'BLIND': 'BLIND FLANGE',
'RED': 'REDUCING FLANGE',
'REDUCING': 'REDUCING FLANGE',
'ORIFICE': 'ORIFICE FLANGE',
'SPECTACLE': 'SPECTACLE BLIND',
'PADDLE': 'PADDLE BLIND',
'SPACER': 'SPACER'
};
// 끝단처리 풀네임 매핑 (영어)
const facingTypeMap = {
'RF': 'RAISED FACE',
'RAISED_FACE': 'RAISED FACE',
'FF': 'FULL FACE',
'FULL_FACE': 'FULL FACE',
'RTJ': 'RING JOINT',
'RING_JOINT': 'RING JOINT',
'MALE': 'MALE',
'FEMALE': 'FEMALE'
};
const rawFlangeType = material.flange_details?.flange_type || '';
const rawFacingType = material.flange_details?.facing_type || '';
// 플랜지 전용 컬럼 (F~O)
base['크기'] = material.size_spec || '-'; // F열
base['압력등급'] = pressure; // G열
base['타입'] = flangeTypeMap[rawFlangeType] || rawFlangeType || 'FLANGE'; // H열
base['재질'] = grade; // I열
base['페이싱'] = facingTypeMap[rawFacingType] || rawFacingType || '-'; // 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['타입'] = material.valve_details?.valve_type || '-'; // 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 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;
}
};