""" PIPE 시스템 공통 유틸리티 모든 PIPE 관련 서비스에서 공통으로 사용되는 함수들을 모아놓은 유틸리티 모듈 """ import logging import math from typing import Dict, List, Optional, Any, Tuple from decimal import Decimal from sqlalchemy.orm import Session from sqlalchemy import text logger = logging.getLogger(__name__) # ========== PIPE 상수 정의 ========== class PipeConstants: """PIPE 시스템에서 사용되는 상수들""" # 길이 관련 STANDARD_PIPE_LENGTH_MM = 6000 # 표준 파이프 길이 (6M) CUTTING_LOSS_PER_CUT_MM = 2 # 절단당 손실 (2mm) # 분류 관련 PIPE_CATEGORY = "PIPE" # 끝단 처리 타입 END_PREPARATION_TYPES = { "무개선": "PLAIN", "한개선": "SINGLE_BEVEL", "양개선": "DOUBLE_BEVEL" } # 상태 관련 REVISION_TYPES = { "NO_REVISION": "no_revision", "PRE_CUTTING_PLAN": "pre_cutting_plan", "POST_CUTTING_PLAN": "post_cutting_plan" } CHANGE_TYPES = { "ADDED": "added", "REMOVED": "removed", "MODIFIED": "modified", "UNCHANGED": "unchanged" } # ========== 데이터 추출 유틸리티 ========== class PipeDataExtractor: """PIPE 데이터 추출 관련 유틸리티""" @staticmethod def extract_pipe_materials_from_file(db: Session, file_id: int) -> List[Dict[str, Any]]: """ 파일에서 PIPE 자재 데이터 추출 Args: db: 데이터베이스 세션 file_id: 파일 ID Returns: PIPE 자재 리스트 """ try: query = text(""" SELECT m.id, m.drawing_name, m.line_no, m.description, m.classified_category, m.full_material_grade, m.main_nom, m.red_nom, m.length, m.total_length, m.quantity, m.row_number, m.original_description FROM materials m WHERE m.file_id = :file_id AND m.classified_category = 'PIPE' AND m.is_active = true ORDER BY m.drawing_name, m.line_no, m.row_number """) result = db.execute(query, {"file_id": file_id}) materials = [] for row in result: materials.append({ "id": row.id, "drawing_name": row.drawing_name or "UNKNOWN", "line_no": row.line_no or "", "description": row.description or "", "original_description": row.original_description or "", "material_grade": row.full_material_grade or "UNKNOWN", "main_nom": row.main_nom or "", "red_nom": row.red_nom or "", "length": float(row.length or 0), "total_length": float(row.total_length or 0), "quantity": int(row.quantity or 1), "row_number": row.row_number or 0 }) logger.info(f"✅ {len(materials)}개 PIPE 자재 추출 완료 (파일 ID: {file_id})") return materials except Exception as e: logger.error(f"❌ PIPE 자재 추출 실패: {e}") raise @staticmethod def parse_pipe_description(description: str) -> Dict[str, Any]: """ PIPE 설명에서 정보 추출 Args: description: 자재 설명 Returns: 추출된 정보 딕셔너리 """ # 기본값 설정 result = { "material_grade": "UNKNOWN", "schedule": "UNKNOWN", "nominal_size": "UNKNOWN", "length_info": None, "end_preparation": "무개선" } if not description: return result # 간단한 파싱 로직 (실제로는 더 복잡할 수 있음) description_upper = description.upper() # 재질 추출 (A106, A53 등) if "A106" in description_upper: result["material_grade"] = "A106 GR.B" elif "A53" in description_upper: result["material_grade"] = "A53 GR.B" # 스케줄 추출 (SCH40, SCH80 등) if "SCH40" in description_upper: result["schedule"] = "SCH40" elif "SCH80" in description_upper: result["schedule"] = "SCH80" # 끝단 처리 추출 if "양개선" in description or "DOUBLE" in description_upper: result["end_preparation"] = "양개선" elif "한개선" in description or "SINGLE" in description_upper: result["end_preparation"] = "한개선" return result # ========== 계산 유틸리티 ========== class PipeCalculator: """PIPE 관련 계산 유틸리티""" @staticmethod def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict[str, Any]: """ PIPE 구매 수량 계산 Args: materials: PIPE 자재 리스트 Returns: 계산 결과 """ total_bom_length = 0 cutting_count = 0 pipe_details = [] for material in materials: # 길이 정보 추출 (Decimal 타입 처리) length_mm = float(material.get('length', 0) or 0) if not length_mm: 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('description', ''), 'original_description': material.get('original_description', ''), 'drawing_name': material.get('drawing_name', ''), 'line_no': material.get('line_no', ''), 'length_mm': length_mm, 'quantity': quantity, 'total_length': total_length }) # 절단 손실 계산 cutting_loss = cutting_count * PipeConstants.CUTTING_LOSS_PER_CUT_MM # 총 필요 길이 required_length = total_bom_length + cutting_loss # 6M 단위로 올림 계산 pipes_needed = math.ceil(required_length / PipeConstants.STANDARD_PIPE_LENGTH_MM) if required_length > 0 else 0 total_purchase_length = pipes_needed * PipeConstants.STANDARD_PIPE_LENGTH_MM 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, 'summary': { 'total_materials': len(materials), 'total_drawings': len(set(m.get('drawing_name', '') for m in materials if m.get('drawing_name'))), 'average_length': total_bom_length / len(materials) if materials else 0 } } @staticmethod def calculate_length_difference(old_length: float, new_length: float) -> Dict[str, Any]: """ 길이 변화량 계산 Args: old_length: 이전 길이 new_length: 새로운 길이 Returns: 변화량 정보 """ difference = new_length - old_length percentage = (difference / old_length * 100) if old_length > 0 else 0 return { 'old_length': old_length, 'new_length': new_length, 'difference': difference, 'percentage': percentage, 'change_type': 'increased' if difference > 0 else 'decreased' if difference < 0 else 'unchanged' } # ========== 비교 유틸리티 ========== class PipeComparator: """PIPE 데이터 비교 유틸리티""" @staticmethod def compare_pipe_segments(old_segments: List[Dict], new_segments: List[Dict]) -> Dict[str, Any]: """ 단관 데이터 비교 Args: old_segments: 이전 단관 데이터 new_segments: 새로운 단관 데이터 Returns: 비교 결과 """ # 키 생성 함수 def create_segment_key(segment): return ( segment.get('drawing_name', ''), segment.get('material_grade', ''), segment.get('length', 0), segment.get('end_preparation', '무개선') ) # 기존 데이터를 키로 매핑 old_map = {} for segment in old_segments: key = create_segment_key(segment) if key not in old_map: old_map[key] = [] old_map[key].append(segment) # 새로운 데이터를 키로 매핑 new_map = {} for segment in new_segments: key = create_segment_key(segment) if key not in new_map: new_map[key] = [] new_map[key].append(segment) # 비교 결과 생성 changes = { 'added': [], 'removed': [], 'modified': [], 'unchanged': [] } all_keys = set(old_map.keys()) | set(new_map.keys()) for key in all_keys: old_count = len(old_map.get(key, [])) new_count = len(new_map.get(key, [])) if old_count == 0: # 새로 추가된 항목 for segment in new_map[key]: changes['added'].append({ **segment, 'change_type': 'added', 'quantity_change': new_count }) elif new_count == 0: # 삭제된 항목 for segment in old_map[key]: changes['removed'].append({ **segment, 'change_type': 'removed', 'quantity_change': -old_count }) elif old_count != new_count: # 수량이 변경된 항목 base_segment = new_map[key][0] if new_map[key] else old_map[key][0] changes['modified'].append({ **base_segment, 'change_type': 'modified', 'old_quantity': old_count, 'new_quantity': new_count, 'quantity_change': new_count - old_count }) else: # 변경되지 않은 항목 base_segment = new_map[key][0] changes['unchanged'].append({ **base_segment, 'change_type': 'unchanged', 'quantity': old_count }) # 통계 생성 stats = { 'total_old': len(old_segments), 'total_new': len(new_segments), 'added_count': len(changes['added']), 'removed_count': len(changes['removed']), 'modified_count': len(changes['modified']), 'unchanged_count': len(changes['unchanged']), 'changed_drawings': len(set( item.get('drawing_name', '') for change_list in [changes['added'], changes['removed'], changes['modified']] for item in change_list if item.get('drawing_name') )) } return { 'changes': changes, 'statistics': stats, 'has_changes': stats['added_count'] + stats['removed_count'] + stats['modified_count'] > 0 } # ========== 검증 유틸리티 ========== class PipeValidator: """PIPE 데이터 검증 유틸리티""" @staticmethod def validate_pipe_data(pipe_data: Dict[str, Any]) -> Dict[str, Any]: """ PIPE 데이터 유효성 검증 Args: pipe_data: 검증할 PIPE 데이터 Returns: 검증 결과 """ errors = [] warnings = [] # 필수 필드 검증 required_fields = ['drawing_name', 'material_grade', 'length'] for field in required_fields: if not pipe_data.get(field): errors.append(f"필수 필드 누락: {field}") # 길이 검증 length = pipe_data.get('length', 0) if length <= 0: errors.append("길이는 0보다 커야 합니다") elif length > 20000: # 20m 초과시 경고 warnings.append(f"길이가 비정상적으로 큽니다: {length}mm") # 수량 검증 quantity = pipe_data.get('quantity', 1) if quantity <= 0: errors.append("수량은 0보다 커야 합니다") # 도면명 검증 drawing_name = pipe_data.get('drawing_name', '') if drawing_name == 'UNKNOWN': warnings.append("도면명이 지정되지 않았습니다") return { 'is_valid': len(errors) == 0, 'errors': errors, 'warnings': warnings, 'error_count': len(errors), 'warning_count': len(warnings) } @staticmethod def validate_cutting_plan_data(cutting_plan: List[Dict]) -> Dict[str, Any]: """ Cutting Plan 데이터 전체 검증 Args: cutting_plan: Cutting Plan 데이터 리스트 Returns: 검증 결과 """ total_errors = [] total_warnings = [] valid_items = 0 for i, item in enumerate(cutting_plan): validation = PipeValidator.validate_pipe_data(item) if validation['is_valid']: valid_items += 1 else: for error in validation['errors']: total_errors.append(f"항목 {i+1}: {error}") for warning in validation['warnings']: total_warnings.append(f"항목 {i+1}: {warning}") return { 'is_valid': len(total_errors) == 0, 'total_items': len(cutting_plan), 'valid_items': valid_items, 'invalid_items': len(cutting_plan) - valid_items, 'errors': total_errors, 'warnings': total_warnings, 'validation_rate': (valid_items / len(cutting_plan) * 100) if cutting_plan else 0 } # ========== 포맷팅 유틸리티 ========== class PipeFormatter: """PIPE 데이터 포맷팅 유틸리티""" @staticmethod def format_length(length_mm: float, unit: str = 'mm') -> str: """ 길이 포맷팅 Args: length_mm: 길이 (mm) unit: 표시 단위 Returns: 포맷된 길이 문자열 """ if unit == 'm': return f"{length_mm / 1000:.3f}m" elif unit == 'mm': return f"{length_mm:.0f}mm" else: return f"{length_mm}" @staticmethod def format_pipe_description(pipe_data: Dict[str, Any]) -> str: """ PIPE 설명 포맷팅 Args: pipe_data: PIPE 데이터 Returns: 포맷된 설명 """ parts = [] if pipe_data.get('material_grade'): parts.append(pipe_data['material_grade']) if pipe_data.get('main_nom'): parts.append(f"{pipe_data['main_nom']}") if pipe_data.get('schedule'): parts.append(pipe_data['schedule']) if pipe_data.get('length'): parts.append(PipeFormatter.format_length(pipe_data['length'])) return " ".join(parts) if parts else "PIPE" @staticmethod def format_change_summary(changes: Dict[str, List]) -> str: """ 변경사항 요약 포맷팅 Args: changes: 변경사항 딕셔너리 Returns: 포맷된 요약 문자열 """ summary_parts = [] if changes.get('added'): summary_parts.append(f"추가 {len(changes['added'])}개") if changes.get('removed'): summary_parts.append(f"삭제 {len(changes['removed'])}개") if changes.get('modified'): summary_parts.append(f"수정 {len(changes['modified'])}개") if not summary_parts: return "변경사항 없음" return ", ".join(summary_parts) # ========== 로깅 유틸리티 ========== class PipeLogger: """PIPE 시스템 전용 로거 유틸리티""" @staticmethod def log_pipe_operation(operation: str, job_no: str, details: Dict[str, Any] = None): """ PIPE 작업 로깅 Args: operation: 작업 유형 job_no: 작업 번호 details: 상세 정보 """ message = f"🔧 PIPE {operation} | Job: {job_no}" if details: detail_parts = [] for key, value in details.items(): detail_parts.append(f"{key}: {value}") message += f" | {', '.join(detail_parts)}" logger.info(message) @staticmethod def log_pipe_error(operation: str, job_no: str, error: Exception, context: Dict[str, Any] = None): """ PIPE 오류 로깅 Args: operation: 작업 유형 job_no: 작업 번호 error: 오류 객체 context: 컨텍스트 정보 """ message = f"❌ PIPE {operation} 실패 | Job: {job_no} | Error: {str(error)}" if context: context_parts = [] for key, value in context.items(): context_parts.append(f"{key}: {value}") message += f" | Context: {', '.join(context_parts)}" logger.error(message)