🔧 재질 정보 표시 개선 및 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

@@ -31,6 +31,130 @@ from app.services.revision_comparator import get_revision_comparison
router = APIRouter()
def extract_enhanced_material_grade(description: str, original_grade: str, category: str) -> str:
"""
원본 설명에서 개선된 재질 정보를 추출
Args:
description: 원본 설명
original_grade: 기존 재질 정보
category: 자재 카테고리 (PIPE, FITTING, FLANGE 등)
Returns:
개선된 재질 정보
"""
if not description:
return original_grade or '-'
desc_upper = description.upper()
# PIPE 재질 패턴
if category == 'PIPE':
pipe_patterns = [
(r'A312\s*(TP\d+[A-Z]*)', lambda m: f'A312 {m.group(1)}'),
(r'A106\s*(GR\.?\s*[A-Z]+)', lambda m: f'A106 {m.group(1)}'), # A106 GR.B, A106 GR B 등 전체 보존
(r'A106\s*([A-Z]+)', lambda m: f'A106 GR.{m.group(1)}'), # A106 B → A106 GR.B
(r'A333\s*(GR\.?\s*[A-Z0-9]+)', lambda m: f'A333 {m.group(1)}'), # A333 GR.6 등 전체 보존
(r'A333\s*([A-Z0-9]+)', lambda m: f'A333 GR.{m.group(1)}'), # A333 6 → A333 GR.6
(r'A53\s*(GR\.?\s*[A-Z]+)', lambda m: f'A53 {m.group(1)}'), # A53 GR.B 등 전체 보존
(r'A53\s*([A-Z]+)', lambda m: f'A53 GR.{m.group(1)}'), # A53 B → A53 GR.B
(r'A335\s*(P\d+[A-Z]*)', lambda m: f'A335 {m.group(1)}'),
(r'STPG\s*(\d+)', lambda m: f'STPG {m.group(1)}'),
(r'STS\s*(\d+[A-Z]*)', lambda m: f'STS {m.group(1)}')
]
for pattern, formatter in pipe_patterns:
match = re.search(pattern, desc_upper)
if match:
return formatter(match)
# FITTING 재질 패턴
elif category == 'FITTING':
fitting_patterns = [
(r'A403\s*(WP\d+[A-Z]*)', lambda m: f'A403 {m.group(1)}'),
(r'A234\s*(WP[A-Z]+)', lambda m: f'A234 {m.group(1)}'),
(r'A420\s*(WPL\d+)', lambda m: f'A420 {m.group(1)}'),
(r'A105\s*(GR\.?\s*[N])', lambda m: f'A105 {m.group(1)}'), # A105 GR.N 전체 보존
(r'A105\s*([N])', lambda m: f'A105 GR.{m.group(1)}'), # A105 N → A105 GR.N
(r'A105(?!\s*[A-Z])', lambda m: f'A105'), # A105만 있는 경우
(r'A106\s*(GR\.?\s*[A-Z]+)', lambda m: f'A106 {m.group(1)}'), # A106 GR.B 전체 보존
(r'A106\s*([A-Z]+)', lambda m: f'A106 GR.{m.group(1)}'), # A106 B → A106 GR.B
(r'A182\s*(F\d+[A-Z]*)', lambda m: f'A182 {m.group(1)}'),
(r'A350\s*(LF\d+)', lambda m: f'A350 {m.group(1)}')
]
for pattern, formatter in fitting_patterns:
match = re.search(pattern, desc_upper)
if match:
return formatter(match)
# FLANGE 재질 패턴
elif category == 'FLANGE':
flange_patterns = [
(r'A182\s*(F\d+[A-Z]*)', lambda m: f'A182 {m.group(1)}'),
(r'A105\s*(GR\.?\s*[N])', lambda m: f'A105 {m.group(1)}'), # A105 GR.N 전체 보존
(r'A105\s*([N])', lambda m: f'A105 GR.{m.group(1)}'), # A105 N → A105 GR.N
(r'A105(?!\s*[A-Z])', lambda m: f'A105'), # A105만 있는 경우
(r'A350\s*(LF\d+)', lambda m: f'A350 {m.group(1)}'),
(r'A694\s*(F\d+)', lambda m: f'A694 {m.group(1)}')
]
for pattern, formatter in flange_patterns:
match = re.search(pattern, desc_upper)
if match:
return formatter(match)
# 패턴이 매치되지 않으면 기존 값 반환
return original_grade or '-'
def extract_enhanced_flange_type(description: str, original_type: str) -> str:
"""
FLANGE 타입에 PIPE측 연결면 정보 추가
Args:
description: 원본 설명
original_type: 기존 플랜지 타입
Returns:
개선된 플랜지 타입 (예: WN RF, SO FF 등)
"""
if not description:
return original_type or '-'
desc_upper = description.upper()
# 기본 플랜지 타입 매핑
flange_type_map = {
'WELD_NECK': 'WN',
'SLIP_ON': 'SO',
'BLIND': 'BL',
'SOCKET_WELD': 'SW',
'LAP_JOINT': 'LJ',
'THREADED': 'TH',
'ORIFICE': 'ORIFICE'
}
display_type = flange_type_map.get(original_type, original_type) if original_type else '-'
# PIPE측 연결면 타입 추출
pipe_end_type = ''
if ' RF' in desc_upper or 'RAISED FACE' in desc_upper:
pipe_end_type = ' RF'
elif ' FF' in desc_upper or 'FLAT FACE' in desc_upper:
pipe_end_type = ' FF'
elif ' RTJ' in desc_upper or 'RING TYPE JOINT' in desc_upper:
pipe_end_type = ' RTJ'
elif ' MSF' in desc_upper or 'MALE AND FEMALE' in desc_upper:
pipe_end_type = ' MSF'
elif ' T&G' in desc_upper or 'TONGUE AND GROOVE' in desc_upper:
pipe_end_type = ' T&G'
# 최종 타입 조합
if pipe_end_type and display_type != '-':
return display_type + pipe_end_type
return display_type
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
@@ -108,7 +232,8 @@ def parse_dataframe(df):
material_grade = ""
if "ASTM" in description.upper():
# ASTM 표준과 등급만 추출, end_preparation(BOE, POE, BBE 등)은 제외
astm_match = re.search(r'ASTM\s+([A-Z0-9]+(?:\s+GR\s+[A-Z0-9]+)?)', description.upper())
# A\d{3,4} 패턴으로 3-4자리 숫자 보장, 등급도 포함
astm_match = re.search(r'ASTM\s+(A\d{3,4}[A-Z]*(?:\s+(?:GR\s+)?[A-Z0-9]+)?)', description.upper())
if astm_match:
material_grade = astm_match.group(0).strip()
@@ -511,6 +636,9 @@ async def upload_file(
classification_result = classify_gasket("", description, main_nom or "")
elif material_type == "INSTRUMENT":
classification_result = classify_instrument("", description, main_nom or "")
elif material_type == "SUPPORT":
from ..services.support_classifier import classify_support
classification_result = classify_support("", description, main_nom or "")
else:
# UNKNOWN 처리
classification_result = {
@@ -528,16 +656,22 @@ async def upload_file(
print(f"최종 분류 결과: {classification_result.get('category', 'UNKNOWN')}")
# 전체 재질명 추출
from ..services.material_grade_extractor import extract_full_material_grade
full_material_grade = extract_full_material_grade(description)
if not full_material_grade and material_data.get("material_grade"):
full_material_grade = material_data["material_grade"]
# 기본 자재 정보 저장
material_insert_query = text("""
INSERT INTO materials (
file_id, original_description, quantity, unit, size_spec,
main_nom, red_nom, material_grade, line_number, row_number,
main_nom, red_nom, material_grade, full_material_grade, line_number, row_number,
classified_category, classification_confidence, is_verified, created_at
)
VALUES (
:file_id, :original_description, :quantity, :unit, :size_spec,
:main_nom, :red_nom, :material_grade, :line_number, :row_number,
:main_nom, :red_nom, :material_grade, :full_material_grade, :line_number, :row_number,
:classified_category, :classification_confidence, :is_verified, :created_at
)
RETURNING id
@@ -559,6 +693,7 @@ async def upload_file(
"main_nom": material_data.get("main_nom"), # 추가
"red_nom": material_data.get("red_nom"), # 추가
"material_grade": material_data["material_grade"],
"full_material_grade": full_material_grade,
"line_number": material_data["line_number"],
"row_number": material_data["row_number"],
"classified_category": classification_result.get("category", "UNKNOWN"),
@@ -1040,6 +1175,79 @@ async def upload_file(
print(f"BOLT 상세 정보 저장 완료: {bolt_type} - {material_standard} {material_grade}")
# SUPPORT 분류 결과인 경우 상세 정보 저장
if classification_result.get("category") == "SUPPORT":
print("SUPPORT 상세 정보 저장 시작")
support_type = classification_result.get("support_type", "UNKNOWN")
support_subtype = classification_result.get("support_subtype", "")
load_rating = classification_result.get("load_rating", "")
load_capacity = classification_result.get("load_capacity", "")
# 재질 정보
material_info = classification_result.get("material", {})
material_standard = material_info.get("standard", "UNKNOWN")
material_grade = material_info.get("grade", "UNKNOWN")
# 사이즈 정보
size_info = classification_result.get("size_info", {})
pipe_size = size_info.get("pipe_size", "")
dimensions = size_info.get("dimensions", {})
length_mm = None
width_mm = None
height_mm = None
if dimensions:
length_str = dimensions.get("length", "")
width_str = dimensions.get("width", "")
height_str = dimensions.get("height", "")
# mm 단위 추출
import re
if length_str:
length_match = re.search(r'(\d+(?:\.\d+)?)', length_str)
if length_match:
length_mm = float(length_match.group(1))
if width_str:
width_match = re.search(r'(\d+(?:\.\d+)?)', width_str)
if width_match:
width_mm = float(width_match.group(1))
if height_str:
height_match = re.search(r'(\d+(?:\.\d+)?)', height_str)
if height_match:
height_mm = float(height_match.group(1))
db.execute(text("""
INSERT INTO support_details (
material_id, file_id, support_type, support_subtype,
load_rating, load_capacity, material_standard, material_grade,
pipe_size, length_mm, width_mm, height_mm, classification_confidence
) VALUES (
:material_id, :file_id, :support_type, :support_subtype,
:load_rating, :load_capacity, :material_standard, :material_grade,
:pipe_size, :length_mm, :width_mm, :height_mm, :classification_confidence
)
"""), {
"material_id": material_id,
"file_id": file_id,
"support_type": support_type,
"support_subtype": support_subtype,
"load_rating": load_rating,
"load_capacity": load_capacity,
"material_standard": material_standard,
"material_grade": material_grade,
"pipe_size": pipe_size,
"length_mm": length_mm,
"width_mm": width_mm,
"height_mm": height_mm,
"classification_confidence": classification_result.get("overall_confidence", 0.0)
})
print(f"SUPPORT 상세 정보 저장 완료: {support_type} - {material_standard} {material_grade}")
# VALVE 분류 결과인 경우 상세 정보 저장
if classification_result.get("category") == "VALVE":
print("VALVE 상세 정보 저장 시작")
@@ -1331,7 +1539,7 @@ async def get_materials(
# 로그 제거 - 과도한 출력 방지
query = """
SELECT m.id, m.file_id, m.original_description, m.quantity, m.unit,
m.size_spec, m.main_nom, m.red_nom, m.material_grade, m.line_number, m.row_number,
m.size_spec, m.main_nom, m.red_nom, m.material_grade, m.full_material_grade, m.line_number, m.row_number,
m.created_at, m.classified_category, m.classification_confidence,
m.classification_details,
m.is_verified, m.verified_by, m.verified_at,
@@ -1503,6 +1711,14 @@ async def get_materials(
# 로그 제거
pass
# 개선된 재질 정보 추출
final_category = m.final_classified_category or m.classified_category
enhanced_material_grade = extract_enhanced_material_grade(
m.original_description,
m.material_grade,
final_category
)
material_dict = {
"id": m.id,
"file_id": m.file_id,
@@ -1516,11 +1732,13 @@ async def get_materials(
"size_spec": m.size_spec,
"main_nom": m.main_nom, # 추가
"red_nom": m.red_nom, # 추가
"material_grade": m.material_grade,
"material_grade": m.full_material_grade or enhanced_material_grade, # 전체 재질명 우선 사용
"original_material_grade": m.material_grade, # 원본 재질 정보도 보존
"full_material_grade": m.full_material_grade, # 전체 재질명
"line_number": m.line_number,
"row_number": m.row_number,
# 구매수량 계산에서 분류된 정보를 우선 사용
"classified_category": m.final_classified_category or m.classified_category,
"classified_category": final_category,
"classification_confidence": float(m.classification_confidence) if m.classification_confidence else 0.0,
"classification_details": m.classification_details,
"is_verified": m.final_is_verified if m.final_is_verified is not None else m.is_verified,
@@ -1664,8 +1882,15 @@ async def get_materials(
flange_result = db.execute(flange_query, {"material_id": m.id})
flange_detail = flange_result.fetchone()
if flange_detail:
# 개선된 플랜지 타입 (PIPE측 연결면 포함)
enhanced_flange_type = extract_enhanced_flange_type(
m.original_description,
flange_detail.flange_type
)
material_dict['flange_details'] = {
"flange_type": flange_detail.flange_type,
"flange_type": enhanced_flange_type, # 개선된 타입 사용
"original_flange_type": flange_detail.flange_type, # 원본 타입 보존
"facing_type": flange_detail.facing_type,
"pressure_rating": flange_detail.pressure_rating,
"material_standard": flange_detail.material_standard,
@@ -2431,6 +2656,7 @@ async def create_user_requirement(
file_id: int,
requirement_type: str,
requirement_title: str,
material_id: Optional[int] = None,
requirement_description: Optional[str] = None,
requirement_spec: Optional[str] = None,
priority: str = "NORMAL",
@@ -2444,11 +2670,11 @@ async def create_user_requirement(
try:
insert_query = text("""
INSERT INTO user_requirements (
file_id, requirement_type, requirement_title, requirement_description,
file_id, material_id, requirement_type, requirement_title, requirement_description,
requirement_spec, priority, assigned_to, due_date
)
VALUES (
:file_id, :requirement_type, :requirement_title, :requirement_description,
:file_id, :material_id, :requirement_type, :requirement_title, :requirement_description,
:requirement_spec, :priority, :assigned_to, :due_date
)
RETURNING id
@@ -2456,6 +2682,7 @@ async def create_user_requirement(
result = db.execute(insert_query, {
"file_id": file_id,
"material_id": material_id,
"requirement_type": requirement_type,
"requirement_title": requirement_title,
"requirement_description": requirement_description,
@@ -2478,6 +2705,41 @@ async def create_user_requirement(
db.rollback()
raise HTTPException(status_code=500, detail=f"요구사항 생성 실패: {str(e)}")
@router.delete("/user-requirements")
async def delete_user_requirements(
file_id: Optional[int] = None,
material_id: Optional[int] = None,
db: Session = Depends(get_db)
):
"""
사용자 요구사항 삭제 (파일별 또는 자재별)
"""
try:
if file_id:
# 파일별 삭제
delete_query = text("DELETE FROM user_requirements WHERE file_id = :file_id")
result = db.execute(delete_query, {"file_id": file_id})
deleted_count = result.rowcount
elif material_id:
# 자재별 삭제
delete_query = text("DELETE FROM user_requirements WHERE material_id = :material_id")
result = db.execute(delete_query, {"material_id": material_id})
deleted_count = result.rowcount
else:
raise HTTPException(status_code=400, detail="file_id 또는 material_id가 필요합니다")
db.commit()
return {
"success": True,
"message": f"{deleted_count}개의 요구사항이 삭제되었습니다",
"deleted_count": deleted_count
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"요구사항 삭제 실패: {str(e)}")
@router.post("/materials/{material_id}/verify")
async def verify_material_classification(
material_id: int,