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