- 실제 볼트 사이즈 추출: 설명의 첫 번째 숫자를 실제 볼트 직경으로 사용 - 분수 표기 변환: 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:
23
RULES.md
23
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. 커밋 메시지**
|
||||
```
|
||||
한국어로 작성 (사용자 선호사항)
|
||||
예: "파이프 길이 계산 및 엑셀 내보내기 버그 수정"
|
||||
|
||||
@@ -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:
|
||||
# 원본 설명에서 길이 추출
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
5
test_bolt_display.csv
Normal 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
|
||||
|
6
test_flange_bolt.csv
Normal file
6
test_flange_bolt.csv
Normal 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.
|
Reference in New Issue
Block a user