From 12ecb937419036730a03526a0c2ea443a1e579a0 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 15 Jul 2025 09:43:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=99=84=EC=A0=84=ED=95=9C=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EB=B6=84=EB=A5=98=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(v1.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐ŸŽฏ ์ฃผ์š” ๊ธฐ๋Šฅ: - ์žฌ์งˆ ๋ถ„๋ฅ˜ ๋ชจ๋“ˆ (ASTM/ASME ๊ทœ๊ฒฉ ์ž๋™ ์ธ์‹) - PIPE ๋ถ„๋ฅ˜ ์‹œ์Šคํ…œ (์ œ์กฐ๋ฐฉ๋ฒ•, ๋๊ฐ€๊ณต, ์Šค์ผ€์ค„, ์ ˆ๋‹จ๊ณ„ํš) - FITTING ๋ถ„๋ฅ˜ ์‹œ์Šคํ…œ (10๊ฐ€์ง€ ํƒ€์ž…, ์—ฐ๊ฒฐ๋ฐฉ์‹, ์••๋ ฅ๋“ฑ๊ธ‰) - FLANGE ๋ถ„๋ฅ˜ ์‹œ์Šคํ…œ (SPECIAL/STANDARD ๊ตฌ๋ถ„, ๋ฉด๊ฐ€๊ณต) - ์Šคํ’€ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ (๋„๋ฉด๋ณ„ A,B,C ๋„˜๋ฒ„๋ง, ์—๋ฆฌ์–ด ๊ด€๋ฆฌ) ๐Ÿ“ ์ƒˆ๋กœ ์ถ”๊ฐ€๋œ ํŒŒ์ผ๋“ค: - app/services/materials_schema.py (์žฌ์งˆ ๊ทœ๊ฒฉ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค) - app/services/material_classifier.py (๊ณตํ†ต ์žฌ์งˆ ๋ถ„๋ฅ˜ ์—”์ง„) - app/services/pipe_classifier.py (ํŒŒ์ดํ”„ ์ „์šฉ ๋ถ„๋ฅ˜๊ธฐ) - app/services/fitting_classifier.py (ํ”ผํŒ… ์ „์šฉ ๋ถ„๋ฅ˜๊ธฐ) - app/services/flange_classifier.py (ํ”Œ๋žœ์ง€ ์ „์šฉ ๋ถ„๋ฅ˜๊ธฐ) - app/services/spool_manager_v2.py (์ˆ˜์ •๋œ ์Šคํ’€ ๊ด€๋ฆฌ) - app/services/test_*.py (๊ฐ ์‹œ์Šคํ…œ๋ณ„ ํ…Œ์ŠคํŠธ ํŒŒ์ผ) ๐Ÿ”ง ๊ธฐ์ˆ ์  ํŠน์ง•: - ์ •๊ทœํ‘œํ˜„์‹ ๊ธฐ๋ฐ˜ ํŒจํ„ด ๋งค์นญ - ์‹ ๋ขฐ๋„ ์ ์ˆ˜ ์‹œ์Šคํ…œ (0.0-1.0) - ์ฆ๊ฑฐ ๊ธฐ๋ฐ˜ ๋ถ„๋ฅ˜ (evidence tracking) - ๋ชจ๋“ˆํ™”๋œ ๊ตฌ์กฐ (์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅ) ๐ŸŽฏ ๋ถ„๋ฅ˜ ์ •ํ™•๋„: - ์žฌ์งˆ ๋ถ„๋ฅ˜: 90-95% ์‹ ๋ขฐ๋„ - PIPE ๋ถ„๋ฅ˜: 85-95% ์‹ ๋ขฐ๋„ - FITTING ๋ถ„๋ฅ˜: 85-95% ์‹ ๋ขฐ๋„ - FLANGE ๋ถ„๋ฅ˜: 85-95% ์‹ ๋ขฐ๋„ ๐Ÿ’พ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๋™: - ๋ชจ๋“  ๋ถ„์„ ๊ฒฐ๊ณผ ์ž๋™ ์ €์žฅ - ํ”„๋กœ์ ํŠธ/๋„๋ฉด ์ •๋ณด ์ž๋™ ์—ฐ๊ฒฐ - ์Šคํ’€ ์ •๋ณด ์‚ฌ์šฉ์ž ์ž…๋ ฅ ๋Œ€๊ธฐ ๐Ÿงช ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€: - ์‹ค์ œ BOM ๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ - ์˜ˆ์™ธ ์ผ€์ด์Šค ์ฒ˜๋ฆฌ - 10+ ๊ฐœ ํ…Œ์ŠคํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค Version: v1.0 Date: 2024-07-15 Author: hyungiahn --- backend/app/api/spools.py | 231 +++++++ backend/app/services/__init__.py | 13 + backend/app/services/fitting_classifier.py | 588 ++++++++++++++++++ backend/app/services/flange_classifier.py | 567 +++++++++++++++++ backend/app/services/material_classifier.py | 313 ++++++++++ backend/app/services/materials_schema.py | 525 ++++++++++++++++ backend/app/services/pipe_classifier.py | 337 ++++++++++ backend/app/services/spool_manager.py | 256 ++++++++ backend/app/services/spool_manager_v2.py | 229 +++++++ .../app/services/test_fitting_classifier.py | 184 ++++++ .../app/services/test_flange_classifier.py | 126 ++++ .../app/services/test_material_classifier.py | 53 ++ backend/app/services/test_pipe_classifier.py | 55 ++ backend/database.py | 29 + backend/example_corrected_spool_usage.py | 30 + backend/temp_main_update.py | 5 + 16 files changed, 3541 insertions(+) create mode 100644 backend/app/api/spools.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/fitting_classifier.py create mode 100644 backend/app/services/flange_classifier.py create mode 100644 backend/app/services/material_classifier.py create mode 100644 backend/app/services/materials_schema.py create mode 100644 backend/app/services/pipe_classifier.py create mode 100644 backend/app/services/spool_manager.py create mode 100644 backend/app/services/spool_manager_v2.py create mode 100644 backend/app/services/test_fitting_classifier.py create mode 100644 backend/app/services/test_flange_classifier.py create mode 100644 backend/app/services/test_material_classifier.py create mode 100644 backend/app/services/test_pipe_classifier.py create mode 100644 backend/database.py create mode 100644 backend/example_corrected_spool_usage.py create mode 100644 backend/temp_main_update.py diff --git a/backend/app/api/spools.py b/backend/app/api/spools.py new file mode 100644 index 0000000..a3e9314 --- /dev/null +++ b/backend/app/api/spools.py @@ -0,0 +1,231 @@ +""" +์Šคํ’€ ๊ด€๋ฆฌ API ์—”๋“œํฌ์ธํŠธ +""" + +from fastapi import APIRouter, Depends, HTTPException, Form +from sqlalchemy.orm import Session +from sqlalchemy import text +from typing import List, Optional +from datetime import datetime + +from ..database import get_db +from ..services.spool_manager import SpoolManager, classify_pipe_with_spool + +router = APIRouter() + +@router.get("/") +async def get_spools_info(): + return { + "message": "์Šคํ’€ ๊ด€๋ฆฌ API", + "features": [ + "์Šคํ’€ ์‹๋ณ„์ž ์ƒ์„ฑ", + "์—๋ฆฌ์–ด/์Šคํ’€ ๋„˜๋ฒ„ ๊ด€๋ฆฌ", + "์Šคํ’€๋ณ„ ํŒŒ์ดํ”„ ๊ทธ๋ฃนํ•‘", + "์Šคํ’€ ์œ ํšจ์„ฑ ๊ฒ€์ฆ" + ] + } + +@router.post("/validate-identifier") +async def validate_spool_identifier( + spool_identifier: str = Form(...), + db: Session = Depends(get_db) +): + """์Šคํ’€ ์‹๋ณ„์ž ์œ ํšจ์„ฑ ๊ฒ€์ฆ""" + + spool_manager = SpoolManager() + validation_result = spool_manager.validate_spool_identifier(spool_identifier) + + return { + "spool_identifier": spool_identifier, + "validation": validation_result, + "timestamp": datetime.now().isoformat() + } + +@router.post("/generate-identifier") +async def generate_spool_identifier( + dwg_name: str = Form(...), + area_number: str = Form(...), + spool_number: str = Form(...), + db: Session = Depends(get_db) +): + """์ƒˆ๋กœ์šด ์Šคํ’€ ์‹๋ณ„์ž ์ƒ์„ฑ""" + + try: + spool_manager = SpoolManager() + spool_identifier = spool_manager.generate_spool_identifier( + dwg_name, area_number, spool_number + ) + + # ์ค‘๋ณต ํ™•์ธ + duplicate_check = db.execute( + text("SELECT id FROM spools WHERE spool_identifier = :spool_id"), + {"spool_id": spool_identifier} + ).fetchone() + + return { + "success": True, + "spool_identifier": spool_identifier, + "is_duplicate": bool(duplicate_check), + "components": { + "dwg_name": dwg_name, + "area_number": spool_manager.format_area_number(area_number), + "spool_number": spool_manager.format_spool_number(spool_number) + } + } + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/next-spool-number") +async def get_next_spool_number( + dwg_name: str, + area_number: str, + db: Session = Depends(get_db) +): + """๋‹ค์Œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์Šคํ’€ ๋„˜๋ฒ„ ์ถ”์ฒœ""" + + try: + # ๊ธฐ์กด ์Šคํ’€๋“ค ์กฐํšŒ + existing_spools_query = text(""" + SELECT spool_identifier + FROM spools + WHERE dwg_name = :dwg_name + """) + + result = db.execute(existing_spools_query, {"dwg_name": dwg_name}) + existing_spools = [row[0] for row in result.fetchall()] + + spool_manager = SpoolManager() + next_spool = spool_manager.get_next_spool_number( + dwg_name, area_number, existing_spools + ) + + return { + "dwg_name": dwg_name, + "area_number": spool_manager.format_area_number(area_number), + "next_spool_number": next_spool, + "existing_spools_count": len(existing_spools) + } + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.post("/materials/{material_id}/assign-spool") +async def assign_spool_to_material( + material_id: int, + area_number: str = Form(...), + spool_number: str = Form(...), + db: Session = Depends(get_db) +): + """์ž์žฌ์— ์Šคํ’€ ์ •๋ณด ํ• ๋‹น""" + + try: + # ์ž์žฌ ์ •๋ณด ์กฐํšŒ + material_query = text(""" + SELECT m.id, m.file_id, f.project_id, f.dwg_name + FROM materials m + JOIN files f ON m.file_id = f.id + WHERE m.id = :material_id + """) + + material = db.execute(material_query, {"material_id": material_id}).fetchone() + if not material: + raise HTTPException(status_code=404, detail="์ž์žฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + + # ์Šคํ’€ ์‹๋ณ„์ž ์ƒ์„ฑ + spool_manager = SpoolManager() + area_formatted = spool_manager.format_area_number(area_number) + spool_formatted = spool_manager.format_spool_number(spool_number) + spool_identifier = spool_manager.generate_spool_identifier( + material.dwg_name, area_formatted, spool_formatted + ) + + # ์ž์žฌ ์—…๋ฐ์ดํŠธ + update_query = text(""" + UPDATE materials + SET area_number = :area_number, + spool_number = :spool_number, + spool_identifier = :spool_identifier, + spool_input_required = FALSE, + spool_validated = TRUE, + updated_at = NOW() + WHERE id = :material_id + """) + + db.execute(update_query, { + "material_id": material_id, + "area_number": area_formatted, + "spool_number": spool_formatted, + "spool_identifier": spool_identifier + }) + + # ์Šคํ’€ ํ…Œ์ด๋ธ”์— ๋“ฑ๋ก (์—†๋‹ค๋ฉด) + spool_insert_query = text(""" + INSERT INTO spools (project_id, dwg_name, area_number, spool_number, spool_identifier, created_at) + VALUES (:project_id, :dwg_name, :area_number, :spool_number, :spool_identifier, NOW()) + ON CONFLICT (project_id, dwg_name, area_number, spool_number) DO NOTHING + """) + + db.execute(spool_insert_query, { + "project_id": material.project_id, + "dwg_name": material.dwg_name, + "area_number": area_formatted, + "spool_number": spool_formatted, + "spool_identifier": spool_identifier + }) + + db.commit() + + return { + "success": True, + "material_id": material_id, + "spool_identifier": spool_identifier, + "message": "์Šคํ’€ ์ •๋ณด๊ฐ€ ํ• ๋‹น๋˜์—ˆ์Šต๋‹ˆ๋‹ค" + } + + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"์Šคํ’€ ํ• ๋‹น ์‹คํŒจ: {str(e)}") + +@router.get("/project/{project_id}/spools") +async def get_project_spools( + project_id: int, + db: Session = Depends(get_db) +): + """ํ”„๋กœ์ ํŠธ์˜ ๋ชจ๋“  ์Šคํ’€ ์กฐํšŒ""" + + query = text(""" + SELECT s.*, + COUNT(m.id) as material_count, + SUM(m.quantity) as total_quantity + FROM spools s + LEFT JOIN materials m ON s.spool_identifier = m.spool_identifier + WHERE s.project_id = :project_id + GROUP BY s.id + ORDER BY s.dwg_name, s.area_number, s.spool_number + """) + + result = db.execute(query, {"project_id": project_id}) + spools = result.fetchall() + + return { + "project_id": project_id, + "spools_count": len(spools), + "spools": [ + { + "id": spool.id, + "spool_identifier": spool.spool_identifier, + "dwg_name": spool.dwg_name, + "area_number": spool.area_number, + "spool_number": spool.spool_number, + "material_count": spool.material_count or 0, + "total_quantity": float(spool.total_quantity or 0), + "status": spool.status, + "created_at": spool.created_at + } + for spool in spools + ] + } diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..57d4295 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,13 @@ +""" +์„œ๋น„์Šค ๋ชจ๋“ˆ +""" + +from .material_classifier import classify_material, get_manufacturing_method_from_material +from .materials_schema import MATERIAL_STANDARDS, SPECIAL_MATERIALS + +__all__ = [ + 'classify_material', + 'get_manufacturing_method_from_material', + 'MATERIAL_STANDARDS', + 'SPECIAL_MATERIALS' +] diff --git a/backend/app/services/fitting_classifier.py b/backend/app/services/fitting_classifier.py new file mode 100644 index 0000000..addb852 --- /dev/null +++ b/backend/app/services/fitting_classifier.py @@ -0,0 +1,588 @@ +""" +FITTING ๋ถ„๋ฅ˜ ์‹œ์Šคํ…œ V2 +์žฌ์งˆ ๋ถ„๋ฅ˜ + ํ”ผํŒ… ํŠนํ™” ๋ถ„๋ฅ˜ + ์Šคํ’€ ์‹œ์Šคํ…œ ํ†ตํ•ฉ +""" + +import re +from typing import Dict, List, Optional +from .material_classifier import classify_material, get_manufacturing_method_from_material + +# ========== FITTING ํƒ€์ž…๋ณ„ ๋ถ„๋ฅ˜ (์‹ค์ œ BOM ๊ธฐ๋ฐ˜) ========== +FITTING_TYPES = { + "ELBOW": { + "dat_file_patterns": ["90L_", "45L_", "ELL_", "ELBOW_"], + "description_keywords": ["ELBOW", "ELL", "์—˜๋ณด"], + "subtypes": { + "90DEG": ["90", "90ยฐ", "90DEG", "90๋„"], + "45DEG": ["45", "45ยฐ", "45DEG", "45๋„"], + "LONG_RADIUS": ["LR", "LONG RADIUS", "์žฅ๋ฐ˜๊ฒฝ"], + "SHORT_RADIUS": ["SR", "SHORT RADIUS", "๋‹จ๋ฐ˜๊ฒฝ"] + }, + "default_subtype": "90DEG", + "common_connections": ["BUTT_WELD", "SOCKET_WELD"], + "size_range": "1/2\" ~ 48\"" + }, + + "TEE": { + "dat_file_patterns": ["TEE_", "T_"], + "description_keywords": ["TEE", "ํ‹ฐ"], + "subtypes": { + "EQUAL": ["EQUAL TEE", "๋“ฑ๊ฒฝํ‹ฐ", "EQUAL"], + "REDUCING": ["REDUCING TEE", "RED TEE", "์ถ•์†Œํ‹ฐ", "REDUCING", "RD"] + }, + "size_analysis": True, # RED_NOM์œผ๋กœ REDUCING ์—ฌ๋ถ€ ํŒ๋‹จ + "common_connections": ["BUTT_WELD", "SOCKET_WELD"], + "size_range": "1/2\" ~ 48\"" + }, + + "REDUCER": { + "dat_file_patterns": ["CNC_", "ECC_", "RED_", "REDUCER_"], + "description_keywords": ["REDUCER", "RED", "๋ฆฌ๋“€์„œ"], + "subtypes": { + "CONCENTRIC": ["CONCENTRIC", "CNC", "๋™์‹ฌ", "CON"], + "ECCENTRIC": ["ECCENTRIC", "ECC", "ํŽธ์‹ฌ"] + }, + "requires_two_sizes": True, + "common_connections": ["BUTT_WELD"], + "size_range": "1/2\" ~ 48\"" + }, + + "CAP": { + "dat_file_patterns": ["CAP_"], + "description_keywords": ["CAP", "์บก", "๋ง‰์Œ"], + "subtypes": { + "BUTT_WELD": ["BW", "BUTT WELD"], + "SOCKET_WELD": ["SW", "SOCKET WELD"], + "THREADED": ["THD", "THREADED", "๋‚˜์‚ฌ", "NPT"] + }, + "common_connections": ["BUTT_WELD", "SOCKET_WELD", "THREADED"], + "size_range": "1/4\" ~ 24\"" + }, + + "NIPPLE": { + "dat_file_patterns": ["NIP_", "NIPPLE_"], + "description_keywords": ["NIPPLE", "๋‹ˆํ”Œ"], + "subtypes": { + "THREADED": ["THREADED", "THD", "NPT", "๋‚˜์‚ฌ"], + "SOCKET_WELD": ["SOCKET WELD", "SW", "์†Œ์ผ“์›ฐ๋“œ"], + "CLOSE": ["CLOSE NIPPLE", "CLOSE"], + "SHORT": ["SHORT NIPPLE", "SHORT"], + "LONG": ["LONG NIPPLE", "LONG"] + }, + "common_connections": ["THREADED", "SOCKET_WELD"], + "size_range": "1/8\" ~ 4\"" + }, + + "SWAGE": { + "dat_file_patterns": ["SWG_"], + "description_keywords": ["SWAGE", "์Šค์›จ์ง€"], + "subtypes": { + "CONCENTRIC": ["CONCENTRIC", "CN", "CON", "๋™์‹ฌ"], + "ECCENTRIC": ["ECCENTRIC", "EC", "ECC", "ํŽธ์‹ฌ"] + }, + "requires_two_sizes": True, + "common_connections": ["BUTT_WELD", "SOCKET_WELD"], + "size_range": "1/2\" ~ 12\"" + }, + + "OLET": { + "dat_file_patterns": ["SOL_", "WOL_", "TOL_", "OLET_"], + "description_keywords": ["OLET", "์˜ฌ๋ ›", "O-LET"], + "subtypes": { + "SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL"], + "WELDOLET": ["WELD-O-LET", "WELDOLET", "WOL"], + "THREADOLET": ["THREAD-O-LET", "THREADOLET", "TOL"], + "ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL"] + }, + "requires_two_sizes": True, # ์ฃผ๋ฐฐ๊ด€ x ๋ถ„๊ธฐ๊ด€ + "common_connections": ["SOCKET_WELD", "THREADED", "BUTT_WELD"], + "size_range": "1/8\" ~ 4\"" + }, + + "COUPLING": { + "dat_file_patterns": ["CPL_", "COUPLING_"], + "description_keywords": ["COUPLING", "์ปคํ”Œ๋ง"], + "subtypes": { + "FULL": ["FULL COUPLING", "FULL"], + "HALF": ["HALF COUPLING", "HALF"], + "REDUCING": ["REDUCING COUPLING", "RED"] + }, + "common_connections": ["SOCKET_WELD", "THREADED"], + "size_range": "1/8\" ~ 4\"" + } +} + +# ========== ์—ฐ๊ฒฐ ๋ฐฉ์‹๋ณ„ ๋ถ„๋ฅ˜ ========== +CONNECTION_METHODS = { + "BUTT_WELD": { + "codes": ["BW", "BUTT WELD", "๋งž๋Œ€๊ธฐ์šฉ์ ‘", "BUTT-WELD"], + "dat_patterns": ["_BW"], + "size_range": "1/2\" ~ 48\"", + "pressure_range": "150LB ~ 2500LB", + "typical_manufacturing": "WELDED_FABRICATED", + "confidence": 0.95 + }, + "SOCKET_WELD": { + "codes": ["SW", "SOCKET WELD", "์†Œ์ผ“์›ฐ๋“œ", "SOCKET-WELD"], + "dat_patterns": ["_SW_"], + "size_range": "1/8\" ~ 4\"", + "pressure_range": "150LB ~ 9000LB", + "typical_manufacturing": "FORGED", + "confidence": 0.95 + }, + "THREADED": { + "codes": ["THD", "THRD", "NPT", "THREADED", "๋‚˜์‚ฌ", "TR"], + "dat_patterns": ["_TR", "_THD"], + "size_range": "1/8\" ~ 4\"", + "pressure_range": "150LB ~ 6000LB", + "typical_manufacturing": "FORGED", + "confidence": 0.95 + }, + "FLANGED": { + "codes": ["FL", "FLG", "FLANGED", "ํ”Œ๋žœ์ง€"], + "dat_patterns": ["_FL_"], + "size_range": "1/2\" ~ 48\"", + "pressure_range": "150LB ~ 2500LB", + "typical_manufacturing": "FORGED_OR_CAST", + "confidence": 0.9 + } +} + +# ========== ์••๋ ฅ ๋“ฑ๊ธ‰๋ณ„ ๋ถ„๋ฅ˜ ========== +PRESSURE_RATINGS = { + "patterns": [ + r"(\d+)LB", + r"CLASS\s*(\d+)", + r"CL\s*(\d+)", + r"(\d+)#", + r"(\d+)\s*LB" + ], + "standard_ratings": { + "150LB": {"max_pressure": "285 PSI", "common_use": "์ €์•• ์ผ๋ฐ˜์šฉ"}, + "300LB": {"max_pressure": "740 PSI", "common_use": "์ค‘์••์šฉ"}, + "600LB": {"max_pressure": "1480 PSI", "common_use": "๊ณ ์••์šฉ"}, + "900LB": {"max_pressure": "2220 PSI", "common_use": "๊ณ ์••์šฉ"}, + "1500LB": {"max_pressure": "3705 PSI", "common_use": "๊ณ ์••์šฉ"}, + "2500LB": {"max_pressure": "6170 PSI", "common_use": "์ดˆ๊ณ ์••์šฉ"}, + "3000LB": {"max_pressure": "7400 PSI", "common_use": "์†Œ๊ตฌ๊ฒฝ ๊ณ ์••์šฉ"}, + "6000LB": {"max_pressure": "14800 PSI", "common_use": "์†Œ๊ตฌ๊ฒฝ ์ดˆ๊ณ ์••์šฉ"}, + "9000LB": {"max_pressure": "22200 PSI", "common_use": "์†Œ๊ตฌ๊ฒฝ ๊ทน๊ณ ์••์šฉ"} + } +} + +def classify_fitting(dat_file: str, description: str, main_nom: str, + red_nom: str = None) -> Dict: + """ + ์™„์ „ํ•œ FITTING ๋ถ„๋ฅ˜ + + Args: + dat_file: DAT_FILE ํ•„๋“œ + description: DESCRIPTION ํ•„๋“œ + main_nom: MAIN_NOM ํ•„๋“œ (์ฃผ ์‚ฌ์ด์ฆˆ) + red_nom: RED_NOM ํ•„๋“œ (์ถ•์†Œ ์‚ฌ์ด์ฆˆ, ์„ ํƒ์‚ฌํ•ญ) + + Returns: + ์™„์ „ํ•œ ํ”ผํŒ… ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ + """ + + # 1. ์žฌ์งˆ ๋ถ„๋ฅ˜ (๊ณตํ†ต ๋ชจ๋“ˆ ์‚ฌ์šฉ) + material_result = classify_material(description) + + # 2. ํ”ผํŒ… ํƒ€์ž… ๋ถ„๋ฅ˜ + fitting_type_result = classify_fitting_type(dat_file, description, main_nom, red_nom) + + # 3. ์—ฐ๊ฒฐ ๋ฐฉ์‹ ๋ถ„๋ฅ˜ + connection_result = classify_connection_method(dat_file, description) + + # 4. ์••๋ ฅ ๋“ฑ๊ธ‰ ๋ถ„๋ฅ˜ + pressure_result = classify_pressure_rating(dat_file, description) + + # 5. ์ œ์ž‘ ๋ฐฉ๋ฒ• ์ถ”์ • + manufacturing_result = determine_fitting_manufacturing( + material_result, connection_result, pressure_result, main_nom + ) + + # 6. ์ตœ์ข… ๊ฒฐ๊ณผ ์กฐํ•ฉ + return { + "category": "FITTING", + + # ์žฌ์งˆ ์ •๋ณด (๊ณตํ†ต ๋ชจ๋“ˆ) + "material": { + "standard": material_result.get('standard', 'UNKNOWN'), + "grade": material_result.get('grade', 'UNKNOWN'), + "material_type": material_result.get('material_type', 'UNKNOWN'), + "confidence": material_result.get('confidence', 0.0) + }, + + # ํ”ผํŒ… ํŠนํ™” ์ •๋ณด + "fitting_type": { + "type": fitting_type_result.get('type', 'UNKNOWN'), + "subtype": fitting_type_result.get('subtype', 'UNKNOWN'), + "confidence": fitting_type_result.get('confidence', 0.0), + "evidence": fitting_type_result.get('evidence', []) + }, + + "connection_method": { + "method": connection_result.get('method', 'UNKNOWN'), + "confidence": connection_result.get('confidence', 0.0), + "matched_code": connection_result.get('matched_code', ''), + "size_range": connection_result.get('size_range', ''), + "pressure_range": connection_result.get('pressure_range', '') + }, + + "pressure_rating": { + "rating": pressure_result.get('rating', 'UNKNOWN'), + "confidence": pressure_result.get('confidence', 0.0), + "max_pressure": pressure_result.get('max_pressure', ''), + "common_use": pressure_result.get('common_use', '') + }, + + "manufacturing": { + "method": manufacturing_result.get('method', 'UNKNOWN'), + "confidence": manufacturing_result.get('confidence', 0.0), + "evidence": manufacturing_result.get('evidence', []), + "characteristics": manufacturing_result.get('characteristics', '') + }, + + "size_info": { + "main_size": main_nom, + "reduced_size": red_nom, + "size_description": format_fitting_size(main_nom, red_nom), + "requires_two_sizes": fitting_type_result.get('requires_two_sizes', False) + }, + + # ์ „์ฒด ์‹ ๋ขฐ๋„ + "overall_confidence": calculate_fitting_confidence({ + "material": material_result.get('confidence', 0), + "fitting_type": fitting_type_result.get('confidence', 0), + "connection": connection_result.get('confidence', 0), + "pressure": pressure_result.get('confidence', 0) + }) + } + +def classify_fitting_type(dat_file: str, description: str, + main_nom: str, red_nom: str = None) -> Dict: + """ํ”ผํŒ… ํƒ€์ž… ๋ถ„๋ฅ˜""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + + # 1. DAT_FILE ํŒจํ„ด์œผ๋กœ 1์ฐจ ๋ถ„๋ฅ˜ (๊ฐ€์žฅ ์‹ ๋ขฐ๋„ ๋†’์Œ) + for fitting_type, type_data in FITTING_TYPES.items(): + for pattern in type_data["dat_file_patterns"]: + if pattern in dat_upper: + subtype_result = classify_fitting_subtype( + fitting_type, desc_upper, main_nom, red_nom, type_data + ) + + return { + "type": fitting_type, + "subtype": subtype_result["subtype"], + "confidence": 0.95, + "evidence": [f"DAT_FILE_PATTERN: {pattern}"], + "subtype_confidence": subtype_result["confidence"], + "requires_two_sizes": type_data.get("requires_two_sizes", False) + } + + # 2. DESCRIPTION ํ‚ค์›Œ๋“œ๋กœ 2์ฐจ ๋ถ„๋ฅ˜ + for fitting_type, type_data in FITTING_TYPES.items(): + for keyword in type_data["description_keywords"]: + if keyword in desc_upper: + subtype_result = classify_fitting_subtype( + fitting_type, desc_upper, main_nom, red_nom, type_data + ) + + return { + "type": fitting_type, + "subtype": subtype_result["subtype"], + "confidence": 0.85, + "evidence": [f"DESCRIPTION_KEYWORD: {keyword}"], + "subtype_confidence": subtype_result["confidence"], + "requires_two_sizes": type_data.get("requires_two_sizes", False) + } + + # 3. ๋ถ„๋ฅ˜ ์‹คํŒจ + return { + "type": "UNKNOWN", + "subtype": "UNKNOWN", + "confidence": 0.0, + "evidence": ["NO_FITTING_TYPE_IDENTIFIED"], + "requires_two_sizes": False + } + +def classify_fitting_subtype(fitting_type: str, description: str, + main_nom: str, red_nom: str, type_data: Dict) -> Dict: + """ํ”ผํŒ… ์„œ๋ธŒํƒ€์ž… ๋ถ„๋ฅ˜""" + + subtypes = type_data.get("subtypes", {}) + + # 1. ํ‚ค์›Œ๋“œ ๊ธฐ๋ฐ˜ ์„œ๋ธŒํƒ€์ž… ๋ถ„๋ฅ˜ (์šฐ์„ ) + for subtype, keywords in subtypes.items(): + for keyword in keywords: + if keyword in description: + return { + "subtype": subtype, + "confidence": 0.9, + "evidence": [f"SUBTYPE_KEYWORD: {keyword}"] + } + + # 2. ์‚ฌ์ด์ฆˆ ๋ถ„์„์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ (TEE, REDUCER ๋“ฑ) + if type_data.get("size_analysis"): + if red_nom and red_nom.strip() and red_nom != main_nom: + return { + "subtype": "REDUCING", + "confidence": 0.85, + "evidence": [f"SIZE_ANALYSIS_REDUCING: {main_nom} x {red_nom}"] + } + else: + return { + "subtype": "EQUAL", + "confidence": 0.8, + "evidence": [f"SIZE_ANALYSIS_EQUAL: {main_nom}"] + } + + # 3. ๋‘ ์‚ฌ์ด์ฆˆ๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ ํ™•์ธ + if type_data.get("requires_two_sizes"): + if red_nom and red_nom.strip(): + confidence = 0.8 + evidence = [f"TWO_SIZES_PROVIDED: {main_nom} x {red_nom}"] + else: + confidence = 0.6 + evidence = [f"TWO_SIZES_EXPECTED_BUT_MISSING"] + else: + confidence = 0.7 + evidence = ["SINGLE_SIZE_FITTING"] + + # 4. ๊ธฐ๋ณธ๊ฐ’ + default_subtype = type_data.get("default_subtype", "GENERAL") + return { + "subtype": default_subtype, + "confidence": confidence, + "evidence": evidence + } + +def classify_connection_method(dat_file: str, description: str) -> Dict: + """์—ฐ๊ฒฐ ๋ฐฉ์‹ ๋ถ„๋ฅ˜""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + combined_text = f"{dat_upper} {desc_upper}" + + # 1. DAT_FILE ํŒจํ„ด ์šฐ์„  ํ™•์ธ (๊ฐ€์žฅ ์‹ ๋ขฐ๋„ ๋†’์Œ) + for method, method_data in CONNECTION_METHODS.items(): + for pattern in method_data["dat_patterns"]: + if pattern in dat_upper: + return { + "method": method, + "confidence": 0.95, + "matched_code": pattern, + "source": "DAT_FILE_PATTERN", + "size_range": method_data["size_range"], + "pressure_range": method_data["pressure_range"], + "typical_manufacturing": method_data["typical_manufacturing"] + } + + # 2. ํ‚ค์›Œ๋“œ ํ™•์ธ + for method, method_data in CONNECTION_METHODS.items(): + for code in method_data["codes"]: + if code in combined_text: + return { + "method": method, + "confidence": method_data["confidence"], + "matched_code": code, + "source": "KEYWORD_MATCH", + "size_range": method_data["size_range"], + "pressure_range": method_data["pressure_range"], + "typical_manufacturing": method_data["typical_manufacturing"] + } + + return { + "method": "UNKNOWN", + "confidence": 0.0, + "matched_code": "", + "source": "NO_CONNECTION_METHOD_FOUND" + } + +def classify_pressure_rating(dat_file: str, description: str) -> Dict: + """์••๋ ฅ ๋“ฑ๊ธ‰ ๋ถ„๋ฅ˜""" + + combined_text = f"{dat_file} {description}".upper() + + # ํŒจํ„ด ๋งค์นญ์œผ๋กœ ์••๋ ฅ ๋“ฑ๊ธ‰ ์ถ”์ถœ + for pattern in PRESSURE_RATINGS["patterns"]: + match = re.search(pattern, combined_text) + if match: + rating_num = match.group(1) + rating = f"{rating_num}LB" + + # ํ‘œ์ค€ ๋“ฑ๊ธ‰ ์ •๋ณด ํ™•์ธ + rating_info = PRESSURE_RATINGS["standard_ratings"].get(rating, {}) + + if rating_info: + confidence = 0.95 + else: + confidence = 0.8 + rating_info = {"max_pressure": "ํ™•์ธ ํ•„์š”", "common_use": "๋น„ํ‘œ์ค€ ๋“ฑ๊ธ‰"} + + return { + "rating": rating, + "confidence": confidence, + "matched_pattern": pattern, + "matched_value": rating_num, + "max_pressure": rating_info.get("max_pressure", ""), + "common_use": rating_info.get("common_use", "") + } + + return { + "rating": "UNKNOWN", + "confidence": 0.0, + "matched_pattern": "", + "max_pressure": "", + "common_use": "" + } + +def determine_fitting_manufacturing(material_result: Dict, connection_result: Dict, + pressure_result: Dict, main_nom: str) -> Dict: + """ํ”ผํŒ… ์ œ์ž‘ ๋ฐฉ๋ฒ• ๊ฒฐ์ •""" + + evidence = [] + + # 1. ์žฌ์งˆ ๊ธฐ๋ฐ˜ ์ œ์ž‘๋ฐฉ๋ฒ• (๊ฐ€์žฅ ํ™•์‹ค) + material_manufacturing = get_manufacturing_method_from_material(material_result) + if material_manufacturing in ["FORGED", "CAST"]: + evidence.append(f"MATERIAL_STANDARD: {material_result.get('standard')}") + + characteristics = { + "FORGED": "๊ณ ๊ฐ•๋„, ๊ณ ์••์šฉ, ์†Œ๊ตฌ๊ฒฝ", + "CAST": "๋ณต์žกํ˜•์ƒ, ์ค‘์ €์••์šฉ" + }.get(material_manufacturing, "") + + return { + "method": material_manufacturing, + "confidence": 0.9, + "evidence": evidence, + "characteristics": characteristics + } + + # 2. ์—ฐ๊ฒฐ๋ฐฉ์‹ + ์••๋ ฅ๋“ฑ๊ธ‰ ์กฐํ•ฉ ์ถ”์ • + connection_method = connection_result.get("method", "") + pressure_rating = pressure_result.get("rating", "") + + # ๊ณ ์•• + ์†Œ์ผ“์›ฐ๋“œ/๋‚˜์‚ฌ = ๋‹จ์กฐ + high_pressure = ["3000LB", "6000LB", "9000LB"] + forged_connections = ["SOCKET_WELD", "THREADED"] + + if (any(pressure in pressure_rating for pressure in high_pressure) and + connection_method in forged_connections): + evidence.append(f"HIGH_PRESSURE: {pressure_rating}") + evidence.append(f"FORGED_CONNECTION: {connection_method}") + return { + "method": "FORGED", + "confidence": 0.85, + "evidence": evidence, + "characteristics": "๊ณ ์••์šฉ ๋‹จ์กฐํ’ˆ" + } + + # 3. ์—ฐ๊ฒฐ๋ฐฉ์‹๋ณ„ ์ผ๋ฐ˜์  ์ œ์ž‘๋ฐฉ๋ฒ• + connection_manufacturing = connection_result.get("typical_manufacturing", "") + if connection_manufacturing: + evidence.append(f"CONNECTION_TYPICAL: {connection_method}") + + characteristics_map = { + "FORGED": "๋‹จ์กฐํ’ˆ, ๊ณ ๊ฐ•๋„", + "WELDED_FABRICATED": "์šฉ์ ‘์ œ์ž‘ํ’ˆ, ๋Œ€๊ตฌ๊ฒฝ", + "FORGED_OR_CAST": "๋‹จ์กฐ ๋˜๋Š” ์ฃผ์กฐ" + } + + return { + "method": connection_manufacturing, + "confidence": 0.7, + "evidence": evidence, + "characteristics": characteristics_map.get(connection_manufacturing, "") + } + + # 4. ๊ธฐ๋ณธ ์ถ”์ • + return { + "method": "UNKNOWN", + "confidence": 0.0, + "evidence": ["INSUFFICIENT_MANUFACTURING_INFO"], + "characteristics": "" + } + +def format_fitting_size(main_nom: str, red_nom: str = None) -> str: + """ํ”ผํŒ… ์‚ฌ์ด์ฆˆ ํ‘œ๊ธฐ ํฌ๋งทํŒ…""" + + if red_nom and red_nom.strip() and red_nom != main_nom: + return f"{main_nom} x {red_nom}" + else: + return main_nom + +def calculate_fitting_confidence(confidence_scores: Dict) -> float: + """ํ”ผํŒ… ๋ถ„๋ฅ˜ ์ „์ฒด ์‹ ๋ขฐ๋„ ๊ณ„์‚ฐ""" + + scores = [score for score in confidence_scores.values() if score > 0] + + if not scores: + return 0.0 + + # ๊ฐ€์ค‘ ํ‰๊ท  (ํ”ผํŒ… ํƒ€์ž…์ด ๊ฐ€์žฅ ์ค‘์š”) + weights = { + "material": 0.25, + "fitting_type": 0.4, + "connection": 0.25, + "pressure": 0.1 + } + + weighted_sum = sum( + confidence_scores.get(key, 0) * weight + for key, weight in weights.items() + ) + + return round(weighted_sum, 2) + +# ========== ํŠน์ˆ˜ ๋ถ„๋ฅ˜ ํ•จ์ˆ˜๋“ค ========== + +def is_high_pressure_fitting(pressure_rating: str) -> bool: + """๊ณ ์•• ํ”ผํŒ… ์—ฌ๋ถ€ ํŒ๋‹จ""" + high_pressure_ratings = ["3000LB", "6000LB", "9000LB"] + return pressure_rating in high_pressure_ratings + +def is_small_bore_fitting(main_nom: str) -> bool: + """์†Œ๊ตฌ๊ฒฝ ํ”ผํŒ… ์—ฌ๋ถ€ ํŒ๋‹จ""" + try: + # ๊ฐ„๋‹จํ•œ ์‚ฌ์ด์ฆˆ ํŒŒ์‹ฑ (์ธ์น˜ ๊ธฐ์ค€) + size_num = float(re.findall(r'(\d+(?:\.\d+)?)', main_nom)[0]) + return size_num <= 2.0 + except: + return False + +def get_fitting_purchase_info(fitting_result: Dict) -> Dict: + """ํ”ผํŒ… ๊ตฌ๋งค ์ •๋ณด ์ƒ์„ฑ""" + + fitting_type = fitting_result["fitting_type"]["type"] + connection = fitting_result["connection_method"]["method"] + pressure = fitting_result["pressure_rating"]["rating"] + manufacturing = fitting_result["manufacturing"]["method"] + + # ๊ณต๊ธ‰์—…์ฒด ํƒ€์ž… ๊ฒฐ์ • + if manufacturing == "FORGED": + supplier_type = "๋‹จ์กฐ ํ”ผํŒ… ์ „๋ฌธ์—…์ฒด" + elif manufacturing == "CAST": + supplier_type = "์ฃผ์กฐ ํ”ผํŒ… ์ „๋ฌธ์—…์ฒด" + else: + supplier_type = "์ผ๋ฐ˜ ํ”ผํŒ… ์—…์ฒด" + + # ๋‚ฉ๊ธฐ ์ถ”์ • + if is_high_pressure_fitting(pressure): + lead_time = "6-10์ฃผ (๊ณ ์••์šฉ)" + elif manufacturing == "FORGED": + lead_time = "4-8์ฃผ (๋‹จ์กฐํ’ˆ)" + else: + lead_time = "2-6์ฃผ (์ผ๋ฐ˜ํ’ˆ)" + + return { + "supplier_type": supplier_type, + "lead_time_estimate": lead_time, + "purchase_category": f"{fitting_type} {connection} {pressure}", + "manufacturing_note": fitting_result["manufacturing"]["characteristics"] + } diff --git a/backend/app/services/flange_classifier.py b/backend/app/services/flange_classifier.py new file mode 100644 index 0000000..d139c4f --- /dev/null +++ b/backend/app/services/flange_classifier.py @@ -0,0 +1,567 @@ +""" +FLANGE ๋ถ„๋ฅ˜ ์‹œ์Šคํ…œ +์ผ๋ฐ˜ ํ”Œ๋žœ์ง€ + SPECIAL ํ”Œ๋žœ์ง€ ๋ถ„๋ฅ˜ +""" + +import re +from typing import Dict, List, Optional +from .material_classifier import classify_material, get_manufacturing_method_from_material + +# ========== SPECIAL FLANGE ํƒ€์ž… ========== +SPECIAL_FLANGE_TYPES = { + "ORIFICE": { + "dat_file_patterns": ["FLG_ORI_", "ORI_"], + "description_keywords": ["ORIFICE", "์˜ค๋ฆฌํ”ผ์Šค", "์œ ๋Ÿ‰์ธก์ •"], + "characteristics": "์œ ๋Ÿ‰ ์ธก์ •์šฉ ๊ตฌ๋ฉ", + "special_features": ["BORE_SIZE", "FLOW_MEASUREMENT"] + }, + "SPECTACLE_BLIND": { + "dat_file_patterns": ["FLG_SPB_", "SPB_", "SPEC_"], + "description_keywords": ["SPECTACLE BLIND", "SPECTACLE", "์•ˆ๊ฒฝํ˜•", "SPB"], + "characteristics": "์•ˆ๊ฒฝํ˜• ์ฐจ๋‹จํŒ (์šด์ „/์ •์ง€ ์ „ํ™˜)", + "special_features": ["SWITCHING", "ISOLATION"] + }, + "PADDLE_BLIND": { + "dat_file_patterns": ["FLG_PAD_", "PAD_", "PADDLE_"], + "description_keywords": ["PADDLE BLIND", "PADDLE", "ํŒจ๋“คํ˜•"], + "characteristics": "ํŒจ๋“คํ˜• ์ฐจ๋‹จํŒ", + "special_features": ["PERMANENT_ISOLATION"] + }, + "SPACER": { + "dat_file_patterns": ["FLG_SPC_", "SPC_", "SPACER_"], + "description_keywords": ["SPACER", "์ŠคํŽ˜์ด์„œ", "๊ฑฐ๋ฆฌ์กฐ์ •"], + "characteristics": "๊ฑฐ๋ฆฌ ์กฐ์ •์šฉ", + "special_features": ["SPACING", "THICKNESS"] + }, + "REDUCING": { + "dat_file_patterns": ["FLG_RED_", "RED_FLG"], + "description_keywords": ["REDUCING", "์ถ•์†Œ", "RED"], + "characteristics": "์‚ฌ์ด์ฆˆ ์ถ•์†Œ์šฉ", + "special_features": ["SIZE_CHANGE", "REDUCING"], + "requires_two_sizes": True + }, + "EXPANDER": { + "dat_file_patterns": ["FLG_EXP_", "EXP_"], + "description_keywords": ["EXPANDER", "EXPANDING", "ํ™•๋Œ€"], + "characteristics": "์‚ฌ์ด์ฆˆ ํ™•๋Œ€์šฉ", + "special_features": ["SIZE_CHANGE", "EXPANDING"], + "requires_two_sizes": True + }, + "SWIVEL": { + "dat_file_patterns": ["FLG_SWV_", "SWV_"], + "description_keywords": ["SWIVEL", "ํšŒ์ „", "ROTATING"], + "characteristics": "ํšŒ์ „/๊ฐ๋„ ์กฐ์ •์šฉ", + "special_features": ["ROTATION", "ANGLE_ADJUSTMENT"] + }, + "INSULATION_SET": { + "dat_file_patterns": ["FLG_INS_", "INS_SET"], + "description_keywords": ["INSULATION", "์ ˆ์—ฐ", "ISOLATING", "INS SET"], + "characteristics": "์ ˆ์—ฐ ํ”Œ๋žœ์ง€ ์„ธํŠธ", + "special_features": ["ELECTRICAL_ISOLATION"] + }, + "DRIP_RING": { + "dat_file_patterns": ["FLG_DRP_", "DRP_"], + "description_keywords": ["DRIP RING", "๋“œ๋ฆฝ๋ง", "DRIP"], + "characteristics": "๋“œ๋ฆฝ ๋ง", + "special_features": ["DRIP_PREVENTION"] + }, + "NOZZLE": { + "dat_file_patterns": ["FLG_NOZ_", "NOZ_"], + "description_keywords": ["NOZZLE", "๋…ธ์ฆ", "OUTLET"], + "characteristics": "๋…ธ์ฆ ํ”Œ๋žœ์ง€ (ํŠน์ˆ˜ ํ˜•์ƒ)", + "special_features": ["SPECIAL_SHAPE", "OUTLET"] + } +} + +# ========== ์ผ๋ฐ˜ FLANGE ํƒ€์ž… ========== +STANDARD_FLANGE_TYPES = { + "WELD_NECK": { + "dat_file_patterns": ["FLG_WN_", "WN_", "WELD_NECK"], + "description_keywords": ["WELD NECK", "WN", "์›ฐ๋“œ๋„ฅ"], + "characteristics": "๋ชฉ ๋ถ€๋ถ„ ์šฉ์ ‘ํ˜•", + "size_range": "1/2\" ~ 48\"", + "pressure_range": "150LB ~ 2500LB" + }, + "SLIP_ON": { + "dat_file_patterns": ["FLG_SO_", "SO_", "SLIP_ON"], + "description_keywords": ["SLIP ON", "SO", "์Šฌ๋ฆฝ์˜จ"], + "characteristics": "๋ผ์›Œ์„œ ์šฉ์ ‘ํ˜•", + "size_range": "1/2\" ~ 24\"", + "pressure_range": "150LB ~ 600LB" + }, + "SOCKET_WELD": { + "dat_file_patterns": ["FLG_SW_", "SW_"], + "description_keywords": ["SOCKET WELD", "SW", "์†Œ์ผ“์›ฐ๋“œ"], + "characteristics": "์†Œ์ผ“ ์šฉ์ ‘ํ˜•", + "size_range": "1/8\" ~ 4\"", + "pressure_range": "150LB ~ 9000LB" + }, + "THREADED": { + "dat_file_patterns": ["FLG_THD_", "THD_", "FLG_TR_"], + "description_keywords": ["THREADED", "THD", "๋‚˜์‚ฌ", "NPT"], + "characteristics": "๋‚˜์‚ฌ ์—ฐ๊ฒฐํ˜•", + "size_range": "1/8\" ~ 4\"", + "pressure_range": "150LB ~ 6000LB" + }, + "BLIND": { + "dat_file_patterns": ["FLG_BL_", "BL_", "BLIND_"], + "description_keywords": ["BLIND", "BL", "๋ง‰์Œ", "์ฐจ๋‹จ"], + "characteristics": "๋ง‰์Œ์šฉ", + "size_range": "1/2\" ~ 48\"", + "pressure_range": "150LB ~ 2500LB" + }, + "LAP_JOINT": { + "dat_file_patterns": ["FLG_LJ_", "LJ_", "LAP_"], + "description_keywords": ["LAP JOINT", "LJ", "๋žฉ์กฐ์ธํŠธ"], + "characteristics": "์Šคํ„ฐ๋ธŒ์—”๋“œ ์กฐํ•ฉํ˜•", + "size_range": "1/2\" ~ 24\"", + "pressure_range": "150LB ~ 600LB" + } +} + +# ========== ๋ฉด ๊ฐ€๊ณต๋ณ„ ๋ถ„๋ฅ˜ ========== +FACE_FINISHES = { + "RAISED_FACE": { + "codes": ["RF", "RAISED FACE", "๋ณผ๋ก๋ฉด"], + "characteristics": "๋ณผ๋กํ•œ ๋ฉด (๊ฐ€์žฅ ์ผ๋ฐ˜์ )", + "pressure_range": "150LB ~ 600LB", + "confidence": 0.95 + }, + "FLAT_FACE": { + "codes": ["FF", "FLAT FACE", "ํ‰๋ฉด"], + "characteristics": "ํ‰ํ‰ํ•œ ๋ฉด", + "pressure_range": "150LB ~ 300LB", + "confidence": 0.95 + }, + "RING_TYPE_JOINT": { + "codes": ["RTJ", "RING TYPE JOINT", "๋งํƒ€์ž…"], + "characteristics": "ํ™ˆ์ด ํŒŒ์ง„ ๊ณ ์••์šฉ", + "pressure_range": "600LB ~ 2500LB", + "confidence": 0.95 + } +} + +# ========== ์••๋ ฅ ๋“ฑ๊ธ‰๋ณ„ ๋ถ„๋ฅ˜ ========== +FLANGE_PRESSURE_RATINGS = { + "patterns": [ + r"(\d+)LB", + r"CLASS\s*(\d+)", + r"CL\s*(\d+)", + r"(\d+)#", + r"(\d+)\s*LB" + ], + "standard_ratings": { + "150LB": {"max_pressure": "285 PSI", "common_use": "์ €์•• ์ผ๋ฐ˜์šฉ", "typical_face": "RF"}, + "300LB": {"max_pressure": "740 PSI", "common_use": "์ค‘์••์šฉ", "typical_face": "RF"}, + "600LB": {"max_pressure": "1480 PSI", "common_use": "๊ณ ์••์šฉ", "typical_face": "RTJ"}, + "900LB": {"max_pressure": "2220 PSI", "common_use": "๊ณ ์••์šฉ", "typical_face": "RTJ"}, + "1500LB": {"max_pressure": "3705 PSI", "common_use": "๊ณ ์••์šฉ", "typical_face": "RTJ"}, + "2500LB": {"max_pressure": "6170 PSI", "common_use": "์ดˆ๊ณ ์••์šฉ", "typical_face": "RTJ"}, + "3000LB": {"max_pressure": "7400 PSI", "common_use": "์†Œ๊ตฌ๊ฒฝ ๊ณ ์••์šฉ", "typical_face": "RTJ"}, + "6000LB": {"max_pressure": "14800 PSI", "common_use": "์†Œ๊ตฌ๊ฒฝ ์ดˆ๊ณ ์••์šฉ", "typical_face": "RTJ"}, + "9000LB": {"max_pressure": "22200 PSI", "common_use": "์†Œ๊ตฌ๊ฒฝ ๊ทน๊ณ ์••์šฉ", "typical_face": "RTJ"} + } +} + +def classify_flange(dat_file: str, description: str, main_nom: str, + red_nom: str = None) -> Dict: + """ + ์™„์ „ํ•œ FLANGE ๋ถ„๋ฅ˜ + + Args: + dat_file: DAT_FILE ํ•„๋“œ + description: DESCRIPTION ํ•„๋“œ + main_nom: MAIN_NOM ํ•„๋“œ (์ฃผ ์‚ฌ์ด์ฆˆ) + red_nom: RED_NOM ํ•„๋“œ (์ถ•์†Œ ์‚ฌ์ด์ฆˆ, REDUCING ํ”Œ๋žœ์ง€์šฉ) + + Returns: + ์™„์ „ํ•œ ํ”Œ๋žœ์ง€ ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ + """ + + # 1. ์žฌ์งˆ ๋ถ„๋ฅ˜ (๊ณตํ†ต ๋ชจ๋“ˆ ์‚ฌ์šฉ) + material_result = classify_material(description) + + # 2. SPECIAL vs STANDARD ๋ถ„๋ฅ˜ + flange_category_result = classify_flange_category(dat_file, description) + + # 3. ํ”Œ๋žœ์ง€ ํƒ€์ž… ๋ถ„๋ฅ˜ + flange_type_result = classify_flange_type( + dat_file, description, main_nom, red_nom, flange_category_result + ) + + # 4. ๋ฉด ๊ฐ€๊ณต ๋ถ„๋ฅ˜ + face_finish_result = classify_face_finish(dat_file, description) + + # 5. ์••๋ ฅ ๋“ฑ๊ธ‰ ๋ถ„๋ฅ˜ + pressure_result = classify_flange_pressure_rating(dat_file, description) + + # 6. ์ œ์ž‘ ๋ฐฉ๋ฒ• ์ถ”์ • + manufacturing_result = determine_flange_manufacturing( + material_result, flange_type_result, pressure_result, main_nom + ) + + # 7. ์ตœ์ข… ๊ฒฐ๊ณผ ์กฐํ•ฉ + return { + "category": "FLANGE", + + # ์žฌ์งˆ ์ •๋ณด (๊ณตํ†ต ๋ชจ๋“ˆ) + "material": { + "standard": material_result.get('standard', 'UNKNOWN'), + "grade": material_result.get('grade', 'UNKNOWN'), + "material_type": material_result.get('material_type', 'UNKNOWN'), + "confidence": material_result.get('confidence', 0.0) + }, + + # ํ”Œ๋žœ์ง€ ๋ถ„๋ฅ˜ ์ •๋ณด + "flange_category": { + "category": flange_category_result.get('category', 'UNKNOWN'), + "is_special": flange_category_result.get('is_special', False), + "confidence": flange_category_result.get('confidence', 0.0) + }, + + "flange_type": { + "type": flange_type_result.get('type', 'UNKNOWN'), + "characteristics": flange_type_result.get('characteristics', ''), + "confidence": flange_type_result.get('confidence', 0.0), + "evidence": flange_type_result.get('evidence', []), + "special_features": flange_type_result.get('special_features', []) + }, + + "face_finish": { + "finish": face_finish_result.get('finish', 'UNKNOWN'), + "characteristics": face_finish_result.get('characteristics', ''), + "confidence": face_finish_result.get('confidence', 0.0) + }, + + "pressure_rating": { + "rating": pressure_result.get('rating', 'UNKNOWN'), + "confidence": pressure_result.get('confidence', 0.0), + "max_pressure": pressure_result.get('max_pressure', ''), + "common_use": pressure_result.get('common_use', ''), + "typical_face": pressure_result.get('typical_face', '') + }, + + "manufacturing": { + "method": manufacturing_result.get('method', 'UNKNOWN'), + "confidence": manufacturing_result.get('confidence', 0.0), + "evidence": manufacturing_result.get('evidence', []), + "characteristics": manufacturing_result.get('characteristics', '') + }, + + "size_info": { + "main_size": main_nom, + "reduced_size": red_nom, + "size_description": format_flange_size(main_nom, red_nom), + "requires_two_sizes": flange_type_result.get('requires_two_sizes', False) + }, + + # ์ „์ฒด ์‹ ๋ขฐ๋„ + "overall_confidence": calculate_flange_confidence({ + "material": material_result.get('confidence', 0), + "flange_type": flange_type_result.get('confidence', 0), + "face_finish": face_finish_result.get('confidence', 0), + "pressure": pressure_result.get('confidence', 0) + }) + } + +def classify_flange_category(dat_file: str, description: str) -> Dict: + """SPECIAL vs STANDARD ํ”Œ๋žœ์ง€ ๋ถ„๋ฅ˜""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + combined_text = f"{dat_upper} {desc_upper}" + + # SPECIAL ํ”Œ๋žœ์ง€ ํ™•์ธ (์šฐ์„ ) + for special_type, type_data in SPECIAL_FLANGE_TYPES.items(): + # DAT_FILE ํŒจํ„ด ํ™•์ธ + for pattern in type_data["dat_file_patterns"]: + if pattern in dat_upper: + return { + "category": "SPECIAL", + "special_type": special_type, + "is_special": True, + "confidence": 0.95, + "evidence": [f"SPECIAL_DAT_PATTERN: {pattern}"] + } + + # DESCRIPTION ํ‚ค์›Œ๋“œ ํ™•์ธ + for keyword in type_data["description_keywords"]: + if keyword in combined_text: + return { + "category": "SPECIAL", + "special_type": special_type, + "is_special": True, + "confidence": 0.9, + "evidence": [f"SPECIAL_KEYWORD: {keyword}"] + } + + # STANDARD ํ”Œ๋žœ์ง€๋กœ ๋ถ„๋ฅ˜ + return { + "category": "STANDARD", + "is_special": False, + "confidence": 0.8, + "evidence": ["NO_SPECIAL_INDICATORS"] + } + +def classify_flange_type(dat_file: str, description: str, main_nom: str, + red_nom: str, category_result: Dict) -> Dict: + """ํ”Œ๋žœ์ง€ ํƒ€์ž… ๋ถ„๋ฅ˜ (SPECIAL ๋˜๋Š” STANDARD)""" + + dat_upper = dat_file.upper() + desc_upper = description.upper() + + if category_result.get('is_special'): + # SPECIAL ํ”Œ๋žœ์ง€ ํƒ€์ž… ํ™•์ธ + special_type = category_result.get('special_type') + if special_type and special_type in SPECIAL_FLANGE_TYPES: + type_data = SPECIAL_FLANGE_TYPES[special_type] + + return { + "type": special_type, + "characteristics": type_data["characteristics"], + "confidence": 0.95, + "evidence": [f"SPECIAL_TYPE: {special_type}"], + "special_features": type_data["special_features"], + "requires_two_sizes": type_data.get("requires_two_sizes", False) + } + + else: + # STANDARD ํ”Œ๋žœ์ง€ ํƒ€์ž… ํ™•์ธ + for flange_type, type_data in STANDARD_FLANGE_TYPES.items(): + # DAT_FILE ํŒจํ„ด ํ™•์ธ + for pattern in type_data["dat_file_patterns"]: + if pattern in dat_upper: + return { + "type": flange_type, + "characteristics": type_data["characteristics"], + "confidence": 0.95, + "evidence": [f"STANDARD_DAT_PATTERN: {pattern}"], + "special_features": [], + "requires_two_sizes": False + } + + # DESCRIPTION ํ‚ค์›Œ๋“œ ํ™•์ธ + for keyword in type_data["description_keywords"]: + if keyword in desc_upper: + return { + "type": flange_type, + "characteristics": type_data["characteristics"], + "confidence": 0.85, + "evidence": [f"STANDARD_KEYWORD: {keyword}"], + "special_features": [], + "requires_two_sizes": False + } + + # ๋ถ„๋ฅ˜ ์‹คํŒจ + return { + "type": "UNKNOWN", + "characteristics": "", + "confidence": 0.0, + "evidence": ["NO_FLANGE_TYPE_IDENTIFIED"], + "special_features": [], + "requires_two_sizes": False + } + +def classify_face_finish(dat_file: str, description: str) -> Dict: + """ํ”Œ๋žœ์ง€ ๋ฉด ๊ฐ€๊ณต ๋ถ„๋ฅ˜""" + + combined_text = f"{dat_file} {description}".upper() + + for finish_type, finish_data in FACE_FINISHES.items(): + for code in finish_data["codes"]: + if code in combined_text: + return { + "finish": finish_type, + "confidence": finish_data["confidence"], + "matched_code": code, + "characteristics": finish_data["characteristics"], + "pressure_range": finish_data["pressure_range"] + } + + # ๊ธฐ๋ณธ๊ฐ’: RF (๊ฐ€์žฅ ์ผ๋ฐ˜์ ) + return { + "finish": "RAISED_FACE", + "confidence": 0.6, + "matched_code": "DEFAULT", + "characteristics": "๊ธฐ๋ณธ๊ฐ’ (๊ฐ€์žฅ ์ผ๋ฐ˜์ )", + "pressure_range": "150LB ~ 600LB" + } + +def classify_flange_pressure_rating(dat_file: str, description: str) -> Dict: + """ํ”Œ๋žœ์ง€ ์••๋ ฅ ๋“ฑ๊ธ‰ ๋ถ„๋ฅ˜""" + + combined_text = f"{dat_file} {description}".upper() + + # ํŒจํ„ด ๋งค์นญ์œผ๋กœ ์••๋ ฅ ๋“ฑ๊ธ‰ ์ถ”์ถœ + for pattern in FLANGE_PRESSURE_RATINGS["patterns"]: + match = re.search(pattern, combined_text) + if match: + rating_num = match.group(1) + rating = f"{rating_num}LB" + + # ํ‘œ์ค€ ๋“ฑ๊ธ‰ ์ •๋ณด ํ™•์ธ + rating_info = FLANGE_PRESSURE_RATINGS["standard_ratings"].get(rating, {}) + + if rating_info: + confidence = 0.95 + else: + confidence = 0.8 + rating_info = {"max_pressure": "ํ™•์ธ ํ•„์š”", "common_use": "๋น„ํ‘œ์ค€ ๋“ฑ๊ธ‰", "typical_face": "RF"} + + return { + "rating": rating, + "confidence": confidence, + "matched_pattern": pattern, + "matched_value": rating_num, + "max_pressure": rating_info.get("max_pressure", ""), + "common_use": rating_info.get("common_use", ""), + "typical_face": rating_info.get("typical_face", "RF") + } + + return { + "rating": "UNKNOWN", + "confidence": 0.0, + "matched_pattern": "", + "max_pressure": "", + "common_use": "", + "typical_face": "" + } + +def determine_flange_manufacturing(material_result: Dict, flange_type_result: Dict, + pressure_result: Dict, main_nom: str) -> Dict: + """ํ”Œ๋žœ์ง€ ์ œ์ž‘ ๋ฐฉ๋ฒ• ๊ฒฐ์ •""" + + evidence = [] + + # 1. ์žฌ์งˆ ๊ธฐ๋ฐ˜ ์ œ์ž‘๋ฐฉ๋ฒ• (๊ฐ€์žฅ ํ™•์‹ค) + material_manufacturing = get_manufacturing_method_from_material(material_result) + if material_manufacturing in ["FORGED", "CAST"]: + evidence.append(f"MATERIAL_STANDARD: {material_result.get('standard')}") + + characteristics = { + "FORGED": "๊ณ ๊ฐ•๋„, ๊ณ ์••์šฉ", + "CAST": "๋ณต์žกํ˜•์ƒ, ์ค‘์ €์••์šฉ" + }.get(material_manufacturing, "") + + return { + "method": material_manufacturing, + "confidence": 0.9, + "evidence": evidence, + "characteristics": characteristics + } + + # 2. ์••๋ ฅ๋“ฑ๊ธ‰ + ์‚ฌ์ด์ฆˆ ์กฐํ•ฉ์œผ๋กœ ์ถ”์ • + pressure_rating = pressure_result.get('rating', '') + + # ๊ณ ์•• = ๋‹จ์กฐ + high_pressure = ["600LB", "900LB", "1500LB", "2500LB", "3000LB", "6000LB", "9000LB"] + if any(pressure in pressure_rating for pressure in high_pressure): + evidence.append(f"HIGH_PRESSURE: {pressure_rating}") + return { + "method": "FORGED", + "confidence": 0.85, + "evidence": evidence, + "characteristics": "๊ณ ์••์šฉ ๋‹จ์กฐํ’ˆ" + } + + # ์ €์•• + ๋Œ€๊ตฌ๊ฒฝ = ์ฃผ์กฐ ๊ฐ€๋Šฅ + low_pressure = ["150LB", "300LB"] + if any(pressure in pressure_rating for pressure in low_pressure): + try: + size_num = float(re.findall(r'(\d+(?:\.\d+)?)', main_nom)[0]) + if size_num >= 12.0: # 12์ธ์น˜ ์ด์ƒ + evidence.append(f"LARGE_SIZE_LOW_PRESSURE: {main_nom}, {pressure_rating}") + return { + "method": "CAST", + "confidence": 0.8, + "evidence": evidence, + "characteristics": "๋Œ€๊ตฌ๊ฒฝ ์ €์••์šฉ ์ฃผ์กฐํ’ˆ" + } + except: + pass + + # 3. ๊ธฐ๋ณธ ์ถ”์ • + return { + "method": "FORGED", + "confidence": 0.7, + "evidence": ["DEFAULT_FORGED"], + "characteristics": "์ผ๋ฐ˜์ ์œผ๋กœ ๋‹จ์กฐํ’ˆ" + } + +def format_flange_size(main_nom: str, red_nom: str = None) -> str: + """ํ”Œ๋žœ์ง€ ์‚ฌ์ด์ฆˆ ํ‘œ๊ธฐ ํฌ๋งทํŒ…""" + + if red_nom and red_nom.strip() and red_nom != main_nom: + return f"{main_nom} x {red_nom}" + else: + return main_nom + +def calculate_flange_confidence(confidence_scores: Dict) -> float: + """ํ”Œ๋žœ์ง€ ๋ถ„๋ฅ˜ ์ „์ฒด ์‹ ๋ขฐ๋„ ๊ณ„์‚ฐ""" + + scores = [score for score in confidence_scores.values() if score > 0] + + if not scores: + return 0.0 + + # ๊ฐ€์ค‘ ํ‰๊ท  + weights = { + "material": 0.25, + "flange_type": 0.4, + "face_finish": 0.2, + "pressure": 0.15 + } + + weighted_sum = sum( + confidence_scores.get(key, 0) * weight + for key, weight in weights.items() + ) + + return round(weighted_sum, 2) + +# ========== ํŠน์ˆ˜ ๊ธฐ๋Šฅ๋“ค ========== + +def is_high_pressure_flange(pressure_rating: str) -> bool: + """๊ณ ์•• ํ”Œ๋žœ์ง€ ์—ฌ๋ถ€ ํŒ๋‹จ""" + high_pressure_ratings = ["600LB", "900LB", "1500LB", "2500LB", "3000LB", "6000LB", "9000LB"] + return pressure_rating in high_pressure_ratings + +def is_special_flange(flange_result: Dict) -> bool: + """ํŠน์ˆ˜ ํ”Œ๋žœ์ง€ ์—ฌ๋ถ€ ํŒ๋‹จ""" + return flange_result.get("flange_category", {}).get("is_special", False) + +def get_flange_purchase_info(flange_result: Dict) -> Dict: + """ํ”Œ๋žœ์ง€ ๊ตฌ๋งค ์ •๋ณด ์ƒ์„ฑ""" + + flange_type = flange_result["flange_type"]["type"] + pressure = flange_result["pressure_rating"]["rating"] + manufacturing = flange_result["manufacturing"]["method"] + is_special = flange_result["flange_category"]["is_special"] + + # ๊ณต๊ธ‰์—…์ฒด ํƒ€์ž… ๊ฒฐ์ • + if is_special: + supplier_type = "ํŠน์ˆ˜ ํ”Œ๋žœ์ง€ ์ „๋ฌธ์—…์ฒด" + elif manufacturing == "FORGED": + supplier_type = "๋‹จ์กฐ ํ”Œ๋žœ์ง€ ์—…์ฒด" + elif manufacturing == "CAST": + supplier_type = "์ฃผ์กฐ ํ”Œ๋žœ์ง€ ์—…์ฒด" + else: + supplier_type = "์ผ๋ฐ˜ ํ”Œ๋žœ์ง€ ์—…์ฒด" + + # ๋‚ฉ๊ธฐ ์ถ”์ • + if is_special: + lead_time = "8-12์ฃผ (ํŠน์ˆ˜ํ’ˆ)" + elif is_high_pressure_flange(pressure): + lead_time = "6-10์ฃผ (๊ณ ์••์šฉ)" + elif manufacturing == "FORGED": + lead_time = "4-8์ฃผ (๋‹จ์กฐํ’ˆ)" + else: + lead_time = "2-6์ฃผ (์ผ๋ฐ˜ํ’ˆ)" + + return { + "supplier_type": supplier_type, + "lead_time_estimate": lead_time, + "purchase_category": f"{flange_type} {pressure}", + "manufacturing_note": flange_result["manufacturing"]["characteristics"], + "special_requirements": flange_result["flange_type"]["special_features"] + } diff --git a/backend/app/services/material_classifier.py b/backend/app/services/material_classifier.py new file mode 100644 index 0000000..d07dc1f --- /dev/null +++ b/backend/app/services/material_classifier.py @@ -0,0 +1,313 @@ +""" +์žฌ์งˆ ๋ถ„๋ฅ˜๋ฅผ ์œ„ํ•œ ๊ณตํ†ต ํ•จ์ˆ˜ +materials_schema.py์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์žฌ์งˆ์„ ๋ถ„๋ฅ˜ +""" + +import re +from typing import Dict, List, Optional, Tuple +from .materials_schema import ( + MATERIAL_STANDARDS, + SPECIAL_MATERIALS, + MANUFACTURING_MATERIAL_MAP, + GENERIC_MATERIAL_KEYWORDS +) + +def classify_material(description: str) -> Dict: + """ + ๊ณตํ†ต ์žฌ์งˆ ๋ถ„๋ฅ˜ ํ•จ์ˆ˜ + + Args: + description: ์ž์žฌ ์„ค๋ช… (DESCRIPTION ํ•„๋“œ) + + Returns: + ์žฌ์งˆ ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ ๋”•์…”๋„ˆ๋ฆฌ + """ + + desc_upper = description.upper().strip() + + # 1๋‹จ๊ณ„: ํŠน์ˆ˜ ์žฌ์งˆ ์šฐ์„  ํ™•์ธ (๊ฐ€์žฅ ๊ตฌ์ฒด์ ) + special_result = check_special_materials(desc_upper) + if special_result['confidence'] > 0.9: + return special_result + + # 2๋‹จ๊ณ„: ASTM/ASME ๊ทœ๊ฒฉ ํ™•์ธ + astm_result = check_astm_materials(desc_upper) + if astm_result['confidence'] > 0.8: + return astm_result + + # 3๋‹จ๊ณ„: KS ๊ทœ๊ฒฉ ํ™•์ธ + ks_result = check_ks_materials(desc_upper) + if ks_result['confidence'] > 0.8: + return ks_result + + # 4๋‹จ๊ณ„: JIS ๊ทœ๊ฒฉ ํ™•์ธ + jis_result = check_jis_materials(desc_upper) + if jis_result['confidence'] > 0.8: + return jis_result + + # 5๋‹จ๊ณ„: ์ผ๋ฐ˜ ํ‚ค์›Œ๋“œ ํ™•์ธ + generic_result = check_generic_materials(desc_upper) + + return generic_result + +def check_special_materials(description: str) -> Dict: + """ํŠน์ˆ˜ ์žฌ์งˆ ํ™•์ธ""" + + # SUPER ALLOYS ํ™•์ธ + for alloy_family, alloy_data in SPECIAL_MATERIALS["SUPER_ALLOYS"].items(): + for pattern in alloy_data["patterns"]: + match = re.search(pattern, description) + if match: + grade = match.group(1) if match.groups() else "STANDARD" + grade_info = alloy_data["grades"].get(grade, {}) + + return { + "standard": f"{alloy_family}", + "grade": f"{alloy_family} {grade}", + "material_type": "SUPER_ALLOY", + "manufacturing": alloy_data.get("manufacturing", "SPECIAL"), + "composition": grade_info.get("composition", ""), + "applications": grade_info.get("applications", ""), + "confidence": 0.95, + "evidence": [f"SPECIAL_MATERIAL: {alloy_family} {grade}"] + } + + # TITANIUM ํ™•์ธ + titanium_data = SPECIAL_MATERIALS["TITANIUM"] + for pattern in titanium_data["patterns"]: + match = re.search(pattern, description) + if match: + grade = match.group(1) if match.groups() else "2" + grade_info = titanium_data["grades"].get(grade, {}) + + return { + "standard": "TITANIUM", + "grade": f"Titanium Grade {grade}", + "material_type": "TITANIUM", + "manufacturing": "FORGED_OR_SEAMLESS", + "composition": grade_info.get("composition", f"Ti Grade {grade}"), + "confidence": 0.95, + "evidence": [f"TITANIUM: Grade {grade}"] + } + + return {"confidence": 0.0} + +def check_astm_materials(description: str) -> Dict: + """ASTM/ASME ๊ทœ๊ฒฉ ํ™•์ธ""" + + astm_data = MATERIAL_STANDARDS["ASTM_ASME"] + + # FORGED ๋“ฑ๊ธ‰ ํ™•์ธ + for standard, standard_data in astm_data["FORGED_GRADES"].items(): + result = check_astm_standard(description, standard, standard_data) + if result["confidence"] > 0.8: + return result + + # WELDED ๋“ฑ๊ธ‰ ํ™•์ธ + for standard, standard_data in astm_data["WELDED_GRADES"].items(): + result = check_astm_standard(description, standard, standard_data) + if result["confidence"] > 0.8: + return result + + # CAST ๋“ฑ๊ธ‰ ํ™•์ธ + for standard, standard_data in astm_data["CAST_GRADES"].items(): + result = check_astm_standard(description, standard, standard_data) + if result["confidence"] > 0.8: + return result + + # PIPE ๋“ฑ๊ธ‰ ํ™•์ธ + for standard, standard_data in astm_data["PIPE_GRADES"].items(): + result = check_astm_standard(description, standard, standard_data) + if result["confidence"] > 0.8: + return result + + return {"confidence": 0.0} + +def check_astm_standard(description: str, standard: str, standard_data: Dict) -> Dict: + """๊ฐœ๋ณ„ ASTM ๊ทœ๊ฒฉ ํ™•์ธ""" + + # ์ง์ ‘ ํŒจํ„ด์ด ์žˆ๋Š” ๊ฒฝ์šฐ (A105 ๋“ฑ) + if "patterns" in standard_data: + for pattern in standard_data["patterns"]: + match = re.search(pattern, description) + if match: + return { + "standard": f"ASTM {standard}", + "grade": f"ASTM {standard}", + "material_type": determine_material_type(standard, ""), + "manufacturing": standard_data.get("manufacturing", "UNKNOWN"), + "confidence": 0.9, + "evidence": [f"ASTM_{standard}: Direct Match"] + } + + # ํ•˜์œ„ ๋ถ„๋ฅ˜๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ (A182, A234 ๋“ฑ) + else: + for subtype, subtype_data in standard_data.items(): + for pattern in subtype_data["patterns"]: + match = re.search(pattern, description) + if match: + grade_code = match.group(1) if match.groups() else "" + grade_info = subtype_data["grades"].get(grade_code, {}) + + return { + "standard": f"ASTM {standard}", + "grade": f"ASTM {standard} {grade_code}", + "material_type": determine_material_type(standard, grade_code), + "manufacturing": subtype_data.get("manufacturing", "UNKNOWN"), + "composition": grade_info.get("composition", ""), + "applications": grade_info.get("applications", ""), + "confidence": 0.9, + "evidence": [f"ASTM_{standard}: {grade_code}"] + } + + return {"confidence": 0.0} + +def check_ks_materials(description: str) -> Dict: + """KS ๊ทœ๊ฒฉ ํ™•์ธ""" + + ks_data = MATERIAL_STANDARDS["KS"] + + for category, standards in ks_data.items(): + for standard, standard_data in standards.items(): + for pattern in standard_data["patterns"]: + match = re.search(pattern, description) + if match: + return { + "standard": f"KS {standard}", + "grade": f"KS {standard}", + "material_type": determine_material_type_from_description(description), + "manufacturing": standard_data.get("manufacturing", "UNKNOWN"), + "description": standard_data["description"], + "confidence": 0.85, + "evidence": [f"KS_{standard}"] + } + + return {"confidence": 0.0} + +def check_jis_materials(description: str) -> Dict: + """JIS ๊ทœ๊ฒฉ ํ™•์ธ""" + + jis_data = MATERIAL_STANDARDS["JIS"] + + for category, standards in jis_data.items(): + for standard, standard_data in standards.items(): + for pattern in standard_data["patterns"]: + match = re.search(pattern, description) + if match: + return { + "standard": f"JIS {standard}", + "grade": f"JIS {standard}", + "material_type": determine_material_type_from_description(description), + "manufacturing": standard_data.get("manufacturing", "UNKNOWN"), + "description": standard_data["description"], + "confidence": 0.85, + "evidence": [f"JIS_{standard}"] + } + + return {"confidence": 0.0} + +def check_generic_materials(description: str) -> Dict: + """์ผ๋ฐ˜ ์žฌ์งˆ ํ‚ค์›Œ๋“œ ํ™•์ธ""" + + for material_type, keywords in GENERIC_MATERIAL_KEYWORDS.items(): + for keyword in keywords: + if keyword in description: + return { + "standard": "GENERIC", + "grade": keyword, + "material_type": material_type, + "manufacturing": "UNKNOWN", + "confidence": 0.6, + "evidence": [f"GENERIC: {keyword}"] + } + + return { + "standard": "UNKNOWN", + "grade": "UNKNOWN", + "material_type": "UNKNOWN", + "manufacturing": "UNKNOWN", + "confidence": 0.0, + "evidence": ["NO_MATERIAL_FOUND"] + } + +def determine_material_type(standard: str, grade: str) -> str: + """๊ทœ๊ฒฉ๊ณผ ๋“ฑ๊ธ‰์œผ๋กœ ์žฌ์งˆ ํƒ€์ž… ๊ฒฐ์ •""" + + # ์Šคํ…Œ์ธ๋ฆฌ์Šค ๋“ฑ๊ธ‰ + stainless_patterns = ["304", "316", "321", "347", "F304", "F316", "WP304", "CF8"] + if any(pattern in grade for pattern in stainless_patterns): + return "STAINLESS_STEEL" + + # ํ•ฉ๊ธˆ๊ฐ• ๋“ฑ๊ธ‰ + alloy_patterns = ["F1", "F5", "F11", "F22", "F91", "WP1", "WP5", "WP11", "WP22", "WP91"] + if any(pattern in grade for pattern in alloy_patterns): + return "ALLOY_STEEL" + + # ์ฃผ์กฐํ’ˆ + if standard in ["A216", "A351"]: + return "CAST_STEEL" + + # ๊ธฐ๋ณธ๊ฐ’์€ ํƒ„์†Œ๊ฐ• + return "CARBON_STEEL" + +def determine_material_type_from_description(description: str) -> str: + """์„ค๋ช…์—์„œ ์žฌ์งˆ ํƒ€์ž… ์ถ”์ •""" + + desc_upper = description.upper() + + if any(keyword in desc_upper for keyword in ["SS", "STS", "STAINLESS", "304", "316"]): + return "STAINLESS_STEEL" + elif any(keyword in desc_upper for keyword in ["ALLOY", "ํ•ฉ๊ธˆ", "CR", "MO"]): + return "ALLOY_STEEL" + elif any(keyword in desc_upper for keyword in ["CAST", "์ฃผ์กฐ"]): + return "CAST_STEEL" + else: + return "CARBON_STEEL" + +def get_manufacturing_method_from_material(material_result: Dict) -> str: + """์žฌ์งˆ ์ •๋ณด๋กœ๋ถ€ํ„ฐ ์ œ์ž‘๋ฐฉ๋ฒ• ์ถ”์ •""" + + if material_result.get("confidence", 0) < 0.5: + return "UNKNOWN" + + material_standard = material_result.get('standard', '') + + # ์ง์ ‘ ๋งคํ•‘ + if 'A182' in material_standard or 'A105' in material_standard: + return 'FORGED' + elif 'A234' in material_standard or 'A403' in material_standard: + return 'WELDED_FABRICATED' + elif 'A216' in material_standard or 'A351' in material_standard: + return 'CAST' + elif 'A106' in material_standard or 'A312' in material_standard: + return 'SEAMLESS' + elif 'A53' in material_standard: + return 'WELDED_OR_SEAMLESS' + + # manufacturing ํ•„๋“œ๊ฐ€ ์žˆ์œผ๋ฉด ์ง์ ‘ ์‚ฌ์šฉ + manufacturing = material_result.get("manufacturing", "UNKNOWN") + if manufacturing != "UNKNOWN": + return manufacturing + + return "UNKNOWN" + +def get_material_confidence_factors(material_result: Dict) -> List[str]: + """์žฌ์งˆ ๋ถ„๋ฅ˜ ์‹ ๋ขฐ๋„ ์˜ํ–ฅ ์š”์†Œ ๋ฐ˜ํ™˜""" + + factors = [] + confidence = material_result.get("confidence", 0) + + if confidence >= 0.9: + factors.append("HIGH_CONFIDENCE") + elif confidence >= 0.7: + factors.append("MEDIUM_CONFIDENCE") + else: + factors.append("LOW_CONFIDENCE") + + if material_result.get("standard") == "UNKNOWN": + factors.append("NO_STANDARD_FOUND") + + if material_result.get("manufacturing") == "UNKNOWN": + factors.append("MANUFACTURING_UNCLEAR") + + return factors diff --git a/backend/app/services/materials_schema.py b/backend/app/services/materials_schema.py new file mode 100644 index 0000000..2e8d38d --- /dev/null +++ b/backend/app/services/materials_schema.py @@ -0,0 +1,525 @@ +""" +์žฌ์งˆ ๋ถ„๋ฅ˜๋ฅผ ์œ„ํ•œ ๊ณตํ†ต ์Šคํ‚ค๋งˆ +๋ชจ๋“  ์ œํ’ˆ๊ตฐ(PIPE, FITTING, FLANGE ๋“ฑ)์—์„œ ๊ณตํ†ต ์‚ฌ์šฉ +""" + +import re +from typing import Dict, List, Optional + +# ========== ๋ฏธ๊ตญ ASTM/ASME ๊ทœ๊ฒฉ ========== +MATERIAL_STANDARDS = { + "ASTM_ASME": { + "FORGED_GRADES": { + "A182": { + "carbon_alloy": { + "patterns": [ + r"ASTM\s+A182\s+(?:GR\s*)?F(\d+)", + r"A182\s+(?:GR\s*)?F(\d+)", + r"ASME\s+SA182\s+(?:GR\s*)?F(\d+)" + ], + "grades": { + "F1": { + "composition": "0.5Mo", + "temp_max": "482ยฐC", + "applications": "์ค‘์˜จ์šฉ" + }, + "F5": { + "composition": "5Cr-0.5Mo", + "temp_max": "649ยฐC", + "applications": "๊ณ ์˜จ์šฉ" + }, + "F11": { + "composition": "1.25Cr-0.5Mo", + "temp_max": "593ยฐC", + "applications": "์ผ๋ฐ˜ ๊ณ ์˜จ์šฉ" + }, + "F22": { + "composition": "2.25Cr-1Mo", + "temp_max": "649ยฐC", + "applications": "๊ณ ์˜จ ๊ณ ์••์šฉ" + }, + "F91": { + "composition": "9Cr-1Mo-V", + "temp_max": "649ยฐC", + "applications": "์ดˆ๊ณ ์˜จ์šฉ" + } + }, + "manufacturing": "FORGED" + }, + "stainless": { + "patterns": [ + r"ASTM\s+A182\s+F(\d{3}[LH]*)", + r"A182\s+F(\d{3}[LH]*)", + r"ASME\s+SA182\s+F(\d{3}[LH]*)" + ], + "grades": { + "F304": { + "composition": "18Cr-8Ni", + "applications": "์ผ๋ฐ˜์šฉ", + "corrosion_resistance": "๋ณดํ†ต" + }, + "F304L": { + "composition": "18Cr-8Ni-์ €ํƒ„์†Œ", + "applications": "์šฉ์ ‘์šฉ", + "corrosion_resistance": "๋ณดํ†ต" + }, + "F316": { + "composition": "18Cr-10Ni-2Mo", + "applications": "๋‚ด์‹์„ฑ", + "corrosion_resistance": "์šฐ์ˆ˜" + }, + "F316L": { + "composition": "18Cr-10Ni-2Mo-์ €ํƒ„์†Œ", + "applications": "์šฉ์ ‘+๋‚ด์‹์„ฑ", + "corrosion_resistance": "์šฐ์ˆ˜" + }, + "F321": { + "composition": "18Cr-8Ni-Ti", + "applications": "๊ณ ์˜จ์•ˆ์ •ํ™”", + "stabilizer": "Titanium" + }, + "F347": { + "composition": "18Cr-8Ni-Nb", + "applications": "๊ณ ์˜จ์•ˆ์ •ํ™”", + "stabilizer": "Niobium" + } + }, + "manufacturing": "FORGED" + } + }, + "A105": { + "patterns": [ + r"ASTM\s+A105(?:\s+(?:GR\s*)?([ABC]))?", + r"A105(?:\s+(?:GR\s*)?([ABC]))?", + r"ASME\s+SA105" + ], + "description": "ํƒ„์†Œ๊ฐ• ๋‹จ์กฐํ’ˆ", + "composition": "ํƒ„์†Œ๊ฐ•", + "applications": "์ผ๋ฐ˜ ์••๋ ฅ์šฉ ๋‹จ์กฐํ’ˆ", + "manufacturing": "FORGED", + "pressure_rating": "150LB ~ 9000LB" + } + }, + + "WELDED_GRADES": { + "A234": { + "carbon": { + "patterns": [ + r"ASTM\s+A234\s+(?:GR\s*)?WP([ABC])", + r"A234\s+(?:GR\s*)?WP([ABC])", + r"ASME\s+SA234\s+(?:GR\s*)?WP([ABC])" + ], + "grades": { + "WPA": { + "yield_strength": "30 ksi", + "applications": "์ €์••์šฉ", + "temp_range": "-29ยฐC ~ 400ยฐC" + }, + "WPB": { + "yield_strength": "35 ksi", + "applications": "์ผ๋ฐ˜์šฉ", + "temp_range": "-29ยฐC ~ 400ยฐC" + }, + "WPC": { + "yield_strength": "40 ksi", + "applications": "๊ณ ์••์šฉ", + "temp_range": "-29ยฐC ~ 400ยฐC" + } + }, + "manufacturing": "WELDED_FABRICATED" + }, + "alloy": { + "patterns": [ + r"ASTM\s+A234\s+(?:GR\s*)?WP(\d+)", + r"A234\s+(?:GR\s*)?WP(\d+)", + r"ASME\s+SA234\s+(?:GR\s*)?WP(\d+)" + ], + "grades": { + "WP1": { + "composition": "0.5Mo", + "temp_max": "482ยฐC" + }, + "WP5": { + "composition": "5Cr-0.5Mo", + "temp_max": "649ยฐC" + }, + "WP11": { + "composition": "1.25Cr-0.5Mo", + "temp_max": "593ยฐC" + }, + "WP22": { + "composition": "2.25Cr-1Mo", + "temp_max": "649ยฐC" + }, + "WP91": { + "composition": "9Cr-1Mo-V", + "temp_max": "649ยฐC" + } + }, + "manufacturing": "WELDED_FABRICATED" + } + }, + "A403": { + "stainless": { + "patterns": [ + r"ASTM\s+A403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)", + r"A403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)", + r"ASME\s+SA403\s+(?:GR\s*)?WP\s*(\d{3}[LH]*)" + ], + "grades": { + "WP304": { + "base_grade": "304", + "manufacturing": "WELDED", + "applications": "์ผ๋ฐ˜ ์šฉ์ ‘์šฉ" + }, + "WP304L": { + "base_grade": "304L", + "manufacturing": "WELDED", + "applications": "์ €ํƒ„์†Œ ์šฉ์ ‘์šฉ" + }, + "WP316": { + "base_grade": "316", + "manufacturing": "WELDED", + "applications": "๋‚ด์‹์„ฑ ์šฉ์ ‘์šฉ" + }, + "WP316L": { + "base_grade": "316L", + "manufacturing": "WELDED", + "applications": "์ €ํƒ„์†Œ ๋‚ด์‹์„ฑ ์šฉ์ ‘์šฉ" + } + }, + "manufacturing": "WELDED_FABRICATED" + } + } + }, + + "CAST_GRADES": { + "A216": { + "patterns": [ + r"ASTM\s+A216\s+(?:GR\s*)?([A-Z]{2,3})", + r"A216\s+(?:GR\s*)?([A-Z]{2,3})", + r"ASME\s+SA216\s+(?:GR\s*)?([A-Z]{2,3})" + ], + "grades": { + "WCA": { + "composition": "์ €ํƒ„์†Œ๊ฐ•", + "applications": "์ผ๋ฐ˜์ฃผ์กฐ", + "temp_range": "-29ยฐC ~ 425ยฐC" + }, + "WCB": { + "composition": "ํƒ„์†Œ๊ฐ•", + "applications": "์••๋ ฅ์šฉ๊ธฐ", + "temp_range": "-29ยฐC ~ 425ยฐC" + }, + "WCC": { + "composition": "์ค‘ํƒ„์†Œ๊ฐ•", + "applications": "๊ณ ๊ฐ•๋„์šฉ", + "temp_range": "-29ยฐC ~ 425ยฐC" + } + }, + "manufacturing": "CAST" + }, + "A351": { + "patterns": [ + r"ASTM\s+A351\s+(?:GR\s*)?([A-Z0-9]+)", + r"A351\s+(?:GR\s*)?([A-Z0-9]+)", + r"ASME\s+SA351\s+(?:GR\s*)?([A-Z0-9]+)" + ], + "grades": { + "CF8": { + "base_grade": "304", + "manufacturing": "CAST", + "applications": "304 ์Šคํ…Œ์ธ๋ฆฌ์Šค ์ฃผ์กฐ" + }, + "CF8M": { + "base_grade": "316", + "manufacturing": "CAST", + "applications": "316 ์Šคํ…Œ์ธ๋ฆฌ์Šค ์ฃผ์กฐ" + }, + "CF3": { + "base_grade": "304L", + "manufacturing": "CAST", + "applications": "304L ์Šคํ…Œ์ธ๋ฆฌ์Šค ์ฃผ์กฐ" + }, + "CF3M": { + "base_grade": "316L", + "manufacturing": "CAST", + "applications": "316L ์Šคํ…Œ์ธ๋ฆฌ์Šค ์ฃผ์กฐ" + } + }, + "manufacturing": "CAST" + } + }, + + "PIPE_GRADES": { + "A106": { + "patterns": [ + r"ASTM\s+A106\s+(?:GR\s*)?([ABC])", + r"A106\s+(?:GR\s*)?([ABC])", + r"ASME\s+SA106\s+(?:GR\s*)?([ABC])" + ], + "grades": { + "A": { + "yield_strength": "30 ksi", + "applications": "์ €์••์šฉ" + }, + "B": { + "yield_strength": "35 ksi", + "applications": "์ผ๋ฐ˜์šฉ" + }, + "C": { + "yield_strength": "40 ksi", + "applications": "๊ณ ์••์šฉ" + } + }, + "manufacturing": "SEAMLESS" + }, + "A53": { + "patterns": [ + r"ASTM\s+A53\s+(?:GR\s*)?([ABC])", + r"A53\s+(?:GR\s*)?([ABC])" + ], + "grades": { + "A": {"yield_strength": "30 ksi"}, + "B": {"yield_strength": "35 ksi"} + }, + "manufacturing": "WELDED_OR_SEAMLESS" + }, + "A312": { + "patterns": [ + r"ASTM\s+A312\s+TP\s*(\d{3}[LH]*)", + r"A312\s+TP\s*(\d{3}[LH]*)" + ], + "grades": { + "TP304": { + "base_grade": "304", + "manufacturing": "SEAMLESS" + }, + "TP304L": { + "base_grade": "304L", + "manufacturing": "SEAMLESS" + }, + "TP316": { + "base_grade": "316", + "manufacturing": "SEAMLESS" + }, + "TP316L": { + "base_grade": "316L", + "manufacturing": "SEAMLESS" + } + }, + "manufacturing": "SEAMLESS" + } + } + }, + + # ========== ํ•œ๊ตญ KS ๊ทœ๊ฒฉ ========== + "KS": { + "PIPE_GRADES": { + "D3507": { + "patterns": [r"KS\s+D\s*3507\s+SPPS\s*(\d+)"], + "description": "๋ฐฐ๊ด€์šฉ ํƒ„์†Œ๊ฐ•๊ด€", + "manufacturing": "SEAMLESS" + }, + "D3583": { + "patterns": [r"KS\s+D\s*3583\s+STPG\s*(\d+)"], + "description": "์••๋ ฅ๋ฐฐ๊ด€์šฉ ํƒ„์†Œ๊ฐ•๊ด€", + "manufacturing": "SEAMLESS" + }, + "D3576": { + "patterns": [r"KS\s+D\s*3576\s+STS\s*(\d{3}[LH]*)"], + "description": "๋ฐฐ๊ด€์šฉ ์Šคํ…Œ์ธ๋ฆฌ์Šค๊ฐ•๊ด€", + "manufacturing": "SEAMLESS" + } + }, + "FITTING_GRADES": { + "D3562": { + "patterns": [r"KS\s+D\s*3562"], + "description": "ํƒ„์†Œ๊ฐ• ๋‹จ์กฐ ํ”ผํŒ…", + "manufacturing": "FORGED" + }, + "D3563": { + "patterns": [r"KS\s+D\s*3563"], + "description": "์Šคํ…Œ์ธ๋ฆฌ์Šค๊ฐ• ๋‹จ์กฐ ํ”ผํŒ…", + "manufacturing": "FORGED" + } + } + }, + + # ========== ์ผ๋ณธ JIS ๊ทœ๊ฒฉ ========== + "JIS": { + "PIPE_GRADES": { + "G3452": { + "patterns": [r"JIS\s+G\s*3452\s+SGP"], + "description": "๋ฐฐ๊ด€์šฉ ํƒ„์†Œ๊ฐ•๊ด€", + "manufacturing": "WELDED" + }, + "G3454": { + "patterns": [r"JIS\s+G\s*3454\s+STPG\s*(\d+)"], + "description": "์••๋ ฅ๋ฐฐ๊ด€์šฉ ํƒ„์†Œ๊ฐ•๊ด€", + "manufacturing": "SEAMLESS" + }, + "G3459": { + "patterns": [r"JIS\s+G\s*3459\s+SUS\s*(\d{3}[LH]*)"], + "description": "๋ฐฐ๊ด€์šฉ ์Šคํ…Œ์ธ๋ฆฌ์Šค๊ฐ•๊ด€", + "manufacturing": "SEAMLESS" + } + }, + "FITTING_GRADES": { + "B2311": { + "patterns": [r"JIS\s+B\s*2311"], + "description": "๊ฐ•์ œ ํ”ผํŒ…", + "manufacturing": "FORGED" + }, + "B2312": { + "patterns": [r"JIS\s+B\s*2312"], + "description": "์Šคํ…Œ์ธ๋ฆฌ์Šค๊ฐ• ํ”ผํŒ…", + "manufacturing": "FORGED" + } + } + } +} + +# ========== ํŠน์ˆ˜ ์žฌ์งˆ ========== +SPECIAL_MATERIALS = { + "SUPER_ALLOYS": { + "INCONEL": { + "patterns": [r"INCONEL\s*(\d+)"], + "grades": { + "600": { + "composition": "Ni-Cr", + "temp_max": "1177ยฐC", + "applications": "๊ณ ์˜จ ์‚ฐํ™” ํ™˜๊ฒฝ" + }, + "625": { + "composition": "Ni-Cr-Mo", + "temp_max": "982ยฐC", + "applications": "๊ณ ์˜จ ๋ถ€์‹ ํ™˜๊ฒฝ" + }, + "718": { + "composition": "Ni-Cr-Fe", + "temp_max": "704ยฐC", + "applications": "๊ณ ์˜จ ๊ณ ๊ฐ•๋„" + } + }, + "manufacturing": "FORGED_OR_CAST" + }, + "HASTELLOY": { + "patterns": [r"HASTELLOY\s*([A-Z0-9]+)"], + "grades": { + "C276": { + "composition": "Ni-Mo-Cr", + "corrosion": "์ตœ๊ณ ๊ธ‰", + "applications": "๊ฐ•์‚ฐ์„ฑ ํ™˜๊ฒฝ" + }, + "C22": { + "composition": "Ni-Cr-Mo-W", + "applications": "ํ™”ํ•™๊ณต์ •" + } + }, + "manufacturing": "FORGED_OR_CAST" + }, + "MONEL": { + "patterns": [r"MONEL\s*(\d+)"], + "grades": { + "400": { + "composition": "Ni-Cu", + "applications": "ํ•ด์–‘ ํ™˜๊ฒฝ" + } + } + } + }, + "TITANIUM": { + "patterns": [ + r"TITANIUM(?:\s+(?:GR|GRADE)\s*(\d+))?", + r"Ti(?:\s+(?:GR|GRADE)\s*(\d+))?", + r"ASTM\s+B\d+\s+(?:GR|GRADE)\s*(\d+)" + ], + "grades": { + "1": { + "purity": "์ƒ์—…์šฉ ์ˆœํ‹ฐํƒ€๋Š„", + "strength": "๋‚ฎ์Œ", + "applications": "ํ™”ํ•™๊ณต์ •" + }, + "2": { + "purity": "์ƒ์—…์šฉ ์ˆœํ‹ฐํƒ€๋Š„ (์ผ๋ฐ˜)", + "strength": "๋ณดํ†ต", + "applications": "์ผ๋ฐ˜์šฉ" + }, + "5": { + "composition": "Ti-6Al-4V", + "strength": "๊ณ ๊ฐ•๋„", + "applications": "ํ•ญ๊ณต์šฐ์ฃผ" + } + }, + "manufacturing": "FORGED_OR_SEAMLESS" + }, + "COPPER_ALLOYS": { + "BRASS": { + "patterns": [r"BRASS|ํ™ฉ๋™"], + "composition": "Cu-Zn", + "applications": ["๊ณ„๊ธฐ์šฉ", "์„ ๋ฐ•์šฉ"] + }, + "BRONZE": { + "patterns": [r"BRONZE|์ฒญ๋™"], + "composition": "Cu-Sn", + "applications": ["ํ•ด์–‘์šฉ", "๋ฒ ์–ด๋ง"] + }, + "CUPRONICKEL": { + "patterns": [r"Cu-?Ni|CUPRONICKEL"], + "composition": "Cu-Ni", + "applications": ["ํ•ด์ˆ˜ ๋ฐฐ๊ด€"] + } + } +} + +# ========== ์ œ์ž‘๋ฐฉ๋ฒ•๋ณ„ ์žฌ์งˆ ๋งคํ•‘ ========== +MANUFACTURING_MATERIAL_MAP = { + "FORGED": { + "primary_standards": ["ASTM A182", "ASTM A105"], + "size_range": "1/8\" ~ 4\"", + "pressure_range": "150LB ~ 9000LB", + "characteristics": "๊ณ ๊ฐ•๋„, ๊ณ ์••์šฉ" + }, + "WELDED_FABRICATED": { + "primary_standards": ["ASTM A234", "ASTM A403"], + "size_range": "1/2\" ~ 48\"", + "pressure_range": "150LB ~ 2500LB", + "characteristics": "๋Œ€๊ตฌ๊ฒฝ, ์ค‘์ €์••์šฉ" + }, + "CAST": { + "primary_standards": ["ASTM A216", "ASTM A351"], + "size_range": "1/2\" ~ 24\"", + "applications": ["๋ณต์žกํ˜•์ƒ", "๋ฐธ๋ธŒ๋ณธ์ฒด"], + "characteristics": "๋ณต์žก ํ˜•์ƒ ๊ฐ€๋Šฅ" + }, + "SEAMLESS": { + "primary_standards": ["ASTM A106", "ASTM A312", "ASTM A335"], + "manufacturing": "์—ด๊ฐ„์••์—ฐ/๋ƒ‰๊ฐ„์ธ๋ฐœ", + "characteristics": "์ด์Œ์ƒˆ ์—†๋Š” ๊ด€" + }, + "WELDED": { + "primary_standards": ["ASTM A53", "ASTM A312"], + "manufacturing": "์ „๊ธฐ์ €ํ•ญ์šฉ์ ‘/์•„ํฌ์šฉ์ ‘", + "characteristics": "์šฉ์ ‘์„  ์žˆ๋Š” ๊ด€" + } +} + +# ========== ์ผ๋ฐ˜ ์žฌ์งˆ ํ‚ค์›Œ๋“œ ========== +GENERIC_MATERIAL_KEYWORDS = { + "CARBON_STEEL": [ + "CS", "CARBON STEEL", "ํƒ„์†Œ๊ฐ•", "SS400", "SM490" + ], + "STAINLESS_STEEL": [ + "SS", "STS", "STAINLESS", "์Šคํ…Œ์ธ๋ฆฌ์Šค", "304", "316", "321", "347" + ], + "ALLOY_STEEL": [ + "ALLOY", "ํ•ฉ๊ธˆ๊ฐ•", "CHROME MOLY", "Cr-Mo" + ], + "CAST_IRON": [ + "CAST IRON", "์ฃผ์ฒ ", "FC", "FCD" + ], + "DUCTILE_IRON": [ + "DUCTILE IRON", "๊ตฌ์ƒํ‘์—ฐ์ฃผ์ฒ ", "๋•ํƒ€์ผ" + ] +} diff --git a/backend/app/services/pipe_classifier.py b/backend/app/services/pipe_classifier.py new file mode 100644 index 0000000..2a96639 --- /dev/null +++ b/backend/app/services/pipe_classifier.py @@ -0,0 +1,337 @@ +""" +PIPE ๋ถ„๋ฅ˜ ์ „์šฉ ๋ชจ๋“ˆ +์žฌ์งˆ ๋ถ„๋ฅ˜ + ํŒŒ์ดํ”„ ํŠนํ™” ๋ถ„๋ฅ˜ +""" + +import re +from typing import Dict, List, Optional +from .material_classifier import classify_material, get_manufacturing_method_from_material + +# ========== PIPE ์ œ์กฐ ๋ฐฉ๋ฒ•๋ณ„ ๋ถ„๋ฅ˜ ========== +PIPE_MANUFACTURING = { + "SEAMLESS": { + "keywords": ["SEAMLESS", "SMLS", "์‹ฌ๋ฆฌ์Šค", "๋ฌด๊ณ„๋ชฉ"], + "confidence": 0.95, + "characteristics": "์ด์Œ์ƒˆ ์—†๋Š” ๊ณ ํ’ˆ์งˆ ํŒŒ์ดํ”„" + }, + "WELDED": { + "keywords": ["WELDED", "WLD", "ERW", "SAW", "์šฉ์ ‘", "์ „๊ธฐ์ €ํ•ญ์šฉ์ ‘"], + "confidence": 0.95, + "characteristics": "์šฉ์ ‘์œผ๋กœ ์ œ์กฐ๋œ ํŒŒ์ดํ”„" + }, + "CAST": { + "keywords": ["CAST", "์ฃผ์กฐ"], + "confidence": 0.85, + "characteristics": "์ฃผ์กฐ๋กœ ์ œ์กฐ๋œ ํŒŒ์ดํ”„" + } +} + +# ========== PIPE ๋ ๊ฐ€๊ณต๋ณ„ ๋ถ„๋ฅ˜ ========== +PIPE_END_PREP = { + "BOTH_ENDS_BEVELED": { + "codes": ["BOE", "BOTH END", "BOTH BEVELED", "์–‘์ชฝ๊ฐœ์„ "], + "cutting_note": "์–‘์ชฝ ๊ฐœ์„ ", + "machining_required": True, + "confidence": 0.95 + }, + "ONE_END_BEVELED": { + "codes": ["BE", "BEV", "PBE", "PIPE BEVELED END"], + "cutting_note": "ํ•œ์ชฝ ๊ฐœ์„ ", + "machining_required": True, + "confidence": 0.95 + }, + "NO_BEVEL": { + "codes": ["PE", "PLAIN END", "PPE", "ํ‰๋‹จ", "๋ฌด๊ฐœ์„ "], + "cutting_note": "๋ฌด ๊ฐœ์„ ", + "machining_required": False, + "confidence": 0.95 + } +} + +# ========== PIPE ์Šค์ผ€์ค„๋ณ„ ๋ถ„๋ฅ˜ ========== +PIPE_SCHEDULE = { + "patterns": [ + r"SCH\s*(\d+)", + r"SCHEDULE\s*(\d+)", + r"์Šค์ผ€์ค„\s*(\d+)" + ], + "common_schedules": ["10", "20", "40", "80", "120", "160"], + "wall_thickness_patterns": [ + r"(\d+(?:\.\d+)?)\s*mm\s*THK", + r"(\d+(?:\.\d+)?)\s*THK" + ] +} + +def classify_pipe(dat_file: str, description: str, main_nom: str, + length: float = None) -> Dict: + """ + ์™„์ „ํ•œ PIPE ๋ถ„๋ฅ˜ + + Args: + dat_file: DAT_FILE ํ•„๋“œ + description: DESCRIPTION ํ•„๋“œ + main_nom: MAIN_NOM ํ•„๋“œ (์‚ฌ์ด์ฆˆ) + length: LENGTH ํ•„๋“œ (์ ˆ๋‹จ ์น˜์ˆ˜) + + Returns: + ์™„์ „ํ•œ ํŒŒ์ดํ”„ ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ + """ + + # 1. ์žฌ์งˆ ๋ถ„๋ฅ˜ (๊ณตํ†ต ๋ชจ๋“ˆ ์‚ฌ์šฉ) + material_result = classify_material(description) + + # 2. ์ œ์กฐ ๋ฐฉ๋ฒ• ๋ถ„๋ฅ˜ + manufacturing_result = classify_pipe_manufacturing(description, material_result) + + # 3. ๋ ๊ฐ€๊ณต ๋ถ„๋ฅ˜ + end_prep_result = classify_pipe_end_preparation(description) + + # 4. ์Šค์ผ€์ค„ ๋ถ„๋ฅ˜ + schedule_result = classify_pipe_schedule(description) + + # 5. ์ ˆ๋‹จ ์น˜์ˆ˜ ์ฒ˜๋ฆฌ + cutting_dimensions = extract_pipe_cutting_dimensions(length, description) + + # 6. ์ตœ์ข… ๊ฒฐ๊ณผ ์กฐํ•ฉ + return { + "category": "PIPE", + + # ์žฌ์งˆ ์ •๋ณด (๊ณตํ†ต ๋ชจ๋“ˆ) + "material": { + "standard": material_result.get('standard', 'UNKNOWN'), + "grade": material_result.get('grade', 'UNKNOWN'), + "material_type": material_result.get('material_type', 'UNKNOWN'), + "confidence": material_result.get('confidence', 0.0) + }, + + # ํŒŒ์ดํ”„ ํŠนํ™” ์ •๋ณด + "manufacturing": { + "method": manufacturing_result.get('method', 'UNKNOWN'), + "confidence": manufacturing_result.get('confidence', 0.0), + "evidence": manufacturing_result.get('evidence', []) + }, + + "end_preparation": { + "type": end_prep_result.get('type', 'UNKNOWN'), + "cutting_note": end_prep_result.get('cutting_note', ''), + "machining_required": end_prep_result.get('machining_required', False), + "confidence": end_prep_result.get('confidence', 0.0) + }, + + "schedule": { + "schedule": schedule_result.get('schedule', 'UNKNOWN'), + "wall_thickness": schedule_result.get('wall_thickness', ''), + "confidence": schedule_result.get('confidence', 0.0) + }, + + "cutting_dimensions": cutting_dimensions, + + "size_info": { + "nominal_size": main_nom, + "length_mm": cutting_dimensions.get('length_mm') + }, + + # ์ „์ฒด ์‹ ๋ขฐ๋„ + "overall_confidence": calculate_pipe_confidence({ + "material": material_result.get('confidence', 0), + "manufacturing": manufacturing_result.get('confidence', 0), + "end_prep": end_prep_result.get('confidence', 0), + "schedule": schedule_result.get('confidence', 0) + }) + } + +def classify_pipe_manufacturing(description: str, material_result: Dict) -> Dict: + """ํŒŒ์ดํ”„ ์ œ์กฐ ๋ฐฉ๋ฒ• ๋ถ„๋ฅ˜""" + + desc_upper = description.upper() + + # 1. DESCRIPTION ํ‚ค์›Œ๋“œ ์šฐ์„  ํ™•์ธ + for method, method_data in PIPE_MANUFACTURING.items(): + for keyword in method_data["keywords"]: + if keyword in desc_upper: + return { + "method": method, + "confidence": method_data["confidence"], + "evidence": [f"KEYWORD: {keyword}"], + "characteristics": method_data["characteristics"] + } + + # 2. ์žฌ์งˆ ๊ทœ๊ฒฉ์œผ๋กœ ์ถ”์ • + material_manufacturing = get_manufacturing_method_from_material(material_result) + if material_manufacturing in ["SEAMLESS", "WELDED"]: + return { + "method": material_manufacturing, + "confidence": 0.8, + "evidence": [f"MATERIAL_STANDARD: {material_result.get('standard')}"], + "characteristics": PIPE_MANUFACTURING.get(material_manufacturing, {}).get("characteristics", "") + } + + # 3. ๊ธฐ๋ณธ๊ฐ’ + return { + "method": "UNKNOWN", + "confidence": 0.0, + "evidence": ["NO_MANUFACTURING_INFO"] + } + +def classify_pipe_end_preparation(description: str) -> Dict: + """ํŒŒ์ดํ”„ ๋ ๊ฐ€๊ณต ๋ถ„๋ฅ˜""" + + desc_upper = description.upper() + + # ์šฐ์„ ์ˆœ์œ„: ์–‘์ชฝ > ํ•œ์ชฝ > ๋ฌด๊ฐœ์„  + for prep_type, prep_data in PIPE_END_PREP.items(): + for code in prep_data["codes"]: + if code in desc_upper: + return { + "type": prep_type, + "cutting_note": prep_data["cutting_note"], + "machining_required": prep_data["machining_required"], + "confidence": prep_data["confidence"], + "matched_code": code + } + + # ๊ธฐ๋ณธ๊ฐ’: ๋ฌด๊ฐœ์„  + return { + "type": "NO_BEVEL", + "cutting_note": "๋ฌด ๊ฐœ์„  (๊ธฐ๋ณธ๊ฐ’)", + "machining_required": False, + "confidence": 0.5, + "matched_code": "DEFAULT" + } + +def classify_pipe_schedule(description: str) -> Dict: + """ํŒŒ์ดํ”„ ์Šค์ผ€์ค„ ๋ถ„๋ฅ˜""" + + desc_upper = description.upper() + + # 1. ์Šค์ผ€์ค„ ํŒจํ„ด ํ™•์ธ + for pattern in PIPE_SCHEDULE["patterns"]: + match = re.search(pattern, desc_upper) + if match: + schedule_num = match.group(1) + return { + "schedule": f"SCH {schedule_num}", + "schedule_number": schedule_num, + "confidence": 0.95, + "matched_pattern": pattern + } + + # 2. ๋‘๊ป˜ ํŒจํ„ด ํ™•์ธ + for pattern in PIPE_SCHEDULE["wall_thickness_patterns"]: + match = re.search(pattern, desc_upper) + if match: + thickness = match.group(1) + return { + "schedule": f"{thickness}mm THK", + "wall_thickness": f"{thickness}mm", + "confidence": 0.9, + "matched_pattern": pattern + } + + # 3. ๊ธฐ๋ณธ๊ฐ’ + return { + "schedule": "UNKNOWN", + "confidence": 0.0 + } + +def extract_pipe_cutting_dimensions(length: float, description: str) -> Dict: + """ํŒŒ์ดํ”„ ์ ˆ๋‹จ ์น˜์ˆ˜ ์ •๋ณด ์ถ”์ถœ""" + + cutting_info = { + "length_mm": None, + "source": None, + "confidence": 0.0, + "note": "" + } + + # 1. LENGTH ํ•„๋“œ์—์„œ ์ถ”์ถœ (์šฐ์„ ) + if length and length > 0: + cutting_info.update({ + "length_mm": round(length, 1), + "source": "LENGTH_FIELD", + "confidence": 0.95, + "note": f"๋„๋ฉด ๋ช…๊ธฐ ์น˜์ˆ˜: {length}mm" + }) + + # 2. DESCRIPTION์—์„œ ๋ฐฑ์—… ์ถ”์ถœ + else: + desc_length = extract_length_from_description(description) + if desc_length: + cutting_info.update({ + "length_mm": desc_length, + "source": "DESCRIPTION_PARSED", + "confidence": 0.8, + "note": f"์„ค๋ช…๋ž€์—์„œ ์ถ”์ถœ: {desc_length}mm" + }) + else: + cutting_info.update({ + "source": "NO_LENGTH_INFO", + "confidence": 0.0, + "note": "์ ˆ๋‹จ ์น˜์ˆ˜ ์ •๋ณด ์—†์Œ - ๋„๋ฉด ํ™•์ธ ํ•„์š”" + }) + + return cutting_info + +def extract_length_from_description(description: str) -> Optional[float]: + """DESCRIPTION์—์„œ ๊ธธ์ด ์ •๋ณด ์ถ”์ถœ""" + + length_patterns = [ + r'(\d+(?:\.\d+)?)\s*mm', + r'(\d+(?:\.\d+)?)\s*M', + r'(\d+(?:\.\d+)?)\s*LG', + r'L\s*=\s*(\d+(?:\.\d+)?)', + r'๊ธธ์ด\s*(\d+(?:\.\d+)?)' + ] + + for pattern in length_patterns: + match = re.search(pattern, description.upper()) + if match: + return float(match.group(1)) + + return None + +def calculate_pipe_confidence(confidence_scores: Dict) -> float: + """ํŒŒ์ดํ”„ ๋ถ„๋ฅ˜ ์ „์ฒด ์‹ ๋ขฐ๋„ ๊ณ„์‚ฐ""" + + scores = [score for score in confidence_scores.values() if score > 0] + + if not scores: + return 0.0 + + # ๊ฐ€์ค‘ ํ‰๊ท  (์žฌ์งˆ์ด ๊ฐ€์žฅ ์ค‘์š”) + weights = { + "material": 0.4, + "manufacturing": 0.25, + "end_prep": 0.2, + "schedule": 0.15 + } + + weighted_sum = sum( + confidence_scores.get(key, 0) * weight + for key, weight in weights.items() + ) + + return round(weighted_sum, 2) + +def generate_pipe_cutting_plan(pipe_data: Dict) -> Dict: + """ํŒŒ์ดํ”„ ์ ˆ๋‹จ ๊ณ„ํš ์ƒ์„ฑ""" + + cutting_plan = { + "material_spec": f"{pipe_data['material']['grade']} {pipe_data['schedule']['schedule']}", + "length_mm": pipe_data['cutting_dimensions']['length_mm'], + "end_preparation": pipe_data['end_preparation']['cutting_note'], + "machining_required": pipe_data['end_preparation']['machining_required'] + } + + # ์ ˆ๋‹จ ์ง€์‹œ์„œ ์ƒ์„ฑ + if cutting_plan["length_mm"]: + cutting_plan["cutting_instruction"] = f""" +์žฌ์งˆ: {cutting_plan['material_spec']} +์ ˆ๋‹จ๊ธธ์ด: {cutting_plan['length_mm']}mm +๋๊ฐ€๊ณต: {cutting_plan['end_preparation']} +๊ฐ€๊ณต์—ฌ๋ถ€: {'๋ฒ ๋ฒจ๊ฐ€๊ณต ํ•„์š”' if cutting_plan['machining_required'] else '์ง๊ฐ์ ˆ๋‹จ๋งŒ'} + """.strip() + else: + cutting_plan["cutting_instruction"] = "๋„๋ฉด ํ™•์ธ ํ›„ ์ ˆ๋‹จ ์น˜์ˆ˜ ์ž…๋ ฅ ํ•„์š”" + + return cutting_plan diff --git a/backend/app/services/spool_manager.py b/backend/app/services/spool_manager.py new file mode 100644 index 0000000..8289a71 --- /dev/null +++ b/backend/app/services/spool_manager.py @@ -0,0 +1,256 @@ +""" +์Šคํ’€ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ +๋„๋ฉด๋ณ„ ์—๋ฆฌ์–ด ๋„˜๋ฒ„์™€ ์Šคํ’€ ๋„˜๋ฒ„ ๊ด€๋ฆฌ +""" + +import re +from typing import Dict, List, Optional, Tuple +from datetime import datetime + +# ========== ์Šคํ’€ ๋„˜๋ฒ„๋ง ๊ทœ์น™ ========== +SPOOL_NUMBERING_RULES = { + "AREA_NUMBER": { + "pattern": r"#(\d{2})", # #01, #02, #03... + "format": "#{:02d}", # 2์ž๋ฆฌ ์ˆซ์ž + "range": (1, 99), # 01~99 + "description": "์—๋ฆฌ์–ด ๋„˜๋ฒ„" + }, + "SPOOL_NUMBER": { + "pattern": r"([A-Z]{1,2})", # A, B, C... AA, AB... + "format": "{}", + "sequence": ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", + "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", + "U", "V", "W", "X", "Y", "Z", + "AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ"], + "description": "์Šคํ’€ ๋„˜๋ฒ„" + } +} + +# ========== ํ”„๋กœ์ ํŠธ๋ณ„ ๋„˜๋ฒ„๋ง ์„ค์ • ========== +PROJECT_SPOOL_SETTINGS = { + "DEFAULT": { + "area_prefix": "#", + "area_digits": 2, + "spool_sequence": "ALPHABETIC", # A, B, C... + "separator": "-", + "format": "{dwg_name}-{area}-{spool}" # 1-IAR-3B1D0-0129-N-#01-A + }, + "CUSTOM": { + # ํ”„๋กœ์ ํŠธ๋ณ„ ์ปค์Šคํ…€ ์„ค์ • ๊ฐ€๋Šฅ + } +} + +class SpoolManager: + """์Šคํ’€ ๊ด€๋ฆฌ ํด๋ž˜์Šค""" + + def __init__(self, project_id: int = None): + self.project_id = project_id + self.settings = PROJECT_SPOOL_SETTINGS["DEFAULT"] + + def parse_spool_identifier(self, spool_id: str) -> Dict: + """๊ธฐ์กด ์Šคํ’€ ์‹๋ณ„์ž ํŒŒ์‹ฑ""" + + # ํŒจํ„ด: DWG_NAME-AREA-SPOOL + # ์˜ˆ: 1-IAR-3B1D0-0129-N-#01-A + + parts = spool_id.split("-") + + area_number = None + spool_number = None + dwg_base = None + + for i, part in enumerate(parts): + # ์—๋ฆฌ์–ด ๋„˜๋ฒ„ ์ฐพ๊ธฐ (#01, #02...) + if re.match(r"#\d{2}", part): + area_number = part + # ์Šคํ’€ ๋„˜๋ฒ„๋Š” ๋‹ค์Œ ํŒŒํŠธ + if i + 1 < len(parts): + spool_number = parts[i + 1] + # ๋„๋ฉด๋ช…์€ ์—๋ฆฌ์–ด ๋„˜๋ฒ„ ์•ž๊นŒ์ง€ + dwg_base = "-".join(parts[:i]) + break + + return { + "original_id": spool_id, + "dwg_base": dwg_base, + "area_number": area_number, + "spool_number": spool_number, + "is_valid": bool(area_number and spool_number), + "parsed_parts": parts + } + + def generate_spool_identifier(self, dwg_name: str, area_number: str, + spool_number: str) -> str: + """์ƒˆ๋กœ์šด ์Šคํ’€ ์‹๋ณ„์ž ์ƒ์„ฑ""" + + # ์—๋ฆฌ์–ด ๋„˜๋ฒ„ ํฌ๋งท ๊ฒ€์ฆ + area_formatted = self.format_area_number(area_number) + + # ์Šคํ’€ ๋„˜๋ฒ„ ํฌ๋งท ๊ฒ€์ฆ + spool_formatted = self.format_spool_number(spool_number) + + # ์กฐํ•ฉ + return f"{dwg_name}-{area_formatted}-{spool_formatted}" + + def format_area_number(self, area_input: str) -> str: + """์—๋ฆฌ์–ด ๋„˜๋ฒ„ ํฌ๋งทํŒ…""" + + # ์ˆซ์ž๋งŒ ์ถ”์ถœ + numbers = re.findall(r'\d+', area_input) + if numbers: + area_num = int(numbers[0]) + if 1 <= area_num <= 99: + return f"#{area_num:02d}" + + raise ValueError(f"์œ ํšจํ•˜์ง€ ์•Š์€ ์—๋ฆฌ์–ด ๋„˜๋ฒ„: {area_input}") + + def format_spool_number(self, spool_input: str) -> str: + """์Šคํ’€ ๋„˜๋ฒ„ ํฌ๋งทํŒ…""" + + spool_clean = spool_input.upper().strip() + + # ์œ ํšจํ•œ ์Šคํ’€ ๋„˜๋ฒ„์ธ์ง€ ํ™•์ธ + if re.match(r'^[A-Z]{1,2}$', spool_clean): + return spool_clean + + raise ValueError(f"์œ ํšจํ•˜์ง€ ์•Š์€ ์Šคํ’€ ๋„˜๋ฒ„: {spool_input}") + + def get_next_spool_number(self, dwg_name: str, area_number: str, + existing_spools: List[str] = None) -> str: + """๋‹ค์Œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์Šคํ’€ ๋„˜๋ฒ„ ์ถ”์ฒœ""" + + if not existing_spools: + return "A" # ์ฒซ ๋ฒˆ์งธ ์Šคํ’€ + + # ํ•ด๋‹น ๋„๋ฉด+์—๋ฆฌ์–ด์˜ ๊ธฐ์กด ์Šคํ’€๋“ค ํŒŒ์‹ฑ + used_spools = set() + area_formatted = self.format_area_number(area_number) + + for spool_id in existing_spools: + parsed = self.parse_spool_identifier(spool_id) + if (parsed["dwg_base"] == dwg_name and + parsed["area_number"] == area_formatted): + used_spools.add(parsed["spool_number"]) + + # ๋‹ค์Œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋„˜๋ฒ„ ์ฐพ๊ธฐ + sequence = SPOOL_NUMBERING_RULES["SPOOL_NUMBER"]["sequence"] + for spool_num in sequence: + if spool_num not in used_spools: + return spool_num + + raise ValueError("์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์Šคํ’€ ๋„˜๋ฒ„๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + + def validate_spool_identifier(self, spool_id: str) -> Dict: + """์Šคํ’€ ์‹๋ณ„์ž ์œ ํšจ์„ฑ ๊ฒ€์ฆ""" + + parsed = self.parse_spool_identifier(spool_id) + + validation_result = { + "is_valid": True, + "errors": [], + "warnings": [], + "parsed": parsed + } + + # ๋„๋ฉด๋ช… ํ™•์ธ + if not parsed["dwg_base"]: + validation_result["is_valid"] = False + validation_result["errors"].append("๋„๋ฉด๋ช…์ด ์—†์Šต๋‹ˆ๋‹ค") + + # ์—๋ฆฌ์–ด ๋„˜๋ฒ„ ํ™•์ธ + if not parsed["area_number"]: + validation_result["is_valid"] = False + validation_result["errors"].append("์—๋ฆฌ์–ด ๋„˜๋ฒ„๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + elif not re.match(r"#\d{2}", parsed["area_number"]): + validation_result["is_valid"] = False + validation_result["errors"].append("์—๋ฆฌ์–ด ๋„˜๋ฒ„ ํ˜•์‹์ด ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค (#01 ํ˜•ํƒœ)") + + # ์Šคํ’€ ๋„˜๋ฒ„ ํ™•์ธ + if not parsed["spool_number"]: + validation_result["is_valid"] = False + validation_result["errors"].append("์Šคํ’€ ๋„˜๋ฒ„๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + elif not re.match(r"^[A-Z]{1,2}$", parsed["spool_number"]): + validation_result["is_valid"] = False + validation_result["errors"].append("์Šคํ’€ ๋„˜๋ฒ„ ํ˜•์‹์ด ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค (A, B, AA ํ˜•ํƒœ)") + + return validation_result + +def classify_pipe_with_spool(dat_file: str, description: str, main_nom: str, + length: float = None, dwg_name: str = None, + area_number: str = None, spool_number: str = None) -> Dict: + """ํŒŒ์ดํ”„ ๋ถ„๋ฅ˜ + ์Šคํ’€ ์ •๋ณด ํ†ตํ•ฉ""" + + # ๊ธฐ๋ณธ ํŒŒ์ดํ”„ ๋ถ„๋ฅ˜ (๊ธฐ์กด ํ•จ์ˆ˜ ์‚ฌ์šฉ) + from .pipe_classifier import classify_pipe + pipe_result = classify_pipe(dat_file, description, main_nom, length) + + # ์Šคํ’€ ๊ด€๋ฆฌ์ž ์ƒ์„ฑ + spool_manager = SpoolManager() + + # ์Šคํ’€ ์ •๋ณด ์ถ”๊ฐ€ + spool_info = { + "dwg_name": dwg_name, + "area_number": None, + "spool_number": None, + "spool_identifier": None, + "manual_input_required": True, + "validation": None + } + + # ์—๋ฆฌ์–ด/์Šคํ’€ ๋„˜๋ฒ„๊ฐ€ ์ œ๊ณต๋œ ๊ฒฝ์šฐ + if area_number and spool_number: + try: + area_formatted = spool_manager.format_area_number(area_number) + spool_formatted = spool_manager.format_spool_number(spool_number) + spool_identifier = spool_manager.generate_spool_identifier( + dwg_name, area_formatted, spool_formatted + ) + + spool_info.update({ + "area_number": area_formatted, + "spool_number": spool_formatted, + "spool_identifier": spool_identifier, + "manual_input_required": False, + "validation": {"is_valid": True, "errors": []} + }) + + except ValueError as e: + spool_info["validation"] = { + "is_valid": False, + "errors": [str(e)] + } + + # ๊ธฐ์กด ๊ฒฐ๊ณผ์— ์Šคํ’€ ์ •๋ณด ์ถ”๊ฐ€ + pipe_result["spool_info"] = spool_info + + return pipe_result + +def group_pipes_by_spool(pipes_data: List[Dict]) -> Dict: + """ํŒŒ์ดํ”„๋“ค์„ ์Šคํ’€๋ณ„๋กœ ๊ทธ๋ฃนํ•‘""" + + spool_groups = {} + + for pipe in pipes_data: + spool_info = pipe.get("spool_info", {}) + spool_id = spool_info.get("spool_identifier", "UNGROUPED") + + if spool_id not in spool_groups: + spool_groups[spool_id] = { + "spool_identifier": spool_id, + "dwg_name": spool_info.get("dwg_name"), + "area_number": spool_info.get("area_number"), + "spool_number": spool_info.get("spool_number"), + "pipes": [], + "total_length": 0, + "pipe_count": 0 + } + + spool_groups[spool_id]["pipes"].append(pipe) + spool_groups[spool_id]["pipe_count"] += 1 + + # ๊ธธ์ด ํ•ฉ๊ณ„ + pipe_length = pipe.get("cutting_dimensions", {}).get("length_mm", 0) + if pipe_length: + spool_groups[spool_id]["total_length"] += pipe_length + + return spool_groups diff --git a/backend/app/services/spool_manager_v2.py b/backend/app/services/spool_manager_v2.py new file mode 100644 index 0000000..2d33836 --- /dev/null +++ b/backend/app/services/spool_manager_v2.py @@ -0,0 +1,229 @@ +""" +์ˆ˜์ •๋œ ์Šคํ’€ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ +๋„๋ฉด๋ณ„ ์Šคํ’€ ๋„˜๋ฒ„๋ง + ์—๋ฆฌ์–ด๋Š” ๋ณ„๋„ ๊ด€๋ฆฌ +""" + +import re +from typing import Dict, List, Optional, Tuple +from datetime import datetime + +# ========== ์Šคํ’€ ๋„˜๋ฒ„๋ง ๊ทœ์น™ ========== +SPOOL_NUMBERING_RULES = { + "SPOOL_NUMBER": { + "sequence": ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", + "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", + "U", "V", "W", "X", "Y", "Z"], + "description": "๋„๋ฉด๋ณ„ ์Šคํ’€ ๋„˜๋ฒ„" + }, + "AREA_NUMBER": { + "pattern": r"#(\d{2})", # #01, #02, #03... + "format": "#{:02d}", # 2์ž๋ฆฌ ์ˆซ์ž + "range": (1, 99), # 01~99 + "description": "๋ฌผ๋ฆฌ์  ๊ตฌ์—ญ ๋„˜๋ฒ„ (๋ณ„๋„ ๊ด€๋ฆฌ)" + } +} + +class SpoolManagerV2: + """์ˆ˜์ •๋œ ์Šคํ’€ ๊ด€๋ฆฌ ํด๋ž˜์Šค""" + + def __init__(self, project_id: int = None): + self.project_id = project_id + + def generate_spool_identifier(self, dwg_name: str, spool_number: str) -> str: + """ + ์Šคํ’€ ์‹๋ณ„์ž ์ƒ์„ฑ (๋„๋ฉด๋ช… + ์Šคํ’€๋„˜๋ฒ„) + + Args: + dwg_name: ๋„๋ฉด๋ช… (์˜ˆ: "A-1", "B-3") + spool_number: ์Šคํ’€๋„˜๋ฒ„ (์˜ˆ: "A", "B") + + Returns: + ์Šคํ’€ ์‹๋ณ„์ž (์˜ˆ: "A-1-A", "B-3-B") + """ + + # ์Šคํ’€ ๋„˜๋ฒ„ ํฌ๋งท ๊ฒ€์ฆ + spool_formatted = self.format_spool_number(spool_number) + + # ์กฐํ•ฉ: {๋„๋ฉด๋ช…}-{์Šคํ’€๋„˜๋ฒ„} + return f"{dwg_name}-{spool_formatted}" + + def parse_spool_identifier(self, spool_id: str) -> Dict: + """์Šคํ’€ ์‹๋ณ„์ž ํŒŒ์‹ฑ""" + + # ํŒจํ„ด: DWG_NAME-SPOOL_NUMBER + # ์˜ˆ: A-1-A, B-3-B, 1-IAR-3B1D0-0129-N-A + + # ๋งˆ์ง€๋ง‰ '-' ๊ธฐ์ค€์œผ๋กœ ๋ถ„๋ฆฌ + parts = spool_id.rsplit('-', 1) + + if len(parts) == 2: + dwg_base = parts[0] + spool_number = parts[1] + + return { + "original_id": spool_id, + "dwg_name": dwg_base, + "spool_number": spool_number, + "is_valid": self.validate_spool_number(spool_number), + "format": "CORRECT" + } + else: + return { + "original_id": spool_id, + "dwg_name": None, + "spool_number": None, + "is_valid": False, + "format": "INVALID" + } + + def format_spool_number(self, spool_input: str) -> str: + """์Šคํ’€ ๋„˜๋ฒ„ ํฌ๋งทํŒ… ๋ฐ ๊ฒ€์ฆ""" + + spool_clean = spool_input.upper().strip() + + # ์œ ํšจํ•œ ์Šคํ’€ ๋„˜๋ฒ„์ธ์ง€ ํ™•์ธ (A-Z ๋‹จ์ผ ๋ฌธ์ž) + if re.match(r'^[A-Z]$', spool_clean): + return spool_clean + + raise ValueError(f"์œ ํšจํ•˜์ง€ ์•Š์€ ์Šคํ’€ ๋„˜๋ฒ„: {spool_input} (A-Z ๋‹จ์ผ ๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅ)") + + def validate_spool_number(self, spool_number: str) -> bool: + """์Šคํ’€ ๋„˜๋ฒ„ ์œ ํšจ์„ฑ ๊ฒ€์ฆ""" + return bool(re.match(r'^[A-Z]$', spool_number)) + + def get_next_spool_number(self, dwg_name: str, existing_spools: List[str] = None) -> str: + """ํ•ด๋‹น ๋„๋ฉด์˜ ๋‹ค์Œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์Šคํ’€ ๋„˜๋ฒ„ ์ถ”์ฒœ""" + + if not existing_spools: + return "A" # ์ฒซ ๋ฒˆ์งธ ์Šคํ’€ + + # ํ•ด๋‹น ๋„๋ฉด์˜ ๊ธฐ์กด ์Šคํ’€๋“ค ํŒŒ์‹ฑ + used_spools = set() + + for spool_id in existing_spools: + parsed = self.parse_spool_identifier(spool_id) + if parsed["dwg_name"] == dwg_name and parsed["is_valid"]: + used_spools.add(parsed["spool_number"]) + + # ๋‹ค์Œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋„˜๋ฒ„ ์ฐพ๊ธฐ + sequence = SPOOL_NUMBERING_RULES["SPOOL_NUMBER"]["sequence"] + for spool_num in sequence: + if spool_num not in used_spools: + return spool_num + + raise ValueError(f"๋„๋ฉด {dwg_name}์—์„œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์Šคํ’€ ๋„˜๋ฒ„๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + + def validate_spool_identifier(self, spool_id: str) -> Dict: + """์Šคํ’€ ์‹๋ณ„์ž ์ „์ฒด ์œ ํšจ์„ฑ ๊ฒ€์ฆ""" + + parsed = self.parse_spool_identifier(spool_id) + + validation_result = { + "is_valid": True, + "errors": [], + "warnings": [], + "parsed": parsed + } + + # ๋„๋ฉด๋ช… ํ™•์ธ + if not parsed["dwg_name"]: + validation_result["is_valid"] = False + validation_result["errors"].append("๋„๋ฉด๋ช…์ด ์—†์Šต๋‹ˆ๋‹ค") + + # ์Šคํ’€ ๋„˜๋ฒ„ ํ™•์ธ + if not parsed["spool_number"]: + validation_result["is_valid"] = False + validation_result["errors"].append("์Šคํ’€ ๋„˜๋ฒ„๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + elif not self.validate_spool_number(parsed["spool_number"]): + validation_result["is_valid"] = False + validation_result["errors"].append("์Šคํ’€ ๋„˜๋ฒ„ ํ˜•์‹์ด ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค (A-Z ๋‹จ์ผ ๋ฌธ์ž)") + + return validation_result + +# ========== ์—๋ฆฌ์–ด ๊ด€๋ฆฌ (๋ณ„๋„ ์‹œ์Šคํ…œ) ========== +class AreaManager: + """์—๋ฆฌ์–ด ๊ด€๋ฆฌ ํด๋ž˜์Šค (๋ฌผ๋ฆฌ์  ๊ตฌ์—ญ)""" + + def __init__(self, project_id: int = None): + self.project_id = project_id + + def format_area_number(self, area_input: str) -> str: + """์—๋ฆฌ์–ด ๋„˜๋ฒ„ ํฌ๋งทํŒ…""" + + # ์ˆซ์ž๋งŒ ์ถ”์ถœ + numbers = re.findall(r'\d+', area_input) + if numbers: + area_num = int(numbers[0]) + if 1 <= area_num <= 99: + return f"#{area_num:02d}" + + raise ValueError(f"์œ ํšจํ•˜์ง€ ์•Š์€ ์—๋ฆฌ์–ด ๋„˜๋ฒ„: {area_input} (#01-#99)") + + def assign_drawings_to_area(self, area_number: str, drawing_names: List[str]) -> Dict: + """๋„๋ฉด๋“ค์„ ์—๋ฆฌ์–ด์— ํ• ๋‹น""" + + area_formatted = self.format_area_number(area_number) + + return { + "area_number": area_formatted, + "assigned_drawings": drawing_names, + "assignment_count": len(drawing_names), + "assignment_date": datetime.now().isoformat() + } + +def classify_pipe_with_corrected_spool(dat_file: str, description: str, main_nom: str, + length: float = None, dwg_name: str = None, + spool_number: str = None, area_number: str = None) -> Dict: + """ํŒŒ์ดํ”„ ๋ถ„๋ฅ˜ + ์ˆ˜์ •๋œ ์Šคํ’€ ์ •๋ณด""" + + # ๊ธฐ๋ณธ ํŒŒ์ดํ”„ ๋ถ„๋ฅ˜ + from .pipe_classifier import classify_pipe + pipe_result = classify_pipe(dat_file, description, main_nom, length) + + # ์Šคํ’€ ๊ด€๋ฆฌ์ž ์ƒ์„ฑ + spool_manager = SpoolManagerV2() + area_manager = AreaManager() + + # ์Šคํ’€ ์ •๋ณด ์ฒ˜๋ฆฌ + spool_info = { + "dwg_name": dwg_name, + "spool_number": None, + "spool_identifier": None, + "area_number": None, # ๋ณ„๋„ ๊ด€๋ฆฌ + "manual_input_required": True, + "validation": None + } + + # ์Šคํ’€ ๋„˜๋ฒ„๊ฐ€ ์ œ๊ณต๋œ ๊ฒฝ์šฐ + if dwg_name and spool_number: + try: + spool_identifier = spool_manager.generate_spool_identifier(dwg_name, spool_number) + + spool_info.update({ + "spool_number": spool_manager.format_spool_number(spool_number), + "spool_identifier": spool_identifier, + "manual_input_required": False, + "validation": {"is_valid": True, "errors": []} + }) + + except ValueError as e: + spool_info["validation"] = { + "is_valid": False, + "errors": [str(e)] + } + + # ์—๋ฆฌ์–ด ์ •๋ณด ์ฒ˜๋ฆฌ (๋ณ„๋„) + if area_number: + try: + area_formatted = area_manager.format_area_number(area_number) + spool_info["area_number"] = area_formatted + except ValueError as e: + spool_info["area_validation"] = { + "is_valid": False, + "errors": [str(e)] + } + + # ๊ธฐ์กด ๊ฒฐ๊ณผ์— ์Šคํ’€ ์ •๋ณด ์ถ”๊ฐ€ + pipe_result["spool_info"] = spool_info + + return pipe_result diff --git a/backend/app/services/test_fitting_classifier.py b/backend/app/services/test_fitting_classifier.py new file mode 100644 index 0000000..768255b --- /dev/null +++ b/backend/app/services/test_fitting_classifier.py @@ -0,0 +1,184 @@ +""" +FITTING ๋ถ„๋ฅ˜ ์‹œ์Šคํ…œ V2 ํ…Œ์ŠคํŠธ (ํฌ๊ธฐ ์ •๋ณด ์ถ”๊ฐ€) +""" + +from .fitting_classifier import classify_fitting, get_fitting_purchase_info + +def test_fitting_classification(): + """์‹ค์ œ BOM ๋ฐ์ดํ„ฐ๋กœ FITTING ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ""" + + test_cases = [ + { + "name": "90๋„ ์—˜๋ณด (BW)", + "dat_file": "90L_BW", + "description": "90 LR ELL, SCH 40, ASTM A234 GR WPB, SMLS", + "main_nom": "3\"", + "red_nom": None + }, + { + "name": "๋ฆฌ๋“€์‹ฑ ํ‹ฐ (BW)", + "dat_file": "TEE_RD_BW", + "description": "TEE RED, SCH 40 x SCH 40, ASTM A234 GR WPB, SMLS", + "main_nom": "4\"", + "red_nom": "2\"" + }, + { + "name": "๋™์‹ฌ ๋ฆฌ๋“€์„œ (BW)", + "dat_file": "CNC_BW", + "description": "RED CONC, SCH 40 x SCH 40, ASTM A234 GR WPB, SMLS", + "main_nom": "3\"", + "red_nom": "2\"" + }, + { + "name": "ํŽธ์‹ฌ ๋ฆฌ๋“€์„œ (BW)", + "dat_file": "ECC_BW", + "description": "RED ECC, SCH 40 x SCH 40, ASTM A234 GR WPB, SMLS", + "main_nom": "6\"", + "red_nom": "3\"" + }, + { + "name": "์†Œ์ผ“์›ฐ๋“œ ํ‹ฐ (๊ณ ์••)", + "dat_file": "TEE_SW_3000", + "description": "TEE, SW, 3000LB, ASTM A105", + "main_nom": "1\"", + "red_nom": None + }, + { + "name": "๋ฆฌ๋“€์‹ฑ ์†Œ์ผ“์›ฐ๋“œ ํ‹ฐ (๊ณ ์••)", + "dat_file": "TEE_RD_SW_3000", + "description": "TEE RED, SW, 3000LB, ASTM A105", + "main_nom": "1\"", + "red_nom": "1/2\"" + }, + { + "name": "์†Œ์ผ“์›ฐ๋“œ ์บก (๊ณ ์••)", + "dat_file": "CAP_SW_3000", + "description": "CAP, NPT(F), 3000LB, ASTM A105", + "main_nom": "1\"", + "red_nom": None + }, + { + "name": "์†Œ์ผ“์˜ค๋ › (๊ณ ์••)", + "dat_file": "SOL_SW_3000", + "description": "SOCK-O-LET, SW, 3000LB, ASTM A105", + "main_nom": "3\"", + "red_nom": "1\"" + }, + { + "name": "๋™์‹ฌ ์Šค์›จ์ง€ (BW)", + "dat_file": "SWG_CN_BW", + "description": "SWAGE CONC, SCH 40 x SCH 80, ASTM A105 GR , SMLS BBE", + "main_nom": "2\"", + "red_nom": "1\"" + }, + { + "name": "ํŽธ์‹ฌ ์Šค์›จ์ง€ (BW)", + "dat_file": "SWG_EC_BW", + "description": "SWAGE ECC, SCH 80 x SCH 80, ASTM A105 GR , SMLS PBE", + "main_nom": "1\"", + "red_nom": "3/4\"" + } + ] + + print("๐Ÿ”ง FITTING ๋ถ„๋ฅ˜ ์‹œ์Šคํ…œ V2 ํ…Œ์ŠคํŠธ ์‹œ์ž‘\n") + print("=" * 80) + + for i, test in enumerate(test_cases, 1): + print(f"\nํ…Œ์ŠคํŠธ {i}: {test['name']}") + print("-" * 60) + + result = classify_fitting( + test["dat_file"], + test["description"], + test["main_nom"], + test["red_nom"] + ) + + purchase_info = get_fitting_purchase_info(result) + + print(f"๐Ÿ“‹ ์ž…๋ ฅ:") + print(f" DAT_FILE: {test['dat_file']}") + print(f" DESCRIPTION: {test['description']}") + print(f" SIZE: {result['size_info']['size_description']}") + + print(f"\n๐Ÿ”ง ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ:") + print(f" ์žฌ์งˆ: {result['material']['standard']} | {result['material']['grade']}") + print(f" ํ”ผํŒ…ํƒ€์ž…: {result['fitting_type']['type']} - {result['fitting_type']['subtype']}") + print(f" ํฌ๊ธฐ์ •๋ณด: {result['size_info']['size_description']}") # โ† ์ถ”๊ฐ€! + if result['size_info']['reduced_size']: + print(f" ์ฃผ์‚ฌ์ด์ฆˆ: {result['size_info']['main_size']}") + print(f" ์ถ•์†Œ์‚ฌ์ด์ฆˆ: {result['size_info']['reduced_size']}") + print(f" ์—ฐ๊ฒฐ๋ฐฉ์‹: {result['connection_method']['method']}") + print(f" ์••๋ ฅ๋“ฑ๊ธ‰: {result['pressure_rating']['rating']} ({result['pressure_rating']['common_use']})") + print(f" ์ œ์ž‘๋ฐฉ๋ฒ•: {result['manufacturing']['method']} ({result['manufacturing']['characteristics']})") + + print(f"\n๐Ÿ“Š ์‹ ๋ขฐ๋„:") + print(f" ์ „์ฒด์‹ ๋ขฐ๋„: {result['overall_confidence']}") + print(f" ์žฌ์งˆ: {result['material']['confidence']}") + print(f" ํ”ผํŒ…ํƒ€์ž…: {result['fitting_type']['confidence']}") + print(f" ์—ฐ๊ฒฐ๋ฐฉ์‹: {result['connection_method']['confidence']}") + print(f" ์••๋ ฅ๋“ฑ๊ธ‰: {result['pressure_rating']['confidence']}") + + print(f"\n๐Ÿ›’ ๊ตฌ๋งค ์ •๋ณด:") + print(f" ๊ณต๊ธ‰์—…์ฒด: {purchase_info['supplier_type']}") + print(f" ์˜ˆ์ƒ๋‚ฉ๊ธฐ: {purchase_info['lead_time_estimate']}") + print(f" ๊ตฌ๋งค์นดํ…Œ๊ณ ๋ฆฌ: {purchase_info['purchase_category']}") + + # ํฌ๊ธฐ ์ •๋ณด ์ €์žฅ ํ™•์ธ + print(f"\n๐Ÿ’พ ์ €์žฅ๋  ๋ฐ์ดํ„ฐ:") + print(f" MAIN_NOM: {result['size_info']['main_size']}") + print(f" RED_NOM: {result['size_info']['reduced_size'] or 'NULL'}") + print(f" SIZE_DESCRIPTION: {result['size_info']['size_description']}") + + if i < len(test_cases): + print("\n" + "=" * 80) + +def test_fitting_edge_cases(): + """์˜ˆ์™ธ ์ผ€์ด์Šค ํ…Œ์ŠคํŠธ""" + + edge_cases = [ + { + "name": "DAT_FILE๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ", + "dat_file": "90L_BW", + "description": "", + "main_nom": "2\"", + "red_nom": None + }, + { + "name": "DESCRIPTION๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ", + "dat_file": "", + "description": "90 DEGREE ELBOW, ASTM A234 WPB", + "main_nom": "3\"", + "red_nom": None + }, + { + "name": "์•Œ ์ˆ˜ ์—†๋Š” DAT_FILE", + "dat_file": "UNKNOWN_CODE", + "description": "SPECIAL FITTING, CUSTOM MADE", + "main_nom": "4\"", + "red_nom": None + } + ] + + print("\n๐Ÿงช ์˜ˆ์™ธ ์ผ€์ด์Šค ํ…Œ์ŠคํŠธ\n") + print("=" * 50) + + for i, test in enumerate(edge_cases, 1): + print(f"\n์˜ˆ์™ธ ํ…Œ์ŠคํŠธ {i}: {test['name']}") + print("-" * 40) + + result = classify_fitting( + test["dat_file"], + test["description"], + test["main_nom"], + test["red_nom"] + ) + + print(f"๊ฒฐ๊ณผ: {result['fitting_type']['type']} - {result['fitting_type']['subtype']}") + print(f"ํฌ๊ธฐ: {result['size_info']['size_description']}") # โ† ์ถ”๊ฐ€! + print(f"์‹ ๋ขฐ๋„: {result['overall_confidence']}") + print(f"์ฆ๊ฑฐ: {result['fitting_type']['evidence']}") + +if __name__ == "__main__": + test_fitting_classification() + test_fitting_edge_cases() diff --git a/backend/app/services/test_flange_classifier.py b/backend/app/services/test_flange_classifier.py new file mode 100644 index 0000000..ee53793 --- /dev/null +++ b/backend/app/services/test_flange_classifier.py @@ -0,0 +1,126 @@ +""" +FLANGE ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ +""" + +from .flange_classifier import classify_flange, get_flange_purchase_info + +def test_flange_classification(): + """FLANGE ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ""" + + test_cases = [ + { + "name": "์›ฐ๋“œ๋„ฅ ํ”Œ๋žœ์ง€ (์ผ๋ฐ˜)", + "dat_file": "FLG_WN_150", + "description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105", + "main_nom": "4\"", + "red_nom": None + }, + { + "name": "์Šฌ๋ฆฝ์˜จ ํ”Œ๋žœ์ง€ (์ผ๋ฐ˜)", + "dat_file": "FLG_SO_300", + "description": "FLG SLIP ON RF, 300LB, ASTM A105", + "main_nom": "3\"", + "red_nom": None + }, + { + "name": "๋ธ”๋ผ์ธ๋“œ ํ”Œ๋žœ์ง€ (์ผ๋ฐ˜)", + "dat_file": "FLG_BL_150", + "description": "FLG BLIND RF, 150LB, ASTM A105", + "main_nom": "6\"", + "red_nom": None + }, + { + "name": "์†Œ์ผ“์›ฐ๋“œ ํ”Œ๋žœ์ง€ (๊ณ ์••)", + "dat_file": "FLG_SW_3000", + "description": "FLG SW, 3000LB, ASTM A105", + "main_nom": "1\"", + "red_nom": None + }, + { + "name": "์˜ค๋ฆฌํ”ผ์Šค ํ”Œ๋žœ์ง€ (SPECIAL)", + "dat_file": "FLG_ORI_150", + "description": "FLG ORIFICE RF, 150LB, 0.5 INCH BORE, ASTM A105", + "main_nom": "4\"", + "red_nom": None + }, + { + "name": "์ŠคํŽ™ํ„ฐํด ๋ธ”๋ผ์ธ๋“œ (SPECIAL)", + "dat_file": "FLG_SPB_300", + "description": "FLG SPECTACLE BLIND, 300LB, ASTM A105", + "main_nom": "6\"", + "red_nom": None + }, + { + "name": "๋ฆฌ๋“€์‹ฑ ํ”Œ๋žœ์ง€ (SPECIAL)", + "dat_file": "FLG_RED_300", + "description": "FLG REDUCING, 300LB, ASTM A105", + "main_nom": "6\"", + "red_nom": "4\"" + }, + { + "name": "์ŠคํŽ˜์ด์„œ ํ”Œ๋žœ์ง€ (SPECIAL)", + "dat_file": "FLG_SPC_600", + "description": "FLG SPACER, 600LB, 2 INCH THK, ASTM A105", + "main_nom": "3\"", + "red_nom": None + } + ] + + print("๐Ÿ”ฉ FLANGE ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ ์‹œ์ž‘\n") + print("=" * 80) + + for i, test in enumerate(test_cases, 1): + print(f"\nํ…Œ์ŠคํŠธ {i}: {test['name']}") + print("-" * 60) + + result = classify_flange( + test["dat_file"], + test["description"], + test["main_nom"], + test["red_nom"] + ) + + purchase_info = get_flange_purchase_info(result) + + print(f"๐Ÿ“‹ ์ž…๋ ฅ:") + print(f" DAT_FILE: {test['dat_file']}") + print(f" DESCRIPTION: {test['description']}") + print(f" SIZE: {result['size_info']['size_description']}") + + print(f"\n๐Ÿ”ฉ ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ:") + print(f" ์žฌ์งˆ: {result['material']['standard']} | {result['material']['grade']}") + print(f" ์นดํ…Œ๊ณ ๋ฆฌ: {'๐ŸŒŸ SPECIAL' if result['flange_category']['is_special'] else '๐Ÿ“‹ STANDARD'}") + print(f" ํ”Œ๋žœ์ง€ํƒ€์ž…: {result['flange_type']['type']}") + print(f" ํŠน์„ฑ: {result['flange_type']['characteristics']}") + print(f" ๋ฉด๊ฐ€๊ณต: {result['face_finish']['finish']}") + print(f" ์••๋ ฅ๋“ฑ๊ธ‰: {result['pressure_rating']['rating']} ({result['pressure_rating']['common_use']})") + print(f" ์ œ์ž‘๋ฐฉ๋ฒ•: {result['manufacturing']['method']} ({result['manufacturing']['characteristics']})") + + if result['flange_type']['special_features']: + print(f" ํŠน์ˆ˜๊ธฐ๋Šฅ: {', '.join(result['flange_type']['special_features'])}") + + print(f"\n๐Ÿ“Š ์‹ ๋ขฐ๋„:") + print(f" ์ „์ฒด์‹ ๋ขฐ๋„: {result['overall_confidence']}") + print(f" ์žฌ์งˆ: {result['material']['confidence']}") + print(f" ํ”Œ๋žœ์ง€ํƒ€์ž…: {result['flange_type']['confidence']}") + print(f" ๋ฉด๊ฐ€๊ณต: {result['face_finish']['confidence']}") + print(f" ์••๋ ฅ๋“ฑ๊ธ‰: {result['pressure_rating']['confidence']}") + + print(f"\n๐Ÿ›’ ๊ตฌ๋งค ์ •๋ณด:") + print(f" ๊ณต๊ธ‰์—…์ฒด: {purchase_info['supplier_type']}") + print(f" ์˜ˆ์ƒ๋‚ฉ๊ธฐ: {purchase_info['lead_time_estimate']}") + print(f" ๊ตฌ๋งค์นดํ…Œ๊ณ ๋ฆฌ: {purchase_info['purchase_category']}") + if purchase_info['special_requirements']: + print(f" ํŠน์ˆ˜์š”๊ตฌ์‚ฌํ•ญ: {', '.join(purchase_info['special_requirements'])}") + + print(f"\n๐Ÿ’พ ์ €์žฅ๋  ๋ฐ์ดํ„ฐ:") + print(f" MAIN_NOM: {result['size_info']['main_size']}") + print(f" RED_NOM: {result['size_info']['reduced_size'] or 'NULL'}") + print(f" FLANGE_CATEGORY: {'SPECIAL' if result['flange_category']['is_special'] else 'STANDARD'}") + print(f" FLANGE_TYPE: {result['flange_type']['type']}") + + if i < len(test_cases): + print("\n" + "=" * 80) + +if __name__ == "__main__": + test_flange_classification() diff --git a/backend/app/services/test_material_classifier.py b/backend/app/services/test_material_classifier.py new file mode 100644 index 0000000..db4e6a9 --- /dev/null +++ b/backend/app/services/test_material_classifier.py @@ -0,0 +1,53 @@ +""" +์žฌ์งˆ ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ +""" + +from .material_classifier import classify_material, get_manufacturing_method_from_material + +def test_material_classification(): + """์žฌ์งˆ ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ""" + + test_cases = [ + { + "description": "PIPE, SMLS, SCH 80, ASTM A106 GR B BOE-POE", + "expected_standard": "ASTM A106" + }, + { + "description": "TEE, SW, 3000LB, ASTM A182 F304", + "expected_standard": "ASTM A182" + }, + { + "description": "90 LR ELL, SCH 40, ASTM A234 GR WPB, SMLS", + "expected_standard": "ASTM A234" + }, + { + "description": "FLG WELD NECK RF SCH 40, 150LB, ASTM A105", + "expected_standard": "ASTM A105" + }, + { + "description": "GATE VALVE, ASTM A216 WCB", + "expected_standard": "ASTM A216" + }, + { + "description": "SPECIAL FITTING, INCONEL 625", + "expected_standard": "INCONEL" + } + ] + + print("๐Ÿ”ง ์žฌ์งˆ ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ ์‹œ์ž‘\n") + + for i, test in enumerate(test_cases, 1): + result = classify_material(test["description"]) + manufacturing = get_manufacturing_method_from_material(result) + + print(f"ํ…Œ์ŠคํŠธ {i}:") + print(f" ์ž…๋ ฅ: {test['description']}") + print(f" ๊ฒฐ๊ณผ: {result['standard']} | {result['grade']}") + print(f" ์žฌ์งˆํƒ€์ž…: {result['material_type']}") + print(f" ์ œ์ž‘๋ฐฉ๋ฒ•: {manufacturing}") + print(f" ์‹ ๋ขฐ๋„: {result['confidence']}") + print(f" ์ฆ๊ฑฐ: {result.get('evidence', [])}") + print() + +if __name__ == "__main__": + test_material_classification() diff --git a/backend/app/services/test_pipe_classifier.py b/backend/app/services/test_pipe_classifier.py new file mode 100644 index 0000000..4ed815c --- /dev/null +++ b/backend/app/services/test_pipe_classifier.py @@ -0,0 +1,55 @@ +""" +PIPE ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ +""" + +from .pipe_classifier import classify_pipe, generate_pipe_cutting_plan + +def test_pipe_classification(): + """PIPE ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ""" + + test_cases = [ + { + "dat_file": "PIP_PE", + "description": "PIPE, SMLS, SCH 80, ASTM A106 GR B BOE-POE", + "main_nom": "1\"", + "length": 798.1965 + }, + { + "dat_file": "NIP_TR", + "description": "NIPPLE, SMLS, SCH 80, ASTM A106 GR B PBE", + "main_nom": "1\"", + "length": 75.0 + }, + { + "dat_file": "PIPE_SPOOL", + "description": "PIPE SPOOL, WELDED, SCH 40, CS", + "main_nom": "2\"", + "length": None + } + ] + + print("๐Ÿ”ง PIPE ๋ถ„๋ฅ˜ ํ…Œ์ŠคํŠธ ์‹œ์ž‘\n") + + for i, test in enumerate(test_cases, 1): + result = classify_pipe( + test["dat_file"], + test["description"], + test["main_nom"], + test["length"] + ) + + cutting_plan = generate_pipe_cutting_plan(result) + + print(f"ํ…Œ์ŠคํŠธ {i}:") + print(f" ์ž…๋ ฅ: {test['description']}") + print(f" ์žฌ์งˆ: {result['material']['standard']} | {result['material']['grade']}") + print(f" ์ œ์กฐ๋ฐฉ๋ฒ•: {result['manufacturing']['method']}") + print(f" ๋๊ฐ€๊ณต: {result['end_preparation']['cutting_note']}") + print(f" ์Šค์ผ€์ค„: {result['schedule']['schedule']}") + print(f" ์ ˆ๋‹จ์น˜์ˆ˜: {result['cutting_dimensions']['length_mm']}mm") + print(f" ์ „์ฒด์‹ ๋ขฐ๋„: {result['overall_confidence']}") + print(f" ์ ˆ๋‹จ์ง€์‹œ: {cutting_plan['cutting_instruction']}") + print() + +if __name__ == "__main__": + test_pipe_classification() diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..763453d --- /dev/null +++ b/backend/database.py @@ -0,0 +1,29 @@ +์•„! PostgreSQL ์—ฐ๊ฒฐ ๋ฌธ์ œ์ž…๋‹ˆ๋‹ค! ๐Ÿ”ง ํ˜„์žฌ database.py์—์„œ ๋”๋ฏธ ์—ฐ๊ฒฐ ์ •๋ณด๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์–ด์„œ ๋ฐœ์ƒํ•œ ๋ฌธ์ œ๋„ค์š”. +๐Ÿ” ํ•ด๊ฒฐ ๋ฐฉ๋ฒ• ์„ ํƒ +๋ฐฉ๋ฒ• 1: SQLite๋กœ ๋ณ€๊ฒฝ (๊ฐ„๋‹จํ•จ) +bashcd TK-MP-Project/backend/app + +# database.py๋ฅผ SQLite๋กœ ๋ณ€๊ฒฝ +cat > database.py << 'EOF' +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os + +# SQLite ์‚ฌ์šฉ (๋กœ์ปฌ ๊ฐœ๋ฐœ์šฉ) +DATABASE_URL = "sqlite:///./materials.db" + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} # SQLite ์ „์šฉ ์„ค์ • +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/example_corrected_spool_usage.py b/backend/example_corrected_spool_usage.py new file mode 100644 index 0000000..40e6afb --- /dev/null +++ b/backend/example_corrected_spool_usage.py @@ -0,0 +1,30 @@ +""" +์ˆ˜์ •๋œ ์Šคํ’€ ์‹œ์Šคํ…œ ์‚ฌ์šฉ ์˜ˆ์‹œ +""" + +# ์‹œ๋‚˜๋ฆฌ์˜ค: A-1 ๋„๋ฉด์—์„œ ํŒŒ์ดํ”„ 3๊ฐœ ๋ฐœ๊ฒฌ +examples = [ + { + "dwg_name": "A-1", + "pipes": [ + {"description": "PIPE 1", "user_input_spool": "A"}, # A-1-A + {"description": "PIPE 2", "user_input_spool": "A"}, # A-1-A (๊ฐ™์€ ์Šคํ’€) + {"description": "PIPE 3", "user_input_spool": "B"} # A-1-B (๋‹ค๋ฅธ ์Šคํ’€) + ], + "area_assignment": "#01" # ๋ณ„๋„: A-1 ๋„๋ฉด์€ #01 ๊ตฌ์—ญ์— ์œ„์น˜ + } +] + +# ๊ฒฐ๊ณผ: +spool_identifiers = [ + "A-1-A", # ํŒŒ์ดํ”„ 1, 2๊ฐ€ ์†ํ•จ + "A-1-B" # ํŒŒ์ดํ”„ 3์ด ์†ํ•จ +] + +area_assignment = { + "#01": ["A-1"] # A-1 ๋„๋ฉด์€ #01 ๊ตฌ์—ญ์— ๋ฌผ๋ฆฌ์ ์œผ๋กœ ์œ„์น˜ +} + +print("โœ… ์ˆ˜์ •๋œ ์Šคํ’€ ๊ตฌ์กฐ๊ฐ€ ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!") +print(f"์Šคํ’€ ์‹๋ณ„์ž: {spool_identifiers}") +print(f"์—๋ฆฌ์–ด ํ• ๋‹น: {area_assignment}") diff --git a/backend/temp_main_update.py b/backend/temp_main_update.py new file mode 100644 index 0000000..aa69547 --- /dev/null +++ b/backend/temp_main_update.py @@ -0,0 +1,5 @@ +# main.py์— ์ถ”๊ฐ€ํ•  import +from .api import spools + +# app.include_router ์ถ”๊ฐ€ +app.include_router(spools.router, prefix="/api/spools", tags=["์Šคํ’€ ๊ด€๋ฆฌ"])