볼트 분류 기능 대폭 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Failing after 2m11s

- 실제 볼트 사이즈 추출: 설명의 첫 번째 숫자를 실제 볼트 직경으로 사용
- 분수 표기 변환: 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 기호 제거: 깔끔한 분수 표시로 현장 가독성 향상
This commit is contained in:
Hyungi Ahn
2025-07-29 14:34:33 +09:00
parent fc925974bb
commit 48f8f634d1
9 changed files with 574 additions and 35 deletions

View File

@@ -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. 커밋 메시지**
```
한국어로 작성 (사용자 선호사항)
예: "파이프 길이 계산 및 엑셀 내보내기 버그 수정"

View File

@@ -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:
# 원본 설명에서 길이 추출

View File

@@ -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']}")

View File

@@ -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:

View File

@@ -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

View File

@@ -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 = () => {
<TableCell><strong>재질</strong></TableCell>
<TableCell><strong>사이즈</strong></TableCell>
<TableCell><strong>길이</strong></TableCell>
<TableCell><strong>특수용도</strong></TableCell>
<TableCell><strong>코팅</strong></TableCell>
<TableCell><strong>압력등급</strong></TableCell>
<TableCell align="right"><strong>수량</strong></TableCell>
</>
)}
@@ -966,8 +1010,8 @@ const MaterialsPage = () => {
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{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'}
<Typography variant="body2" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
{spec.display_size || spec.size_display || 'Unknown'}
</Typography>
</TableCell>
<TableCell>
@@ -976,13 +1020,32 @@ const MaterialsPage = () => {
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{spec.coating_type ? spec.coating_type.replace('_', ' ') : 'N/A'}
</Typography>
{spec.special_applications && spec.special_applications.length > 0 ? (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{spec.special_applications.map((app) => (
<Chip
key={app}
label={app}
size="small"
color={
app === 'PSV' ? 'error' :
app === 'LT' ? 'warning' :
app === 'CK' ? 'info' :
app === 'ORI' ? 'secondary' : 'default'
}
variant="outlined"
/>
))}
</Box>
) : (
<Typography variant="body2" color="success.main" sx={{ fontStyle: 'italic' }}>
일반
</Typography>
)}
</TableCell>
<TableCell>
<Typography variant="body2">
{spec.pressure_rating || 'N/A'}
{spec.coating_type ? spec.coating_type.replace('_', ' ') : 'N/A'}
</Typography>
</TableCell>
<TableCell align="right">

View File

@@ -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 (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="textSecondary">
분수 사이즈: {(item.size_fraction || item.size_spec || '').replace(/"/g, '')} |
표면처리: {item.surface_treatment || '없음'}
</Typography>
{/* 특수 용도 볼트 정보 */}
<Box sx={{ mt: 1, p: 1, bgcolor: 'info.50', borderRadius: 1 }}>
<Typography variant="caption" fontWeight="bold" color="info.main">
특수 용도 볼트 현황:
</Typography>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid item xs={3}>
<Typography variant="caption" color={psvCount > 0 ? "error.main" : "textSecondary"}>
PSV용: {psvCount}
</Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="caption" color={ltCount > 0 ? "warning.main" : "textSecondary"}>
저온용: {ltCount}
</Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="caption" color={ckCount > 0 ? "info.main" : "textSecondary"}>
체크밸브용: {ckCount}
</Typography>
</Grid>
<Grid item xs={3}>
<Typography variant="caption" color={oriCount > 0 ? "secondary.main" : "textSecondary"}>
오리피스용: {oriCount}
</Typography>
</Grid>
</Grid>
{(psvCount + ltCount + ckCount + oriCount) === 0 && (
<Typography variant="caption" color="success.main" sx={{ fontStyle: 'italic' }}>
특수 용도 볼트 없음 (일반 볼트만 포함)
</Typography>
)}
</Box>
</Box>
);
};
return (
<Box sx={{ p: 3 }}>
{/* 헤더 */}
@@ -235,6 +289,7 @@ const PurchaseConfirmationPage = () => {
{item.bom_quantity} {item.unit}
</Typography>
{formatPipeInfo(item)}
{formatBoltInfo(item)}
</Grid>
{/* 구매 수량 */}

5
test_bolt_display.csv Normal file
View File

@@ -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
1 DAT_FILE DESCRIPTION MAIN_NOM RED_NOM LENGTH QUANTITY UNIT
2 BOLT_HEX 0.625 HEX BOLT 100.0000 LG PSV ASTM A193/A194 GR B7/2H ELEC.GALV 0.625 100 10 EA
3 BOLT_STUD 0.5 STUD BOLT 75.0000 LG LT ASTM A320 GR L7 0.5 75 8 EA
4 BOLT_HEX 0.75 HEX BOLT 120.0000 LG CK ASTM A193 GR B8 0.75 120 6 EA
5 BOLT_HEX 0.625 HEX BOLT 100.0000 LG ASTM A193/A194 GR B7/2H ELEC.GALV 0.625 100 12 EA

6
test_flange_bolt.csv Normal file
View File

@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 26.