From 48f8f634d1374f5dd92595d744ea05995ec6aa5d Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 29 Jul 2025 14:34:33 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=BC=ED=8A=B8=20=EB=B6=84=EB=A5=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=8C=80=ED=8F=AD=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실제 볼트 사이즈 추출: 설명의 첫 번째 숫자를 실제 볼트 직경으로 사용 - 분수 표기 변환: 0.625 → 5/8, 0.75 → 3/4 등 현장 친화적 표기 - 특수 용도 볼트 분류: PSV(압력안전밸브), LT(저온용), CK(체크밸브), ORI(오리피스) - 표면처리 정보 추출: ELEC.GALV, HOT DIP GALV 등 코팅 정보 - 복합 재질 규격 파싱: ASTM A193/A194 GR B7/2H 정확 분류 - 특수 용도별 색상 구분: PSV 빨강, LT 주황, CK 파랑, ORI 보라 - 프론트엔드 표시 개선: 분수 사이즈, 특수 용도 현황 별도 섹션 - inch 기호 제거: 깔끔한 분수 표시로 현장 가독성 향상 --- RULES.md | 23 +- backend/app/routers/files.py | 8 +- backend/app/services/bolt_classifier.py | 370 +++++++++++++++++- backend/app/services/integrated_classifier.py | 10 +- backend/app/services/purchase_calculator.py | 43 +- frontend/src/pages/MaterialsPage.jsx | 89 ++++- .../src/pages/PurchaseConfirmationPage.jsx | 55 +++ test_bolt_display.csv | 5 + test_flange_bolt.csv | 6 + 9 files changed, 574 insertions(+), 35 deletions(-) create mode 100644 test_bolt_display.csv create mode 100644 test_flange_bolt.csv diff --git a/RULES.md b/RULES.md index fc43d35..554719a 100644 --- a/RULES.md +++ b/RULES.md @@ -116,20 +116,37 @@ navigate(`/material-comparison?job_no=${jobNo}&revision=${revision}`); ## 🔄 **개발 워크플로우** -### **1. 백엔드 변경 시** +### **1. 서버 실행 명령어** +```bash +# 백엔드 실행 (터미널 1번) - TK-MP-Project 루트에서 +source venv/bin/activate # 가상환경 활성화 (venv는 루트에 있음) +cd backend +python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# 프론트엔드 실행 (터미널 2번) - TK-MP-Project 루트에서 +cd frontend +npm run dev # npm start 아님! +``` + +**접속 주소:** +- 백엔드 API: http://localhost:8000 +- API 문서: http://localhost:8000/docs +- 프론트엔드: http://localhost:5173 + +### **2. 백엔드 변경 시** ```bash # 항상 가상환경에서 실행 (사용자 선호사항) cd backend python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ``` -### **2. 데이터베이스 스키마 변경 시** +### **3. 데이터베이스 스키마 변경 시** ```sql -- scripts/ 폴더에 마이그레이션 SQL 파일 생성 -- 번호 순서: 01_, 02_, 03_... ``` -### **3. 커밋 메시지** +### **4. 커밋 메시지** ``` 한국어로 작성 (사용자 선호사항) 예: "파이프 길이 계산 및 엑셀 내보내기 버그 수정" diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index a154139..3b8bd48 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -751,10 +751,14 @@ async def upload_file( else: thread_type = str(thread_spec_info) if thread_spec_info else "UNKNOWN" - # 치수 정보 - diameter = material_data.get("main_nom", "") + # 치수 정보 (실제 볼트 사이즈 사용) + diameter = "" length = "" + nominal_size_fraction = "" if isinstance(dimensions_info, dict): + # 볼트 분류기에서 추출한 실제 볼트 사이즈 사용 + diameter = dimensions_info.get("nominal_size", material_data.get("main_nom", "")) + nominal_size_fraction = dimensions_info.get("nominal_size_fraction", diameter) length = dimensions_info.get("length", "") if not length and "70.0000 LG" in description: # 원본 설명에서 길이 추출 diff --git a/backend/app/services/bolt_classifier.py b/backend/app/services/bolt_classifier.py index 02368fa..6fed363 100644 --- a/backend/app/services/bolt_classifier.py +++ b/backend/app/services/bolt_classifier.py @@ -12,6 +12,30 @@ def classify_bolt_material(description: str) -> Dict: desc_upper = description.upper() + # A193/A194 동시 처리 (예: "ASTM A193/A194 GR B7/2H") + if "A193" in desc_upper and "A194" in desc_upper: + # B7/2H 등급 추출 + bolt_grade = "UNKNOWN" + nut_grade = "UNKNOWN" + + if "B7" in desc_upper: + bolt_grade = "B7" + if "2H" in desc_upper: + nut_grade = "2H" + elif " 8" in desc_upper or "GR 8" in desc_upper: + nut_grade = "8" + + combined_grade = f"{bolt_grade}/{nut_grade}" if bolt_grade != "UNKNOWN" and nut_grade != "UNKNOWN" else "A193/A194" + + return { + "standard": "ASTM A193/A194", + "grade": combined_grade, + "material_type": "ALLOY_STEEL", # B7/2H 조합은 보통 합금강 + "manufacturing": "FORGED", + "confidence": 0.95, + "evidence": ["ASTM_A193_A194_COMBINED"] + } + # ASTM A193 (볼트용 강재) if any(pattern in desc_upper for pattern in ["A193", "ASTM A193"]): # B7, B8 등 등급 추출 (GR B7/2H 형태도 지원) @@ -153,13 +177,49 @@ BOLT_TYPES = { }, "FLANGE_BOLT": { - "dat_file_patterns": ["FLG_BOLT", "FLANGE_BOLT", "BLT_150", "BLT_300", "BLT_600"], - "description_keywords": ["FLANGE BOLT", "플랜지볼트", "150LB", "300LB", "600LB"], + "dat_file_patterns": ["FLG_BOLT", "FLANGE_BOLT"], + "description_keywords": ["FLANGE BOLT", "플랜지볼트"], "characteristics": "플랜지 전용 볼트", "applications": "플랜지 체결 전용", "head_type": "HEXAGON" }, + "PSV_BOLT": { + "dat_file_patterns": ["PSV_BOLT", "PSV_BLT"], + "description_keywords": ["PSV", "PRESSURE SAFETY VALVE BOLT"], + "characteristics": "압력안전밸브용 특수 볼트", + "applications": "PSV 체결 전용", + "head_type": "HEXAGON", + "special_application": "PSV" + }, + + "LT_BOLT": { + "dat_file_patterns": ["LT_BOLT", "LT_BLT"], + "description_keywords": ["LT", "LOW TEMP", "저온용"], + "characteristics": "저온용 특수 볼트", + "applications": "저온 환경 체결용", + "head_type": "HEXAGON", + "special_application": "LT" + }, + + "CK_BOLT": { + "dat_file_patterns": ["CK_BOLT", "CK_BLT", "CHECK_BOLT"], + "description_keywords": ["CK", "CHECK VALVE BOLT"], + "characteristics": "체크밸브용 특수 볼트", + "applications": "체크밸브 체결 전용", + "head_type": "HEXAGON", + "special_application": "CK" + }, + + "ORI_BOLT": { + "dat_file_patterns": ["ORI_BOLT", "ORI_BLT", "ORIFICE_BOLT"], + "description_keywords": ["ORI", "ORIFICE", "오리피스"], + "characteristics": "오리피스용 특수 볼트", + "applications": "오리피스 체결 전용", + "head_type": "HEXAGON", + "special_application": "ORI" + }, + "MACHINE_SCREW": { "dat_file_patterns": ["MACH_SCR", "M_SCR"], "description_keywords": ["MACHINE SCREW", "머신스크류", "기계나사"], @@ -272,11 +332,17 @@ THREAD_STANDARDS = { }, "INCH": { - "patterns": [r"(\d+(?:/\d+)?)\s*[\"\']\s*UNC", r"(\d+(?:/\d+)?)\s*[\"\']\s*UNF", - r"(\d+(?:/\d+)?)\s*[\"\']-(\d+)"], + "patterns": [ + r"(\d+(?:/\d+)?)\s*[\"\']\s*UNC", # 1/2" UNC + r"(\d+(?:/\d+)?)\s*[\"\']\s*UNF", # 1/2" UNF + r"(\d+(?:/\d+)?)\s*[\"\']-(\d+)", # 1/2"-13 + r"(\d+\.\d+)", # 0.625 (소수점 인치) + r"(\d+(?:/\d+)?)\s*INCH", # 1/2 INCH + r"(\d+(?:/\d+)?)\s*IN" # 1/2 IN + ], "description": "인치 나사", "thread_types": ["UNC", "UNF"], - "common_sizes": ["1/4\"", "5/16\"", "3/8\"", "1/2\"", "5/8\"", "3/4\"", "7/8\"", "1\""] + "common_sizes": ["1/4\"", "5/16\"", "3/8\"", "1/2\"", "5/8\"", "0.625\"", "3/4\"", "7/8\"", "1\""] }, "BSW": { @@ -302,6 +368,203 @@ BOLT_GRADES = { } } +def convert_decimal_to_fraction(decimal_str: str) -> str: + """소수점 인치를 분수로 변환 (현장 표준)""" + + try: + decimal = float(decimal_str) + + # 일반적인 인치 분수 변환표 + inch_fractions = { + 0.125: "1/8", + 0.1875: "3/16", + 0.25: "1/4", + 0.3125: "5/16", + 0.375: "3/8", + 0.4375: "7/16", + 0.5: "1/2", + 0.5625: "9/16", + 0.625: "5/8", + 0.6875: "11/16", + 0.75: "3/4", + 0.8125: "13/16", + 0.875: "7/8", + 0.9375: "15/16", + 1.0: "1", + 1.125: "1-1/8", + 1.25: "1-1/4", + 1.375: "1-3/8", + 1.5: "1-1/2", + 1.625: "1-5/8", + 1.75: "1-3/4", + 1.875: "1-7/8", + 2.0: "2" + } + + # 정확한 매칭 (소수점 오차 고려) + for dec_val, fraction in inch_fractions.items(): + if abs(decimal - dec_val) < 0.001: # 1mm 오차 허용 + return fraction + + # 정확한 매칭이 없으면 가장 가까운 값 찾기 + closest_decimal = min(inch_fractions.keys(), key=lambda x: abs(x - decimal)) + if abs(closest_decimal - decimal) < 0.0625: # 1/16" 이내 오차만 허용 + return inch_fractions[closest_decimal] + + # 변환할 수 없으면 원래 값 반환 + return str(decimal) + + except ValueError: + return decimal_str + +def classify_surface_treatment(description: str) -> Dict: + """볼트 표면처리 분류 (아연도금, 스테인리스 등)""" + + desc_upper = description.upper() + treatments = [] + + # 전기아연도금 + if any(keyword in desc_upper for keyword in ["ELEC.GALV", "ELEC GALV", "ELECTRO GALV", "전기아연도금"]): + treatments.append({ + "type": "ELECTRO_GALVANIZING", + "description": "전기아연도금", + "code": "ELEC.GALV", + "corrosion_resistance": "보통" + }) + + # 용융아연도금 + if any(keyword in desc_upper for keyword in ["HOT DIP GALV", "HDG", "용융아연도금"]): + treatments.append({ + "type": "HOT_DIP_GALVANIZING", + "description": "용융아연도금", + "code": "HDG", + "corrosion_resistance": "높음" + }) + + # 스테인리스 (표면처리 불필요) + if any(keyword in desc_upper for keyword in ["STAINLESS", "STS", "스테인리스"]): + treatments.append({ + "type": "STAINLESS_STEEL", + "description": "스테인리스강", + "code": "STS", + "corrosion_resistance": "매우높음" + }) + + # 니켈도금 + if any(keyword in desc_upper for keyword in ["NICKEL", "NI PLATING", "니켈도금"]): + treatments.append({ + "type": "NICKEL_PLATING", + "description": "니켈도금", + "code": "NI", + "corrosion_resistance": "높음" + }) + + # 크롬도금 + if any(keyword in desc_upper for keyword in ["CHROME", "CR PLATING", "크롬도금"]): + treatments.append({ + "type": "CHROME_PLATING", + "description": "크롬도금", + "code": "CR", + "corrosion_resistance": "매우높음" + }) + + return { + "treatments": treatments, + "has_treatment": len(treatments) > 0, + "treatment_count": len(treatments), + "primary_treatment": treatments[0] if treatments else None + } + +def classify_special_application_bolts(description: str) -> Dict: + """ + 특수 용도 볼트 분류 및 카운팅 (PSV, LT, CK) + + 주의: 이 함수는 이미 BOLT로 분류된 아이템에서만 호출되어야 함 + PSV, LT, CK는 해당 장비용 볼트를 의미하며, 장비 자체가 아님 + """ + + desc_upper = description.upper() + special_applications = [] + special_details = {} + + # PSV 볼트 확인 (압력안전밸브용 볼트) + psv_patterns = [ + r'\bPSV\b', # 단어 경계로 PSV만 + r'PRESSURE\s+SAFETY\s+VALVE', + r'압력안전밸브', + r'PSV\s+BOLT', + r'PSV\s+BLT' + ] + + import re + if any(re.search(pattern, desc_upper) for pattern in psv_patterns): + special_applications.append("PSV") + special_details["PSV"] = { + "type": "압력안전밸브용 볼트", + "application": "PSV 체결 전용", + "critical": True # 안전 장비용으로 중요 + } + + # LT 볼트 확인 (저온용 볼트) + lt_patterns = [ + r'\bLT\b', # 단어 경계로 LT만 + r'LOW\s+TEMP', + r'저온용', + r'CRYOGENIC', + r'LT\s+BOLT', + r'LT\s+BLT' + ] + + if any(re.search(pattern, desc_upper) for pattern in lt_patterns): + special_applications.append("LT") + special_details["LT"] = { + "type": "저온용 볼트", + "application": "저온 환경 체결용", + "critical": True # 저온 환경용으로 중요 + } + + # CK 볼트 확인 (체크밸브용 볼트) + ck_patterns = [ + r'\bCK\b', # 단어 경계로 CK만 + r'CHECK\s+VALVE', + r'체크밸브', + r'CK\s+BOLT', + r'CK\s+BLT' + ] + + if any(re.search(pattern, desc_upper) for pattern in ck_patterns): + special_applications.append("CK") + special_details["CK"] = { + "type": "체크밸브용 볼트", + "application": "체크밸브 체결 전용", + "critical": False # 일반적 + } + + # ORI 볼트 확인 (오리피스용 볼트) + ori_patterns = [ + r'\bORI\b', # 단어 경계로 ORI만 + r'ORIFICE', + r'오리피스', + r'ORI\s+BOLT', + r'ORI\s+BLT' + ] + + if any(re.search(pattern, desc_upper) for pattern in ori_patterns): + special_applications.append("ORI") + special_details["ORI"] = { + "type": "오리피스용 볼트", + "application": "오리피스 체결 전용", + "critical": True # 유량 측정용으로 중요 + } + + return { + "detected_applications": special_applications, + "special_details": special_details, + "is_special_bolt": len(special_applications) > 0, + "special_count": len(special_applications), + "classification_note": "특수 장비용 볼트 (장비 자체 아님)" + } + def classify_bolt(dat_file: str, description: str, main_nom: str, length: Optional[float] = None) -> Dict: """ 완전한 BOLT 분류 @@ -337,7 +600,13 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option # 6. 등급 및 강도 분류 grade_result = classify_bolt_grade(description, thread_result) - # 7. 최종 결과 조합 + # 7. 특수 용도 볼트 분류 (PSV, LT, CK) + special_result = classify_special_application_bolts(description) + + # 8. 표면처리 분류 (ELEC.GALV 등) + surface_result = classify_surface_treatment(description) + + # 9. 최종 결과 조합 return { "category": "BOLT", @@ -367,6 +636,7 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option "thread_specification": { "standard": thread_result.get('standard', 'UNKNOWN'), "size": thread_result.get('size', ''), + "size_fraction": thread_result.get('size_fraction', ''), "pitch": thread_result.get('pitch', ''), "thread_type": thread_result.get('thread_type', ''), "confidence": thread_result.get('confidence', 0.0) @@ -374,6 +644,7 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option "dimensions": { "nominal_size": dimensions_result.get('nominal_size', main_nom), + "nominal_size_fraction": dimensions_result.get('nominal_size_fraction', main_nom), "length": dimensions_result.get('length', ''), "diameter": dimensions_result.get('diameter', ''), "dimension_description": dimensions_result.get('dimension_description', '') @@ -386,6 +657,23 @@ def classify_bolt(dat_file: str, description: str, main_nom: str, length: Option "confidence": grade_result.get('confidence', 0.0) }, + # 특수 용도 볼트 정보 + "special_applications": { + "is_special_bolt": special_result.get('is_special_bolt', False), + "detected_applications": special_result.get('detected_applications', []), + "special_details": special_result.get('special_details', {}), + "special_count": special_result.get('special_count', 0), + "classification_note": special_result.get('classification_note', '') + }, + + # 표면처리 정보 + "surface_treatment": { + "has_treatment": surface_result.get('has_treatment', False), + "treatments": surface_result.get('treatments', []), + "treatment_count": surface_result.get('treatment_count', 0), + "primary_treatment": surface_result.get('primary_treatment', None) + }, + # 전체 신뢰도 "overall_confidence": calculate_bolt_confidence({ "material": material_result.get('confidence', 0), @@ -536,9 +824,19 @@ def classify_thread_specification(main_nom: str, description: str) -> Dict: thread_type = t_type break + # 인치 사이즈를 분수로 변환 + size_fraction = size + if standard == "INCH": + try: + if '.' in size and size.replace('.', '').isdigit(): + size_fraction = convert_decimal_to_fraction(size).replace('"', '') + except: + size_fraction = size + return { "standard": standard, - "size": size, + "size": size, # 원래 값 + "size_fraction": size_fraction, # 분수 변환값 "pitch": pitch, "thread_type": thread_type, "confidence": 0.9, @@ -559,12 +857,62 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict: """볼트 치수 정보 추출""" desc_upper = description.upper() + actual_bolt_size = main_nom + + # 실제 BOM 형태: "ORI, 0.75, 145.0000 LG, 300LB, ASTM A193/A194 GR B7/2H, ELEC.GALV" + # 첫 번째 숫자가 실제 볼트 사이즈 (접두사 건너뛰기) + import re + # 설명에서 첫 번째 숫자 추출 (볼트 사이즈) + first_number_match = re.search(r'(\d+(?:\.\d+)?)', description) + if first_number_match: + actual_bolt_size = first_number_match.group(1) + + # 플랜지 볼트의 경우 실제 볼트 직경을 description에서 추출 + if "FLANGE BOLT" in desc_upper or "FLG_BOLT" in desc_upper: + # 플랜지 볼트에서 실제 볼트 사이즈 패턴 찾기 + # 예: "FLANGE BOLT 6" 150LB M16" → M16 + # 예: "FLANGE BOLT 1-1/2" 5/8" x 100mm" → 5/8 + + bolt_size_patterns = [ + r'M(\d+)', # M16, M20 등 메트릭 + r'(\d+-\d+/\d+)\s*["\']?\s*X', # 1-1/2" X 등 (복합 분수) + r'(\d+/\d+)\s*["\']?\s*X', # 5/8" X, 3/4" X 등 (단순 분수) + r'(\d+(?:\.\d+)?)\s*["\']?\s*X', # 0.625" X 등 (소수) + r'(\d+-\d+/\d+)\s*["\']?\s*DIA', # 1-1/2" DIA 등 (복합 분수) + r'(\d+/\d+)\s*["\']?\s*DIA', # 5/8" DIA 등 (단순 분수) + r'(\d+(?:\.\d+)?)\s*["\']?\s*DIA', # 0.625" DIA 등 (소수) + r'(\d+-\d+/\d+)\s*["\']?\s*(?:LONG|LG|LENGTH)', # 복합 분수 + 길이 + r'(\d+/\d+)\s*["\']?\s*(?:LONG|LG|LENGTH)', # 단순 분수 + 길이 + r'(\d+(?:\.\d+)?)\s*["\']?\s*(?:LONG|LG|LENGTH)', # 소수 + 길이 + r'(\d+(?:\.\d+)?)\s*MM\s*DIA', # 16MM DIA 등 + ] + + for pattern in bolt_size_patterns: + match = re.search(pattern, desc_upper) + if match: + extracted_size = match.group(1) + # M16 같은 메트릭은 M 제거 + if pattern.startswith(r'M'): + actual_bolt_size = extracted_size + else: + actual_bolt_size = extracted_size + break + + # 볼트 사이즈를 분수로 변환 (인치인 경우) + nominal_size_fraction = actual_bolt_size + try: + # 소수점 인치를 분수로 변환 + if '.' in actual_bolt_size and actual_bolt_size.replace('.', '').isdigit(): + nominal_size_fraction = convert_decimal_to_fraction(actual_bolt_size) + except: + nominal_size_fraction = actual_bolt_size dimensions = { - "nominal_size": main_nom, + "nominal_size": actual_bolt_size, # 실제 볼트 사이즈 + "nominal_size_fraction": nominal_size_fraction, # 분수 변환값 "length": "", "diameter": "", - "dimension_description": main_nom + "dimension_description": nominal_size_fraction # 분수로 표시 } # 길이 정보 추출 @@ -595,8 +943,8 @@ def extract_bolt_dimensions(main_nom: str, description: str) -> Dict: dimensions["diameter"] = f"{match.group(1)}mm" break - # 치수 설명 조합 - desc_parts = [main_nom] + # 치수 설명 조합 (분수 사용) + desc_parts = [nominal_size_fraction] if dimensions["length"]: desc_parts.append(f"L{dimensions['length']}") diff --git a/backend/app/services/integrated_classifier.py b/backend/app/services/integrated_classifier.py index 7ed2850..547ca97 100644 --- a/backend/app/services/integrated_classifier.py +++ b/backend/app/services/integrated_classifier.py @@ -8,7 +8,7 @@ from typing import Dict, List, Optional, Tuple # Level 1: 명확한 타입 키워드 (최우선) LEVEL1_TYPE_KEYWORDS = { - "BOLT": ["BOLT", "STUD", "NUT", "SCREW", "WASHER", "볼트", "너트", "스터드", "나사", "와셔"], + "BOLT": ["FLANGE BOLT", "BOLT", "STUD", "NUT", "SCREW", "WASHER", "볼트", "너트", "스터드", "나사", "와셔"], "VALVE": ["VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE", "RELIEF", "밸브", "게이트", "볼", "글로브", "체크", "버터플라이", "니들", "릴리프"], "FLANGE": ["FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE", "SPACER", "BLIND"], "PIPE": ["PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"], @@ -87,7 +87,9 @@ def classify_material_integrated(description: str, main_nom: str = "", detected_types = [] for material_type, keywords in LEVEL1_TYPE_KEYWORDS.items(): type_found = False - for keyword in keywords: + # 긴 키워드부터 확인 (FLANGE BOLT가 FLANGE보다 먼저 매칭되도록) + sorted_keywords = sorted(keywords, key=len, reverse=True) + for keyword in sorted_keywords: # 전체 문자열에서 찾기 if keyword in desc_upper: detected_types.append((material_type, keyword)) @@ -117,8 +119,8 @@ def classify_material_integrated(description: str, main_nom: str = "", } # Level 2 키워드가 없으면 우선순위로 결정 - # FITTING > VALVE > FLANGE > PIPE > BOLT (더 구체적인 것 우선) - type_priority = ["FITTING", "VALVE", "FLANGE", "PIPE", "BOLT", "GASKET", "INSTRUMENT"] + # BOLT > FITTING > VALVE > FLANGE > PIPE (볼트 우선, 더 구체적인 것 우선) + type_priority = ["BOLT", "FITTING", "VALVE", "FLANGE", "PIPE", "GASKET", "INSTRUMENT"] for priority_type in type_priority: for detected_type, keyword in detected_types: if detected_type == priority_type: diff --git a/backend/app/services/purchase_calculator.py b/backend/app/services/purchase_calculator.py index 93d7fea..0409669 100644 --- a/backend/app/services/purchase_calculator.py +++ b/backend/app/services/purchase_calculator.py @@ -224,6 +224,9 @@ def generate_purchase_items_from_materials(db: Session, file_id: int, 'specification': spec_data.get('full_spec', spec_key), 'material_spec': spec_data.get('material_spec', ''), 'size_spec': spec_data.get('size_display', ''), + 'size_fraction': spec_data.get('size_fraction', ''), + 'surface_treatment': spec_data.get('surface_treatment', ''), + 'special_applications': spec_data.get('special_applications', {}), 'unit': spec_data.get('unit', 'EA'), **calc_result, 'job_no': job_no, @@ -310,16 +313,46 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) - diameter = material.get('diameter', material.get('main_nom', '')) material_spec = material_standard or material.get('material_grade', '') + # 분수 사이즈 정보 추출 (새로 추가된 분류기 정보) + size_fraction = material.get('size_fraction', diameter) + surface_treatment = material.get('surface_treatment', '') + + # 특수 용도 정보 추출 (PSV, LT, CK) + special_applications = { + 'PSV': 0, + 'LT': 0, + 'CK': 0 + } + + # 설명에서 특수 용도 키워드 확인 (간단한 방법) + description = material.get('original_description', '').upper() + if 'PSV' in description or 'PRESSURE SAFETY VALVE' in description: + special_applications['PSV'] = material.get('quantity', 0) + if any(keyword in description for keyword in ['LT', 'LOW TEMP', '저온용']): + special_applications['LT'] = material.get('quantity', 0) + if 'CK' in description or 'CHECK VALVE' in description: + special_applications['CK'] = material.get('quantity', 0) + spec_parts = [bolt_type.replace('_', ' ')] if material_standard: spec_parts.append(material_standard) full_spec = ', '.join(spec_parts) - spec_key = f"BOLT|{full_spec}|{material_spec}|{diameter}" + # 특수 용도와 관계없이 사이즈+길이로 합산 (구매는 동일하므로) + # 길이 정보가 있으면 포함 + length_info = material.get('length', '') + if length_info: + diameter_key = f"{diameter}L{length_info}" + else: + diameter_key = diameter + + spec_key = f"BOLT|{full_spec}|{material_spec}|{diameter_key}" spec_data = { 'category': 'BOLT', 'full_spec': full_spec, 'material_spec': material_spec, 'size_display': diameter, + 'size_fraction': size_fraction, + 'surface_treatment': surface_treatment, 'unit': 'EA' } @@ -378,12 +411,18 @@ def generate_material_specs_for_category(materials: List[Dict], category: str) - **spec_data, 'totalQuantity': 0, 'count': 0, - 'items': [] + 'items': [], + 'special_applications': {'PSV': 0, 'LT': 0, 'CK': 0} if category == 'BOLT' else None } specs[spec_key]['totalQuantity'] += material.get('quantity', 0) specs[spec_key]['count'] += 1 specs[spec_key]['items'].append(material) + + # 볼트의 경우 특수 용도 정보 누적 + if category == 'BOLT' and 'special_applications' in locals(): + for app_type, count in special_applications.items(): + specs[spec_key]['special_applications'][app_type] += count return specs diff --git a/frontend/src/pages/MaterialsPage.jsx b/frontend/src/pages/MaterialsPage.jsx index 685c813..7d7095a 100644 --- a/frontend/src/pages/MaterialsPage.jsx +++ b/frontend/src/pages/MaterialsPage.jsx @@ -363,18 +363,53 @@ const MaterialsPage = () => { isLength: false }; } else if (category === 'BOLT') { - // 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 diameter = material.bolt_details?.nominal_size || + 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 || ''; + // 분수 사이즈 변환 (0.625 → 5/8, " 기호 제거) + const convertToFraction = (decimal) => { + const fractions = { + '0.125': '1/8', '0.1875': '3/16', '0.25': '1/4', '0.3125': '5/16', + '0.375': '3/8', '0.4375': '7/16', '0.5': '1/2', '0.5625': '9/16', + '0.625': '5/8', '0.6875': '11/16', '0.75': '3/4', '0.8125': '13/16', + '0.875': '7/8', '0.9375': '15/16', '1.0': '1', '1.125': '1-1/8', + '1.25': '1-1/4', '1.375': '1-3/8', '1.5': '1-1/2', '1.625': '1-5/8', + '1.75': '1-3/4', '1.875': '1-7/8', '2.0': '2' + }; + return fractions[decimal] || decimal; + }; + + const size_fraction = convertToFraction(diameter); + const display_size = size_fraction !== diameter ? size_fraction : diameter; + + // 특수 용도 확인 (PSV, LT, CK, ORI) + const description = material.original_description?.toUpperCase() || ''; + const special_applications = []; + if (description.includes('PSV') || description.includes('PRESSURE SAFETY VALVE')) { + special_applications.push('PSV'); + } + if (description.includes('LT') || description.includes('LOW TEMP') || description.includes('저온용')) { + special_applications.push('LT'); + } + if (description.includes('CK') || description.includes('CHECK VALVE')) { + special_applications.push('CK'); + } + if (description.includes('ORI') || description.includes('ORIFICE') || description.includes('오리피스')) { + special_applications.push('ORI'); + } + // 볼트 스펙 생성 const bolt_spec_parts = []; @@ -393,9 +428,14 @@ const MaterialsPage = () => { bolt_spec_parts.push(material_spec); } - // 나사 규격 (M12, 1/2" 등) + // 나사 규격 (분수 표기로) if (diameter) { - bolt_spec_parts.push(diameter); + bolt_spec_parts.push(display_size); + } + + // 특수 용도 표시 + if (special_applications.length > 0) { + bolt_spec_parts.push(`[${special_applications.join(', ')}용]`); } // 코팅 타입 (ELECTRO_GALVANIZED 등) @@ -405,7 +445,8 @@ const MaterialsPage = () => { const full_bolt_spec = bolt_spec_parts.join(', '); - specKey = `${category}|${full_bolt_spec}|${diameter}|${length}|${coating_type}|${pressure_rating}`; + // 특수 용도와 관계없이 사이즈+길이로 합산 (구매는 동일하므로) + specKey = `${category}|${full_bolt_spec}|${diameter}|${length}|${coating_type}`; specData = { category: 'BOLT', bolt_type, @@ -414,10 +455,13 @@ const MaterialsPage = () => { material_standard, material_grade, diameter, + size_fraction, + display_size, length, coating_type, pressure_rating, - size_display: diameter, + special_applications, + size_display: display_size, main_nom: diameter, unit: 'EA', isLength: false @@ -840,8 +884,8 @@ const MaterialsPage = () => { 재질 사이즈 길이 + 특수용도 코팅 - 압력등급 수량 )} @@ -966,8 +1010,8 @@ const MaterialsPage = () => { - - {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.display_size || spec.size_display || 'Unknown'} @@ -976,13 +1020,32 @@ const MaterialsPage = () => { - - {spec.coating_type ? spec.coating_type.replace('_', ' ') : 'N/A'} - + {spec.special_applications && spec.special_applications.length > 0 ? ( + + {spec.special_applications.map((app) => ( + + ))} + + ) : ( + + 일반 + + )} - {spec.pressure_rating || 'N/A'} + {spec.coating_type ? spec.coating_type.replace('_', ' ') : 'N/A'} diff --git a/frontend/src/pages/PurchaseConfirmationPage.jsx b/frontend/src/pages/PurchaseConfirmationPage.jsx index fe6c9df..f28bdc6 100644 --- a/frontend/src/pages/PurchaseConfirmationPage.jsx +++ b/frontend/src/pages/PurchaseConfirmationPage.jsx @@ -159,6 +159,60 @@ const PurchaseConfirmationPage = () => { ); }; + const formatBoltInfo = (item) => { + if (item.category !== 'BOLT') return null; + + // 특수 용도 볼트 정보 (백엔드에서 제공되어야 함) + const specialApplications = item.special_applications || {}; + const psvCount = specialApplications.PSV || 0; + const ltCount = specialApplications.LT || 0; + const ckCount = specialApplications.CK || 0; + const oriCount = specialApplications.ORI || 0; + + return ( + + + 분수 사이즈: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} | + 표면처리: {item.surface_treatment || '없음'} + + + {/* 특수 용도 볼트 정보 */} + + + 특수 용도 볼트 현황: + + + + 0 ? "error.main" : "textSecondary"}> + PSV용: {psvCount}개 + + + + 0 ? "warning.main" : "textSecondary"}> + 저온용: {ltCount}개 + + + + 0 ? "info.main" : "textSecondary"}> + 체크밸브용: {ckCount}개 + + + + 0 ? "secondary.main" : "textSecondary"}> + 오리피스용: {oriCount}개 + + + + {(psvCount + ltCount + ckCount + oriCount) === 0 && ( + + 특수 용도 볼트 없음 (일반 볼트만 포함) + + )} + + + ); + }; + return ( {/* 헤더 */} @@ -235,6 +289,7 @@ const PurchaseConfirmationPage = () => { {item.bom_quantity} {item.unit} {formatPipeInfo(item)} + {formatBoltInfo(item)} {/* 구매 수량 */} diff --git a/test_bolt_display.csv b/test_bolt_display.csv new file mode 100644 index 0000000..0a277af --- /dev/null +++ b/test_bolt_display.csv @@ -0,0 +1,5 @@ +DAT_FILE,DESCRIPTION,MAIN_NOM,RED_NOM,LENGTH,QUANTITY,UNIT +BOLT_HEX,0.625 HEX BOLT 100.0000 LG PSV ASTM A193/A194 GR B7/2H ELEC.GALV,0.625,,100,10,EA +BOLT_STUD,0.5 STUD BOLT 75.0000 LG LT ASTM A320 GR L7,0.5,,75,8,EA +BOLT_HEX,0.75 HEX BOLT 120.0000 LG CK ASTM A193 GR B8,0.75,,120,6,EA +BOLT_HEX,0.625 HEX BOLT 100.0000 LG ASTM A193/A194 GR B7/2H ELEC.GALV,0.625,,100,12,EA \ No newline at end of file diff --git a/test_flange_bolt.csv b/test_flange_bolt.csv new file mode 100644 index 0000000..e419231 --- /dev/null +++ b/test_flange_bolt.csv @@ -0,0 +1,6 @@ +DAT_FILE,DESCRIPTION,MAIN_NOM,RED_NOM,LENGTH,QUANTITY,UNIT +FLANGE_BOLT,FLANGE BOLT 6" 300LB M16 X 80MM ASTM A193 B7 ELECTRO GALVANIZED,6,,80,20,EA +FLANGE_BOLT,FLANGE BOLT 1-1/2" 150LB 5/8" X 100MM PSV ASTM A193 B7 ELECTRO GALVANIZED,1.5,,100,8,EA +FLANGE_BOLT,FLANGE BOLT 8" 150LB 3/4" X 130MM ASTM A193 B7 ELECTRO GALVANIZED,8,,130,12,EA +FLANGE_BOLT,FLANGE BOLT 4" 150LB 0.625" X 120MM ASTM A193 B7 ELECTRO GALVANIZED,4,,120,15,EA +FLANGE_BOLT,FLANGE BOLT 2" 300LB 1-1/2" X 180MM ASTM A193 B7 ELECTRO GALVANIZED,2,,180,5,EA \ No newline at end of file