feat: 엑셀 다운로드 방식 개선 - BOM에서 생성한 엑셀을 구매관리에서 다운로드
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- 파이프, 피팅, 플랜지, 밸브 카테고리에 새로운 엑셀 업로드 로직 적용
- createExcelBlob 함수로 클라이언트에서 엑셀 생성 후 서버 업로드
- /purchase-request/upload-excel API로 엑셀 파일 서버 저장
- 구매관리 페이지에서 원본 엑셀 파일 다운로드 가능
- 가스켓, 볼트, 서포트는 추후 개선 시 적용 예정

배포 버전: index-5e5aa4a4.js
This commit is contained in:
hyungi
2025-10-16 14:53:22 +09:00
parent c7297c6fb7
commit a5bfeec9aa
8 changed files with 425 additions and 200 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader, MaterialTable } from '../shared';
@@ -412,7 +412,18 @@ const FittingMaterialsView = ({
}));
try {
// 1. 구매신청 생성
console.log('🔄 피팅 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'FITTING',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
@@ -433,34 +444,41 @@ const FittingMaterialsView = ({
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}`);
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 2. 구매신청된 자재 ID를 purchasedMaterials에 추가
// 3. 생성된 엑셀 파일을 서버에 업로드
console.log('📤 서버에 엑셀 파일 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'FITTING');
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 3. 서버에 엑셀 파일 저장 요청
await api.post('/files/save-excel', {
file_id: fileId,
category: 'FITTING',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
});
// 4. 클라이언트에서 다운로드
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FITTING',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
// 4. 클라이언트 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패해도 다운로드는 진행
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FITTING',
filename: excelFileName,

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader, MaterialTable } from '../shared';
@@ -32,12 +32,21 @@ const FlangeMaterialsView = ({
'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',
'SPACER': 'SPACER'
'PADDLE_BLIND': 'PADDLE BLIND',
'SPACER': 'SPACER',
'SWIVEL': 'SWIVEL FLANGE',
'DRIP_RING': 'DRIP RING',
'NOZZLE': 'NOZZLE FLANGE'
};
const facingTypeMap = {
@@ -52,8 +61,25 @@ const FlangeMaterialsView = ({
const rawFlangeType = flangeDetails.flange_type || '';
const rawFacingType = flangeDetails.facing_type || '';
let displayType = flangeTypeMap[rawFlangeType] || rawFlangeType || '-';
let facingType = facingTypeMap[rawFacingType] || rawFacingType || '-';
// rawFlangeType에서 facing 정보 분리 (예: "WN RF" -> "WN")
let cleanFlangeType = rawFlangeType;
let extractedFacing = rawFacingType;
// facing 정보가 flange_type에 포함된 경우 분리
if (rawFlangeType.includes(' RF')) {
cleanFlangeType = rawFlangeType.replace(' RF', '').trim();
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'RAISED_FACE';
} else if (rawFlangeType.includes(' FF')) {
cleanFlangeType = rawFlangeType.replace(' FF', '').trim();
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'FLAT_FACE';
} else if (rawFlangeType.includes(' RTJ')) {
cleanFlangeType = rawFlangeType.replace(' RTJ', '').trim();
if (!extractedFacing || extractedFacing === '-') extractedFacing = 'RING_TYPE_JOINT';
}
let displayType = flangeTypeMap[cleanFlangeType] || '-';
let facingType = facingTypeMap[extractedFacing] || '-';
// Description에서 추출
if (displayType === '-') {
@@ -70,8 +96,23 @@ const FlangeMaterialsView = ({
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';
if (facingType === '-') facingType = 'RAISED FACE';
} else if (desc.includes('WN FF') || desc.includes('WN-FF')) {
displayType = 'WELD NECK FLANGE';
if (facingType === '-') facingType = 'FLAT FACE';
} else if (desc.includes('WN RTJ') || desc.includes('WN-RTJ')) {
displayType = 'WELD NECK FLANGE';
if (facingType === '-') facingType = 'RING TYPE JOINT';
} else if (desc.includes('WN')) {
displayType = 'WELD NECK FLANGE';
} else if (desc.includes('SO RF') || desc.includes('SO-RF')) {
displayType = 'SLIP ON FLANGE';
if (facingType === '-') facingType = 'RAISED FACE';
} else if (desc.includes('SO FF') || desc.includes('SO-FF')) {
displayType = 'SLIP ON FLANGE';
if (facingType === '-') facingType = 'FLAT FACE';
} else if (desc.includes('SO')) {
displayType = 'SLIP ON FLANGE';
} else if (desc.includes('SW')) {
@@ -201,7 +242,18 @@ const FlangeMaterialsView = ({
}));
try {
// 1. 구매신청 생성
console.log('🔄 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'FLANGE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
@@ -222,31 +274,41 @@ const FlangeMaterialsView = ({
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}`);
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 3. 생성된 엑셀 파일을 서버에 업로드
console.log('📤 서버에 엑셀 파일 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'FLANGE');
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 2. 서버에 엑셀 파일 저장
await api.post('/files/save-excel', {
file_id: fileId,
category: 'FLANGE',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
});
// 3. 클라이언트 다운로드
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FLANGE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
// 4. 클라이언트 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FLANGE',
filename: excelFileName,
@@ -386,42 +448,47 @@ const FlangeMaterialsView = ({
<div style={{
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
overflow: 'auto',
maxHeight: '600px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)'
}}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 1fr 200px 150px 120px 100px 120px 150px 80px 80px 200px',
gap: '12px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
<div>
<input
type="checkbox"
checked={selectedMaterials.size === filteredMaterials.length && filteredMaterials.length > 0}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
<div style={{ minWidth: '1400px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
checked={(() => {
const selectableMaterials = filteredMaterials.filter(m => !purchasedMaterials.has(m.id));
return selectedMaterials.size === selectableMaterials.length && selectableMaterials.length > 0;
})()}
onChange={handleSelectAll}
style={{ cursor: 'pointer' }}
/>
</div>
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
<FilterableHeader sortKey="facing" filterKey="facing">Facing</FilterableHeader>
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
<FilterableHeader sortKey="schedule" filterKey="schedule">Schedule</FilterableHeader>
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
<div>Purchase Quantity</div>
<div>Additional Request</div>
</div>
<FilterableHeader sortKey="subtype" filterKey="subtype">Type</FilterableHeader>
<FilterableHeader sortKey="facing" filterKey="facing">Facing</FilterableHeader>
<FilterableHeader sortKey="size" filterKey="size">Size</FilterableHeader>
<FilterableHeader sortKey="pressure" filterKey="pressure">Pressure</FilterableHeader>
<FilterableHeader sortKey="schedule" filterKey="schedule">Schedule</FilterableHeader>
<FilterableHeader sortKey="grade" filterKey="grade">Material Grade</FilterableHeader>
<FilterableHeader sortKey="quantity" filterKey="quantity">Quantity</FilterableHeader>
<div>Unit</div>
<div>User Requirement</div>
</div>
{/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
<div>
{filteredMaterials.map((material, index) => {
const info = parseFlangeInfo(material);
const isSelected = selectedMaterials.has(material.id);
@@ -432,7 +499,7 @@ const FlangeMaterialsView = ({
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 1fr 200px 150px 120px 100px 120px 150px 80px 80px 200px',
gridTemplateColumns: '50px 250px 150px 120px 120px 100px 150px 180px 200px',
gap: '12px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
@@ -455,11 +522,15 @@ const FlangeMaterialsView = ({
type="checkbox"
checked={isSelected}
onChange={() => handleMaterialSelect(material.id)}
style={{ cursor: 'pointer' }}
disabled={isPurchased}
style={{
cursor: isPurchased ? 'not-allowed' : 'pointer',
opacity: isPurchased ? 0.5 : 1
}}
/>
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
FLANGE
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500', textAlign: 'center' }}>
{info.subtype}
{isPurchased && (
<span style={{
marginLeft: '8px',
@@ -474,29 +545,23 @@ const FlangeMaterialsView = ({
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '500' }}>
{info.subtype}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.facing}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.size}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.pressure}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.schedule}
</div>
<div style={{ fontSize: '14px', color: '#1f2937' }}>
<div style={{ fontSize: '14px', color: '#1f2937', textAlign: 'center' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'center' }}>
{info.quantity} {info.unit}
</div>
<div>
<input
@@ -506,7 +571,7 @@ const FlangeMaterialsView = ({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
placeholder="Enter additional request..."
style={{
width: '100%',
padding: '6px 8px',
@@ -520,7 +585,6 @@ const FlangeMaterialsView = ({
);
})}
</div>
</div>
{filteredMaterials.length === 0 && (
<div style={{
@@ -538,6 +602,8 @@ const FlangeMaterialsView = ({
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader, MaterialTable } from '../shared';
@@ -179,7 +179,18 @@ const PipeMaterialsView = ({
}));
try {
// 1. 구매신청 생성
console.log('🔄 파이프 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'PIPE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
@@ -200,34 +211,41 @@ const PipeMaterialsView = ({
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}`);
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 2. 구매신청된 자재 ID를 purchasedMaterials에 추가
// 3. 생성된 엑셀 파일을 서버에 업로드
console.log('📤 서버에 엑셀 파일 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'PIPE');
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 3. 서버에 엑셀 파일 저장 요청
await api.post('/files/save-excel', {
file_id: fileId,
category: 'PIPE',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
});
// 4. 클라이언트에서 다운로드
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'PIPE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
// 4. 클라이언트 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패해도 다운로드는 진행
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'PIPE',
filename: excelFileName,

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { exportMaterialsToExcel } from '../../../utils/excelExport';
import { exportMaterialsToExcel, createExcelBlob } from '../../../utils/excelExport';
import api from '../../../api';
import { FilterableHeader } from '../shared';
@@ -156,28 +156,79 @@ const ValveMaterialsView = ({
}));
try {
await api.post('/files/save-excel', {
console.log('🔄 밸브 엑셀 내보내기 시작 - 새로운 방식');
// 1. 먼저 클라이언트에서 엑셀 파일 생성
console.log('📊 엑셀 Blob 생성 중...', dataWithRequirements.length, '개 자료');
const excelBlob = await createExcelBlob(dataWithRequirements, excelFileName, {
category: 'VALVE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
console.log('✅ 엑셀 Blob 생성 완료:', excelBlob.size, 'bytes');
// 2. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'VALVE',
materials: dataWithRequirements,
filename: excelFileName,
user_id: user?.id
material_ids: allMaterialIds,
materials_data: dataWithRequirements.map(m => ({
material_id: m.id,
description: m.original_description,
category: m.classified_category,
size: m.size_inch || m.size_spec,
schedule: m.schedule,
material_grade: m.material_grade || m.full_material_grade,
quantity: m.quantity,
unit: m.unit,
user_requirement: userRequirements[m.id] || ''
}))
});
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'VALVE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}, request_id: ${response.data.request_id}`);
// 3. 생성된 엑셀 파일을 서버에 업로드
console.log('📤 서버에 엑셀 파일 업로드 중...');
const formData = new FormData();
formData.append('excel_file', excelBlob, excelFileName);
formData.append('request_id', response.data.request_id);
formData.append('category', 'VALVE');
const uploadResponse = await api.post('/purchase-request/upload-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log('✅ 엑셀 업로드 완료:', uploadResponse.data);
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 4. 클라이언트 다운로드
const url = window.URL.createObjectURL(excelBlob);
const link = document.createElement('a');
link.href = url;
link.download = excelFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 실패:', error);
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패 시에도 클라이언트 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'VALVE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
};