feat: BOM과 구매관리 페이지 엑셀 통합 및 완전 동일화
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
엑셀 내보내기 통합: - BOM 페이지: 구매신청 시 백엔드에서 엑셀 파일 생성 및 저장 - 구매관리 페이지: 저장된 엑셀 파일 직접 다운로드 (재생성 안 함) - 두 페이지에서 완전히 동일한 엑셀 파일 제공 백엔드 엑셀 생성: - openpyxl 사용하여 서버에서 엑셀 생성 - 카테고리별 시트 구성 - 헤더 스타일링 (연파랑 배경) - 컬럼 너비 자동 조정 FLANGE 품목명 개선: - 품목명: FLANGE (간단) - 상세내역: WELD NECK RF, SLIP-ON RF 등 (전체 이름) - 특수 플랜지: ORIFICE FLANGE, SPECTACLE BLIND 등 구분 구매신청 관리 API 개선: - 상세 정보 포함 (pipe_details, fitting_details, flange_details 등) - BOM 형식과 동일한 데이터 구조 - 수량 정수 변환 (3.000 → 3) 에러 수정: - fileName 중복 선언 해결 - flange_details.connection_method 컬럼 제거 (존재하지 않음) - Python 문법 오류 수정 (new Date() → datetime.now()) DB 스키마 개선: - revision_status 컬럼 추가 및 크기 조정 (VARCHAR(30)) - 리비전 변경사항 추적 지원
This commit is contained in:
@@ -1382,6 +1382,20 @@ const NewMaterialsPage = ({
|
||||
user_requirement: userRequirements[material.id] || ''
|
||||
}));
|
||||
|
||||
// 1단계: 엑셀 파일 생성
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const fileName = `${jobNo}_${selectedCategory}_${timestamp}.xlsx`;
|
||||
|
||||
// BOM 페이지에서 사용하는 엑셀 내보내기 함수 사용
|
||||
await exportCategoryToExcel(
|
||||
selectedCategory,
|
||||
dataWithRequirements,
|
||||
jobNo,
|
||||
fileName,
|
||||
userRequirements
|
||||
);
|
||||
|
||||
// 2단계: 생성된 엑셀을 서버에 업로드 (구매신청과 함께)
|
||||
// 서버에 구매신청 생성
|
||||
try {
|
||||
// 그룹화된 데이터를 구매신청 형식으로 변환
|
||||
@@ -1472,9 +1486,9 @@ const NewMaterialsPage = ({
|
||||
uploadDate: new Date().toLocaleDateString()
|
||||
};
|
||||
|
||||
const fileName = `${selectedCategory}_${jobNo || 'export'}_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
const excelFileName = `${selectedCategory}_${jobNo || 'export'}_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
|
||||
exportMaterialsToExcel(dataWithRequirements, fileName, additionalInfo);
|
||||
exportMaterialsToExcel(dataWithRequirements, excelFileName, additionalInfo);
|
||||
|
||||
console.log('✅ 엑셀 내보내기 성공');
|
||||
} catch (error) {
|
||||
|
||||
@@ -54,81 +54,27 @@ const PurchaseRequestPage = ({ onNavigate, fileId, jobNo, selectedProject }) =>
|
||||
const handleDownloadExcel = async (requestId, requestNo) => {
|
||||
try {
|
||||
console.log('📥 엑셀 다운로드 시작:', requestId, requestNo);
|
||||
// 서버에서 자재 데이터 가져오기
|
||||
const response = await api.get(`/purchase-request/${requestId}/download-excel`);
|
||||
console.log('📦 서버 응답:', response.data);
|
||||
|
||||
if (response.data.success) {
|
||||
const materials = response.data.materials;
|
||||
const groupedMaterials = response.data.grouped_materials || [];
|
||||
const jobNo = response.data.job_no;
|
||||
|
||||
console.log('📊 materials:', materials.length, '개');
|
||||
console.log('📊 groupedMaterials:', groupedMaterials.length, '개');
|
||||
|
||||
// 사용자 요구사항 매핑
|
||||
const userRequirements = {};
|
||||
materials.forEach(material => {
|
||||
if (material.user_requirement) {
|
||||
userRequirements[material.material_id || material.id] = material.user_requirement;
|
||||
}
|
||||
});
|
||||
|
||||
// 개별 자재 사용 (BOM 페이지와 동일하게)
|
||||
// groupedMaterials는 일부 자재가 누락될 수 있으므로 materials 사용
|
||||
const dataToExport = materials;
|
||||
|
||||
// 파일명 생성
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const fileName = `${jobNo}_${requestNo}_${timestamp}.xlsx`;
|
||||
|
||||
console.log('📄 내보낼 데이터:', dataToExport.length, '개');
|
||||
console.log('📄 첫 번째 자재:', dataToExport[0]);
|
||||
|
||||
// materials 데이터를 BOM 형식으로 변환
|
||||
const materialsForExport = materials.map(m => {
|
||||
// description에서 품목명 추출 (NIPPLE, ELBOW, TEE 등)
|
||||
const desc = m.description || '';
|
||||
const category = m.category || 'UNKNOWN';
|
||||
|
||||
return {
|
||||
...m,
|
||||
classified_category: category,
|
||||
original_description: desc,
|
||||
size_spec: m.size,
|
||||
size_inch: m.size,
|
||||
material_grade: m.material_grade,
|
||||
full_material_grade: m.material_grade,
|
||||
schedule: m.schedule,
|
||||
quantity: m.quantity,
|
||||
unit: m.unit,
|
||||
// FITTING의 경우 타입 정보 추가
|
||||
fitting_details: category === 'FITTING' ? {
|
||||
fitting_type: desc.includes('NIPPLE') ? 'NIPPLE' :
|
||||
desc.includes('ELBOW') ? 'ELBOW' :
|
||||
desc.includes('TEE') ? 'TEE' :
|
||||
desc.includes('REDUCER') ? 'REDUCER' :
|
||||
desc.includes('CAP') ? 'CAP' :
|
||||
desc.includes('PLUG') ? 'PLUG' :
|
||||
desc.includes('COUPLING') ? 'COUPLING' : 'FITTING'
|
||||
} : undefined
|
||||
};
|
||||
});
|
||||
|
||||
console.log('🔄 BOM 형식으로 변환 완료:', materialsForExport.length, '개');
|
||||
|
||||
// 기존 엑셀 유틸리티 사용
|
||||
exportMaterialsToExcel(materialsForExport, fileName, {
|
||||
jobNo,
|
||||
requestNo,
|
||||
userRequirements
|
||||
});
|
||||
|
||||
console.log('✅ 엑셀 내보내기 완료');
|
||||
} else {
|
||||
console.error('❌ 서버 응답 실패:', response.data);
|
||||
alert('데이터를 가져올 수 없습니다');
|
||||
}
|
||||
// 서버에서 생성된 엑셀 파일 직접 다운로드 (BOM 페이지와 동일한 파일)
|
||||
const response = await api.get(`/purchase-request/${requestId}/download-excel`, {
|
||||
responseType: 'blob' // 파일 다운로드용
|
||||
});
|
||||
|
||||
// 파일 다운로드 처리
|
||||
const blob = new Blob([response.data], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${requestNo}_재다운로드.xlsx`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 엑셀 파일 다운로드 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 엑셀 다운로드 실패:', error);
|
||||
alert('엑셀 다운로드 실패: ' + error.message);
|
||||
|
||||
@@ -150,66 +150,43 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
// 구매 수량 계산
|
||||
const purchaseInfo = calculatePurchaseQuantity(material);
|
||||
|
||||
// 품목명 생성 (카테고리별 상세 처리)
|
||||
// 변수 선언 (먼저 선언)
|
||||
let itemName = '';
|
||||
let detailInfo = '';
|
||||
let gasketMaterial = '';
|
||||
let gasketThickness = '';
|
||||
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);
|
||||
// 플랜지는 품목명만 간단하게 (상세내역에 타입 정보)
|
||||
itemName = 'FLANGE';
|
||||
|
||||
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';
|
||||
}
|
||||
// 특수 플랜지는 구분
|
||||
const desc = cleanDescription.toUpperCase();
|
||||
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';
|
||||
}
|
||||
|
||||
// 상세내역에 플랜지 타입 정보 저장 (줄임말 사용)
|
||||
if (material.flange_details && material.flange_details.flange_type) {
|
||||
detailInfo = material.flange_details.flange_type; // WN RF, SO RF 등
|
||||
} 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';
|
||||
// description에서 추출 (전체 이름 그대로 사용)
|
||||
const flangeTypeMatch = cleanDescription.match(/FLG\s+([^,]+?)(?=\s*SCH|\s*,\s*\d+LB|$)/i);
|
||||
if (flangeTypeMatch) {
|
||||
detailInfo = flangeTypeMatch[1].trim(); // WELD NECK RF 등 그대로
|
||||
}
|
||||
}
|
||||
console.log(' → 품목명:', itemName);
|
||||
} else if (category === 'VALVE') {
|
||||
itemName = 'VALVE';
|
||||
} else if (category === 'GASKET') {
|
||||
@@ -379,10 +356,7 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리별 상세 정보 추출
|
||||
let detailInfo = '';
|
||||
let gasketMaterial = '';
|
||||
let gasketThickness = '';
|
||||
// 카테고리별 상세 정보 추출 (이미 위에서 선언됨)
|
||||
|
||||
if (category === 'BOLT') {
|
||||
// 볼트의 경우 표면처리 정보 추출
|
||||
|
||||
Reference in New Issue
Block a user