🔧 볼트 재질 정보 개선 및 A320/A194M 패턴 지원
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- bolt_classifier.py: A320/A194M 조합 패턴 처리 로직 추가
- material_grade_extractor.py: A320/A194M 패턴 추출 개선
- integrated_classifier.py: SPECIAL, U_BOLT 카테고리 우선 분류
- 데이터베이스: 492개 볼트의 material_grade를 완전한 형태로 업데이트
  - A320/A194M GR B8/8: 78개
  - A193/A194 GR B7/2H: 414개
- 프론트엔드: BOLT 카테고리 전용 UI (길이 표시)
- Excel 내보내기: BOLT용 컬럼 순서 및 재질 정보 개선
- SPECIAL, U_BOLT 카테고리 지원 추가
This commit is contained in:
Hyungi Ahn
2025-10-01 08:18:25 +09:00
parent 50570e4624
commit 2e0d91cf59
12 changed files with 2370 additions and 256 deletions

View File

@@ -150,7 +150,7 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
// 구매 수량 계산
const purchaseInfo = calculatePurchaseQuantity(material);
// 품목명 생성 (간단하게)
// 품목명 생성 (카테고리별 상세 처리)
let itemName = '';
if (category === 'PIPE') {
itemName = material.pipe_details?.manufacturing_method || 'PIPE';
@@ -161,34 +161,237 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
} else if (category === 'VALVE') {
itemName = 'VALVE';
} else if (category === 'GASKET') {
itemName = '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 {
itemName = category || 'UNKNOWN';
}
// 압력 등급 추출
// 압력 등급 추출 (카테고리별 처리)
let pressure = '-';
const pressureMatch = cleanDescription.match(/(\d+)LB/i);
if (pressureMatch) {
pressure = `${pressureMatch[1]}LB`;
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 = '-';
const scheduleMatch = cleanDescription.match(/SCH\s*(\d+[A-Z]*(?:\s*[xX×]\s*SCH\s*\d+[A-Z]*)?)/i);
if (scheduleMatch) {
schedule = scheduleMatch[0];
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];
}
}
// 재질 추출 (ASTM 등)
// 재질 추출 (카테고리별 처리)
let grade = '-';
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();
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(', ');
}
// 새로운 엑셀 양식에 맞춘 데이터 구조
const base = {
'TAGNO': '', // 비워둠
@@ -197,18 +400,46 @@ const formatMaterialForExcel = (material, includeComparison = false) => {
'통화구분': 'KRW', // 기본값
'단가': 1, // 일괄 1로 설정
'크기': material.size_spec || '-',
'압력등급': pressure,
'스케줄': schedule,
'재질': grade,
'사용자요구': '',
'관리항목1': '', // 빈칸
'관리항목7': '', // 빈칸
'관리항목8': '', // 빈칸
'관리항목9': '', // 빈칸
'관리항목10': '', // 빈칸
'납기일(YYYY-MM-DD)': new Date().toISOString().split('T')[0] // 오늘 날짜
'압력등급': 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) {
@@ -245,46 +476,96 @@ export const exportMaterialsToExcel = (materials, filename, additionalInfo = {})
// 새 워크북 생성
const workbook = XLSX.utils.book_new();
// 요약 시트 생성
const summaryData = [
['파일 정보', ''],
['파일명', additionalInfo.filename || ''],
['Job No', additionalInfo.jobNo || ''],
['리비전', additionalInfo.revision || ''],
['업로드일', additionalInfo.uploadDate || new Date().toLocaleDateString()],
['총 자재 수', consolidatedMaterials.length],
['', ''],
['카테고리별 요약', ''],
['카테고리', '수량']
];
// 카테고리별 요약 추가 (합쳐진 자재 기준)
Object.entries(categoryGroups).forEach(([category, items]) => {
const consolidatedItems = consolidateMaterials(items);
summaryData.push([category, consolidatedItems.length]);
});
const summarySheet = XLSX.utils.aoa_to_sheet(summaryData);
XLSX.utils.book_append_sheet(workbook, summarySheet, '요약');
// 전체 자재 시트 (합쳐진 자재)
const allMaterialsFormatted = consolidatedMaterials.map(material => formatMaterialForExcel(material));
const allSheet = XLSX.utils.json_to_sheet(allMaterialsFormatted);
XLSX.utils.book_append_sheet(workbook, allSheet, '전체 자재');
// 카테고리별 시트 생성 (합쳐진 자재)
Object.entries(categoryGroups).forEach(([category, items]) => {
const consolidatedItems = consolidateMaterials(items);
const formattedItems = consolidatedItems.map(material => formatMaterialForExcel(material));
const categorySheet = XLSX.utils.json_to_sheet(formattedItems);
// 시트명에서 특수문자 제거 (엑셀 시트명 규칙)
const sheetName = category.replace(/[\\\/\*\?\[\]]/g, '_').substring(0, 31);
XLSX.utils.book_append_sheet(workbook, categorySheet, sheetName);
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);
}
});
// 파일 저장
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
// 워크북 속성 설정 (스타일 지원)
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`;