Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
✨ 주요 기능: - 완전한 데이터베이스 스키마 분석 및 자동 마이그레이션 시스템 - 44개 테이블 완전 지원 (운영 서버 43개 + 1개 추가) - 누락된 테이블/컬럼 자동 감지 및 생성 🔧 해결된 스키마 문제: - users.status 컬럼 누락 → 자동 추가 - files 테이블 4개 컬럼 누락 → 자동 추가 - materials 테이블 22개 컬럼 누락 → 자동 추가 - support_details, purchase_requests, purchase_request_items 테이블 누락 → 자동 생성 - material_purchase_tracking.description, purchase_status 컬럼 누락 → 자동 추가 🚀 자동화 도구: - schema_analyzer.py: 코드와 DB 스키마 비교 분석 - auto_migrator.py: 자동 마이그레이션 실행 - docker_migrator.py: Docker 환경용 간편 마이그레이션 - schema_monitor.py: 실시간 스키마 모니터링 📋 리비전 관리 시스템: - 8개 카테고리별 리비전 페이지 구현 - PIPE Cutting Plan 관리 시스템 - PIPE Issue Management 시스템 - 완전한 리비전 비교 및 추적 기능 🎯 사용법: docker exec tk-mp-backend python3 scripts/docker_migrator.py 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
422 lines
16 KiB
Python
422 lines
16 KiB
Python
"""
|
|
리비전 상태 관리 서비스
|
|
리비전 진행 상태, 히스토리, 확정 등 관리
|
|
"""
|
|
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import text, desc
|
|
from typing import List, Dict, Any, Optional
|
|
from datetime import datetime
|
|
|
|
from ..models import File, RevisionComparison, RevisionChangeLog
|
|
from ..utils.logger import get_logger
|
|
from .database_service import DatabaseService
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class RevisionStatusService:
|
|
"""리비전 상태 관리 서비스"""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
self.db_service = DatabaseService(db)
|
|
|
|
def get_revision_status(self, job_no: str, file_id: int) -> Dict[str, Any]:
|
|
"""
|
|
리비전 상태 조회
|
|
|
|
Args:
|
|
job_no: 작업 번호
|
|
file_id: 파일 ID
|
|
|
|
Returns:
|
|
리비전 상태 정보
|
|
"""
|
|
|
|
# 파일 정보 조회
|
|
current_file = self._get_file_info(file_id)
|
|
if not current_file:
|
|
return {"error": "파일을 찾을 수 없습니다."}
|
|
|
|
# 같은 작업의 모든 파일 조회
|
|
all_files = self._get_job_files(job_no)
|
|
|
|
# 리비전 히스토리 구성
|
|
revision_history = self._build_revision_history(all_files, file_id)
|
|
|
|
# 현재 리비전의 처리 상태
|
|
processing_status = self._get_processing_status(file_id)
|
|
|
|
return {
|
|
"job_no": job_no,
|
|
"current_file": current_file,
|
|
"revision_history": revision_history,
|
|
"processing_status": processing_status,
|
|
"is_latest": revision_history.get("is_latest", False),
|
|
"can_upload_new_revision": revision_history.get("can_upload_new", True),
|
|
"status_summary": self._generate_status_summary(processing_status)
|
|
}
|
|
|
|
def get_revision_history(self, job_no: str) -> List[Dict[str, Any]]:
|
|
"""작업의 전체 리비전 히스토리 조회"""
|
|
|
|
files = self._get_job_files(job_no)
|
|
|
|
history = []
|
|
for i, file_info in enumerate(files):
|
|
# 이전 파일과의 비교 정보
|
|
comparison_info = None
|
|
if i > 0:
|
|
prev_file = files[i-1]
|
|
comparison_info = self._get_comparison_summary(file_info['id'], prev_file['id'])
|
|
|
|
history.append({
|
|
"file_id": file_info['id'],
|
|
"revision": file_info['revision'],
|
|
"filename": file_info['original_filename'],
|
|
"upload_date": file_info['upload_date'],
|
|
"uploaded_by": file_info['uploaded_by'],
|
|
"file_size": file_info['file_size'],
|
|
"material_count": self._get_material_count(file_info['id']),
|
|
"comparison_with_previous": comparison_info,
|
|
"is_latest": i == 0, # 최신순 정렬이므로 첫 번째가 최신
|
|
"processing_status": self._get_processing_status(file_info['id'])
|
|
})
|
|
|
|
return history
|
|
|
|
def create_revision_comparison_record(
|
|
self,
|
|
job_no: str,
|
|
current_file_id: int,
|
|
previous_file_id: int,
|
|
comparison_result: Dict[str, Any],
|
|
created_by: str
|
|
) -> int:
|
|
"""리비전 비교 기록 생성"""
|
|
|
|
try:
|
|
comparison_record = RevisionComparison(
|
|
job_no=job_no,
|
|
current_file_id=current_file_id,
|
|
previous_file_id=previous_file_id,
|
|
comparison_result=comparison_result,
|
|
summary_stats=comparison_result.get("summary", {}),
|
|
created_by=created_by,
|
|
is_applied=False
|
|
)
|
|
|
|
self.db.add(comparison_record)
|
|
self.db.commit()
|
|
self.db.refresh(comparison_record)
|
|
|
|
logger.info(f"Created revision comparison record: {comparison_record.id}")
|
|
return comparison_record.id
|
|
|
|
except Exception as e:
|
|
self.db.rollback()
|
|
logger.error(f"Failed to create revision comparison record: {e}")
|
|
raise
|
|
|
|
def apply_revision_comparison(
|
|
self,
|
|
comparison_id: int,
|
|
applied_by: str
|
|
) -> Dict[str, Any]:
|
|
"""리비전 비교 결과 적용"""
|
|
|
|
try:
|
|
# 비교 기록 조회
|
|
comparison = self.db.query(RevisionComparison).filter(
|
|
RevisionComparison.id == comparison_id
|
|
).first()
|
|
|
|
if not comparison:
|
|
return {"success": False, "error": "비교 기록을 찾을 수 없습니다."}
|
|
|
|
if comparison.is_applied:
|
|
return {"success": False, "error": "이미 적용된 비교 결과입니다."}
|
|
|
|
# 적용 처리
|
|
comparison.is_applied = True
|
|
comparison.applied_at = datetime.utcnow()
|
|
comparison.applied_by = applied_by
|
|
|
|
# 변경 로그 생성
|
|
self._create_change_logs(comparison)
|
|
|
|
self.db.commit()
|
|
|
|
logger.info(f"Applied revision comparison: {comparison_id}")
|
|
|
|
return {
|
|
"success": True,
|
|
"comparison_id": comparison_id,
|
|
"applied_at": comparison.applied_at.isoformat(),
|
|
"applied_by": applied_by
|
|
}
|
|
|
|
except Exception as e:
|
|
self.db.rollback()
|
|
logger.error(f"Failed to apply revision comparison: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
def get_pending_revisions(self, job_no: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
"""대기 중인 리비전 목록 조회"""
|
|
|
|
query = """
|
|
SELECT
|
|
rc.id, rc.job_no, rc.current_file_id, rc.previous_file_id,
|
|
rc.comparison_date, rc.created_by, rc.summary_stats,
|
|
cf.original_filename as current_filename,
|
|
cf.revision as current_revision,
|
|
pf.original_filename as previous_filename,
|
|
pf.revision as previous_revision
|
|
FROM revision_comparisons rc
|
|
JOIN files cf ON rc.current_file_id = cf.id
|
|
LEFT JOIN files pf ON rc.previous_file_id = pf.id
|
|
WHERE rc.is_applied = false
|
|
"""
|
|
|
|
params = {}
|
|
if job_no:
|
|
query += " AND rc.job_no = :job_no"
|
|
params["job_no"] = job_no
|
|
|
|
query += " ORDER BY rc.comparison_date DESC"
|
|
|
|
result = self.db_service.execute_query(query, params)
|
|
|
|
pending_revisions = []
|
|
for row in result.fetchall():
|
|
row_dict = dict(row._mapping)
|
|
pending_revisions.append({
|
|
"comparison_id": row_dict['id'],
|
|
"job_no": row_dict['job_no'],
|
|
"current_file": {
|
|
"id": row_dict['current_file_id'],
|
|
"filename": row_dict['current_filename'],
|
|
"revision": row_dict['current_revision']
|
|
},
|
|
"previous_file": {
|
|
"id": row_dict['previous_file_id'],
|
|
"filename": row_dict['previous_filename'],
|
|
"revision": row_dict['previous_revision']
|
|
} if row_dict['previous_file_id'] else None,
|
|
"comparison_date": row_dict['comparison_date'],
|
|
"created_by": row_dict['created_by'],
|
|
"summary_stats": row_dict['summary_stats']
|
|
})
|
|
|
|
return pending_revisions
|
|
|
|
def _get_file_info(self, file_id: int) -> Optional[Dict[str, Any]]:
|
|
"""파일 정보 조회"""
|
|
|
|
query = """
|
|
SELECT
|
|
id, filename, original_filename, file_path, job_no, revision,
|
|
bom_name, description, file_size, parsed_count,
|
|
upload_date, uploaded_by, is_active
|
|
FROM files
|
|
WHERE id = :file_id
|
|
"""
|
|
|
|
result = self.db_service.execute_query(query, {"file_id": file_id})
|
|
row = result.fetchone()
|
|
|
|
return dict(row._mapping) if row else None
|
|
|
|
def _get_job_files(self, job_no: str) -> List[Dict[str, Any]]:
|
|
"""작업의 모든 파일 조회 (최신순)"""
|
|
|
|
query = """
|
|
SELECT
|
|
id, filename, original_filename, file_path, job_no, revision,
|
|
bom_name, description, file_size, parsed_count,
|
|
upload_date, uploaded_by, is_active
|
|
FROM files
|
|
WHERE job_no = :job_no AND is_active = true
|
|
ORDER BY upload_date DESC, id DESC
|
|
"""
|
|
|
|
result = self.db_service.execute_query(query, {"job_no": job_no})
|
|
|
|
return [dict(row._mapping) for row in result.fetchall()]
|
|
|
|
def _build_revision_history(self, all_files: List[Dict], current_file_id: int) -> Dict[str, Any]:
|
|
"""리비전 히스토리 구성"""
|
|
|
|
current_index = None
|
|
for i, file_info in enumerate(all_files):
|
|
if file_info['id'] == current_file_id:
|
|
current_index = i
|
|
break
|
|
|
|
if current_index is None:
|
|
return {"error": "현재 파일을 찾을 수 없습니다."}
|
|
|
|
return {
|
|
"total_revisions": len(all_files),
|
|
"current_position": current_index + 1, # 1-based
|
|
"is_latest": current_index == 0,
|
|
"is_first": current_index == len(all_files) - 1,
|
|
"can_upload_new": current_index == 0, # 최신 리비전에서만 새 리비전 업로드 가능
|
|
"previous_file_id": all_files[current_index + 1]['id'] if current_index < len(all_files) - 1 else None,
|
|
"next_file_id": all_files[current_index - 1]['id'] if current_index > 0 else None
|
|
}
|
|
|
|
def _get_processing_status(self, file_id: int) -> Dict[str, Any]:
|
|
"""파일의 처리 상태 조회"""
|
|
|
|
# 자재별 처리 상태 통계
|
|
query = """
|
|
SELECT
|
|
classified_category,
|
|
COUNT(*) as total_count,
|
|
SUM(CASE WHEN purchase_confirmed = true THEN 1 ELSE 0 END) as purchased_count,
|
|
SUM(CASE WHEN revision_status IS NOT NULL THEN 1 ELSE 0 END) as processed_count,
|
|
COUNT(DISTINCT COALESCE(revision_status, 'pending')) as status_types
|
|
FROM materials
|
|
WHERE file_id = :file_id AND is_active = true AND classified_category != 'PIPE'
|
|
GROUP BY classified_category
|
|
"""
|
|
|
|
result = self.db_service.execute_query(query, {"file_id": file_id})
|
|
|
|
category_status = {}
|
|
total_materials = 0
|
|
total_purchased = 0
|
|
total_processed = 0
|
|
|
|
for row in result.fetchall():
|
|
row_dict = dict(row._mapping)
|
|
category = row_dict['classified_category']
|
|
|
|
category_status[category] = {
|
|
"total": row_dict['total_count'],
|
|
"purchased": row_dict['purchased_count'],
|
|
"processed": row_dict['processed_count'],
|
|
"pending": row_dict['total_count'] - row_dict['processed_count']
|
|
}
|
|
|
|
total_materials += row_dict['total_count']
|
|
total_purchased += row_dict['purchased_count']
|
|
total_processed += row_dict['processed_count']
|
|
|
|
return {
|
|
"file_id": file_id,
|
|
"total_materials": total_materials,
|
|
"total_purchased": total_purchased,
|
|
"total_processed": total_processed,
|
|
"pending_processing": total_materials - total_processed,
|
|
"category_breakdown": category_status,
|
|
"completion_percentage": (total_processed / total_materials * 100) if total_materials > 0 else 0
|
|
}
|
|
|
|
def _get_comparison_summary(self, current_file_id: int, previous_file_id: int) -> Optional[Dict[str, Any]]:
|
|
"""비교 요약 정보 조회"""
|
|
|
|
query = """
|
|
SELECT summary_stats, comparison_date, is_applied
|
|
FROM revision_comparisons
|
|
WHERE current_file_id = :current_file_id AND previous_file_id = :previous_file_id
|
|
ORDER BY comparison_date DESC
|
|
LIMIT 1
|
|
"""
|
|
|
|
result = self.db_service.execute_query(query, {
|
|
"current_file_id": current_file_id,
|
|
"previous_file_id": previous_file_id
|
|
})
|
|
|
|
row = result.fetchone()
|
|
if row:
|
|
row_dict = dict(row._mapping)
|
|
return {
|
|
"summary_stats": row_dict['summary_stats'],
|
|
"comparison_date": row_dict['comparison_date'],
|
|
"is_applied": row_dict['is_applied']
|
|
}
|
|
|
|
return None
|
|
|
|
def _get_material_count(self, file_id: int) -> int:
|
|
"""파일의 자재 개수 조회"""
|
|
|
|
query = """
|
|
SELECT COUNT(*) as count
|
|
FROM materials
|
|
WHERE file_id = :file_id AND is_active = true AND classified_category != 'PIPE'
|
|
"""
|
|
|
|
result = self.db_service.execute_query(query, {"file_id": file_id})
|
|
row = result.fetchone()
|
|
|
|
return row.count if row else 0
|
|
|
|
def _create_change_logs(self, comparison: RevisionComparison):
|
|
"""변경 로그 생성"""
|
|
|
|
try:
|
|
changes = comparison.comparison_result.get("changes", {})
|
|
|
|
# 각 변경사항에 대해 로그 생성
|
|
for change_type, change_list in changes.items():
|
|
for change_item in change_list:
|
|
change_log = RevisionChangeLog(
|
|
comparison_id=comparison.id,
|
|
material_id=change_item.get("material", {}).get("id"),
|
|
change_type=change_type,
|
|
previous_data=change_item.get("previous"),
|
|
current_data=change_item.get("current") or change_item.get("material"),
|
|
action_taken=change_item.get("action", change_type),
|
|
notes=change_item.get("reason", "")
|
|
)
|
|
|
|
self.db.add(change_log)
|
|
|
|
logger.info(f"Created change logs for comparison {comparison.id}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to create change logs: {e}")
|
|
raise
|
|
|
|
def _generate_status_summary(self, processing_status: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""상태 요약 생성"""
|
|
|
|
total = processing_status.get("total_materials", 0)
|
|
processed = processing_status.get("total_processed", 0)
|
|
purchased = processing_status.get("total_purchased", 0)
|
|
|
|
if total == 0:
|
|
return {"status": "empty", "message": "자료가 없습니다."}
|
|
|
|
completion_rate = processed / total
|
|
|
|
if completion_rate >= 1.0:
|
|
status = "completed"
|
|
message = "모든 자재 처리 완료"
|
|
elif completion_rate >= 0.8:
|
|
status = "nearly_complete"
|
|
message = f"처리 진행 중 ({processed}/{total})"
|
|
elif completion_rate >= 0.5:
|
|
status = "in_progress"
|
|
message = f"처리 진행 중 ({processed}/{total})"
|
|
else:
|
|
status = "started"
|
|
message = f"처리 시작됨 ({processed}/{total})"
|
|
|
|
return {
|
|
"status": status,
|
|
"message": message,
|
|
"completion_rate": completion_rate,
|
|
"stats": {
|
|
"total": total,
|
|
"processed": processed,
|
|
"purchased": purchased,
|
|
"pending": total - processed
|
|
}
|
|
}
|