Files
TK-BOM-Project/backend/app/services/file_upload_service.py
Hyungi Ahn 3398f71b80
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
🔄 전반적인 시스템 리팩토링 완료
 백엔드 구조 개선:
- DatabaseService: 공통 DB 쿼리 로직 통합
- FileUploadService: 파일 업로드 로직 모듈화 및 트랜잭션 관리 개선
- 서비스 레이어 패턴 도입으로 코드 재사용성 향상

 프론트엔드 컴포넌트 개선:
- LoadingSpinner, ErrorMessage, ConfirmDialog 공통 컴포넌트 생성
- 재사용 가능한 컴포넌트 라이브러리 구축
- deprecated/backup 파일들 완전 제거

 성능 최적화:
- optimize_database.py: 핵심 DB 인덱스 자동 생성
- 쿼리 최적화 및 통계 업데이트 자동화
- VACUUM ANALYZE 자동 실행

 코드 정리:
- 개별 SQL 마이그레이션 파일들을 legacy/ 폴더로 정리
- 중복된 마이그레이션 스크립트 정리
- 깔끔하고 체계적인 프로젝트 구조 완성

 자동 마이그레이션 시스템 강화:
- complete_migrate.py: SQLAlchemy 기반 완전한 마이그레이션
- analyze_and_fix_schema.py: 백엔드 코드 분석 기반 스키마 수정
- fix_missing_tables.py: 누락된 테이블/컬럼 자동 생성
- start.sh: 배포 시 자동 실행 순서 최적화
2025-10-20 08:41:06 +09:00

608 lines
27 KiB
Python

"""
파일 업로드 서비스
파일 업로드 관련 로직을 통합하고 트랜잭션 관리 개선
"""
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