feat(tkeg): tkeg BOM 자재관리 서비스 초기 세팅 (api + web + docker-compose)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
13
tkeg/api/app/services/__init__.py
Normal file
13
tkeg/api/app/services/__init__.py
Normal file
@@ -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'
|
||||
]
|
||||
362
tkeg/api/app/services/activity_logger.py
Normal file
362
tkeg/api/app/services/activity_logger.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
사용자 활동 로그 서비스
|
||||
모든 업무 활동을 추적하고 기록하는 서비스
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import Request
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ActivityLogger:
|
||||
"""사용자 활동 로그 관리 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def log_activity(
|
||||
self,
|
||||
username: str,
|
||||
activity_type: str,
|
||||
activity_description: str,
|
||||
target_id: Optional[int] = None,
|
||||
target_type: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""
|
||||
사용자 활동 로그 기록
|
||||
|
||||
Args:
|
||||
username: 사용자명 (필수)
|
||||
activity_type: 활동 유형 (FILE_UPLOAD, PROJECT_CREATE 등)
|
||||
activity_description: 활동 설명
|
||||
target_id: 대상 ID (파일, 프로젝트 등)
|
||||
target_type: 대상 유형 (FILE, PROJECT 등)
|
||||
user_id: 사용자 ID
|
||||
ip_address: IP 주소
|
||||
user_agent: 브라우저 정보
|
||||
metadata: 추가 메타데이터
|
||||
|
||||
Returns:
|
||||
int: 생성된 로그 ID
|
||||
"""
|
||||
try:
|
||||
insert_query = text("""
|
||||
INSERT INTO user_activity_logs (
|
||||
user_id, username, activity_type, activity_description,
|
||||
target_id, target_type, ip_address, user_agent, metadata
|
||||
) VALUES (
|
||||
:user_id, :username, :activity_type, :activity_description,
|
||||
:target_id, :target_type, :ip_address, :user_agent, :metadata
|
||||
) RETURNING id
|
||||
""")
|
||||
|
||||
result = self.db.execute(insert_query, {
|
||||
'user_id': user_id,
|
||||
'username': username,
|
||||
'activity_type': activity_type,
|
||||
'activity_description': activity_description,
|
||||
'target_id': target_id,
|
||||
'target_type': target_type,
|
||||
'ip_address': ip_address,
|
||||
'user_agent': user_agent,
|
||||
'metadata': json.dumps(metadata) if metadata else None
|
||||
})
|
||||
|
||||
log_id = result.fetchone()[0]
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Activity logged: {username} - {activity_type} - {activity_description}")
|
||||
return log_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log activity: {str(e)}")
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
def log_file_upload(
|
||||
self,
|
||||
username: str,
|
||||
file_id: int,
|
||||
filename: str,
|
||||
file_size: int,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""파일 업로드 활동 로그"""
|
||||
metadata = {
|
||||
'filename': filename,
|
||||
'file_size': file_size,
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'upload_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='FILE_UPLOAD',
|
||||
activity_description=f'BOM 파일 업로드: {filename} (Job: {job_no}, Rev: {revision})',
|
||||
target_id=file_id,
|
||||
target_type='FILE',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def log_project_create(
|
||||
self,
|
||||
username: str,
|
||||
project_id: int,
|
||||
project_name: str,
|
||||
job_no: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""프로젝트 생성 활동 로그"""
|
||||
metadata = {
|
||||
'project_name': project_name,
|
||||
'job_no': job_no,
|
||||
'create_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='PROJECT_CREATE',
|
||||
activity_description=f'프로젝트 생성: {project_name} ({job_no})',
|
||||
target_id=project_id,
|
||||
target_type='PROJECT',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def log_material_classify(
|
||||
self,
|
||||
username: str,
|
||||
file_id: int,
|
||||
classified_count: int,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""자재 분류 활동 로그"""
|
||||
metadata = {
|
||||
'classified_count': classified_count,
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'classify_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='MATERIAL_CLASSIFY',
|
||||
activity_description=f'자재 분류 완료: {classified_count}개 자재 (Job: {job_no}, Rev: {revision})',
|
||||
target_id=file_id,
|
||||
target_type='FILE',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def log_purchase_confirm(
|
||||
self,
|
||||
username: str,
|
||||
job_no: str,
|
||||
revision: str,
|
||||
confirmed_count: int,
|
||||
total_amount: Optional[float] = None,
|
||||
user_id: Optional[int] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None
|
||||
) -> int:
|
||||
"""구매 확정 활동 로그"""
|
||||
metadata = {
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'confirmed_count': confirmed_count,
|
||||
'total_amount': total_amount,
|
||||
'confirm_time': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return self.log_activity(
|
||||
username=username,
|
||||
activity_type='PURCHASE_CONFIRM',
|
||||
activity_description=f'구매 확정: {confirmed_count}개 품목 (Job: {job_no}, Rev: {revision})',
|
||||
target_id=None, # 구매는 특정 ID가 없음
|
||||
target_type='PURCHASE',
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
def get_user_activities(
|
||||
self,
|
||||
username: str,
|
||||
activity_type: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> list:
|
||||
"""사용자 활동 이력 조회"""
|
||||
try:
|
||||
where_clause = "WHERE username = :username"
|
||||
params = {'username': username}
|
||||
|
||||
if activity_type:
|
||||
where_clause += " AND activity_type = :activity_type"
|
||||
params['activity_type'] = activity_type
|
||||
|
||||
query = text(f"""
|
||||
SELECT
|
||||
id, activity_type, activity_description,
|
||||
target_id, target_type, metadata, created_at
|
||||
FROM user_activity_logs
|
||||
{where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
|
||||
params.update({'limit': limit, 'offset': offset})
|
||||
result = self.db.execute(query, params)
|
||||
|
||||
activities = []
|
||||
for row in result.fetchall():
|
||||
activity = {
|
||||
'id': row[0],
|
||||
'activity_type': row[1],
|
||||
'activity_description': row[2],
|
||||
'target_id': row[3],
|
||||
'target_type': row[4],
|
||||
'metadata': json.loads(row[5]) if row[5] else {},
|
||||
'created_at': row[6].isoformat() if row[6] else None
|
||||
}
|
||||
activities.append(activity)
|
||||
|
||||
return activities
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user activities: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_recent_activities(
|
||||
self,
|
||||
days: int = 7,
|
||||
limit: int = 100
|
||||
) -> list:
|
||||
"""최근 활동 조회 (전체 사용자)"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
username, activity_type, activity_description,
|
||||
target_id, target_type, created_at
|
||||
FROM user_activity_logs
|
||||
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '%s days'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
""" % days)
|
||||
|
||||
result = self.db.execute(query, {'limit': limit})
|
||||
|
||||
activities = []
|
||||
for row in result.fetchall():
|
||||
activity = {
|
||||
'username': row[0],
|
||||
'activity_type': row[1],
|
||||
'activity_description': row[2],
|
||||
'target_id': row[3],
|
||||
'target_type': row[4],
|
||||
'created_at': row[5].isoformat() if row[5] else None
|
||||
}
|
||||
activities.append(activity)
|
||||
|
||||
return activities
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get recent activities: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def get_client_info(request: Request) -> tuple:
|
||||
"""
|
||||
요청에서 클라이언트 정보 추출
|
||||
|
||||
Args:
|
||||
request: FastAPI Request 객체
|
||||
|
||||
Returns:
|
||||
tuple: (ip_address, user_agent)
|
||||
"""
|
||||
# IP 주소 추출 (프록시 고려)
|
||||
ip_address = (
|
||||
request.headers.get('x-forwarded-for', '').split(',')[0].strip() or
|
||||
request.headers.get('x-real-ip', '') or
|
||||
request.client.host if request.client else 'unknown'
|
||||
)
|
||||
|
||||
# User-Agent 추출
|
||||
user_agent = request.headers.get('user-agent', 'unknown')
|
||||
|
||||
return ip_address, user_agent
|
||||
|
||||
|
||||
def log_activity_from_request(
|
||||
db: Session,
|
||||
request: Request,
|
||||
username: str,
|
||||
activity_type: str,
|
||||
activity_description: str,
|
||||
target_id: Optional[int] = None,
|
||||
target_type: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""
|
||||
요청 정보를 포함한 활동 로그 기록 (편의 함수)
|
||||
|
||||
Args:
|
||||
db: 데이터베이스 세션
|
||||
request: FastAPI Request 객체
|
||||
username: 사용자명
|
||||
activity_type: 활동 유형
|
||||
activity_description: 활동 설명
|
||||
target_id: 대상 ID
|
||||
target_type: 대상 유형
|
||||
user_id: 사용자 ID
|
||||
metadata: 추가 메타데이터
|
||||
|
||||
Returns:
|
||||
int: 생성된 로그 ID
|
||||
"""
|
||||
ip_address, user_agent = get_client_info(request)
|
||||
|
||||
activity_logger = ActivityLogger(db)
|
||||
return activity_logger.log_activity(
|
||||
username=username,
|
||||
activity_type=activity_type,
|
||||
activity_description=activity_description,
|
||||
target_id=target_id,
|
||||
target_type=target_type,
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata
|
||||
)
|
||||
1251
tkeg/api/app/services/bolt_classifier.py
Normal file
1251
tkeg/api/app/services/bolt_classifier.py
Normal file
File diff suppressed because it is too large
Load Diff
157
tkeg/api/app/services/classifier_constants.py
Normal file
157
tkeg/api/app/services/classifier_constants.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
자재 분류 시스템용 상수 및 키워드 정의
|
||||
중복 로직 제거 및 유지보수성 향상을 위해 중앙 집중화됨
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
# ==============================================================================
|
||||
# 1. 압력 등급 (Pressure Ratings)
|
||||
# ==============================================================================
|
||||
|
||||
# 단순 키워드 목록 (Integrated Classifier용)
|
||||
LEVEL3_PRESSURE_KEYWORDS = [
|
||||
"150LB", "300LB", "600LB", "900LB", "1500LB",
|
||||
"2500LB", "3000LB", "6000LB", "9000LB"
|
||||
]
|
||||
|
||||
# 상세 스펙 및 메타데이터 (Fitting Classifier용)
|
||||
PRESSURE_RATINGS_SPECS = {
|
||||
"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": "소구경 극고압용"}
|
||||
}
|
||||
|
||||
# 정규식 패턴 (Fitting Classifier용)
|
||||
PRESSURE_PATTERNS = [
|
||||
r"(\d+)LB",
|
||||
r"CLASS\s*(\d+)",
|
||||
r"CL\s*(\d+)",
|
||||
r"(\d+)#",
|
||||
r"(\d+)\s*LB"
|
||||
]
|
||||
|
||||
# ==============================================================================
|
||||
# 2. OLET 키워드 (OLET Keywords)
|
||||
# ==============================================================================
|
||||
# Fitting Classifier와 Integrated Classifier에서 공통 사용
|
||||
OLET_KEYWORDS = [
|
||||
# Full Names
|
||||
"SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET",
|
||||
"NIP-O-LET", "COUP-O-LET",
|
||||
# Variations
|
||||
"SOCKOLET", "WELDOLET", "ELLOLET", "THREADOLET", "ELBOLET", "NIPOLET", "COUPOLET",
|
||||
"OLET", "올렛", "O-LET", "SOCKLET", "SOCKET-O-LET", "WELD O-LET", "ELL O-LET",
|
||||
"THREADED-O-LET", "ELBOW-O-LET", "NIPPLE-O-LET", "COUPLING-O-LET",
|
||||
# Abbreviations (Caution: specific context needed sometimes)
|
||||
"SOL", "WOL", "EOL", "TOL", "NOL", "COL"
|
||||
]
|
||||
|
||||
# ==============================================================================
|
||||
# 3. 연결 방식 (Connection Methods)
|
||||
# ==============================================================================
|
||||
LEVEL3_CONNECTION_KEYWORDS = {
|
||||
"SW": ["SW", "SOCKET WELD", "소켓웰드", "SOCKET-WELD", "_SW_"],
|
||||
"THD": ["THD", "THREADED", "NPT", "나사", "THRD", "TR", "_TR", "_THD"],
|
||||
"BW": ["BW", "BUTT WELD", "맞대기용접", "BUTT-WELD", "_BW"],
|
||||
"FL": ["FL", "FLANGED", "플랜지", "FLG", "_FL_"]
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 4. 재질 키워드 (Material Keywords)
|
||||
# ==============================================================================
|
||||
LEVEL4_MATERIAL_KEYWORDS = {
|
||||
"PIPE": ["A106", "A333", "A312", "A53"],
|
||||
"FITTING": ["A234", "A403", "A420"],
|
||||
"FLANGE": ["A182", "A350"],
|
||||
"VALVE": ["A216", "A217", "A351", "A352"],
|
||||
"BOLT": ["A193", "A194", "A320", "A325", "A490"]
|
||||
}
|
||||
|
||||
GENERIC_MATERIALS = {
|
||||
"A105": ["VALVE", "FLANGE", "FITTING"],
|
||||
"316": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"],
|
||||
"304": ["VALVE", "FLANGE", "FITTING", "PIPE", "BOLT"]
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 5. 메인 분류 키워드 (Level 1 Type Keywords)
|
||||
# ==============================================================================
|
||||
LEVEL1_TYPE_KEYWORDS = {
|
||||
"BOLT": [
|
||||
"FLANGE BOLT", "U-BOLT", "U BOLT", "BOLT", "STUD", "NUT", "SCREW",
|
||||
"WASHER", "볼트", "너트", "스터드", "나사", "와셔", "유볼트"
|
||||
],
|
||||
"VALVE": [
|
||||
"VALVE", "GATE", "BALL", "GLOBE", "CHECK", "BUTTERFLY", "NEEDLE",
|
||||
"RELIEF", "SIGHT GLASS", "STRAINER", "밸브", "게이트", "볼", "글로브",
|
||||
"체크", "버터플라이", "니들", "릴리프", "사이트글라스", "스트레이너"
|
||||
],
|
||||
"FLANGE": [
|
||||
"FLG", "FLANGE", "플랜지", "프랜지", "ORIFICE", "SPECTACLE", "PADDLE",
|
||||
"SPACER", "BLIND", "REDUCING FLANGE", "RED FLANGE"
|
||||
],
|
||||
"PIPE": [
|
||||
"PIPE", "TUBE", "파이프", "배관", "SMLS", "SEAMLESS"
|
||||
],
|
||||
"FITTING": [
|
||||
# Standard Fittings
|
||||
"ELBOW", "ELL", "TEE", "REDUCER", "CAP", "COUPLING", "NIPPLE", "SWAGE", "PLUG",
|
||||
"엘보", "티", "리듀서", "캡", "니플", "커플링", "플러그", "CONC", "ECC",
|
||||
# Instrument Fittings
|
||||
"SWAGELOK", "DK-LOK", "HY-LOK", "SUPERLOK", "TUBE FITTING", "COMPRESSION",
|
||||
"UNION", "FERRULE", "NUT & FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR",
|
||||
"TUBE ADAPTER", "PORT CONNECTOR", "CONNECTOR"
|
||||
] + OLET_KEYWORDS, # OLET Keywords 병합
|
||||
"GASKET": [
|
||||
"GASKET", "GASK", "가스켓", "SWG", "SPIRAL"
|
||||
],
|
||||
"INSTRUMENT": [
|
||||
"GAUGE", "TRANSMITTER", "SENSOR", "THERMOMETER", "계기", "게이지", "트랜스미터", "센서"
|
||||
],
|
||||
"SUPPORT": [
|
||||
"URETHANE BLOCK", "URETHANE", "BLOCK SHOE", "CLAMP", "SUPPORT", "HANGER",
|
||||
"SPRING", "우레탄", "블록", "클램프", "서포트", "행거", "스프링", "PIPE CLAMP"
|
||||
],
|
||||
"PLATE": [
|
||||
"PLATE", "PL", "CHECKER PLATE", "판재", "철판"
|
||||
],
|
||||
"STRUCTURAL": [
|
||||
"H-BEAM", "BEAM", "ANGLE", "CHANNEL", "H-SECTION", "I-BEAM", "형강", "앵글", "채널"
|
||||
]
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# 6. 서브타입 키워드 (Level 2 Subtype Keywords)
|
||||
# ==============================================================================
|
||||
LEVEL2_SUBTYPE_KEYWORDS = {
|
||||
"VALVE": {
|
||||
"GATE": ["GATE VALVE", "GATE", "게이트 밸브"],
|
||||
"BALL": ["BALL VALVE", "BALL", "볼 밸브"],
|
||||
"GLOBE": ["GLOBE VALVE", "GLOBE", "글로브 밸브"],
|
||||
"CHECK": ["CHECK VALVE", "CHECK", "체크 밸브", "역지 밸브"]
|
||||
},
|
||||
"FLANGE": {
|
||||
"WELD_NECK": ["WELD NECK", "WN", "웰드넥"],
|
||||
"SLIP_ON": ["SLIP ON", "SO", "슬립온"],
|
||||
"BLIND": ["BLIND", "BL", "막음", "차단"],
|
||||
"SOCKET_WELD": ["SOCKET WELD", "소켓웰드"]
|
||||
},
|
||||
"BOLT": {
|
||||
"HEX_BOLT": ["HEX BOLT", "HEXAGON", "육각 볼트"],
|
||||
"STUD_BOLT": ["STUD BOLT", "STUD", "스터드 볼트"],
|
||||
"U_BOLT": ["U-BOLT", "U BOLT", "유볼트"]
|
||||
},
|
||||
"SUPPORT": {
|
||||
"URETHANE_BLOCK": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록"],
|
||||
"CLAMP": ["CLAMP", "클램프"],
|
||||
"HANGER": ["HANGER", "SUPPORT", "행거", "서포트"],
|
||||
"SPRING": ["SPRING", "스프링"]
|
||||
}
|
||||
}
|
||||
300
tkeg/api/app/services/excel_parser.py
Normal file
300
tkeg/api/app/services/excel_parser.py
Normal file
@@ -0,0 +1,300 @@
|
||||
|
||||
import pandas as pd
|
||||
import re
|
||||
from typing import List, Dict, Optional
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# 허용된 확장자
|
||||
ALLOWED_EXTENSIONS = {".xlsx", ".xls", ".csv"}
|
||||
|
||||
class BOMParser:
|
||||
"""BOM 파일 파싱을 담당하는 클래스"""
|
||||
|
||||
@staticmethod
|
||||
def validate_extension(filename: str) -> bool:
|
||||
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_filename(original_filename: str) -> str:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
stem = Path(original_filename).stem
|
||||
suffix = Path(original_filename).suffix
|
||||
return f"{stem}_{timestamp}_{unique_id}{suffix}"
|
||||
|
||||
@staticmethod
|
||||
def detect_format(df: pd.DataFrame) -> str:
|
||||
"""
|
||||
엑셀 헤더를 분석하여 양식을 감지합니다.
|
||||
|
||||
Returns:
|
||||
'INVENTOR': 인벤터 추출 양식 (NO., NAME, Q'ty, LENGTH & THICKNESS...)
|
||||
'STANDARD': 기존 표준/퍼지 매핑 엑셀 (DWG_NAME, LINE_NUM, MAIN_NOM...)
|
||||
"""
|
||||
columns = [str(c).strip().upper() for c in df.columns]
|
||||
|
||||
# 인벤터 양식 특징 (오타 포함)
|
||||
INVENTOR_KEYWORDS = ['LENGTH & THICKNESS', 'DESCIPTION', "Q'TY"]
|
||||
|
||||
for keyword in INVENTOR_KEYWORDS:
|
||||
if any(keyword in col for col in columns):
|
||||
return 'INVENTOR'
|
||||
|
||||
# 표준 양식 특징
|
||||
STANDARD_KEYWORDS = ['MAIN_NOM', 'DWG_NAME', 'LINE_NUM']
|
||||
for keyword in STANDARD_KEYWORDS:
|
||||
if any(keyword in col for col in columns):
|
||||
return 'STANDARD'
|
||||
|
||||
return 'STANDARD' # 기본값
|
||||
|
||||
@classmethod
|
||||
def parse_file(cls, file_path: str) -> List[Dict]:
|
||||
"""파일 경로를 받아 적절한 파서를 통해 데이터를 추출합니다."""
|
||||
file_extension = Path(file_path).suffix.lower()
|
||||
|
||||
try:
|
||||
if file_extension == ".csv":
|
||||
df = pd.read_csv(file_path, encoding='utf-8')
|
||||
elif file_extension in [".xlsx", ".xls"]:
|
||||
# xlrd 엔진 명시 (xls 지원)
|
||||
if file_extension == ".xls":
|
||||
df = pd.read_excel(file_path, sheet_name=0, engine='xlrd')
|
||||
else:
|
||||
df = pd.read_excel(file_path, sheet_name=0, engine='openpyxl')
|
||||
else:
|
||||
raise ValueError("지원하지 않는 파일 형식")
|
||||
|
||||
# 데이터프레임 전처리 (빈 행 제거 등)
|
||||
df = df.dropna(how='all')
|
||||
|
||||
# 양식 감지
|
||||
format_type = cls.detect_format(df)
|
||||
print(f"📋 감지된 BOM 양식: {format_type}")
|
||||
|
||||
if format_type == 'INVENTOR':
|
||||
return cls._parse_inventor_bom(df)
|
||||
else:
|
||||
return cls._parse_standard_bom(df)
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"파일 파싱 실패: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def _parse_standard_bom(df: pd.DataFrame) -> List[Dict]:
|
||||
"""기존의 퍼지 매핑 방식 파서 (표준 양식)"""
|
||||
# 컬럼명 전처리
|
||||
df.columns = df.columns.str.strip().str.upper()
|
||||
|
||||
# 대소문자 구분 없는 매핑을 위해 컬럼명 대문자화 사용
|
||||
column_mapping = {
|
||||
'description': ['DESCRIPTION', 'ITEM', 'MATERIAL', '품명', '자재명', 'SPECIFICATION'],
|
||||
'quantity': ['QTY', 'QUANTITY', 'EA', '수량', 'AMOUNT'],
|
||||
'main_size': ['MAIN_NOM', 'NOMINAL_DIAMETER', 'ND', '주배관', 'SIZE'],
|
||||
'red_size': ['RED_NOM', 'REDUCED_DIAMETER', '축소배관'],
|
||||
'length': ['LENGTH', 'LEN', '길이'],
|
||||
'weight': ['WEIGHT', 'WT', '중량'],
|
||||
'dwg_name': ['DWG_NAME', 'DRAWING', '도면명', '도면번호'],
|
||||
'line_num': ['LINE_NUM', 'LINE_NUMBER', '라인번호']
|
||||
}
|
||||
|
||||
mapped_columns = {}
|
||||
for standard_col, possible_names in column_mapping.items():
|
||||
for possible_name in possible_names:
|
||||
# 대문자로 비교
|
||||
possible_upper = possible_name.upper()
|
||||
if possible_upper in df.columns:
|
||||
mapped_columns[standard_col] = possible_upper
|
||||
break
|
||||
|
||||
print(f"📋 [Standard] 컬럼 매핑 결과: {mapped_columns}")
|
||||
|
||||
materials = []
|
||||
for index, row in df.iterrows():
|
||||
description = str(row.get(mapped_columns.get('description', ''), ''))
|
||||
|
||||
# 제외 항목 처리
|
||||
description_upper = description.upper()
|
||||
if ('WELD GAP' in description_upper or 'WELDING GAP' in description_upper or
|
||||
'웰드갭' in description_upper or '용접갭' in description_upper):
|
||||
continue
|
||||
|
||||
# 수량 처리
|
||||
quantity_raw = row.get(mapped_columns.get('quantity', ''), 0)
|
||||
try:
|
||||
quantity = float(quantity_raw) if pd.notna(quantity_raw) else 0
|
||||
except:
|
||||
quantity = 0
|
||||
|
||||
# 재질 등급 추출 (ASTM)
|
||||
material_grade = ""
|
||||
if "ASTM" in description_upper:
|
||||
astm_match = re.search(r'ASTM\s+(A\d{3,4}[A-Z]*(?:\s+(?:GR\s+)?[A-Z0-9]+)?)', description_upper)
|
||||
if astm_match:
|
||||
material_grade = astm_match.group(0).strip()
|
||||
|
||||
# 사이즈 처리
|
||||
main_size = str(row.get(mapped_columns.get('main_size', ''), ''))
|
||||
red_size = str(row.get(mapped_columns.get('red_size', ''), ''))
|
||||
|
||||
main_nom = main_size if main_size != 'nan' and main_size != '' else None
|
||||
red_nom = red_size if red_size != 'nan' and red_size != '' else None
|
||||
|
||||
if main_size != 'nan' and red_size != 'nan' and red_size != '':
|
||||
size_spec = f"{main_size} x {red_size}"
|
||||
elif main_size != 'nan' and main_size != '':
|
||||
size_spec = main_size
|
||||
else:
|
||||
size_spec = ""
|
||||
|
||||
# 길이 처리
|
||||
length_raw = row.get(mapped_columns.get('length', ''), '')
|
||||
length_value = None
|
||||
if pd.notna(length_raw) and str(length_raw).strip() != '':
|
||||
try:
|
||||
length_value = float(str(length_raw).strip())
|
||||
except:
|
||||
length_value = None
|
||||
|
||||
# 도면/라인 번호
|
||||
dwg_name = row.get(mapped_columns.get('dwg_name', ''), '')
|
||||
dwg_name = str(dwg_name).strip() if pd.notna(dwg_name) and str(dwg_name).strip() not in ['', 'nan', 'None'] else None
|
||||
|
||||
line_num = row.get(mapped_columns.get('line_num', ''), '')
|
||||
line_num = str(line_num).strip() if pd.notna(line_num) and str(line_num).strip() not in ['', 'nan', 'None'] else None
|
||||
|
||||
if description and description not in ['nan', 'None', '']:
|
||||
materials.append({
|
||||
'original_description': description,
|
||||
'quantity': quantity,
|
||||
'unit': "EA",
|
||||
'size_spec': size_spec,
|
||||
'main_nom': main_nom,
|
||||
'red_nom': red_nom,
|
||||
'material_grade': material_grade,
|
||||
'length': length_value,
|
||||
'dwg_name': dwg_name,
|
||||
'line_num': line_num,
|
||||
'line_number': index + 1,
|
||||
'row_number': index + 1
|
||||
})
|
||||
|
||||
return materials
|
||||
|
||||
@staticmethod
|
||||
def _parse_inventor_bom(df: pd.DataFrame) -> List[Dict]:
|
||||
"""
|
||||
[신규] 인벤터 추출 양식 파서
|
||||
헤더: NO., NAME, Q'ty, LENGTH & THICKNESS, WEIGHT, DESCIPTION, REMARK
|
||||
특징: Size 컬럼 부재, NAME에 주요 정보 포함
|
||||
"""
|
||||
print("⚠️ [Inventor] 인벤터 양식 파서를 사용합니다.")
|
||||
|
||||
# 컬럼명 전처리 (좌우 공백 제거 및 대문자화)
|
||||
df.columns = df.columns.str.strip().str.upper()
|
||||
|
||||
# 인벤터 전용 매핑
|
||||
col_name = 'NAME'
|
||||
col_qty = "Q'TY"
|
||||
col_desc = 'DESCIPTION' # 오타 그대로 반영
|
||||
col_remark = 'REMARK'
|
||||
col_length = 'LENGTH & THICKNESS' # 길이 정보가 여기 있을 수 있음
|
||||
|
||||
materials = []
|
||||
for index, row in df.iterrows():
|
||||
# 1. 품명 (NAME 컬럼 우선 사용)
|
||||
name_val = str(row.get(col_name, '')).strip()
|
||||
desc_val = str(row.get(col_desc, '')).strip()
|
||||
|
||||
# NAME과 DESCIPTION 병합 (필요시)
|
||||
# 보통 인벤터에서 NAME이 'ADAPTER OD 1/4" x NPT(F) 1/2"' 같이 상세 스펙
|
||||
# DESCIPTION은 'SS-400-7-8' 같은 자재 코드일 수 있음
|
||||
# 일단 NAME을 메인 description으로 사용하고, DESCIPTION을 괄호에 추가
|
||||
if desc_val and desc_val not in ['nan', 'None', '']:
|
||||
full_description = f"{name_val} ({desc_val})"
|
||||
else:
|
||||
full_description = name_val
|
||||
|
||||
if not full_description or full_description in ['nan', 'None', '']:
|
||||
continue
|
||||
|
||||
# 2. 수량
|
||||
qty_raw = row.get(col_qty, 0)
|
||||
try:
|
||||
quantity = float(qty_raw) if pd.notna(qty_raw) else 0
|
||||
except:
|
||||
quantity = 0
|
||||
|
||||
# 3. 사이즈 추출 (NAME 컬럼 분석)
|
||||
# 패턴: 1/2", 1/4", 100A, 50A, 10x20 등
|
||||
size_spec = ""
|
||||
main_nom = None
|
||||
red_nom = None
|
||||
|
||||
# 인치/MM 사이즈 추출 시도
|
||||
# 예: "ADAPTER OD 1/4" x NPT(F) 1/2"" -> 1/4" x 1/2"
|
||||
# 예: "ELBOW 90D 100A" -> 100A
|
||||
|
||||
# 인치 패턴 (1/2", 3/4" 등)
|
||||
inch_sizes = re.findall(r'(\d+(?:/\d+)?)"', name_val)
|
||||
# A단위 패턴 (100A, 50A 등)
|
||||
a_sizes = re.findall(r'(\d+)A', name_val)
|
||||
|
||||
if inch_sizes:
|
||||
if len(inch_sizes) >= 2:
|
||||
main_nom = f'{inch_sizes[0]}"'
|
||||
red_nom = f'{inch_sizes[1]}"'
|
||||
size_spec = f'{main_nom} x {red_nom}'
|
||||
else:
|
||||
main_nom = f'{inch_sizes[0]}"'
|
||||
size_spec = main_nom
|
||||
elif a_sizes:
|
||||
if len(a_sizes) >= 2:
|
||||
main_nom = f'{a_sizes[0]}A'
|
||||
red_nom = f'{a_sizes[1]}A'
|
||||
size_spec = f'{main_nom} x {red_nom}'
|
||||
else:
|
||||
main_nom = f'{a_sizes[0]}A'
|
||||
size_spec = main_nom
|
||||
|
||||
# 4. 재질 정보
|
||||
material_grade = ""
|
||||
# NAME이나 DESCIPTION에서 재질 키워드 찾기 (SS, SUS, A105 등)
|
||||
combined_text = (full_description + " " + desc_val).upper()
|
||||
if "SUS" in combined_text or "SS" in combined_text:
|
||||
if "304" in combined_text: material_grade = "SUS304"
|
||||
elif "316" in combined_text: material_grade = "SUS316"
|
||||
else: material_grade = "SUS"
|
||||
elif "A105" in combined_text:
|
||||
material_grade = "A105"
|
||||
|
||||
# 5. 길이 정보
|
||||
length_value = None
|
||||
length_raw = row.get(col_length, '')
|
||||
# 값이 있고 숫자로 변환 가능하면 사용
|
||||
if pd.notna(length_raw) and str(length_raw).strip():
|
||||
try:
|
||||
# '100 mm' 등의 형식 처리 필요할 수 있음
|
||||
length_str = str(length_raw).lower().replace('mm', '').strip()
|
||||
length_value = float(length_str)
|
||||
except:
|
||||
pass
|
||||
|
||||
materials.append({
|
||||
'original_description': full_description,
|
||||
'quantity': quantity,
|
||||
'unit': "EA",
|
||||
'size_spec': size_spec,
|
||||
'main_nom': main_nom,
|
||||
'red_nom': red_nom,
|
||||
'material_grade': material_grade,
|
||||
'length': length_value,
|
||||
'dwg_name': None, # 인벤터 파일엔 도면명 컬럼이 없음
|
||||
'line_num': None,
|
||||
'line_number': index + 1,
|
||||
'row_number': index + 1
|
||||
})
|
||||
|
||||
return materials
|
||||
80
tkeg/api/app/services/exclude_classifier.py
Normal file
80
tkeg/api/app/services/exclude_classifier.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
EXCLUDE 분류 시스템
|
||||
실제 자재가 아닌 계산용/제외 항목들 분류
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
# ========== 제외 대상 타입 ==========
|
||||
EXCLUDE_TYPES = {
|
||||
"CUTTING_LOSS": {
|
||||
"description_keywords": ["CUTTING LOSS", "CUT LOSS", "절단로스", "컷팅로스"],
|
||||
"characteristics": "절단 시 손실 고려용 계산 항목",
|
||||
"reason": "실제 자재 아님 - 절단 로스 계산용"
|
||||
},
|
||||
"SPARE_ALLOWANCE": {
|
||||
"description_keywords": ["SPARE", "ALLOWANCE", "여유분", "스페어"],
|
||||
"characteristics": "예비품/여유분 계산 항목",
|
||||
"reason": "실제 자재 아님 - 여유분 계산용"
|
||||
},
|
||||
"THICKNESS_NOTE": {
|
||||
"description_keywords": ["THK", "THICK", "두께", "THICKNESS"],
|
||||
"characteristics": "두께 표기용 항목",
|
||||
"reason": "실제 자재 아님 - 두께 정보"
|
||||
},
|
||||
"CALCULATION_ITEM": {
|
||||
"description_keywords": ["CALC", "CALCULATION", "계산", "산정"],
|
||||
"characteristics": "기타 계산용 항목",
|
||||
"reason": "실제 자재 아님 - 계산 목적"
|
||||
}
|
||||
}
|
||||
|
||||
def classify_exclude(dat_file: str, description: str, main_nom: str = "") -> Dict:
|
||||
"""
|
||||
제외 대상 분류
|
||||
|
||||
Args:
|
||||
dat_file: DAT_FILE 필드
|
||||
description: DESCRIPTION 필드
|
||||
main_nom: MAIN_NOM 필드
|
||||
|
||||
Returns:
|
||||
제외 분류 결과
|
||||
"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 제외 대상 키워드 확인
|
||||
for exclude_type, type_data in EXCLUDE_TYPES.items():
|
||||
for keyword in type_data["description_keywords"]:
|
||||
if keyword in desc_upper:
|
||||
return {
|
||||
"category": "EXCLUDE",
|
||||
"exclude_type": exclude_type,
|
||||
"characteristics": type_data["characteristics"],
|
||||
"reason": type_data["reason"],
|
||||
"overall_confidence": 0.95,
|
||||
"evidence": [f"EXCLUDE_KEYWORD: {keyword}"],
|
||||
"recommendation": "BOM에서 제외 권장"
|
||||
}
|
||||
|
||||
# 제외 대상 아님
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"overall_confidence": 0.0,
|
||||
"reason": "제외 대상 키워드 없음"
|
||||
}
|
||||
|
||||
def is_exclude_item(description: str) -> bool:
|
||||
"""간단한 제외 대상 체크"""
|
||||
desc_upper = description.upper()
|
||||
|
||||
exclude_keywords = [
|
||||
"WELD GAP", "WELDING GAP", "GAP",
|
||||
"CUTTING LOSS", "CUT LOSS",
|
||||
"SPARE", "ALLOWANCE",
|
||||
"THK", "THICK"
|
||||
]
|
||||
|
||||
return any(keyword in desc_upper for keyword in exclude_keywords)
|
||||
333
tkeg/api/app/services/file_service.py
Normal file
333
tkeg/api/app/services/file_service.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
파일 관리 비즈니스 로직
|
||||
API 레이어에서 분리된 핵심 비즈니스 로직
|
||||
"""
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.cache_manager import tkmp_cache
|
||||
from ..utils.transaction_manager import TransactionManager, async_transactional
|
||||
from ..schemas.response_models import FileInfo
|
||||
from ..config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class FileService:
|
||||
"""파일 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.transaction_manager = TransactionManager(db)
|
||||
|
||||
async def get_files(
|
||||
self,
|
||||
job_no: Optional[str] = None,
|
||||
show_history: bool = False,
|
||||
use_cache: bool = True
|
||||
) -> Tuple[List[Dict], bool]:
|
||||
"""
|
||||
파일 목록 조회
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
show_history: 이력 표시 여부
|
||||
use_cache: 캐시 사용 여부
|
||||
|
||||
Returns:
|
||||
Tuple[List[Dict], bool]: (파일 목록, 캐시 히트 여부)
|
||||
"""
|
||||
try:
|
||||
logger.info(f"파일 목록 조회 - job_no: {job_no}, show_history: {show_history}")
|
||||
|
||||
# 캐시 확인
|
||||
if use_cache:
|
||||
cached_files = tkmp_cache.get_file_list(job_no, show_history)
|
||||
if cached_files:
|
||||
logger.info(f"캐시에서 파일 목록 반환 - {len(cached_files)}개 파일")
|
||||
return cached_files, True
|
||||
|
||||
# 데이터베이스에서 조회
|
||||
query, params = self._build_file_query(job_no, show_history)
|
||||
result = self.db.execute(text(query), params)
|
||||
files = result.fetchall()
|
||||
|
||||
# 결과 변환
|
||||
file_list = self._convert_files_to_dict(files)
|
||||
|
||||
# 캐시에 저장
|
||||
if use_cache:
|
||||
tkmp_cache.set_file_list(file_list, job_no, show_history)
|
||||
logger.debug("파일 목록 캐시 저장 완료")
|
||||
|
||||
logger.info(f"파일 목록 조회 완료 - {len(file_list)}개 파일 반환")
|
||||
return file_list, False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 목록 조회 실패: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
|
||||
|
||||
def _build_file_query(self, job_no: Optional[str], show_history: bool) -> Tuple[str, Dict]:
|
||||
"""파일 조회 쿼리 생성"""
|
||||
if show_history:
|
||||
# 전체 이력 표시
|
||||
query = "SELECT * FROM files"
|
||||
params = {}
|
||||
|
||||
if job_no:
|
||||
query += " WHERE job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
query += " ORDER BY original_filename, revision DESC"
|
||||
else:
|
||||
# 최신 리비전만 표시
|
||||
if job_no:
|
||||
query = """
|
||||
SELECT f1.* FROM files f1
|
||||
INNER JOIN (
|
||||
SELECT original_filename, MAX(revision) as max_revision
|
||||
FROM files
|
||||
WHERE job_no = :job_no
|
||||
GROUP BY original_filename
|
||||
) f2 ON f1.original_filename = f2.original_filename
|
||||
AND f1.revision = f2.max_revision
|
||||
WHERE f1.job_no = :job_no
|
||||
ORDER BY f1.upload_date DESC
|
||||
"""
|
||||
params = {"job_no": job_no}
|
||||
else:
|
||||
query = "SELECT * FROM files ORDER BY upload_date DESC"
|
||||
params = {}
|
||||
|
||||
return query, params
|
||||
|
||||
def _convert_files_to_dict(self, files) -> List[Dict]:
|
||||
"""파일 결과를 딕셔너리로 변환"""
|
||||
return [
|
||||
{
|
||||
"id": f.id,
|
||||
"filename": f.original_filename,
|
||||
"original_filename": f.original_filename,
|
||||
"name": f.original_filename,
|
||||
"job_no": f.job_no,
|
||||
"bom_name": f.bom_name or f.original_filename,
|
||||
"revision": f.revision or "Rev.0",
|
||||
"parsed_count": f.parsed_count or 0,
|
||||
"bom_type": f.file_type or "unknown",
|
||||
"status": "active" if f.is_active else "inactive",
|
||||
"file_size": f.file_size,
|
||||
"created_at": f.upload_date,
|
||||
"upload_date": f.upload_date,
|
||||
"description": f"파일: {f.original_filename}"
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
|
||||
async def delete_file(self, file_id: int) -> Dict:
|
||||
"""
|
||||
파일 삭제 (트랜잭션 관리 적용)
|
||||
|
||||
Args:
|
||||
file_id: 파일 ID
|
||||
|
||||
Returns:
|
||||
Dict: 삭제 결과
|
||||
"""
|
||||
try:
|
||||
logger.info(f"파일 삭제 요청 - file_id: {file_id}")
|
||||
|
||||
# 트랜잭션 내에서 삭제 작업 수행
|
||||
with self.transaction_manager.transaction():
|
||||
# 파일 정보 조회
|
||||
file_info = self._get_file_info(file_id)
|
||||
if not file_info:
|
||||
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
|
||||
|
||||
# 관련 데이터 삭제 (세이브포인트 사용)
|
||||
with self.transaction_manager.savepoint("delete_related_data"):
|
||||
self._delete_related_data(file_id)
|
||||
|
||||
# 파일 삭제
|
||||
with self.transaction_manager.savepoint("delete_file_record"):
|
||||
self._delete_file_record(file_id)
|
||||
|
||||
# 트랜잭션이 성공적으로 완료되면 캐시 무효화
|
||||
self._invalidate_file_cache(file_id, file_info)
|
||||
|
||||
logger.info(f"파일 삭제 완료 - file_id: {file_id}, filename: {file_info.original_filename}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "파일과 관련 데이터가 삭제되었습니다",
|
||||
"deleted_file_id": file_id
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"파일 삭제 실패 - file_id: {file_id}, error: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}")
|
||||
|
||||
def _get_file_info(self, file_id: int):
|
||||
"""파일 정보 조회"""
|
||||
file_query = text("SELECT * FROM files WHERE id = :file_id")
|
||||
file_result = self.db.execute(file_query, {"file_id": file_id})
|
||||
return file_result.fetchone()
|
||||
|
||||
def _delete_related_data(self, file_id: int):
|
||||
"""관련 데이터 삭제"""
|
||||
# 상세 테이블 목록
|
||||
detail_tables = [
|
||||
'pipe_details', 'fitting_details', 'valve_details',
|
||||
'flange_details', 'bolt_details', 'gasket_details',
|
||||
'instrument_details'
|
||||
]
|
||||
|
||||
# 해당 파일의 materials ID 조회
|
||||
material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id")
|
||||
material_ids_result = self.db.execute(material_ids_query, {"file_id": file_id})
|
||||
material_ids = [row[0] for row in material_ids_result]
|
||||
|
||||
if material_ids:
|
||||
logger.info(f"관련 자재 데이터 삭제 - {len(material_ids)}개 자재")
|
||||
# 각 상세 테이블에서 관련 데이터 삭제
|
||||
for table in detail_tables:
|
||||
delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)")
|
||||
self.db.execute(delete_detail_query, {"material_ids": material_ids})
|
||||
|
||||
# materials 테이블 데이터 삭제
|
||||
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
|
||||
self.db.execute(materials_query, {"file_id": file_id})
|
||||
|
||||
def _delete_file_record(self, file_id: int):
|
||||
"""파일 레코드 삭제"""
|
||||
delete_query = text("DELETE FROM files WHERE id = :file_id")
|
||||
self.db.execute(delete_query, {"file_id": file_id})
|
||||
|
||||
def _invalidate_file_cache(self, file_id: int, file_info):
|
||||
"""파일 관련 캐시 무효화"""
|
||||
tkmp_cache.invalidate_file_cache(file_id)
|
||||
if hasattr(file_info, 'job_no') and file_info.job_no:
|
||||
tkmp_cache.invalidate_job_cache(file_info.job_no)
|
||||
|
||||
async def get_file_statistics(self, job_no: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
파일 통계 조회
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
|
||||
Returns:
|
||||
Dict: 파일 통계
|
||||
"""
|
||||
try:
|
||||
# 캐시 확인
|
||||
if job_no:
|
||||
cached_stats = tkmp_cache.get_statistics(job_no, "file_stats")
|
||||
if cached_stats:
|
||||
return cached_stats
|
||||
|
||||
# 통계 쿼리 실행
|
||||
stats_query = self._build_statistics_query(job_no)
|
||||
result = self.db.execute(text(stats_query["query"]), stats_query["params"])
|
||||
stats_data = result.fetchall()
|
||||
|
||||
# 통계 데이터 변환
|
||||
statistics = self._convert_statistics_data(stats_data)
|
||||
|
||||
# 캐시에 저장
|
||||
if job_no:
|
||||
tkmp_cache.set_statistics(statistics, job_no, "file_stats")
|
||||
|
||||
return statistics
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 통계 조회 실패: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"파일 통계 조회 실패: {str(e)}")
|
||||
|
||||
def _build_statistics_query(self, job_no: Optional[str]) -> Dict:
|
||||
"""통계 쿼리 생성"""
|
||||
base_query = """
|
||||
SELECT
|
||||
COUNT(*) as total_files,
|
||||
COUNT(DISTINCT job_no) as total_jobs,
|
||||
SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active_files,
|
||||
SUM(file_size) as total_size,
|
||||
AVG(file_size) as avg_size,
|
||||
MAX(upload_date) as latest_upload,
|
||||
MIN(upload_date) as earliest_upload
|
||||
FROM files
|
||||
"""
|
||||
|
||||
params = {}
|
||||
if job_no:
|
||||
base_query += " WHERE job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
return {"query": base_query, "params": params}
|
||||
|
||||
def _convert_statistics_data(self, stats_data) -> Dict:
|
||||
"""통계 데이터 변환"""
|
||||
if not stats_data:
|
||||
return {
|
||||
"total_files": 0,
|
||||
"total_jobs": 0,
|
||||
"active_files": 0,
|
||||
"total_size": 0,
|
||||
"avg_size": 0,
|
||||
"latest_upload": None,
|
||||
"earliest_upload": None
|
||||
}
|
||||
|
||||
stats = stats_data[0]
|
||||
return {
|
||||
"total_files": stats.total_files or 0,
|
||||
"total_jobs": stats.total_jobs or 0,
|
||||
"active_files": stats.active_files or 0,
|
||||
"total_size": stats.total_size or 0,
|
||||
"total_size_mb": round((stats.total_size or 0) / (1024 * 1024), 2),
|
||||
"avg_size": stats.avg_size or 0,
|
||||
"avg_size_mb": round((stats.avg_size or 0) / (1024 * 1024), 2),
|
||||
"latest_upload": stats.latest_upload,
|
||||
"earliest_upload": stats.earliest_upload
|
||||
}
|
||||
|
||||
async def validate_file_access(self, file_id: int, user_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
파일 접근 권한 검증
|
||||
|
||||
Args:
|
||||
file_id: 파일 ID
|
||||
user_id: 사용자 ID
|
||||
|
||||
Returns:
|
||||
bool: 접근 권한 여부
|
||||
"""
|
||||
try:
|
||||
# 파일 존재 여부 확인
|
||||
file_info = self._get_file_info(file_id)
|
||||
if not file_info:
|
||||
return False
|
||||
|
||||
# 파일이 활성 상태인지 확인
|
||||
if not file_info.is_active:
|
||||
logger.warning(f"비활성 파일 접근 시도 - file_id: {file_id}")
|
||||
return False
|
||||
|
||||
# 추가 권한 검증 로직 (필요시 구현)
|
||||
# 예: 사용자별 프로젝트 접근 권한 등
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"파일 접근 권한 검증 실패: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def get_file_service(db: Session) -> FileService:
|
||||
"""파일 서비스 팩토리 함수"""
|
||||
return FileService(db)
|
||||
886
tkeg/api/app/services/fitting_classifier.py
Normal file
886
tkeg/api/app/services/fitting_classifier.py
Normal file
@@ -0,0 +1,886 @@
|
||||
"""
|
||||
FITTING 분류 시스템 V2
|
||||
재질 분류 + 피팅 특화 분류 + 스풀 시스템 통합
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from .material_classifier import classify_material, get_manufacturing_method_from_material
|
||||
from .classifier_constants import PRESSURE_PATTERNS, PRESSURE_RATINGS_SPECS, OLET_KEYWORDS
|
||||
|
||||
# ========== FITTING 타입별 분류 (실제 BOM 기반) ==========
|
||||
FITTING_TYPES = {
|
||||
"ELBOW": {
|
||||
"dat_file_patterns": ["90L_", "45L_", "ELL_", "ELBOW_"],
|
||||
"description_keywords": ["ELBOW", "ELL", "엘보", "90 ELBOW", "45 ELBOW", "LR ELBOW", "SR ELBOW", "90 ELL", "45 ELL"],
|
||||
"subtypes": {
|
||||
"90DEG_LONG_RADIUS": ["90 LR", "90° LR", "90DEG LR", "90도 장반경", "90 LONG RADIUS", "LR 90"],
|
||||
"90DEG_SHORT_RADIUS": ["90 SR", "90° SR", "90DEG SR", "90도 단반경", "90 SHORT RADIUS", "SR 90"],
|
||||
"45DEG_LONG_RADIUS": ["45 LR", "45° LR", "45DEG LR", "45도 장반경", "45 LONG RADIUS", "LR 45"],
|
||||
"45DEG_SHORT_RADIUS": ["45 SR", "45° SR", "45DEG SR", "45도 단반경", "45 SHORT RADIUS", "SR 45"],
|
||||
"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", "CONC", "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\""
|
||||
},
|
||||
|
||||
"PLUG": {
|
||||
"dat_file_patterns": ["PLUG_", "HEX_PLUG"],
|
||||
"description_keywords": ["PLUG", "플러그", "HEX.PLUG", "HEX PLUG", "HEXAGON PLUG"],
|
||||
"subtypes": {
|
||||
"HEX": ["HEX", "HEXAGON", "육각"],
|
||||
"SQUARE": ["SQUARE", "사각"],
|
||||
"THREADED": ["THD", "THREADED", "나사", "NPT"]
|
||||
},
|
||||
"common_connections": ["THREADED", "NPT"],
|
||||
"size_range": "1/8\" ~ 4\""
|
||||
},
|
||||
|
||||
"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", "CONC", "CN", "CON", "동심"],
|
||||
"ECCENTRIC": ["ECCENTRIC", "ECC", "EC", "편심"]
|
||||
},
|
||||
"requires_two_sizes": True,
|
||||
"common_connections": ["BUTT_WELD", "SOCKET_WELD"],
|
||||
"size_range": "1/2\" ~ 12\""
|
||||
},
|
||||
|
||||
"OLET": {
|
||||
"dat_file_patterns": ["SOL_", "WOL_", "TOL_", "EOL_", "NOL_", "COL_", "OLET_", "SOCK-O-LET", "WELD-O-LET", "ELL-O-LET", "THREAD-O-LET", "ELB-O-LET", "NIP-O-LET", "COUP-O-LET"],
|
||||
"description_keywords": OLET_KEYWORDS,
|
||||
"subtypes": {
|
||||
"SOCKOLET": ["SOCK-O-LET", "SOCKOLET", "SOL", "SOCK O-LET", "SOCKET-O-LET", "SOCKLET"],
|
||||
"WELDOLET": ["WELD-O-LET", "WELDOLET", "WOL", "WELD O-LET", "WELDING-O-LET"],
|
||||
"ELLOLET": ["ELL-O-LET", "ELLOLET", "EOL", "ELL O-LET", "ELBOW-O-LET"],
|
||||
"THREADOLET": ["THREAD-O-LET", "THREADOLET", "TOL", "THREADED-O-LET"],
|
||||
"ELBOLET": ["ELB-O-LET", "ELBOLET", "EOL", "ELBOW-O-LET"],
|
||||
"NIPOLET": ["NIP-O-LET", "NIPOLET", "NOL", "NIPPLE-O-LET"],
|
||||
"COUPOLET": ["COUP-O-LET", "COUPOLET", "COL", "COUPLING-O-LET"]
|
||||
},
|
||||
"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": PRESSURE_PATTERNS,
|
||||
"standard_ratings": PRESSURE_RATINGS_SPECS
|
||||
}
|
||||
|
||||
def classify_fitting(dat_file: str, description: str, main_nom: str,
|
||||
red_nom: str = None, length: float = None) -> Dict:
|
||||
"""
|
||||
완전한 FITTING 분류
|
||||
|
||||
Args:
|
||||
dat_file: DAT_FILE 필드
|
||||
description: DESCRIPTION 필드
|
||||
main_nom: MAIN_NOM 필드 (주 사이즈)
|
||||
red_nom: RED_NOM 필드 (축소 사이즈, 선택사항)
|
||||
|
||||
Returns:
|
||||
완전한 피팅 분류 결과
|
||||
"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
dat_upper = dat_file.upper()
|
||||
|
||||
# 1. 피팅 키워드 확인 (재질만 있어도 통합 분류기가 이미 피팅으로 분류했으므로 진행)
|
||||
# OLET 키워드를 우선 확인하여 정확한 분류 수행
|
||||
olet_keywords = OLET_KEYWORDS
|
||||
has_olet_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in olet_keywords)
|
||||
|
||||
fitting_keywords = ['ELBOW', 'ELL', 'TEE', 'REDUCER', 'RED', 'CAP', 'NIPPLE', 'SWAGE', 'COUPLING', 'PLUG', '엘보', '티', '리듀서', '캡', '니플', '스웨지', '올렛', '커플링', '플러그'] + olet_keywords
|
||||
has_fitting_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in fitting_keywords)
|
||||
|
||||
# 피팅 재질 확인 (A234, A403, A420)
|
||||
fitting_materials = ['A234', 'A403', 'A420']
|
||||
has_fitting_material = any(material in desc_upper for material in fitting_materials)
|
||||
|
||||
# 피팅 키워드도 없고 피팅 재질도 없으면 UNKNOWN
|
||||
if not has_fitting_keyword and not has_fitting_material:
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"overall_confidence": 0.0,
|
||||
"reason": "피팅 키워드 및 재질 없음"
|
||||
}
|
||||
|
||||
# 2. 재질 분류 (공통 모듈 사용)
|
||||
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)
|
||||
|
||||
# 4.5. 스케줄 분류 (니플 등에 중요) - 분리 스케줄 지원
|
||||
schedule_result = classify_fitting_schedule_with_reducing(description, main_nom, red_nom)
|
||||
|
||||
# 5. 제작 방법 추정
|
||||
manufacturing_result = determine_fitting_manufacturing(
|
||||
material_result, connection_result, pressure_result, main_nom
|
||||
)
|
||||
|
||||
# 6. 최종 결과 조합
|
||||
# --- 계장용(Instrument/Swagelok) 피팅 감지 로직 추가 ---
|
||||
instrument_keywords = ["SWAGELOK", "DK-LOK", "TUBE FITTING", "UNION", "FERRULE", "MALE CONNECTOR", "FEMALE CONNECTOR"]
|
||||
is_instrument = any(kw in desc_upper for kw in instrument_keywords)
|
||||
|
||||
if is_instrument:
|
||||
fitting_type_result["category"] = "INSTRUMENT_FITTING"
|
||||
if "SWAGELOK" in desc_upper: fitting_type_result["brand"] = "SWAGELOK"
|
||||
|
||||
# Tube OD 추출 (예: 1/4", 6MM, 12MM)
|
||||
tube_match = re.search(r'(\d+(?:/\d+)?)\s*(?:\"|INCH|MM)\s*(?:OD|TUBE)', desc_upper)
|
||||
if tube_match:
|
||||
fitting_type_result["tube_od"] = tube_match.group(0)
|
||||
|
||||
return {
|
||||
"category": "FITTING",
|
||||
"fitting_type": fitting_type_result,
|
||||
"connection_method": connection_result,
|
||||
"pressure_rating": pressure_result,
|
||||
"schedule": schedule_result,
|
||||
"manufacturing": manufacturing_result,
|
||||
"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 analyze_size_pattern_for_fitting_type(description: str, main_nom: str, red_nom: str = None) -> Dict:
|
||||
"""
|
||||
실제 BOM 패턴 기반 TEE vs REDUCER 구분
|
||||
|
||||
실제 패턴:
|
||||
- TEE RED, SMLS, SCH 40 x SCH 80 → TEE (키워드 우선)
|
||||
- RED CONC, SMLS, SCH 80 x SCH 80 → REDUCER (키워드 우선)
|
||||
- 모두 A x B 형태 (메인 x 감소)
|
||||
"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. 키워드 기반 분류 (최우선) - 실제 BOM 패턴
|
||||
if "TEE RED" in desc_upper or "TEE REDUCING" in desc_upper:
|
||||
return {
|
||||
"type": "TEE",
|
||||
"subtype": "REDUCING",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["KEYWORD_TEE_RED"],
|
||||
"subtype_confidence": 0.95,
|
||||
"requires_two_sizes": False
|
||||
}
|
||||
|
||||
if "RED CONC" in desc_upper or "REDUCER CONC" in desc_upper:
|
||||
return {
|
||||
"type": "REDUCER",
|
||||
"subtype": "CONCENTRIC",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["KEYWORD_RED_CONC"],
|
||||
"subtype_confidence": 0.95,
|
||||
"requires_two_sizes": True
|
||||
}
|
||||
|
||||
if "RED ECC" in desc_upper or "REDUCER ECC" in desc_upper:
|
||||
return {
|
||||
"type": "REDUCER",
|
||||
"subtype": "ECCENTRIC",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["KEYWORD_RED_ECC"],
|
||||
"subtype_confidence": 0.95,
|
||||
"requires_two_sizes": True
|
||||
}
|
||||
|
||||
# 2. 사이즈 패턴 분석 (보조) - 기존 로직 유지
|
||||
# x 또는 × 기호로 연결된 사이즈들 찾기
|
||||
connected_sizes = re.findall(r'(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?\s*[xX×]\s*(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?(?:\s*[xX×]\s*(\d+(?:\s+\d+/\d+)?(?:\.\d+)?)"?)?', description)
|
||||
|
||||
if connected_sizes:
|
||||
# 연결된 사이즈들을 리스트로 변환
|
||||
sizes = []
|
||||
for size_group in connected_sizes:
|
||||
for size in size_group:
|
||||
if size.strip():
|
||||
sizes.append(size.strip())
|
||||
|
||||
# 중복 제거하되 순서 유지
|
||||
unique_sizes = []
|
||||
for size in sizes:
|
||||
if size not in unique_sizes:
|
||||
unique_sizes.append(size)
|
||||
|
||||
sizes = unique_sizes
|
||||
|
||||
if len(sizes) == 3:
|
||||
# A x B x B 패턴 → TEE REDUCING
|
||||
if sizes[1] == sizes[2]:
|
||||
return {
|
||||
"type": "TEE",
|
||||
"subtype": "REDUCING",
|
||||
"confidence": 0.85,
|
||||
"evidence": [f"SIZE_PATTERN_TEE_REDUCING: {' x '.join(sizes)}"],
|
||||
"subtype_confidence": 0.85,
|
||||
"requires_two_sizes": False
|
||||
}
|
||||
# A x B x C 패턴 → TEE REDUCING (모두 다른 사이즈)
|
||||
else:
|
||||
return {
|
||||
"type": "TEE",
|
||||
"subtype": "REDUCING",
|
||||
"confidence": 0.80,
|
||||
"evidence": [f"SIZE_PATTERN_TEE_REDUCING_UNEQUAL: {' x '.join(sizes)}"],
|
||||
"subtype_confidence": 0.80,
|
||||
"requires_two_sizes": False
|
||||
}
|
||||
elif len(sizes) == 2:
|
||||
# A x B 패턴 → 키워드가 없으면 REDUCER로 기본 분류
|
||||
if "CONC" in desc_upper or "CONCENTRIC" in desc_upper:
|
||||
return {
|
||||
"type": "REDUCER",
|
||||
"subtype": "CONCENTRIC",
|
||||
"confidence": 0.80,
|
||||
"evidence": [f"SIZE_PATTERN_REDUCER_CONC: {' x '.join(sizes)}"],
|
||||
"subtype_confidence": 0.80,
|
||||
"requires_two_sizes": True
|
||||
}
|
||||
elif "ECC" in desc_upper or "ECCENTRIC" in desc_upper:
|
||||
return {
|
||||
"type": "REDUCER",
|
||||
"subtype": "ECCENTRIC",
|
||||
"confidence": 0.80,
|
||||
"evidence": [f"SIZE_PATTERN_REDUCER_ECC: {' x '.join(sizes)}"],
|
||||
"subtype_confidence": 0.80,
|
||||
"requires_two_sizes": True
|
||||
}
|
||||
else:
|
||||
# 키워드 없는 A x B 패턴은 낮은 신뢰도로 REDUCER
|
||||
return {
|
||||
"type": "REDUCER",
|
||||
"subtype": "CONCENTRIC", # 기본값
|
||||
"confidence": 0.60,
|
||||
"evidence": [f"SIZE_PATTERN_REDUCER_DEFAULT: {' x '.join(sizes)}"],
|
||||
"subtype_confidence": 0.60,
|
||||
"requires_two_sizes": True
|
||||
}
|
||||
|
||||
return {"confidence": 0.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()
|
||||
|
||||
# 0. OLET 우선 확인 (ELL과의 혼동 방지)
|
||||
olet_specific_keywords = OLET_KEYWORDS
|
||||
for keyword in olet_specific_keywords:
|
||||
if keyword in desc_upper or keyword in dat_upper:
|
||||
subtype_result = classify_fitting_subtype(
|
||||
"OLET", desc_upper, main_nom, red_nom, FITTING_TYPES["OLET"]
|
||||
)
|
||||
return {
|
||||
"type": "OLET",
|
||||
"subtype": subtype_result["subtype"],
|
||||
"confidence": 0.95,
|
||||
"evidence": [f"OLET_PRIORITY_KEYWORD: {keyword}"],
|
||||
"subtype_confidence": subtype_result["confidence"],
|
||||
"requires_two_sizes": FITTING_TYPES["OLET"].get("requires_two_sizes", False)
|
||||
}
|
||||
|
||||
# 1. 사이즈 패턴 분석으로 TEE vs REDUCER 구분
|
||||
size_pattern_result = analyze_size_pattern_for_fitting_type(desc_upper, main_nom, red_nom)
|
||||
if size_pattern_result.get("confidence", 0) > 0.85:
|
||||
return size_pattern_result
|
||||
|
||||
# 2. 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)
|
||||
}
|
||||
|
||||
# 3. 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)
|
||||
}
|
||||
|
||||
# 4. 분류 실패
|
||||
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:
|
||||
"""피팅 서브타입 분류"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
subtypes = type_data.get("subtypes", {})
|
||||
|
||||
# 1. 키워드 기반 서브타입 분류 (우선) - 대소문자 구분 없이
|
||||
for subtype, keywords in subtypes.items():
|
||||
for keyword in keywords:
|
||||
if keyword.upper() in desc_upper:
|
||||
return {
|
||||
"subtype": subtype,
|
||||
"confidence": 0.9,
|
||||
"evidence": [f"SUBTYPE_KEYWORD: {keyword}"]
|
||||
}
|
||||
|
||||
# 1.5. ELBOW 특별 처리 - 조합 키워드 우선 확인
|
||||
if fitting_type == "ELBOW":
|
||||
# 90도 + 반경 조합
|
||||
if ("90" in desc_upper or "90°" in desc_upper or "90DEG" in desc_upper):
|
||||
if ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "90DEG_LONG_RADIUS",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["90DEG + LONG_RADIUS"]
|
||||
}
|
||||
elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "90DEG_SHORT_RADIUS",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["90DEG + SHORT_RADIUS"]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"subtype": "90DEG",
|
||||
"confidence": 0.85,
|
||||
"evidence": ["90DEG_DETECTED"]
|
||||
}
|
||||
|
||||
# 45도 + 반경 조합
|
||||
elif ("45" in desc_upper or "45°" in desc_upper or "45DEG" in desc_upper):
|
||||
if ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "45DEG_LONG_RADIUS",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["45DEG + LONG_RADIUS"]
|
||||
}
|
||||
elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "45DEG_SHORT_RADIUS",
|
||||
"confidence": 0.95,
|
||||
"evidence": ["45DEG + SHORT_RADIUS"]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"subtype": "45DEG",
|
||||
"confidence": 0.85,
|
||||
"evidence": ["45DEG_DETECTED"]
|
||||
}
|
||||
|
||||
# 반경만 있는 경우 (기본 90도 가정)
|
||||
elif ("LR" in desc_upper or "LONG RADIUS" in desc_upper or "장반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "90DEG_LONG_RADIUS",
|
||||
"confidence": 0.8,
|
||||
"evidence": ["LONG_RADIUS_DEFAULT_90DEG"]
|
||||
}
|
||||
elif ("SR" in desc_upper or "SHORT RADIUS" in desc_upper or "단반경" in desc_upper):
|
||||
return {
|
||||
"subtype": "90DEG_SHORT_RADIUS",
|
||||
"confidence": 0.8,
|
||||
"evidence": ["SHORT_RADIUS_DEFAULT_90DEG"]
|
||||
}
|
||||
|
||||
# 2. 사이즈 분석이 필요한 경우 (TEE, REDUCER 등)
|
||||
if type_data.get("size_analysis"):
|
||||
if red_nom and str(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 str(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:
|
||||
"""피팅 사이즈 표기 포맷팅"""
|
||||
main_nom_str = str(main_nom) if main_nom is not None else ""
|
||||
red_nom_str = str(red_nom) if red_nom is not None else ""
|
||||
if red_nom_str.strip() and red_nom_str != main_nom_str:
|
||||
return f"{main_nom_str} x {red_nom_str}"
|
||||
else:
|
||||
return main_nom_str
|
||||
|
||||
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"]
|
||||
}
|
||||
|
||||
def classify_fitting_schedule(description: str) -> Dict:
|
||||
"""피팅 스케줄 분류 (특히 니플용)"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 스케줄 패턴 매칭
|
||||
schedule_patterns = [
|
||||
r'SCH\s*(\d+)',
|
||||
r'SCHEDULE\s*(\d+)',
|
||||
r'스케줄\s*(\d+)'
|
||||
]
|
||||
|
||||
for pattern in schedule_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
schedule_number = match.group(1)
|
||||
schedule = f"SCH {schedule_number}"
|
||||
|
||||
# 일반적인 스케줄 정보
|
||||
common_schedules = {
|
||||
"10": {"wall": "얇음", "pressure": "저압"},
|
||||
"20": {"wall": "얇음", "pressure": "저압"},
|
||||
"40": {"wall": "표준", "pressure": "중압"},
|
||||
"80": {"wall": "두꺼움", "pressure": "고압"},
|
||||
"120": {"wall": "매우 두꺼움", "pressure": "고압"},
|
||||
"160": {"wall": "매우 두꺼움", "pressure": "초고압"}
|
||||
}
|
||||
|
||||
schedule_info = common_schedules.get(schedule_number, {"wall": "비표준", "pressure": "확인 필요"})
|
||||
|
||||
return {
|
||||
"schedule": schedule,
|
||||
"schedule_number": schedule_number,
|
||||
"wall_thickness": schedule_info["wall"],
|
||||
"pressure_class": schedule_info["pressure"],
|
||||
"confidence": 0.95,
|
||||
"matched_pattern": pattern
|
||||
}
|
||||
|
||||
return {
|
||||
"schedule": "UNKNOWN",
|
||||
"schedule_number": "",
|
||||
"wall_thickness": "",
|
||||
"pressure_class": "",
|
||||
"confidence": 0.0,
|
||||
"matched_pattern": ""
|
||||
}
|
||||
|
||||
def classify_fitting_schedule_with_reducing(description: str, main_nom: str, red_nom: str = None) -> Dict:
|
||||
"""
|
||||
실제 BOM 패턴 기반 분리 스케줄 처리
|
||||
|
||||
실제 패턴:
|
||||
- "TEE RED, SMLS, SCH 40 x SCH 80" → main: SCH 40, red: SCH 80
|
||||
- "RED CONC, SMLS, SCH 40S x SCH 40S" → main: SCH 40S, red: SCH 40S
|
||||
- "RED CONC, SMLS, SCH 80 x SCH 80" → main: SCH 80, red: SCH 80
|
||||
"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. 분리 스케줄 패턴 확인 (SCH XX x SCH YY) - 개선된 패턴
|
||||
separated_schedule_patterns = [
|
||||
r'SCH\s*(\d+S?)\s*[xX×]\s*SCH\s*(\d+S?)', # SCH 40 x SCH 80
|
||||
r'SCH\s*(\d+S?)\s*X\s*(\d+S?)', # SCH 40S X 40S (SCH 생략)
|
||||
]
|
||||
|
||||
for pattern in separated_schedule_patterns:
|
||||
separated_match = re.search(pattern, desc_upper)
|
||||
if separated_match:
|
||||
main_schedule = f"SCH {separated_match.group(1)}"
|
||||
red_schedule = f"SCH {separated_match.group(2)}"
|
||||
|
||||
return {
|
||||
"schedule": main_schedule, # 기본 스케줄 (호환성)
|
||||
"main_schedule": main_schedule,
|
||||
"red_schedule": red_schedule,
|
||||
"has_different_schedules": main_schedule != red_schedule,
|
||||
"confidence": 0.95,
|
||||
"matched_pattern": separated_match.group(0),
|
||||
"schedule_type": "SEPARATED"
|
||||
}
|
||||
|
||||
# 2. 단일 스케줄 패턴 (기존 로직 사용)
|
||||
basic_result = classify_fitting_schedule(description)
|
||||
|
||||
# 단일 스케줄을 main/red 모두에 적용
|
||||
schedule = basic_result.get("schedule", "UNKNOWN")
|
||||
|
||||
return {
|
||||
"schedule": schedule, # 기본 스케줄 (호환성)
|
||||
"main_schedule": schedule,
|
||||
"red_schedule": schedule if red_nom else None,
|
||||
"has_different_schedules": False,
|
||||
"confidence": basic_result.get("confidence", 0.0),
|
||||
"matched_pattern": basic_result.get("matched_pattern", ""),
|
||||
"schedule_type": "UNIFIED"
|
||||
}
|
||||
595
tkeg/api/app/services/flange_classifier.py
Normal file
595
tkeg/api/app/services/flange_classifier.py
Normal file
@@ -0,0 +1,595 @@
|
||||
"""
|
||||
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_", "ORIFICE_"],
|
||||
"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, length: float = None) -> Dict:
|
||||
"""
|
||||
완전한 FLANGE 분류
|
||||
|
||||
Args:
|
||||
dat_file: DAT_FILE 필드
|
||||
description: DESCRIPTION 필드
|
||||
main_nom: MAIN_NOM 필드 (주 사이즈)
|
||||
red_nom: RED_NOM 필드 (축소 사이즈, REDUCING 플랜지용)
|
||||
|
||||
Returns:
|
||||
완전한 플랜지 분류 결과
|
||||
"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
dat_upper = dat_file.upper()
|
||||
|
||||
# 1. 플랜지 키워드 확인 (재질만 있어도 통합 분류기가 이미 플랜지로 분류했으므로 진행)
|
||||
# 사이트 글라스와 스트레이너는 밸브로 분류되어야 함
|
||||
if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '사이트글라스' in desc_upper or '스트레이너' in desc_upper:
|
||||
return {
|
||||
"category": "VALVE",
|
||||
"overall_confidence": 1.0,
|
||||
"reason": "SIGHT GLASS 또는 STRAINER는 밸브로 분류"
|
||||
}
|
||||
|
||||
flange_keywords = ['FLG', 'FLANGE', '플랜지', 'ORIFICE', 'SPECTACLE', 'PADDLE', 'SPACER']
|
||||
has_flange_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in flange_keywords)
|
||||
|
||||
# 플랜지 재질 확인 (A182, A350, A105 - 범용이지만 플랜지에 많이 사용)
|
||||
flange_materials = ['A182', 'A350', 'A105']
|
||||
has_flange_material = any(material in desc_upper for material in flange_materials)
|
||||
|
||||
# 플랜지 키워드도 없고 플랜지 재질도 없으면 UNKNOWN
|
||||
if not has_flange_keyword and not has_flange_material:
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"overall_confidence": 0.0,
|
||||
"reason": "플랜지 키워드 및 재질 없음"
|
||||
}
|
||||
|
||||
# 2. 재질 분류 (공통 모듈 사용)
|
||||
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:
|
||||
"""플랜지 사이즈 표기 포맷팅"""
|
||||
main_nom_str = str(main_nom) if main_nom is not None else ""
|
||||
red_nom_str = str(red_nom) if red_nom is not None else ""
|
||||
if red_nom_str.strip() and red_nom_str != main_nom_str:
|
||||
return f"{main_nom_str} x {red_nom_str}"
|
||||
else:
|
||||
return main_nom_str
|
||||
|
||||
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"]
|
||||
}
|
||||
616
tkeg/api/app/services/gasket_classifier.py
Normal file
616
tkeg/api/app/services/gasket_classifier.py
Normal file
@@ -0,0 +1,616 @@
|
||||
"""
|
||||
GASKET 분류 시스템
|
||||
플랜지용 가스켓 및 씰링 제품 분류
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from .material_classifier import classify_material
|
||||
|
||||
# ========== 가스켓 타입별 분류 ==========
|
||||
GASKET_TYPES = {
|
||||
"SPIRAL_WOUND": {
|
||||
"dat_file_patterns": ["SWG_", "SPIRAL_"],
|
||||
"description_keywords": ["SPIRAL WOUND", "SPIRAL", "스파이럴", "SWG"],
|
||||
"characteristics": "금속 스트립과 필러의 나선형 조합",
|
||||
"pressure_range": "150LB ~ 2500LB",
|
||||
"temperature_range": "-200°C ~ 800°C",
|
||||
"applications": "고온고압, 일반 산업용"
|
||||
},
|
||||
|
||||
"RING_JOINT": {
|
||||
"dat_file_patterns": ["RTJ_", "RJ_", "RING_"],
|
||||
"description_keywords": ["RING JOINT", "RTJ", "RING TYPE JOINT", "링조인트"],
|
||||
"characteristics": "금속 링 형태의 고압용 가스켓",
|
||||
"pressure_range": "600LB ~ 2500LB",
|
||||
"temperature_range": "-100°C ~ 650°C",
|
||||
"applications": "고압 플랜지 전용"
|
||||
},
|
||||
|
||||
"FULL_FACE": {
|
||||
"dat_file_patterns": ["FF_", "FULL_"],
|
||||
"description_keywords": ["FULL FACE", "FF", "풀페이스"],
|
||||
"characteristics": "플랜지 전면 커버 가스켓",
|
||||
"pressure_range": "150LB ~ 300LB",
|
||||
"temperature_range": "-50°C ~ 400°C",
|
||||
"applications": "평면 플랜지용"
|
||||
},
|
||||
|
||||
"RAISED_FACE": {
|
||||
"dat_file_patterns": ["RF_", "RAISED_"],
|
||||
"description_keywords": ["RAISED FACE", "RF", "레이즈드"],
|
||||
"characteristics": "볼록한 면 전용 가스켓",
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"temperature_range": "-50°C ~ 450°C",
|
||||
"applications": "일반 볼록면 플랜지용"
|
||||
},
|
||||
|
||||
"O_RING": {
|
||||
"dat_file_patterns": ["OR_", "ORING_"],
|
||||
"description_keywords": ["O-RING", "O RING", "ORING", "오링"],
|
||||
"characteristics": "원형 단면의 씰링 링",
|
||||
"pressure_range": "저압 ~ 고압 (재질별)",
|
||||
"temperature_range": "-60°C ~ 300°C (재질별)",
|
||||
"applications": "홈 씰링, 회전축 씰링"
|
||||
},
|
||||
|
||||
"SHEET_GASKET": {
|
||||
"dat_file_patterns": ["SHEET_", "SHT_"],
|
||||
"description_keywords": ["SHEET GASKET", "SHEET", "시트"],
|
||||
"characteristics": "판 형태의 가스켓",
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"temperature_range": "-50°C ~ 500°C",
|
||||
"applications": "일반 플랜지, 맨홀"
|
||||
},
|
||||
|
||||
"KAMMPROFILE": {
|
||||
"dat_file_patterns": ["KAMM_", "KP_"],
|
||||
"description_keywords": ["KAMMPROFILE", "KAMM", "캄프로파일"],
|
||||
"characteristics": "파형 금속에 소프트 코팅",
|
||||
"pressure_range": "150LB ~ 1500LB",
|
||||
"temperature_range": "-200°C ~ 700°C",
|
||||
"applications": "고온고압, 화학공정"
|
||||
},
|
||||
|
||||
"CUSTOM_GASKET": {
|
||||
"dat_file_patterns": ["CUSTOM_", "SPEC_"],
|
||||
"description_keywords": ["CUSTOM", "SPECIAL", "특주", "맞춤"],
|
||||
"characteristics": "특수 제작 가스켓",
|
||||
"pressure_range": "요구사항별",
|
||||
"temperature_range": "요구사항별",
|
||||
"applications": "특수 형상, 특수 조건"
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 가스켓 재질별 분류 ==========
|
||||
GASKET_MATERIALS = {
|
||||
"GRAPHITE": {
|
||||
"keywords": ["GRAPHITE", "그라파이트", "흑연"],
|
||||
"characteristics": "고온 내성, 화학 안정성",
|
||||
"temperature_range": "-200°C ~ 650°C",
|
||||
"applications": "고온 스팀, 화학공정"
|
||||
},
|
||||
|
||||
"PTFE": {
|
||||
"keywords": ["PTFE", "TEFLON", "테프론"],
|
||||
"characteristics": "화학 내성, 낮은 마찰",
|
||||
"temperature_range": "-200°C ~ 260°C",
|
||||
"applications": "화학공정, 식품용"
|
||||
},
|
||||
|
||||
"VITON": {
|
||||
"keywords": ["VITON", "FKM", "바이톤"],
|
||||
"characteristics": "유류 내성, 고온 내성",
|
||||
"temperature_range": "-20°C ~ 200°C",
|
||||
"applications": "유류, 고온 가스"
|
||||
},
|
||||
|
||||
"EPDM": {
|
||||
"keywords": ["EPDM", "이피디엠"],
|
||||
"characteristics": "일반 고무, 스팀 내성",
|
||||
"temperature_range": "-50°C ~ 150°C",
|
||||
"applications": "스팀, 일반용"
|
||||
},
|
||||
|
||||
"NBR": {
|
||||
"keywords": ["NBR", "NITRILE", "니트릴"],
|
||||
"characteristics": "유류 내성",
|
||||
"temperature_range": "-30°C ~ 100°C",
|
||||
"applications": "유압, 윤활유"
|
||||
},
|
||||
|
||||
"METAL": {
|
||||
"keywords": ["METAL", "SS", "STAINLESS", "금속"],
|
||||
"characteristics": "고온고압 내성",
|
||||
"temperature_range": "-200°C ~ 800°C",
|
||||
"applications": "극한 조건"
|
||||
},
|
||||
|
||||
"COMPOSITE": {
|
||||
"keywords": ["COMPOSITE", "복합재", "FIBER"],
|
||||
"characteristics": "다층 구조",
|
||||
"temperature_range": "재질별 상이",
|
||||
"applications": "특수 조건"
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 압력 등급별 분류 ==========
|
||||
GASKET_PRESSURE_RATINGS = {
|
||||
"patterns": [
|
||||
r"(\d+)LB",
|
||||
r"CLASS\s*(\d+)",
|
||||
r"CL\s*(\d+)",
|
||||
r"(\d+)#"
|
||||
],
|
||||
"standard_ratings": {
|
||||
"150LB": {"max_pressure": "285 PSI", "typical_gasket": "SHEET, SPIRAL_WOUND"},
|
||||
"300LB": {"max_pressure": "740 PSI", "typical_gasket": "SPIRAL_WOUND, SHEET"},
|
||||
"600LB": {"max_pressure": "1480 PSI", "typical_gasket": "SPIRAL_WOUND, RTJ"},
|
||||
"900LB": {"max_pressure": "2220 PSI", "typical_gasket": "SPIRAL_WOUND, RTJ"},
|
||||
"1500LB": {"max_pressure": "3705 PSI", "typical_gasket": "RTJ, SPIRAL_WOUND"},
|
||||
"2500LB": {"max_pressure": "6170 PSI", "typical_gasket": "RTJ"}
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 사이즈 표기법 ==========
|
||||
GASKET_SIZE_PATTERNS = {
|
||||
"flange_size": r"(\d+(?:\.\d+)?)\s*[\"\'']?\s*(?:INCH|IN|인치)?",
|
||||
"inner_diameter": r"ID\s*(\d+(?:\.\d+)?)",
|
||||
"outer_diameter": r"OD\s*(\d+(?:\.\d+)?)",
|
||||
"thickness": r"THK?\s*(\d+(?:\.\d+)?)\s*MM"
|
||||
}
|
||||
|
||||
def classify_gasket(dat_file: str, description: str, main_nom: str, length: float = None) -> Dict:
|
||||
"""
|
||||
완전한 GASKET 분류
|
||||
|
||||
Args:
|
||||
dat_file: DAT_FILE 필드
|
||||
description: DESCRIPTION 필드
|
||||
main_nom: MAIN_NOM 필드 (플랜지 사이즈)
|
||||
|
||||
Returns:
|
||||
완전한 가스켓 분류 결과
|
||||
"""
|
||||
|
||||
# 1. 재질 분류 (공통 모듈 + 가스켓 전용)
|
||||
material_result = classify_material(description)
|
||||
gasket_material_result = classify_gasket_material(description)
|
||||
|
||||
# 2. 가스켓 타입 분류
|
||||
gasket_type_result = classify_gasket_type(dat_file, description)
|
||||
|
||||
# 3. 압력 등급 분류
|
||||
pressure_result = classify_gasket_pressure_rating(dat_file, description)
|
||||
|
||||
# 4. 사이즈 정보 추출
|
||||
size_result = extract_gasket_size_info(main_nom, description)
|
||||
|
||||
# 5. 온도 범위 추출
|
||||
temperature_result = extract_temperature_range(description, gasket_type_result, gasket_material_result)
|
||||
|
||||
# 6. 최종 결과 조합
|
||||
return {
|
||||
"category": "GASKET",
|
||||
|
||||
# 재질 정보 (공통 + 가스켓 전용)
|
||||
"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)
|
||||
},
|
||||
|
||||
"gasket_material": {
|
||||
"material": gasket_material_result.get('material', 'UNKNOWN'),
|
||||
"characteristics": gasket_material_result.get('characteristics', ''),
|
||||
"temperature_range": gasket_material_result.get('temperature_range', ''),
|
||||
"confidence": gasket_material_result.get('confidence', 0.0),
|
||||
"swg_details": gasket_material_result.get('swg_details', {})
|
||||
},
|
||||
|
||||
# 가스켓 분류 정보
|
||||
"gasket_type": {
|
||||
"type": gasket_type_result.get('type', 'UNKNOWN'),
|
||||
"characteristics": gasket_type_result.get('characteristics', ''),
|
||||
"confidence": gasket_type_result.get('confidence', 0.0),
|
||||
"evidence": gasket_type_result.get('evidence', []),
|
||||
"pressure_range": gasket_type_result.get('pressure_range', ''),
|
||||
"applications": gasket_type_result.get('applications', '')
|
||||
},
|
||||
|
||||
"pressure_rating": {
|
||||
"rating": pressure_result.get('rating', 'UNKNOWN'),
|
||||
"confidence": pressure_result.get('confidence', 0.0),
|
||||
"max_pressure": pressure_result.get('max_pressure', ''),
|
||||
"typical_gasket": pressure_result.get('typical_gasket', '')
|
||||
},
|
||||
|
||||
"size_info": {
|
||||
"flange_size": size_result.get('flange_size', main_nom),
|
||||
"inner_diameter": size_result.get('inner_diameter', ''),
|
||||
"outer_diameter": size_result.get('outer_diameter', ''),
|
||||
"thickness": size_result.get('thickness', ''),
|
||||
"size_description": size_result.get('size_description', main_nom)
|
||||
},
|
||||
|
||||
"temperature_info": {
|
||||
"range": temperature_result.get('range', ''),
|
||||
"max_temp": temperature_result.get('max_temp', ''),
|
||||
"min_temp": temperature_result.get('min_temp', ''),
|
||||
"confidence": temperature_result.get('confidence', 0.0)
|
||||
},
|
||||
|
||||
# 전체 신뢰도
|
||||
"overall_confidence": calculate_gasket_confidence({
|
||||
"gasket_type": gasket_type_result.get('confidence', 0),
|
||||
"gasket_material": gasket_material_result.get('confidence', 0),
|
||||
"pressure": pressure_result.get('confidence', 0),
|
||||
"size": size_result.get('confidence', 0.8) # 기본 신뢰도
|
||||
})
|
||||
}
|
||||
|
||||
def classify_gasket_type(dat_file: str, description: str) -> Dict:
|
||||
"""가스켓 타입 분류"""
|
||||
|
||||
dat_upper = dat_file.upper()
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. DAT_FILE 패턴으로 1차 분류
|
||||
for gasket_type, type_data in GASKET_TYPES.items():
|
||||
for pattern in type_data["dat_file_patterns"]:
|
||||
if pattern in dat_upper:
|
||||
return {
|
||||
"type": gasket_type,
|
||||
"characteristics": type_data["characteristics"],
|
||||
"confidence": 0.95,
|
||||
"evidence": [f"DAT_FILE_PATTERN: {pattern}"],
|
||||
"pressure_range": type_data["pressure_range"],
|
||||
"temperature_range": type_data["temperature_range"],
|
||||
"applications": type_data["applications"]
|
||||
}
|
||||
|
||||
# 2. DESCRIPTION 키워드로 2차 분류
|
||||
for gasket_type, type_data in GASKET_TYPES.items():
|
||||
for keyword in type_data["description_keywords"]:
|
||||
if keyword in desc_upper:
|
||||
return {
|
||||
"type": gasket_type,
|
||||
"characteristics": type_data["characteristics"],
|
||||
"confidence": 0.85,
|
||||
"evidence": [f"DESCRIPTION_KEYWORD: {keyword}"],
|
||||
"pressure_range": type_data["pressure_range"],
|
||||
"temperature_range": type_data["temperature_range"],
|
||||
"applications": type_data["applications"]
|
||||
}
|
||||
|
||||
# 3. 분류 실패
|
||||
return {
|
||||
"type": "UNKNOWN",
|
||||
"characteristics": "",
|
||||
"confidence": 0.0,
|
||||
"evidence": ["NO_GASKET_TYPE_IDENTIFIED"],
|
||||
"pressure_range": "",
|
||||
"temperature_range": "",
|
||||
"applications": ""
|
||||
}
|
||||
|
||||
def parse_swg_details(description: str) -> Dict:
|
||||
"""SWG (Spiral Wound Gasket) 상세 정보 파싱"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
result = {
|
||||
"face_type": "UNKNOWN",
|
||||
"outer_ring": "UNKNOWN",
|
||||
"filler": "UNKNOWN",
|
||||
"inner_ring": "UNKNOWN",
|
||||
"thickness": None,
|
||||
"detailed_construction": "",
|
||||
"confidence": 0.0
|
||||
}
|
||||
|
||||
# H/F/I/O 패턴 파싱 (Head/Face/Inner/Outer)
|
||||
hfio_pattern = r'H/F/I/O|HFIO'
|
||||
if re.search(hfio_pattern, desc_upper):
|
||||
result["face_type"] = "H/F/I/O"
|
||||
result["confidence"] += 0.3
|
||||
|
||||
# 재질 구성 파싱 (SS304/GRAPHITE/CS/CS)
|
||||
# H/F/I/O 다음에 나오는 재질 구성을 찾음
|
||||
material_pattern = r'H/F/I/O\s+([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)'
|
||||
material_match = re.search(material_pattern, desc_upper)
|
||||
if material_match:
|
||||
result["outer_ring"] = material_match.group(1) # SS304
|
||||
result["filler"] = material_match.group(2) # GRAPHITE
|
||||
result["inner_ring"] = material_match.group(3) # CS
|
||||
# 네 번째는 보통 outer ring 반복
|
||||
result["detailed_construction"] = f"{material_match.group(1)}/{material_match.group(2)}/{material_match.group(3)}/{material_match.group(4)}"
|
||||
result["confidence"] += 0.4
|
||||
else:
|
||||
# H/F/I/O 없이 재질만 있는 경우
|
||||
material_pattern_simple = r'([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)'
|
||||
material_match = re.search(material_pattern_simple, desc_upper)
|
||||
if material_match:
|
||||
result["outer_ring"] = material_match.group(1)
|
||||
result["filler"] = material_match.group(2)
|
||||
result["inner_ring"] = material_match.group(3)
|
||||
result["detailed_construction"] = f"{material_match.group(1)}/{material_match.group(2)}/{material_match.group(3)}/{material_match.group(4)}"
|
||||
result["confidence"] += 0.3
|
||||
|
||||
# 두께 파싱 (4.5mm)
|
||||
thickness_pattern = r'(\d+(?:\.\d+)?)\s*MM'
|
||||
thickness_match = re.search(thickness_pattern, desc_upper)
|
||||
if thickness_match:
|
||||
result["thickness"] = float(thickness_match.group(1))
|
||||
result["confidence"] += 0.3
|
||||
|
||||
return result
|
||||
|
||||
def classify_gasket_material(description: str) -> Dict:
|
||||
"""가스켓 전용 재질 분류 (SWG 상세 정보 포함)"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# SWG 상세 정보 파싱
|
||||
swg_details = None
|
||||
if "SWG" in desc_upper or "SPIRAL WOUND" in desc_upper:
|
||||
swg_details = parse_swg_details(description)
|
||||
|
||||
# 기본 가스켓 재질 확인
|
||||
for material_type, material_data in GASKET_MATERIALS.items():
|
||||
for keyword in material_data["keywords"]:
|
||||
if keyword in desc_upper:
|
||||
result = {
|
||||
"material": material_type,
|
||||
"characteristics": material_data["characteristics"],
|
||||
"temperature_range": material_data["temperature_range"],
|
||||
"confidence": 0.9,
|
||||
"matched_keyword": keyword,
|
||||
"applications": material_data["applications"]
|
||||
}
|
||||
|
||||
# SWG 상세 정보 추가
|
||||
if swg_details and swg_details["confidence"] > 0:
|
||||
result["swg_details"] = swg_details
|
||||
result["confidence"] = min(0.95, result["confidence"] + swg_details["confidence"] * 0.1)
|
||||
|
||||
return result
|
||||
|
||||
# 일반 재질 키워드 확인
|
||||
if any(keyword in desc_upper for keyword in ["RUBBER", "고무"]):
|
||||
return {
|
||||
"material": "RUBBER",
|
||||
"characteristics": "일반 고무계",
|
||||
"temperature_range": "-50°C ~ 100°C",
|
||||
"confidence": 0.7,
|
||||
"matched_keyword": "RUBBER",
|
||||
"applications": "일반용"
|
||||
}
|
||||
|
||||
return {
|
||||
"material": "UNKNOWN",
|
||||
"characteristics": "",
|
||||
"temperature_range": "",
|
||||
"confidence": 0.0,
|
||||
"matched_keyword": "",
|
||||
"applications": ""
|
||||
}
|
||||
|
||||
def classify_gasket_pressure_rating(dat_file: str, description: str) -> Dict:
|
||||
"""가스켓 압력 등급 분류"""
|
||||
|
||||
combined_text = f"{dat_file} {description}".upper()
|
||||
|
||||
# 패턴 매칭으로 압력 등급 추출
|
||||
for pattern in GASKET_PRESSURE_RATINGS["patterns"]:
|
||||
match = re.search(pattern, combined_text)
|
||||
if match:
|
||||
rating_num = match.group(1)
|
||||
rating = f"{rating_num}LB"
|
||||
|
||||
# 표준 등급 정보 확인
|
||||
rating_info = GASKET_PRESSURE_RATINGS["standard_ratings"].get(rating, {})
|
||||
|
||||
if rating_info:
|
||||
confidence = 0.95
|
||||
else:
|
||||
confidence = 0.8
|
||||
rating_info = {
|
||||
"max_pressure": "확인 필요",
|
||||
"typical_gasket": "확인 필요"
|
||||
}
|
||||
|
||||
return {
|
||||
"rating": rating,
|
||||
"confidence": confidence,
|
||||
"matched_pattern": pattern,
|
||||
"matched_value": rating_num,
|
||||
"max_pressure": rating_info.get("max_pressure", ""),
|
||||
"typical_gasket": rating_info.get("typical_gasket", "")
|
||||
}
|
||||
|
||||
return {
|
||||
"rating": "UNKNOWN",
|
||||
"confidence": 0.0,
|
||||
"matched_pattern": "",
|
||||
"max_pressure": "",
|
||||
"typical_gasket": ""
|
||||
}
|
||||
|
||||
def extract_gasket_size_info(main_nom: str, description: str) -> Dict:
|
||||
"""가스켓 사이즈 정보 추출"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
size_info = {
|
||||
"flange_size": main_nom,
|
||||
"inner_diameter": "",
|
||||
"outer_diameter": "",
|
||||
"thickness": "",
|
||||
"size_description": main_nom,
|
||||
"confidence": 0.8
|
||||
}
|
||||
|
||||
# 내경(ID) 추출
|
||||
id_match = re.search(GASKET_SIZE_PATTERNS["inner_diameter"], desc_upper)
|
||||
if id_match:
|
||||
size_info["inner_diameter"] = f"{id_match.group(1)}mm"
|
||||
|
||||
# 외경(OD) 추출
|
||||
od_match = re.search(GASKET_SIZE_PATTERNS["outer_diameter"], desc_upper)
|
||||
if od_match:
|
||||
size_info["outer_diameter"] = f"{od_match.group(1)}mm"
|
||||
|
||||
# 두께(THK) 추출
|
||||
thk_match = re.search(GASKET_SIZE_PATTERNS["thickness"], desc_upper)
|
||||
if thk_match:
|
||||
size_info["thickness"] = f"{thk_match.group(1)}mm"
|
||||
|
||||
# 사이즈 설명 조합
|
||||
size_parts = [main_nom]
|
||||
if size_info["inner_diameter"] and size_info["outer_diameter"]:
|
||||
size_parts.append(f"ID{size_info['inner_diameter']}")
|
||||
size_parts.append(f"OD{size_info['outer_diameter']}")
|
||||
if size_info["thickness"]:
|
||||
size_parts.append(f"THK{size_info['thickness']}")
|
||||
|
||||
size_info["size_description"] = " ".join(size_parts)
|
||||
|
||||
return size_info
|
||||
|
||||
def extract_temperature_range(description: str, gasket_type_result: Dict,
|
||||
gasket_material_result: Dict) -> Dict:
|
||||
"""온도 범위 정보 추출"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# DESCRIPTION에서 직접 온도 추출
|
||||
temp_patterns = [
|
||||
r'(\-?\d+(?:\.\d+)?)\s*°?C\s*~\s*(\-?\d+(?:\.\d+)?)\s*°?C',
|
||||
r'(\-?\d+(?:\.\d+)?)\s*TO\s*(\-?\d+(?:\.\d+)?)\s*°?C',
|
||||
r'MAX\s*(\-?\d+(?:\.\d+)?)\s*°?C',
|
||||
r'MIN\s*(\-?\d+(?:\.\d+)?)\s*°?C'
|
||||
]
|
||||
|
||||
for pattern in temp_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
if len(match.groups()) == 2: # 범위
|
||||
return {
|
||||
"range": f"{match.group(1)}°C ~ {match.group(2)}°C",
|
||||
"min_temp": f"{match.group(1)}°C",
|
||||
"max_temp": f"{match.group(2)}°C",
|
||||
"confidence": 0.95,
|
||||
"source": "DESCRIPTION_RANGE"
|
||||
}
|
||||
else: # 단일 온도
|
||||
temp_value = match.group(1)
|
||||
if "MAX" in pattern:
|
||||
return {
|
||||
"range": f"~ {temp_value}°C",
|
||||
"max_temp": f"{temp_value}°C",
|
||||
"confidence": 0.9,
|
||||
"source": "DESCRIPTION_MAX"
|
||||
}
|
||||
|
||||
# 가스켓 재질 기반 온도 범위
|
||||
material_temp = gasket_material_result.get('temperature_range', '')
|
||||
if material_temp:
|
||||
return {
|
||||
"range": material_temp,
|
||||
"confidence": 0.8,
|
||||
"source": "MATERIAL_BASED"
|
||||
}
|
||||
|
||||
# 가스켓 타입 기반 온도 범위
|
||||
type_temp = gasket_type_result.get('temperature_range', '')
|
||||
if type_temp:
|
||||
return {
|
||||
"range": type_temp,
|
||||
"confidence": 0.7,
|
||||
"source": "TYPE_BASED"
|
||||
}
|
||||
|
||||
return {
|
||||
"range": "",
|
||||
"confidence": 0.0,
|
||||
"source": "NO_TEMPERATURE_INFO"
|
||||
}
|
||||
|
||||
def calculate_gasket_confidence(confidence_scores: Dict) -> float:
|
||||
"""가스켓 분류 전체 신뢰도 계산"""
|
||||
|
||||
scores = [score for score in confidence_scores.values() if score > 0]
|
||||
|
||||
if not scores:
|
||||
return 0.0
|
||||
|
||||
# 가중 평균
|
||||
weights = {
|
||||
"gasket_type": 0.4,
|
||||
"gasket_material": 0.3,
|
||||
"pressure": 0.2,
|
||||
"size": 0.1
|
||||
}
|
||||
|
||||
weighted_sum = sum(
|
||||
confidence_scores.get(key, 0) * weight
|
||||
for key, weight in weights.items()
|
||||
)
|
||||
|
||||
return round(weighted_sum, 2)
|
||||
|
||||
# ========== 특수 기능들 ==========
|
||||
|
||||
def get_gasket_purchase_info(gasket_result: Dict) -> Dict:
|
||||
"""가스켓 구매 정보 생성"""
|
||||
|
||||
gasket_type = gasket_result["gasket_type"]["type"]
|
||||
gasket_material = gasket_result["gasket_material"]["material"]
|
||||
pressure = gasket_result["pressure_rating"]["rating"]
|
||||
|
||||
# 공급업체 타입 결정
|
||||
if gasket_type == "CUSTOM_GASKET":
|
||||
supplier_type = "특수 가스켓 제작업체"
|
||||
elif gasket_material in ["GRAPHITE", "PTFE"]:
|
||||
supplier_type = "고급 씰링 전문업체"
|
||||
elif gasket_type in ["SPIRAL_WOUND", "RING_JOINT"]:
|
||||
supplier_type = "산업용 가스켓 전문업체"
|
||||
else:
|
||||
supplier_type = "일반 가스켓 업체"
|
||||
|
||||
# 납기 추정
|
||||
if gasket_type == "CUSTOM_GASKET":
|
||||
lead_time = "4-8주 (특수 제작)"
|
||||
elif gasket_type in ["SPIRAL_WOUND", "KAMMPROFILE"]:
|
||||
lead_time = "2-4주 (제작품)"
|
||||
else:
|
||||
lead_time = "1-2주 (재고품)"
|
||||
|
||||
# 구매 단위
|
||||
if gasket_type == "O_RING":
|
||||
purchase_unit = "EA (개별)"
|
||||
elif gasket_type == "SHEET_GASKET":
|
||||
purchase_unit = "SHEET (시트)"
|
||||
else:
|
||||
purchase_unit = "SET (세트)"
|
||||
|
||||
return {
|
||||
"supplier_type": supplier_type,
|
||||
"lead_time_estimate": lead_time,
|
||||
"purchase_category": f"{gasket_type} {pressure}",
|
||||
"purchase_unit": purchase_unit,
|
||||
"material_note": gasket_result["gasket_material"]["characteristics"],
|
||||
"temperature_note": gasket_result["temperature_info"]["range"],
|
||||
"applications": gasket_result["gasket_type"]["applications"]
|
||||
}
|
||||
|
||||
def is_high_temperature_gasket(gasket_result: Dict) -> bool:
|
||||
"""고온용 가스켓 여부 판단"""
|
||||
temp_range = gasket_result.get("temperature_info", {}).get("range", "")
|
||||
return any(indicator in temp_range for indicator in ["500°C", "600°C", "700°C", "800°C"])
|
||||
|
||||
def is_high_pressure_gasket(gasket_result: Dict) -> bool:
|
||||
"""고압용 가스켓 여부 판단"""
|
||||
pressure_rating = gasket_result.get("pressure_rating", {}).get("rating", "")
|
||||
high_pressure_ratings = ["600LB", "900LB", "1500LB", "2500LB"]
|
||||
return any(pressure in pressure_rating for pressure in high_pressure_ratings)
|
||||
233
tkeg/api/app/services/instrument_classifier.py
Normal file
233
tkeg/api/app/services/instrument_classifier.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
INSTRUMENT 분류 시스템 (간단 버전)
|
||||
완제품 구매용 기본 분류만
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from .material_classifier import classify_material
|
||||
|
||||
# ========== 기본 계기 타입 ==========
|
||||
INSTRUMENT_TYPES = {
|
||||
"PRESSURE_GAUGE": {
|
||||
"dat_file_patterns": ["PG_", "PRESS_G"],
|
||||
"description_keywords": ["PRESSURE GAUGE", "압력계", "PG"],
|
||||
"characteristics": "압력 측정용 게이지"
|
||||
},
|
||||
"TEMPERATURE_GAUGE": {
|
||||
"dat_file_patterns": ["TG_", "TEMP_G"],
|
||||
"description_keywords": ["TEMPERATURE GAUGE", "온도계", "TG", "THERMOMETER"],
|
||||
"characteristics": "온도 측정용 게이지"
|
||||
},
|
||||
"FLOW_METER": {
|
||||
"dat_file_patterns": ["FM_", "FLOW_"],
|
||||
"description_keywords": ["FLOW METER", "유량계", "FM"],
|
||||
"characteristics": "유량 측정용"
|
||||
},
|
||||
"LEVEL_GAUGE": {
|
||||
"dat_file_patterns": ["LG_", "LEVEL_"],
|
||||
"description_keywords": ["LEVEL GAUGE", "액위계", "LG", "SIGHT GLASS"],
|
||||
"characteristics": "액위 측정용"
|
||||
},
|
||||
"TRANSMITTER": {
|
||||
"dat_file_patterns": ["PT_", "TT_", "FT_", "LT_"],
|
||||
"description_keywords": ["TRANSMITTER", "트랜스미터", "4-20MA"],
|
||||
"characteristics": "신호 전송용"
|
||||
},
|
||||
"INDICATOR": {
|
||||
"dat_file_patterns": ["PI_", "TI_", "FI_", "LI_"],
|
||||
"description_keywords": ["INDICATOR", "지시계", "DISPLAY"],
|
||||
"characteristics": "표시용"
|
||||
},
|
||||
"SPECIAL_INSTRUMENT": {
|
||||
"dat_file_patterns": ["INST_", "SPEC_"],
|
||||
"description_keywords": ["THERMOWELL", "ORIFICE PLATE", "MANOMETER", "ROTAMETER"],
|
||||
"characteristics": "특수 계기류"
|
||||
}
|
||||
}
|
||||
|
||||
def classify_instrument(dat_file: str, description: str, main_nom: str, length: Optional[float] = None) -> Dict:
|
||||
"""
|
||||
간단한 INSTRUMENT 분류
|
||||
|
||||
Args:
|
||||
dat_file: DAT_FILE 필드
|
||||
description: DESCRIPTION 필드
|
||||
main_nom: MAIN_NOM 필드 (연결 사이즈)
|
||||
|
||||
Returns:
|
||||
간단한 계기 분류 결과
|
||||
"""
|
||||
|
||||
# 1. 먼저 계기인지 확인 (계기 키워드가 있어야 함)
|
||||
desc_upper = description.upper()
|
||||
dat_upper = dat_file.upper()
|
||||
combined_text = f"{dat_upper} {desc_upper}"
|
||||
|
||||
# 계기 관련 키워드 확인
|
||||
instrument_keywords = [
|
||||
"GAUGE", "METER", "TRANSMITTER", "INDICATOR", "SENSOR",
|
||||
"THERMOMETER", "MANOMETER", "ROTAMETER", "THERMOWELL",
|
||||
"ORIFICE PLATE", "DISPLAY", "4-20MA", "4-20 MA",
|
||||
"압력계", "온도계", "유량계", "액위계", "게이지", "계기",
|
||||
"트랜스미터", "지시계", "센서"
|
||||
]
|
||||
|
||||
# 계기가 아닌 것들의 키워드
|
||||
non_instrument_keywords = [
|
||||
"BOLT", "SCREW", "STUD", "NUT", "WASHER", "볼트", "나사", "너트", "와셔",
|
||||
"PIPE", "TUBE", "파이프", "배관",
|
||||
"ELBOW", "TEE", "REDUCER", "CAP", "엘보", "티", "리듀서",
|
||||
"VALVE", "GATE", "BALL", "GLOBE", "CHECK", "밸브",
|
||||
"FLANGE", "FLG", "플랜지",
|
||||
"GASKET", "GASK", "가스켓"
|
||||
]
|
||||
|
||||
# 계기가 아닌 키워드가 있으면 거부
|
||||
if any(keyword in combined_text for keyword in non_instrument_keywords):
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"overall_confidence": 0.1,
|
||||
"reason": "NON_INSTRUMENT_KEYWORDS_DETECTED"
|
||||
}
|
||||
|
||||
# 계기 키워드가 없으면 거부
|
||||
has_instrument_keyword = any(keyword in combined_text for keyword in instrument_keywords)
|
||||
if not has_instrument_keyword:
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"overall_confidence": 0.1,
|
||||
"reason": "NO_INSTRUMENT_KEYWORDS_FOUND"
|
||||
}
|
||||
|
||||
# 2. 재질 분류 (공통 모듈)
|
||||
material_result = classify_material(description)
|
||||
|
||||
# 3. 계기 타입 분류
|
||||
instrument_type_result = classify_instrument_type(dat_file, description)
|
||||
|
||||
# 4. 측정 범위 추출 (있다면)
|
||||
measurement_range = extract_measurement_range(description)
|
||||
|
||||
# 5. 전체 신뢰도 계산
|
||||
base_confidence = 0.8 if has_instrument_keyword else 0.1
|
||||
instrument_confidence = instrument_type_result.get('confidence', 0.0)
|
||||
overall_confidence = (base_confidence + instrument_confidence) / 2
|
||||
|
||||
# 6. 최종 결과
|
||||
return {
|
||||
"category": "INSTRUMENT",
|
||||
|
||||
# 재질 정보
|
||||
"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)
|
||||
},
|
||||
|
||||
# 계기 정보
|
||||
"instrument_type": {
|
||||
"type": instrument_type_result.get('type', 'UNKNOWN'),
|
||||
"characteristics": instrument_type_result.get('characteristics', ''),
|
||||
"confidence": instrument_type_result.get('confidence', 0.0)
|
||||
},
|
||||
|
||||
"measurement_info": {
|
||||
"range": measurement_range.get('range', ''),
|
||||
"unit": measurement_range.get('unit', ''),
|
||||
"signal_type": measurement_range.get('signal_type', '')
|
||||
},
|
||||
|
||||
"size_info": {
|
||||
"connection_size": main_nom,
|
||||
"size_description": main_nom
|
||||
},
|
||||
|
||||
"purchase_info": {
|
||||
"category": "완제품 구매",
|
||||
"supplier_type": "계기 전문업체",
|
||||
"lead_time": "2-4주",
|
||||
"note": "사양서 확인 후 주문"
|
||||
},
|
||||
|
||||
# 전체 신뢰도
|
||||
"overall_confidence": overall_confidence
|
||||
}
|
||||
|
||||
def classify_instrument_type(dat_file: str, description: str) -> Dict:
|
||||
"""계기 타입 분류"""
|
||||
|
||||
dat_upper = dat_file.upper()
|
||||
desc_upper = description.upper()
|
||||
|
||||
# DAT_FILE 패턴 확인
|
||||
for inst_type, type_data in INSTRUMENT_TYPES.items():
|
||||
for pattern in type_data["dat_file_patterns"]:
|
||||
if pattern in dat_upper:
|
||||
return {
|
||||
"type": inst_type,
|
||||
"characteristics": type_data["characteristics"],
|
||||
"confidence": 0.9,
|
||||
"evidence": [f"DAT_PATTERN: {pattern}"]
|
||||
}
|
||||
|
||||
# DESCRIPTION 키워드 확인
|
||||
for inst_type, type_data in INSTRUMENT_TYPES.items():
|
||||
for keyword in type_data["description_keywords"]:
|
||||
if keyword in desc_upper:
|
||||
return {
|
||||
"type": inst_type,
|
||||
"characteristics": type_data["characteristics"],
|
||||
"confidence": 0.8,
|
||||
"evidence": [f"KEYWORD: {keyword}"]
|
||||
}
|
||||
|
||||
return {
|
||||
"type": "UNKNOWN",
|
||||
"characteristics": "분류되지 않은 계기",
|
||||
"confidence": 0.0,
|
||||
"evidence": ["NO_INSTRUMENT_TYPE_FOUND"]
|
||||
}
|
||||
|
||||
def extract_measurement_range(description: str) -> Dict:
|
||||
"""측정 범위 추출 (간단히)"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 압력 범위
|
||||
pressure_match = re.search(r'(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*(PSI|BAR|KPA)', desc_upper)
|
||||
if pressure_match:
|
||||
return {
|
||||
"range": f"{pressure_match.group(1)}-{pressure_match.group(2)}",
|
||||
"unit": pressure_match.group(3),
|
||||
"signal_type": "PRESSURE"
|
||||
}
|
||||
|
||||
# 온도 범위
|
||||
temp_match = re.search(r'(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*(°?C|°?F)', desc_upper)
|
||||
if temp_match:
|
||||
return {
|
||||
"range": f"{temp_match.group(1)}-{temp_match.group(2)}",
|
||||
"unit": temp_match.group(3),
|
||||
"signal_type": "TEMPERATURE"
|
||||
}
|
||||
|
||||
# 신호 타입
|
||||
if "4-20MA" in desc_upper or "4-20 MA" in desc_upper:
|
||||
return {
|
||||
"range": "4-20mA",
|
||||
"unit": "mA",
|
||||
"signal_type": "ANALOG"
|
||||
}
|
||||
|
||||
return {
|
||||
"range": "",
|
||||
"unit": "",
|
||||
"signal_type": ""
|
||||
}
|
||||
|
||||
def calculate_simple_confidence(scores: List[float]) -> float:
|
||||
"""간단한 신뢰도 계산"""
|
||||
valid_scores = [s for s in scores if s > 0]
|
||||
return round(sum(valid_scores) / len(valid_scores), 2) if valid_scores else 0.0
|
||||
301
tkeg/api/app/services/integrated_classifier.py
Normal file
301
tkeg/api/app/services/integrated_classifier.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
통합 자재 분류 시스템
|
||||
메모리에 정의된 키워드 우선순위 체계를 적용
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from .fitting_classifier import classify_fitting
|
||||
from .classifier_constants import (
|
||||
LEVEL1_TYPE_KEYWORDS,
|
||||
LEVEL2_SUBTYPE_KEYWORDS,
|
||||
LEVEL3_CONNECTION_KEYWORDS,
|
||||
LEVEL3_PRESSURE_KEYWORDS,
|
||||
LEVEL4_MATERIAL_KEYWORDS,
|
||||
GENERIC_MATERIALS
|
||||
)
|
||||
|
||||
def classify_material_integrated(description: str, main_nom: str = "",
|
||||
red_nom: str = "", length: float = None) -> Dict:
|
||||
"""
|
||||
통합 자재 분류 함수
|
||||
|
||||
Args:
|
||||
description: 자재 설명
|
||||
main_nom: 주 사이즈
|
||||
red_nom: 축소 사이즈 (플랜지/피팅용)
|
||||
length: 길이 (파이프용)
|
||||
|
||||
Returns:
|
||||
분류 결과 딕셔너리
|
||||
"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 최우선: SPECIAL 키워드 확인 (도면 업로드가 필요한 특수 자재)
|
||||
# SPECIAL이 포함된 경우 (단, SPECIFICATION은 제외)
|
||||
if 'SPECIAL' in desc_upper and 'SPECIFICATION' not in desc_upper:
|
||||
return {
|
||||
"category": "SPECIAL",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["SPECIAL_KEYWORD"],
|
||||
"classification_level": "LEVEL0_SPECIAL",
|
||||
"reason": "SPECIAL 키워드 발견"
|
||||
}
|
||||
|
||||
# 스페셜 관련 한글 키워드
|
||||
if '스페셜' in desc_upper or 'SPL' in desc_upper:
|
||||
return {
|
||||
"category": "SPECIAL",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["SPECIAL_KEYWORD"],
|
||||
"classification_level": "LEVEL0_SPECIAL",
|
||||
"reason": "스페셜 키워드 발견"
|
||||
}
|
||||
|
||||
|
||||
# VALVE 카테고리 우선 확인 (SIGHT GLASS, STRAINER)
|
||||
if ('SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or
|
||||
'사이트글라스' in desc_upper or '스트레이너' in desc_upper):
|
||||
return {
|
||||
"category": "VALVE",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["VALVE_SPECIAL_KEYWORD"],
|
||||
"classification_level": "LEVEL0_VALVE",
|
||||
"reason": "SIGHT GLASS 또는 STRAINER 키워드 발견"
|
||||
}
|
||||
|
||||
# SUPPORT 카테고리 우선 확인 (BOLT 카테고리보다 먼저)
|
||||
# U-BOLT, CLAMP, URETHANE BLOCK 등
|
||||
if ('U-BOLT' in desc_upper or 'U BOLT' in desc_upper or '유볼트' in desc_upper or
|
||||
'URETHANE BLOCK' in desc_upper or 'BLOCK SHOE' in desc_upper or '우레탄' in desc_upper or
|
||||
'CLAMP' in desc_upper or '클램프' in desc_upper or 'PIPE CLAMP' in desc_upper):
|
||||
return {
|
||||
"category": "SUPPORT",
|
||||
"confidence": 1.0,
|
||||
"evidence": ["SUPPORT_SYSTEM_KEYWORD"],
|
||||
"classification_level": "LEVEL0_SUPPORT",
|
||||
"reason": "SUPPORT 시스템 키워드 발견"
|
||||
}
|
||||
|
||||
# [신규] Swagelok 스타일 파트 넘버 패턴 확인
|
||||
# 예: SS-400-1-4, SS-810-6, B-400-9, SS-1610-P
|
||||
swagelok_pattern = r'\b(SS|S|B|A|M)-([0-9]{3,4}|[0-9]+M[0-9]*)-([0-9A-Z])'
|
||||
if re.search(swagelok_pattern, desc_upper):
|
||||
return {
|
||||
"category": "TUBE_FITTING",
|
||||
"confidence": 0.98,
|
||||
"evidence": ["SWAGELOK_PART_NO"],
|
||||
"classification_level": "LEVEL0_PARTNO",
|
||||
"reason": "Swagelok 스타일 파트넘버 감지"
|
||||
}
|
||||
|
||||
# 쉼표로 구분된 각 부분을 별도로 체크 (예: "NIPPLE, SMLS, SCH 80")
|
||||
desc_parts = [part.strip() for part in desc_upper.split(',')]
|
||||
|
||||
# 1단계: Level 1 키워드로 타입 식별
|
||||
detected_types = []
|
||||
|
||||
# 특별 우선순위: REDUCING FLANGE 먼저 확인 (강화된 로직)
|
||||
reducing_flange_patterns = [
|
||||
"REDUCING FLANGE", "RED FLANGE", "REDUCER FLANGE",
|
||||
"REDUCING FLG", "RED FLG", "REDUCER FLG"
|
||||
]
|
||||
|
||||
# FLANGE와 REDUCING/RED/REDUCER가 함께 있는 경우도 확인
|
||||
has_flange = any(flange_word in desc_upper for flange_word in ["FLANGE", "FLG"])
|
||||
has_reducing = any(red_word in desc_upper for red_word in ["REDUCING", "RED", "REDUCER"])
|
||||
|
||||
# 직접 패턴 매칭 또는 FLANGE + REDUCING 조합
|
||||
reducing_flange_detected = False
|
||||
for pattern in reducing_flange_patterns:
|
||||
if pattern in desc_upper:
|
||||
detected_types.append(("FLANGE", "REDUCING FLANGE"))
|
||||
reducing_flange_detected = True
|
||||
break
|
||||
|
||||
# FLANGE와 REDUCING이 모두 있으면 REDUCING FLANGE로 분류
|
||||
if not reducing_flange_detected and has_flange and has_reducing:
|
||||
detected_types.append(("FLANGE", "REDUCING FLANGE"))
|
||||
reducing_flange_detected = True
|
||||
|
||||
# REDUCING FLANGE가 감지되지 않은 경우에만 일반 키워드 검사
|
||||
if not reducing_flange_detected:
|
||||
for material_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
|
||||
type_found = False
|
||||
# 긴 키워드부터 확인 (FLANGE BOLT가 FLANGE보다 먼저 매칭되도록)
|
||||
sorted_keywords = sorted(keywords, key=len, reverse=True)
|
||||
for keyword in sorted_keywords:
|
||||
# [강화된 로직] 짧은 키워드나 중의적 키워드에 대한 엄격한 검사
|
||||
is_strict_match = True
|
||||
|
||||
# 1. "PL" 키워드 검사 (PLATE)
|
||||
if keyword == "PL":
|
||||
# 단독 단어이거나 숫자 뒤에 붙는 경우만 허용 (예: 10PL, 10 PL)
|
||||
# COUPLING, NIPPLE, PLUG 등에 포함된 PL은 제외
|
||||
pl_pattern = r'(\b|\d)PL\b'
|
||||
if not re.search(pl_pattern, desc_upper):
|
||||
is_strict_match = False
|
||||
|
||||
# 2. "ANGLE" 키워드 검사 (STRUCTURAL)
|
||||
elif keyword == "ANGLE" or keyword == "앵글":
|
||||
# VALVE와 함께 쓰이면 제외 (ANGLE VALVE)
|
||||
if "VALVE" in desc_upper or "밸브" in desc_upper:
|
||||
is_strict_match = False
|
||||
|
||||
# 3. "UNION" 키워드 검사 (FITTING)
|
||||
elif keyword == "UNION":
|
||||
# 계장용인지 파이프용인지 구분은 fitting_classifier에서 하되,
|
||||
# 여기서는 일단 FITTING으로 잡히도록 둠.
|
||||
pass
|
||||
|
||||
# 4. "BEAM" 키워드 검사 (STRUCTURAL)
|
||||
elif keyword == "BEAM":
|
||||
# "BEAM CLAMP" 같은 경우 SUPPORT로 가야 함 (SUPPORT가 우선순위 높으므로 괜찮음)
|
||||
pass
|
||||
|
||||
if not is_strict_match:
|
||||
continue
|
||||
|
||||
# 전체 문자열에서 찾기
|
||||
if keyword in desc_upper:
|
||||
detected_types.append((material_type, keyword))
|
||||
type_found = True
|
||||
break
|
||||
# 각 부분에서도 정확히 매칭되는지 확인
|
||||
for part in desc_parts:
|
||||
if keyword == part or keyword in part:
|
||||
detected_types.append((material_type, keyword))
|
||||
type_found = True
|
||||
break
|
||||
if type_found:
|
||||
break
|
||||
|
||||
# 2단계: 복수 타입 감지 시 Level 2로 구체화
|
||||
if len(detected_types) > 1:
|
||||
# Level 2 키워드로 우선순위 결정
|
||||
for material_type, subtype_dict in LEVEL2_SUBTYPE_KEYWORDS.items():
|
||||
for subtype, keywords in subtype_dict.items():
|
||||
for keyword in keywords:
|
||||
if keyword in desc_upper:
|
||||
return {
|
||||
"category": material_type,
|
||||
"confidence": 0.95,
|
||||
"evidence": [f"L1_KEYWORD: {detected_types}", f"L2_KEYWORD: {keyword}"],
|
||||
"classification_level": "LEVEL2"
|
||||
}
|
||||
|
||||
# Level 2 키워드가 없으면 우선순위로 결정
|
||||
# BOLT > SUPPORT > FITTING > VALVE > FLANGE > PIPE (볼트 우선, 더 구체적인 것 우선)
|
||||
type_priority = ["BOLT", "SUPPORT", "FITTING", "VALVE", "FLANGE", "PIPE", "GASKET", "INSTRUMENT"]
|
||||
for priority_type in type_priority:
|
||||
for detected_type, keyword in detected_types:
|
||||
if detected_type == priority_type:
|
||||
return {
|
||||
"category": priority_type,
|
||||
"confidence": 0.85,
|
||||
"evidence": [f"L1_MULTI_TYPE: {detected_types}", f"PRIORITY: {priority_type}"],
|
||||
"classification_level": "LEVEL1_PRIORITY"
|
||||
}
|
||||
|
||||
# 3단계: 단일 타입 확정 또는 Level 3/4로 판단
|
||||
if len(detected_types) == 1:
|
||||
material_type = detected_types[0][0]
|
||||
|
||||
# FITTING으로 분류된 경우 상세 분류기 호출
|
||||
if material_type == "FITTING":
|
||||
try:
|
||||
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
|
||||
# 상세 분류 결과가 있으면 사용, 없으면 기본 FITTING 반환
|
||||
if detailed_result and detailed_result.get("category"):
|
||||
return detailed_result
|
||||
except Exception as e:
|
||||
# 상세 분류 실패 시 기본 FITTING으로 처리
|
||||
pass
|
||||
|
||||
return {
|
||||
"category": material_type,
|
||||
"confidence": 0.9,
|
||||
"evidence": [f"L1_KEYWORD: {detected_types[0][1]}"],
|
||||
"classification_level": "LEVEL1"
|
||||
}
|
||||
|
||||
# 4단계: Level 1 없으면 재질 기반 분류
|
||||
if not detected_types:
|
||||
# 전용 재질 확인
|
||||
for material_type, materials in LEVEL4_MATERIAL_KEYWORDS.items():
|
||||
for material in materials:
|
||||
if material in desc_upper:
|
||||
# 볼트 재질(A193, A194)은 다른 키워드가 있는지 확인
|
||||
if material_type == "BOLT":
|
||||
# 다른 타입 키워드가 있으면 볼트로 분류하지 않음
|
||||
other_type_found = False
|
||||
for other_type, keywords in LEVEL1_TYPE_KEYWORDS.items():
|
||||
if other_type != "BOLT":
|
||||
for keyword in keywords:
|
||||
if keyword in desc_upper:
|
||||
other_type_found = True
|
||||
break
|
||||
if other_type_found:
|
||||
break
|
||||
|
||||
if other_type_found:
|
||||
continue # 볼트로 분류하지 않음
|
||||
|
||||
# FITTING으로 분류된 경우 상세 분류기 호출
|
||||
if material_type == "FITTING":
|
||||
try:
|
||||
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
|
||||
if detailed_result and detailed_result.get("category"):
|
||||
return detailed_result
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return {
|
||||
"category": material_type,
|
||||
"confidence": 0.35, # 재질만으로 분류 시 낮은 신뢰도
|
||||
"evidence": [f"L4_MATERIAL: {material}"],
|
||||
"classification_level": "LEVEL4"
|
||||
}
|
||||
|
||||
# 범용 재질 확인
|
||||
for material, priority_types in GENERIC_MATERIALS.items():
|
||||
if material in desc_upper:
|
||||
# 우선순위에 따라 타입 결정
|
||||
material_type = priority_types[0] # 첫 번째 우선순위
|
||||
|
||||
# FITTING으로 분류된 경우 상세 분류기 호출
|
||||
if material_type == "FITTING":
|
||||
try:
|
||||
detailed_result = classify_fitting("", description, main_nom, red_nom, length)
|
||||
if detailed_result and detailed_result.get("category"):
|
||||
return detailed_result
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return {
|
||||
"category": material_type,
|
||||
"confidence": 0.3,
|
||||
"evidence": [f"GENERIC_MATERIAL: {material}"],
|
||||
"classification_level": "LEVEL4_GENERIC"
|
||||
}
|
||||
|
||||
# 분류 실패
|
||||
return {
|
||||
"category": "UNCLASSIFIED",
|
||||
"confidence": 0.0,
|
||||
"evidence": ["NO_CLASSIFICATION_POSSIBLE"],
|
||||
"classification_level": "NONE"
|
||||
}
|
||||
|
||||
def should_exclude_material(description: str) -> bool:
|
||||
"""
|
||||
제외 대상 자재인지 확인
|
||||
"""
|
||||
exclude_keywords = [
|
||||
"DUMMY", "RESERVED", "SPARE", "DELETED", "CANCELED",
|
||||
"더미", "예비", "삭제", "취소", "예약"
|
||||
]
|
||||
|
||||
desc_upper = description.upper()
|
||||
return any(keyword in desc_upper for keyword in exclude_keywords)
|
||||
338
tkeg/api/app/services/material_classifier.py
Normal file
338
tkeg/api/app/services/material_classifier.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
재질 분류를 위한 공통 함수
|
||||
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 = str(description).upper().strip() if description is not None else ""
|
||||
|
||||
# 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:
|
||||
grade_code = match.group(1) if match.groups() else ""
|
||||
full_grade = f"ASTM {standard}" + (f" {grade_code}" if grade_code else "")
|
||||
|
||||
return {
|
||||
"standard": f"ASTM {standard}",
|
||||
"grade": full_grade,
|
||||
"material_type": determine_material_type(standard, grade_code),
|
||||
"manufacturing": standard_data.get("manufacturing", "UNKNOWN"),
|
||||
"confidence": 0.9,
|
||||
"evidence": [f"ASTM_{standard}: {grade_code if grade_code else '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, {})
|
||||
|
||||
# A312의 경우 TP304 형태로 전체 grade 표시
|
||||
if standard == "A312" and grade_code and not grade_code.startswith("TP"):
|
||||
full_grade = f"ASTM {standard} TP{grade_code}"
|
||||
elif grade_code.startswith("TP"):
|
||||
full_grade = f"ASTM {standard} {grade_code}"
|
||||
# A403의 경우 WP304 형태로 전체 grade 표시
|
||||
elif standard == "A403" and grade_code and not grade_code.startswith("WP"):
|
||||
full_grade = f"ASTM {standard} WP{grade_code}"
|
||||
elif grade_code.startswith("WP"):
|
||||
full_grade = f"ASTM {standard} {grade_code}"
|
||||
# A420의 경우 WPL3 형태로 전체 grade 표시
|
||||
elif standard == "A420" and grade_code and not grade_code.startswith("WPL"):
|
||||
full_grade = f"ASTM {standard} WPL{grade_code}"
|
||||
elif grade_code.startswith("WPL"):
|
||||
full_grade = f"ASTM {standard} {grade_code}"
|
||||
else:
|
||||
full_grade = f"ASTM {standard} {grade_code}" if grade_code else f"ASTM {standard}"
|
||||
|
||||
return {
|
||||
"standard": f"ASTM {standard}",
|
||||
"grade": full_grade,
|
||||
"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:
|
||||
"""규격과 등급으로 재질 타입 결정"""
|
||||
|
||||
# grade가 None이면 기본값 처리
|
||||
if not grade:
|
||||
grade = ""
|
||||
|
||||
# 스테인리스 등급
|
||||
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 or 'A420' 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
|
||||
263
tkeg/api/app/services/material_grade_extractor.py
Normal file
263
tkeg/api/app/services/material_grade_extractor.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
전체 재질명 추출기
|
||||
원본 설명에서 완전한 재질명을 추출하여 축약되지 않은 형태로 제공
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional, Dict
|
||||
|
||||
def extract_full_material_grade(description: str) -> str:
|
||||
"""
|
||||
원본 설명에서 전체 재질명 추출
|
||||
|
||||
Args:
|
||||
description: 원본 자재 설명
|
||||
|
||||
Returns:
|
||||
전체 재질명 (예: "ASTM A312 TP304", "ASTM A106 GR B")
|
||||
"""
|
||||
if not description:
|
||||
return ""
|
||||
|
||||
desc_upper = description.upper().strip()
|
||||
|
||||
# 1. ASTM 규격 패턴들 (가장 구체적인 것부터)
|
||||
astm_patterns = [
|
||||
# A320 L7, A325, A490 등 단독 규격 (ASTM 없이)
|
||||
r'\bA320\s+L[0-9]+\b', # A320 L7
|
||||
r'\bA325\b', # A325
|
||||
r'\bA490\b', # A490
|
||||
# ASTM A193/A194 GR B7/2H (볼트용 조합 패턴) - 최우선
|
||||
r'ASTM\s+A193/A194\s+GR\s+[A-Z0-9/]+',
|
||||
r'ASTM\s+A193/A194\s+[A-Z0-9/]+',
|
||||
# ASTM A320/A194M GR B8/8 (저온용 볼트 조합 패턴)
|
||||
r'ASTM\s+A320[M]?/A194[M]?\s+GR\s+[A-Z0-9/]+',
|
||||
r'ASTM\s+A320[M]?/A194[M]?\s+[A-Z0-9/]+',
|
||||
# 단독 A193/A194 패턴 (ASTM 없이)
|
||||
r'\bA193/A194\s+GR\s+[A-Z0-9/]+\b',
|
||||
r'\bA193/A194\s+[A-Z0-9/]+\b',
|
||||
# 단독 A320/A194M 패턴 (ASTM 없이)
|
||||
r'\bA320[M]?/A194[M]?\s+GR\s+[A-Z0-9/]+\b',
|
||||
r'\bA320[M]?/A194[M]?\s+[A-Z0-9/]+\b',
|
||||
# ASTM A312 TP304, ASTM A312 TP316L 등
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+TP\d+[A-Z]*',
|
||||
# ASTM A182 F304, ASTM A182 F316L 등
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+F\d+[A-Z]*',
|
||||
# ASTM A403 WP304, ASTM A234 WPB 등
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+WP[A-Z0-9]+',
|
||||
# ASTM A351 CF8M, ASTM A216 WCB 등
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z]{2,4}[0-9]*[A-Z]*',
|
||||
# ASTM A106 GR B, ASTM A105 등 - GR 포함
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+GR\s+[A-Z0-9/]+',
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+GRADE\s+[A-Z0-9/]+',
|
||||
# ASTM A106 B (GR 없이 바로 등급) - 단일 문자 등급
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*\s+[A-Z](?=\s|$)',
|
||||
# ASTM A105, ASTM A234 등 (등급 없는 경우)
|
||||
r'ASTM\s+A\d{3,4}[A-Z]*(?!\s+[A-Z0-9])',
|
||||
# 2자리 ASTM 규격도 지원 (A10, A36 등)
|
||||
r'ASTM\s+A\d{2}(?:\s+GR\s+[A-Z0-9/]+)?',
|
||||
]
|
||||
|
||||
for pattern in astm_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
full_grade = match.group(0).strip()
|
||||
# 추가 정보가 있는지 확인 (PBE, BBE 등은 제외)
|
||||
end_pos = match.end()
|
||||
remaining = desc_upper[end_pos:].strip()
|
||||
|
||||
# 끝단 가공 정보는 제외
|
||||
end_prep_codes = ['PBE', 'BBE', 'POE', 'BOE', 'TOE']
|
||||
for code in end_prep_codes:
|
||||
remaining = re.sub(rf'\b{code}\b', '', remaining).strip()
|
||||
|
||||
# 남은 재질 관련 정보가 있으면 추가
|
||||
additional_info = []
|
||||
if remaining:
|
||||
# 일반적인 재질 추가 정보 패턴
|
||||
additional_patterns = [
|
||||
r'\bH\b', # H (고온용)
|
||||
r'\bL\b', # L (저탄소)
|
||||
r'\bN\b', # N (질소 첨가)
|
||||
r'\bS\b', # S (황 첨가)
|
||||
r'\bMOD\b', # MOD (개량형)
|
||||
]
|
||||
|
||||
for add_pattern in additional_patterns:
|
||||
if re.search(add_pattern, remaining):
|
||||
additional_info.append(re.search(add_pattern, remaining).group(0))
|
||||
|
||||
if additional_info:
|
||||
full_grade += ' ' + ' '.join(additional_info)
|
||||
|
||||
return full_grade
|
||||
|
||||
# 2. ASME 규격 패턴들
|
||||
asme_patterns = [
|
||||
r'ASME\s+SA\d+[A-Z]*\s+TP\d+[A-Z]*(?:\s+[A-Z]+)*',
|
||||
r'ASME\s+SA\d+[A-Z]*\s+GR\s+[A-Z0-9]+(?:\s+[A-Z]+)*',
|
||||
r'ASME\s+SA\d+[A-Z]*\s+F\d+[A-Z]*(?:\s+[A-Z]+)*',
|
||||
r'ASME\s+SA\d+[A-Z]*(?!\s+[A-Z0-9])',
|
||||
]
|
||||
|
||||
for pattern in asme_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
# 3. KS 규격 패턴들
|
||||
ks_patterns = [
|
||||
r'KS\s+D\d+[A-Z]*\s+[A-Z0-9]+(?:\s+[A-Z]+)*',
|
||||
r'KS\s+D\d+[A-Z]*(?!\s+[A-Z0-9])',
|
||||
]
|
||||
|
||||
for pattern in ks_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
# 4. JIS 규격 패턴들
|
||||
jis_patterns = [
|
||||
r'JIS\s+[A-Z]\d+[A-Z]*\s+[A-Z0-9]+(?:\s+[A-Z]+)*',
|
||||
r'JIS\s+[A-Z]\d+[A-Z]*(?!\s+[A-Z0-9])',
|
||||
]
|
||||
|
||||
for pattern in jis_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
# 5. 특수 재질 패턴들
|
||||
special_patterns = [
|
||||
# Inconel, Hastelloy 등
|
||||
r'INCONEL\s+\d+[A-Z]*',
|
||||
r'HASTELLOY\s+[A-Z]\d*[A-Z]*',
|
||||
r'MONEL\s+\d+[A-Z]*',
|
||||
# Titanium
|
||||
r'TITANIUM\s+GRADE\s+\d+[A-Z]*',
|
||||
r'TI\s+GR\s*\d+[A-Z]*',
|
||||
# 듀플렉스 스테인리스
|
||||
r'DUPLEX\s+\d+[A-Z]*',
|
||||
r'SUPER\s+DUPLEX\s+\d+[A-Z]*',
|
||||
]
|
||||
|
||||
for pattern in special_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
# 6. 일반 스테인리스 패턴들 (숫자만)
|
||||
stainless_patterns = [
|
||||
r'\b(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b',
|
||||
r'\bSS\s*(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b',
|
||||
r'\bSUS\s*(304L?|316L?|321|347|310|317L?|904L?|254SMO)\b',
|
||||
]
|
||||
|
||||
for pattern in stainless_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
grade = match.group(1) if match.groups() else match.group(0)
|
||||
if grade.startswith(('SS', 'SUS')):
|
||||
return grade
|
||||
else:
|
||||
return f"SS{grade}"
|
||||
|
||||
# 7. 탄소강 패턴들
|
||||
carbon_patterns = [
|
||||
r'\bSM\d+[A-Z]*\b', # SM400, SM490 등
|
||||
r'\bSS\d+[A-Z]*\b', # SS400, SS490 등 (스테인리스와 구분)
|
||||
r'\bS\d+C\b', # S45C, S50C 등
|
||||
]
|
||||
|
||||
for pattern in carbon_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
# 8. 기존 material_grade가 있으면 그대로 반환
|
||||
# (분류기에서 이미 처리된 경우)
|
||||
return ""
|
||||
|
||||
def update_full_material_grades(db, batch_size: int = 1000) -> Dict:
|
||||
"""
|
||||
기존 자재들의 full_material_grade 업데이트
|
||||
|
||||
Args:
|
||||
db: 데이터베이스 세션
|
||||
batch_size: 배치 처리 크기
|
||||
|
||||
Returns:
|
||||
업데이트 결과 통계
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
try:
|
||||
# 전체 자재 수 조회
|
||||
count_query = text("SELECT COUNT(*) FROM materials WHERE full_material_grade IS NULL OR full_material_grade = ''")
|
||||
total_count = db.execute(count_query).scalar()
|
||||
|
||||
print(f"📊 업데이트 대상 자재: {total_count}개")
|
||||
|
||||
updated_count = 0
|
||||
processed_count = 0
|
||||
|
||||
# 배치 단위로 처리
|
||||
offset = 0
|
||||
while offset < total_count:
|
||||
# 배치 조회
|
||||
select_query = text("""
|
||||
SELECT id, original_description, material_grade
|
||||
FROM materials
|
||||
WHERE full_material_grade IS NULL OR full_material_grade = ''
|
||||
ORDER BY id
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
|
||||
results = db.execute(select_query, {"limit": batch_size, "offset": offset}).fetchall()
|
||||
|
||||
if not results:
|
||||
break
|
||||
|
||||
# 배치 업데이트
|
||||
for material_id, original_description, current_grade in results:
|
||||
full_grade = extract_full_material_grade(original_description)
|
||||
|
||||
# 전체 재질명이 추출되지 않으면 기존 grade 사용
|
||||
if not full_grade and current_grade:
|
||||
full_grade = current_grade
|
||||
|
||||
if full_grade:
|
||||
update_query = text("""
|
||||
UPDATE materials
|
||||
SET full_material_grade = :full_grade
|
||||
WHERE id = :material_id
|
||||
""")
|
||||
db.execute(update_query, {
|
||||
"full_grade": full_grade,
|
||||
"material_id": material_id
|
||||
})
|
||||
updated_count += 1
|
||||
|
||||
processed_count += 1
|
||||
|
||||
# 배치 커밋
|
||||
db.commit()
|
||||
offset += batch_size
|
||||
|
||||
print(f"📈 진행률: {processed_count}/{total_count} ({processed_count/total_count*100:.1f}%)")
|
||||
|
||||
return {
|
||||
"total_processed": processed_count,
|
||||
"updated_count": updated_count,
|
||||
"success": True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"❌ 업데이트 실패: {str(e)}")
|
||||
return {
|
||||
"total_processed": 0,
|
||||
"updated_count": 0,
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
592
tkeg/api/app/services/material_service.py
Normal file
592
tkeg/api/app/services/material_service.py
Normal file
@@ -0,0 +1,592 @@
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Dict, Optional
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.integrated_classifier import classify_material_integrated, should_exclude_material
|
||||
from app.services.bolt_classifier import classify_bolt
|
||||
from app.services.flange_classifier import classify_flange
|
||||
from app.services.fitting_classifier import classify_fitting
|
||||
from app.services.gasket_classifier import classify_gasket
|
||||
from app.services.instrument_classifier import classify_instrument
|
||||
from app.services.valve_classifier import classify_valve
|
||||
from app.services.support_classifier import classify_support
|
||||
from app.services.plate_classifier import classify_plate
|
||||
from app.services.structural_classifier import classify_structural
|
||||
from app.services.pipe_classifier import classify_pipe_for_purchase, extract_end_preparation_info
|
||||
from app.services.material_grade_extractor import extract_full_material_grade
|
||||
|
||||
class MaterialService:
|
||||
"""자재 처리 및 저장을 담당하는 서비스"""
|
||||
|
||||
@staticmethod
|
||||
def process_and_save_materials(
|
||||
db: Session,
|
||||
file_id: int,
|
||||
materials_data: List[Dict],
|
||||
revision_comparison: Optional[Dict] = None,
|
||||
parent_file_id: Optional[int] = None,
|
||||
purchased_materials_map: Optional[Dict] = None
|
||||
) -> int:
|
||||
"""
|
||||
자재 목록을 분류하고 DB에 저장합니다.
|
||||
|
||||
Args:
|
||||
db: DB 세션
|
||||
file_id: 파일 ID
|
||||
materials_data: 파싱된 자재 데이터 목록
|
||||
revision_comparison: 리비전 비교 결과
|
||||
parent_file_id: 이전 리비전 파일 ID
|
||||
purchased_materials_map: 구매 확정된 자재 매핑 정보
|
||||
|
||||
Returns:
|
||||
저장된 자재 수
|
||||
"""
|
||||
materials_inserted = 0
|
||||
|
||||
# 변경/신규 자재 키 집합 (리비전 추적용)
|
||||
changed_materials_keys = set()
|
||||
new_materials_keys = set()
|
||||
|
||||
# 리비전 업로드인 경우 변경사항 분석
|
||||
if parent_file_id is not None:
|
||||
MaterialService._analyze_changes(
|
||||
db, parent_file_id, materials_data,
|
||||
changed_materials_keys, new_materials_keys
|
||||
)
|
||||
|
||||
# 변경 없는 자재 (확정된 자재) 먼저 처리
|
||||
if revision_comparison and revision_comparison.get("has_previous_confirmation", False):
|
||||
unchanged_materials = revision_comparison.get("unchanged_materials", [])
|
||||
for material_data in unchanged_materials:
|
||||
MaterialService._save_unchanged_material(db, file_id, material_data)
|
||||
materials_inserted += 1
|
||||
|
||||
# 분류가 필요한 자재 처리 (신규 또는 변경된 자재)
|
||||
# revision_comparison에서 필터링된 목록이 있으면 그것을 사용, 아니면 전체
|
||||
materials_to_classify = materials_data
|
||||
if revision_comparison and revision_comparison.get("materials_to_classify"):
|
||||
materials_to_classify = revision_comparison.get("materials_to_classify")
|
||||
|
||||
print(f"🔧 자재 분류 및 저장 시작: {len(materials_to_classify)}개")
|
||||
|
||||
for material_data in materials_to_classify:
|
||||
MaterialService._classify_and_save_single_material(
|
||||
db, file_id, material_data,
|
||||
changed_materials_keys, new_materials_keys,
|
||||
purchased_materials_map
|
||||
)
|
||||
materials_inserted += 1
|
||||
|
||||
return materials_inserted
|
||||
|
||||
@staticmethod
|
||||
def _analyze_changes(db: Session, parent_file_id: int, materials_data: List[Dict],
|
||||
changed_keys: set, new_keys: set):
|
||||
"""이전 리비전과 비교하여 변경/신규 자재를 식별합니다."""
|
||||
try:
|
||||
prev_materials_query = text("""
|
||||
SELECT original_description, size_spec, material_grade, main_nom,
|
||||
drawing_name, line_no, quantity
|
||||
FROM materials
|
||||
WHERE file_id = :parent_file_id
|
||||
""")
|
||||
prev_materials = db.execute(prev_materials_query, {"parent_file_id": parent_file_id}).fetchall()
|
||||
|
||||
prev_dict = {}
|
||||
for pm in prev_materials:
|
||||
key = MaterialService._generate_material_key(
|
||||
pm.drawing_name, pm.line_no, pm.original_description,
|
||||
pm.size_spec, pm.material_grade
|
||||
)
|
||||
prev_dict[key] = float(pm.quantity) if pm.quantity else 0
|
||||
|
||||
for mat in materials_data:
|
||||
new_key = MaterialService._generate_material_key(
|
||||
mat.get("dwg_name"), mat.get("line_num"), mat["original_description"],
|
||||
mat.get("size_spec"), mat.get("material_grade")
|
||||
)
|
||||
|
||||
if new_key in prev_dict:
|
||||
if abs(prev_dict[new_key] - float(mat.get("quantity", 0))) > 0.001:
|
||||
changed_keys.add(new_key)
|
||||
else:
|
||||
new_keys.add(new_key)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 변경사항 분석 실패: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _generate_material_key(dwg, line, desc, size, grade):
|
||||
"""자재 고유 키 생성"""
|
||||
parts = []
|
||||
if dwg: parts.append(str(dwg))
|
||||
elif line: parts.append(str(line))
|
||||
|
||||
parts.append(str(desc))
|
||||
parts.append(str(size or ''))
|
||||
parts.append(str(grade or ''))
|
||||
return "|".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _save_unchanged_material(db: Session, file_id: int, material_data: Dict):
|
||||
"""변경 없는(확정된) 자재 저장"""
|
||||
previous_item = material_data.get("previous_item", {})
|
||||
|
||||
query = text("""
|
||||
INSERT INTO materials (
|
||||
file_id, original_description, classified_category, confidence,
|
||||
quantity, unit, size_spec, material_grade, specification,
|
||||
reused_from_confirmation, created_at
|
||||
) VALUES (
|
||||
:file_id, :desc, :category, 1.0,
|
||||
:qty, :unit, :size, :grade, :spec,
|
||||
TRUE, :created_at
|
||||
)
|
||||
""")
|
||||
|
||||
db.execute(query, {
|
||||
"file_id": file_id,
|
||||
"desc": material_data["original_description"],
|
||||
"category": previous_item.get("category", "UNCLASSIFIED"),
|
||||
"qty": material_data["quantity"],
|
||||
"unit": material_data.get("unit", "EA"),
|
||||
"size": material_data.get("size_spec", ""),
|
||||
"grade": previous_item.get("material", ""),
|
||||
"spec": previous_item.get("specification", ""),
|
||||
"created_at": datetime.now()
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _classify_and_save_single_material(
|
||||
db: Session, file_id: int, material_data: Dict,
|
||||
changed_keys: set, new_keys: set, purchased_map: Optional[Dict]
|
||||
):
|
||||
"""단일 자재 분류 및 저장 (상세 정보 포함)"""
|
||||
description = material_data["original_description"]
|
||||
main_nom = material_data.get("main_nom", "")
|
||||
red_nom = material_data.get("red_nom", "")
|
||||
length_val = material_data.get("length")
|
||||
|
||||
# 1. 통합 분류
|
||||
integrated_result = classify_material_integrated(description, main_nom, red_nom, length_val)
|
||||
classification_result = integrated_result
|
||||
|
||||
# 2. 상세 분류
|
||||
if not should_exclude_material(description):
|
||||
category = integrated_result.get('category')
|
||||
if category == "PIPE":
|
||||
classification_result = classify_pipe_for_purchase("", description, main_nom, length_val)
|
||||
elif category == "FITTING":
|
||||
classification_result = classify_fitting("", description, main_nom, red_nom)
|
||||
elif category == "FLANGE":
|
||||
classification_result = classify_flange("", description, main_nom, red_nom)
|
||||
elif category == "VALVE":
|
||||
classification_result = classify_valve("", description, main_nom)
|
||||
elif category == "BOLT":
|
||||
classification_result = classify_bolt("", description, main_nom)
|
||||
elif category == "GASKET":
|
||||
classification_result = classify_gasket("", description, main_nom)
|
||||
elif category == "INSTRUMENT":
|
||||
classification_result = classify_instrument("", description, main_nom)
|
||||
elif category == "SUPPORT":
|
||||
classification_result = classify_support("", description, main_nom)
|
||||
elif category == "PLATE":
|
||||
classification_result = classify_plate("", description, main_nom)
|
||||
elif category == "STRUCTURAL":
|
||||
classification_result = classify_structural("", description, main_nom)
|
||||
|
||||
# 신뢰도 조정
|
||||
if integrated_result.get('confidence', 0) < 0.5:
|
||||
classification_result['overall_confidence'] = min(
|
||||
classification_result.get('overall_confidence', 1.0),
|
||||
integrated_result.get('confidence', 0.0) + 0.2
|
||||
)
|
||||
else:
|
||||
classification_result = {"category": "EXCLUDE", "overall_confidence": 0.95}
|
||||
|
||||
# 3. 구매 확정 정보 상속 확인
|
||||
is_purchase_confirmed = False
|
||||
purchase_confirmed_at = None
|
||||
purchase_confirmed_by = None
|
||||
|
||||
if purchased_map:
|
||||
key = f"{description.strip().upper()}|{material_data.get('size_spec', '')}"
|
||||
if key in purchased_map:
|
||||
info = purchased_map[key]
|
||||
is_purchase_confirmed = True
|
||||
purchase_confirmed_at = info.get("purchase_confirmed_at")
|
||||
purchase_confirmed_by = info.get("purchase_confirmed_by")
|
||||
|
||||
# 4. 자재 기본 정보 저장
|
||||
full_grade = extract_full_material_grade(description) or material_data.get("material_grade", "")
|
||||
|
||||
insert_query = text("""
|
||||
INSERT INTO materials (
|
||||
file_id, original_description, quantity, unit, size_spec,
|
||||
main_nom, red_nom, material_grade, full_material_grade, line_number, row_number,
|
||||
classified_category, classification_confidence, is_verified,
|
||||
drawing_name, line_no, created_at,
|
||||
purchase_confirmed, purchase_confirmed_at, purchase_confirmed_by,
|
||||
revision_status
|
||||
) VALUES (
|
||||
:file_id, :desc, :qty, :unit, :size,
|
||||
:main, :red, :grade, :full_grade, :line_num, :row_num,
|
||||
:category, :confidence, :verified,
|
||||
:dwg, :line, :created_at,
|
||||
:confirmed, :confirmed_at, :confirmed_by,
|
||||
:status
|
||||
) RETURNING id
|
||||
""")
|
||||
|
||||
# 리비전 상태 결정
|
||||
mat_key = MaterialService._generate_material_key(
|
||||
material_data.get("dwg_name"), material_data.get("line_num"), description,
|
||||
material_data.get("size_spec"), material_data.get("material_grade")
|
||||
)
|
||||
rev_status = 'changed' if mat_key in changed_keys else ('active' if mat_key in new_keys else None)
|
||||
|
||||
result = db.execute(insert_query, {
|
||||
"file_id": file_id,
|
||||
"desc": description,
|
||||
"qty": material_data["quantity"],
|
||||
"unit": material_data["unit"],
|
||||
"size": material_data.get("size_spec", ""),
|
||||
"main": main_nom,
|
||||
"red": red_nom,
|
||||
"grade": material_data.get("material_grade", ""),
|
||||
"full_grade": full_grade,
|
||||
"line_num": material_data.get("line_number"),
|
||||
"row_num": material_data.get("row_number"),
|
||||
"category": classification_result.get("category", "UNCLASSIFIED"),
|
||||
"confidence": classification_result.get("overall_confidence", 0.0),
|
||||
"verified": False,
|
||||
"dwg": material_data.get("dwg_name"),
|
||||
"line": material_data.get("line_num"),
|
||||
"created_at": datetime.now(),
|
||||
"confirmed": is_purchase_confirmed,
|
||||
"confirmed_at": purchase_confirmed_at,
|
||||
"confirmed_by": purchase_confirmed_by,
|
||||
"status": rev_status
|
||||
})
|
||||
|
||||
material_id = result.fetchone()[0]
|
||||
|
||||
# 5. 상세 정보 저장 (별도 메서드로 분리)
|
||||
MaterialService._save_material_details(
|
||||
db, material_id, file_id, classification_result, material_data
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _save_material_details(db: Session, material_id: int, file_id: int,
|
||||
result: Dict, data: Dict):
|
||||
"""카테고리별 상세 정보 저장"""
|
||||
category = result.get("category")
|
||||
|
||||
if category == "PIPE":
|
||||
MaterialService._save_pipe_details(db, material_id, file_id, result, data)
|
||||
elif category == "FITTING":
|
||||
MaterialService._save_fitting_details(db, material_id, file_id, result, data)
|
||||
elif category == "FLANGE":
|
||||
MaterialService._save_flange_details(db, material_id, file_id, result, data)
|
||||
elif category == "BOLT":
|
||||
MaterialService._save_bolt_details(db, material_id, file_id, result, data)
|
||||
elif category == "VALVE":
|
||||
MaterialService._save_valve_details(db, material_id, file_id, result, data)
|
||||
elif category == "GASKET":
|
||||
MaterialService._save_gasket_details(db, material_id, file_id, result, data)
|
||||
elif category == "SUPPORT":
|
||||
MaterialService._save_support_details(db, material_id, file_id, result, data)
|
||||
elif category == "PLATE":
|
||||
MaterialService._save_plate_details(db, material_id, file_id, result, data)
|
||||
elif category == "STRUCTURAL":
|
||||
MaterialService._save_structural_details(db, material_id, file_id, result, data)
|
||||
|
||||
@staticmethod
|
||||
def _save_plate_details(db: Session, mid: int, fid: int, res: Dict, data: Dict):
|
||||
"""판재 정보 업데이트 (현재는 materials 테이블에 요약 정보 저장)"""
|
||||
details = res.get("details", {})
|
||||
spec = f"{details.get('thickness')}T x {details.get('dimensions')}"
|
||||
db.execute(text("""
|
||||
UPDATE materials
|
||||
SET size_spec = :size, material_grade = :mat
|
||||
WHERE id = :id
|
||||
"""), {"size": spec, "mat": details.get("material"), "id": mid})
|
||||
|
||||
@staticmethod
|
||||
def _save_structural_details(db: Session, mid: int, fid: int, res: Dict, data: Dict):
|
||||
"""형강 정보 업데이트 (현재는 materials 테이블에 요약 정보 저장)"""
|
||||
details = res.get("details", {})
|
||||
spec = f"{details.get('type')} {details.get('dimension')}"
|
||||
db.execute(text("""
|
||||
UPDATE materials
|
||||
SET size_spec = :size
|
||||
WHERE id = :id
|
||||
"""), {"size": spec, "id": mid})
|
||||
|
||||
# --- 각 카테고리별 상세 저장 메서드들 (기존 로직 이관) ---
|
||||
|
||||
@staticmethod
|
||||
def inherit_purchase_requests(db: Session, current_file_id: int, parent_file_id: int):
|
||||
"""이전 리비전의 구매신청 정보를 상속합니다."""
|
||||
try:
|
||||
print(f"🔄 구매신청 정보 상속 처리 시작...")
|
||||
|
||||
# 1. 이전 리비전에서 그룹별 구매신청 수량 집계
|
||||
prev_purchase_summary = text("""
|
||||
SELECT
|
||||
m.original_description,
|
||||
m.size_spec,
|
||||
m.material_grade,
|
||||
m.drawing_name,
|
||||
COUNT(DISTINCT pri.material_id) as purchased_count,
|
||||
SUM(pri.quantity) as total_purchased_qty,
|
||||
MIN(pri.request_id) as request_id
|
||||
FROM materials m
|
||||
JOIN purchase_request_items pri ON m.id = pri.material_id
|
||||
WHERE m.file_id = :parent_file_id
|
||||
GROUP BY m.original_description, m.size_spec, m.material_grade, m.drawing_name
|
||||
""")
|
||||
|
||||
prev_purchases = db.execute(prev_purchase_summary, {"parent_file_id": parent_file_id}).fetchall()
|
||||
|
||||
# 2. 새 리비전에서 같은 그룹의 자재에 수량만큼 구매신청 상속
|
||||
for prev_purchase in prev_purchases:
|
||||
purchased_count = prev_purchase.purchased_count
|
||||
|
||||
# 새 리비전에서 같은 그룹의 자재 조회 (순서대로)
|
||||
new_group_materials = text("""
|
||||
SELECT id, quantity
|
||||
FROM materials
|
||||
WHERE file_id = :file_id
|
||||
AND original_description = :description
|
||||
AND COALESCE(size_spec, '') = :size_spec
|
||||
AND COALESCE(material_grade, '') = :material_grade
|
||||
AND COALESCE(drawing_name, '') = :drawing_name
|
||||
ORDER BY id
|
||||
LIMIT :limit
|
||||
""")
|
||||
|
||||
new_materials = db.execute(new_group_materials, {
|
||||
"file_id": current_file_id,
|
||||
"description": prev_purchase.original_description,
|
||||
"size_spec": prev_purchase.size_spec or '',
|
||||
"material_grade": prev_purchase.material_grade or '',
|
||||
"drawing_name": prev_purchase.drawing_name or '',
|
||||
"limit": purchased_count
|
||||
}).fetchall()
|
||||
|
||||
# 구매신청 수량만큼만 상속
|
||||
for new_mat in new_materials:
|
||||
inherit_query = text("""
|
||||
INSERT INTO purchase_request_items (
|
||||
request_id, material_id, quantity, unit, user_requirement
|
||||
) VALUES (
|
||||
:request_id, :material_id, :quantity, 'EA', ''
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
db.execute(inherit_query, {
|
||||
"request_id": prev_purchase.request_id,
|
||||
"material_id": new_mat.id,
|
||||
"quantity": new_mat.quantity
|
||||
})
|
||||
|
||||
inherited_count = len(new_materials)
|
||||
if inherited_count > 0:
|
||||
print(f" ✅ {prev_purchase.original_description[:30]}... (도면: {prev_purchase.drawing_name or 'N/A'}) → {inherited_count}/{purchased_count}개 상속")
|
||||
|
||||
# 커밋은 호출하는 쪽에서 일괄 처리하거나 여기서 처리
|
||||
# db.commit()
|
||||
print(f"✅ 구매신청 정보 상속 완료")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 구매신청 정보 상속 실패: {str(e)}")
|
||||
# 상속 실패는 전체 프로세스를 중단하지 않음
|
||||
|
||||
@staticmethod
|
||||
def _save_pipe_details(db, mid, fid, res, data):
|
||||
# PIPE 상세 저장 로직
|
||||
end_prep_info = extract_end_preparation_info(data["original_description"])
|
||||
|
||||
# 1. End Prep 정보 저장
|
||||
db.execute(text("""
|
||||
INSERT INTO pipe_end_preparations (
|
||||
material_id, file_id, end_preparation_type, end_preparation_code,
|
||||
machining_required, cutting_note, original_description, confidence
|
||||
) VALUES (
|
||||
:mid, :fid, :type, :code, :req, :note, :desc, :conf
|
||||
)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": end_prep_info["end_preparation_type"],
|
||||
"code": end_prep_info["end_preparation_code"],
|
||||
"req": end_prep_info["machining_required"],
|
||||
"note": end_prep_info["cutting_note"],
|
||||
"desc": end_prep_info["original_description"],
|
||||
"conf": end_prep_info["confidence"]
|
||||
})
|
||||
|
||||
# 2. Pipe Details 저장
|
||||
length_info = res.get("length_info", {})
|
||||
length_mm = length_info.get("length_mm") or data.get("length", 0.0)
|
||||
|
||||
mat_info = res.get("material", {})
|
||||
sch_info = res.get("schedule", {})
|
||||
|
||||
# 재질 정보 업데이트
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO pipe_details (
|
||||
material_id, file_id, outer_diameter, schedule,
|
||||
material_spec, manufacturing_method, length_mm
|
||||
) VALUES (:mid, :fid, :od, :sch, :spec, :method, :len)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"od": data.get("main_nom") or data.get("size_spec"),
|
||||
"sch": sch_info.get("schedule", "UNKNOWN") if isinstance(sch_info, dict) else str(sch_info),
|
||||
"spec": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"method": res.get("manufacturing", {}).get("method", "UNKNOWN") if isinstance(res.get("manufacturing"), dict) else "UNKNOWN",
|
||||
"len": length_mm or 0.0
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_fitting_details(db, mid, fid, res, data):
|
||||
fit_type = res.get("fitting_type", {})
|
||||
mat_info = res.get("material", {})
|
||||
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO fitting_details (
|
||||
material_id, file_id, fitting_type, fitting_subtype,
|
||||
connection_method, pressure_rating, material_grade,
|
||||
main_size, reduced_size
|
||||
) VALUES (:mid, :fid, :type, :subtype, :conn, :rating, :grade, :main, :red)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": fit_type.get("type", "UNKNOWN") if isinstance(fit_type, dict) else str(fit_type),
|
||||
"subtype": fit_type.get("subtype", "UNKNOWN") if isinstance(fit_type, dict) else "UNKNOWN",
|
||||
"conn": res.get("connection_method", {}).get("method", "UNKNOWN") if isinstance(res.get("connection_method"), dict) else "UNKNOWN",
|
||||
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
|
||||
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"main": data.get("main_nom") or data.get("size_spec"),
|
||||
"red": data.get("red_nom", "")
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_flange_details(db, mid, fid, res, data):
|
||||
flg_type = res.get("flange_type", {})
|
||||
mat_info = res.get("material", {})
|
||||
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO flange_details (
|
||||
material_id, file_id, flange_type, pressure_rating,
|
||||
facing_type, material_grade, size_inches
|
||||
) VALUES (:mid, :fid, :type, :rating, :face, :grade, :size)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": flg_type.get("type", "UNKNOWN") if isinstance(flg_type, dict) else str(flg_type),
|
||||
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
|
||||
"face": res.get("face_finish", {}).get("finish", "UNKNOWN") if isinstance(res.get("face_finish"), dict) else "UNKNOWN",
|
||||
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"size": data.get("main_nom") or data.get("size_spec")
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_bolt_details(db, mid, fid, res, data):
|
||||
fast_type = res.get("fastener_type", {})
|
||||
mat_info = res.get("material", {})
|
||||
dim_info = res.get("dimensions", {})
|
||||
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
# 볼트 타입 결정 (특수 용도 고려)
|
||||
bolt_type = fast_type.get("type", "UNKNOWN") if isinstance(fast_type, dict) else str(fast_type)
|
||||
special_apps = res.get("special_applications", {}).get("detected_applications", [])
|
||||
if "LT" in special_apps: bolt_type = "LT_BOLT"
|
||||
elif "PSV" in special_apps: bolt_type = "PSV_BOLT"
|
||||
|
||||
# 코팅 타입
|
||||
desc_upper = data["original_description"].upper()
|
||||
coating = "UNKNOWN"
|
||||
if "GALV" in desc_upper: coating = "GALVANIZED"
|
||||
elif "ZINC" in desc_upper: coating = "ZINC_PLATED"
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO bolt_details (
|
||||
material_id, file_id, bolt_type, thread_type,
|
||||
diameter, length, material_grade, coating_type
|
||||
) VALUES (:mid, :fid, :type, :thread, :dia, :len, :grade, :coating)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": bolt_type,
|
||||
"thread": res.get("thread_specification", {}).get("standard", "UNKNOWN") if isinstance(res.get("thread_specification"), dict) else "UNKNOWN",
|
||||
"dia": dim_info.get("nominal_size", data.get("main_nom", "")),
|
||||
"len": dim_info.get("length", ""),
|
||||
"grade": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"coating": coating
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_valve_details(db, mid, fid, res, data):
|
||||
val_type = res.get("valve_type", {})
|
||||
mat_info = res.get("material", {})
|
||||
|
||||
if mat_info.get("grade"):
|
||||
db.execute(text("UPDATE materials SET material_grade = :g WHERE id = :id"),
|
||||
{"g": mat_info.get("grade"), "id": mid})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO valve_details (
|
||||
material_id, file_id, valve_type, connection_method,
|
||||
pressure_rating, body_material, size_inches
|
||||
) VALUES (:mid, :fid, :type, :conn, :rating, :body, :size)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": val_type.get("type", "UNKNOWN") if isinstance(val_type, dict) else str(val_type),
|
||||
"conn": res.get("connection_method", {}).get("method", "UNKNOWN") if isinstance(res.get("connection_method"), dict) else "UNKNOWN",
|
||||
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
|
||||
"body": mat_info.get("grade", "") if isinstance(mat_info, dict) else "",
|
||||
"size": data.get("main_nom") or data.get("size_spec")
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_gasket_details(db, mid, fid, res, data):
|
||||
gask_type = res.get("gasket_type", {})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO gasket_details (
|
||||
material_id, file_id, gasket_type, pressure_rating, size_inches
|
||||
) VALUES (:mid, :fid, :type, :rating, :size)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": gask_type.get("type", "UNKNOWN") if isinstance(gask_type, dict) else str(gask_type),
|
||||
"rating": res.get("pressure_rating", {}).get("rating", "UNKNOWN") if isinstance(res.get("pressure_rating"), dict) else "UNKNOWN",
|
||||
"size": data.get("main_nom") or data.get("size_spec")
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_support_details(db, mid, fid, res, data):
|
||||
db.execute(text("""
|
||||
INSERT INTO support_details (
|
||||
material_id, file_id, support_type, pipe_size
|
||||
) VALUES (:mid, :fid, :type, :size)
|
||||
"""), {
|
||||
"mid": mid, "fid": fid,
|
||||
"type": res.get("support_type", "UNKNOWN"),
|
||||
"size": res.get("size_info", {}).get("pipe_size", "")
|
||||
})
|
||||
590
tkeg/api/app/services/materials_schema.py
Normal file
590
tkeg/api/app/services/materials_schema.py
Normal file
@@ -0,0 +1,590 @@
|
||||
"""
|
||||
재질 분류를 위한 공통 스키마
|
||||
모든 제품군(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]*)",
|
||||
r"ASTM\s+A403\s+(WP\d{3}[LH]*)",
|
||||
r"A403\s+(WP\d{3}[LH]*)",
|
||||
r"(WP\d{3}[LH]*)\s+A403",
|
||||
r"(WP\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"
|
||||
}
|
||||
},
|
||||
"A420": {
|
||||
"low_temp_carbon": {
|
||||
"patterns": [
|
||||
r"ASTM\s+A420\s+(?:GR\s*)?WPL\s*(\d+)",
|
||||
r"A420\s+(?:GR\s*)?WPL\s*(\d+)",
|
||||
r"ASME\s+SA420\s+(?:GR\s*)?WPL\s*(\d+)",
|
||||
r"ASTM\s+A420\s+(WPL\d+)",
|
||||
r"A420\s+(WPL\d+)",
|
||||
r"(WPL\d+)\s+A420",
|
||||
r"(WPL\d+)"
|
||||
],
|
||||
"grades": {
|
||||
"WPL1": {
|
||||
"composition": "탄소강",
|
||||
"temp_min": "-29°C",
|
||||
"applications": "저온용 피팅"
|
||||
},
|
||||
"WPL3": {
|
||||
"composition": "3.5Ni",
|
||||
"temp_min": "-46°C",
|
||||
"applications": "저온용 피팅"
|
||||
},
|
||||
"WPL6": {
|
||||
"composition": "탄소강",
|
||||
"temp_min": "-46°C",
|
||||
"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]*)",
|
||||
r"ASTM\s+A312\s+(TP\d{3}[LH]*)",
|
||||
r"A312\s+(TP\d{3}[LH]*)",
|
||||
r"(TP\d{3}[LH]*)\s+A312",
|
||||
r"(TP\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"
|
||||
},
|
||||
"A333": {
|
||||
"patterns": [
|
||||
r"ASTM\s+A333\s+(?:GR\s*)?(\d+)",
|
||||
r"A333\s+(?:GR\s*)?(\d+)",
|
||||
r"ASME\s+SA333\s+(?:GR\s*)?(\d+)"
|
||||
],
|
||||
"grades": {
|
||||
"1": {
|
||||
"composition": "탄소강",
|
||||
"temp_min": "-29°C",
|
||||
"applications": "저온용 배관"
|
||||
},
|
||||
"3": {
|
||||
"composition": "3.5Ni",
|
||||
"temp_min": "-46°C",
|
||||
"applications": "저온용 배관"
|
||||
},
|
||||
"6": {
|
||||
"composition": "탄소강",
|
||||
"temp_min": "-46°C",
|
||||
"applications": "저온용 배관"
|
||||
}
|
||||
},
|
||||
"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", "구상흑연주철", "덕타일"
|
||||
]
|
||||
}
|
||||
573
tkeg/api/app/services/pipe_classifier.py
Normal file
573
tkeg/api/app/services/pipe_classifier.py
Normal file
@@ -0,0 +1,573 @@
|
||||
"""
|
||||
PIPE 분류 전용 모듈
|
||||
재질 분류 + 파이프 특화 분류
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from .material_classifier import classify_material, get_manufacturing_method_from_material
|
||||
|
||||
# ========== PIPE USER 요구사항 키워드 ==========
|
||||
PIPE_USER_REQUIREMENTS = {
|
||||
"IMPACT_TEST": {
|
||||
"keywords": ["IMPACT", "충격시험", "CHARPY", "CVN", "IMPACT TEST", "충격", "NOTCH"],
|
||||
"description": "충격시험 요구",
|
||||
"confidence": 0.95
|
||||
},
|
||||
"ASME_CODE": {
|
||||
"keywords": ["ASME", "ASME CODE", "CODE", "B31.1", "B31.3", "B31.4", "B31.8", "VIII"],
|
||||
"description": "ASME 코드 준수",
|
||||
"confidence": 0.95
|
||||
},
|
||||
"STRESS_RELIEF": {
|
||||
"keywords": ["STRESS RELIEF", "SR", "응력제거", "열처리", "HEAT TREATMENT"],
|
||||
"description": "응력제거 열처리",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"RADIOGRAPHIC_TEST": {
|
||||
"keywords": ["RT", "RADIOGRAPHIC", "방사선시험", "X-RAY", "엑스레이"],
|
||||
"description": "방사선 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"ULTRASONIC_TEST": {
|
||||
"keywords": ["UT", "ULTRASONIC", "초음파시험", "초음파"],
|
||||
"description": "초음파 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"MAGNETIC_PARTICLE": {
|
||||
"keywords": ["MT", "MAGNETIC PARTICLE", "자분탐상", "자분"],
|
||||
"description": "자분탐상 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"LIQUID_PENETRANT": {
|
||||
"keywords": ["PT", "LIQUID PENETRANT", "침투탐상", "침투"],
|
||||
"description": "침투탐상 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"HYDROSTATIC_TEST": {
|
||||
"keywords": ["HYDROSTATIC", "수압시험", "PRESSURE TEST", "압력시험"],
|
||||
"description": "수압 시험",
|
||||
"confidence": 0.90
|
||||
},
|
||||
"LOW_TEMPERATURE": {
|
||||
"keywords": ["LOW TEMP", "저온", "LTCS", "LOW TEMPERATURE", "CRYOGENIC"],
|
||||
"description": "저온용",
|
||||
"confidence": 0.85
|
||||
},
|
||||
"HIGH_TEMPERATURE": {
|
||||
"keywords": ["HIGH TEMP", "고온", "HTCS", "HIGH TEMPERATURE"],
|
||||
"description": "고온용",
|
||||
"confidence": 0.85
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 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": ["BBE", "BOE", "BOTH END", "BOTH BEVELED", "양쪽개선"],
|
||||
"cutting_note": "양쪽 개선",
|
||||
"machining_required": True,
|
||||
"confidence": 0.95
|
||||
},
|
||||
"ONE_END_BEVELED": {
|
||||
"codes": ["BE", "BEV", "PBE", "PIPE BEVELED END", "POE"],
|
||||
"cutting_note": "한쪽 개선",
|
||||
"machining_required": True,
|
||||
"confidence": 0.95
|
||||
},
|
||||
"NO_BEVEL": {
|
||||
"codes": ["PE", "PLAIN END", "PPE", "평단", "무개선"],
|
||||
"cutting_note": "무 개선",
|
||||
"machining_required": False,
|
||||
"confidence": 0.95
|
||||
},
|
||||
"THREADED": {
|
||||
"codes": ["TOE", "THE", "THREADED", "나사", "스레드"],
|
||||
"cutting_note": "나사 가공",
|
||||
"machining_required": True,
|
||||
"confidence": 0.90
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 구매용 파이프 분류 (끝단 가공 제외) ==========
|
||||
def get_purchase_pipe_description(description: str) -> str:
|
||||
"""구매용 파이프 설명 - 끝단 가공 정보 제거"""
|
||||
|
||||
# 모든 끝단 가공 코드들을 수집
|
||||
end_prep_codes = []
|
||||
for prep_data in PIPE_END_PREP.values():
|
||||
end_prep_codes.extend(prep_data["codes"])
|
||||
|
||||
# 설명에서 끝단 가공 코드 제거
|
||||
clean_description = description.upper()
|
||||
|
||||
# 끝단 가공 코드들을 길이 순으로 정렬 (긴 것부터 처리)
|
||||
end_prep_codes.sort(key=len, reverse=True)
|
||||
|
||||
for code in end_prep_codes:
|
||||
# 단어 경계를 고려하여 제거 (부분 매칭 방지)
|
||||
pattern = r'\b' + re.escape(code) + r'\b'
|
||||
clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE)
|
||||
|
||||
# 끝단 가공 관련 패턴들 추가 제거
|
||||
# BOE-POE, POE-TOE 같은 조합 패턴들
|
||||
end_prep_patterns = [
|
||||
r'\b[A-Z]{2,3}E-[A-Z]{2,3}E\b', # BOE-POE, POE-TOE 등
|
||||
r'\b[A-Z]{2,3}E-[A-Z]{2,3}\b', # BOE-TO, POE-TO 등
|
||||
r'\b[A-Z]{2,3}-[A-Z]{2,3}E\b', # BO-POE, PO-TOE 등
|
||||
r'\b[A-Z]{2,3}-[A-Z]{2,3}\b', # BO-PO, PO-TO 등
|
||||
]
|
||||
|
||||
for pattern in end_prep_patterns:
|
||||
clean_description = re.sub(pattern, '', clean_description, flags=re.IGNORECASE)
|
||||
|
||||
# 남은 하이픈과 공백 정리
|
||||
clean_description = re.sub(r'\s*-\s*', ' ', clean_description) # 하이픈 제거
|
||||
clean_description = re.sub(r'\s+', ' ', clean_description).strip() # 연속 공백 정리
|
||||
|
||||
return clean_description
|
||||
|
||||
def extract_end_preparation_info(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 {
|
||||
"end_preparation_type": prep_type,
|
||||
"end_preparation_code": code,
|
||||
"machining_required": prep_data["machining_required"],
|
||||
"cutting_note": prep_data["cutting_note"],
|
||||
"confidence": prep_data["confidence"],
|
||||
"matched_pattern": code,
|
||||
"original_description": description,
|
||||
"clean_description": get_purchase_pipe_description(description)
|
||||
}
|
||||
|
||||
# 기본값: PBE (양쪽 무개선)
|
||||
return {
|
||||
"end_preparation_type": "NO_BEVEL", # PBE로 매핑될 예정
|
||||
"end_preparation_code": "PBE",
|
||||
"machining_required": False,
|
||||
"cutting_note": "양쪽 무개선 (기본값)",
|
||||
"confidence": 0.5,
|
||||
"matched_pattern": "DEFAULT",
|
||||
"original_description": description,
|
||||
"clean_description": get_purchase_pipe_description(description)
|
||||
}
|
||||
|
||||
# ========== 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 extract_pipe_user_requirements(description: str) -> List[str]:
|
||||
"""
|
||||
파이프 설명에서 User 요구사항 추출
|
||||
|
||||
Args:
|
||||
description: 파이프 설명
|
||||
|
||||
Returns:
|
||||
발견된 요구사항 리스트
|
||||
"""
|
||||
desc_upper = description.upper()
|
||||
found_requirements = []
|
||||
|
||||
for req_type, req_data in PIPE_USER_REQUIREMENTS.items():
|
||||
for keyword in req_data["keywords"]:
|
||||
if keyword in desc_upper:
|
||||
found_requirements.append(req_data["description"])
|
||||
break # 같은 타입에서 중복 방지
|
||||
|
||||
return found_requirements
|
||||
|
||||
def classify_pipe_for_purchase(dat_file: str, description: str, main_nom: str,
|
||||
length: Optional[float] = None) -> Dict:
|
||||
"""구매용 파이프 분류 - 끝단 가공 정보 제외"""
|
||||
|
||||
# 끝단 가공 정보 제거한 설명으로 분류
|
||||
clean_description = get_purchase_pipe_description(description)
|
||||
|
||||
# 기본 파이프 분류 수행
|
||||
result = classify_pipe(dat_file, clean_description, main_nom, length)
|
||||
|
||||
# 구매용임을 표시
|
||||
result["purchase_classification"] = True
|
||||
result["original_description"] = description
|
||||
result["clean_description"] = clean_description
|
||||
|
||||
return result
|
||||
|
||||
def classify_pipe(dat_file: str, description: str, main_nom: str,
|
||||
length: Optional[float] = None) -> Dict:
|
||||
"""
|
||||
완전한 PIPE 분류
|
||||
|
||||
Args:
|
||||
dat_file: DAT_FILE 필드
|
||||
description: DESCRIPTION 필드
|
||||
main_nom: MAIN_NOM 필드 (사이즈)
|
||||
length: LENGTH 필드 (절단 치수)
|
||||
|
||||
Returns:
|
||||
완전한 파이프 분류 결과
|
||||
"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. 명칭 우선 확인 (다른 자재 타입 키워드가 있으면 파이프가 아님)
|
||||
other_material_keywords = [
|
||||
'FLG', 'FLANGE', '플랜지', # 플랜지
|
||||
'ELBOW', 'ELL', 'TEE', 'REDUCER', 'CAP', 'COUPLING', '엘보', '티', '리듀서', # 피팅
|
||||
'VALVE', 'BALL', 'GATE', 'GLOBE', 'CHECK', '밸브', # 밸브
|
||||
'BOLT', 'STUD', 'NUT', 'SCREW', '볼트', '너트', # 볼트
|
||||
'GASKET', 'GASK', '가스켓', # 가스켓
|
||||
'GAUGE', 'SENSOR', 'TRANSMITTER', 'INSTRUMENT', '계기' # 계기
|
||||
]
|
||||
|
||||
for keyword in other_material_keywords:
|
||||
if keyword in desc_upper:
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"overall_confidence": 0.0,
|
||||
"reason": f"다른 자재 키워드 발견: {keyword}"
|
||||
}
|
||||
|
||||
# 2. 파이프 키워드 확인
|
||||
pipe_keywords = ['PIPE', 'TUBE', '파이프', '배관', 'SMLS', 'SEAMLESS']
|
||||
has_pipe_keyword = any(keyword in desc_upper for keyword in pipe_keywords)
|
||||
|
||||
# 파이프 재질 확인 (A106, A333, A312, A53)
|
||||
pipe_materials = ['A106', 'A333', 'A312', 'A53']
|
||||
has_pipe_material = any(material in desc_upper for material in pipe_materials)
|
||||
|
||||
# 파이프 키워드도 없고 파이프 재질도 없으면 UNKNOWN
|
||||
if not has_pipe_keyword and not has_pipe_material:
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"overall_confidence": 0.0,
|
||||
"reason": "파이프 키워드 및 재질 없음"
|
||||
}
|
||||
|
||||
# 3. 재질 분류 (공통 모듈 사용)
|
||||
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, material_result)
|
||||
|
||||
# 5. 길이(절단 치수) 처리
|
||||
length_info = extract_pipe_length_info(length, description)
|
||||
|
||||
# 6. User 요구사항 추출
|
||||
user_requirements = extract_pipe_user_requirements(description)
|
||||
|
||||
# 7. 최종 결과 조합
|
||||
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)
|
||||
},
|
||||
|
||||
"length_info": length_info,
|
||||
|
||||
"size_info": {
|
||||
"nominal_size": main_nom,
|
||||
"length_mm": length_info.get('length_mm')
|
||||
},
|
||||
|
||||
# User 요구사항
|
||||
"user_requirements": user_requirements,
|
||||
|
||||
# 전체 신뢰도
|
||||
"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, material_result: Dict = None) -> Dict:
|
||||
"""파이프 스케줄 분류 - 재질별 표현 개선"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 재질 정보 확인
|
||||
material_type = "CARBON" # 기본값
|
||||
if material_result:
|
||||
material_grade = material_result.get('grade', '').upper()
|
||||
material_standard = material_result.get('standard', '').upper()
|
||||
|
||||
# 스테인리스 스틸 판단
|
||||
if any(sus_indicator in material_grade or sus_indicator in material_standard
|
||||
for sus_indicator in ['SUS', 'SS', 'A312', 'A358', 'A376', '304', '316', '321', '347']):
|
||||
material_type = "STAINLESS"
|
||||
|
||||
# 1. 스케줄 패턴 확인
|
||||
for pattern in PIPE_SCHEDULE["patterns"]:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
schedule_num = match.group(1)
|
||||
|
||||
# 재질별 스케줄 표현
|
||||
if material_type == "STAINLESS":
|
||||
# 스테인리스 스틸: SCH 40S, SCH 80S
|
||||
if schedule_num in ["10", "20", "40", "80", "120", "160"]:
|
||||
schedule_display = f"SCH {schedule_num}S"
|
||||
else:
|
||||
schedule_display = f"SCH {schedule_num}"
|
||||
else:
|
||||
# 카본 스틸: SCH 40, SCH 80
|
||||
schedule_display = f"SCH {schedule_num}"
|
||||
|
||||
return {
|
||||
"schedule": schedule_display,
|
||||
"schedule_number": schedule_num,
|
||||
"material_type": material_type,
|
||||
"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",
|
||||
"material_type": material_type,
|
||||
"confidence": 0.9,
|
||||
"matched_pattern": pattern
|
||||
}
|
||||
|
||||
# 3. 기본값
|
||||
return {
|
||||
"schedule": "UNKNOWN",
|
||||
"material_type": material_type,
|
||||
"confidence": 0.0
|
||||
}
|
||||
|
||||
def extract_pipe_length_info(length: Optional[float], description: str) -> Dict:
|
||||
"""파이프 길이(절단 치수) 정보 추출"""
|
||||
|
||||
length_info = {
|
||||
"length_mm": None,
|
||||
"source": None,
|
||||
"confidence": 0.0,
|
||||
"note": ""
|
||||
}
|
||||
|
||||
# 1. LENGTH 필드에서 추출 (우선)
|
||||
if length and length > 0:
|
||||
length_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:
|
||||
length_info.update({
|
||||
"length_mm": desc_length,
|
||||
"source": "DESCRIPTION_PARSED",
|
||||
"confidence": 0.8,
|
||||
"note": f"설명란에서 추출: {desc_length}mm"
|
||||
})
|
||||
else:
|
||||
length_info.update({
|
||||
"source": "NO_LENGTH_INFO",
|
||||
"confidence": 0.0,
|
||||
"note": "길이 정보 없음 - 도면 확인 필요"
|
||||
})
|
||||
|
||||
return length_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['length_info']['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
|
||||
50
tkeg/api/app/services/plate_classifier.py
Normal file
50
tkeg/api/app/services/plate_classifier.py
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
import re
|
||||
from typing import Dict, Optional
|
||||
|
||||
def classify_plate(tag: str, description: str, main_nom: str = "") -> Dict:
|
||||
"""
|
||||
판재(PLATE) 분류기
|
||||
규격 예: PLATE 10T x 1219 x 2438
|
||||
"""
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. 두께(Thickness) 추출
|
||||
# 패턴: 10T, 10.5T, THK 10, THK. 10, t=10
|
||||
thickness = None
|
||||
t_match = re.search(r'(\d+(?:\.\d+)?)\s*T\b', desc_upper)
|
||||
if not t_match:
|
||||
t_match = re.search(r'(?:THK\.?|t=)\s*(\d+(?:\.\d+)?)', desc_upper, re.IGNORECASE)
|
||||
|
||||
if t_match:
|
||||
thickness = t_match.group(1)
|
||||
|
||||
# 2. 규격(Dimensions) 추출
|
||||
# 패턴: 1219x2438, 4'x8', 1000*2000
|
||||
dimensions = ""
|
||||
dim_match = re.search(r'(\d+(?:\.\d+)?)\s*[X\*]\s*(\d+(?:\.\d+)?)(?:\s*[X\*]\s*(\d+(?:\.\d+)?))?', desc_upper)
|
||||
if dim_match:
|
||||
groups = [g for g in dim_match.groups() if g]
|
||||
dimensions = " x ".join(groups)
|
||||
|
||||
# 3. 재질 추출
|
||||
material = "UNKNOWN"
|
||||
# 압력용기용 및 일반 구조용 강판 재질 추가
|
||||
plate_materials = [
|
||||
"SUS304", "SUS316", "SUS321", "SS400", "A36", "SM490",
|
||||
"SA516", "A516", "SA283", "A283", "SA537", "A537", "POS-M"
|
||||
]
|
||||
for mat in plate_materials:
|
||||
if mat in desc_upper:
|
||||
material = mat
|
||||
break
|
||||
|
||||
return {
|
||||
"category": "PLATE",
|
||||
"overall_confidence": 0.9,
|
||||
"details": {
|
||||
"thickness": thickness,
|
||||
"dimensions": dimensions,
|
||||
"material": material
|
||||
}
|
||||
}
|
||||
754
tkeg/api/app/services/purchase_calculator.py
Normal file
754
tkeg/api/app/services/purchase_calculator.py
Normal file
@@ -0,0 +1,754 @@
|
||||
"""
|
||||
구매 수량 계산 서비스
|
||||
- 자재별 여유율 적용
|
||||
- PIPE: 절단 손실 + 6M 단위 계산
|
||||
- 기타: 최소 주문 수량 적용
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Dict, List, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
# 자재별 기본 여유율 (올바른 규칙으로 수정)
|
||||
SAFETY_FACTORS = {
|
||||
'PIPE': 1.00, # 0% 추가 (절단 손실은 별도 계산)
|
||||
'FITTING': 1.00, # 0% 추가 (BOM 수량 그대로)
|
||||
'VALVE': 1.00, # 0% 추가 (BOM 수량 그대로)
|
||||
'FLANGE': 1.00, # 0% 추가 (BOM 수량 그대로)
|
||||
'BOLT': 1.05, # 5% 추가 (분실율)
|
||||
'GASKET': 1.00, # 0% 추가 (5의 배수 올림으로 처리)
|
||||
'INSTRUMENT': 1.00, # 0% 추가 (BOM 수량 그대로)
|
||||
'DEFAULT': 1.00 # 기본 0% 추가
|
||||
}
|
||||
|
||||
# 최소 주문 수량 (자재별) - 올바른 규칙으로 수정
|
||||
MINIMUM_ORDER_QTY = {
|
||||
'PIPE': 6000, # 6M 단위
|
||||
'FITTING': 1, # 개별 주문 가능
|
||||
'VALVE': 1, # 개별 주문 가능
|
||||
'FLANGE': 1, # 개별 주문 가능
|
||||
'BOLT': 4, # 4의 배수 단위
|
||||
'GASKET': 5, # 5의 배수 단위
|
||||
'INSTRUMENT': 1, # 개별 주문 가능
|
||||
'DEFAULT': 1
|
||||
}
|
||||
|
||||
def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
PIPE 구매 수량 계산
|
||||
- 각 절단마다 2mm 손실 (올바른 규칙)
|
||||
- 6,000mm (6M) 단위로 올림
|
||||
"""
|
||||
total_bom_length = 0
|
||||
cutting_count = 0
|
||||
pipe_details = []
|
||||
|
||||
for material in materials:
|
||||
# 길이 정보 추출 (Decimal 타입 처리)
|
||||
length_mm = float(material.get('length_mm', 0) or 0)
|
||||
quantity = float(material.get('quantity', 1) or 1)
|
||||
|
||||
if length_mm > 0:
|
||||
total_length = length_mm * quantity # 총 길이 = 단위길이 × 수량
|
||||
total_bom_length += total_length
|
||||
cutting_count += quantity # 절단 횟수 = 수량
|
||||
pipe_details.append({
|
||||
'description': material.get('original_description', ''),
|
||||
'length_mm': length_mm,
|
||||
'quantity': quantity,
|
||||
'total_length': total_length
|
||||
})
|
||||
|
||||
# 절단 손실 계산 (각 절단마다 2mm - 올바른 규칙)
|
||||
cutting_loss = cutting_count * 2
|
||||
|
||||
# 총 필요 길이 = BOM 길이 + 절단 손실
|
||||
required_length = total_bom_length + cutting_loss
|
||||
|
||||
# 6M 단위로 올림 계산
|
||||
standard_length = 6000 # 6M = 6,000mm
|
||||
pipes_needed = math.ceil(required_length / standard_length) if required_length > 0 else 0
|
||||
total_purchase_length = pipes_needed * standard_length
|
||||
waste_length = total_purchase_length - required_length if pipes_needed > 0 else 0
|
||||
|
||||
return {
|
||||
'bom_quantity': total_bom_length,
|
||||
'cutting_count': cutting_count,
|
||||
'cutting_loss': cutting_loss,
|
||||
'required_length': required_length,
|
||||
'pipes_count': pipes_needed,
|
||||
'calculated_qty': total_purchase_length,
|
||||
'waste_length': waste_length,
|
||||
'utilization_rate': (required_length / total_purchase_length * 100) if total_purchase_length > 0 else 0,
|
||||
'unit': 'mm',
|
||||
'pipe_details': pipe_details
|
||||
}
|
||||
|
||||
def calculate_standard_purchase_quantity(category: str, bom_quantity: float,
|
||||
safety_factor: float = None) -> Dict:
|
||||
"""
|
||||
일반 자재 구매 수량 계산
|
||||
- 여유율 적용
|
||||
- 최소 주문 수량 적용
|
||||
"""
|
||||
# 여유율 결정
|
||||
if safety_factor is None:
|
||||
safety_factor = SAFETY_FACTORS.get(category, SAFETY_FACTORS['DEFAULT'])
|
||||
|
||||
# 1단계: 여유율 적용 (Decimal 타입 처리)
|
||||
bom_quantity = float(bom_quantity) if bom_quantity else 0.0
|
||||
safety_qty = bom_quantity * safety_factor
|
||||
|
||||
# 2단계: 최소 주문 수량 확인
|
||||
min_order_qty = MINIMUM_ORDER_QTY.get(category, MINIMUM_ORDER_QTY['DEFAULT'])
|
||||
|
||||
# 3단계: 최소 주문 수량과 비교하여 큰 값 선택
|
||||
calculated_qty = max(safety_qty, min_order_qty)
|
||||
|
||||
# 4단계: 특별 처리 (올바른 규칙 적용)
|
||||
if category == 'BOLT':
|
||||
# BOLT: 5% 여유율 후 4의 배수로 올림
|
||||
calculated_qty = math.ceil(safety_qty / min_order_qty) * min_order_qty
|
||||
elif category == 'GASKET':
|
||||
# GASKET: 5의 배수로 올림 (여유율 없음)
|
||||
calculated_qty = math.ceil(bom_quantity / min_order_qty) * min_order_qty
|
||||
|
||||
return {
|
||||
'bom_quantity': bom_quantity,
|
||||
'safety_factor': safety_factor,
|
||||
'safety_qty': safety_qty,
|
||||
'min_order_qty': min_order_qty,
|
||||
'calculated_qty': calculated_qty,
|
||||
'waste_quantity': calculated_qty - bom_quantity,
|
||||
'utilization_rate': (bom_quantity / calculated_qty * 100) if calculated_qty > 0 else 0
|
||||
}
|
||||
|
||||
def generate_purchase_items_from_materials(db: Session, file_id: int,
|
||||
job_no: str, revision: str) -> List[Dict]:
|
||||
"""
|
||||
자재 데이터로부터 구매 품목 생성
|
||||
"""
|
||||
# 1. 파일의 모든 자재 조회 (상세 테이블 정보 포함)
|
||||
materials_query = text("""
|
||||
SELECT m.*,
|
||||
pd.length_mm, pd.outer_diameter, pd.schedule, pd.material_spec as pipe_material_spec,
|
||||
fd.fitting_type, fd.connection_method as fitting_connection, fd.main_size as fitting_main_size,
|
||||
fd.reduced_size as fitting_reduced_size, fd.material_grade as fitting_material_grade,
|
||||
vd.valve_type, vd.connection_method as valve_connection, vd.pressure_rating as valve_pressure,
|
||||
vd.size_inches as valve_size,
|
||||
fl.flange_type, fl.pressure_rating as flange_pressure, fl.size_inches as flange_size,
|
||||
gd.gasket_type, gd.gasket_subtype, gd.material_type as gasket_material, gd.filler_material,
|
||||
gd.size_inches as gasket_size, gd.pressure_rating as gasket_pressure, gd.thickness as gasket_thickness,
|
||||
bd.bolt_type, bd.material_standard, bd.diameter as bolt_diameter, bd.length as bolt_length,
|
||||
id.instrument_type, id.connection_size as instrument_size
|
||||
FROM materials m
|
||||
LEFT JOIN pipe_details pd ON m.id = pd.material_id
|
||||
LEFT JOIN fitting_details fd ON m.id = fd.material_id
|
||||
LEFT JOIN valve_details vd ON m.id = vd.material_id
|
||||
LEFT JOIN flange_details fl ON m.id = fl.material_id
|
||||
LEFT JOIN gasket_details gd ON m.id = gd.material_id
|
||||
LEFT JOIN bolt_details bd ON m.id = bd.material_id
|
||||
LEFT JOIN instrument_details id ON m.id = id.material_id
|
||||
WHERE m.file_id = :file_id
|
||||
ORDER BY m.classified_category, m.original_description
|
||||
""")
|
||||
|
||||
materials = db.execute(materials_query, {"file_id": file_id}).fetchall()
|
||||
|
||||
|
||||
|
||||
# 2. 카테고리별로 그룹핑
|
||||
grouped_materials = {}
|
||||
for material in materials:
|
||||
category = material.classified_category or 'OTHER'
|
||||
if category not in grouped_materials:
|
||||
grouped_materials[category] = []
|
||||
|
||||
# Row 객체를 딕셔너리로 안전하게 변환
|
||||
material_dict = {
|
||||
'id': material.id,
|
||||
'file_id': material.file_id,
|
||||
'original_description': material.original_description,
|
||||
'quantity': material.quantity,
|
||||
'unit': material.unit,
|
||||
'size_spec': material.size_spec,
|
||||
'material_grade': material.material_grade,
|
||||
'classified_category': material.classified_category,
|
||||
'line_number': material.line_number,
|
||||
# PIPE 상세 정보
|
||||
'length_mm': getattr(material, 'length_mm', None),
|
||||
'outer_diameter': getattr(material, 'outer_diameter', None),
|
||||
'schedule': getattr(material, 'schedule', None),
|
||||
'pipe_material_spec': getattr(material, 'pipe_material_spec', None),
|
||||
# FITTING 상세 정보
|
||||
'fitting_type': getattr(material, 'fitting_type', None),
|
||||
'fitting_connection': getattr(material, 'fitting_connection', None),
|
||||
'fitting_main_size': getattr(material, 'fitting_main_size', None),
|
||||
'fitting_reduced_size': getattr(material, 'fitting_reduced_size', None),
|
||||
'fitting_material_grade': getattr(material, 'fitting_material_grade', None),
|
||||
# VALVE 상세 정보
|
||||
'valve_type': getattr(material, 'valve_type', None),
|
||||
'valve_connection': getattr(material, 'valve_connection', None),
|
||||
'valve_pressure': getattr(material, 'valve_pressure', None),
|
||||
'valve_size': getattr(material, 'valve_size', None),
|
||||
# FLANGE 상세 정보
|
||||
'flange_type': getattr(material, 'flange_type', None),
|
||||
'flange_pressure': getattr(material, 'flange_pressure', None),
|
||||
'flange_size': getattr(material, 'flange_size', None),
|
||||
# GASKET 상세 정보
|
||||
'gasket_type': getattr(material, 'gasket_type', None),
|
||||
'gasket_subtype': getattr(material, 'gasket_subtype', None),
|
||||
'gasket_material': getattr(material, 'gasket_material', None),
|
||||
'filler_material': getattr(material, 'filler_material', None),
|
||||
'gasket_size': getattr(material, 'gasket_size', None),
|
||||
'gasket_pressure': getattr(material, 'gasket_pressure', None),
|
||||
'gasket_thickness': getattr(material, 'gasket_thickness', None),
|
||||
# BOLT 상세 정보
|
||||
'bolt_type': getattr(material, 'bolt_type', None),
|
||||
'material_standard': getattr(material, 'material_standard', None),
|
||||
'bolt_diameter': getattr(material, 'bolt_diameter', None),
|
||||
'bolt_length': getattr(material, 'bolt_length', None),
|
||||
# INSTRUMENT 상세 정보
|
||||
'instrument_type': getattr(material, 'instrument_type', None),
|
||||
'instrument_size': getattr(material, 'instrument_size', None)
|
||||
}
|
||||
|
||||
grouped_materials[category].append(material_dict)
|
||||
|
||||
# 3. 각 카테고리별로 구매 품목 생성
|
||||
purchase_items = []
|
||||
|
||||
for category, category_materials in grouped_materials.items():
|
||||
if category == 'PIPE':
|
||||
# PIPE는 재질+사이즈+스케줄별로 그룹핑
|
||||
pipe_groups = {}
|
||||
for material in category_materials:
|
||||
# 그룹핑 키 생성
|
||||
material_spec = material.get('pipe_material_spec') or material.get('material_grade', '')
|
||||
outer_diameter = material.get('outer_diameter') or material.get('main_nom', '')
|
||||
schedule = material.get('schedule', '')
|
||||
|
||||
group_key = f"{material_spec}|{outer_diameter}|{schedule}"
|
||||
|
||||
if group_key not in pipe_groups:
|
||||
pipe_groups[group_key] = []
|
||||
pipe_groups[group_key].append(material)
|
||||
|
||||
# 각 PIPE 그룹별로 구매 수량 계산
|
||||
for group_key, group_materials in pipe_groups.items():
|
||||
pipe_calc = calculate_pipe_purchase_quantity(group_materials)
|
||||
|
||||
if pipe_calc['calculated_qty'] > 0:
|
||||
material_spec, outer_diameter, schedule = group_key.split('|')
|
||||
|
||||
# 품목 코드 생성
|
||||
item_code = generate_item_code('PIPE', material_spec, outer_diameter, schedule)
|
||||
|
||||
# 사양 생성
|
||||
spec_parts = [f"PIPE {outer_diameter}"]
|
||||
if schedule: spec_parts.append(schedule)
|
||||
if material_spec: spec_parts.append(material_spec)
|
||||
specification = ', '.join(spec_parts)
|
||||
|
||||
purchase_item = {
|
||||
'item_code': item_code,
|
||||
'category': 'PIPE',
|
||||
'specification': specification,
|
||||
'material_spec': material_spec,
|
||||
'size_spec': outer_diameter,
|
||||
'unit': 'mm',
|
||||
**pipe_calc,
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'file_id': file_id,
|
||||
'materials': group_materials
|
||||
}
|
||||
purchase_items.append(purchase_item)
|
||||
|
||||
else:
|
||||
# 기타 자재들은 사양별로 그룹핑
|
||||
spec_groups = generate_material_specs_for_category(category_materials, category)
|
||||
|
||||
for spec_key, spec_data in spec_groups.items():
|
||||
if spec_data['totalQuantity'] > 0:
|
||||
# 구매 수량 계산
|
||||
calc_result = calculate_standard_purchase_quantity(
|
||||
category,
|
||||
spec_data['totalQuantity']
|
||||
)
|
||||
|
||||
# 품목 코드 생성
|
||||
item_code = generate_item_code(category, spec_data.get('material_spec', ''),
|
||||
spec_data.get('size_display', ''))
|
||||
|
||||
purchase_item = {
|
||||
'item_code': item_code,
|
||||
'category': category,
|
||||
'specification': spec_data.get('full_spec', spec_key),
|
||||
'material_spec': spec_data.get('material_spec', ''),
|
||||
'size_spec': spec_data.get('size_display', ''),
|
||||
'size_fraction': spec_data.get('size_fraction', ''),
|
||||
'surface_treatment': spec_data.get('surface_treatment', ''),
|
||||
'special_applications': spec_data.get('special_applications', {}),
|
||||
'unit': spec_data.get('unit', 'EA'),
|
||||
**calc_result,
|
||||
'job_no': job_no,
|
||||
'revision': revision,
|
||||
'file_id': file_id,
|
||||
'materials': spec_data['items']
|
||||
}
|
||||
purchase_items.append(purchase_item)
|
||||
|
||||
return purchase_items
|
||||
|
||||
def generate_material_specs_for_category(materials: List[Dict], category: str) -> Dict:
|
||||
"""카테고리별 자재 사양 그룹핑 (MaterialsPage.jsx 로직과 동일)"""
|
||||
specs = {}
|
||||
|
||||
for material in materials:
|
||||
spec_key = ''
|
||||
spec_data = {}
|
||||
|
||||
if category == 'FITTING':
|
||||
fitting_type = material.get('fitting_type', 'FITTING')
|
||||
connection_method = material.get('fitting_connection', '')
|
||||
# 상세 테이블의 재질 정보 우선 사용
|
||||
material_spec = material.get('fitting_material_grade') or material.get('material_grade', '')
|
||||
# 상세 테이블의 사이즈 정보 사용
|
||||
main_size = material.get('fitting_main_size', '')
|
||||
reduced_size = material.get('fitting_reduced_size', '')
|
||||
|
||||
# 사이즈 표시 생성 (축소형인 경우 main x reduced 형태)
|
||||
if main_size and reduced_size and main_size != reduced_size:
|
||||
size_display = f"{main_size} x {reduced_size}"
|
||||
else:
|
||||
size_display = main_size or material.get('size_spec', '')
|
||||
|
||||
# 기존 분류기 방식: 피팅 타입 + 연결방식 + 압력등급
|
||||
# 예: "ELBOW, SOCKET WELD, 3000LB"
|
||||
fitting_display = fitting_type.replace('_', ' ') if fitting_type else 'FITTING'
|
||||
|
||||
spec_parts = [fitting_display]
|
||||
|
||||
# 연결방식 추가
|
||||
if connection_method and connection_method != 'UNKNOWN':
|
||||
connection_display = connection_method.replace('_', ' ')
|
||||
spec_parts.append(connection_display)
|
||||
|
||||
# 압력등급 추출 (description에서)
|
||||
description = material.get('original_description', '').upper()
|
||||
import re
|
||||
pressure_match = re.search(r'(\d+)LB', description)
|
||||
if pressure_match:
|
||||
spec_parts.append(f"{pressure_match.group(1)}LB")
|
||||
|
||||
# 스케줄 정보 추출 (니플 등에 중요)
|
||||
schedule_match = re.search(r'SCH\s*(\d+)', description)
|
||||
if schedule_match:
|
||||
spec_parts.append(f"SCH {schedule_match.group(1)}")
|
||||
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"FITTING|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'FITTING',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
elif category == 'VALVE':
|
||||
valve_type = material.get('valve_type', 'VALVE')
|
||||
connection_method = material.get('valve_connection', '')
|
||||
pressure_rating = material.get('valve_pressure', '')
|
||||
material_spec = material.get('material_grade', '')
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
size_display = material.get('valve_size') or material.get('size_spec', '')
|
||||
|
||||
spec_parts = [valve_type.replace('_', ' ')]
|
||||
if connection_method: spec_parts.append(connection_method.replace('_', ' '))
|
||||
if pressure_rating: spec_parts.append(pressure_rating)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"VALVE|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'VALVE',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
elif category == 'FLANGE':
|
||||
flange_type = material.get('flange_type', 'FLANGE')
|
||||
pressure_rating = material.get('flange_pressure', '')
|
||||
material_spec = material.get('material_grade', '')
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
size_display = material.get('flange_size') or material.get('size_spec', '')
|
||||
|
||||
spec_parts = [flange_type.replace('_', ' ')]
|
||||
if pressure_rating: spec_parts.append(pressure_rating)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"FLANGE|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'FLANGE',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
elif category == 'BOLT':
|
||||
bolt_type = material.get('bolt_type', 'BOLT')
|
||||
material_standard = material.get('material_standard', '')
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
diameter = material.get('bolt_diameter') or material.get('size_spec', '')
|
||||
length = material.get('bolt_length', '')
|
||||
material_spec = material_standard or material.get('material_grade', '')
|
||||
|
||||
# 기존 분류기 방식에 따른 사이즈 표시 (분수 형태)
|
||||
# 소수점을 분수로 변환 (예: 0.625 -> 5/8)
|
||||
size_display = diameter
|
||||
if diameter and '.' in diameter:
|
||||
try:
|
||||
decimal_val = float(diameter)
|
||||
# 일반적인 볼트 사이즈 분수 변환
|
||||
fraction_map = {
|
||||
0.25: "1/4\"", 0.3125: "5/16\"", 0.375: "3/8\"",
|
||||
0.4375: "7/16\"", 0.5: "1/2\"", 0.5625: "9/16\"",
|
||||
0.625: "5/8\"", 0.6875: "11/16\"", 0.75: "3/4\"",
|
||||
0.8125: "13/16\"", 0.875: "7/8\"", 1.0: "1\""
|
||||
}
|
||||
if decimal_val in fraction_map:
|
||||
size_display = fraction_map[decimal_val]
|
||||
except:
|
||||
pass
|
||||
|
||||
# 길이 정보 포함한 사이즈 표시 (예: 5/8" x 165L)
|
||||
if length:
|
||||
# 길이에서 숫자만 추출
|
||||
import re
|
||||
length_match = re.search(r'(\d+(?:\.\d+)?)', str(length))
|
||||
if length_match:
|
||||
length_num = length_match.group(1)
|
||||
size_display_with_length = f"{size_display} x {length_num}L"
|
||||
else:
|
||||
size_display_with_length = f"{size_display} x {length}"
|
||||
else:
|
||||
size_display_with_length = size_display
|
||||
|
||||
spec_parts = [bolt_type.replace('_', ' ')]
|
||||
if material_standard: spec_parts.append(material_standard)
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
# 사이즈+길이로 그룹핑
|
||||
spec_key = f"BOLT|{full_spec}|{material_spec}|{size_display_with_length}"
|
||||
spec_data = {
|
||||
'category': 'BOLT',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': size_display_with_length,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
elif category == 'GASKET':
|
||||
# 상세 테이블 정보 우선 사용
|
||||
gasket_type = material.get('gasket_type', 'GASKET')
|
||||
gasket_subtype = material.get('gasket_subtype', '')
|
||||
gasket_material = material.get('gasket_material', '')
|
||||
filler_material = material.get('filler_material', '')
|
||||
gasket_pressure = material.get('gasket_pressure', '')
|
||||
gasket_thickness = material.get('gasket_thickness', '')
|
||||
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
size_display = material.get('gasket_size') or material.get('size_spec', '')
|
||||
|
||||
# 기존 분류기 방식: 가스켓 타입 + 압력등급 + 재질
|
||||
# 예: "SPIRAL WOUND, 150LB, 304SS + GRAPHITE"
|
||||
spec_parts = [gasket_type.replace('_', ' ')]
|
||||
|
||||
# 서브타입 추가 (있는 경우)
|
||||
if gasket_subtype and gasket_subtype != gasket_type:
|
||||
spec_parts.append(gasket_subtype.replace('_', ' '))
|
||||
|
||||
# 상세 테이블의 압력등급 우선 사용, 없으면 description에서 추출
|
||||
if gasket_pressure:
|
||||
spec_parts.append(gasket_pressure)
|
||||
else:
|
||||
description = material.get('original_description', '').upper()
|
||||
import re
|
||||
pressure_match = re.search(r'(\d+)LB', description)
|
||||
if pressure_match:
|
||||
spec_parts.append(f"{pressure_match.group(1)}LB")
|
||||
|
||||
# 재질 정보 구성 (상세 테이블 정보 활용)
|
||||
material_spec_parts = []
|
||||
|
||||
# SWG의 경우 메탈 + 필러 형태로 구성
|
||||
if gasket_type == 'SPIRAL_WOUND':
|
||||
# 기존 저장된 데이터가 부정확한 경우, 원본 description에서 직접 파싱
|
||||
description = material.get('original_description', '').upper()
|
||||
|
||||
# SS304/GRAPHITE/CS/CS 패턴 파싱 (H/F/I/O 다음에 오는 재질 정보)
|
||||
import re
|
||||
material_spec = None
|
||||
|
||||
# H/F/I/O SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시)
|
||||
hfio_material_match = re.search(r'H/F/I/O\s+([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description)
|
||||
if hfio_material_match:
|
||||
part1 = hfio_material_match.group(1) # SS304
|
||||
part2 = hfio_material_match.group(2) # GRAPHITE
|
||||
part3 = hfio_material_match.group(3) # CS
|
||||
part4 = hfio_material_match.group(4) # CS
|
||||
material_spec = f"{part1}/{part2}/{part3}/{part4}"
|
||||
else:
|
||||
# 단순 SS304/GRAPHITE/CS/CS 패턴 (전체 구성 표시)
|
||||
simple_material_match = re.search(r'([A-Z0-9]+)/([A-Z]+)/([A-Z0-9]+)/([A-Z0-9]+)', description)
|
||||
if simple_material_match:
|
||||
part1 = simple_material_match.group(1) # SS304
|
||||
part2 = simple_material_match.group(2) # GRAPHITE
|
||||
part3 = simple_material_match.group(3) # CS
|
||||
part4 = simple_material_match.group(4) # CS
|
||||
material_spec = f"{part1}/{part2}/{part3}/{part4}"
|
||||
|
||||
if not material_spec:
|
||||
# 상세 테이블 정보 사용
|
||||
if gasket_material and gasket_material != 'GRAPHITE': # 메탈 부분
|
||||
material_spec_parts.append(gasket_material)
|
||||
elif gasket_material == 'GRAPHITE':
|
||||
# GRAPHITE만 있는 경우 description에서 메탈 부분 찾기
|
||||
metal_match = re.search(r'(SS\d+|CS|INCONEL\d*)', description)
|
||||
if metal_match:
|
||||
material_spec_parts.append(metal_match.group(1))
|
||||
|
||||
if filler_material and filler_material != gasket_material: # 필러 부분
|
||||
material_spec_parts.append(filler_material)
|
||||
elif 'GRAPHITE' in description and 'GRAPHITE' not in material_spec_parts:
|
||||
material_spec_parts.append('GRAPHITE')
|
||||
|
||||
if material_spec_parts:
|
||||
material_spec = ' + '.join(material_spec_parts) # SS304 + GRAPHITE
|
||||
else:
|
||||
material_spec = material.get('material_grade', '')
|
||||
else:
|
||||
# 일반 가스켓의 경우
|
||||
if gasket_material:
|
||||
material_spec_parts.append(gasket_material)
|
||||
if filler_material and filler_material != gasket_material:
|
||||
material_spec_parts.append(filler_material)
|
||||
|
||||
if material_spec_parts:
|
||||
material_spec = ', '.join(material_spec_parts)
|
||||
else:
|
||||
material_spec = material.get('material_grade', '')
|
||||
|
||||
if material_spec:
|
||||
spec_parts.append(material_spec)
|
||||
|
||||
# 두께 정보 추가 (있는 경우)
|
||||
if gasket_thickness:
|
||||
spec_parts.append(f"THK {gasket_thickness}")
|
||||
|
||||
full_spec = ', '.join(spec_parts)
|
||||
|
||||
spec_key = f"GASKET|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'GASKET',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
elif category == 'INSTRUMENT':
|
||||
instrument_type = material.get('instrument_type', 'INSTRUMENT')
|
||||
material_spec = material.get('material_grade', '')
|
||||
# 상세 테이블의 사이즈 정보 우선 사용
|
||||
size_display = material.get('instrument_size') or material.get('size_spec', '')
|
||||
|
||||
full_spec = instrument_type.replace('_', ' ')
|
||||
|
||||
spec_key = f"INSTRUMENT|{full_spec}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': 'INSTRUMENT',
|
||||
'full_spec': full_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
else:
|
||||
# 기타 자재
|
||||
material_spec = material.get('material_grade', '')
|
||||
size_display = material.get('main_nom') or material.get('size_spec', '')
|
||||
|
||||
spec_key = f"{category}|{material_spec}|{size_display}"
|
||||
spec_data = {
|
||||
'category': category,
|
||||
'full_spec': material_spec,
|
||||
'material_spec': material_spec,
|
||||
'size_display': size_display,
|
||||
'unit': 'EA'
|
||||
}
|
||||
|
||||
# 스펙별 수량 집계
|
||||
if spec_key not in specs:
|
||||
specs[spec_key] = {
|
||||
**spec_data,
|
||||
'totalQuantity': 0,
|
||||
'count': 0,
|
||||
'items': [],
|
||||
'special_applications': {'PSV': 0, 'LT': 0, 'CK': 0} if category == 'BOLT' else None
|
||||
}
|
||||
|
||||
specs[spec_key]['totalQuantity'] += material.get('quantity', 0)
|
||||
specs[spec_key]['count'] += 1
|
||||
specs[spec_key]['items'].append(material)
|
||||
|
||||
# 볼트의 경우 특수 용도 정보 누적
|
||||
if category == 'BOLT' and 'special_applications' in locals():
|
||||
for app_type, count in special_applications.items():
|
||||
specs[spec_key]['special_applications'][app_type] += count
|
||||
|
||||
return specs
|
||||
|
||||
def generate_item_code(category: str, material_spec: str = '', size_spec: str = '',
|
||||
schedule: str = '') -> str:
|
||||
"""구매 품목 코드 생성"""
|
||||
import hashlib
|
||||
|
||||
# 기본 접두사
|
||||
prefix = f"PI-{category}"
|
||||
|
||||
# 재질 약어 생성
|
||||
material_abbr = ''
|
||||
if 'A106' in material_spec:
|
||||
material_abbr = 'A106'
|
||||
elif 'A333' in material_spec:
|
||||
material_abbr = 'A333'
|
||||
elif 'SS316' in material_spec or '316' in material_spec:
|
||||
material_abbr = 'SS316'
|
||||
elif 'A105' in material_spec:
|
||||
material_abbr = 'A105'
|
||||
elif material_spec:
|
||||
material_abbr = material_spec.replace(' ', '')[:6]
|
||||
|
||||
# 사이즈 약어
|
||||
size_abbr = size_spec.replace('"', 'IN').replace(' ', '').replace('x', 'X')[:10]
|
||||
|
||||
# 스케줄 (PIPE용)
|
||||
schedule_abbr = schedule.replace(' ', '')[:6]
|
||||
|
||||
# 유니크 해시 생성 (중복 방지)
|
||||
unique_str = f"{category}|{material_spec}|{size_spec}|{schedule}"
|
||||
hash_suffix = hashlib.md5(unique_str.encode()).hexdigest()[:4].upper()
|
||||
|
||||
# 최종 코드 조합
|
||||
code_parts = [prefix]
|
||||
if material_abbr: code_parts.append(material_abbr)
|
||||
if size_abbr: code_parts.append(size_abbr)
|
||||
if schedule_abbr: code_parts.append(schedule_abbr)
|
||||
code_parts.append(hash_suffix)
|
||||
|
||||
return '-'.join(code_parts)
|
||||
|
||||
def save_purchase_items_to_db(db: Session, purchase_items: List[Dict]) -> List[int]:
|
||||
"""구매 품목을 데이터베이스에 저장"""
|
||||
saved_ids = []
|
||||
|
||||
for item in purchase_items:
|
||||
# 기존 품목 확인 (동일 사양이 있는지)
|
||||
existing_query = text("""
|
||||
SELECT id FROM purchase_items
|
||||
WHERE job_no = :job_no AND revision = :revision AND item_code = :item_code
|
||||
""")
|
||||
existing = db.execute(existing_query, {
|
||||
'job_no': item['job_no'],
|
||||
'revision': item['revision'],
|
||||
'item_code': item['item_code']
|
||||
}).fetchone()
|
||||
|
||||
if existing:
|
||||
# 기존 품목 업데이트
|
||||
update_query = text("""
|
||||
UPDATE purchase_items SET
|
||||
bom_quantity = :bom_quantity,
|
||||
calculated_qty = :calculated_qty,
|
||||
safety_factor = :safety_factor,
|
||||
cutting_loss = :cutting_loss,
|
||||
pipes_count = :pipes_count,
|
||||
waste_length = :waste_length,
|
||||
detailed_spec = :detailed_spec,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :id
|
||||
""")
|
||||
db.execute(update_query, {
|
||||
'id': existing.id,
|
||||
'bom_quantity': item['bom_quantity'],
|
||||
'calculated_qty': item['calculated_qty'],
|
||||
'safety_factor': item.get('safety_factor', 1.0),
|
||||
'cutting_loss': item.get('cutting_loss', 0),
|
||||
'pipes_count': item.get('pipes_count'),
|
||||
'waste_length': item.get('waste_length'),
|
||||
'detailed_spec': item.get('detailed_spec', '{}')
|
||||
})
|
||||
saved_ids.append(existing.id)
|
||||
else:
|
||||
# 새 품목 생성
|
||||
insert_query = text("""
|
||||
INSERT INTO purchase_items (
|
||||
item_code, category, specification, material_spec, size_spec, unit,
|
||||
bom_quantity, safety_factor, minimum_order_qty, calculated_qty,
|
||||
cutting_loss, standard_length, pipes_count, waste_length,
|
||||
job_no, revision, file_id, is_active, created_by
|
||||
) VALUES (
|
||||
:item_code, :category, :specification, :material_spec, :size_spec, :unit,
|
||||
:bom_quantity, :safety_factor, :minimum_order_qty, :calculated_qty,
|
||||
:cutting_loss, :standard_length, :pipes_count, :waste_length,
|
||||
:job_no, :revision, :file_id, :is_active, :created_by
|
||||
) RETURNING id
|
||||
""")
|
||||
|
||||
result = db.execute(insert_query, {
|
||||
'item_code': item['item_code'],
|
||||
'category': item['category'],
|
||||
'specification': item['specification'],
|
||||
'material_spec': item['material_spec'],
|
||||
'size_spec': item['size_spec'],
|
||||
'unit': item['unit'],
|
||||
'bom_quantity': item['bom_quantity'],
|
||||
'safety_factor': item.get('safety_factor', 1.0),
|
||||
'minimum_order_qty': item.get('min_order_qty', 0),
|
||||
'calculated_qty': item['calculated_qty'],
|
||||
'cutting_loss': item.get('cutting_loss', 0),
|
||||
'standard_length': item.get('standard_length', 6000 if item['category'] == 'PIPE' else None),
|
||||
'pipes_count': item.get('pipes_count'),
|
||||
'waste_length': item.get('waste_length'),
|
||||
'job_no': item['job_no'],
|
||||
'revision': item['revision'],
|
||||
'file_id': item['file_id'],
|
||||
'is_active': True,
|
||||
'created_by': 'system'
|
||||
})
|
||||
|
||||
result_row = result.fetchone()
|
||||
new_id = result_row[0] if result_row else None
|
||||
saved_ids.append(new_id)
|
||||
|
||||
# 개별 자재와 구매 품목 연결
|
||||
for material in item['materials']:
|
||||
mapping_query = text("""
|
||||
INSERT INTO material_purchase_mapping (material_id, purchase_item_id)
|
||||
VALUES (:material_id, :purchase_item_id)
|
||||
ON CONFLICT (material_id, purchase_item_id) DO NOTHING
|
||||
""")
|
||||
db.execute(mapping_query, {
|
||||
'material_id': material['id'],
|
||||
'purchase_item_id': new_id
|
||||
})
|
||||
|
||||
db.commit()
|
||||
return saved_ids
|
||||
417
tkeg/api/app/services/revision_comparator.py
Normal file
417
tkeg/api/app/services/revision_comparator.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
리비전 비교 서비스
|
||||
- 기존 확정 자재와 신규 자재 비교
|
||||
- 변경된 자재만 분류 처리
|
||||
- 리비전 업로드 최적화
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RevisionComparator:
|
||||
"""리비전 비교 및 차이 분석 클래스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_previous_confirmed_materials(self, job_no: str, current_revision: str) -> Optional[Dict]:
|
||||
"""
|
||||
이전 확정된 자재 목록 조회
|
||||
|
||||
Args:
|
||||
job_no: 프로젝트 번호
|
||||
current_revision: 현재 리비전 (예: Rev.1)
|
||||
|
||||
Returns:
|
||||
확정된 자재 정보 딕셔너리 또는 None
|
||||
"""
|
||||
try:
|
||||
# 현재 리비전 번호 추출
|
||||
current_rev_num = self._extract_revision_number(current_revision)
|
||||
|
||||
# 이전 리비전들 중 확정된 것 찾기 (역순으로 검색)
|
||||
for prev_rev_num in range(current_rev_num - 1, -1, -1):
|
||||
prev_revision = f"Rev.{prev_rev_num}"
|
||||
|
||||
# 해당 리비전의 확정 데이터 조회
|
||||
query = text("""
|
||||
SELECT pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by,
|
||||
COUNT(cpi.id) as confirmed_items_count
|
||||
FROM purchase_confirmations pc
|
||||
LEFT JOIN confirmed_purchase_items cpi ON pc.id = cpi.confirmation_id
|
||||
WHERE pc.job_no = :job_no
|
||||
AND pc.revision = :revision
|
||||
AND pc.is_active = TRUE
|
||||
GROUP BY pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by
|
||||
ORDER BY pc.confirmed_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
result = self.db.execute(query, {
|
||||
"job_no": job_no,
|
||||
"revision": prev_revision
|
||||
}).fetchone()
|
||||
|
||||
if result and result.confirmed_items_count > 0:
|
||||
logger.info(f"이전 확정 자료 발견: {job_no} {prev_revision} ({result.confirmed_items_count}개 품목)")
|
||||
|
||||
# 확정된 품목들 상세 조회
|
||||
items_query = text("""
|
||||
SELECT cpi.item_code, cpi.category, cpi.specification,
|
||||
cpi.size, cpi.material, cpi.bom_quantity,
|
||||
cpi.calculated_qty, cpi.unit, cpi.safety_factor
|
||||
FROM confirmed_purchase_items cpi
|
||||
WHERE cpi.confirmation_id = :confirmation_id
|
||||
ORDER BY cpi.category, cpi.specification
|
||||
""")
|
||||
|
||||
items_result = self.db.execute(items_query, {
|
||||
"confirmation_id": result.id
|
||||
}).fetchall()
|
||||
|
||||
return {
|
||||
"confirmation_id": result.id,
|
||||
"revision": result.revision,
|
||||
"confirmed_at": result.confirmed_at,
|
||||
"confirmed_by": result.confirmed_by,
|
||||
"items": [dict(item) for item in items_result],
|
||||
"items_count": len(items_result)
|
||||
}
|
||||
|
||||
logger.info(f"이전 확정 자료 없음: {job_no} (현재: {current_revision})")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"이전 확정 자료 조회 실패: {str(e)}")
|
||||
return None
|
||||
|
||||
def compare_materials(self, previous_confirmed: Dict, new_materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
기존 확정 자재와 신규 자재 비교
|
||||
"""
|
||||
try:
|
||||
from rapidfuzz import fuzz
|
||||
|
||||
# 이전 확정 자재 해시맵 생성
|
||||
confirmed_materials = {}
|
||||
for item in previous_confirmed["items"]:
|
||||
material_hash = self._generate_material_hash(
|
||||
item["specification"],
|
||||
item["size"],
|
||||
item["material"]
|
||||
)
|
||||
confirmed_materials[material_hash] = item
|
||||
|
||||
# 해시 역참조 맵 (유사도 비교용)
|
||||
# 해시 -> 정규화된 설명 문자열 (비교 대상)
|
||||
# 여기서는 specification 자체를 비교 대상으로 사용 (가장 정보량이 많음)
|
||||
confirmed_specs = {
|
||||
h: item["specification"] for h, item in confirmed_materials.items()
|
||||
}
|
||||
|
||||
# 신규 자재 분석
|
||||
unchanged_materials = []
|
||||
changed_materials = []
|
||||
new_materials_list = []
|
||||
|
||||
for new_material in new_materials:
|
||||
description = new_material.get("description", "")
|
||||
size = self._extract_size_from_description(description)
|
||||
material = self._extract_material_from_description(description)
|
||||
|
||||
material_hash = self._generate_material_hash(description, size, material)
|
||||
|
||||
if material_hash in confirmed_materials:
|
||||
# 정확히 일치하는 자재 발견 (해시 일치)
|
||||
confirmed_item = confirmed_materials[material_hash]
|
||||
|
||||
new_qty = float(new_material.get("quantity", 0))
|
||||
confirmed_qty = float(confirmed_item["bom_quantity"])
|
||||
|
||||
if abs(new_qty - confirmed_qty) > 0.001:
|
||||
changed_materials.append({
|
||||
**new_material,
|
||||
"change_type": "QUANTITY_CHANGED",
|
||||
"previous_quantity": confirmed_qty,
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
else:
|
||||
unchanged_materials.append({
|
||||
**new_material,
|
||||
"reuse_classification": True,
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
else:
|
||||
# 해시 불일치 - 유사도 검사 (Fuzzy Matching)
|
||||
# 신규 자재 설명과 기존 확정 자재들의 스펙 비교
|
||||
best_match_hash = None
|
||||
best_match_score = 0
|
||||
|
||||
# 성능을 위해 간단한 필터링 후 정밀 비교 권장되나,
|
||||
# 현재는 전체 비교 (데이터량이 많지 않다고 가정)
|
||||
for h, spec in confirmed_specs.items():
|
||||
score = fuzz.ratio(description.lower(), spec.lower())
|
||||
if score > 85: # 85점 이상이면 매우 유사
|
||||
if score > best_match_score:
|
||||
best_match_score = score
|
||||
best_match_hash = h
|
||||
|
||||
if best_match_hash:
|
||||
# 유사한 자재 발견 (오타 또는 미세 변경 가능성)
|
||||
similar_item = confirmed_materials[best_match_hash]
|
||||
new_materials_list.append({
|
||||
**new_material,
|
||||
"change_type": "NEW_BUT_SIMILAR",
|
||||
"similarity_score": best_match_score,
|
||||
"similar_to": similar_item
|
||||
})
|
||||
else:
|
||||
# 완전히 새로운 자재
|
||||
new_materials_list.append({
|
||||
**new_material,
|
||||
"change_type": "NEW_MATERIAL"
|
||||
})
|
||||
|
||||
# 삭제된 자재 찾기
|
||||
new_material_hashes = set()
|
||||
for material in new_materials:
|
||||
d = material.get("description", "")
|
||||
s = self._extract_size_from_description(d)
|
||||
m = self._extract_material_from_description(d)
|
||||
new_material_hashes.add(self._generate_material_hash(d, s, m))
|
||||
|
||||
removed_materials = []
|
||||
for hash_key, confirmed_item in confirmed_materials.items():
|
||||
if hash_key not in new_material_hashes:
|
||||
removed_materials.append({
|
||||
"change_type": "REMOVED",
|
||||
"previous_item": confirmed_item
|
||||
})
|
||||
|
||||
comparison_result = {
|
||||
"has_previous_confirmation": True,
|
||||
"previous_revision": previous_confirmed["revision"],
|
||||
"previous_confirmed_at": previous_confirmed["confirmed_at"],
|
||||
"unchanged_count": len(unchanged_materials),
|
||||
"changed_count": len(changed_materials),
|
||||
"new_count": len(new_materials_list),
|
||||
"removed_count": len(removed_materials),
|
||||
"total_materials": len(new_materials),
|
||||
"classification_needed": len(changed_materials) + len(new_materials_list),
|
||||
"unchanged_materials": unchanged_materials,
|
||||
"changed_materials": changed_materials,
|
||||
"new_materials": new_materials_list,
|
||||
"removed_materials": removed_materials
|
||||
}
|
||||
|
||||
logger.info(f"리비전 비교 완료 (Fuzzy 적용): 변경없음 {len(unchanged_materials)}, "
|
||||
f"변경됨 {len(changed_materials)}, 신규 {len(new_materials_list)}, "
|
||||
f"삭제됨 {len(removed_materials)}")
|
||||
|
||||
return comparison_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"자재 비교 실패: {str(e)}")
|
||||
raise
|
||||
|
||||
def _extract_revision_number(self, revision: str) -> int:
|
||||
"""리비전 문자열에서 숫자 추출 (Rev.1 → 1)"""
|
||||
try:
|
||||
if revision.startswith("Rev."):
|
||||
return int(revision.replace("Rev.", ""))
|
||||
return 0
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def _generate_material_hash(self, description: str, size: str, material: str) -> str:
|
||||
"""
|
||||
자재 고유성 판단을 위한 해시 생성
|
||||
|
||||
Args:
|
||||
description: 자재 설명
|
||||
size: 자재 규격/크기
|
||||
material: 자재 재질
|
||||
|
||||
Returns:
|
||||
MD5 해시 문자열
|
||||
"""
|
||||
import re
|
||||
|
||||
def normalize(s: Optional[str]) -> str:
|
||||
if s is None:
|
||||
return ""
|
||||
# 다중 공백을 단일 공백으로 치환하고 앞뒤 공백 제거
|
||||
s = re.sub(r'\s+', ' ', str(s))
|
||||
return s.strip().lower()
|
||||
|
||||
# 각 컴포넌트 정규화
|
||||
d_norm = normalize(description)
|
||||
s_norm = normalize(size)
|
||||
m_norm = normalize(material)
|
||||
|
||||
# RULES.md의 코딩 컨벤션 준수 (pipe separator 사용)
|
||||
# 값이 없는 경우에도 구분자를 포함하여 구조 유지 (예: "desc||mat")
|
||||
hash_input = f"{d_norm}|{s_norm}|{m_norm}"
|
||||
|
||||
return hashlib.md5(hash_input.encode()).hexdigest()
|
||||
|
||||
def _extract_size_from_description(self, description: str) -> str:
|
||||
"""
|
||||
자재 설명에서 사이즈 정보 추출
|
||||
|
||||
지원하는 패턴 (단어 경계 \b 추가하여 정확도 향상):
|
||||
- 1/2" (인치)
|
||||
- 100A (A단위)
|
||||
- 50mm (밀리미터)
|
||||
- 10x20 (가로x세로)
|
||||
- DN100 (DN단위)
|
||||
"""
|
||||
if not description:
|
||||
return ""
|
||||
|
||||
import re
|
||||
size_patterns = [
|
||||
# 인치 패턴 (분수 포함): 1/2", 1.5", 1-1/2"
|
||||
r'\b(\d+(?:[-/.]\d+)?)\s*(?:inch|인치|")',
|
||||
# 밀리미터 패턴: 100mm, 100.5 MM
|
||||
r'\b(\d+(?:\.\d+)?)\s*(?:mm|MM)\b',
|
||||
# A단위 패턴: 100A, 100 A
|
||||
r'\b(\d+)\s*A\b',
|
||||
# DN단위 패턴: DN100, DN 100
|
||||
r'DN\s*(\d+)\b',
|
||||
# 치수 패턴: 10x20, 10*20
|
||||
r'\b(\d+(?:\.\d+)?)\s*[xX*]\s*(\d+(?:\.\d+)?)\b'
|
||||
]
|
||||
|
||||
for pattern in size_patterns:
|
||||
match = re.search(pattern, description, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _load_materials_from_db(self) -> List[str]:
|
||||
"""DB에서 자재 목록 동적 로딩 (캐싱 적용 고려 가능)"""
|
||||
try:
|
||||
# MaterialSpecification 및 SpecialMaterial 테이블에서 자재 코드 조회
|
||||
query = text("""
|
||||
SELECT spec_code FROM material_specifications
|
||||
WHERE is_active = TRUE
|
||||
UNION
|
||||
SELECT grade_code FROM material_grades
|
||||
WHERE is_active = TRUE
|
||||
UNION
|
||||
SELECT material_name FROM special_materials
|
||||
WHERE is_active = TRUE
|
||||
""")
|
||||
result = self.db.execute(query).fetchall()
|
||||
db_materials = [row[0] for row in result]
|
||||
|
||||
# 기본 하드코딩 리스트 (DB 조회 실패 시 또는 보완용)
|
||||
default_materials = [
|
||||
"SUS316L", "SUS316", "SUS304L", "SUS304",
|
||||
"SS316L", "SS316", "SS304L", "SS304",
|
||||
"A105N", "A105",
|
||||
"A234 WPB", "A234",
|
||||
"A106 Gr.B", "A106",
|
||||
"WCB", "CF8M", "CF8",
|
||||
"CS", "STS", "PVC", "PP", "PE"
|
||||
]
|
||||
|
||||
# 합치고 중복 제거 후 길이 역순 정렬 (긴 단어 우선 매칭)
|
||||
combined = list(set(db_materials + default_materials))
|
||||
combined.sort(key=len, reverse=True)
|
||||
|
||||
return combined
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"DB 자재 로딩 실패 (기본값 사용): {str(e)}")
|
||||
materials = [
|
||||
"SUS316L", "SUS316", "SUS304L", "SUS304",
|
||||
"SS316L", "SS316", "SS304L", "SS304",
|
||||
"A105N", "A105",
|
||||
"A234 WPB", "A234",
|
||||
"A106 Gr.B", "A106",
|
||||
"WCB", "CF8M", "CF8",
|
||||
"CS", "STS", "PVC", "PP", "PE"
|
||||
]
|
||||
return materials
|
||||
|
||||
def _extract_material_from_description(self, description: str) -> str:
|
||||
"""
|
||||
자재 설명에서 재질 정보 추출
|
||||
우선순위에 따라 매칭 (구체적인 재질 먼저)
|
||||
"""
|
||||
if not description:
|
||||
return ""
|
||||
|
||||
# 자재 목록 로딩 (메모리 캐싱을 위해 클래스 속성으로 저장 고려 가능하지만 여기선 매번 호출로 단순화)
|
||||
# 성능이 중요하다면 __init__ 시점에 로드하거나 lru_cache 사용 권장
|
||||
materials = self._load_materials_from_db()
|
||||
|
||||
description_upper = description.upper()
|
||||
|
||||
for material in materials:
|
||||
# 단어 매칭을 위해 간단한 검사 수행 (부분 문자열이 다른 단어의 일부가 아닌지)
|
||||
if material.upper() in description_upper:
|
||||
return material
|
||||
|
||||
return ""
|
||||
|
||||
def get_revision_comparison(db: Session, job_no: str, current_revision: str,
|
||||
new_materials: List[Dict]) -> Dict:
|
||||
"""
|
||||
리비전 비교 수행 (편의 함수)
|
||||
|
||||
Args:
|
||||
db: 데이터베이스 세션
|
||||
job_no: 프로젝트 번호
|
||||
current_revision: 현재 리비전
|
||||
new_materials: 신규 자재 목록
|
||||
|
||||
Returns:
|
||||
비교 결과 또는 전체 분류 필요 정보
|
||||
"""
|
||||
comparator = RevisionComparator(db)
|
||||
|
||||
# 이전 확정 자료 조회
|
||||
previous_confirmed = comparator.get_previous_confirmed_materials(job_no, current_revision)
|
||||
|
||||
if previous_confirmed is None:
|
||||
# 이전 확정 자료가 없으면 전체 분류 필요
|
||||
return {
|
||||
"has_previous_confirmation": False,
|
||||
"classification_needed": len(new_materials),
|
||||
"all_materials_need_classification": True,
|
||||
"materials_to_classify": new_materials,
|
||||
"message": "이전 확정 자료가 없어 전체 자재를 분류합니다."
|
||||
}
|
||||
|
||||
# 이전 확정 자료가 있으면 비교 수행
|
||||
return comparator.compare_materials(previous_confirmed, new_materials)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
457
tkeg/api/app/services/revision_comparison_service.py
Normal file
457
tkeg/api/app/services/revision_comparison_service.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
리비전 비교 및 변경 처리 서비스
|
||||
- 자재 비교 로직 (구매된/미구매 자재 구분)
|
||||
- 리비전 액션 결정 (추가구매, 재고이관, 수량변경 등)
|
||||
- GASKET/BOLT 특별 처리 (BOM 원본 수량 기준)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
|
||||
from ..models import Material
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RevisionComparisonService:
|
||||
"""리비전 비교 및 변경 처리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def compare_materials_by_category(
|
||||
self,
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
category: str,
|
||||
session_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""카테고리별 자재 비교 및 변경사항 기록"""
|
||||
|
||||
try:
|
||||
logger.info(f"카테고리 {category} 자재 비교 시작 (현재: {current_file_id}, 이전: {previous_file_id})")
|
||||
|
||||
# 현재 파일의 자재 조회
|
||||
current_materials = self._get_materials_by_category(current_file_id, category)
|
||||
previous_materials = self._get_materials_by_category(previous_file_id, category)
|
||||
|
||||
logger.info(f"현재 자재: {len(current_materials)}개, 이전 자재: {len(previous_materials)}개")
|
||||
|
||||
# 자재 그룹화 (동일 자재 식별)
|
||||
current_grouped = self._group_materials_by_key(current_materials, category)
|
||||
previous_grouped = self._group_materials_by_key(previous_materials, category)
|
||||
|
||||
# 비교 결과 저장
|
||||
comparison_results = {
|
||||
"added": [],
|
||||
"removed": [],
|
||||
"changed": [],
|
||||
"unchanged": []
|
||||
}
|
||||
|
||||
# 현재 자재 기준으로 비교
|
||||
for key, current_group in current_grouped.items():
|
||||
if key in previous_grouped:
|
||||
previous_group = previous_grouped[key]
|
||||
|
||||
# 수량 비교 (GASKET/BOLT는 BOM 원본 수량 기준)
|
||||
current_qty = self._get_comparison_quantity(current_group, category)
|
||||
previous_qty = self._get_comparison_quantity(previous_group, category)
|
||||
|
||||
if current_qty != previous_qty:
|
||||
# 수량 변경됨
|
||||
change_record = self._create_change_record(
|
||||
current_group, previous_group, "quantity_changed",
|
||||
current_qty, previous_qty, category, session_id
|
||||
)
|
||||
comparison_results["changed"].append(change_record)
|
||||
else:
|
||||
# 수량 동일
|
||||
unchanged_record = self._create_change_record(
|
||||
current_group, previous_group, "unchanged",
|
||||
current_qty, previous_qty, category, session_id
|
||||
)
|
||||
comparison_results["unchanged"].append(unchanged_record)
|
||||
else:
|
||||
# 새로 추가된 자재
|
||||
current_qty = self._get_comparison_quantity(current_group, category)
|
||||
added_record = self._create_change_record(
|
||||
current_group, None, "added",
|
||||
current_qty, 0, category, session_id
|
||||
)
|
||||
comparison_results["added"].append(added_record)
|
||||
|
||||
# 제거된 자재 확인
|
||||
for key, previous_group in previous_grouped.items():
|
||||
if key not in current_grouped:
|
||||
previous_qty = self._get_comparison_quantity(previous_group, category)
|
||||
removed_record = self._create_change_record(
|
||||
None, previous_group, "removed",
|
||||
0, previous_qty, category, session_id
|
||||
)
|
||||
comparison_results["removed"].append(removed_record)
|
||||
|
||||
# DB에 변경사항 저장
|
||||
self._save_material_changes(comparison_results, session_id)
|
||||
|
||||
# 통계 정보
|
||||
summary = {
|
||||
"category": category,
|
||||
"added_count": len(comparison_results["added"]),
|
||||
"removed_count": len(comparison_results["removed"]),
|
||||
"changed_count": len(comparison_results["changed"]),
|
||||
"unchanged_count": len(comparison_results["unchanged"]),
|
||||
"total_changes": len(comparison_results["added"]) + len(comparison_results["removed"]) + len(comparison_results["changed"])
|
||||
}
|
||||
|
||||
logger.info(f"카테고리 {category} 비교 완료: {summary}")
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"changes": comparison_results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"카테고리 {category} 자재 비교 실패: {e}")
|
||||
raise
|
||||
|
||||
def _get_materials_by_category(self, file_id: int, category: str) -> List[Material]:
|
||||
"""파일의 특정 카테고리 자재 조회"""
|
||||
|
||||
return self.db.query(Material).filter(
|
||||
and_(
|
||||
Material.file_id == file_id,
|
||||
Material.classified_category == category,
|
||||
Material.is_active == True
|
||||
)
|
||||
).all()
|
||||
|
||||
def _group_materials_by_key(self, materials: List[Material], category: str) -> Dict[str, Dict]:
|
||||
"""자재를 고유 키로 그룹화"""
|
||||
|
||||
grouped = {}
|
||||
|
||||
for material in materials:
|
||||
# 카테고리별 고유 키 생성 전략
|
||||
if category == "PIPE":
|
||||
# PIPE: description + material_grade + main_nom
|
||||
key_parts = [
|
||||
material.original_description.strip().upper(),
|
||||
material.material_grade or '',
|
||||
material.main_nom or ''
|
||||
]
|
||||
elif category in ["GASKET", "BOLT"]:
|
||||
# GASKET/BOLT: description + main_nom (집계 공식 적용 전 원본 기준)
|
||||
key_parts = [
|
||||
material.original_description.strip().upper(),
|
||||
material.main_nom or ''
|
||||
]
|
||||
else:
|
||||
# 기타: description + drawing + main_nom + red_nom
|
||||
key_parts = [
|
||||
material.original_description.strip().upper(),
|
||||
material.drawing_name or '',
|
||||
material.main_nom or '',
|
||||
material.red_nom or ''
|
||||
]
|
||||
|
||||
key = "|".join(key_parts)
|
||||
|
||||
if key in grouped:
|
||||
# 동일한 자재가 있으면 수량 합산
|
||||
grouped[key]['total_quantity'] += float(material.quantity)
|
||||
grouped[key]['materials'].append(material)
|
||||
|
||||
# 구매 확정 상태 확인 (하나라도 구매 확정되면 전체가 구매 확정)
|
||||
if getattr(material, 'purchase_confirmed', False):
|
||||
grouped[key]['purchase_confirmed'] = True
|
||||
grouped[key]['purchase_confirmed_at'] = getattr(material, 'purchase_confirmed_at', None)
|
||||
|
||||
else:
|
||||
grouped[key] = {
|
||||
'key': key,
|
||||
'representative_material': material,
|
||||
'materials': [material],
|
||||
'total_quantity': float(material.quantity),
|
||||
'purchase_confirmed': getattr(material, 'purchase_confirmed', False),
|
||||
'purchase_confirmed_at': getattr(material, 'purchase_confirmed_at', None),
|
||||
'category': category
|
||||
}
|
||||
|
||||
return grouped
|
||||
|
||||
def _get_comparison_quantity(self, material_group: Dict, category: str) -> Decimal:
|
||||
"""비교용 수량 계산 (GASKET/BOLT는 집계 공식 적용 전 원본 수량)"""
|
||||
|
||||
if category in ["GASKET", "BOLT"]:
|
||||
# GASKET/BOLT: BOM 원본 수량 기준 (집계 공식 적용 전)
|
||||
# 실제 BOM에서 읽은 원본 수량을 사용
|
||||
original_quantity = 0
|
||||
for material in material_group['materials']:
|
||||
# classification_details에서 원본 수량 추출 시도
|
||||
details = getattr(material, 'classification_details', {})
|
||||
if isinstance(details, dict) and 'original_quantity' in details:
|
||||
original_quantity += float(details['original_quantity'])
|
||||
else:
|
||||
# 원본 수량 정보가 없으면 현재 수량 사용
|
||||
original_quantity += float(material.quantity)
|
||||
|
||||
return Decimal(str(original_quantity))
|
||||
else:
|
||||
# 기타 카테고리: 현재 수량 사용
|
||||
return Decimal(str(material_group['total_quantity']))
|
||||
|
||||
def _create_change_record(
|
||||
self,
|
||||
current_group: Optional[Dict],
|
||||
previous_group: Optional[Dict],
|
||||
change_type: str,
|
||||
current_qty: Decimal,
|
||||
previous_qty: Decimal,
|
||||
category: str,
|
||||
session_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""변경 기록 생성"""
|
||||
|
||||
# 대표 자재 정보
|
||||
if current_group:
|
||||
material = current_group['representative_material']
|
||||
material_id = material.id
|
||||
description = material.original_description
|
||||
purchase_status = 'purchased' if current_group['purchase_confirmed'] else 'not_purchased'
|
||||
purchase_confirmed_at = current_group.get('purchase_confirmed_at')
|
||||
else:
|
||||
material = previous_group['representative_material']
|
||||
material_id = None # 제거된 자재는 현재 material_id가 없음
|
||||
description = material.original_description
|
||||
purchase_status = 'purchased' if previous_group['purchase_confirmed'] else 'not_purchased'
|
||||
purchase_confirmed_at = previous_group.get('purchase_confirmed_at')
|
||||
|
||||
# 리비전 액션 결정
|
||||
revision_action = self._determine_revision_action(
|
||||
change_type, current_qty, previous_qty, purchase_status, category
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"material_id": material_id,
|
||||
"previous_material_id": material.id if previous_group else None,
|
||||
"material_description": description,
|
||||
"category": category,
|
||||
"change_type": change_type,
|
||||
"current_quantity": float(current_qty),
|
||||
"previous_quantity": float(previous_qty),
|
||||
"quantity_difference": float(current_qty - previous_qty),
|
||||
"purchase_status": purchase_status,
|
||||
"purchase_confirmed_at": purchase_confirmed_at,
|
||||
"revision_action": revision_action
|
||||
}
|
||||
|
||||
def _determine_revision_action(
|
||||
self,
|
||||
change_type: str,
|
||||
current_qty: Decimal,
|
||||
previous_qty: Decimal,
|
||||
purchase_status: str,
|
||||
category: str
|
||||
) -> str:
|
||||
"""리비전 액션 결정 로직"""
|
||||
|
||||
if change_type == "added":
|
||||
return "new_material"
|
||||
elif change_type == "removed":
|
||||
if purchase_status == "purchased":
|
||||
return "inventory_transfer" # 구매된 자재 → 재고 이관
|
||||
else:
|
||||
return "purchase_cancel" # 미구매 자재 → 구매 취소
|
||||
elif change_type == "quantity_changed":
|
||||
quantity_diff = current_qty - previous_qty
|
||||
|
||||
if purchase_status == "purchased":
|
||||
if quantity_diff > 0:
|
||||
return "additional_purchase" # 구매된 자재 수량 증가 → 추가 구매
|
||||
else:
|
||||
return "inventory_transfer" # 구매된 자재 수량 감소 → 재고 이관
|
||||
else:
|
||||
return "quantity_update" # 미구매 자재 → 수량 업데이트
|
||||
else:
|
||||
return "maintain" # 변경 없음
|
||||
|
||||
def _save_material_changes(self, comparison_results: Dict, session_id: int):
|
||||
"""변경사항을 DB에 저장"""
|
||||
|
||||
try:
|
||||
all_changes = []
|
||||
for change_type, changes in comparison_results.items():
|
||||
all_changes.extend(changes)
|
||||
|
||||
if not all_changes:
|
||||
return
|
||||
|
||||
# 배치 삽입
|
||||
insert_query = """
|
||||
INSERT INTO revision_material_changes (
|
||||
session_id, material_id, previous_material_id, material_description,
|
||||
category, change_type, current_quantity, previous_quantity,
|
||||
quantity_difference, purchase_status, purchase_confirmed_at, revision_action
|
||||
) VALUES (
|
||||
:session_id, :material_id, :previous_material_id, :material_description,
|
||||
:category, :change_type, :current_quantity, :previous_quantity,
|
||||
:quantity_difference, :purchase_status, :purchase_confirmed_at, :revision_action
|
||||
)
|
||||
"""
|
||||
|
||||
self.db.execute(text(insert_query), all_changes)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"변경사항 {len(all_changes)}건 저장 완료 (세션: {session_id})")
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"변경사항 저장 실패: {e}")
|
||||
raise
|
||||
|
||||
def get_session_changes(self, session_id: int, category: str = None) -> List[Dict[str, Any]]:
|
||||
"""세션의 변경사항 조회"""
|
||||
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
id, material_id, material_description, category,
|
||||
change_type, current_quantity, previous_quantity, quantity_difference,
|
||||
purchase_status, revision_action, action_status,
|
||||
processed_by, processed_at, processing_notes
|
||||
FROM revision_material_changes
|
||||
WHERE session_id = :session_id
|
||||
"""
|
||||
params = {"session_id": session_id}
|
||||
|
||||
if category:
|
||||
query += " AND category = :category"
|
||||
params["category"] = category
|
||||
|
||||
query += " ORDER BY category, material_description"
|
||||
|
||||
changes = self.db.execute(text(query), params).fetchall()
|
||||
|
||||
return [dict(change._mapping) for change in changes]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"세션 변경사항 조회 실패: {e}")
|
||||
raise
|
||||
|
||||
def process_revision_action(
|
||||
self,
|
||||
change_id: int,
|
||||
action: str,
|
||||
username: str,
|
||||
notes: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""리비전 액션 처리"""
|
||||
|
||||
try:
|
||||
# 변경사항 조회
|
||||
change = self.db.execute(text("""
|
||||
SELECT * FROM revision_material_changes WHERE id = :change_id
|
||||
"""), {"change_id": change_id}).fetchone()
|
||||
|
||||
if not change:
|
||||
raise ValueError(f"변경사항을 찾을 수 없습니다: {change_id}")
|
||||
|
||||
result = {"success": False, "message": ""}
|
||||
|
||||
# 액션별 처리
|
||||
if action == "additional_purchase":
|
||||
result = self._process_additional_purchase(change, username, notes)
|
||||
elif action == "inventory_transfer":
|
||||
result = self._process_inventory_transfer(change, username, notes)
|
||||
elif action == "purchase_cancel":
|
||||
result = self._process_purchase_cancel(change, username, notes)
|
||||
elif action == "quantity_update":
|
||||
result = self._process_quantity_update(change, username, notes)
|
||||
else:
|
||||
result = {"success": True, "message": "처리 완료"}
|
||||
|
||||
# 처리 상태 업데이트
|
||||
status = "completed" if result["success"] else "failed"
|
||||
self.db.execute(text("""
|
||||
UPDATE revision_material_changes
|
||||
SET action_status = :status, processed_by = :username,
|
||||
processed_at = CURRENT_TIMESTAMP, processing_notes = :notes
|
||||
WHERE id = :change_id
|
||||
"""), {
|
||||
"change_id": change_id,
|
||||
"status": status,
|
||||
"username": username,
|
||||
"notes": notes or result["message"]
|
||||
})
|
||||
|
||||
# 액션 로그 기록
|
||||
self.db.execute(text("""
|
||||
INSERT INTO revision_action_logs (
|
||||
session_id, revision_change_id, action_type, action_description,
|
||||
executed_by, result, result_message
|
||||
) VALUES (
|
||||
:session_id, :change_id, :action, :description,
|
||||
:username, :result, :message
|
||||
)
|
||||
"""), {
|
||||
"session_id": change.session_id,
|
||||
"change_id": change_id,
|
||||
"action": action,
|
||||
"description": f"{change.material_description} - {action}",
|
||||
"username": username,
|
||||
"result": "success" if result["success"] else "failed",
|
||||
"message": result["message"]
|
||||
})
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 액션 처리 실패: {e}")
|
||||
raise
|
||||
|
||||
def _process_additional_purchase(self, change, username: str, notes: str) -> Dict[str, Any]:
|
||||
"""추가 구매 처리"""
|
||||
# 구매 요청 생성 로직 구현
|
||||
return {"success": True, "message": f"추가 구매 요청 생성: {change.quantity_difference}개"}
|
||||
|
||||
def _process_inventory_transfer(self, change, username: str, notes: str) -> Dict[str, Any]:
|
||||
"""재고 이관 처리"""
|
||||
# 재고 이관 로직 구현
|
||||
try:
|
||||
self.db.execute(text("""
|
||||
INSERT INTO inventory_transfers (
|
||||
revision_change_id, material_description, category,
|
||||
quantity, unit, transferred_by, storage_notes
|
||||
) VALUES (
|
||||
:change_id, :description, :category,
|
||||
:quantity, 'EA', :username, :notes
|
||||
)
|
||||
"""), {
|
||||
"change_id": change.id,
|
||||
"description": change.material_description,
|
||||
"category": change.category,
|
||||
"quantity": abs(change.quantity_difference),
|
||||
"username": username,
|
||||
"notes": notes
|
||||
})
|
||||
|
||||
return {"success": True, "message": f"재고 이관 완료: {abs(change.quantity_difference)}개"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"재고 이관 실패: {str(e)}"}
|
||||
|
||||
def _process_purchase_cancel(self, change, username: str, notes: str) -> Dict[str, Any]:
|
||||
"""구매 취소 처리"""
|
||||
return {"success": True, "message": "구매 취소 완료"}
|
||||
|
||||
def _process_quantity_update(self, change, username: str, notes: str) -> Dict[str, Any]:
|
||||
"""수량 업데이트 처리"""
|
||||
return {"success": True, "message": f"수량 업데이트 완료: {change.current_quantity}개"}
|
||||
289
tkeg/api/app/services/revision_session_service.py
Normal file
289
tkeg/api/app/services/revision_session_service.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
리비전 세션 관리 서비스
|
||||
- 리비전 세션 생성, 관리, 완료 처리
|
||||
- 자재 변경 사항 추적 및 처리
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
|
||||
from ..models import File, Material
|
||||
from ..database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RevisionSessionService:
|
||||
"""리비전 세션 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_revision_session(
|
||||
self,
|
||||
job_no: str,
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
username: str
|
||||
) -> Dict[str, Any]:
|
||||
"""새로운 리비전 세션 생성"""
|
||||
|
||||
try:
|
||||
# 파일 정보 조회
|
||||
current_file = self.db.query(File).filter(File.id == current_file_id).first()
|
||||
previous_file = self.db.query(File).filter(File.id == previous_file_id).first()
|
||||
|
||||
if not current_file or not previous_file:
|
||||
raise ValueError("파일 정보를 찾을 수 없습니다")
|
||||
|
||||
# 기존 진행 중인 세션이 있는지 확인
|
||||
existing_session = self.db.execute(text("""
|
||||
SELECT id FROM revision_sessions
|
||||
WHERE job_no = :job_no AND status = 'processing'
|
||||
"""), {"job_no": job_no}).fetchone()
|
||||
|
||||
if existing_session:
|
||||
logger.warning(f"기존 진행 중인 리비전 세션이 있습니다: {existing_session[0]}")
|
||||
return {"session_id": existing_session[0], "status": "existing"}
|
||||
|
||||
# 새 세션 생성
|
||||
session_data = {
|
||||
"job_no": job_no,
|
||||
"current_file_id": current_file_id,
|
||||
"previous_file_id": previous_file_id,
|
||||
"current_revision": current_file.revision,
|
||||
"previous_revision": previous_file.revision,
|
||||
"status": "processing",
|
||||
"created_by": username
|
||||
}
|
||||
|
||||
result = self.db.execute(text("""
|
||||
INSERT INTO revision_sessions (
|
||||
job_no, current_file_id, previous_file_id,
|
||||
current_revision, previous_revision, status, created_by
|
||||
) VALUES (
|
||||
:job_no, :current_file_id, :previous_file_id,
|
||||
:current_revision, :previous_revision, :status, :created_by
|
||||
) RETURNING id
|
||||
"""), session_data)
|
||||
|
||||
session_id = result.fetchone()[0]
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"새 리비전 세션 생성: {session_id} (Job: {job_no})")
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "created",
|
||||
"job_no": job_no,
|
||||
"current_revision": current_file.revision,
|
||||
"previous_revision": previous_file.revision
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 세션 생성 실패: {e}")
|
||||
raise
|
||||
|
||||
def get_session_status(self, session_id: int) -> Dict[str, Any]:
|
||||
"""리비전 세션 상태 조회"""
|
||||
|
||||
try:
|
||||
session_info = self.db.execute(text("""
|
||||
SELECT
|
||||
id, job_no, current_file_id, previous_file_id,
|
||||
current_revision, previous_revision, status,
|
||||
total_materials, processed_materials,
|
||||
added_count, removed_count, changed_count, unchanged_count,
|
||||
purchase_cancel_count, inventory_transfer_count, additional_purchase_count,
|
||||
created_by, created_at, completed_at
|
||||
FROM revision_sessions
|
||||
WHERE id = :session_id
|
||||
"""), {"session_id": session_id}).fetchone()
|
||||
|
||||
if not session_info:
|
||||
raise ValueError(f"리비전 세션을 찾을 수 없습니다: {session_id}")
|
||||
|
||||
# 변경 사항 상세 조회
|
||||
changes = self.db.execute(text("""
|
||||
SELECT
|
||||
category, change_type, revision_action, action_status,
|
||||
COUNT(*) as count,
|
||||
SUM(CASE WHEN purchase_status = 'purchased' THEN 1 ELSE 0 END) as purchased_count,
|
||||
SUM(CASE WHEN purchase_status = 'not_purchased' THEN 1 ELSE 0 END) as unpurchased_count
|
||||
FROM revision_material_changes
|
||||
WHERE session_id = :session_id
|
||||
GROUP BY category, change_type, revision_action, action_status
|
||||
ORDER BY category, change_type
|
||||
"""), {"session_id": session_id}).fetchall()
|
||||
|
||||
return {
|
||||
"session_info": dict(session_info._mapping),
|
||||
"changes_summary": [dict(change._mapping) for change in changes],
|
||||
"progress_percentage": (
|
||||
(session_info.processed_materials / session_info.total_materials * 100)
|
||||
if session_info.total_materials > 0 else 0
|
||||
)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"리비전 세션 상태 조회 실패: {e}")
|
||||
raise
|
||||
|
||||
def update_session_progress(
|
||||
self,
|
||||
session_id: int,
|
||||
total_materials: int = None,
|
||||
processed_materials: int = None,
|
||||
**counts
|
||||
) -> bool:
|
||||
"""리비전 세션 진행 상황 업데이트"""
|
||||
|
||||
try:
|
||||
update_fields = []
|
||||
update_values = {"session_id": session_id}
|
||||
|
||||
if total_materials is not None:
|
||||
update_fields.append("total_materials = :total_materials")
|
||||
update_values["total_materials"] = total_materials
|
||||
|
||||
if processed_materials is not None:
|
||||
update_fields.append("processed_materials = :processed_materials")
|
||||
update_values["processed_materials"] = processed_materials
|
||||
|
||||
# 카운트 필드들 업데이트
|
||||
count_fields = [
|
||||
"added_count", "removed_count", "changed_count", "unchanged_count",
|
||||
"purchase_cancel_count", "inventory_transfer_count", "additional_purchase_count"
|
||||
]
|
||||
|
||||
for field in count_fields:
|
||||
if field in counts:
|
||||
update_fields.append(f"{field} = :{field}")
|
||||
update_values[field] = counts[field]
|
||||
|
||||
if not update_fields:
|
||||
return True # 업데이트할 내용이 없음
|
||||
|
||||
query = f"""
|
||||
UPDATE revision_sessions
|
||||
SET {', '.join(update_fields)}
|
||||
WHERE id = :session_id
|
||||
"""
|
||||
|
||||
self.db.execute(text(query), update_values)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"리비전 세션 진행 상황 업데이트: {session_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 세션 진행 상황 업데이트 실패: {e}")
|
||||
raise
|
||||
|
||||
def complete_session(self, session_id: int, username: str) -> Dict[str, Any]:
|
||||
"""리비전 세션 완료 처리"""
|
||||
|
||||
try:
|
||||
# 세션 상태를 완료로 변경
|
||||
self.db.execute(text("""
|
||||
UPDATE revision_sessions
|
||||
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :session_id AND status = 'processing'
|
||||
"""), {"session_id": session_id})
|
||||
|
||||
# 완료 로그 기록
|
||||
self.db.execute(text("""
|
||||
INSERT INTO revision_action_logs (
|
||||
session_id, action_type, action_description,
|
||||
executed_by, result, result_message
|
||||
) VALUES (
|
||||
:session_id, 'session_complete', '리비전 세션 완료',
|
||||
:username, 'success', '모든 리비전 처리 완료'
|
||||
)
|
||||
"""), {
|
||||
"session_id": session_id,
|
||||
"username": username
|
||||
})
|
||||
|
||||
self.db.commit()
|
||||
|
||||
# 최종 상태 조회
|
||||
final_status = self.get_session_status(session_id)
|
||||
|
||||
logger.info(f"리비전 세션 완료: {session_id}")
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"session_id": session_id,
|
||||
"final_status": final_status
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 세션 완료 처리 실패: {e}")
|
||||
raise
|
||||
|
||||
def cancel_session(self, session_id: int, username: str, reason: str = None) -> bool:
|
||||
"""리비전 세션 취소"""
|
||||
|
||||
try:
|
||||
# 세션 상태를 취소로 변경
|
||||
self.db.execute(text("""
|
||||
UPDATE revision_sessions
|
||||
SET status = 'cancelled', completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = :session_id AND status = 'processing'
|
||||
"""), {"session_id": session_id})
|
||||
|
||||
# 취소 로그 기록
|
||||
self.db.execute(text("""
|
||||
INSERT INTO revision_action_logs (
|
||||
session_id, action_type, action_description,
|
||||
executed_by, result, result_message
|
||||
) VALUES (
|
||||
:session_id, 'session_cancel', '리비전 세션 취소',
|
||||
:username, 'cancelled', :reason
|
||||
)
|
||||
"""), {
|
||||
"session_id": session_id,
|
||||
"username": username,
|
||||
"reason": reason or "사용자 요청에 의한 취소"
|
||||
})
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"리비전 세션 취소: {session_id}, 사유: {reason}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"리비전 세션 취소 실패: {e}")
|
||||
raise
|
||||
|
||||
def get_job_revision_history(self, job_no: str) -> List[Dict[str, Any]]:
|
||||
"""Job의 리비전 히스토리 조회"""
|
||||
|
||||
try:
|
||||
sessions = self.db.execute(text("""
|
||||
SELECT
|
||||
rs.id, rs.current_revision, rs.previous_revision,
|
||||
rs.status, rs.created_by, rs.created_at, rs.completed_at,
|
||||
rs.added_count, rs.removed_count, rs.changed_count,
|
||||
cf.filename as current_filename,
|
||||
pf.filename as previous_filename
|
||||
FROM revision_sessions rs
|
||||
LEFT JOIN files cf ON rs.current_file_id = cf.id
|
||||
LEFT JOIN files pf ON rs.previous_file_id = pf.id
|
||||
WHERE rs.job_no = :job_no
|
||||
ORDER BY rs.created_at DESC
|
||||
"""), {"job_no": job_no}).fetchall()
|
||||
|
||||
return [dict(session._mapping) for session in sessions]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"리비전 히스토리 조회 실패: {e}")
|
||||
raise
|
||||
256
tkeg/api/app/services/spool_manager.py
Normal file
256
tkeg/api/app/services/spool_manager.py
Normal file
@@ -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
|
||||
229
tkeg/api/app/services/spool_manager_v2.py
Normal file
229
tkeg/api/app/services/spool_manager_v2.py
Normal file
@@ -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
|
||||
34
tkeg/api/app/services/structural_classifier.py
Normal file
34
tkeg/api/app/services/structural_classifier.py
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
import re
|
||||
from typing import Dict
|
||||
|
||||
def classify_structural(tag: str, description: str, main_nom: str = "") -> Dict:
|
||||
"""
|
||||
형강(STRUCTURAL) 분류기
|
||||
규격 예: H-BEAM 100x100x6x8
|
||||
"""
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. 타입 식별
|
||||
struct_type = "UNKNOWN"
|
||||
if "H-BEAM" in desc_upper or "H-SECTION" in desc_upper: struct_type = "H-BEAM"
|
||||
elif "ANGLE" in desc_upper or "L-SECTION" in desc_upper: struct_type = "ANGLE"
|
||||
elif "CHANNEL" in desc_upper or "C-SECTION" in desc_upper: struct_type = "CHANNEL"
|
||||
elif "BEAM" in desc_upper: struct_type = "I-BEAM"
|
||||
|
||||
# 2. 규격 추출
|
||||
# 패턴: 100x100, 100x100x6x8, 50x50x5T, H-200x200
|
||||
dimension = ""
|
||||
# 숫자와 x 또는 *로 구성된 패턴, 앞에 H- 등이 붙을 수 있음
|
||||
dim_match = re.search(r'(?:H-|L-|C-)?(\d+(?:\.\d+)?(?:\s*[X\*]\s*\d+(?:\.\d+)?){1,3})', desc_upper)
|
||||
if dim_match:
|
||||
dimension = dim_match.group(1).replace("*", "x")
|
||||
|
||||
return {
|
||||
"category": "STRUCTURAL",
|
||||
"overall_confidence": 0.9,
|
||||
"details": {
|
||||
"type": struct_type,
|
||||
"dimension": dimension
|
||||
}
|
||||
}
|
||||
329
tkeg/api/app/services/support_classifier.py
Normal file
329
tkeg/api/app/services/support_classifier.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
SUPPORT 분류 시스템
|
||||
배관 지지재, 우레탄 블록, 클램프 등 지지 부품 분류
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from .material_classifier import classify_material
|
||||
|
||||
# ========== 서포트 타입별 분류 ==========
|
||||
SUPPORT_TYPES = {
|
||||
"URETHANE_BLOCK": {
|
||||
"dat_file_patterns": ["URETHANE", "BLOCK", "SHOE"],
|
||||
"description_keywords": ["URETHANE BLOCK", "BLOCK SHOE", "우레탄 블록", "우레탄", "URETHANE"],
|
||||
"characteristics": "우레탄 블록 슈",
|
||||
"applications": "배관 지지, 진동 흡수",
|
||||
"material_type": "URETHANE"
|
||||
},
|
||||
|
||||
"CLAMP": {
|
||||
"dat_file_patterns": ["CLAMP", "CL-"],
|
||||
"description_keywords": ["CLAMP", "클램프", "CL-1", "CL-2", "CL-3"],
|
||||
"characteristics": "배관 클램프",
|
||||
"applications": "배관 고정, 지지",
|
||||
"material_type": "STEEL"
|
||||
},
|
||||
|
||||
"HANGER": {
|
||||
"dat_file_patterns": ["HANGER", "HANG", "SUPP"],
|
||||
"description_keywords": ["HANGER", "SUPPORT", "행거", "서포트", "PIPE HANGER"],
|
||||
"characteristics": "배관 행거",
|
||||
"applications": "배관 매달기, 지지",
|
||||
"material_type": "STEEL"
|
||||
},
|
||||
|
||||
"SPRING_HANGER": {
|
||||
"dat_file_patterns": ["SPRING", "SPR_"],
|
||||
"description_keywords": ["SPRING HANGER", "SPRING", "스프링", "스프링 행거"],
|
||||
"characteristics": "스프링 행거",
|
||||
"applications": "가변 하중 지지",
|
||||
"material_type": "STEEL"
|
||||
},
|
||||
|
||||
"GUIDE": {
|
||||
"dat_file_patterns": ["GUIDE", "GD_"],
|
||||
"description_keywords": ["GUIDE", "가이드", "PIPE GUIDE"],
|
||||
"characteristics": "배관 가이드",
|
||||
"applications": "배관 방향 제어",
|
||||
"material_type": "STEEL"
|
||||
},
|
||||
|
||||
"ANCHOR": {
|
||||
"dat_file_patterns": ["ANCHOR", "ANCH"],
|
||||
"description_keywords": ["ANCHOR", "앵커", "PIPE ANCHOR"],
|
||||
"characteristics": "배관 앵커",
|
||||
"applications": "배관 고정점",
|
||||
"material_type": "STEEL"
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 하중 등급 분류 ==========
|
||||
LOAD_RATINGS = {
|
||||
"LIGHT": {
|
||||
"patterns": [r"(\d+)T", r"(\d+)TON"],
|
||||
"range": (0, 5), # 5톤 이하
|
||||
"description": "경하중용"
|
||||
},
|
||||
"MEDIUM": {
|
||||
"patterns": [r"(\d+)T", r"(\d+)TON"],
|
||||
"range": (5, 20), # 5-20톤
|
||||
"description": "중하중용"
|
||||
},
|
||||
"HEAVY": {
|
||||
"patterns": [r"(\d+)T", r"(\d+)TON"],
|
||||
"range": (20, 100), # 20-100톤
|
||||
"description": "중하중용"
|
||||
}
|
||||
}
|
||||
|
||||
def classify_support(dat_file: str, description: str, main_nom: str,
|
||||
length: Optional[float] = None) -> Dict:
|
||||
"""
|
||||
SUPPORT 분류 메인 함수
|
||||
|
||||
Args:
|
||||
dat_file: DAT 파일명
|
||||
description: 자재 설명
|
||||
main_nom: 주 사이즈
|
||||
length: 길이 (옵션)
|
||||
|
||||
Returns:
|
||||
분류 결과 딕셔너리
|
||||
"""
|
||||
|
||||
dat_upper = dat_file.upper()
|
||||
desc_upper = description.upper()
|
||||
combined_text = f"{dat_upper} {desc_upper}"
|
||||
|
||||
# 1. 서포트 타입 분류
|
||||
support_type_result = classify_support_type(dat_file, description)
|
||||
|
||||
# 2. 재질 분류 (공통 모듈 사용)
|
||||
material_result = classify_material(description)
|
||||
|
||||
# 3. 하중 등급 분류
|
||||
load_result = classify_load_rating(description)
|
||||
|
||||
# 4. 사이즈 정보 추출
|
||||
size_result = extract_support_size(description, main_nom)
|
||||
|
||||
# 5. 사용자 요구사항 추출
|
||||
user_requirements = extract_support_user_requirements(description)
|
||||
|
||||
# 6. 우레탄 블럭슈 두께 정보 추출 및 Material Grade 보강
|
||||
enhanced_material_grade = material_result.get('grade', 'UNKNOWN')
|
||||
if support_type_result.get("support_type") == "URETHANE_BLOCK":
|
||||
# 두께 정보 추출 (40t, 27t 등)
|
||||
thickness_match = re.search(r'(\d+)\s*[tT](?![oO])', description.upper())
|
||||
if thickness_match:
|
||||
thickness = f"{thickness_match.group(1)}t"
|
||||
if enhanced_material_grade == 'UNKNOWN' or not enhanced_material_grade:
|
||||
enhanced_material_grade = thickness
|
||||
elif thickness not in enhanced_material_grade:
|
||||
enhanced_material_grade = f"{enhanced_material_grade} {thickness}"
|
||||
|
||||
# 7. 최종 결과 조합
|
||||
return {
|
||||
"category": "SUPPORT",
|
||||
|
||||
# 서포트 특화 정보
|
||||
"support_type": support_type_result.get("support_type", "UNKNOWN"),
|
||||
"support_subtype": support_type_result.get("subtype", ""),
|
||||
"load_rating": load_result.get("load_rating", ""),
|
||||
"load_capacity": load_result.get("capacity", ""),
|
||||
|
||||
# 재질 정보 (공통 모듈) - 우레탄 블럭슈 두께 정보 포함
|
||||
"material": {
|
||||
"standard": material_result.get('standard', 'UNKNOWN'),
|
||||
"grade": enhanced_material_grade,
|
||||
"material_type": material_result.get('material_type', 'UNKNOWN'),
|
||||
"confidence": material_result.get('confidence', 0.0)
|
||||
},
|
||||
|
||||
# 사이즈 정보
|
||||
"size_info": size_result,
|
||||
|
||||
# 사용자 요구사항
|
||||
"user_requirements": user_requirements,
|
||||
|
||||
# 전체 신뢰도
|
||||
"overall_confidence": calculate_support_confidence({
|
||||
"type": support_type_result.get('confidence', 0),
|
||||
"material": material_result.get('confidence', 0),
|
||||
"load": load_result.get('confidence', 0),
|
||||
"size": size_result.get('confidence', 0)
|
||||
}),
|
||||
|
||||
# 증거
|
||||
"evidence": [
|
||||
f"SUPPORT_TYPE: {support_type_result.get('support_type', 'UNKNOWN')}",
|
||||
f"MATERIAL: {material_result.get('standard', 'UNKNOWN')}",
|
||||
f"LOAD: {load_result.get('load_rating', 'UNKNOWN')}"
|
||||
]
|
||||
}
|
||||
|
||||
def classify_support_type(dat_file: str, description: str) -> Dict:
|
||||
"""서포트 타입 분류"""
|
||||
|
||||
dat_upper = dat_file.upper()
|
||||
desc_upper = description.upper()
|
||||
combined_text = f"{dat_upper} {desc_upper}"
|
||||
|
||||
for support_type, type_data in SUPPORT_TYPES.items():
|
||||
# DAT 파일 패턴 확인
|
||||
for pattern in type_data["dat_file_patterns"]:
|
||||
if pattern in dat_upper:
|
||||
return {
|
||||
"support_type": support_type,
|
||||
"subtype": type_data["characteristics"],
|
||||
"applications": type_data["applications"],
|
||||
"confidence": 0.95,
|
||||
"evidence": [f"DAT_PATTERN: {pattern}"]
|
||||
}
|
||||
|
||||
# 설명 키워드 확인
|
||||
for keyword in type_data["description_keywords"]:
|
||||
if keyword in desc_upper:
|
||||
return {
|
||||
"support_type": support_type,
|
||||
"subtype": type_data["characteristics"],
|
||||
"applications": type_data["applications"],
|
||||
"confidence": 0.9,
|
||||
"evidence": [f"DESC_KEYWORD: {keyword}"]
|
||||
}
|
||||
|
||||
return {
|
||||
"support_type": "UNKNOWN",
|
||||
"subtype": "",
|
||||
"applications": "",
|
||||
"confidence": 0.0,
|
||||
"evidence": ["NO_SUPPORT_TYPE_FOUND"]
|
||||
}
|
||||
|
||||
def extract_support_user_requirements(description: str) -> List[str]:
|
||||
"""서포트 사용자 요구사항 추출"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
requirements = []
|
||||
|
||||
# 표면처리 관련
|
||||
if 'GALV' in desc_upper or 'GALVANIZED' in desc_upper:
|
||||
requirements.append('GALVANIZED')
|
||||
if 'HDG' in desc_upper or 'HOT DIP' in desc_upper:
|
||||
requirements.append('HOT DIP GALVANIZED')
|
||||
if 'PAINT' in desc_upper or 'PAINTED' in desc_upper:
|
||||
requirements.append('PAINTED')
|
||||
|
||||
# 재질 관련
|
||||
if 'SS' in desc_upper or 'STAINLESS' in desc_upper:
|
||||
requirements.append('STAINLESS STEEL')
|
||||
if 'CARBON' in desc_upper:
|
||||
requirements.append('CARBON STEEL')
|
||||
|
||||
# 특수 요구사항
|
||||
if 'FIRE SAFE' in desc_upper:
|
||||
requirements.append('FIRE SAFE')
|
||||
if 'SEISMIC' in desc_upper or '내진' in desc_upper:
|
||||
requirements.append('SEISMIC')
|
||||
|
||||
return requirements
|
||||
|
||||
def classify_load_rating(description: str) -> Dict:
|
||||
"""하중 등급 분류"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 하중 패턴 찾기 (40T, 50TON 등)
|
||||
for rating, rating_data in LOAD_RATINGS.items():
|
||||
for pattern in rating_data["patterns"]:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
capacity = int(match.group(1))
|
||||
min_load, max_load = rating_data["range"]
|
||||
|
||||
if min_load <= capacity <= max_load:
|
||||
return {
|
||||
"load_rating": rating,
|
||||
"capacity": f"{capacity}T",
|
||||
"description": rating_data["description"],
|
||||
"confidence": 0.9,
|
||||
"evidence": [f"LOAD_PATTERN: {match.group(0)}"]
|
||||
}
|
||||
|
||||
# 특정 하중 값이 있지만 등급을 모르는 경우
|
||||
load_match = re.search(r'(\d+)\s*[T톤]', desc_upper)
|
||||
if load_match:
|
||||
capacity = int(load_match.group(1))
|
||||
return {
|
||||
"load_rating": "CUSTOM",
|
||||
"capacity": f"{capacity}T",
|
||||
"description": f"{capacity}톤 하중",
|
||||
"confidence": 0.7,
|
||||
"evidence": [f"CUSTOM_LOAD: {load_match.group(0)}"]
|
||||
}
|
||||
|
||||
return {
|
||||
"load_rating": "UNKNOWN",
|
||||
"capacity": "",
|
||||
"description": "",
|
||||
"confidence": 0.0,
|
||||
"evidence": ["NO_LOAD_RATING_FOUND"]
|
||||
}
|
||||
|
||||
def extract_support_size(description: str, main_nom: str) -> Dict:
|
||||
"""서포트 사이즈 정보 추출"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 파이프 사이즈 (서포트가 지지하는 파이프 크기)
|
||||
pipe_size = main_nom if main_nom else ""
|
||||
|
||||
# 서포트 자체 치수 (길이x폭x높이 등)
|
||||
dimension_patterns = [
|
||||
r'(\d+)\s*[X×]\s*(\d+)\s*[X×]\s*(\d+)', # 100x50x20
|
||||
r'(\d+)\s*[X×]\s*(\d+)', # 100x50
|
||||
r'L\s*(\d+)', # L100 (길이)
|
||||
r'W\s*(\d+)', # W50 (폭)
|
||||
r'H\s*(\d+)' # H20 (높이)
|
||||
]
|
||||
|
||||
dimensions = {}
|
||||
for pattern in dimension_patterns:
|
||||
match = re.search(pattern, desc_upper)
|
||||
if match:
|
||||
if len(match.groups()) == 3:
|
||||
dimensions = {
|
||||
"length": f"{match.group(1)}mm",
|
||||
"width": f"{match.group(2)}mm",
|
||||
"height": f"{match.group(3)}mm"
|
||||
}
|
||||
elif len(match.groups()) == 2:
|
||||
dimensions = {
|
||||
"length": f"{match.group(1)}mm",
|
||||
"width": f"{match.group(2)}mm"
|
||||
}
|
||||
break
|
||||
|
||||
return {
|
||||
"pipe_size": pipe_size,
|
||||
"dimensions": dimensions,
|
||||
"confidence": 0.8 if dimensions else 0.3
|
||||
}
|
||||
|
||||
def calculate_support_confidence(confidence_scores: Dict) -> float:
|
||||
"""서포트 분류 전체 신뢰도 계산"""
|
||||
|
||||
weights = {
|
||||
"type": 0.4, # 타입이 가장 중요
|
||||
"material": 0.2, # 재질
|
||||
"load": 0.2, # 하중
|
||||
"size": 0.2 # 사이즈
|
||||
}
|
||||
|
||||
weighted_sum = sum(
|
||||
confidence_scores.get(key, 0) * weight
|
||||
for key, weight in weights.items()
|
||||
)
|
||||
|
||||
return round(weighted_sum, 2)
|
||||
143
tkeg/api/app/services/test_bolt_classifier.py
Normal file
143
tkeg/api/app/services/test_bolt_classifier.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
BOLT 분류 테스트
|
||||
"""
|
||||
|
||||
from .bolt_classifier import (
|
||||
classify_bolt,
|
||||
get_bolt_purchase_info,
|
||||
is_high_strength_bolt,
|
||||
is_stainless_bolt
|
||||
)
|
||||
|
||||
def test_bolt_classification():
|
||||
"""BOLT 분류 테스트"""
|
||||
|
||||
test_cases = [
|
||||
{
|
||||
"name": "육각 볼트 (미터)",
|
||||
"dat_file": "BOLT_HEX_M12",
|
||||
"description": "HEX BOLT, M12 X 50MM, GRADE 8.8, ZINC PLATED",
|
||||
"main_nom": "M12"
|
||||
},
|
||||
{
|
||||
"name": "소켓 헤드 캡 스크류",
|
||||
"dat_file": "SHCS_M8",
|
||||
"description": "SOCKET HEAD CAP SCREW, M8 X 25MM, SS316",
|
||||
"main_nom": "M8"
|
||||
},
|
||||
{
|
||||
"name": "스터드 볼트",
|
||||
"dat_file": "STUD_M16",
|
||||
"description": "STUD BOLT, M16 X 100MM, ASTM A193 B7",
|
||||
"main_nom": "M16"
|
||||
},
|
||||
{
|
||||
"name": "플랜지 볼트",
|
||||
"dat_file": "FLG_BOLT_M20",
|
||||
"description": "FLANGE BOLT, M20 X 80MM, GRADE 10.9",
|
||||
"main_nom": "M20"
|
||||
},
|
||||
{
|
||||
"name": "인치 볼트",
|
||||
"dat_file": "BOLT_HEX_1/2",
|
||||
"description": "HEX BOLT, 1/2-13 UNC X 2 INCH, ASTM A325",
|
||||
"main_nom": "1/2\""
|
||||
},
|
||||
{
|
||||
"name": "육각 너트",
|
||||
"dat_file": "NUT_HEX_M12",
|
||||
"description": "HEX NUT, M12, GRADE 8, ZINC PLATED",
|
||||
"main_nom": "M12"
|
||||
},
|
||||
{
|
||||
"name": "헤비 너트",
|
||||
"dat_file": "HEAVY_NUT_M16",
|
||||
"description": "HEAVY HEX NUT, M16, SS316",
|
||||
"main_nom": "M16"
|
||||
},
|
||||
{
|
||||
"name": "평 와셔",
|
||||
"dat_file": "WASH_FLAT_M12",
|
||||
"description": "FLAT WASHER, M12, STAINLESS STEEL",
|
||||
"main_nom": "M12"
|
||||
},
|
||||
{
|
||||
"name": "스프링 와셔",
|
||||
"dat_file": "SPRING_WASH_M10",
|
||||
"description": "SPRING WASHER, M10, CARBON STEEL",
|
||||
"main_nom": "M10"
|
||||
},
|
||||
{
|
||||
"name": "U볼트",
|
||||
"dat_file": "U_BOLT_M8",
|
||||
"description": "U-BOLT, M8 X 50MM, GALVANIZED",
|
||||
"main_nom": "M8"
|
||||
}
|
||||
]
|
||||
|
||||
print("🔩 BOLT 분류 테스트 시작\n")
|
||||
print("=" * 80)
|
||||
|
||||
for i, test in enumerate(test_cases, 1):
|
||||
print(f"\n테스트 {i}: {test['name']}")
|
||||
print("-" * 60)
|
||||
|
||||
result = classify_bolt(
|
||||
test["dat_file"],
|
||||
test["description"],
|
||||
test["main_nom"]
|
||||
)
|
||||
|
||||
purchase_info = get_bolt_purchase_info(result)
|
||||
|
||||
print(f"📋 입력:")
|
||||
print(f" DAT_FILE: {test['dat_file']}")
|
||||
print(f" DESCRIPTION: {test['description']}")
|
||||
print(f" SIZE: {result['dimensions']['dimension_description']}")
|
||||
|
||||
print(f"\n🔩 분류 결과:")
|
||||
print(f" 카테고리: {result['fastener_category']['category']}")
|
||||
print(f" 타입: {result['fastener_type']['type']}")
|
||||
print(f" 특성: {result['fastener_type']['characteristics']}")
|
||||
print(f" 나사규격: {result['thread_specification']['standard']} {result['thread_specification']['size']}")
|
||||
if result['thread_specification']['pitch']:
|
||||
print(f" 피치: {result['thread_specification']['pitch']}")
|
||||
print(f" 치수: {result['dimensions']['dimension_description']}")
|
||||
print(f" 등급: {result['grade_strength']['grade']}")
|
||||
if result['grade_strength']['tensile_strength']:
|
||||
print(f" 인장강도: {result['grade_strength']['tensile_strength']}")
|
||||
|
||||
# 특수 조건 표시
|
||||
conditions = []
|
||||
if is_high_strength_bolt(result):
|
||||
conditions.append("💪 고강도")
|
||||
if is_stainless_bolt(result):
|
||||
conditions.append("✨ 스테인리스")
|
||||
if conditions:
|
||||
print(f" 특수조건: {' '.join(conditions)}")
|
||||
|
||||
print(f"\n📊 신뢰도:")
|
||||
print(f" 전체신뢰도: {result['overall_confidence']}")
|
||||
print(f" 재질: {result['material']['confidence']}")
|
||||
print(f" 타입: {result['fastener_type']['confidence']}")
|
||||
print(f" 나사규격: {result['thread_specification']['confidence']}")
|
||||
print(f" 등급: {result['grade_strength']['confidence']}")
|
||||
|
||||
print(f"\n🛒 구매 정보:")
|
||||
print(f" 공급업체: {purchase_info['supplier_type']}")
|
||||
print(f" 예상납기: {purchase_info['lead_time_estimate']}")
|
||||
print(f" 구매단위: {purchase_info['purchase_unit']}")
|
||||
print(f" 구매카테고리: {purchase_info['purchase_category']}")
|
||||
|
||||
print(f"\n💾 저장될 데이터:")
|
||||
print(f" FASTENER_CATEGORY: {result['fastener_category']['category']}")
|
||||
print(f" FASTENER_TYPE: {result['fastener_type']['type']}")
|
||||
print(f" THREAD_STANDARD: {result['thread_specification']['standard']}")
|
||||
print(f" THREAD_SIZE: {result['thread_specification']['size']}")
|
||||
print(f" GRADE: {result['grade_strength']['grade']}")
|
||||
|
||||
if i < len(test_cases):
|
||||
print("\n" + "=" * 80)
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_bolt_classification()
|
||||
184
tkeg/api/app/services/test_fitting_classifier.py
Normal file
184
tkeg/api/app/services/test_fitting_classifier.py
Normal file
@@ -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()
|
||||
126
tkeg/api/app/services/test_flange_classifier.py
Normal file
126
tkeg/api/app/services/test_flange_classifier.py
Normal file
@@ -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()
|
||||
127
tkeg/api/app/services/test_gasket_classifier.py
Normal file
127
tkeg/api/app/services/test_gasket_classifier.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
GASKET 분류 테스트
|
||||
"""
|
||||
|
||||
from .gasket_classifier import (
|
||||
classify_gasket,
|
||||
get_gasket_purchase_info,
|
||||
is_high_temperature_gasket,
|
||||
is_high_pressure_gasket
|
||||
)
|
||||
|
||||
def test_gasket_classification():
|
||||
"""GASKET 분류 테스트"""
|
||||
|
||||
test_cases = [
|
||||
{
|
||||
"name": "스파이럴 와운드 가스켓",
|
||||
"dat_file": "SWG_150",
|
||||
"description": "SPIRAL WOUND GASKET, GRAPHITE FILLER, SS316 WINDING, 150LB",
|
||||
"main_nom": "4\""
|
||||
},
|
||||
{
|
||||
"name": "링 조인트 가스켓",
|
||||
"dat_file": "RTJ_600",
|
||||
"description": "RING JOINT GASKET, RTJ, SS316, 600LB",
|
||||
"main_nom": "6\""
|
||||
},
|
||||
{
|
||||
"name": "풀 페이스 가스켓",
|
||||
"dat_file": "FF_150",
|
||||
"description": "FULL FACE GASKET, RUBBER, 150LB",
|
||||
"main_nom": "8\""
|
||||
},
|
||||
{
|
||||
"name": "레이즈드 페이스 가스켓",
|
||||
"dat_file": "RF_300",
|
||||
"description": "RAISED FACE GASKET, PTFE, 300LB, -200°C TO 260°C",
|
||||
"main_nom": "3\""
|
||||
},
|
||||
{
|
||||
"name": "오링",
|
||||
"dat_file": "OR_VITON",
|
||||
"description": "O-RING, VITON, ID 50MM, THK 3MM",
|
||||
"main_nom": "50mm"
|
||||
},
|
||||
{
|
||||
"name": "시트 가스켓",
|
||||
"dat_file": "SHEET_150",
|
||||
"description": "SHEET GASKET, GRAPHITE, 150LB, MAX 650°C",
|
||||
"main_nom": "10\""
|
||||
},
|
||||
{
|
||||
"name": "캄프로파일 가스켓",
|
||||
"dat_file": "KAMM_600",
|
||||
"description": "KAMMPROFILE GASKET, GRAPHITE FACING, SS304 CORE, 600LB",
|
||||
"main_nom": "12\""
|
||||
},
|
||||
{
|
||||
"name": "특수 가스켓",
|
||||
"dat_file": "CUSTOM_SPEC",
|
||||
"description": "CUSTOM GASKET, SPECIAL SHAPE, PTFE, -50°C TO 200°C",
|
||||
"main_nom": "특수"
|
||||
}
|
||||
]
|
||||
|
||||
print("🔧 GASKET 분류 테스트 시작\n")
|
||||
print("=" * 80)
|
||||
|
||||
for i, test in enumerate(test_cases, 1):
|
||||
print(f"\n테스트 {i}: {test['name']}")
|
||||
print("-" * 60)
|
||||
|
||||
result = classify_gasket(
|
||||
test["dat_file"],
|
||||
test["description"],
|
||||
test["main_nom"]
|
||||
)
|
||||
|
||||
purchase_info = get_gasket_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['gasket_type']['type']}")
|
||||
print(f" 특성: {result['gasket_type']['characteristics']}")
|
||||
print(f" 가스켓재질: {result['gasket_material']['material']}")
|
||||
print(f" 재질특성: {result['gasket_material']['characteristics']}")
|
||||
print(f" 압력등급: {result['pressure_rating']['rating']}")
|
||||
print(f" 온도범위: {result['temperature_info']['range']}")
|
||||
print(f" 용도: {result['gasket_type']['applications']}")
|
||||
|
||||
# 특수 조건 표시
|
||||
conditions = []
|
||||
if is_high_temperature_gasket(result):
|
||||
conditions.append("🔥 고온용")
|
||||
if is_high_pressure_gasket(result):
|
||||
conditions.append("💪 고압용")
|
||||
if conditions:
|
||||
print(f" 특수조건: {' '.join(conditions)}")
|
||||
|
||||
print(f"\n📊 신뢰도:")
|
||||
print(f" 전체신뢰도: {result['overall_confidence']}")
|
||||
print(f" 가스켓타입: {result['gasket_type']['confidence']}")
|
||||
print(f" 가스켓재질: {result['gasket_material']['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_unit']}")
|
||||
print(f" 구매카테고리: {purchase_info['purchase_category']}")
|
||||
|
||||
print(f"\n💾 저장될 데이터:")
|
||||
print(f" GASKET_TYPE: {result['gasket_type']['type']}")
|
||||
print(f" GASKET_MATERIAL: {result['gasket_material']['material']}")
|
||||
print(f" PRESSURE_RATING: {result['pressure_rating']['rating']}")
|
||||
print(f" SIZE_INFO: {result['size_info']['size_description']}")
|
||||
print(f" TEMPERATURE_RANGE: {result['temperature_info']['range']}")
|
||||
|
||||
if i < len(test_cases):
|
||||
print("\n" + "=" * 80)
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_gasket_classification()
|
||||
48
tkeg/api/app/services/test_instrument_classifier.py
Normal file
48
tkeg/api/app/services/test_instrument_classifier.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
INSTRUMENT 간단 테스트
|
||||
"""
|
||||
|
||||
from .instrument_classifier import classify_instrument
|
||||
|
||||
def test_instrument_classification():
|
||||
"""간단한 계기류 테스트"""
|
||||
|
||||
test_cases = [
|
||||
{
|
||||
"name": "압력 게이지",
|
||||
"dat_file": "PG_001",
|
||||
"description": "PRESSURE GAUGE, 0-100 PSI, 1/4 NPT",
|
||||
"main_nom": "1/4\""
|
||||
},
|
||||
{
|
||||
"name": "온도 트랜스미터",
|
||||
"dat_file": "TT_001",
|
||||
"description": "TEMPERATURE TRANSMITTER, 4-20mA, 0-200°C",
|
||||
"main_nom": "1/2\""
|
||||
},
|
||||
{
|
||||
"name": "유량계",
|
||||
"dat_file": "FM_001",
|
||||
"description": "FLOW METER, MAGNETIC TYPE",
|
||||
"main_nom": "3\""
|
||||
}
|
||||
]
|
||||
|
||||
print("🔧 INSTRUMENT 간단 테스트\n")
|
||||
|
||||
for i, test in enumerate(test_cases, 1):
|
||||
result = classify_instrument(
|
||||
test["dat_file"],
|
||||
test["description"],
|
||||
test["main_nom"]
|
||||
)
|
||||
|
||||
print(f"테스트 {i}: {test['name']}")
|
||||
print(f" 계기타입: {result['instrument_type']['type']}")
|
||||
print(f" 측정범위: {result['measurement_info']['range']} {result['measurement_info']['unit']}")
|
||||
print(f" 연결사이즈: {result['size_info']['connection_size']}")
|
||||
print(f" 구매정보: {result['purchase_info']['category']}")
|
||||
print()
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_instrument_classification()
|
||||
53
tkeg/api/app/services/test_material_classifier.py
Normal file
53
tkeg/api/app/services/test_material_classifier.py
Normal file
@@ -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()
|
||||
55
tkeg/api/app/services/test_pipe_classifier.py
Normal file
55
tkeg/api/app/services/test_pipe_classifier.py
Normal file
@@ -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()
|
||||
130
tkeg/api/app/services/test_valve_classifier.py
Normal file
130
tkeg/api/app/services/test_valve_classifier.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
VALVE 분류 테스트
|
||||
"""
|
||||
|
||||
from .valve_classifier import classify_valve, get_valve_purchase_info, is_forged_valve
|
||||
|
||||
def test_valve_classification():
|
||||
"""VALVE 분류 테스트"""
|
||||
|
||||
test_cases = [
|
||||
{
|
||||
"name": "게이트 밸브 (주조)",
|
||||
"dat_file": "GATE_FL_150",
|
||||
"description": "GATE VALVE, FLANGED, 150LB, WCB, OS&Y",
|
||||
"main_nom": "6\""
|
||||
},
|
||||
{
|
||||
"name": "볼 밸브 (주조)",
|
||||
"dat_file": "BALL_FL_300",
|
||||
"description": "BALL VALVE, FULL PORT, FLANGED, 300LB, WCB",
|
||||
"main_nom": "4\""
|
||||
},
|
||||
{
|
||||
"name": "볼 밸브 (단조)",
|
||||
"dat_file": "BALL_SW_1500",
|
||||
"description": "BALL VALVE, FORGED, SW, 1500LB, A105",
|
||||
"main_nom": "1\""
|
||||
},
|
||||
{
|
||||
"name": "글로브 밸브 (단조)",
|
||||
"dat_file": "GLOBE_SW_800",
|
||||
"description": "GLOBE VALVE, FORGED, SW, 800LB, A182 F316",
|
||||
"main_nom": "2\""
|
||||
},
|
||||
{
|
||||
"name": "체크 밸브 (주조)",
|
||||
"dat_file": "CHK_FL_150",
|
||||
"description": "CHECK VALVE, SWING TYPE, FLANGED, 150LB, WCB",
|
||||
"main_nom": "8\""
|
||||
},
|
||||
{
|
||||
"name": "체크 밸브 (단조)",
|
||||
"dat_file": "CHK_SW_3000",
|
||||
"description": "CHECK VALVE, LIFT TYPE, SW, 3000LB, A105",
|
||||
"main_nom": "1\""
|
||||
},
|
||||
{
|
||||
"name": "니들 밸브 (단조)",
|
||||
"dat_file": "NEEDLE_THD_6000",
|
||||
"description": "NEEDLE VALVE, FORGED, THD, 6000LB, SS316",
|
||||
"main_nom": "1/2\""
|
||||
},
|
||||
{
|
||||
"name": "버터플라이 밸브",
|
||||
"dat_file": "BUTTERFLY_WAF_150",
|
||||
"description": "BUTTERFLY VALVE, WAFER TYPE, 150LB, GEAR OPERATED",
|
||||
"main_nom": "12\""
|
||||
},
|
||||
{
|
||||
"name": "릴리프 밸브",
|
||||
"dat_file": "RELIEF_FL_150",
|
||||
"description": "RELIEF VALVE, SET PRESSURE 150 PSI, FLANGED, 150LB",
|
||||
"main_nom": "3\""
|
||||
},
|
||||
{
|
||||
"name": "솔레노이드 밸브",
|
||||
"dat_file": "SOLENOID_THD",
|
||||
"description": "SOLENOID VALVE, 2-WAY, 24VDC, 1/4 NPT",
|
||||
"main_nom": "1/4\""
|
||||
}
|
||||
]
|
||||
|
||||
print("🔧 VALVE 분류 테스트 시작\n")
|
||||
print("=" * 80)
|
||||
|
||||
for i, test in enumerate(test_cases, 1):
|
||||
print(f"\n테스트 {i}: {test['name']}")
|
||||
print("-" * 60)
|
||||
|
||||
result = classify_valve(
|
||||
test["dat_file"],
|
||||
test["description"],
|
||||
test["main_nom"]
|
||||
)
|
||||
|
||||
purchase_info = get_valve_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['valve_type']['type']}")
|
||||
print(f" 특성: {result['valve_type']['characteristics']}")
|
||||
print(f" 연결방식: {result['connection_method']['method']}")
|
||||
print(f" 압력등급: {result['pressure_rating']['rating']}")
|
||||
print(f" 작동방식: {result['actuation']['method']}")
|
||||
print(f" 제작방법: {result['manufacturing']['method']} ({'🔨 단조' if is_forged_valve(result) else '🏭 주조'})")
|
||||
|
||||
if result['special_features']:
|
||||
print(f" 특수기능: {', '.join(result['special_features'])}")
|
||||
|
||||
print(f"\n📊 신뢰도:")
|
||||
print(f" 전체신뢰도: {result['overall_confidence']}")
|
||||
print(f" 재질: {result['material']['confidence']}")
|
||||
print(f" 밸브타입: {result['valve_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']}")
|
||||
if purchase_info['special_requirements']:
|
||||
print(f" 특수요구사항: {', '.join(purchase_info['special_requirements'])}")
|
||||
|
||||
print(f"\n💾 저장될 데이터:")
|
||||
print(f" VALVE_TYPE: {result['valve_type']['type']}")
|
||||
print(f" CONNECTION: {result['connection_method']['method']}")
|
||||
print(f" PRESSURE_RATING: {result['pressure_rating']['rating']}")
|
||||
print(f" MANUFACTURING: {result['manufacturing']['method']}")
|
||||
print(f" ACTUATION: {result['actuation']['method']}")
|
||||
|
||||
if i < len(test_cases):
|
||||
print("\n" + "=" * 80)
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_valve_classification()
|
||||
759
tkeg/api/app/services/valve_classifier.py
Normal file
759
tkeg/api/app/services/valve_classifier.py
Normal file
@@ -0,0 +1,759 @@
|
||||
"""
|
||||
VALVE 분류 시스템
|
||||
주조 밸브 + 단조 밸브 구분 포함
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from .material_classifier import classify_material, get_manufacturing_method_from_material
|
||||
|
||||
# ========== 밸브 타입별 분류 ==========
|
||||
VALVE_TYPES = {
|
||||
"GATE_VALVE": {
|
||||
"dat_file_patterns": ["GATE_", "GV_"],
|
||||
"description_keywords": ["GATE VALVE", "GATE", "게이트"],
|
||||
"characteristics": "완전 개폐용, 직선 유로",
|
||||
"typical_connections": ["FLANGED", "SOCKET_WELD", "BUTT_WELD"],
|
||||
"pressure_range": "150LB ~ 2500LB",
|
||||
"special_features": ["OS&Y", "RS", "NRS"]
|
||||
},
|
||||
|
||||
"BALL_VALVE": {
|
||||
"dat_file_patterns": ["BALL_", "BV_"],
|
||||
"description_keywords": ["BALL VALVE", "BALL", "볼밸브"],
|
||||
"characteristics": "빠른 개폐, 낮은 압력손실",
|
||||
"typical_connections": ["FLANGED", "THREADED", "SOCKET_WELD"],
|
||||
"pressure_range": "150LB ~ 6000LB",
|
||||
"special_features": ["FULL_PORT", "REDUCED_PORT", "3_WAY", "4_WAY"]
|
||||
},
|
||||
|
||||
"GLOBE_VALVE": {
|
||||
"dat_file_patterns": ["GLOBE_", "GLV_"],
|
||||
"description_keywords": ["GLOBE VALVE", "GLOBE", "글로브"],
|
||||
"characteristics": "유량 조절용, 정밀 제어",
|
||||
"typical_connections": ["FLANGED", "SOCKET_WELD", "BUTT_WELD"],
|
||||
"pressure_range": "150LB ~ 2500LB",
|
||||
"special_features": ["ANGLE_TYPE", "Y_TYPE"]
|
||||
},
|
||||
|
||||
"CHECK_VALVE": {
|
||||
"dat_file_patterns": ["CHK_", "CHECK_", "CV_"],
|
||||
"description_keywords": ["CHECK VALVE", "CHECK", "체크", "역지"],
|
||||
"characteristics": "역류 방지용",
|
||||
"typical_connections": ["FLANGED", "SOCKET_WELD", "WAFER"],
|
||||
"pressure_range": "150LB ~ 2500LB",
|
||||
"special_features": ["SWING_TYPE", "LIFT_TYPE", "DUAL_PLATE", "PISTON_TYPE"]
|
||||
},
|
||||
|
||||
"BUTTERFLY_VALVE": {
|
||||
"dat_file_patterns": ["BUTTERFLY_", "BFV_"],
|
||||
"description_keywords": ["BUTTERFLY VALVE", "BUTTERFLY", "버터플라이"],
|
||||
"characteristics": "대구경용, 경량",
|
||||
"typical_connections": ["WAFER", "LUG", "FLANGED"],
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"special_features": ["GEAR_OPERATED", "LEVER_OPERATED"]
|
||||
},
|
||||
|
||||
"NEEDLE_VALVE": {
|
||||
"dat_file_patterns": ["NEEDLE_", "NV_"],
|
||||
"description_keywords": ["NEEDLE VALVE", "NEEDLE", "니들"],
|
||||
"characteristics": "정밀 유량 조절용",
|
||||
"typical_connections": ["THREADED", "SOCKET_WELD"],
|
||||
"pressure_range": "800LB ~ 6000LB",
|
||||
"special_features": ["FINE_ADJUSTMENT"],
|
||||
"typically_forged": True
|
||||
},
|
||||
|
||||
"RELIEF_VALVE": {
|
||||
"dat_file_patterns": ["RELIEF_", "RV_", "PSV_"],
|
||||
"description_keywords": ["RELIEF VALVE", "PRESSURE RELIEF", "SAFETY VALVE", "PSV", "압력 안전", "릴리프"],
|
||||
"characteristics": "안전 압력 방출용",
|
||||
"typical_connections": ["FLANGED", "THREADED"],
|
||||
"pressure_range": "150LB ~ 2500LB",
|
||||
"special_features": ["SET_PRESSURE", "PILOT_OPERATED"]
|
||||
},
|
||||
|
||||
"SOLENOID_VALVE": {
|
||||
"dat_file_patterns": ["SOLENOID_", "SOL_"],
|
||||
"description_keywords": ["SOLENOID VALVE", "SOLENOID", "솔레노이드"],
|
||||
"characteristics": "전기 제어용",
|
||||
"typical_connections": ["THREADED"],
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"special_features": ["2_WAY", "3_WAY", "NC", "NO"]
|
||||
},
|
||||
|
||||
"PLUG_VALVE": {
|
||||
"dat_file_patterns": ["PLUG_", "PV_"],
|
||||
"description_keywords": ["PLUG VALVE", "PLUG", "플러그"],
|
||||
"characteristics": "다방향 제어용",
|
||||
"typical_connections": ["FLANGED", "THREADED"],
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"special_features": ["LUBRICATED", "NON_LUBRICATED"]
|
||||
},
|
||||
|
||||
"SIGHT_GLASS": {
|
||||
"dat_file_patterns": ["SIGHT_", "SG_"],
|
||||
"description_keywords": ["SIGHT GLASS", "SIGHT", "사이트글라스", "사이트 글라스"],
|
||||
"characteristics": "유체 확인용 관찰창",
|
||||
"typical_connections": ["FLANGED", "THREADED"],
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"special_features": ["TRANSPARENT", "VISUAL_INSPECTION"]
|
||||
},
|
||||
|
||||
"STRAINER": {
|
||||
"dat_file_patterns": ["STRAINER_", "STR_"],
|
||||
"description_keywords": ["STRAINER", "스트레이너", "여과기"],
|
||||
"characteristics": "이물질 여과용",
|
||||
"typical_connections": ["FLANGED", "THREADED"],
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"special_features": ["MESH_FILTER", "Y_TYPE", "BASKET_TYPE"]
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 연결 방식별 분류 ==========
|
||||
VALVE_CONNECTIONS = {
|
||||
"FLANGED": {
|
||||
"codes": ["FL", "FLANGED", "플랜지"],
|
||||
"dat_patterns": ["_FL_"],
|
||||
"size_range": "1\" ~ 48\"",
|
||||
"pressure_range": "150LB ~ 2500LB",
|
||||
"manufacturing": "CAST",
|
||||
"confidence": 0.95
|
||||
},
|
||||
"THREADED": {
|
||||
"codes": ["THD", "THRD", "NPT", "THREADED", "나사"],
|
||||
"dat_patterns": ["_THD_", "_TR_"],
|
||||
"size_range": "1/4\" ~ 4\"",
|
||||
"pressure_range": "150LB ~ 6000LB",
|
||||
"manufacturing": "FORGED_OR_CAST",
|
||||
"confidence": 0.95
|
||||
},
|
||||
"SOCKET_WELD": {
|
||||
"codes": ["SW", "SOCKET WELD", "소켓웰드"],
|
||||
"dat_patterns": ["_SW_"],
|
||||
"size_range": "1/4\" ~ 4\"",
|
||||
"pressure_range": "800LB ~ 9000LB",
|
||||
"manufacturing": "FORGED",
|
||||
"confidence": 0.95
|
||||
},
|
||||
"BUTT_WELD": {
|
||||
"codes": ["BW", "BUTT WELD", "맞대기용접"],
|
||||
"dat_patterns": ["_BW_"],
|
||||
"size_range": "1/2\" ~ 48\"",
|
||||
"pressure_range": "150LB ~ 2500LB",
|
||||
"manufacturing": "CAST_OR_FORGED",
|
||||
"confidence": 0.95
|
||||
},
|
||||
"WAFER": {
|
||||
"codes": ["WAFER", "WAFER TYPE", "웨이퍼"],
|
||||
"dat_patterns": ["_WAF_"],
|
||||
"size_range": "2\" ~ 48\"",
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"manufacturing": "CAST",
|
||||
"confidence": 0.9
|
||||
},
|
||||
"LUG": {
|
||||
"codes": ["LUG", "LUG TYPE", "러그"],
|
||||
"dat_patterns": ["_LUG_"],
|
||||
"size_range": "2\" ~ 48\"",
|
||||
"pressure_range": "150LB ~ 600LB",
|
||||
"manufacturing": "CAST",
|
||||
"confidence": 0.9
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 작동 방식별 분류 ==========
|
||||
VALVE_ACTUATION = {
|
||||
"MANUAL": {
|
||||
"keywords": ["MANUAL", "HAND WHEEL", "LEVER", "수동"],
|
||||
"characteristics": "수동 조작",
|
||||
"applications": "일반 개폐용"
|
||||
},
|
||||
"GEAR_OPERATED": {
|
||||
"keywords": ["GEAR OPERATED", "GEAR", "기어"],
|
||||
"characteristics": "기어 구동",
|
||||
"applications": "대구경 수동 조작"
|
||||
},
|
||||
"PNEUMATIC": {
|
||||
"keywords": ["PNEUMATIC", "AIR OPERATED", "공압"],
|
||||
"characteristics": "공압 구동",
|
||||
"applications": "자동 제어"
|
||||
},
|
||||
"ELECTRIC": {
|
||||
"keywords": ["ELECTRIC", "MOTOR OPERATED", "전동"],
|
||||
"characteristics": "전동 구동",
|
||||
"applications": "원격 제어"
|
||||
},
|
||||
"SOLENOID": {
|
||||
"keywords": ["SOLENOID", "솔레노이드"],
|
||||
"characteristics": "전자 밸브",
|
||||
"applications": "빠른 전기 제어"
|
||||
}
|
||||
}
|
||||
|
||||
# ========== 압력 등급별 분류 ==========
|
||||
VALVE_PRESSURE_RATINGS = {
|
||||
"patterns": [
|
||||
r"(\d+)LB",
|
||||
r"CLASS\s*(\d+)",
|
||||
r"CL\s*(\d+)",
|
||||
r"(\d+)#",
|
||||
r"(\d+)\s*WOG" # Water Oil Gas
|
||||
],
|
||||
"standard_ratings": {
|
||||
"150LB": {"max_pressure": "285 PSI", "typical_manufacturing": "CAST"},
|
||||
"300LB": {"max_pressure": "740 PSI", "typical_manufacturing": "CAST"},
|
||||
"600LB": {"max_pressure": "1480 PSI", "typical_manufacturing": "CAST_OR_FORGED"},
|
||||
"800LB": {"max_pressure": "2000 PSI", "typical_manufacturing": "FORGED"},
|
||||
"900LB": {"max_pressure": "2220 PSI", "typical_manufacturing": "CAST_OR_FORGED"},
|
||||
"1500LB": {"max_pressure": "3705 PSI", "typical_manufacturing": "FORGED"},
|
||||
"2500LB": {"max_pressure": "6170 PSI", "typical_manufacturing": "FORGED"},
|
||||
"3000LB": {"max_pressure": "7400 PSI", "typical_manufacturing": "FORGED"},
|
||||
"6000LB": {"max_pressure": "14800 PSI", "typical_manufacturing": "FORGED"},
|
||||
"9000LB": {"max_pressure": "22200 PSI", "typical_manufacturing": "FORGED"}
|
||||
}
|
||||
}
|
||||
|
||||
def classify_valve(dat_file: str, description: str, main_nom: str, length: float = None) -> Dict:
|
||||
"""
|
||||
완전한 VALVE 분류
|
||||
|
||||
Args:
|
||||
dat_file: DAT_FILE 필드
|
||||
description: DESCRIPTION 필드
|
||||
main_nom: MAIN_NOM 필드 (사이즈)
|
||||
|
||||
Returns:
|
||||
완전한 밸브 분류 결과
|
||||
"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
dat_upper = dat_file.upper()
|
||||
|
||||
# 1. 사이트 글라스와 스트레이너 우선 확인
|
||||
if 'SIGHT GLASS' in desc_upper or 'STRAINER' in desc_upper or '사이트글라스' in desc_upper or '스트레이너' in desc_upper:
|
||||
# 사이트 글라스와 스트레이너는 항상 밸브로 분류
|
||||
pass
|
||||
|
||||
# 밸브 키워드 확인 (재질만 있어도 통합 분류기가 이미 밸브로 분류했으므로 진행)
|
||||
valve_keywords = ['VALVE', 'GATE', 'BALL', 'GLOBE', 'CHECK', 'BUTTERFLY', 'NEEDLE', 'RELIEF', 'SOLENOID', 'SIGHT GLASS', 'STRAINER', '밸브', '게이트', '볼', '글로브', '체크', '버터플라이', '니들', '릴리프', '솔레노이드', '사이트글라스', '스트레이너']
|
||||
has_valve_keyword = any(keyword in desc_upper or keyword in dat_upper for keyword in valve_keywords)
|
||||
|
||||
# 밸브 재질 확인 (A216, A217, A351, A352)
|
||||
valve_materials = ['A216', 'A217', 'A351', 'A352']
|
||||
has_valve_material = any(material in desc_upper for material in valve_materials)
|
||||
|
||||
# 밸브 키워드도 없고 밸브 재질도 없으면 UNKNOWN
|
||||
if not has_valve_keyword and not has_valve_material:
|
||||
return {
|
||||
"category": "UNKNOWN",
|
||||
"overall_confidence": 0.0,
|
||||
"reason": "밸브 키워드 및 재질 없음"
|
||||
}
|
||||
|
||||
# 2. 재질 분류 (공통 모듈 사용)
|
||||
material_result = classify_material(description)
|
||||
|
||||
# 2. 밸브 타입 분류
|
||||
valve_type_result = classify_valve_type(dat_file, description)
|
||||
|
||||
# 3. 연결 방식 분류
|
||||
connection_result = classify_valve_connection(dat_file, description)
|
||||
|
||||
# 4. 압력 등급 분류
|
||||
pressure_result = classify_valve_pressure_rating(dat_file, description)
|
||||
|
||||
# 5. 작동 방식 분류
|
||||
actuation_result = classify_valve_actuation(description)
|
||||
|
||||
# 6. 제작 방법 결정 (주조 vs 단조)
|
||||
manufacturing_result = determine_valve_manufacturing(
|
||||
material_result, valve_type_result, connection_result,
|
||||
pressure_result, main_nom
|
||||
)
|
||||
|
||||
# 7. 특수 기능 추출
|
||||
special_features = extract_valve_special_features(description, valve_type_result)
|
||||
|
||||
# 8. 최종 결과 조합
|
||||
return {
|
||||
"category": "VALVE",
|
||||
|
||||
# 재질 정보 (공통 모듈)
|
||||
"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)
|
||||
},
|
||||
|
||||
# 밸브 분류 정보
|
||||
"valve_type": {
|
||||
"type": valve_type_result.get('type', 'UNKNOWN'),
|
||||
"characteristics": valve_type_result.get('characteristics', ''),
|
||||
"confidence": valve_type_result.get('confidence', 0.0),
|
||||
"evidence": valve_type_result.get('evidence', [])
|
||||
},
|
||||
|
||||
"connection_method": {
|
||||
"method": connection_result.get('method', 'UNKNOWN'),
|
||||
"confidence": connection_result.get('confidence', 0.0),
|
||||
"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', ''),
|
||||
"typical_manufacturing": pressure_result.get('typical_manufacturing', '')
|
||||
},
|
||||
|
||||
"actuation": {
|
||||
"method": actuation_result.get('method', 'MANUAL'),
|
||||
"characteristics": actuation_result.get('characteristics', ''),
|
||||
"confidence": actuation_result.get('confidence', 0.6)
|
||||
},
|
||||
|
||||
"manufacturing": {
|
||||
"method": manufacturing_result.get('method', 'UNKNOWN'),
|
||||
"confidence": manufacturing_result.get('confidence', 0.0),
|
||||
"evidence": manufacturing_result.get('evidence', []),
|
||||
"characteristics": manufacturing_result.get('characteristics', '')
|
||||
},
|
||||
|
||||
"special_features": special_features,
|
||||
|
||||
"size_info": {
|
||||
"valve_size": main_nom,
|
||||
"size_description": main_nom
|
||||
},
|
||||
|
||||
# 전체 신뢰도
|
||||
"overall_confidence": calculate_valve_confidence({
|
||||
"material": material_result.get('confidence', 0),
|
||||
"valve_type": valve_type_result.get('confidence', 0),
|
||||
"connection": connection_result.get('confidence', 0),
|
||||
"pressure": pressure_result.get('confidence', 0)
|
||||
})
|
||||
}
|
||||
|
||||
def classify_valve_type(dat_file: str, description: str) -> Dict:
|
||||
"""밸브 타입 분류"""
|
||||
|
||||
dat_upper = dat_file.upper()
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 1. DAT_FILE 패턴으로 1차 분류 (가장 신뢰도 높음)
|
||||
for valve_type, type_data in VALVE_TYPES.items():
|
||||
for pattern in type_data["dat_file_patterns"]:
|
||||
if pattern in dat_upper:
|
||||
return {
|
||||
"type": valve_type,
|
||||
"characteristics": type_data["characteristics"],
|
||||
"confidence": 0.95,
|
||||
"evidence": [f"DAT_FILE_PATTERN: {pattern}"],
|
||||
"typical_connections": type_data["typical_connections"],
|
||||
"special_features": type_data.get("special_features", [])
|
||||
}
|
||||
|
||||
# 2. DESCRIPTION 키워드로 2차 분류
|
||||
for valve_type, type_data in VALVE_TYPES.items():
|
||||
for keyword in type_data["description_keywords"]:
|
||||
if keyword in desc_upper:
|
||||
return {
|
||||
"type": valve_type,
|
||||
"characteristics": type_data["characteristics"],
|
||||
"confidence": 0.85,
|
||||
"evidence": [f"DESCRIPTION_KEYWORD: {keyword}"],
|
||||
"typical_connections": type_data["typical_connections"],
|
||||
"special_features": type_data.get("special_features", [])
|
||||
}
|
||||
|
||||
# 3. 분류 실패
|
||||
return {
|
||||
"type": "UNKNOWN",
|
||||
"characteristics": "",
|
||||
"confidence": 0.0,
|
||||
"evidence": ["NO_VALVE_TYPE_IDENTIFIED"],
|
||||
"typical_connections": [],
|
||||
"special_features": []
|
||||
}
|
||||
|
||||
def classify_valve_connection(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 connection_type, conn_data in VALVE_CONNECTIONS.items():
|
||||
for pattern in conn_data["dat_patterns"]:
|
||||
if pattern in dat_upper:
|
||||
return {
|
||||
"method": connection_type,
|
||||
"confidence": 0.95,
|
||||
"matched_pattern": pattern,
|
||||
"source": "DAT_FILE_PATTERN",
|
||||
"size_range": conn_data["size_range"],
|
||||
"pressure_range": conn_data["pressure_range"],
|
||||
"typical_manufacturing": conn_data["manufacturing"]
|
||||
}
|
||||
|
||||
# 2. 키워드 확인
|
||||
for connection_type, conn_data in VALVE_CONNECTIONS.items():
|
||||
for code in conn_data["codes"]:
|
||||
if code in combined_text:
|
||||
return {
|
||||
"method": connection_type,
|
||||
"confidence": conn_data["confidence"],
|
||||
"matched_code": code,
|
||||
"source": "KEYWORD_MATCH",
|
||||
"size_range": conn_data["size_range"],
|
||||
"pressure_range": conn_data["pressure_range"],
|
||||
"typical_manufacturing": conn_data["manufacturing"]
|
||||
}
|
||||
|
||||
return {
|
||||
"method": "UNKNOWN",
|
||||
"confidence": 0.0,
|
||||
"matched_code": "",
|
||||
"source": "NO_CONNECTION_METHOD_FOUND"
|
||||
}
|
||||
|
||||
def classify_valve_pressure_rating(dat_file: str, description: str) -> Dict:
|
||||
"""밸브 압력 등급 분류"""
|
||||
|
||||
combined_text = f"{dat_file} {description}".upper()
|
||||
|
||||
# 패턴 매칭으로 압력 등급 추출
|
||||
for pattern in VALVE_PRESSURE_RATINGS["patterns"]:
|
||||
match = re.search(pattern, combined_text)
|
||||
if match:
|
||||
rating_num = match.group(1)
|
||||
|
||||
# WOG 처리 (Water Oil Gas)
|
||||
if "WOG" in pattern:
|
||||
rating = f"{rating_num}WOG"
|
||||
# WOG를 LB로 변환 (대략적)
|
||||
if int(rating_num) <= 600:
|
||||
equivalent_lb = "150LB"
|
||||
elif int(rating_num) <= 1000:
|
||||
equivalent_lb = "300LB"
|
||||
else:
|
||||
equivalent_lb = "600LB"
|
||||
|
||||
return {
|
||||
"rating": f"{rating} ({equivalent_lb} 상당)",
|
||||
"confidence": 0.8,
|
||||
"matched_pattern": pattern,
|
||||
"max_pressure": f"{rating_num} PSI",
|
||||
"typical_manufacturing": "CAST_OR_FORGED"
|
||||
}
|
||||
else:
|
||||
rating = f"{rating_num}LB"
|
||||
|
||||
# 표준 등급 정보 확인
|
||||
rating_info = VALVE_PRESSURE_RATINGS["standard_ratings"].get(rating, {})
|
||||
|
||||
if rating_info:
|
||||
confidence = 0.95
|
||||
else:
|
||||
confidence = 0.8
|
||||
rating_info = {
|
||||
"max_pressure": "확인 필요",
|
||||
"typical_manufacturing": "UNKNOWN"
|
||||
}
|
||||
|
||||
return {
|
||||
"rating": rating,
|
||||
"confidence": confidence,
|
||||
"matched_pattern": pattern,
|
||||
"matched_value": rating_num,
|
||||
"max_pressure": rating_info.get("max_pressure", ""),
|
||||
"typical_manufacturing": rating_info.get("typical_manufacturing", "")
|
||||
}
|
||||
|
||||
return {
|
||||
"rating": "UNKNOWN",
|
||||
"confidence": 0.0,
|
||||
"matched_pattern": "",
|
||||
"max_pressure": "",
|
||||
"typical_manufacturing": ""
|
||||
}
|
||||
|
||||
def classify_valve_actuation(description: str) -> Dict:
|
||||
"""밸브 작동 방식 분류"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
|
||||
# 키워드 기반 작동 방식 분류
|
||||
for actuation_type, act_data in VALVE_ACTUATION.items():
|
||||
for keyword in act_data["keywords"]:
|
||||
if keyword in desc_upper:
|
||||
return {
|
||||
"method": actuation_type,
|
||||
"characteristics": act_data["characteristics"],
|
||||
"confidence": 0.9,
|
||||
"matched_keyword": keyword,
|
||||
"applications": act_data["applications"]
|
||||
}
|
||||
|
||||
# 기본값: MANUAL
|
||||
return {
|
||||
"method": "MANUAL",
|
||||
"characteristics": "수동 조작 (기본값)",
|
||||
"confidence": 0.6,
|
||||
"matched_keyword": "DEFAULT",
|
||||
"applications": "일반 수동 조작"
|
||||
}
|
||||
|
||||
def determine_valve_manufacturing(material_result: Dict, valve_type_result: Dict,
|
||||
connection_result: Dict, pressure_result: Dict,
|
||||
main_nom: str) -> Dict:
|
||||
"""밸브 제작 방법 결정 (주조 vs 단조)"""
|
||||
|
||||
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. 단조 밸브 조건 확인
|
||||
forged_indicators = 0
|
||||
|
||||
# 연결방식이 소켓웰드
|
||||
connection_method = connection_result.get('method', '')
|
||||
if connection_method == "SOCKET_WELD":
|
||||
forged_indicators += 2
|
||||
evidence.append(f"SOCKET_WELD_CONNECTION")
|
||||
|
||||
# 고압 등급
|
||||
pressure_rating = pressure_result.get('rating', '')
|
||||
high_pressure = ["800LB", "1500LB", "2500LB", "3000LB", "6000LB", "9000LB"]
|
||||
if any(pressure in pressure_rating for pressure in high_pressure):
|
||||
forged_indicators += 2
|
||||
evidence.append(f"HIGH_PRESSURE: {pressure_rating}")
|
||||
|
||||
# 소구경
|
||||
try:
|
||||
size_num = float(re.findall(r'(\d+(?:\.\d+)?)', main_nom)[0])
|
||||
if size_num <= 4.0:
|
||||
forged_indicators += 1
|
||||
evidence.append(f"SMALL_SIZE: {main_nom}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# 니들 밸브는 일반적으로 단조
|
||||
valve_type = valve_type_result.get('type', '')
|
||||
if valve_type == "NEEDLE_VALVE":
|
||||
forged_indicators += 2
|
||||
evidence.append("NEEDLE_VALVE_TYPICALLY_FORGED")
|
||||
|
||||
# 단조 결정
|
||||
if forged_indicators >= 3:
|
||||
return {
|
||||
"method": "FORGED",
|
||||
"confidence": 0.85,
|
||||
"evidence": evidence,
|
||||
"characteristics": "단조품 - 고압, 소구경용"
|
||||
}
|
||||
|
||||
# 3. 압력등급별 일반적 제작방법
|
||||
pressure_manufacturing = pressure_result.get('typical_manufacturing', '')
|
||||
if pressure_manufacturing:
|
||||
if pressure_manufacturing == "FORGED":
|
||||
evidence.append(f"PRESSURE_BASED: {pressure_rating}")
|
||||
return {
|
||||
"method": "FORGED",
|
||||
"confidence": 0.75,
|
||||
"evidence": evidence,
|
||||
"characteristics": "고압용 단조품"
|
||||
}
|
||||
elif pressure_manufacturing == "CAST":
|
||||
evidence.append(f"PRESSURE_BASED: {pressure_rating}")
|
||||
return {
|
||||
"method": "CAST",
|
||||
"confidence": 0.75,
|
||||
"evidence": evidence,
|
||||
"characteristics": "저중압용 주조품"
|
||||
}
|
||||
|
||||
# 4. 연결방식별 일반적 제작방법
|
||||
connection_manufacturing = connection_result.get('typical_manufacturing', '')
|
||||
if connection_manufacturing:
|
||||
evidence.append(f"CONNECTION_BASED: {connection_method}")
|
||||
|
||||
if connection_manufacturing == "FORGED":
|
||||
return {
|
||||
"method": "FORGED",
|
||||
"confidence": 0.7,
|
||||
"evidence": evidence,
|
||||
"characteristics": "소구경 단조품"
|
||||
}
|
||||
elif connection_manufacturing == "CAST":
|
||||
return {
|
||||
"method": "CAST",
|
||||
"confidence": 0.7,
|
||||
"evidence": evidence,
|
||||
"characteristics": "대구경 주조품"
|
||||
}
|
||||
|
||||
# 5. 기본 추정 (사이즈 기반)
|
||||
try:
|
||||
size_num = float(re.findall(r'(\d+(?:\.\d+)?)', main_nom)[0])
|
||||
if size_num <= 2.0:
|
||||
return {
|
||||
"method": "FORGED",
|
||||
"confidence": 0.6,
|
||||
"evidence": ["SIZE_BASED_SMALL"],
|
||||
"characteristics": "소구경 - 일반적으로 단조품"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"method": "CAST",
|
||||
"confidence": 0.6,
|
||||
"evidence": ["SIZE_BASED_LARGE"],
|
||||
"characteristics": "대구경 - 일반적으로 주조품"
|
||||
}
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"method": "UNKNOWN",
|
||||
"confidence": 0.0,
|
||||
"evidence": ["INSUFFICIENT_MANUFACTURING_INFO"],
|
||||
"characteristics": ""
|
||||
}
|
||||
|
||||
def extract_valve_special_features(description: str, valve_type_result: Dict) -> List[str]:
|
||||
"""밸브 특수 기능 추출"""
|
||||
|
||||
desc_upper = description.upper()
|
||||
features = []
|
||||
|
||||
# 밸브 타입별 특수 기능
|
||||
valve_special_features = valve_type_result.get('special_features', [])
|
||||
for feature in valve_special_features:
|
||||
# 기능별 키워드 매핑
|
||||
feature_keywords = {
|
||||
"OS&Y": ["OS&Y", "OUTSIDE SCREW"],
|
||||
"FULL_PORT": ["FULL PORT", "FULL BORE"],
|
||||
"REDUCED_PORT": ["REDUCED PORT", "REDUCED BORE"],
|
||||
"3_WAY": ["3 WAY", "3-WAY", "THREE WAY"],
|
||||
"SWING_TYPE": ["SWING", "SWING TYPE"],
|
||||
"LIFT_TYPE": ["LIFT", "LIFT TYPE"],
|
||||
"GEAR_OPERATED": ["GEAR OPERATED", "GEAR"],
|
||||
"SET_PRESSURE": ["SET PRESSURE", "SET @"]
|
||||
}
|
||||
|
||||
keywords = feature_keywords.get(feature, [feature])
|
||||
for keyword in keywords:
|
||||
if keyword in desc_upper:
|
||||
features.append(feature)
|
||||
break
|
||||
|
||||
# 일반적인 특수 기능들
|
||||
general_features = {
|
||||
"FIRE_SAFE": ["FIRE SAFE", "FIRE-SAFE"],
|
||||
"ANTI_STATIC": ["ANTI STATIC", "ANTI-STATIC"],
|
||||
"BLOW_OUT_PROOF": ["BLOW OUT PROOF", "BOP"],
|
||||
"EXTENDED_STEM": ["EXTENDED STEM", "EXT STEM"],
|
||||
"CRYOGENIC": ["CRYOGENIC", "CRYO"],
|
||||
"HIGH_TEMPERATURE": ["HIGH TEMP", "HT"]
|
||||
}
|
||||
|
||||
for feature, keywords in general_features.items():
|
||||
for keyword in keywords:
|
||||
if keyword in desc_upper:
|
||||
features.append(feature)
|
||||
break
|
||||
|
||||
return list(set(features)) # 중복 제거
|
||||
|
||||
def calculate_valve_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.2,
|
||||
"valve_type": 0.4,
|
||||
"connection": 0.25,
|
||||
"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_forged_valve(valve_result: Dict) -> bool:
|
||||
"""단조 밸브 여부 판단"""
|
||||
return valve_result.get("manufacturing", {}).get("method") == "FORGED"
|
||||
|
||||
def is_high_pressure_valve(valve_result: Dict) -> bool:
|
||||
"""고압 밸브 여부 판단"""
|
||||
pressure_rating = valve_result.get("pressure_rating", {}).get("rating", "")
|
||||
high_pressure_ratings = ["800LB", "1500LB", "2500LB", "3000LB", "6000LB", "9000LB"]
|
||||
return any(pressure in pressure_rating for pressure in high_pressure_ratings)
|
||||
|
||||
def get_valve_purchase_info(valve_result: Dict) -> Dict:
|
||||
"""밸브 구매 정보 생성"""
|
||||
|
||||
valve_type = valve_result["valve_type"]["type"]
|
||||
connection = valve_result["connection_method"]["method"]
|
||||
pressure = valve_result["pressure_rating"]["rating"]
|
||||
manufacturing = valve_result["manufacturing"]["method"]
|
||||
actuation = valve_result["actuation"]["method"]
|
||||
|
||||
# 공급업체 타입 결정
|
||||
if manufacturing == "FORGED":
|
||||
supplier_type = "단조 밸브 전문업체"
|
||||
elif valve_type == "BUTTERFLY_VALVE":
|
||||
supplier_type = "버터플라이 밸브 전문업체"
|
||||
elif actuation in ["PNEUMATIC", "ELECTRIC"]:
|
||||
supplier_type = "자동 밸브 전문업체"
|
||||
else:
|
||||
supplier_type = "일반 밸브 업체"
|
||||
|
||||
# 납기 추정
|
||||
if manufacturing == "FORGED" and is_high_pressure_valve(valve_result):
|
||||
lead_time = "8-12주 (단조 고압용)"
|
||||
elif actuation in ["PNEUMATIC", "ELECTRIC"]:
|
||||
lead_time = "6-10주 (자동 밸브)"
|
||||
elif manufacturing == "FORGED":
|
||||
lead_time = "6-8주 (단조품)"
|
||||
else:
|
||||
lead_time = "4-8주 (일반품)"
|
||||
|
||||
return {
|
||||
"supplier_type": supplier_type,
|
||||
"lead_time_estimate": lead_time,
|
||||
"purchase_category": f"{valve_type} {connection} {pressure}",
|
||||
"manufacturing_note": valve_result["manufacturing"]["characteristics"],
|
||||
"actuation_note": valve_result["actuation"]["characteristics"],
|
||||
"special_requirements": valve_result["special_features"]
|
||||
}
|
||||
Reference in New Issue
Block a user