feat: SWG 가스켓 전체 구성 정보 표시 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시 - 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시 - 외부링/필러/내부링/추가구성 모든 정보 포함 - 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
This commit is contained in:
333
backend/app/services/file_service.py
Normal file
333
backend/app/services/file_service.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
파일 관리 비즈니스 로직
|
||||
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)
|
||||
Reference in New Issue
Block a user