Files
TK-BOM-Project/backend/app/services/file_upload_service.py
Hyungi Ahn 1dc735f362
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
리비전 페이지 제거 및 트랜잭션 오류 임시 수정
- frontend/src/pages/revision/ 폴더 완전 삭제
- EnhancedRevisionPage.css 제거
- support_details 저장 시 트랜잭션 오류로 인해 임시로 상세 정보 저장 비활성화
- 리비전 기능 재설계 예정
2025-10-21 12:11:57 +09:00

704 lines
32 KiB
Python

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