Files
TK-BOM-Project/backend/app/services/file_service.py
Hyungi Ahn 4f8e395f87
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
feat: SWG 가스켓 전체 구성 정보 표시 개선
- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시
- 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시
- 외부링/필러/내부링/추가구성 모든 정보 포함
- 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
2025-08-30 14:23:01 +09:00

334 lines
12 KiB
Python

"""
파일 관리 비즈니스 로직
API 레이어에서 분리된 핵심 비즈니스 로직
"""
from typing import List, Dict, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import text
from fastapi import HTTPException
from ..utils.logger import get_logger
from ..utils.cache_manager import tkmp_cache
from ..utils.transaction_manager import TransactionManager, async_transactional
from ..schemas.response_models import FileInfo
from ..config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class FileService:
"""파일 관리 서비스"""
def __init__(self, db: Session):
self.db = db
self.transaction_manager = TransactionManager(db)
async def get_files(
self,
job_no: Optional[str] = None,
show_history: bool = False,
use_cache: bool = True
) -> Tuple[List[Dict], bool]:
"""
파일 목록 조회
Args:
job_no: 작업 번호
show_history: 이력 표시 여부
use_cache: 캐시 사용 여부
Returns:
Tuple[List[Dict], bool]: (파일 목록, 캐시 히트 여부)
"""
try:
logger.info(f"파일 목록 조회 - job_no: {job_no}, show_history: {show_history}")
# 캐시 확인
if use_cache:
cached_files = tkmp_cache.get_file_list(job_no, show_history)
if cached_files:
logger.info(f"캐시에서 파일 목록 반환 - {len(cached_files)}개 파일")
return cached_files, True
# 데이터베이스에서 조회
query, params = self._build_file_query(job_no, show_history)
result = self.db.execute(text(query), params)
files = result.fetchall()
# 결과 변환
file_list = self._convert_files_to_dict(files)
# 캐시에 저장
if use_cache:
tkmp_cache.set_file_list(file_list, job_no, show_history)
logger.debug("파일 목록 캐시 저장 완료")
logger.info(f"파일 목록 조회 완료 - {len(file_list)}개 파일 반환")
return file_list, False
except Exception as e:
logger.error(f"파일 목록 조회 실패: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"파일 목록 조회 실패: {str(e)}")
def _build_file_query(self, job_no: Optional[str], show_history: bool) -> Tuple[str, Dict]:
"""파일 조회 쿼리 생성"""
if show_history:
# 전체 이력 표시
query = "SELECT * FROM files"
params = {}
if job_no:
query += " WHERE job_no = :job_no"
params["job_no"] = job_no
query += " ORDER BY original_filename, revision DESC"
else:
# 최신 리비전만 표시
if job_no:
query = """
SELECT f1.* FROM files f1
INNER JOIN (
SELECT original_filename, MAX(revision) as max_revision
FROM files
WHERE job_no = :job_no
GROUP BY original_filename
) f2 ON f1.original_filename = f2.original_filename
AND f1.revision = f2.max_revision
WHERE f1.job_no = :job_no
ORDER BY f1.upload_date DESC
"""
params = {"job_no": job_no}
else:
query = "SELECT * FROM files ORDER BY upload_date DESC"
params = {}
return query, params
def _convert_files_to_dict(self, files) -> List[Dict]:
"""파일 결과를 딕셔너리로 변환"""
return [
{
"id": f.id,
"filename": f.original_filename,
"original_filename": f.original_filename,
"name": f.original_filename,
"job_no": f.job_no,
"bom_name": f.bom_name or f.original_filename,
"revision": f.revision or "Rev.0",
"parsed_count": f.parsed_count or 0,
"bom_type": f.file_type or "unknown",
"status": "active" if f.is_active else "inactive",
"file_size": f.file_size,
"created_at": f.upload_date,
"upload_date": f.upload_date,
"description": f"파일: {f.original_filename}"
}
for f in files
]
async def delete_file(self, file_id: int) -> Dict:
"""
파일 삭제 (트랜잭션 관리 적용)
Args:
file_id: 파일 ID
Returns:
Dict: 삭제 결과
"""
try:
logger.info(f"파일 삭제 요청 - file_id: {file_id}")
# 트랜잭션 내에서 삭제 작업 수행
with self.transaction_manager.transaction():
# 파일 정보 조회
file_info = self._get_file_info(file_id)
if not file_info:
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
# 관련 데이터 삭제 (세이브포인트 사용)
with self.transaction_manager.savepoint("delete_related_data"):
self._delete_related_data(file_id)
# 파일 삭제
with self.transaction_manager.savepoint("delete_file_record"):
self._delete_file_record(file_id)
# 트랜잭션이 성공적으로 완료되면 캐시 무효화
self._invalidate_file_cache(file_id, file_info)
logger.info(f"파일 삭제 완료 - file_id: {file_id}, filename: {file_info.original_filename}")
return {
"success": True,
"message": "파일과 관련 데이터가 삭제되었습니다",
"deleted_file_id": file_id
}
except HTTPException:
raise
except Exception as e:
logger.error(f"파일 삭제 실패 - file_id: {file_id}, error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"파일 삭제 실패: {str(e)}")
def _get_file_info(self, file_id: int):
"""파일 정보 조회"""
file_query = text("SELECT * FROM files WHERE id = :file_id")
file_result = self.db.execute(file_query, {"file_id": file_id})
return file_result.fetchone()
def _delete_related_data(self, file_id: int):
"""관련 데이터 삭제"""
# 상세 테이블 목록
detail_tables = [
'pipe_details', 'fitting_details', 'valve_details',
'flange_details', 'bolt_details', 'gasket_details',
'instrument_details'
]
# 해당 파일의 materials ID 조회
material_ids_query = text("SELECT id FROM materials WHERE file_id = :file_id")
material_ids_result = self.db.execute(material_ids_query, {"file_id": file_id})
material_ids = [row[0] for row in material_ids_result]
if material_ids:
logger.info(f"관련 자재 데이터 삭제 - {len(material_ids)}개 자재")
# 각 상세 테이블에서 관련 데이터 삭제
for table in detail_tables:
delete_detail_query = text(f"DELETE FROM {table} WHERE material_id = ANY(:material_ids)")
self.db.execute(delete_detail_query, {"material_ids": material_ids})
# materials 테이블 데이터 삭제
materials_query = text("DELETE FROM materials WHERE file_id = :file_id")
self.db.execute(materials_query, {"file_id": file_id})
def _delete_file_record(self, file_id: int):
"""파일 레코드 삭제"""
delete_query = text("DELETE FROM files WHERE id = :file_id")
self.db.execute(delete_query, {"file_id": file_id})
def _invalidate_file_cache(self, file_id: int, file_info):
"""파일 관련 캐시 무효화"""
tkmp_cache.invalidate_file_cache(file_id)
if hasattr(file_info, 'job_no') and file_info.job_no:
tkmp_cache.invalidate_job_cache(file_info.job_no)
async def get_file_statistics(self, job_no: Optional[str] = None) -> Dict:
"""
파일 통계 조회
Args:
job_no: 작업 번호
Returns:
Dict: 파일 통계
"""
try:
# 캐시 확인
if job_no:
cached_stats = tkmp_cache.get_statistics(job_no, "file_stats")
if cached_stats:
return cached_stats
# 통계 쿼리 실행
stats_query = self._build_statistics_query(job_no)
result = self.db.execute(text(stats_query["query"]), stats_query["params"])
stats_data = result.fetchall()
# 통계 데이터 변환
statistics = self._convert_statistics_data(stats_data)
# 캐시에 저장
if job_no:
tkmp_cache.set_statistics(statistics, job_no, "file_stats")
return statistics
except Exception as e:
logger.error(f"파일 통계 조회 실패: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"파일 통계 조회 실패: {str(e)}")
def _build_statistics_query(self, job_no: Optional[str]) -> Dict:
"""통계 쿼리 생성"""
base_query = """
SELECT
COUNT(*) as total_files,
COUNT(DISTINCT job_no) as total_jobs,
SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active_files,
SUM(file_size) as total_size,
AVG(file_size) as avg_size,
MAX(upload_date) as latest_upload,
MIN(upload_date) as earliest_upload
FROM files
"""
params = {}
if job_no:
base_query += " WHERE job_no = :job_no"
params["job_no"] = job_no
return {"query": base_query, "params": params}
def _convert_statistics_data(self, stats_data) -> Dict:
"""통계 데이터 변환"""
if not stats_data:
return {
"total_files": 0,
"total_jobs": 0,
"active_files": 0,
"total_size": 0,
"avg_size": 0,
"latest_upload": None,
"earliest_upload": None
}
stats = stats_data[0]
return {
"total_files": stats.total_files or 0,
"total_jobs": stats.total_jobs or 0,
"active_files": stats.active_files or 0,
"total_size": stats.total_size or 0,
"total_size_mb": round((stats.total_size or 0) / (1024 * 1024), 2),
"avg_size": stats.avg_size or 0,
"avg_size_mb": round((stats.avg_size or 0) / (1024 * 1024), 2),
"latest_upload": stats.latest_upload,
"earliest_upload": stats.earliest_upload
}
async def validate_file_access(self, file_id: int, user_id: Optional[str] = None) -> bool:
"""
파일 접근 권한 검증
Args:
file_id: 파일 ID
user_id: 사용자 ID
Returns:
bool: 접근 권한 여부
"""
try:
# 파일 존재 여부 확인
file_info = self._get_file_info(file_id)
if not file_info:
return False
# 파일이 활성 상태인지 확인
if not file_info.is_active:
logger.warning(f"비활성 파일 접근 시도 - file_id: {file_id}")
return False
# 추가 권한 검증 로직 (필요시 구현)
# 예: 사용자별 프로젝트 접근 권한 등
return True
except Exception as e:
logger.error(f"파일 접근 권한 검증 실패: {str(e)}", exc_info=True)
return False
def get_file_service(db: Session) -> FileService:
"""파일 서비스 팩토리 함수"""
return FileService(db)