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로 완전한 구성 표시 - 외부링/필러/내부링/추가구성 모든 정보 포함 - 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
334 lines
12 KiB
Python
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)
|