🔧 재질 정보 표시 개선 및 UI 확장
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

 주요 수정사항:
- 재질 GRADE 전체 표기: ASTM A106 B 완전 표시 (A10 잘림 현상 해결)
- material_grade_extractor.py 정규표현식 패턴 개선
- files.py 파일 업로드 시 재질 추출 로직 수정
- CSS 그리드 너비 확장으로 텍스트 잘림 현상 해결
- 사용자 요구사항 엑셀 다운로드 기능 완료

🎯 해결된 문제:
1. ASTM A106 B → ASTM A10 잘림 문제
2. 재질 컬럼 너비 부족으로 인한 표시 문제
3. 사용자 요구사항이 엑셀에 반영되지 않는 문제

📋 다음 단계 준비:
- 파이프 끝단 정보 제외 취합 로직 개선
- 플랜지 타입 정보 확장
- 자재 분류 필터 기능 추가
This commit is contained in:
Hyungi Ahn
2025-09-25 08:32:17 +09:00
parent af4ad25a54
commit 0f9a5ad2ea
29 changed files with 1281 additions and 58 deletions

View File

@@ -8,13 +8,14 @@ from typing import Dict, List, Optional, Tuple
# Level 1: 명확한 타입 키워드 (최우선)
LEVEL1_TYPE_KEYWORDS = {
"BOLT": ["FLANGE BOLT", "BOLT", "STUD", "NUT", "SCREW", "WASHER", "볼트", "너트", "스터드", "나사", "와셔"],
"BOLT": ["FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW", "WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트"],
"VALVE": ["VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE", "RELIEF", "밸브", "게이트", "", "글로브", "체크", "버터플라이", "니들", "릴리프"],
"FLANGE": ["FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE", "SPACER", "BLIND"],
"PIPE": ["PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"],
"FITTING": ["ELBOW", "ELL", "TEE", "REDUCER", "RED", "CAP", "COUPLING", "NIPPLE", "SWAGE", "OLET", "PLUG", "엘보", "", "리듀서", "", "니플", "커플링", "플러그", "CONC", "ECC", "SOCK-O-LET", "WELD-O-LET", "SOCKOLET", "WELDOLET", "THREADOLET"],
"GASKET": ["GASKET", "GASK", "가스켓", "SWG", "SPIRAL"],
"INSTRUMENT": ["GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서"]
"INSTRUMENT": ["GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서"],
"SUPPORT": ["URETHANE BLOCK", "URETHANE", "BLOCK SHOE", "CLAMP", "SUPPORT", "HANGER", "SPRING", "우레탄", "블록", "클램프", "서포트", "행거", "스프링"]
}
# Level 2: 서브타입 키워드 (구체화)
@@ -33,7 +34,14 @@ LEVEL2_SUBTYPE_KEYWORDS = {
},
"BOLT": {
"HEX_BOLT": ["HEX BOLT", "HEXAGON", "육각 볼트"],
"STUD_BOLT": ["STUD BOLT", "STUD", "스터드 볼트"]
"STUD_BOLT": ["STUD BOLT", "STUD", "스터드 볼트"],
"U_BOLT": ["U-BOLT", "U BOLT", "유볼트"]
},
"SUPPORT": {
"URETHANE_BLOCK": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록"],
"CLAMP": ["CLAMP", "클램프"],
"HANGER": ["HANGER", "SUPPORT", "행거", "서포트"],
"SPRING": ["SPRING", "스프링"]
}
}
@@ -119,8 +127,8 @@ def classify_material_integrated(description: str, main_nom: str = "",
}
# Level 2 키워드가 없으면 우선순위로 결정
# BOLT > FITTING > VALVE > FLANGE > PIPE (볼트 우선, 더 구체적인 것 우선)
type_priority = ["BOLT", "FITTING", "VALVE", "FLANGE", "PIPE", "GASKET", "INSTRUMENT"]
# BOLT > SUPPORT > FITTING > VALVE > FLANGE > PIPE (볼트 우선, 더 구체적인 것 우선)
type_priority = ["BOLT", "SUPPORT", "FITTING", "VALVE", "FLANGE", "PIPE", "GASKET", "INSTRUMENT"]
for priority_type in type_priority:
for detected_type, keyword in detected_types:
if detected_type == priority_type:

View File

@@ -0,0 +1,247 @@
"""
전체 재질명 추출기
원본 설명에서 완전한 재질명을 추출하여 축약되지 않은 형태로 제공
"""
import re
from typing import Optional, Dict
def extract_full_material_grade(description: str) -> str:
"""
원본 설명에서 전체 재질명 추출
Args:
description: 원본 자재 설명
Returns:
전체 재질명 (예: "ASTM A312 TP304", "ASTM A106 GR B")
"""
if not description:
return ""
desc_upper = description.upper().strip()
# 1. ASTM 규격 패턴들 (가장 구체적인 것부터)
astm_patterns = [
# ASTM A312 TP304, ASTM A312 TP316L 등
r'ASTM\s+A\d{3,4}[A-Z]*\s+TP\d+[A-Z]*',
# ASTM A182 F304, ASTM A182 F316L 등
r'ASTM\s+A\d{3,4}[A-Z]*\s+F\d+[A-Z]*',
# ASTM A403 WP304, ASTM A234 WPB 등
r'ASTM\s+A\d{3,4}[A-Z]*\s+WP[A-Z0-9]+',
# ASTM A351 CF8M, ASTM A216 WCB 등
r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z]{2,4}[0-9]*[A-Z]*',
# ASTM A106 GR B, ASTM A105 등 - GR 포함
r'ASTM\s+A\d{3,4}[A-Z]*\s+GR\s+[A-Z0-9]+',
r'ASTM\s+A\d{3,4}[A-Z]*\s+GRADE\s+[A-Z0-9]+',
# ASTM A106 B (GR 없이 바로 등급) - 단일 문자 등급
r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z](?=\s|$)',
# ASTM A105, ASTM A234 등 (등급 없는 경우)
r'ASTM\s+A\d{3,4}[A-Z]*(?!\s+[A-Z0-9])',
# 2자리 ASTM 규격도 지원 (A10, A36 등)
r'ASTM\s+A\d{2}(?:\s+GR\s+[A-Z0-9]+)?',
]
for pattern in astm_patterns:
match = re.search(pattern, desc_upper)
if match:
full_grade = match.group(0).strip()
# 추가 정보가 있는지 확인 (PBE, BBE 등은 제외)
end_pos = match.end()
remaining = desc_upper[end_pos:].strip()
# 끝단 가공 정보는 제외
end_prep_codes = ['PBE', 'BBE', 'POE', 'BOE', 'TOE']
for code in end_prep_codes:
remaining = re.sub(rf'\b{code}\b', '', remaining).strip()
# 남은 재질 관련 정보가 있으면 추가
additional_info = []
if remaining:
# 일반적인 재질 추가 정보 패턴
additional_patterns = [
r'\bH\b', # H (고온용)
r'\bL\b', # L (저탄소)
r'\bN\b', # N (질소 첨가)
r'\bS\b', # S (황 첨가)
r'\bMOD\b', # MOD (개량형)
]
for add_pattern in additional_patterns:
if re.search(add_pattern, remaining):
additional_info.append(re.search(add_pattern, remaining).group(0))
if additional_info:
full_grade += ' ' + ' '.join(additional_info)
return full_grade
# 2. ASME 규격 패턴들
asme_patterns = [
r'ASME\s+SA\d+[A-Z]*\s+TP\d+[A-Z]*(?:\s+[A-Z]+)*',
r'ASME\s+SA\d+[A-Z]*\s+GR\s+[A-Z0-9]+(?:\s+[A-Z]+)*',
r'ASME\s+SA\d+[A-Z]*\s+F\d+[A-Z]*(?:\s+[A-Z]+)*',
r'ASME\s+SA\d+[A-Z]*(?!\s+[A-Z0-9])',
]
for pattern in asme_patterns:
match = re.search(pattern, desc_upper)
if match:
return match.group(0).strip()
# 3. KS 규격 패턴들
ks_patterns = [
r'KS\s+D\d+[A-Z]*\s+[A-Z0-9]+(?:\s+[A-Z]+)*',
r'KS\s+D\d+[A-Z]*(?!\s+[A-Z0-9])',
]
for pattern in ks_patterns:
match = re.search(pattern, desc_upper)
if match:
return match.group(0).strip()
# 4. JIS 규격 패턴들
jis_patterns = [
r'JIS\s+[A-Z]\d+[A-Z]*\s+[A-Z0-9]+(?:\s+[A-Z]+)*',
r'JIS\s+[A-Z]\d+[A-Z]*(?!\s+[A-Z0-9])',
]
for pattern in jis_patterns:
match = re.search(pattern, desc_upper)
if match:
return match.group(0).strip()
# 5. 특수 재질 패턴들
special_patterns = [
# Inconel, Hastelloy 등
r'INCONEL\s+\d+[A-Z]*',
r'HASTELLOY\s+[A-Z]\d*[A-Z]*',
r'MONEL\s+\d+[A-Z]*',
# Titanium
r'TITANIUM\s+GRADE\s+\d+[A-Z]*',
r'TI\s+GR\s*\d+[A-Z]*',
# 듀플렉스 스테인리스
r'DUPLEX\s+\d+[A-Z]*',
r'SUPER\s+DUPLEX\s+\d+[A-Z]*',
]
for pattern in special_patterns:
match = re.search(pattern, desc_upper)
if match:
return match.group(0).strip()
# 6. 일반 스테인리스 패턴들 (숫자만)
stainless_patterns = [
r'\b(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b',
r'\bSS\s*(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b',
r'\bSUS\s*(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b',
]
for pattern in stainless_patterns:
match = re.search(pattern, desc_upper)
if match:
grade = match.group(1) if match.groups() else match.group(0)
if grade.startswith(('SS', 'SUS')):
return grade
else:
return f"SS{grade}"
# 7. 탄소강 패턴들
carbon_patterns = [
r'\bSM\d+[A-Z]*\b', # SM400, SM490 등
r'\bSS\d+[A-Z]*\b', # SS400, SS490 등 (스테인리스와 구분)
r'\bS\d+C\b', # S45C, S50C 등
]
for pattern in carbon_patterns:
match = re.search(pattern, desc_upper)
if match:
return match.group(0).strip()
# 8. 기존 material_grade가 있으면 그대로 반환
# (분류기에서 이미 처리된 경우)
return ""
def update_full_material_grades(db, batch_size: int = 1000) -> Dict:
"""
기존 자재들의 full_material_grade 업데이트
Args:
db: 데이터베이스 세션
batch_size: 배치 처리 크기
Returns:
업데이트 결과 통계
"""
from sqlalchemy import text
try:
# 전체 자재 수 조회
count_query = text("SELECT COUNT(*) FROM materials WHERE full_material_grade IS NULL OR full_material_grade = ''")
total_count = db.execute(count_query).scalar()
print(f"📊 업데이트 대상 자재: {total_count}")
updated_count = 0
processed_count = 0
# 배치 단위로 처리
offset = 0
while offset < total_count:
# 배치 조회
select_query = text("""
SELECT id, original_description, material_grade
FROM materials
WHERE full_material_grade IS NULL OR full_material_grade = ''
ORDER BY id
LIMIT :limit OFFSET :offset
""")
results = db.execute(select_query, {"limit": batch_size, "offset": offset}).fetchall()
if not results:
break
# 배치 업데이트
for material_id, original_description, current_grade in results:
full_grade = extract_full_material_grade(original_description)
# 전체 재질명이 추출되지 않으면 기존 grade 사용
if not full_grade and current_grade:
full_grade = current_grade
if full_grade:
update_query = text("""
UPDATE materials
SET full_material_grade = :full_grade
WHERE id = :material_id
""")
db.execute(update_query, {
"full_grade": full_grade,
"material_id": material_id
})
updated_count += 1
processed_count += 1
# 배치 커밋
db.commit()
offset += batch_size
print(f"📈 진행률: {processed_count}/{total_count} ({processed_count/total_count*100:.1f}%)")
return {
"total_processed": processed_count,
"updated_count": updated_count,
"success": True
}
except Exception as e:
db.rollback()
print(f"❌ 업데이트 실패: {str(e)}")
return {
"total_processed": 0,
"updated_count": 0,
"success": False,
"error": str(e)
}

View File

@@ -287,3 +287,8 @@ def get_revision_comparison(db: Session, job_no: str, current_revision: str,

View File

@@ -0,0 +1,283 @@
"""
SUPPORT 분류 시스템
배관 지지재, 우레탄 블록, 클램프 등 지지 부품 분류
"""
import re
from typing import Dict, List, Optional
from .material_classifier import classify_material
# ========== 서포트 타입별 분류 ==========
SUPPORT_TYPES = {
"URETHANE_BLOCK": {
"dat_file_patterns": ["URETHANE", "BLOCK", "SHOE"],
"description_keywords": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록", "우레탄", "URETHANE"],
"characteristics": "우레탄 블록 슈",
"applications": "배관 지지, 진동 흡수",
"material_type": "URETHANE"
},
"CLAMP": {
"dat_file_patterns": ["CLAMP", "CL-"],
"description_keywords": ["CLAMP", "클램프", "CL-1", "CL-2", "CL-3"],
"characteristics": "배관 클램프",
"applications": "배관 고정, 지지",
"material_type": "STEEL"
},
"HANGER": {
"dat_file_patterns": ["HANGER", "HANG", "SUPP"],
"description_keywords": ["HANGER", "SUPPORT", "행거", "서포트", "PIPE HANGER"],
"characteristics": "배관 행거",
"applications": "배관 매달기, 지지",
"material_type": "STEEL"
},
"SPRING_HANGER": {
"dat_file_patterns": ["SPRING", "SPR_"],
"description_keywords": ["SPRING HANGER", "SPRING", "스프링", "스프링 행거"],
"characteristics": "스프링 행거",
"applications": "가변 하중 지지",
"material_type": "STEEL"
},
"GUIDE": {
"dat_file_patterns": ["GUIDE", "GD_"],
"description_keywords": ["GUIDE", "가이드", "PIPE GUIDE"],
"characteristics": "배관 가이드",
"applications": "배관 방향 제어",
"material_type": "STEEL"
},
"ANCHOR": {
"dat_file_patterns": ["ANCHOR", "ANCH"],
"description_keywords": ["ANCHOR", "앵커", "PIPE ANCHOR"],
"characteristics": "배관 앵커",
"applications": "배관 고정점",
"material_type": "STEEL"
}
}
# ========== 하중 등급 분류 ==========
LOAD_RATINGS = {
"LIGHT": {
"patterns": [r"(\d+)T", r"(\d+)TON"],
"range": (0, 5), # 5톤 이하
"description": "경하중용"
},
"MEDIUM": {
"patterns": [r"(\d+)T", r"(\d+)TON"],
"range": (5, 20), # 5-20톤
"description": "중하중용"
},
"HEAVY": {
"patterns": [r"(\d+)T", r"(\d+)TON"],
"range": (20, 100), # 20-100톤
"description": "중하중용"
}
}
def classify_support(dat_file: str, description: str, main_nom: str,
length: Optional[float] = None) -> Dict:
"""
SUPPORT 분류 메인 함수
Args:
dat_file: DAT 파일명
description: 자재 설명
main_nom: 주 사이즈
length: 길이 (옵션)
Returns:
분류 결과 딕셔너리
"""
dat_upper = dat_file.upper()
desc_upper = description.upper()
combined_text = f"{dat_upper} {desc_upper}"
# 1. 서포트 타입 분류
support_type_result = classify_support_type(dat_file, description)
# 2. 재질 분류 (공통 모듈 사용)
material_result = classify_material(description)
# 3. 하중 등급 분류
load_result = classify_load_rating(description)
# 4. 사이즈 정보 추출
size_result = extract_support_size(description, main_nom)
# 5. 최종 결과 조합
return {
"category": "SUPPORT",
# 서포트 특화 정보
"support_type": support_type_result.get("support_type", "UNKNOWN"),
"support_subtype": support_type_result.get("subtype", ""),
"load_rating": load_result.get("load_rating", ""),
"load_capacity": load_result.get("capacity", ""),
# 재질 정보 (공통 모듈)
"material": {
"standard": material_result.get('standard', 'UNKNOWN'),
"grade": material_result.get('grade', 'UNKNOWN'),
"material_type": material_result.get('material_type', 'UNKNOWN'),
"confidence": material_result.get('confidence', 0.0)
},
# 사이즈 정보
"size_info": size_result,
# 전체 신뢰도
"overall_confidence": calculate_support_confidence({
"type": support_type_result.get('confidence', 0),
"material": material_result.get('confidence', 0),
"load": load_result.get('confidence', 0),
"size": size_result.get('confidence', 0)
}),
# 증거
"evidence": [
f"SUPPORT_TYPE: {support_type_result.get('support_type', 'UNKNOWN')}",
f"MATERIAL: {material_result.get('standard', 'UNKNOWN')}",
f"LOAD: {load_result.get('load_rating', 'UNKNOWN')}"
]
}
def classify_support_type(dat_file: str, description: str) -> Dict:
"""서포트 타입 분류"""
dat_upper = dat_file.upper()
desc_upper = description.upper()
combined_text = f"{dat_upper} {desc_upper}"
for support_type, type_data in SUPPORT_TYPES.items():
# DAT 파일 패턴 확인
for pattern in type_data["dat_file_patterns"]:
if pattern in dat_upper:
return {
"support_type": support_type,
"subtype": type_data["characteristics"],
"applications": type_data["applications"],
"confidence": 0.95,
"evidence": [f"DAT_PATTERN: {pattern}"]
}
# 설명 키워드 확인
for keyword in type_data["description_keywords"]:
if keyword in desc_upper:
return {
"support_type": support_type,
"subtype": type_data["characteristics"],
"applications": type_data["applications"],
"confidence": 0.9,
"evidence": [f"DESC_KEYWORD: {keyword}"]
}
return {
"support_type": "UNKNOWN",
"subtype": "",
"applications": "",
"confidence": 0.0,
"evidence": ["NO_SUPPORT_TYPE_FOUND"]
}
def classify_load_rating(description: str) -> Dict:
"""하중 등급 분류"""
desc_upper = description.upper()
# 하중 패턴 찾기 (40T, 50TON 등)
for rating, rating_data in LOAD_RATINGS.items():
for pattern in rating_data["patterns"]:
match = re.search(pattern, desc_upper)
if match:
capacity = int(match.group(1))
min_load, max_load = rating_data["range"]
if min_load <= capacity <= max_load:
return {
"load_rating": rating,
"capacity": f"{capacity}T",
"description": rating_data["description"],
"confidence": 0.9,
"evidence": [f"LOAD_PATTERN: {match.group(0)}"]
}
# 특정 하중 값이 있지만 등급을 모르는 경우
load_match = re.search(r'(\d+)\s*[T톤]', desc_upper)
if load_match:
capacity = int(load_match.group(1))
return {
"load_rating": "CUSTOM",
"capacity": f"{capacity}T",
"description": f"{capacity}톤 하중",
"confidence": 0.7,
"evidence": [f"CUSTOM_LOAD: {load_match.group(0)}"]
}
return {
"load_rating": "UNKNOWN",
"capacity": "",
"description": "",
"confidence": 0.0,
"evidence": ["NO_LOAD_RATING_FOUND"]
}
def extract_support_size(description: str, main_nom: str) -> Dict:
"""서포트 사이즈 정보 추출"""
desc_upper = description.upper()
# 파이프 사이즈 (서포트가 지지하는 파이프 크기)
pipe_size = main_nom if main_nom else ""
# 서포트 자체 치수 (길이x폭x높이 등)
dimension_patterns = [
r'(\d+)\s*[X×]\s*(\d+)\s*[X×]\s*(\d+)', # 100x50x20
r'(\d+)\s*[X×]\s*(\d+)', # 100x50
r'L\s*(\d+)', # L100 (길이)
r'W\s*(\d+)', # W50 (폭)
r'H\s*(\d+)' # H20 (높이)
]
dimensions = {}
for pattern in dimension_patterns:
match = re.search(pattern, desc_upper)
if match:
if len(match.groups()) == 3:
dimensions = {
"length": f"{match.group(1)}mm",
"width": f"{match.group(2)}mm",
"height": f"{match.group(3)}mm"
}
elif len(match.groups()) == 2:
dimensions = {
"length": f"{match.group(1)}mm",
"width": f"{match.group(2)}mm"
}
break
return {
"pipe_size": pipe_size,
"dimensions": dimensions,
"confidence": 0.8 if dimensions else 0.3
}
def calculate_support_confidence(confidence_scores: Dict) -> float:
"""서포트 분류 전체 신뢰도 계산"""
weights = {
"type": 0.4, # 타입이 가장 중요
"material": 0.2, # 재질
"load": 0.2, # 하중
"size": 0.2 # 사이즈
}
weighted_sum = sum(
confidence_scores.get(key, 0) * weight
for key, weight in weights.items()
)
return round(weighted_sum, 2)