""" 파일 업로드 서비스 파일 업로드 관련 로직을 통합하고 트랜잭션 관리 개선 """ import os import shutil 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 validate_file_extension, generate_unique_filename from .database_service import DatabaseService from .material_classification_service import MaterialClassificationService logger = get_logger(__name__) UPLOAD_DIR = Path("uploads") ALLOWED_EXTENSIONS = {'.xls', '.xlsx', '.csv'} MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB 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 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 ) -> 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) # 상세 정보 저장 (분류별) self._save_material_details(processed_materials, file_id) 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: """자재 분류 (기존 로직 유지)""" # 기존 분류 로직을 여기에 통합 # 현재는 기본값만 설정 material_data.update({ 'line_number': line_number, 'row_number': row_number, 'classified_category': material_data.get('classified_category', 'UNKNOWN'), 'classification_confidence': material_data.get('classification_confidence', 0.0), 'classification_details': material_data.get('classification_details', {}) }) return material_data