diff --git a/backend/app/api/files.py b/backend/app/api/files.py
index f2ef8aa..f1ce8fc 100644
--- a/backend/app/api/files.py
+++ b/backend/app/api/files.py
@@ -596,7 +596,21 @@ async def upload_file(
# GASKET 분류기 결과 구조에 맞게 데이터 추출
gasket_type_info = gasket_info.get('gasket_type', {})
- material_info = gasket_info.get('material', {})
+ gasket_material_info = gasket_info.get('gasket_material', {})
+ pressure_info = gasket_info.get('pressure_rating', {})
+ size_info = gasket_info.get('size_info', {})
+ temp_info = gasket_info.get('temperature_info', {})
+
+ # SWG 상세 정보 추출
+ swg_details = gasket_material_info.get('swg_details', {})
+ additional_info = {
+ "swg_details": swg_details,
+ "face_type": swg_details.get('face_type', ''),
+ "construction": swg_details.get('detailed_construction', ''),
+ "filler": swg_details.get('filler', ''),
+ "outer_ring": swg_details.get('outer_ring', ''),
+ "inner_ring": swg_details.get('inner_ring', '')
+ }
db.execute(gasket_insert_query, {
"file_id": file_id,
@@ -604,14 +618,14 @@ async def upload_file(
"row_number": material_data["row_number"],
"gasket_type": gasket_type_info.get('type', ''),
"gasket_subtype": gasket_type_info.get('subtype', ''),
- "material_type": material_info.get('type', ''),
- "size_inches": material_data.get('size_spec', ''),
- "pressure_rating": gasket_info.get('pressure_rating', ''),
- "thickness": gasket_info.get('thickness', ''),
- "temperature_range": material_info.get('temperature_range', ''),
+ "material_type": gasket_material_info.get('material', ''),
+ "size_inches": material_data.get('main_nom', '') or material_data.get('size_spec', ''),
+ "pressure_rating": pressure_info.get('rating', ''),
+ "thickness": swg_details.get('thickness', None),
+ "temperature_range": temp_info.get('range', ''),
"fire_safe": gasket_info.get('fire_safe', False),
"classification_confidence": confidence,
- "additional_info": json.dumps(gasket_info, ensure_ascii=False)
+ "additional_info": json.dumps(additional_info, ensure_ascii=False)
})
print(f"GASKET 상세정보 저장 완료: {material_data['original_description']}")
diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py
index e4218b2..7262950 100644
--- a/backend/app/routers/files.py
+++ b/backend/app/routers/files.py
@@ -9,6 +9,7 @@ import uuid
import pandas as pd
import re
from pathlib import Path
+import json
from ..database import get_db
from app.services.material_classifier import classify_material
@@ -220,7 +221,14 @@ async def upload_file(
file_id = file_result.fetchone()[0]
print(f"파일 저장 완료: file_id = {file_id}")
- # 자재 데이터 저장 (분류 포함)
+ # 자재 데이터 저장 (분류 포함) - 배치 처리로 성능 개선
+ materials_to_insert = []
+ pipe_details_to_insert = []
+ fitting_details_to_insert = []
+ bolt_details_to_insert = []
+ gasket_details_to_insert = []
+ flange_details_to_insert = []
+
materials_inserted = 0
for material_data in materials_data:
# 자재 타입 분류기 적용 (PIPE, FITTING, VALVE 등)
@@ -242,47 +250,101 @@ async def upload_file(
main_nom = material_data.get("main_nom")
red_nom = material_data.get("red_nom")
- classification_result = None
+ classification_results = []
try:
# EXCLUDE 분류기 우선 호출 (제외 대상 먼저 걸러냄)
from app.services.exclude_classifier import classify_exclude
- classification_result = classify_exclude("", description, main_nom or "")
- print(f"EXCLUDE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
+ exclude_result = classify_exclude("", description, main_nom or "")
+ print(f"EXCLUDE 분류 결과: {exclude_result.get('category', 'UNKNOWN')} (신뢰도: {exclude_result.get('overall_confidence', 0)})")
- if classification_result.get("overall_confidence", 0) < 0.5:
- # 파이프 분류기 호출
- classification_result = classify_pipe("", description, main_nom or "", length_value)
- print(f"PIPE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
-
- if classification_result.get("overall_confidence", 0) < 0.5:
- # 피팅 분류기 호출 (main_nom, red_nom 개별 전달)
- classification_result = classify_fitting("", description, main_nom or "", red_nom)
- print(f"FITTING 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
-
- if classification_result.get("overall_confidence", 0) < 0.5:
- # 플랜지 분류기 호출 (main_nom, red_nom 개별 전달)
- classification_result = classify_flange("", description, main_nom or "", red_nom)
- print(f"FLANGE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
-
- if classification_result.get("overall_confidence", 0) < 0.5:
- # 밸브 분류기 호출
- classification_result = classify_valve("", description, main_nom or "")
- print(f"VALVE 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
-
- if classification_result.get("overall_confidence", 0) < 0.5:
- # 볼트 분류기 호출
- classification_result = classify_bolt("", description, main_nom or "")
- print(f"BOLT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
-
- if classification_result.get("overall_confidence", 0) < 0.5:
- # 가스켓 분류기 호출
- classification_result = classify_gasket("", description, main_nom or "")
- print(f"GASKET 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
-
- if classification_result.get("overall_confidence", 0) < 0.5:
- # 계기 분류기 호출
- classification_result = classify_instrument("", description, main_nom or "")
- print(f"INSTRUMENT 분류 결과: {classification_result.get('category', 'UNKNOWN')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
+ # EXCLUDE가 높은 신뢰도로 제외 대상이라고 하면 바로 사용
+ if exclude_result.get("overall_confidence", 0) >= 0.8:
+ classification_result = exclude_result
+ else:
+ # 키워드 기반 빠른 분류기 선택 (성능 개선)
+ classification_results = []
+
+ # 키워드 기반으로 우선 분류기 결정
+ desc_lower = description.lower()
+ primary_classifiers = []
+
+ # 볼트 관련 키워드
+ if any(keyword in desc_lower for keyword in ['bolt', 'stud', 'nut', 'screw', 'washer', '볼트', '너트', 'a193', 'a194']):
+ primary_classifiers.append(('bolt', classify_bolt))
+
+ # 파이프 관련 키워드 (확장)
+ pipe_keywords = [
+ 'pipe', 'tube', 'smls', '파이프', '배관',
+ 'a106', 'a333', 'a312', 'a53', 'seamless', 'sch', 'schedule',
+ 'boe', 'poe', 'bbe', 'pbe' # end preparation
+ ]
+ if any(keyword in desc_lower for keyword in pipe_keywords):
+ primary_classifiers.append(('pipe', classify_pipe))
+
+ # 피팅 관련 키워드 (확장)
+ fitting_keywords = [
+ 'elbow', 'ell', 'tee', 'reducer', 'red', 'cap', 'coupling', 'nipple', 'swage', 'olet',
+ '엘보', '티', '리듀서', '캡', '니플', '커플링',
+ '90l_', '45l_', 'socket', 'sw', 'equal', 'reducing', 'concentric', 'eccentric',
+ 'sockolet', 'weldolet', 'threadolet', 'socklet', 'plug'
+ ]
+ if any(keyword in desc_lower for keyword in fitting_keywords):
+ primary_classifiers.append(('fitting', classify_fitting))
+
+ # 플랜지 관련 키워드 (확장)
+ flange_keywords = [
+ 'flg', 'flange', '플랜지', 'weld neck', 'blind', 'slip on', 'socket weld',
+ 'threaded', 'lap joint', 'orifice', 'spectacle', 'paddle', 'spacer',
+ 'wn', 'so', 'bl', 'sw', 'thd', 'lj', 'rf', 'ff', 'rtj',
+ 'raised face', 'flat face', 'ring joint'
+ ]
+ if any(keyword in desc_lower for keyword in flange_keywords):
+ primary_classifiers.append(('flange', classify_flange))
+
+ # 밸브 관련 키워드
+ if any(keyword in desc_lower for keyword in ['valve', 'gate', 'ball', 'globe', 'check', '밸브']):
+ primary_classifiers.append(('valve', classify_valve))
+
+ # 가스켓 관련 키워드
+ if any(keyword in desc_lower for keyword in ['gasket', 'gask', '가스켓', 'swg', 'spiral']):
+ primary_classifiers.append(('gasket', classify_gasket))
+
+ # 계기 관련 키워드
+ if any(keyword in desc_lower for keyword in ['gauge', 'transmitter', 'sensor', 'thermometer', '계기', '게이지']):
+ primary_classifiers.append(('instrument', classify_instrument))
+
+ # 우선 분류기만 실행 (1-2개)
+ if primary_classifiers:
+ for name, classifier in primary_classifiers:
+ try:
+ if name in ['fitting', 'flange']:
+ result = classifier("", description, main_nom or "", red_nom)
+ elif name == 'pipe':
+ result = classifier("", description, main_nom or "", length_value)
+ else:
+ result = classifier("", description, main_nom or "")
+ classification_results.append(result)
+ except Exception as e:
+ print(f"분류기 {name} 오류: {e}")
+ continue
+
+ # 우선 분류기로 결과가 없으면 모든 분류기 실행
+ if not classification_results or max(r.get('overall_confidence', 0) for r in classification_results) < 0.3:
+ # 볼트는 항상 확인 (매우 일반적)
+ if not any('bolt' in str(r) for r in primary_classifiers):
+ bolt_result = classify_bolt("", description, main_nom or "")
+ classification_results.append(bolt_result)
+
+ # 가장 높은 신뢰도의 결과 선택 (UNKNOWN 제외)
+ valid_results = [r for r in classification_results if r.get('category') != 'UNKNOWN' and r.get('overall_confidence', 0) > 0]
+
+ if valid_results:
+ classification_result = max(valid_results, key=lambda x: x.get('overall_confidence', 0))
+ print(f"최종 선택: {classification_result.get('category')} (신뢰도: {classification_result.get('overall_confidence', 0)})")
+ else:
+ # 모든 분류기가 UNKNOWN이면 가장 높은 신뢰도의 UNKNOWN 선택
+ classification_result = max(classification_results, key=lambda x: x.get('overall_confidence', 0))
+ print(f"모든 분류기 실패, 최고 신뢰도 UNKNOWN 선택: (신뢰도: {classification_result.get('overall_confidence', 0)})")
except Exception as e:
print(f"분류기 실행 중 오류 발생: {e}")
@@ -430,15 +492,31 @@ async def upload_file(
main_size = material_data.get("main_nom") or material_data.get("size_spec", "")
reduced_size = material_data.get("red_nom", "")
+ # NIPPLE인 경우 길이와 스케줄 정보 추가
+ length_mm = None
+ schedule = "UNKNOWN"
+ if fitting_type == "NIPPLE":
+ # 길이 정보 추출
+ length_mm = material_data.get("length", 0.0) if material_data.get("length") else None
+
+ # 스케줄 정보 추출 (분류 결과에서)
+ schedule_info = classification_result.get("schedule_info", {})
+ schedule = schedule_info.get("schedule", "UNKNOWN")
+ schedule_info = classification_result.get("schedule", {})
+ if isinstance(schedule_info, dict):
+ schedule = schedule_info.get("schedule", "UNKNOWN")
+ else:
+ schedule = str(schedule_info) if schedule_info else "UNKNOWN"
+
db.execute(text("""
INSERT INTO fitting_details (
material_id, file_id, fitting_type, fitting_subtype,
connection_method, pressure_rating, material_standard,
- material_grade, main_size, reduced_size
+ material_grade, main_size, reduced_size, length_mm, schedule
) VALUES (
:material_id, :file_id, :fitting_type, :fitting_subtype,
:connection_method, :pressure_rating, :material_standard,
- :material_grade, :main_size, :reduced_size
+ :material_grade, :main_size, :reduced_size, :length_mm, :schedule
)
"""), {
"material_id": material_id,
@@ -450,10 +528,248 @@ async def upload_file(
"material_standard": material_standard,
"material_grade": material_grade,
"main_size": main_size,
- "reduced_size": reduced_size
+ "reduced_size": reduced_size,
+ "length_mm": length_mm,
+ "schedule": schedule
})
print(f"FITTING 상세 정보 저장 완료: {fitting_type} - {fitting_subtype}")
+
+ # FLANGE 분류 결과인 경우 상세 정보 저장
+ if classification_result.get("category") == "FLANGE":
+ print("FLANGE 상세 정보 저장 시작")
+
+ # 플랜지 타입 정보
+ flange_type_info = classification_result.get("flange_type", {})
+ pressure_info = classification_result.get("pressure_rating", {})
+ face_finish_info = classification_result.get("face_finish", {})
+ material_info = classification_result.get("material", {})
+
+ # 플랜지 타입 (WN, BL, SO 등)
+ flange_type = ""
+ if isinstance(flange_type_info, dict):
+ flange_type = flange_type_info.get("type", "UNKNOWN")
+ else:
+ flange_type = str(flange_type_info) if flange_type_info else "UNKNOWN"
+
+ # 압력 등급 (150LB, 300LB 등)
+ pressure_rating = ""
+ if isinstance(pressure_info, dict):
+ pressure_rating = pressure_info.get("rating", "UNKNOWN")
+ else:
+ pressure_rating = str(pressure_info) if pressure_info else "UNKNOWN"
+
+ # 면 가공 (RF, FF, RTJ 등)
+ facing_type = ""
+ if isinstance(face_finish_info, dict):
+ facing_type = face_finish_info.get("finish", "UNKNOWN")
+ else:
+ facing_type = str(face_finish_info) if face_finish_info else "UNKNOWN"
+
+ # 재질 정보
+ material_standard = ""
+ material_grade = ""
+ if isinstance(material_info, dict):
+ material_standard = material_info.get("standard", "")
+ material_grade = material_info.get("grade", "")
+
+ # 사이즈 정보
+ size_inches = material_data.get("main_nom") or material_data.get("size_spec", "")
+
+ db.execute(text("""
+ INSERT INTO flange_details (
+ material_id, file_id, flange_type, pressure_rating,
+ facing_type, material_standard, material_grade, size_inches
+ ) VALUES (
+ :material_id, :file_id, :flange_type, :pressure_rating,
+ :facing_type, :material_standard, :material_grade, :size_inches
+ )
+ """), {
+ "material_id": material_id,
+ "file_id": file_id,
+ "flange_type": flange_type,
+ "pressure_rating": pressure_rating,
+ "facing_type": facing_type,
+ "material_standard": material_standard,
+ "material_grade": material_grade,
+ "size_inches": size_inches
+ })
+
+ print(f"FLANGE 상세 정보 저장 완료: {flange_type} - {pressure_rating}")
+
+ # GASKET 분류 결과인 경우 상세 정보 저장
+ if classification_result.get("category") == "GASKET":
+ print("GASKET 상세 정보 저장 시작")
+
+ # 가스켓 타입 정보
+ gasket_type_info = classification_result.get("gasket_type", {})
+ gasket_material_info = classification_result.get("gasket_material", {})
+ pressure_info = classification_result.get("pressure_rating", {})
+
+ # 가스켓 타입 (SPIRAL_WOUND, O_RING 등)
+ gasket_type = ""
+ if isinstance(gasket_type_info, dict):
+ gasket_type = gasket_type_info.get("type", "UNKNOWN")
+ else:
+ gasket_type = str(gasket_type_info) if gasket_type_info else "UNKNOWN"
+
+ # 가스켓 소재 (GRAPHITE, PTFE 등)
+ material_type = ""
+ if isinstance(gasket_material_info, dict):
+ material_type = gasket_material_info.get("material", "UNKNOWN")
+ else:
+ material_type = str(gasket_material_info) if gasket_material_info else "UNKNOWN"
+
+ # 압력 등급
+ pressure_rating = ""
+ if isinstance(pressure_info, dict):
+ pressure_rating = pressure_info.get("rating", "UNKNOWN")
+ else:
+ pressure_rating = str(pressure_info) if pressure_info else "UNKNOWN"
+
+ # 사이즈 정보
+ size_inches = material_data.get("main_nom") or material_data.get("size_spec", "")
+
+ # SWG 상세 정보 추출
+ swg_details = gasket_material_info.get("swg_details", {}) if isinstance(gasket_material_info, dict) else {}
+ thickness = swg_details.get("thickness", None) if swg_details else None
+ filler_material = swg_details.get("filler", "") if swg_details else ""
+
+ # additional_info에 SWG 상세 정보 저장
+ additional_info = ""
+ if swg_details:
+ face_type = swg_details.get("face_type", "")
+ outer_ring = swg_details.get("outer_ring", "")
+ inner_ring = swg_details.get("inner_ring", "")
+ construction = swg_details.get("detailed_construction", "")
+
+ # JSON 형태로 additional_info 생성
+ additional_info = {
+ "face_type": face_type,
+ "construction": construction,
+ "outer_ring": outer_ring,
+ "inner_ring": inner_ring,
+ "filler": swg_details.get("filler", ""),
+ "thickness": swg_details.get("thickness", None)
+ }
+ additional_info_json = json.dumps(additional_info, ensure_ascii=False)
+
+ db.execute(text("""
+ INSERT INTO gasket_details (
+ material_id, file_id, gasket_type, material_type,
+ pressure_rating, size_inches, thickness, filler_material, additional_info
+ ) VALUES (
+ :material_id, :file_id, :gasket_type, :material_type,
+ :pressure_rating, :size_inches, :thickness, :filler_material, :additional_info
+ )
+ """), {
+ "material_id": material_id,
+ "file_id": file_id,
+ "gasket_type": gasket_type,
+ "material_type": material_type,
+ "pressure_rating": pressure_rating,
+ "size_inches": size_inches,
+ "thickness": thickness,
+ "filler_material": filler_material,
+ "additional_info": additional_info_json
+ })
+
+ print(f"GASKET 상세 정보 저장 완료: {gasket_type} - {material_type}")
+
+ # BOLT 분류 결과인 경우 상세 정보 저장
+ if classification_result.get("category") == "BOLT":
+ print("BOLT 상세 정보 저장 시작")
+
+ # 볼트 타입 정보
+ fastener_type_info = classification_result.get("fastener_type", {})
+ thread_spec_info = classification_result.get("thread_specification", {})
+ dimensions_info = classification_result.get("dimensions", {})
+ material_info = classification_result.get("material", {})
+
+ # 볼트 타입 (STUD_BOLT, HEX_BOLT 등)
+ bolt_type = ""
+ if isinstance(fastener_type_info, dict):
+ bolt_type = fastener_type_info.get("type", "UNKNOWN")
+ else:
+ bolt_type = str(fastener_type_info) if fastener_type_info else "UNKNOWN"
+
+ # 나사 타입 (METRIC, INCH 등)
+ thread_type = ""
+ if isinstance(thread_spec_info, dict):
+ thread_type = thread_spec_info.get("standard", "UNKNOWN")
+ else:
+ thread_type = str(thread_spec_info) if thread_spec_info else "UNKNOWN"
+
+ # 치수 정보
+ diameter = material_data.get("main_nom", "")
+ length = ""
+ if isinstance(dimensions_info, dict):
+ length = dimensions_info.get("length", "")
+ if not length and "70.0000 LG" in description:
+ # 원본 설명에서 길이 추출
+ import re
+ length_match = re.search(r'(\d+(?:\.\d+)?)\s*LG', description.upper())
+ if length_match:
+ length = f"{length_match.group(1)}mm"
+
+ # 재질 정보
+ material_standard = ""
+ material_grade = ""
+ if isinstance(material_info, dict):
+ material_standard = material_info.get("standard", "")
+ material_grade = material_info.get("grade", "")
+
+ # 압력 등급 (150LB 등)
+ pressure_rating = ""
+ if "150LB" in description.upper():
+ pressure_rating = "150LB"
+ elif "300LB" in description.upper():
+ pressure_rating = "300LB"
+ elif "600LB" in description.upper():
+ pressure_rating = "600LB"
+
+ # 코팅 타입 (ELEC.GALV 등)
+ coating_type = ""
+ if "ELEC.GALV" in description.upper() or "ELEC GALV" in description.upper():
+ coating_type = "ELECTRO_GALVANIZED"
+ elif "HOT.GALV" in description.upper() or "HOT GALV" in description.upper():
+ coating_type = "HOT_DIP_GALVANIZED"
+ elif "GALV" in description.upper():
+ coating_type = "GALVANIZED"
+ elif "ZINC" in description.upper():
+ coating_type = "ZINC_PLATED"
+ elif "DACROMET" in description.upper():
+ coating_type = "DACROMET"
+ elif "SS" in description.upper() or "STAINLESS" in description.upper():
+ coating_type = "STAINLESS"
+ elif "PLAIN" in description.upper() or "BLACK" in description.upper():
+ coating_type = "PLAIN"
+
+ db.execute(text("""
+ INSERT INTO bolt_details (
+ material_id, file_id, bolt_type, thread_type,
+ diameter, length, material_standard, material_grade,
+ coating_type, pressure_rating, classification_confidence
+ ) VALUES (
+ :material_id, :file_id, :bolt_type, :thread_type,
+ :diameter, :length, :material_standard, :material_grade,
+ :coating_type, :pressure_rating, :classification_confidence
+ )
+ """), {
+ "material_id": material_id,
+ "file_id": file_id,
+ "bolt_type": bolt_type,
+ "thread_type": thread_type,
+ "diameter": diameter,
+ "length": length,
+ "material_standard": material_standard,
+ "material_grade": material_grade,
+ "coating_type": coating_type,
+ "pressure_rating": pressure_rating,
+ "classification_confidence": classification_result.get("overall_confidence", 0.0)
+ })
+
+ print(f"BOLT 상세 정보 저장 완료: {bolt_type} - {material_standard} {material_grade}")
db.commit()
print(f"자재 저장 완료: {materials_inserted}개")
@@ -724,7 +1040,35 @@ async def get_materials(
"material_standard": fitting_detail.material_standard,
"material_grade": fitting_detail.material_grade,
"main_size": fitting_detail.main_size,
- "reduced_size": fitting_detail.reduced_size
+ "reduced_size": fitting_detail.reduced_size,
+ "length_mm": float(fitting_detail.length_mm) if fitting_detail.length_mm else None,
+ "schedule": fitting_detail.schedule
+ }
+ elif m.classified_category == 'FLANGE':
+ flange_query = text("SELECT * FROM flange_details WHERE material_id = :material_id")
+ flange_result = db.execute(flange_query, {"material_id": m.id})
+ flange_detail = flange_result.fetchone()
+ if flange_detail:
+ material_dict['flange_details'] = {
+ "flange_type": flange_detail.flange_type,
+ "facing_type": flange_detail.facing_type,
+ "pressure_rating": flange_detail.pressure_rating,
+ "material_standard": flange_detail.material_standard,
+ "material_grade": flange_detail.material_grade,
+ "size_inches": flange_detail.size_inches
+ }
+ elif m.classified_category == 'GASKET':
+ gasket_query = text("SELECT * FROM gasket_details WHERE material_id = :material_id")
+ gasket_result = db.execute(gasket_query, {"material_id": m.id})
+ gasket_detail = gasket_result.fetchone()
+ if gasket_detail:
+ material_dict['gasket_details'] = {
+ "gasket_type": gasket_detail.gasket_type,
+ "material_type": gasket_detail.material_type,
+ "pressure_rating": gasket_detail.pressure_rating,
+ "size_inches": gasket_detail.size_inches,
+ "thickness": gasket_detail.thickness,
+ "temperature_range": gasket_detail.temperature_range
}
elif m.classified_category == 'VALVE':
valve_query = text("SELECT * FROM valve_details WHERE material_id = :material_id")
@@ -740,6 +1084,21 @@ async def get_materials(
"body_material": valve_detail.body_material,
"size_inches": valve_detail.size_inches
}
+ elif m.classified_category == 'BOLT':
+ bolt_query = text("SELECT * FROM bolt_details WHERE material_id = :material_id")
+ bolt_result = db.execute(bolt_query, {"material_id": m.id})
+ bolt_detail = bolt_result.fetchone()
+ if bolt_detail:
+ material_dict['bolt_details'] = {
+ "bolt_type": bolt_detail.bolt_type,
+ "thread_type": bolt_detail.thread_type,
+ "diameter": bolt_detail.diameter,
+ "length": bolt_detail.length,
+ "material_standard": bolt_detail.material_standard,
+ "material_grade": bolt_detail.material_grade,
+ "coating_type": bolt_detail.coating_type,
+ "pressure_rating": bolt_detail.pressure_rating
+ }
material_list.append(material_dict)
diff --git a/backend/app/services/bolt_classifier.py b/backend/app/services/bolt_classifier.py
index 329e9a0..02368fa 100644
--- a/backend/app/services/bolt_classifier.py
+++ b/backend/app/services/bolt_classifier.py
@@ -7,6 +7,125 @@ import re
from typing import Dict, List, Optional
from .material_classifier import classify_material
+def classify_bolt_material(description: str) -> Dict:
+ """볼트용 재질 분류 (ASTM A193, A194 등)"""
+
+ desc_upper = description.upper()
+
+ # ASTM A193 (볼트용 강재)
+ if any(pattern in desc_upper for pattern in ["A193", "ASTM A193"]):
+ # B7, B8 등 등급 추출 (GR B7/2H 형태도 지원)
+ grade = "UNKNOWN"
+ if "GR B7" in desc_upper or " B7" in desc_upper:
+ grade = "B7"
+ elif "GR B8" in desc_upper or " B8" in desc_upper:
+ grade = "B8"
+ elif "GR B16" in desc_upper or " B16" in desc_upper:
+ grade = "B16"
+
+ return {
+ "standard": "ASTM A193",
+ "grade": grade if grade != "UNKNOWN" else "ASTM A193",
+ "material_type": "ALLOY_STEEL" if "B7" in grade else "STAINLESS_STEEL",
+ "manufacturing": "FORGED",
+ "confidence": 0.95,
+ "evidence": ["ASTM_A193_BOLT_MATERIAL"]
+ }
+
+ # ASTM A194 (너트용 강재)
+ if any(pattern in desc_upper for pattern in ["A194", "ASTM A194"]):
+ grade = "UNKNOWN"
+ if "GR 2H" in desc_upper or " 2H" in desc_upper or "/2H" in desc_upper:
+ grade = "2H"
+ elif "GR 8" in desc_upper or " 8" in desc_upper:
+ grade = "8"
+
+ return {
+ "standard": "ASTM A194",
+ "grade": grade if grade != "UNKNOWN" else "ASTM A194",
+ "material_type": "ALLOY_STEEL" if "2H" in grade else "STAINLESS_STEEL",
+ "manufacturing": "FORGED",
+ "confidence": 0.95,
+ "evidence": ["ASTM_A194_NUT_MATERIAL"]
+ }
+
+ # ASTM A320 (저온용 볼트)
+ if any(pattern in desc_upper for pattern in ["A320", "ASTM A320"]):
+ grade = "UNKNOWN"
+ if "L7" in desc_upper:
+ grade = "L7"
+ elif "L43" in desc_upper:
+ grade = "L43"
+ elif "B8M" in desc_upper:
+ grade = "B8M"
+
+ return {
+ "standard": "ASTM A320",
+ "grade": grade if grade != "UNKNOWN" else "ASTM A320",
+ "material_type": "LOW_TEMP_STEEL",
+ "manufacturing": "FORGED",
+ "confidence": 0.95,
+ "evidence": ["ASTM_A320_LOW_TEMP_BOLT"]
+ }
+
+ # ASTM A325 (구조용 볼트)
+ if any(pattern in desc_upper for pattern in ["A325", "ASTM A325"]):
+ return {
+ "standard": "ASTM A325",
+ "grade": "ASTM A325",
+ "material_type": "STRUCTURAL_STEEL",
+ "manufacturing": "HEAT_TREATED",
+ "confidence": 0.95,
+ "evidence": ["ASTM_A325_STRUCTURAL_BOLT"]
+ }
+
+ # ASTM A490 (고강도 구조용 볼트)
+ if any(pattern in desc_upper for pattern in ["A490", "ASTM A490"]):
+ return {
+ "standard": "ASTM A490",
+ "grade": "ASTM A490",
+ "material_type": "HIGH_STRENGTH_STEEL",
+ "manufacturing": "HEAT_TREATED",
+ "confidence": 0.95,
+ "evidence": ["ASTM_A490_HIGH_STRENGTH_BOLT"]
+ }
+
+ # DIN 934 (DIN 너트)
+ if any(pattern in desc_upper for pattern in ["DIN 934", "DIN934"]):
+ return {
+ "standard": "DIN 934",
+ "grade": "DIN 934",
+ "material_type": "CARBON_STEEL",
+ "manufacturing": "FORGED",
+ "confidence": 0.90,
+ "evidence": ["DIN_934_NUT"]
+ }
+
+ # ISO 4762 (소켓 헤드 캡 스크류)
+ if any(pattern in desc_upper for pattern in ["ISO 4762", "ISO4762", "DIN 912", "DIN912"]):
+ return {
+ "standard": "ISO 4762",
+ "grade": "ISO 4762",
+ "material_type": "ALLOY_STEEL",
+ "manufacturing": "HEAT_TREATED",
+ "confidence": 0.90,
+ "evidence": ["ISO_4762_SOCKET_SCREW"]
+ }
+
+ # 기본 재질 분류기 호출 (materials_schema 문제가 있어도 우회)
+ try:
+ return classify_material(description)
+ except:
+ # materials_schema에 문제가 있으면 기본값 반환
+ return {
+ "standard": "UNKNOWN",
+ "grade": "UNKNOWN",
+ "material_type": "UNKNOWN",
+ "manufacturing": "UNKNOWN",
+ "confidence": 0.0,
+ "evidence": ["MATERIAL_SCHEMA_ERROR"]
+ }
+
# ========== 볼트 타입별 분류 ==========
BOLT_TYPES = {
"HEX_BOLT": {
@@ -26,16 +145,16 @@ BOLT_TYPES = {
},
"STUD_BOLT": {
- "dat_file_patterns": ["STUD_", "STUD_BOLT"],
- "description_keywords": ["STUD BOLT", "STUD", "스터드볼트", "전나사"],
+ "dat_file_patterns": ["STUD_", "STUD_BOLT", "_TK", "BLT_"],
+ "description_keywords": ["STUD BOLT", "STUD", "스터드볼트", "전나사", "BLT"],
"characteristics": "양끝 나사 스터드",
"applications": "플랜지 체결용",
"head_type": "NONE"
},
"FLANGE_BOLT": {
- "dat_file_patterns": ["FLG_BOLT", "FLANGE_BOLT"],
- "description_keywords": ["FLANGE BOLT", "플랜지볼트"],
+ "dat_file_patterns": ["FLG_BOLT", "FLANGE_BOLT", "BLT_150", "BLT_300", "BLT_600"],
+ "description_keywords": ["FLANGE BOLT", "플랜지볼트", "150LB", "300LB", "600LB"],
"characteristics": "플랜지 전용 볼트",
"applications": "플랜지 체결 전용",
"head_type": "HEXAGON"
@@ -183,7 +302,7 @@ BOLT_GRADES = {
}
}
-def classify_bolt(dat_file: str, description: str, main_nom: str, length: float = None) -> Dict:
+def classify_bolt(dat_file: str, description: str, main_nom: str, length: Optional[float] = None) -> Dict:
"""
완전한 BOLT 분류
@@ -196,8 +315,10 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: float
완전한 볼트 분류 결과
"""
- # 1. 재질 분류 (공통 모듈 사용)
- material_result = classify_material(description)
+
+
+ # 1. 재질 분류 (볼트 전용 버전)
+ material_result = classify_bolt_material(description)
# 2. 체결재 타입 분류 (볼트/너트/와셔)
fastener_category = classify_fastener_category(dat_file, description)
@@ -282,7 +403,7 @@ def classify_fastener_category(dat_file: str, description: str) -> Dict:
combined_text = f"{dat_upper} {desc_upper}"
# 볼트 키워드
- bolt_keywords = ["BOLT", "SCREW", "STUD", "볼트", "나사", "스크류"]
+ bolt_keywords = ["BOLT", "SCREW", "STUD", "BLT", "볼트", "나사", "스크류", "A193", "A194", "A320", "A325", "A490"]
if any(keyword in combined_text for keyword in bolt_keywords):
return {
"category": "BOLT",
@@ -308,11 +429,30 @@ def classify_fastener_category(dat_file: str, description: str) -> Dict:
"evidence": ["WASHER_KEYWORDS"]
}
- # 기본값: BOLT
+
+ # 볼트가 아닌 것 같은 키워드들 체크
+ non_bolt_keywords = [
+ "PIPE", "TUBE", "파이프", "배관", # 파이프
+ "ELBOW", "TEE", "REDUCER", "CAP", "엘보", "티", "리듀서", # 피팅
+ "VALVE", "GATE", "BALL", "GLOBE", "CHECK", "밸브", # 밸브
+ "FLANGE", "FLG", "플랜지", # 플랜지
+ "GASKET", "GASK", "가스켓", # 가스켓
+ "GAUGE", "METER", "TRANSMITTER", "INDICATOR", "SENSOR", "계기", "게이지", # 계기
+ "THERMOWELL", "ORIFICE", "MANOMETER" # 특수 계기
+ ]
+
+ if any(keyword in combined_text for keyword in non_bolt_keywords):
+ return {
+ "category": "UNKNOWN",
+ "confidence": 0.1,
+ "evidence": ["NON_BOLT_KEYWORDS_DETECTED"]
+ }
+
+ # 기본값: BOLT (하지만 낮은 신뢰도)
return {
"category": "BOLT",
- "confidence": 0.6,
- "evidence": ["DEFAULT_BOLT"]
+ "confidence": 0.1, # 0.6에서 0.1로 낮춤
+ "evidence": ["DEFAULT_BOLT_LOW_CONFIDENCE"]
}
def classify_specific_fastener_type(dat_file: str, description: str,
@@ -429,6 +569,8 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict:
# 길이 정보 추출
length_patterns = [
+ r'(\d+(?:\.\d+)?)\s*LG', # 70.0000 LG 형태 (최우선)
+ r'(\d+(?:\.\d+)?)\s*MM\s*LG', # 70MM LG 형태
r'L\s*(\d+(?:\.\d+)?)\s*MM',
r'LENGTH\s*(\d+(?:\.\d+)?)\s*MM',
r'(\d+(?:\.\d+)?)\s*MM\s*LONG',
@@ -513,19 +655,41 @@ def classify_bolt_grade(description: str, thread_result: Dict) -> Dict:
}
def calculate_bolt_confidence(confidence_scores: Dict) -> float:
- """볼트 분류 전체 신뢰도 계산"""
+ """볼트 분류 전체 신뢰도 계산 (개선된 버전)"""
- scores = [score for score in confidence_scores.values() if score > 0]
+ # 기본 점수들
+ material_conf = confidence_scores.get("material", 0)
+ fastener_type_conf = confidence_scores.get("fastener_type", 0)
+ thread_conf = confidence_scores.get("thread", 0)
+ grade_conf = confidence_scores.get("grade", 0)
- if not scores:
- return 0.0
+ # 체결재 카테고리 신뢰도 (BOLT인지 확실한가?)
+ fastener_category_conf = confidence_scores.get("fastener_category", 0)
- # 가중 평균
+ # 볼트 확신도 보너스
+ bolt_certainty_bonus = 0.0
+
+ # 1. 체결재 카테고리가 BOLT이고 신뢰도가 높으면 보너스
+ if fastener_category_conf >= 0.8:
+ bolt_certainty_bonus += 0.2
+
+ # 2. 피팅 타입이 명확하게 인식되면 보너스
+ if fastener_type_conf >= 0.8:
+ bolt_certainty_bonus += 0.1
+
+ # 3. ASTM 볼트 재질이 인식되면 큰 보너스
+ if material_conf >= 0.8:
+ bolt_certainty_bonus += 0.2
+ elif material_conf >= 0.5:
+ bolt_certainty_bonus += 0.1
+
+ # 기본 가중 평균 (재질 비중을 낮추고 체결재 타입 비중 증가)
weights = {
- "material": 0.2,
- "fastener_type": 0.4,
- "thread": 0.3,
- "grade": 0.1
+ "fastener_category": 0.3, # 새로 추가
+ "material": 0.1, # 낮춤 (0.2 -> 0.1)
+ "fastener_type": 0.4, # 유지
+ "thread": 0.15, # 낮춤 (0.3 -> 0.15)
+ "grade": 0.05 # 낮춤 (0.1 -> 0.05)
}
weighted_sum = sum(
@@ -533,7 +697,11 @@ def calculate_bolt_confidence(confidence_scores: Dict) -> float:
for key, weight in weights.items()
)
- return round(weighted_sum, 2)
+ # 최종 신뢰도 = 기본 가중평균 + 보너스
+ final_confidence = weighted_sum + bolt_certainty_bonus
+
+ # 최대값 1.0으로 제한
+ return round(min(final_confidence, 1.0), 2)
# ========== 특수 기능들 ==========
diff --git a/backend/app/services/fitting_classifier.py b/backend/app/services/fitting_classifier.py
index f92e7c7..873dbe4 100644
--- a/backend/app/services/fitting_classifier.py
+++ b/backend/app/services/fitting_classifier.py
@@ -225,6 +225,9 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
# 4. 압력 등급 분류
pressure_result = classify_pressure_rating(dat_file, description)
+ # 4.5. 스케줄 분류 (니플 등에 중요)
+ schedule_result = classify_fitting_schedule(description)
+
# 5. 제작 방법 추정
manufacturing_result = determine_fitting_manufacturing(
material_result, connection_result, pressure_result, main_nom
@@ -279,6 +282,14 @@ def classify_fitting(dat_file: str, description: str, main_nom: str,
"requires_two_sizes": fitting_type_result.get('requires_two_sizes', False)
},
+ "schedule_info": {
+ "schedule": schedule_result.get('schedule', 'UNKNOWN'),
+ "schedule_number": schedule_result.get('schedule_number', ''),
+ "wall_thickness": schedule_result.get('wall_thickness', ''),
+ "pressure_class": schedule_result.get('pressure_class', ''),
+ "confidence": schedule_result.get('confidence', 0.0)
+ },
+
# 전체 신뢰도
"overall_confidence": calculate_fitting_confidence({
"material": material_result.get('confidence', 0),
@@ -615,3 +626,51 @@ def get_fitting_purchase_info(fitting_result: Dict) -> Dict:
"purchase_category": f"{fitting_type} {connection} {pressure}",
"manufacturing_note": fitting_result["manufacturing"]["characteristics"]
}
+
+def classify_fitting_schedule(description: str) -> Dict:
+ """피팅 스케줄 분류 (특히 니플용)"""
+
+ desc_upper = description.upper()
+
+ # 스케줄 패턴 매칭
+ schedule_patterns = [
+ r'SCH\s*(\d+)',
+ r'SCHEDULE\s*(\d+)',
+ r'스케줄\s*(\d+)'
+ ]
+
+ for pattern in schedule_patterns:
+ match = re.search(pattern, desc_upper)
+ if match:
+ schedule_number = match.group(1)
+ schedule = f"SCH {schedule_number}"
+
+ # 일반적인 스케줄 정보
+ common_schedules = {
+ "10": {"wall": "얇음", "pressure": "저압"},
+ "20": {"wall": "얇음", "pressure": "저압"},
+ "40": {"wall": "표준", "pressure": "중압"},
+ "80": {"wall": "두꺼움", "pressure": "고압"},
+ "120": {"wall": "매우 두꺼움", "pressure": "고압"},
+ "160": {"wall": "매우 두꺼움", "pressure": "초고압"}
+ }
+
+ schedule_info = common_schedules.get(schedule_number, {"wall": "비표준", "pressure": "확인 필요"})
+
+ return {
+ "schedule": schedule,
+ "schedule_number": schedule_number,
+ "wall_thickness": schedule_info["wall"],
+ "pressure_class": schedule_info["pressure"],
+ "confidence": 0.95,
+ "matched_pattern": pattern
+ }
+
+ return {
+ "schedule": "UNKNOWN",
+ "schedule_number": "",
+ "wall_thickness": "",
+ "pressure_class": "",
+ "confidence": 0.0,
+ "matched_pattern": ""
+ }
diff --git a/backend/app/services/flange_classifier.py b/backend/app/services/flange_classifier.py
index c83b588..67964c6 100644
--- a/backend/app/services/flange_classifier.py
+++ b/backend/app/services/flange_classifier.py
@@ -182,7 +182,7 @@ def classify_flange(dat_file: str, description: str, main_nom: str,
dat_upper = dat_file.upper()
# 1. 명칭 우선 확인 (플랜지 키워드가 있으면 플랜지)
- flange_keywords = ['FLG', 'FLANGE', '플랜지']
+ flange_keywords = ['FLG', 'FLANGE', '플랜지', 'ORIFICE', 'SPECTACLE', 'PADDLE', 'SPACER']
is_flange = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords)
if not is_flange:
diff --git a/backend/app/services/gasket_classifier.py b/backend/app/services/gasket_classifier.py
index fba2df0..39b606f 100644
--- a/backend/app/services/gasket_classifier.py
+++ b/backend/app/services/gasket_classifier.py
@@ -205,7 +205,8 @@ def classify_gasket(dat_file: str, description: str, main_nom: str, length: floa
"material": gasket_material_result.get('material', 'UNKNOWN'),
"characteristics": gasket_material_result.get('characteristics', ''),
"temperature_range": gasket_material_result.get('temperature_range', ''),
- "confidence": gasket_material_result.get('confidence', 0.0)
+ "confidence": gasket_material_result.get('confidence', 0.0),
+ "swg_details": gasket_material_result.get('swg_details', {})
},
# 가스켓 분류 정보
@@ -294,16 +295,72 @@ def classify_gasket_type(dat_file: str, description: str) -> Dict:
"applications": ""
}
+def parse_swg_details(description: str) -> Dict:
+ """SWG (Spiral Wound Gasket) 상세 정보 파싱"""
+
+ desc_upper = description.upper()
+ result = {
+ "face_type": "UNKNOWN",
+ "outer_ring": "UNKNOWN",
+ "filler": "UNKNOWN",
+ "inner_ring": "UNKNOWN",
+ "thickness": None,
+ "detailed_construction": "",
+ "confidence": 0.0
+ }
+
+ # H/F/I/O 패턴 파싱 (Head/Face/Inner/Outer)
+ hfio_pattern = r'H/F/I/O|HFIO'
+ if re.search(hfio_pattern, desc_upper):
+ result["face_type"] = "H/F/I/O"
+ result["confidence"] += 0.3
+
+ # 재질 구성 파싱 (SS304/GRAPHITE/CS/CS)
+ # H/F/I/O 다음에 나오는 재질 구성을 찾음
+ material_pattern = r'H/F/I/O\s+([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)'
+ material_match = re.search(material_pattern, desc_upper)
+ if material_match:
+ result["outer_ring"] = material_match.group(1) # SS304
+ result["filler"] = material_match.group(2) # GRAPHITE
+ result["inner_ring"] = material_match.group(3) # CS
+ # 네 번째는 보통 outer ring 반복
+ result["detailed_construction"] = f"{material_match.group(1)}/{material_match.group(2)}/{material_match.group(3)}/{material_match.group(4)}"
+ result["confidence"] += 0.4
+ else:
+ # H/F/I/O 없이 재질만 있는 경우
+ material_pattern_simple = r'([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)'
+ material_match = re.search(material_pattern_simple, desc_upper)
+ if material_match:
+ result["outer_ring"] = material_match.group(1)
+ result["filler"] = material_match.group(2)
+ result["inner_ring"] = material_match.group(3)
+ result["detailed_construction"] = f"{material_match.group(1)}/{material_match.group(2)}/{material_match.group(3)}/{material_match.group(4)}"
+ result["confidence"] += 0.3
+
+ # 두께 파싱 (4.5mm)
+ thickness_pattern = r'(\d+(?:\.\d+)?)\s*MM'
+ thickness_match = re.search(thickness_pattern, desc_upper)
+ if thickness_match:
+ result["thickness"] = float(thickness_match.group(1))
+ result["confidence"] += 0.3
+
+ return result
+
def classify_gasket_material(description: str) -> Dict:
- """가스켓 전용 재질 분류"""
+ """가스켓 전용 재질 분류 (SWG 상세 정보 포함)"""
desc_upper = description.upper()
- # 가스켓 전용 재질 확인
+ # SWG 상세 정보 파싱
+ swg_details = None
+ if "SWG" in desc_upper or "SPIRAL WOUND" in desc_upper:
+ swg_details = parse_swg_details(description)
+
+ # 기본 가스켓 재질 확인
for material_type, material_data in GASKET_MATERIALS.items():
for keyword in material_data["keywords"]:
if keyword in desc_upper:
- return {
+ result = {
"material": material_type,
"characteristics": material_data["characteristics"],
"temperature_range": material_data["temperature_range"],
@@ -311,6 +368,13 @@ def classify_gasket_material(description: str) -> Dict:
"matched_keyword": keyword,
"applications": material_data["applications"]
}
+
+ # SWG 상세 정보 추가
+ if swg_details and swg_details["confidence"] > 0:
+ result["swg_details"] = swg_details
+ result["confidence"] = min(0.95, result["confidence"] + swg_details["confidence"] * 0.1)
+
+ return result
# 일반 재질 키워드 확인
if any(keyword in desc_upper for keyword in ["RUBBER", "고무"]):
diff --git a/backend/app/services/instrument_classifier.py b/backend/app/services/instrument_classifier.py
index 9dab93b..0b8c413 100644
--- a/backend/app/services/instrument_classifier.py
+++ b/backend/app/services/instrument_classifier.py
@@ -46,7 +46,7 @@ INSTRUMENT_TYPES = {
}
}
-def classify_instrument(dat_file: str, description: str, main_nom: str, length: float = None) -> Dict:
+def classify_instrument(dat_file: str, description: str, main_nom: str, length: Optional[float] = None) -> Dict:
"""
간단한 INSTRUMENT 분류
@@ -59,16 +59,62 @@ def classify_instrument(dat_file: str, description: str, main_nom: str, length:
간단한 계기 분류 결과
"""
- # 1. 재질 분류 (공통 모듈)
+ # 1. 먼저 계기인지 확인 (계기 키워드가 있어야 함)
+ desc_upper = description.upper()
+ dat_upper = dat_file.upper()
+ combined_text = f"{dat_upper} {desc_upper}"
+
+ # 계기 관련 키워드 확인
+ instrument_keywords = [
+ "GAUGE", "METER", "TRANSMITTER", "INDICATOR", "SENSOR",
+ "THERMOMETER", "MANOMETER", "ROTAMETER", "THERMOWELL",
+ "ORIFICE PLATE", "DISPLAY", "4-20MA", "4-20 MA",
+ "압력계", "온도계", "유량계", "액위계", "게이지", "계기",
+ "트랜스미터", "지시계", "센서"
+ ]
+
+ # 계기가 아닌 것들의 키워드
+ non_instrument_keywords = [
+ "BOLT", "SCREW", "STUD", "NUT", "WASHER", "볼트", "나사", "너트", "와셔",
+ "PIPE", "TUBE", "파이프", "배관",
+ "ELBOW", "TEE", "REDUCER", "CAP", "엘보", "티", "리듀서",
+ "VALVE", "GATE", "BALL", "GLOBE", "CHECK", "밸브",
+ "FLANGE", "FLG", "플랜지",
+ "GASKET", "GASK", "가스켓"
+ ]
+
+ # 계기가 아닌 키워드가 있으면 거부
+ if any(keyword in combined_text for keyword in non_instrument_keywords):
+ return {
+ "category": "UNKNOWN",
+ "overall_confidence": 0.1,
+ "reason": "NON_INSTRUMENT_KEYWORDS_DETECTED"
+ }
+
+ # 계기 키워드가 없으면 거부
+ has_instrument_keyword = any(keyword in combined_text for keyword in instrument_keywords)
+ if not has_instrument_keyword:
+ return {
+ "category": "UNKNOWN",
+ "overall_confidence": 0.1,
+ "reason": "NO_INSTRUMENT_KEYWORDS_FOUND"
+ }
+
+ # 2. 재질 분류 (공통 모듈)
material_result = classify_material(description)
- # 2. 계기 타입 분류
+ # 3. 계기 타입 분류
instrument_type_result = classify_instrument_type(dat_file, description)
- # 3. 측정 범위 추출 (있다면)
+ # 4. 측정 범위 추출 (있다면)
measurement_range = extract_measurement_range(description)
- # 4. 최종 결과
+ # 5. 전체 신뢰도 계산
+ base_confidence = 0.8 if has_instrument_keyword else 0.1
+ instrument_confidence = instrument_type_result.get('confidence', 0.0)
+ overall_confidence = (base_confidence + instrument_confidence) / 2
+
+ # 6. 최종 결과
return {
"category": "INSTRUMENT",
@@ -105,11 +151,8 @@ def classify_instrument(dat_file: str, description: str, main_nom: str, length:
"note": "사양서 확인 후 주문"
},
- # 간단한 신뢰도
- "overall_confidence": calculate_simple_confidence([
- material_result.get('confidence', 0),
- instrument_type_result.get('confidence', 0)
- ])
+ # 전체 신뢰도
+ "overall_confidence": overall_confidence
}
def classify_instrument_type(dat_file: str, description: str) -> Dict:
diff --git a/backend/app/services/materials_schema.py b/backend/app/services/materials_schema.py
index 2e8d38d..b4ba7b0 100644
--- a/backend/app/services/materials_schema.py
+++ b/backend/app/services/materials_schema.py
@@ -87,18 +87,19 @@ MATERIAL_STANDARDS = {
"manufacturing": "FORGED"
}
},
- "A105": {
- "patterns": [
- r"ASTM\s+A105(?:\s+(?:GR\s*)?([ABC]))?",
- r"A105(?:\s+(?:GR\s*)?([ABC]))?",
- r"ASME\s+SA105"
- ],
- "description": "탄소강 단조품",
- "composition": "탄소강",
- "applications": "일반 압력용 단조품",
- "manufacturing": "FORGED",
- "pressure_rating": "150LB ~ 9000LB"
- }
+
+ "A105": {
+ "patterns": [
+ r"ASTM\s+A105(?:\s+(?:GR\s*)?([ABC]))?",
+ r"A105(?:\s+(?:GR\s*)?([ABC]))?",
+ r"ASME\s+SA105"
+ ],
+ "description": "탄소강 단조품",
+ "composition": "탄소강",
+ "applications": "일반 압력용 단조품",
+ "manufacturing": "FORGED",
+ "pressure_rating": "150LB ~ 9000LB"
+ }
},
"WELDED_GRADES": {
diff --git a/backend/scripts/06_add_pressure_rating_to_bolt_details.sql b/backend/scripts/06_add_pressure_rating_to_bolt_details.sql
new file mode 100644
index 0000000..14f3acc
--- /dev/null
+++ b/backend/scripts/06_add_pressure_rating_to_bolt_details.sql
@@ -0,0 +1,16 @@
+-- bolt_details 테이블에 pressure_rating 컬럼 추가
+-- 2025.01.16 - 볼트 압력등급 정보 저장을 위한 스키마 개선
+
+ALTER TABLE bolt_details ADD COLUMN IF NOT EXISTS pressure_rating VARCHAR(50);
+
+-- 기존 레코드에 기본값 설정 (선택사항)
+UPDATE bolt_details SET pressure_rating = 'UNKNOWN' WHERE pressure_rating IS NULL;
+
+-- 인덱스 추가 (성능 최적화)
+CREATE INDEX IF NOT EXISTS idx_bolt_details_pressure_rating ON bolt_details(pressure_rating);
+
+-- 확인용 쿼리
+SELECT column_name, data_type, is_nullable
+FROM information_schema.columns
+WHERE table_name = 'bolt_details'
+AND column_name = 'pressure_rating';
\ No newline at end of file
diff --git a/frontend/src/components/MaterialList.jsx b/frontend/src/components/MaterialList.jsx
index 146874f..5f460e7 100644
--- a/frontend/src/components/MaterialList.jsx
+++ b/frontend/src/components/MaterialList.jsx
@@ -254,7 +254,9 @@ function MaterialList({ selectedProject }) {
'VALVE': 'success',
'FLANGE': 'warning',
'BOLT': 'info',
- 'OTHER': 'default'
+ 'GASKET': 'error',
+ 'INSTRUMENT': 'purple',
+ 'OTHER': 'default'
};
return colors[itemType] || 'default';
};
@@ -540,6 +542,8 @@ function MaterialList({ selectedProject }) {
+
+
diff --git a/frontend/src/pages/MaterialsPage.jsx b/frontend/src/pages/MaterialsPage.jsx
index 890a95c..ef23e52 100644
--- a/frontend/src/pages/MaterialsPage.jsx
+++ b/frontend/src/pages/MaterialsPage.jsx
@@ -125,8 +125,8 @@ const MaterialsPage = () => {
spec_parts.push(fitting_subtype);
}
- // NIPPLE 스케줄 정보 추가 (중요!)
- const nipple_schedule = material.fitting_details?.schedule || material.pipe_details?.schedule;
+ // NIPPLE 스케줄 정보 추가 (fitting_details에서 가져옴)
+ const nipple_schedule = material.fitting_details?.schedule;
if (nipple_schedule && nipple_schedule !== 'UNKNOWN') {
spec_parts.push(nipple_schedule);
}
@@ -137,8 +137,8 @@ const MaterialsPage = () => {
spec_parts.push(connection_method);
}
- // NIPPLE 길이 정보 추가 (mm → m 변환 또는 그대로)
- const length_mm = material.length || material.pipe_details?.length_mm;
+ // NIPPLE 길이 정보 추가 (fitting_details에서 가져옴)
+ const length_mm = material.fitting_details?.length_mm;
if (length_mm && length_mm > 0) {
if (length_mm >= 1000) {
spec_parts.push(`${(length_mm / 1000).toFixed(2)}m`);
@@ -190,6 +190,228 @@ const MaterialsPage = () => {
unit: 'EA',
isLength: false
};
+ } else if (category === 'FLANGE') {
+ // FLANGE: 타입 + 압력등급 + 면가공 + 재질
+ const material_spec = material.flange_details?.material_spec || material.material_grade || '';
+ const main_nom = material.main_nom || '';
+ const flange_type = material.flange_details?.flange_type || 'UNKNOWN';
+ const pressure_rating = material.flange_details?.pressure_rating || '';
+ const facing_type = material.flange_details?.facing_type || '';
+
+ // 플랜지 스펙 생성
+ const flange_spec_parts = [];
+
+ // 플랜지 타입 (WN, BL, SO 등)
+ if (flange_type && flange_type !== 'UNKNOWN') {
+ flange_spec_parts.push(flange_type);
+ }
+
+ // 면 가공 (RF, FF, RTJ 등)
+ if (facing_type && facing_type !== 'UNKNOWN') {
+ flange_spec_parts.push(facing_type);
+ }
+
+ // 압력등급 (150LB, 300LB 등)
+ if (pressure_rating && pressure_rating !== 'UNKNOWN') {
+ flange_spec_parts.push(pressure_rating);
+ }
+
+ const full_flange_spec = flange_spec_parts.join(', ');
+
+ specKey = `${category}|${full_flange_spec}|${material_spec}|${main_nom}`;
+ specData = {
+ category: 'FLANGE',
+ flange_type,
+ pressure_rating,
+ facing_type,
+ full_flange_spec,
+ material_spec,
+ size_display: main_nom,
+ main_nom,
+ unit: 'EA',
+ isLength: false
+ };
+ } else if (category === 'GASKET') {
+ // GASKET: 타입 + 소재 + 압력등급 + 사이즈
+ const main_nom = material.main_nom || '';
+ const gasket_type = material.gasket_details?.gasket_type || 'UNKNOWN';
+ const material_type = material.gasket_details?.material_type || 'UNKNOWN';
+ const pressure_rating = material.gasket_details?.pressure_rating || '';
+
+ // 가스켓 재질은 gasket_details에서 가져옴
+ const material_spec = material_type !== 'UNKNOWN' ? material_type : (material.material_grade || 'Unknown');
+
+ // SWG 상세 정보 파싱 (additional_info에서)
+ let detailed_construction = 'N/A';
+ let face_type = '';
+ let thickness = material.gasket_details?.thickness || null;
+
+ // API에서 gasket_details의 추가 정보를 확인 (브라우저 콘솔에서 확인용)
+ if (material.gasket_details && Object.keys(material.gasket_details).length > 0) {
+ console.log('Gasket details:', material.gasket_details);
+ }
+
+ // 상세 구성 정보 생성 (Face Type + Construction)
+ // H/F/I/O SS304/GRAPHITE/CS/CS 형태로 표시
+ if (material.original_description) {
+ const desc = material.original_description.toUpperCase();
+
+ // H/F/I/O 다음에 오는 재질 구성만 찾기 (H/F/I/O는 제외)
+ const fullMatch = desc.match(/H\/F\/I\/O\s+([A-Z0-9]+\/[A-Z]+\/[A-Z0-9]+\/[A-Z0-9]+)/);
+ if (fullMatch) {
+ // H/F/I/O와 재질 구성 둘 다 있는 경우
+ face_type = 'H/F/I/O';
+ const construction = fullMatch[1];
+ detailed_construction = `${face_type} ${construction}`;
+ } else {
+ // H/F/I/O만 있는 경우
+ const faceMatch = desc.match(/H\/F\/I\/O/);
+ if (faceMatch) {
+ detailed_construction = 'H/F/I/O';
+ } else {
+ // 재질 구성만 있는 경우 (H/F/I/O 없이)
+ const constructionOnlyMatch = desc.match(/([A-Z0-9]+\/[A-Z]+\/[A-Z0-9]+\/[A-Z0-9]+)/);
+ if (constructionOnlyMatch) {
+ detailed_construction = constructionOnlyMatch[1];
+ }
+ }
+ }
+ }
+
+ // 가스켓 스펙 생성
+ const gasket_spec_parts = [];
+
+ // 가스켓 타입 (SPIRAL_WOUND, O_RING 등)
+ if (gasket_type && gasket_type !== 'UNKNOWN') {
+ gasket_spec_parts.push(gasket_type.replace('_', ' '));
+ }
+
+ // 소재 (GRAPHITE, PTFE 등)
+ if (material_type && material_type !== 'UNKNOWN') {
+ gasket_spec_parts.push(material_type);
+ }
+
+ // 압력등급 (150LB, 300LB 등)
+ if (pressure_rating && pressure_rating !== 'UNKNOWN') {
+ gasket_spec_parts.push(pressure_rating);
+ }
+
+ const full_gasket_spec = gasket_spec_parts.join(', ');
+
+ specKey = `${category}|${full_gasket_spec}|${material_spec}|${main_nom}|${detailed_construction}`;
+ specData = {
+ category: 'GASKET',
+ gasket_type,
+ material_type,
+ pressure_rating,
+ full_gasket_spec,
+ material_spec,
+ size_display: main_nom,
+ main_nom,
+ detailed_construction,
+ thickness,
+ unit: 'EA',
+ isLength: false
+ };
+ } else if (category === 'BOLT') {
+ // BOLT: 타입 + 재질 + 사이즈 + 길이
+ const material_spec = material.material_grade || '';
+ const main_nom = material.main_nom || '';
+ const bolt_type = material.bolt_details?.bolt_type || 'BOLT';
+ const material_standard = material.bolt_details?.material_standard || '';
+ const material_grade = material.bolt_details?.material_grade || '';
+ const thread_type = material.bolt_details?.thread_type || '';
+ const diameter = material.bolt_details?.diameter || main_nom;
+ const length = material.bolt_details?.length || '';
+ const pressure_rating = material.bolt_details?.pressure_rating || '';
+ const coating_type = material.bolt_details?.coating_type || '';
+
+ // 볼트 스펙 생성
+ const bolt_spec_parts = [];
+
+ // 볼트 타입 (HEX_BOLT, STUD_BOLT 등)
+ if (bolt_type && bolt_type !== 'UNKNOWN') {
+ bolt_spec_parts.push(bolt_type.replace('_', ' '));
+ }
+
+ // 재질 (ASTM A193, ASTM A194 등)
+ if (material_standard) {
+ bolt_spec_parts.push(material_standard);
+ if (material_grade && material_grade !== material_standard) {
+ bolt_spec_parts.push(material_grade);
+ }
+ } else if (material_spec) {
+ bolt_spec_parts.push(material_spec);
+ }
+
+ // 나사 규격 (M12, 1/2" 등)
+ if (diameter) {
+ bolt_spec_parts.push(diameter);
+ }
+
+ // 코팅 타입 (ELECTRO_GALVANIZED 등)
+ if (coating_type && coating_type !== 'PLAIN') {
+ bolt_spec_parts.push(coating_type.replace('_', ' '));
+ }
+
+ const full_bolt_spec = bolt_spec_parts.join(', ');
+
+ specKey = `${category}|${full_bolt_spec}|${diameter}|${length}|${coating_type}|${pressure_rating}`;
+ specData = {
+ category: 'BOLT',
+ bolt_type,
+ thread_type,
+ full_bolt_spec,
+ material_standard,
+ material_grade,
+ diameter,
+ length,
+ coating_type,
+ pressure_rating,
+ size_display: diameter,
+ main_nom: diameter,
+ unit: 'EA',
+ isLength: false
+ };
+ } else if (category === 'INSTRUMENT') {
+ // INSTRUMENT: 타입 + 연결사이즈 + 측정범위
+ const main_nom = material.main_nom || '';
+ const instrument_type = material.instrument_details?.instrument_type || 'INSTRUMENT';
+ const measurement_range = material.instrument_details?.measurement_range || '';
+ const signal_type = material.instrument_details?.signal_type || '';
+
+ // 계기 스펙 생성
+ const instrument_spec_parts = [];
+
+ // 계기 타입 (PRESSURE_GAUGE, TEMPERATURE_TRANSMITTER 등)
+ if (instrument_type && instrument_type !== 'UNKNOWN') {
+ instrument_spec_parts.push(instrument_type.replace('_', ' '));
+ }
+
+ // 측정 범위 (0-100 PSI, 4-20mA 등)
+ if (measurement_range) {
+ instrument_spec_parts.push(measurement_range);
+ }
+
+ // 연결 사이즈 (1/4", 1/2" 등)
+ if (main_nom) {
+ instrument_spec_parts.push(`${main_nom} CONNECTION`);
+ }
+
+ const full_instrument_spec = instrument_spec_parts.join(', ');
+
+ specKey = `${category}|${full_instrument_spec}|${main_nom}`;
+ specData = {
+ category: 'INSTRUMENT',
+ instrument_type,
+ measurement_range,
+ signal_type,
+ full_instrument_spec,
+ size_display: main_nom,
+ main_nom,
+ unit: 'EA',
+ isLength: false
+ };
} else {
// 기타 자재: 기본 분류
const material_spec = material.material_grade || '';
@@ -385,15 +607,46 @@ const MaterialsPage = () => {
수량
>
)}
- {!['PIPE', 'FITTING'].includes(category) && (
+ {category === 'FLANGE' && (
<>
+ 플랜지 타입
재질
사이즈
수량
>
)}
- {!['PIPE', 'FITTING'].includes(category) && (
+ {category === 'GASKET' && (
<>
+ 가스켓 타입
+ 상세 구성
+ 재질
+ 두께
+ 사이즈
+ 수량
+ >
+ )}
+ {category === 'BOLT' && (
+ <>
+ 볼트 타입
+ 재질
+ 사이즈
+ 길이
+ 코팅
+ 압력등급
+ 수량
+ >
+ )}
+ {category === 'INSTRUMENT' && (
+ <>
+ 계기 타입
+ 측정범위
+ 연결사이즈
+ 수량
+ >
+ )}
+ {!['PIPE', 'FITTING', 'FLANGE', 'GASKET', 'BOLT', 'INSTRUMENT'].includes(category) && (
+ <>
+ 재질
사이즈
수량
>
@@ -437,8 +690,13 @@ const MaterialsPage = () => {
>
)}
- {(!['PIPE', 'FITTING'].includes(category)) && (
+ {category === 'FLANGE' && (
<>
+
+
+ {spec.full_flange_spec || spec.flange_type || 'UNKNOWN'}
+
+
{spec.material_spec || 'Unknown'}
{spec.size_display || 'Unknown'}
@@ -448,8 +706,90 @@ const MaterialsPage = () => {
>
)}
- {(!['PIPE', 'FITTING'].includes(category)) && (
+ {category === 'GASKET' && (
<>
+
+
+ {spec.gasket_type?.replace('_', ' ') || 'UNKNOWN'}
+
+
+
+
+ {spec.detailed_construction || 'N/A'}
+
+
+ {spec.material_spec || 'Unknown'}
+
+
+ {spec.thickness ? `${spec.thickness}mm` : 'N/A'}
+
+
+ {spec.size_display || 'Unknown'}
+
+
+ {spec.totalQuantity} {spec.unit}
+
+
+ >
+ )}
+ {category === 'BOLT' && (
+ <>
+
+
+ {spec.bolt_type?.replace('_', ' ') || 'UNKNOWN'}
+
+
+
+
+ {spec.material_standard || 'Unknown'} {spec.material_grade || ''}
+
+
+
+
+ {spec.diameter ? (spec.diameter.includes('"') ? spec.diameter : spec.diameter.replace('0.5', '1/2"').replace('0.75', '3/4"').replace('1.0', '1"').replace('1.5', '1 1/2"')) : 'Unknown'}
+
+
+
+
+ {spec.length || 'N/A'}
+
+
+
+
+ {spec.coating_type ? spec.coating_type.replace('_', ' ') : 'N/A'}
+
+
+
+
+ {spec.pressure_rating || 'N/A'}
+
+
+
+
+ {spec.totalQuantity} {spec.unit}
+
+
+ >
+ )}
+ {category === 'INSTRUMENT' && (
+ <>
+
+
+ {spec.instrument_type?.replace('_', ' ') || 'UNKNOWN'}
+
+
+ {spec.measurement_range || 'Unknown'}
+ {spec.size_display || 'Unknown'}
+
+
+ {spec.totalQuantity} {spec.unit}
+
+
+ >
+ )}
+ {(!['PIPE', 'FITTING', 'FLANGE', 'GASKET', 'BOLT', 'INSTRUMENT'].includes(category)) && (
+ <>
+ {spec.material_spec || 'Unknown'}
{spec.size_display || 'Unknown'}
diff --git a/test_bolt_data.csv b/test_bolt_data.csv
new file mode 100644
index 0000000..406d3b5
--- /dev/null
+++ b/test_bolt_data.csv
@@ -0,0 +1,4 @@
+description,qty,main_nom
+"0.5, 70.0000 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",76,3/4"
+"HEX BOLT M16 X 100MM, ASTM A193 B7",10,M16
+"STUD BOLT 1/2"" X 120MM, ASTM A193 GR B7",25,1/2"
\ No newline at end of file
diff --git a/test_bolt_upload.csv b/test_bolt_upload.csv
new file mode 100644
index 0000000..e952091
--- /dev/null
+++ b/test_bolt_upload.csv
@@ -0,0 +1,6 @@
+DAT_FILE,DESCRIPTION,MAIN_NOM,RED_NOM,QUANTITY,UNIT,DRAWING_NAME,AREA_CODE,LINE_NO
+BLT_150_TK,"STUD BOLT, 0.5, 70.0000 LG, 150LB, ASTM A193/A194 GR B7/2H, ELEC.GALV",0.5,70.0000,8.0,EA,P&ID-001,#01,LINE-001-A
+BLT_300_TK,"FLANGE BOLT, 3/4, 80.0000 LG, 300LB, ASTM A193/A194 GR B7/2H",3/4,80.0000,12.0,EA,P&ID-002,#02,LINE-002-B
+BOLT_HEX_M16,"HEX BOLT, M16 X 60MM, GRADE 8.8, ZINC PLATED",M16,60.0000,10.0,EA,P&ID-003,#03,LINE-003-C
+STUD_M20,"STUD BOLT, M20 X 100MM, ASTM A193 B7, 600LB",M20,100.0000,6.0,EA,P&ID-004,#04,LINE-004-D
+NUT_HEX_M16,"HEX NUT, M16, ASTM A194 2H",M16,,16.0,EA,P&ID-003,#03,LINE-003-C
\ No newline at end of file