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 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
363 lines
14 KiB
Python
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)
|