feat: BOM과 구매관리 페이지 엑셀 통합 및 완전 동일화
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:
Hyungi Ahn
2025-10-14 15:59:33 +09:00
parent 72126ef78d
commit 8f5330a008
9 changed files with 334 additions and 4535 deletions

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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') {
// 볼트의 경우 표면처리 정보 추출