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