Files
TK-BOM-Project/backend/app/services/pipe_issue_snapshot_service.py
Hyungi Ahn 8f42a1054e
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

앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
2025-10-21 10:34:45 +09:00

363 lines
14 KiB
Python

"""
PIPE 이슈 관리용 스냅샷 시스템
단관 관리 DB의 특정 시점 데이터를 고정하여
이후 리비전이 발생해도 이슈 관리에 영향을 주지 않도록 함
"""
import logging
from typing import Dict, List, Optional, Any
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import text, and_, or_
from ..database import get_db
from ..models import (
PipeCuttingPlan, PipeIssueSnapshot, PipeIssueSegment,
PipeDrawingIssue, PipeSegmentIssue
)
logger = logging.getLogger(__name__)
class PipeIssueSnapshotService:
"""PIPE 이슈 관리용 스냅샷 서비스"""
def __init__(self, db: Session):
self.db = db
def create_and_lock_snapshot_on_finalize(self, job_no: str, created_by: str = "system") -> Dict[str, Any]:
"""
Cutting Plan 확정 시 스냅샷 생성 및 즉시 잠금
Args:
job_no: 작업 번호
created_by: 생성자
Returns:
생성된 스냅샷 정보
"""
try:
# 1. 기존 활성 스냅샷 확인
existing_snapshot = self.db.query(PipeIssueSnapshot).filter(
and_(
PipeIssueSnapshot.job_no == job_no,
PipeIssueSnapshot.is_active == True
)
).first()
if existing_snapshot:
return {
"success": False,
"message": f"이미 확정된 Cutting Plan이 존재합니다: {existing_snapshot.snapshot_name}",
"existing_snapshot_id": existing_snapshot.id,
"can_manage_issues": True
}
# 2. 현재 단관 데이터 조회
current_segments = self._get_current_cutting_plan_data(job_no)
if not current_segments:
return {
"success": False,
"message": "확정할 Cutting Plan 데이터가 없습니다."
}
# 3. 자동 스냅샷 이름 생성
snapshot_name = f"Cutting Plan 확정 - {datetime.now().strftime('%Y%m%d_%H%M%S')}"
# 4. 스냅샷 레코드 생성 (즉시 잠금 상태)
snapshot = PipeIssueSnapshot(
job_no=job_no,
snapshot_name=snapshot_name,
created_by=created_by,
is_locked=True, # 확정과 동시에 잠금
locked_at=datetime.utcnow(),
locked_by=created_by,
total_segments=len(current_segments),
total_drawings=len(set(seg["drawing_name"] for seg in current_segments))
)
self.db.add(snapshot)
self.db.flush() # ID 생성을 위해
# 4. 단관 데이터 스냅샷 저장
snapshot_segments = []
for segment_data in current_segments:
segment = PipeIssueSegment(
snapshot_id=snapshot.id,
area=segment_data.get("area"),
drawing_name=segment_data["drawing_name"],
line_no=segment_data["line_no"],
material_grade=segment_data.get("material_grade"),
schedule_spec=segment_data.get("schedule_spec"),
nominal_size=segment_data.get("nominal_size"),
length_mm=segment_data["length_mm"],
end_preparation=segment_data.get("end_preparation", "무개선"),
original_cutting_plan_id=segment_data.get("original_id")
)
snapshot_segments.append(segment)
self.db.add_all(snapshot_segments)
self.db.commit()
logger.info(f"Created snapshot {snapshot.id} for job {job_no} with {len(snapshot_segments)} segments")
return {
"success": True,
"snapshot_id": snapshot.id,
"snapshot_name": snapshot_name,
"total_segments": len(snapshot_segments),
"total_drawings": snapshot.total_drawings,
"is_locked": True,
"locked_at": snapshot.locked_at,
"message": f"Cutting Plan이 확정되었습니다! 이제 이슈 관리를 시작할 수 있습니다.",
"next_action": "start_issue_management"
}
except Exception as e:
self.db.rollback()
logger.error(f"Failed to create snapshot: {e}")
return {
"success": False,
"message": f"스냅샷 생성 실패: {str(e)}"
}
def lock_snapshot(self, snapshot_id: int, locked_by: str = "system") -> Dict[str, Any]:
"""
스냅샷 잠금 (이슈 등록 시작)
잠금 후에는 더 이상 리비전 영향을 받지 않음
"""
try:
snapshot = self.db.query(PipeIssueSnapshot).filter(
PipeIssueSnapshot.id == snapshot_id
).first()
if not snapshot:
return {
"success": False,
"message": "스냅샷을 찾을 수 없습니다."
}
if snapshot.is_locked:
return {
"success": False,
"message": f"이미 잠긴 스냅샷입니다. (잠금자: {snapshot.locked_by})"
}
# 스냅샷 잠금
snapshot.is_locked = True
snapshot.locked_at = datetime.utcnow()
snapshot.locked_by = locked_by
self.db.commit()
logger.info(f"Locked snapshot {snapshot_id} by {locked_by}")
return {
"success": True,
"message": f"스냅샷 '{snapshot.snapshot_name}'이 잠금되었습니다. 이제 이슈 관리를 시작할 수 있습니다.",
"locked_at": snapshot.locked_at
}
except Exception as e:
self.db.rollback()
logger.error(f"Failed to lock snapshot: {e}")
return {
"success": False,
"message": f"스냅샷 잠금 실패: {str(e)}"
}
def get_snapshot_info(self, job_no: str) -> Dict[str, Any]:
"""작업의 스냅샷 정보 조회"""
try:
snapshot = self.db.query(PipeIssueSnapshot).filter(
and_(
PipeIssueSnapshot.job_no == job_no,
PipeIssueSnapshot.is_active == True
)
).first()
if not snapshot:
return {
"has_snapshot": False,
"message": "생성된 스냅샷이 없습니다."
}
# 이슈 통계 조회
drawing_issues_count = self.db.query(PipeDrawingIssue).filter(
PipeDrawingIssue.snapshot_id == snapshot.id
).count()
segment_issues_count = self.db.query(PipeSegmentIssue).filter(
PipeSegmentIssue.snapshot_id == snapshot.id
).count()
return {
"has_snapshot": True,
"snapshot_id": snapshot.id,
"snapshot_name": snapshot.snapshot_name,
"is_locked": snapshot.is_locked,
"created_at": snapshot.created_at,
"created_by": snapshot.created_by,
"locked_at": snapshot.locked_at,
"locked_by": snapshot.locked_by,
"total_segments": snapshot.total_segments,
"total_drawings": snapshot.total_drawings,
"drawing_issues_count": drawing_issues_count,
"segment_issues_count": segment_issues_count,
"can_start_issue_management": not snapshot.is_locked,
"message": "잠긴 스냅샷 - 이슈 관리 진행 중" if snapshot.is_locked else "스냅샷 준비 완료"
}
except Exception as e:
logger.error(f"Failed to get snapshot info: {e}")
return {
"has_snapshot": False,
"message": f"스냅샷 정보 조회 실패: {str(e)}"
}
def get_snapshot_segments(self, snapshot_id: int, area: str = None, drawing_name: str = None) -> List[Dict[str, Any]]:
"""스냅샷된 단관 데이터 조회"""
try:
query = self.db.query(PipeIssueSegment).filter(
PipeIssueSegment.snapshot_id == snapshot_id
)
if area:
query = query.filter(PipeIssueSegment.area == area)
if drawing_name:
query = query.filter(PipeIssueSegment.drawing_name == drawing_name)
segments = query.order_by(
PipeIssueSegment.area,
PipeIssueSegment.drawing_name,
PipeIssueSegment.line_no
).all()
result = []
for segment in segments:
result.append({
"id": segment.id,
"area": segment.area,
"drawing_name": segment.drawing_name,
"line_no": segment.line_no,
"material_grade": segment.material_grade,
"schedule_spec": segment.schedule_spec,
"nominal_size": segment.nominal_size,
"length_mm": float(segment.length_mm) if segment.length_mm else 0,
"end_preparation": segment.end_preparation,
"material_info": f"{segment.material_grade or ''} {segment.schedule_spec or ''} {segment.nominal_size or ''}".strip()
})
return result
except Exception as e:
logger.error(f"Failed to get snapshot segments: {e}")
return []
def get_available_areas(self, snapshot_id: int) -> List[str]:
"""스냅샷의 사용 가능한 구역 목록"""
try:
result = self.db.query(PipeIssueSegment.area).filter(
and_(
PipeIssueSegment.snapshot_id == snapshot_id,
PipeIssueSegment.area.isnot(None)
)
).distinct().all()
areas = [row.area for row in result if row.area]
return sorted(areas)
except Exception as e:
logger.error(f"Failed to get available areas: {e}")
return []
def get_available_drawings(self, snapshot_id: int, area: str = None) -> List[str]:
"""스냅샷의 사용 가능한 도면 목록"""
try:
query = self.db.query(PipeIssueSegment.drawing_name).filter(
PipeIssueSegment.snapshot_id == snapshot_id
)
if area:
query = query.filter(PipeIssueSegment.area == area)
result = query.distinct().all()
drawings = [row.drawing_name for row in result if row.drawing_name]
return sorted(drawings)
except Exception as e:
logger.error(f"Failed to get available drawings: {e}")
return []
def _get_current_cutting_plan_data(self, job_no: str) -> List[Dict[str, Any]]:
"""현재 단관 관리 DB에서 데이터 조회"""
try:
cutting_plans = self.db.query(PipeCuttingPlan).filter(
PipeCuttingPlan.job_no == job_no
).all()
segments = []
for plan in cutting_plans:
segments.append({
"original_id": plan.id,
"area": plan.area,
"drawing_name": plan.drawing_name,
"line_no": plan.line_no,
"material_grade": plan.material_grade,
"schedule_spec": plan.schedule_spec,
"nominal_size": plan.nominal_size,
"length_mm": float(plan.length_mm) if plan.length_mm else 0,
"end_preparation": plan.end_preparation or "무개선"
})
return segments
except Exception as e:
logger.error(f"Failed to get current cutting plan data: {e}")
return []
def check_revision_protection(self, job_no: str) -> Dict[str, Any]:
"""
리비전 보호 상태 확인
잠긴 스냅샷이 있으면 더 이상 리비전 영향을 받지 않음
"""
try:
snapshot = self.db.query(PipeIssueSnapshot).filter(
and_(
PipeIssueSnapshot.job_no == job_no,
PipeIssueSnapshot.is_active == True,
PipeIssueSnapshot.is_locked == True
)
).first()
if snapshot:
return {
"is_protected": True,
"snapshot_id": snapshot.id,
"snapshot_name": snapshot.snapshot_name,
"locked_at": snapshot.locked_at,
"locked_by": snapshot.locked_by,
"message": f"이슈 관리가 진행 중입니다. 스냅샷 '{snapshot.snapshot_name}'이 보호되고 있습니다."
}
else:
return {
"is_protected": False,
"message": "리비전 보호가 활성화되지 않았습니다."
}
except Exception as e:
logger.error(f"Failed to check revision protection: {e}")
return {
"is_protected": False,
"message": f"리비전 보호 상태 확인 실패: {str(e)}"
}
def get_pipe_issue_snapshot_service(db: Session = None) -> PipeIssueSnapshotService:
"""PipeIssueSnapshotService 인스턴스 생성"""
if db is None:
db = next(get_db())
return PipeIssueSnapshotService(db)