From 3dd301cb5730bab715c1462c2ac6b15220027c36 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 18 Jul 2025 12:48:24 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=BC=ED=8A=B8=20=EB=B6=84=EB=A5=98=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 볼트 길이 추출 로직 개선: '70.0000 LG' 형태 인식 추가 - 재질 중복 표시 수정: 'ASTM A193 ASTM A193 B7' → 'B7' - A193/A194 등급 추출 로직 개선: 'GR B7/2H' 형태 지원 - bolt_details 테이블에 pressure_rating 컬럼 추가 - 볼트 분류기 오분류 방지: 플랜지/피팅이 볼트로 분류되지 않도록 수정 - 업로드 성능 개선: 키워드 기반 빠른 분류기 선택 로직 추가 - 분류 키워드 대폭 확장: 피팅/파이프/플랜지 키워드 추가 --- backend/app/api/files.py | 28 +- backend/app/routers/files.py | 443 ++++++++++++++++-- backend/app/services/bolt_classifier.py | 210 ++++++++- backend/app/services/fitting_classifier.py | 59 +++ backend/app/services/flange_classifier.py | 2 +- backend/app/services/gasket_classifier.py | 72 ++- backend/app/services/instrument_classifier.py | 63 ++- backend/app/services/materials_schema.py | 25 +- ...06_add_pressure_rating_to_bolt_details.sql | 16 + frontend/src/components/MaterialList.jsx | 6 +- frontend/src/pages/MaterialsPage.jsx | 356 +++++++++++++- test_bolt_data.csv | 4 + test_bolt_upload.csv | 6 + 13 files changed, 1184 insertions(+), 106 deletions(-) create mode 100644 backend/scripts/06_add_pressure_rating_to_bolt_details.sql create mode 100644 test_bolt_data.csv create mode 100644 test_bolt_upload.csv 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 }) { VALVE FLANGE BOLT + GASKET + INSTRUMENT 기타 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