feat: 피팅류 엑셀 내보내기 개선 및 프로젝트 비활성화 버그 수정
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

 피팅류 엑셀 내보내기 개선:
- 품목명에 상세 피팅 타입 표시 (SOCK-O-LET, ELBOW 90° LR 등)
- G열부터 압력등급/스케줄/재질/사용자요구/추가요청사항 체계적 배치
- 분류기 추출 요구사항(J열)과 사용자 입력 요구사항(K열) 분리
- P열 납기일 고정 규칙 유지, 관리항목 자동 채움

🐛 프로젝트 비활성화 버그 수정:
- 백엔드: job_no 필드 추가로 프론트엔드 호환성 확보
- 프론트엔드: 안전한 프로젝트 식별자 처리 로직 구현
- 개별 프로젝트 비활성화 시 전체 프로젝트 영향 문제 해결
- 디버깅 로그 추가로 상태 변경 추적 가능

🔧 기타 개선사항:
- BOM 페이지 이모지 제거
- 구매신청 후 자재 비활성화 기능 구현
- 모든 카테고리 뷰에 onPurchasedMaterialsUpdate 콜백 추가
This commit is contained in:
hyungi
2025-10-16 14:00:44 +09:00
parent 22baea38e1
commit c7297c6fb7
9 changed files with 339 additions and 86 deletions

View File

@@ -526,6 +526,7 @@ async def get_projects(
projects.append({
"id": row.id,
"official_project_code": row.official_project_code,
"job_no": row.official_project_code, # job_no 필드 추가 (프론트엔드 호환성)
"project_name": row.project_name,
"job_name": row.project_name, # 호환성을 위해 추가
"client_name": row.client_name,

View File

@@ -148,9 +148,13 @@ function App() {
// 프로젝트 활성화
const handleActivateProject = (project) => {
const projectId = project.job_no || project.official_project_code || project.id;
console.log('🔄 프로젝트 활성화:', { project, projectId });
setInactiveProjects(prev => {
const newSet = new Set(prev);
newSet.delete(project.job_no);
newSet.delete(projectId);
console.log('📦 활성화 프로젝트 업데이트:', { prev: Array.from(prev), new: Array.from(newSet) });
return newSet;
});
};

View File

@@ -10,7 +10,9 @@ const FittingMaterialsView = ({
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
fileId,
jobNo,
user,
onNavigate
}) => {
@@ -245,16 +247,73 @@ const FittingMaterialsView = ({
}
}
// 스케줄 표시 (분리 스케줄 지원)
// 스케줄 표시 (분리 스케줄 지원) - 개선된 로직
// 레듀싱 자재인지 확인
const isReducingFitting = displayType.includes('REDUCING') || displayType.includes('RED') ||
description.toUpperCase().includes('RED') ||
description.toUpperCase().includes('REDUCING');
if (hasDifferentSchedules && mainSchedule && redSchedule) {
schedule = `${mainSchedule}×${redSchedule}`;
} else if (mainSchedule) {
} else if (isReducingFitting && mainSchedule && mainSchedule !== 'UNKNOWN') {
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
schedule = `${mainSchedule}×${mainSchedule}`;
} else if (mainSchedule && mainSchedule !== 'UNKNOWN') {
schedule = mainSchedule;
} else {
// Description에서 스케줄 추출
const scheduleMatch = description.match(/SCH\s*(\d+[A-Z]*)/i);
if (scheduleMatch) {
schedule = `SCH ${scheduleMatch[1]}`;
// Description에서 스케줄 추출 - 더 강력한 패턴 매칭
const schedulePatterns = [
/SCH\s*(\d+S?)/i, // SCH 40, SCH 80S
/SCHEDULE\s*(\d+S?)/i, // SCHEDULE 40
/스케줄\s*(\d+S?)/i, // 스케줄 40
/(\d+S?)\s*SCH/i, // 40 SCH (역순)
/SCH\.?\s*(\d+S?)/i, // SCH.40
/SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)/i // SCH 40 x SCH 80
];
for (const pattern of schedulePatterns) {
const match = description.match(pattern);
if (match) {
if (match.length > 2) {
// 분리 스케줄 패턴 (SCH 40 x SCH 80)
schedule = `SCH ${match[1]}×SCH ${match[2]}`;
} else {
const scheduleNum = match[1];
if (isReducingFitting) {
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
schedule = `SCH ${scheduleNum}×SCH ${scheduleNum}`;
} else {
schedule = `SCH ${scheduleNum}`;
}
}
break;
}
}
// 여전히 찾지 못했다면 더 넓은 패턴 시도
if (schedule === '-') {
const broadPatterns = [
/\b(\d+)\s*LB/i, // 압력 등급에서 유추
/\b(40|80|120|160)\b/i, // 일반적인 스케줄 숫자
/\b(10|20|30|40|60|80|100|120|140|160)\b/i // 모든 표준 스케줄
];
for (const pattern of broadPatterns) {
const match = description.match(pattern);
if (match) {
const num = match[1];
// 압력 등급이 아닌 경우만 스케줄로 간주
if (!description.includes(`${num}LB`)) {
if (isReducingFitting) {
// 레듀싱 자재는 같은 스케줄이라도 명시적으로 표시
schedule = `SCH ${num}×SCH ${num}`;
} else {
schedule = `SCH ${num}`;
}
break;
}
}
}
}
}
@@ -353,6 +412,36 @@ const FittingMaterialsView = ({
}));
try {
// 1. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'FITTING',
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] || ''
}))
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}`);
// 2. 구매신청된 자재 ID를 purchasedMaterials에 추가
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 3. 서버에 엑셀 파일 저장 요청
await api.post('/files/save-excel', {
file_id: fileId,
category: 'FITTING',
@@ -361,21 +450,27 @@ const FittingMaterialsView = ({
user_id: user?.id
});
// 4. 클라이언트에서 다운로드
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FITTING',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 실패:', error);
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패해도 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FITTING',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
// 선택 해제
setSelectedMaterials(new Set());
};
const filteredMaterials = getFilteredAndSortedMaterials();
@@ -506,21 +601,24 @@ const FittingMaterialsView = ({
<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 200px 120px 100px 120px 150px 80px 80px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
<div style={{ minWidth: '1380px' }}>
{/* 헤더 */}
<div style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 200px',
gap: '16px',
padding: '16px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
fontSize: '14px',
fontWeight: '600',
color: '#374151',
textAlign: 'center'
}}>
<div>
<input
type="checkbox"
@@ -537,13 +635,12 @@ const FittingMaterialsView = ({
<div>Pressure</div>
<div>Schedule</div>
<div>Material Grade</div>
<div>Quantity</div>
<div>Unit</div>
<div>User Requirement</div>
<div>User Requirements</div>
<div>Purchase Quantity</div>
<div>Additional Request</div>
</div>
{/* 데이터 행들 */}
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
{/* 데이터 행들 */}
{filteredMaterials.map((material, index) => {
const info = parseFittingInfo(material);
const isSelected = selectedMaterials.has(material.id);
@@ -554,12 +651,13 @@ const FittingMaterialsView = ({
key={material.id}
style={{
display: 'grid',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 80px 80px 200px',
gridTemplateColumns: '50px 200px 120px 100px 120px 150px 180px 120px 200px',
gap: '16px',
padding: '16px',
borderBottom: index < filteredMaterials.length - 1 ? '1px solid #f1f5f9' : 'none',
background: isSelected ? '#eff6ff' : (isPurchased ? '#fef3c7' : 'white'),
transition: 'background 0.15s ease'
transition: 'background 0.15s ease',
textAlign: 'center'
}}
onMouseEnter={(e) => {
if (!isSelected && !isPurchased) {
@@ -612,11 +710,17 @@ const FittingMaterialsView = ({
<div style={{ fontSize: '14px', color: '#1f2937' }}>
{info.grade}
</div>
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity}
<div style={{
fontSize: '14px',
color: '#1f2937',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
{material.user_requirements?.join(', ') || '-'}
</div>
<div style={{ fontSize: '14px', color: '#6b7280' }}>
{info.unit}
<div style={{ fontSize: '14px', color: '#1f2937', fontWeight: '600', textAlign: 'right' }}>
{info.quantity} {info.unit}
</div>
<div>
<input
@@ -626,7 +730,7 @@ const FittingMaterialsView = ({
...userRequirements,
[material.id]: e.target.value
})}
placeholder="Enter requirement..."
placeholder="Enter additional request..."
style={{
width: '100%',
padding: '6px 8px',

View File

@@ -10,7 +10,9 @@ const FlangeMaterialsView = ({
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
fileId,
jobNo,
user,
onNavigate
}) => {
@@ -199,6 +201,34 @@ const FlangeMaterialsView = ({
}));
try {
// 1. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'FLANGE',
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] || ''
}))
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}`);
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 2. 서버에 엑셀 파일 저장
await api.post('/files/save-excel', {
file_id: fileId,
category: 'FLANGE',
@@ -207,21 +237,25 @@ const FlangeMaterialsView = ({
user_id: user?.id
});
// 3. 클라이언트 다운로드
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FLANGE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 실패:', error);
console.error('엑셀 저장 또는 구매신청 실패:', error);
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'FLANGE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
setSelectedMaterials(new Set());
};
const filteredMaterials = getFilteredAndSortedMaterials();

View File

@@ -10,7 +10,9 @@ const PipeMaterialsView = ({
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
fileId,
jobNo,
user,
onNavigate
}) => {
@@ -177,7 +179,36 @@ const PipeMaterialsView = ({
}));
try {
// 서버에 엑셀 파일 저장 요청
// 1. 구매신청 생성
const allMaterialIds = selectedMaterialsData.map(m => m.id);
const response = await api.post('/purchase-request/create', {
file_id: fileId,
job_no: jobNo,
category: 'PIPE',
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] || ''
}))
});
if (response.data.success) {
console.log(`✅ 구매신청 완료: ${response.data.request_no}`);
// 2. 구매신청된 자재 ID를 purchasedMaterials에 추가
if (onPurchasedMaterialsUpdate) {
onPurchasedMaterialsUpdate(allMaterialIds);
}
}
// 3. 서버에 엑셀 파일 저장 요청
await api.post('/files/save-excel', {
file_id: fileId,
category: 'PIPE',
@@ -186,23 +217,27 @@ const PipeMaterialsView = ({
user_id: user?.id
});
// 클라이언트에서 다운로드
// 4. 클라이언트에서 다운로드
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'PIPE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일이 생성되고 서버에 저장되었습니다.');
alert(`구매신청 ${response.data?.request_no || ''}이 생성되고 엑셀 파일이 저장되었습니다.`);
} catch (error) {
console.error('엑셀 저장 실패:', error);
console.error('엑셀 저장 또는 구매신청 실패:', error);
// 실패해도 다운로드는 진행
exportMaterialsToExcel(dataWithRequirements, excelFileName, {
category: 'PIPE',
filename: excelFileName,
uploadDate: new Date().toLocaleDateString()
});
alert('엑셀 파일은 다운로드되었지만 구매신청 생성에 실패했습니다.');
}
// 선택 해제
setSelectedMaterials(new Set());
};
const filteredMaterials = getFilteredAndSortedMaterials();

View File

@@ -10,7 +10,9 @@ const ValveMaterialsView = ({
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate,
fileId,
jobNo,
user
}) => {
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });

View File

@@ -167,7 +167,16 @@ const BOMManagementPage = ({
userRequirements,
setUserRequirements,
purchasedMaterials,
onPurchasedMaterialsUpdate: (materialIds) => {
setPurchasedMaterials(prev => {
const newSet = new Set(prev);
materialIds.forEach(id => newSet.add(id));
console.log(`📦 구매신청 자재 추가: 기존 ${prev.size}개 → 신규 ${newSet.size}`);
return newSet;
});
},
fileId,
jobNo,
user,
onNavigate
};

View File

@@ -43,9 +43,20 @@ const DashboardPage = ({
// 프로젝트 비활성화
const handleDeactivateProject = (project) => {
if (window.confirm(`"${project.job_name || project.job_no}" 프로젝트를 비활성화하시겠습니까?`)) {
setInactiveProjects(prev => new Set([...prev, project.job_no]));
if (selectedProject?.job_no === project.job_no) {
const projectId = project.job_no || project.official_project_code || project.id;
const projectName = project.job_name || project.project_name || projectId;
console.log('🔍 비활성화 요청:', { project, projectId, projectName });
if (window.confirm(`"${projectName}" 프로젝트를 비활성화하시겠습니까?`)) {
setInactiveProjects(prev => {
const newSet = new Set([...prev, projectId]);
console.log('📦 비활성화 프로젝트 업데이트:', { prev: Array.from(prev), new: Array.from(newSet) });
return newSet;
});
const selectedProjectId = selectedProject?.job_no || selectedProject?.official_project_code || selectedProject?.id;
if (selectedProjectId === projectId) {
setSelectedProject(null);
}
setShowProjectDropdown(false);
@@ -323,7 +334,10 @@ const DashboardPage = ({
</div>
) : (
projects
.filter(project => !inactiveProjects.has(project.job_no))
.filter(project => {
const projectId = project.job_no || project.official_project_code || project.id;
return !inactiveProjects.has(projectId);
})
.map((project) => (
<div
key={project.job_no}

View File

@@ -180,49 +180,112 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
}
}
} else if (category === 'FITTING') {
// 피팅 상세 타입 표시 (OLET 등 풀네임)
// 피팅 상세 타입 표시 - 프론트엔드 표시와 동일한 로직 사용
const fittingDetails = material.fitting_details || {};
const fittingType = fittingDetails.fitting_type || '';
const fittingSubtype = fittingDetails.fitting_subtype || '';
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':
itemName = 'SOCK-O-LET';
displayType = 'SOCK-O-LET';
break;
case 'WELDOLET':
itemName = 'WELD-O-LET';
displayType = 'WELD-O-LET';
break;
case 'ELLOLET':
itemName = 'ELL-O-LET';
displayType = 'ELL-O-LET';
break;
case 'THREADOLET':
itemName = 'THREAD-O-LET';
displayType = 'THREAD-O-LET';
break;
case 'ELBOLET':
itemName = 'ELB-O-LET';
displayType = 'ELB-O-LET';
break;
case 'NIPOLET':
itemName = 'NIP-O-LET';
displayType = 'NIP-O-LET';
break;
case 'COUPOLET':
itemName = 'COUP-O-LET';
displayType = 'COUP-O-LET';
break;
default:
itemName = 'OLET';
// 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');
elbowDetails.push('90°');
} else if (fittingSubtype.includes('45DEG') || cleanDescription.includes('45')) {
elbowDetails.push('45');
} else {
elbowDetails.push('90도'); // 기본값
elbowDetails.push('45°');
}
// 반경 정보
@@ -232,25 +295,12 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
elbowDetails.push('SR');
}
// 연결 방식
if (cleanDescription.includes('SW')) {
elbowDetails.push('SW');
} else if (cleanDescription.includes('BW')) {
elbowDetails.push('BW');
}
itemName = `엘보 ${elbowDetails.join(' ')}`.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();
displayType = elbowDetails.length > 0 ? `ELBOW ${elbowDetails.join(' ')}` : 'ELBOW';
} else {
itemName = fittingType || 'FITTING';
displayType = fittingType || 'FITTING';
}
itemName = displayType;
} else if (category === 'FLANGE') {
// 플랜지 상세 타입 표시
const flangeDetails = material.flange_details || {};
@@ -674,17 +724,17 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
base['관리항목3'] = ''; // N열
base['관리항목4'] = ''; // O열
} else if (category === 'FITTING') {
// 피팅 전용 컬럼 (F~O) - 타입 제거, 품목명에 포함됨
// 피팅 전용 컬럼 (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열
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') {
// 플랜지 타입 풀네임 매핑 (영어)
const flangeTypeMap = {