""" 파일 업로드 서비스 파일 업로드 관련 로직을 통합하고 트랜잭션 관리 개선 """ import os import shutil import uuid from datetime import datetime from pathlib import Path from typing import Dict, List, Any, Optional, Tuple from fastapi import UploadFile, HTTPException from sqlalchemy.orm import Session from sqlalchemy import text from ..models import File, Material from ..utils.logger import get_logger from ..utils.file_processor import parse_file_data from ..utils.file_validator import file_validator from .database_service import DatabaseService logger = get_logger(__name__) UPLOAD_DIR = Path("uploads") ALLOWED_EXTENSIONS = {'.xls', '.xlsx', '.csv'} MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB 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}" class FileUploadService: """파일 업로드 서비스""" def __init__(self, db: Session): self.db = db self.db_service = DatabaseService(db) self.classification_service = MaterialClassificationService() def validate_upload_request(self, file: UploadFile, job_no: str) -> None: """업로드 요청 검증""" if not file_validator.validate_file_extension(file.filename): raise HTTPException( status_code=400, detail=f"지원하지 않는 파일 형식입니다. 허용된 확장자: {', '.join(ALLOWED_EXTENSIONS)}" ) if file.size and file.size > MAX_FILE_SIZE: raise HTTPException( status_code=400, detail="파일 크기는 10MB를 초과할 수 없습니다" ) if not job_no or len(job_no.strip()) == 0: raise HTTPException( status_code=400, detail="작업 번호는 필수입니다" ) def save_uploaded_file(self, file: UploadFile) -> Tuple[str, Path]: """업로드된 파일 저장""" try: # 고유 파일명 생성 unique_filename = generate_unique_filename(file.filename) file_path = UPLOAD_DIR / unique_filename # 업로드 디렉토리 생성 UPLOAD_DIR.mkdir(exist_ok=True) # 파일 저장 with open(file_path, "wb") as buffer: shutil.copyfileobj(file.file, buffer) logger.info(f"File saved: {file_path}") return unique_filename, file_path except Exception as e: logger.error(f"Failed to save file: {e}") raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}") def create_file_record( self, filename: str, original_filename: str, file_path: str, job_no: str, revision: str, bom_name: Optional[str], file_size: int, parsed_count: int, uploaded_by: str, parent_file_id: Optional[int] = None ) -> File: """파일 레코드 생성""" try: # BOM 이름 자동 생성 (제공되지 않은 경우) if not bom_name: bom_name = original_filename.rsplit('.', 1)[0] # 파일 설명 생성 description = f"BOM 파일 - {parsed_count}개 자재" file_record = File( filename=filename, original_filename=original_filename, file_path=file_path, job_no=job_no, revision=revision, bom_name=bom_name, description=description, file_size=file_size, parsed_count=parsed_count, is_active=True, uploaded_by=uploaded_by ) self.db.add(file_record) self.db.flush() # ID 생성을 위해 flush logger.info(f"File record created: ID={file_record.id}, Job={job_no}") return file_record except Exception as e: logger.error(f"Failed to create file record: {e}") raise HTTPException(status_code=500, detail=f"파일 레코드 생성 실패: {str(e)}") def process_materials_data( self, file_path: Path, file_id: int, job_no: str, revision: str, parent_file_id: Optional[int] = None, is_revision: bool = False ) -> Dict[str, Any]: """자재 데이터 처리""" try: # 파일 파싱 logger.info(f"Parsing file: {file_path}") materials_data = parse_file_data(str(file_path)) if not materials_data: raise HTTPException(status_code=400, detail="파일에서 자재 데이터를 찾을 수 없습니다") # 자재 분류 및 처리 processed_materials = [] classification_results = { "total_materials": len(materials_data), "classified_count": 0, "categories": {} } for idx, material_data in enumerate(materials_data): try: # 자재 분류 classified_material = self.classification_service.classify_material( material_data, line_number=idx + 1, row_number=material_data.get('row_number', idx + 1) ) # 리비전 상태 설정 if parent_file_id: # 리비전 업로드인 경우 변경 상태 분석 classified_material['revision_status'] = self._analyze_revision_status( classified_material, parent_file_id ) else: classified_material['revision_status'] = 'new' processed_materials.append(classified_material) # 분류 통계 업데이트 category = classified_material.get('classified_category', 'UNKNOWN') classification_results['categories'][category] = classification_results['categories'].get(category, 0) + 1 if category != 'UNKNOWN': classification_results['classified_count'] += 1 except Exception as e: logger.warning(f"Failed to classify material at line {idx + 1}: {e}") # 분류 실패 시 기본값으로 처리 material_data.update({ 'classified_category': 'UNKNOWN', 'classification_confidence': 0.0, 'revision_status': 'new' }) processed_materials.append(material_data) # 자재 데이터 DB 저장 inserted_count = self.db_service.bulk_insert_materials(processed_materials, file_id) # 상세 정보 저장 (임시로 모든 업로드에서 건너뛰기) # TODO: 상세 정보 저장 로직 수정 필요 logger.info(f"Skipped material details saving (temporarily disabled)") logger.info(f"Processed {inserted_count} materials for file {file_id}") return { "materials_count": inserted_count, "classification_results": classification_results, "processed_materials": processed_materials[:10] # 처음 10개만 반환 } except Exception as e: logger.error(f"Failed to process materials data: {e}") raise HTTPException(status_code=500, detail=f"자재 데이터 처리 실패: {str(e)}") def _analyze_revision_status(self, material: Dict, parent_file_id: int) -> str: """리비전 상태 분석""" try: # 부모 파일의 동일한 자재 찾기 parent_material_query = """ SELECT * FROM materials WHERE file_id = :parent_file_id AND drawing_name = :drawing_name AND original_description = :description LIMIT 1 """ result = self.db_service.execute_query( parent_material_query, { "parent_file_id": parent_file_id, "drawing_name": material.get('drawing_name'), "description": material.get('original_description') } ) parent_material = result.fetchone() if not parent_material: return 'new' # 새로운 자재 # 변경 사항 확인 if ( float(material.get('quantity', 0)) != float(parent_material.quantity or 0) or material.get('material_grade') != parent_material.material_grade or material.get('size_spec') != parent_material.size_spec ): return 'changed' # 변경된 자재 return 'inventory' # 기존 자재 (변경 없음) except Exception as e: logger.warning(f"Failed to analyze revision status: {e}") return 'new' def _save_material_details(self, materials: List[Dict], file_id: int) -> None: """자재 상세 정보 저장""" try: # 자재 ID 매핑 (방금 삽입된 자재들) material_ids_query = """ SELECT id, row_number FROM materials WHERE file_id = :file_id ORDER BY row_number """ result = self.db_service.execute_query(material_ids_query, {"file_id": file_id}) material_id_map = {row.row_number: row.id for row in result.fetchall()} # 분류별 상세 정보 저장 for material in materials: material_id = material_id_map.get(material.get('row_number')) if not material_id: continue category = material.get('classified_category') if category == 'PIPE': self._save_pipe_details(material, material_id, file_id) elif category == 'FITTING': self._save_fitting_details(material, material_id, file_id) elif category == 'FLANGE': self._save_flange_details(material, material_id, file_id) elif category == 'VALVE': self._save_valve_details(material, material_id, file_id) elif category == 'BOLT': self._save_bolt_details(material, material_id, file_id) elif category == 'GASKET': self._save_gasket_details(material, material_id, file_id) elif category == 'SUPPORT': self._save_support_details(material, material_id, file_id) elif category == 'INSTRUMENT': self._save_instrument_details(material, material_id, file_id) logger.info(f"Saved material details for {len(materials)} materials") except Exception as e: logger.error(f"Failed to save material details: {e}") # 상세 정보 저장 실패는 전체 프로세스를 중단하지 않음 def _save_pipe_details(self, material: Dict, material_id: int, file_id: int) -> None: """파이프 상세 정보 저장""" details = material.get('classification_details', {}) insert_query = """ INSERT INTO pipe_details ( material_id, file_id, material_standard, material_grade, material_type, manufacturing_method, end_preparation, schedule, wall_thickness, nominal_size, length_mm, material_confidence, manufacturing_confidence, end_prep_confidence, schedule_confidence ) VALUES ( :material_id, :file_id, :material_standard, :material_grade, :material_type, :manufacturing_method, :end_preparation, :schedule, :wall_thickness, :nominal_size, :length_mm, :material_confidence, :manufacturing_confidence, :end_prep_confidence, :schedule_confidence ) """ try: self.db_service.execute_query(insert_query, { "material_id": material_id, "file_id": file_id, "material_standard": details.get('material_standard'), "material_grade": material.get('material_grade'), "material_type": details.get('material_type'), "manufacturing_method": details.get('manufacturing_method'), "end_preparation": details.get('end_preparation'), "schedule": material.get('schedule'), "wall_thickness": details.get('wall_thickness'), "nominal_size": material.get('main_nom'), "length_mm": material.get('length'), "material_confidence": details.get('material_confidence', 0.0), "manufacturing_confidence": details.get('manufacturing_confidence', 0.0), "end_prep_confidence": details.get('end_prep_confidence', 0.0), "schedule_confidence": details.get('schedule_confidence', 0.0) }) except Exception as e: logger.warning(f"Failed to save pipe details for material {material_id}: {e}") def _save_fitting_details(self, material: Dict, material_id: int, file_id: int) -> None: """피팅 상세 정보 저장""" details = material.get('classification_details', {}) insert_query = """ INSERT INTO fitting_details ( material_id, file_id, fitting_type, fitting_subtype, connection_type, main_size, reduced_size, length_mm, material_standard, material_grade, pressure_rating, temperature_rating, classification_confidence ) VALUES ( :material_id, :file_id, :fitting_type, :fitting_subtype, :connection_type, :main_size, :reduced_size, :length_mm, :material_standard, :material_grade, :pressure_rating, :temperature_rating, :classification_confidence ) """ try: self.db_service.execute_query(insert_query, { "material_id": material_id, "file_id": file_id, "fitting_type": details.get('fitting_type', 'UNKNOWN'), "fitting_subtype": details.get('fitting_subtype'), "connection_type": details.get('connection_type'), "main_size": material.get('main_nom'), "reduced_size": material.get('red_nom'), "length_mm": material.get('length'), "material_standard": details.get('material_standard'), "material_grade": material.get('material_grade'), "pressure_rating": details.get('pressure_rating'), "temperature_rating": details.get('temperature_rating'), "classification_confidence": material.get('classification_confidence', 0.0) }) except Exception as e: logger.warning(f"Failed to save fitting details for material {material_id}: {e}") def _save_flange_details(self, material: Dict, material_id: int, file_id: int) -> None: """플랜지 상세 정보 저장""" details = material.get('classification_details', {}) insert_query = """ INSERT INTO flange_details ( material_id, file_id, flange_type, flange_subtype, pressure_rating, face_type, connection_method, nominal_size, material_standard, material_grade, classification_confidence ) VALUES ( :material_id, :file_id, :flange_type, :flange_subtype, :pressure_rating, :face_type, :connection_method, :nominal_size, :material_standard, :material_grade, :classification_confidence ) """ try: self.db_service.execute_query(insert_query, { "material_id": material_id, "file_id": file_id, "flange_type": details.get('flange_type', 'UNKNOWN'), "flange_subtype": details.get('flange_subtype'), "pressure_rating": details.get('pressure_rating'), "face_type": details.get('face_type'), "connection_method": details.get('connection_method'), "nominal_size": material.get('main_nom'), "material_standard": details.get('material_standard'), "material_grade": material.get('material_grade'), "classification_confidence": material.get('classification_confidence', 0.0) }) except Exception as e: logger.warning(f"Failed to save flange details for material {material_id}: {e}") def _save_valve_details(self, material: Dict, material_id: int, file_id: int) -> None: """밸브 상세 정보 저장""" details = material.get('classification_details', {}) insert_query = """ INSERT INTO valve_details ( material_id, file_id, valve_type, valve_subtype, actuation_type, pressure_rating, temperature_rating, nominal_size, connection_type, material_standard, material_grade, classification_confidence ) VALUES ( :material_id, :file_id, :valve_type, :valve_subtype, :actuation_type, :pressure_rating, :temperature_rating, :nominal_size, :connection_type, :material_standard, :material_grade, :classification_confidence ) """ try: self.db_service.execute_query(insert_query, { "material_id": material_id, "file_id": file_id, "valve_type": details.get('valve_type', 'UNKNOWN'), "valve_subtype": details.get('valve_subtype'), "actuation_type": details.get('actuation_type'), "pressure_rating": details.get('pressure_rating'), "temperature_rating": details.get('temperature_rating'), "nominal_size": material.get('main_nom'), "connection_type": details.get('connection_type'), "material_standard": details.get('material_standard'), "material_grade": material.get('material_grade'), "classification_confidence": material.get('classification_confidence', 0.0) }) except Exception as e: logger.warning(f"Failed to save valve details for material {material_id}: {e}") def _save_bolt_details(self, material: Dict, material_id: int, file_id: int) -> None: """볼트 상세 정보 저장""" details = material.get('classification_details', {}) insert_query = """ INSERT INTO bolt_details ( material_id, file_id, bolt_type, bolt_subtype, thread_type, head_type, material_standard, material_grade, pressure_rating, length_mm, diameter_mm, classification_confidence ) VALUES ( :material_id, :file_id, :bolt_type, :bolt_subtype, :thread_type, :head_type, :material_standard, :material_grade, :pressure_rating, :length_mm, :diameter_mm, :classification_confidence ) """ try: self.db_service.execute_query(insert_query, { "material_id": material_id, "file_id": file_id, "bolt_type": details.get('bolt_type', 'UNKNOWN'), "bolt_subtype": details.get('bolt_subtype'), "thread_type": details.get('thread_type'), "head_type": details.get('head_type'), "material_standard": details.get('material_standard'), "material_grade": material.get('material_grade'), "pressure_rating": details.get('pressure_rating'), "length_mm": material.get('length'), "diameter_mm": details.get('diameter_mm'), "classification_confidence": material.get('classification_confidence', 0.0) }) except Exception as e: logger.warning(f"Failed to save bolt details for material {material_id}: {e}") def _save_gasket_details(self, material: Dict, material_id: int, file_id: int) -> None: """가스켓 상세 정보 저장""" details = material.get('classification_details', {}) insert_query = """ INSERT INTO gasket_details ( material_id, file_id, gasket_type, gasket_subtype, material_type, filler_material, pressure_rating, size_inches, thickness, temperature_range, fire_safe, classification_confidence ) VALUES ( :material_id, :file_id, :gasket_type, :gasket_subtype, :material_type, :filler_material, :pressure_rating, :size_inches, :thickness, :temperature_range, :fire_safe, :classification_confidence ) """ try: self.db_service.execute_query(insert_query, { "material_id": material_id, "file_id": file_id, "gasket_type": details.get('gasket_type', 'UNKNOWN'), "gasket_subtype": details.get('gasket_subtype'), "material_type": details.get('material_type'), "filler_material": details.get('filler_material'), "pressure_rating": details.get('pressure_rating'), "size_inches": material.get('main_nom'), "thickness": details.get('thickness'), "temperature_range": details.get('temperature_range'), "fire_safe": details.get('fire_safe', False), "classification_confidence": material.get('classification_confidence', 0.0) }) except Exception as e: logger.warning(f"Failed to save gasket details for material {material_id}: {e}") def _save_support_details(self, material: Dict, material_id: int, file_id: int) -> None: """서포트 상세 정보 저장""" details = material.get('classification_details', {}) insert_query = """ INSERT INTO support_details ( material_id, file_id, support_type, support_subtype, load_rating, load_capacity, material_standard, material_grade, pipe_size, length_mm, width_mm, height_mm, classification_confidence ) VALUES ( :material_id, :file_id, :support_type, :support_subtype, :load_rating, :load_capacity, :material_standard, :material_grade, :pipe_size, :length_mm, :width_mm, :height_mm, :classification_confidence ) """ try: self.db_service.execute_query(insert_query, { "material_id": material_id, "file_id": file_id, "support_type": details.get('support_type', 'UNKNOWN'), "support_subtype": details.get('support_subtype'), "load_rating": details.get('load_rating', 'UNKNOWN'), "load_capacity": details.get('load_capacity'), "material_standard": details.get('material_standard', 'UNKNOWN'), "material_grade": details.get('material_grade', 'UNKNOWN'), "pipe_size": material.get('main_nom'), "length_mm": material.get('length'), "width_mm": details.get('width_mm'), "height_mm": details.get('height_mm'), "classification_confidence": material.get('classification_confidence', 0.0) }) except Exception as e: logger.warning(f"Failed to save support details for material {material_id}: {e}") def _save_instrument_details(self, material: Dict, material_id: int, file_id: int) -> None: """계기 상세 정보 저장""" details = material.get('classification_details', {}) insert_query = """ INSERT INTO instrument_details ( material_id, file_id, instrument_type, instrument_subtype, measurement_type, connection_size, pressure_rating, temperature_rating, accuracy_class, material_standard, classification_confidence ) VALUES ( :material_id, :file_id, :instrument_type, :instrument_subtype, :measurement_type, :connection_size, :pressure_rating, :temperature_rating, :accuracy_class, :material_standard, :classification_confidence ) """ try: self.db_service.execute_query(insert_query, { "material_id": material_id, "file_id": file_id, "instrument_type": details.get('instrument_type', 'UNKNOWN'), "instrument_subtype": details.get('instrument_subtype'), "measurement_type": details.get('measurement_type'), "connection_size": material.get('main_nom'), "pressure_rating": details.get('pressure_rating'), "temperature_rating": details.get('temperature_rating'), "accuracy_class": details.get('accuracy_class'), "material_standard": details.get('material_standard'), "classification_confidence": material.get('classification_confidence', 0.0) }) except Exception as e: logger.warning(f"Failed to save instrument details for material {material_id}: {e}") def cleanup_failed_upload(self, file_path: Path) -> None: """실패한 업로드 정리""" try: if file_path.exists(): file_path.unlink() logger.info(f"Cleaned up failed upload: {file_path}") except Exception as e: logger.warning(f"Failed to cleanup file {file_path}: {e}") class MaterialClassificationService: """자재 분류 서비스""" def classify_material(self, material_data: Dict, line_number: int, row_number: int) -> Dict: """자재 분류 (실제 분류 로직 적용)""" description = material_data.get('original_description', '') main_nom = material_data.get('main_nom', '') red_nom = material_data.get('red_nom', '') length_value = material_data.get('length', 0) try: # 1. 통합 분류기로 1차 분류 from ..services.integrated_classifier import classify_material_integrated integrated_result = classify_material_integrated(description, main_nom, red_nom, length_value) # 2. 제외 대상 확인 if self._should_exclude_material(description): classification_result = { "category": "EXCLUDE", "overall_confidence": 0.95, "reason": "제외 대상 자재" } else: # 3. 타입별 상세 분류기 실행 material_type = integrated_result.get('category', 'UNCLASSIFIED') if material_type == "PIPE": from ..services.pipe_classifier import classify_pipe_for_purchase classification_result = classify_pipe_for_purchase("", description, main_nom, length_value) elif material_type == "FITTING": from ..services.fitting_classifier import classify_fitting classification_result = classify_fitting("", description, main_nom, red_nom) elif material_type == "FLANGE": from ..services.flange_classifier import classify_flange classification_result = classify_flange("", description, main_nom, red_nom) elif material_type == "VALVE": from ..services.valve_classifier import classify_valve classification_result = classify_valve("", description, main_nom) elif material_type == "BOLT": from ..services.bolt_classifier import classify_bolt classification_result = classify_bolt("", description, main_nom) elif material_type == "GASKET": from ..services.gasket_classifier import classify_gasket classification_result = classify_gasket("", description, main_nom) elif material_type == "SUPPORT": from ..services.support_classifier import classify_support classification_result = classify_support("", description, main_nom) elif material_type == "SPECIAL": classification_result = { "category": "SPECIAL", "overall_confidence": integrated_result.get('confidence', 0.8), "reason": integrated_result.get('reason', 'SPECIAL 키워드 발견') } else: classification_result = { "category": "UNCLASSIFIED", "overall_confidence": 0.1, "reason": "분류되지 않은 자재" } # 4. 결과 적용 final_category = classification_result.get('category', 'UNCLASSIFIED') if final_category == 'EXCLUDE': return None # 제외 대상은 None 반환 material_data.update({ 'line_number': line_number, 'row_number': row_number, 'classified_category': final_category, 'classification_confidence': classification_result.get('overall_confidence', 0.0), 'classification_details': classification_result }) return material_data except Exception as e: logger.warning(f"자재 분류 실패 (line {line_number}): {e}") # 분류 실패 시 기본값 설정 material_data.update({ 'line_number': line_number, 'row_number': row_number, 'classified_category': 'UNCLASSIFIED', 'classification_confidence': 0.0, 'classification_details': {'error': str(e)} }) return material_data def _should_exclude_material(self, description: str) -> bool: """제외 대상 자재 확인""" exclude_keywords = [ 'WELD GAP', '용접갭', '용접 갭', 'WELDING GAP', 'WELD ALLOWANCE', 'CUTTING ALLOWANCE', '절단여유', 'SPARE', '예비품', 'RESERVE' ] desc_upper = description.upper() return any(keyword.upper() in desc_upper for keyword in exclude_keywords)