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 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
542 lines
23 KiB
Python
542 lines
23 KiB
Python
"""
|
|
PIPE 전용 리비전 관리 서비스
|
|
|
|
Cutting Plan 작성 전/후에 따른 차별화된 리비전 처리 로직
|
|
"""
|
|
|
|
import logging
|
|
from typing import Dict, List, Optional, Tuple, Any
|
|
from datetime import datetime
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import text, and_, or_
|
|
|
|
from ..database import get_db
|
|
from ..models import (
|
|
File, Material, PipeCuttingPlan, PipeRevisionComparison,
|
|
PipeRevisionChange, PipeLengthCalculation
|
|
)
|
|
from ..utils.pipe_utils import (
|
|
PipeConstants, PipeDataExtractor, PipeCalculator,
|
|
PipeComparator, PipeValidator, PipeLogger
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PipeRevisionService:
|
|
"""PIPE 전용 리비전 관리 서비스"""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
|
|
def check_revision_status(self, job_no: str, new_file_id: int) -> Dict[str, Any]:
|
|
"""
|
|
리비전 상태 확인 및 처리 방식 결정
|
|
|
|
Returns:
|
|
- revision_type: 'no_revision', 'pre_cutting_plan', 'post_cutting_plan'
|
|
- requires_action: 처리가 필요한지 여부
|
|
- message: 사용자에게 표시할 메시지
|
|
"""
|
|
try:
|
|
# 기존 파일 확인
|
|
previous_file = self._get_previous_file(job_no, new_file_id)
|
|
if not previous_file:
|
|
return {
|
|
"revision_type": "no_revision",
|
|
"requires_action": False,
|
|
"message": "첫 번째 BOM 파일입니다. 새로운 Cutting Plan을 작성해주세요."
|
|
}
|
|
|
|
# Cutting Plan 존재 여부 확인
|
|
has_cutting_plan = self._has_existing_cutting_plan(job_no)
|
|
|
|
if not has_cutting_plan:
|
|
# Cutting Plan 작성 전 리비전
|
|
return {
|
|
"revision_type": "pre_cutting_plan",
|
|
"requires_action": True,
|
|
"previous_file_id": previous_file.id,
|
|
"message": "Cutting Plan 작성 전 리비전이 감지되었습니다. 새로운 BOM으로 Cutting Plan을 작성해주세요."
|
|
}
|
|
else:
|
|
# Cutting Plan 작성 후 리비전
|
|
return {
|
|
"revision_type": "post_cutting_plan",
|
|
"requires_action": True,
|
|
"previous_file_id": previous_file.id,
|
|
"message": "기존 Cutting Plan이 있는 상태에서 리비전이 감지되었습니다. 변경사항을 비교 검토해주세요."
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to check pipe revision status: {e}")
|
|
return {
|
|
"revision_type": "error",
|
|
"requires_action": False,
|
|
"message": f"리비전 상태 확인 중 오류가 발생했습니다: {str(e)}"
|
|
}
|
|
|
|
def handle_pre_cutting_plan_revision(self, job_no: str, new_file_id: int) -> Dict[str, Any]:
|
|
"""
|
|
Cutting Plan 작성 전 리비전 처리
|
|
- 기존 PIPE 관련 데이터 전체 삭제
|
|
- 새 BOM 파일로 초기화
|
|
"""
|
|
try:
|
|
logger.info(f"Processing pre-cutting-plan revision for job {job_no}")
|
|
|
|
# 1. 기존 PIPE 관련 데이터 삭제
|
|
deleted_count = self._delete_existing_pipe_data(job_no)
|
|
|
|
# 2. 새 BOM에서 PIPE 데이터 추출
|
|
pipe_materials = self._extract_pipe_materials_from_bom(new_file_id)
|
|
|
|
# 3. 처리 결과 반환
|
|
return {
|
|
"status": "success",
|
|
"revision_type": "pre_cutting_plan",
|
|
"deleted_items": deleted_count,
|
|
"new_pipe_materials": len(pipe_materials),
|
|
"message": f"기존 PIPE 데이터 {deleted_count}건이 삭제되었습니다. 새로운 Cutting Plan을 작성해주세요.",
|
|
"next_action": "create_new_cutting_plan",
|
|
"pipe_materials": pipe_materials
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to handle pre-cutting-plan revision: {e}")
|
|
return {
|
|
"status": "error",
|
|
"message": f"Cutting Plan 작성 전 리비전 처리 실패: {str(e)}"
|
|
}
|
|
|
|
def handle_post_cutting_plan_revision(self, job_no: str, new_file_id: int) -> Dict[str, Any]:
|
|
"""
|
|
Cutting Plan 작성 후 리비전 처리
|
|
- 기존 Cutting Plan과 신규 BOM 비교
|
|
- 변경사항 상세 분석
|
|
"""
|
|
try:
|
|
logger.info(f"Processing post-cutting-plan revision for job {job_no}")
|
|
|
|
# 1. 기존 Cutting Plan 조회
|
|
existing_plan = self._get_existing_cutting_plan_data(job_no)
|
|
if not existing_plan:
|
|
return {
|
|
"status": "error",
|
|
"message": "기존 Cutting Plan을 찾을 수 없습니다."
|
|
}
|
|
|
|
# 2. 새 BOM에서 PIPE 데이터 추출
|
|
new_pipe_data = self._extract_pipe_materials_from_bom(new_file_id)
|
|
|
|
# 3. 도면별 비교 수행
|
|
comparison_result = self._compare_pipe_data_by_drawing(existing_plan, new_pipe_data)
|
|
|
|
# 4. 비교 결과 저장
|
|
comparison_id = self._save_comparison_result(job_no, new_file_id, comparison_result)
|
|
|
|
# 5. 변경사항 요약
|
|
summary = self._generate_comparison_summary(comparison_result)
|
|
|
|
return {
|
|
"status": "success",
|
|
"revision_type": "post_cutting_plan",
|
|
"comparison_id": comparison_id,
|
|
"summary": summary,
|
|
"changed_drawings": [d for d in comparison_result if d["has_changes"]],
|
|
"unchanged_drawings": [d for d in comparison_result if not d["has_changes"]],
|
|
"message": f"리비전 비교가 완료되었습니다. {summary['changed_drawings_count']}개 도면에서 변경사항이 발견되었습니다.",
|
|
"next_action": "review_changes"
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to handle post-cutting-plan revision: {e}")
|
|
return {
|
|
"status": "error",
|
|
"message": f"Cutting Plan 작성 후 리비전 처리 실패: {str(e)}"
|
|
}
|
|
|
|
def _get_previous_file(self, job_no: str, current_file_id: int) -> Optional[File]:
|
|
"""이전 파일 조회"""
|
|
return self.db.query(File).filter(
|
|
and_(
|
|
File.job_no == job_no,
|
|
File.id < current_file_id,
|
|
File.is_active == True
|
|
)
|
|
).order_by(File.id.desc()).first()
|
|
|
|
def _has_existing_cutting_plan(self, job_no: str) -> bool:
|
|
"""기존 Cutting Plan 존재 여부 확인"""
|
|
count = self.db.query(PipeCuttingPlan).filter(
|
|
PipeCuttingPlan.job_no == job_no
|
|
).count()
|
|
return count > 0
|
|
|
|
def _delete_existing_pipe_data(self, job_no: str) -> int:
|
|
"""기존 PIPE 관련 데이터 삭제"""
|
|
try:
|
|
# Cutting Plan 데이터 삭제
|
|
cutting_plan_count = self.db.query(PipeCuttingPlan).filter(
|
|
PipeCuttingPlan.job_no == job_no
|
|
).count()
|
|
|
|
self.db.query(PipeCuttingPlan).filter(
|
|
PipeCuttingPlan.job_no == job_no
|
|
).delete()
|
|
|
|
# Length Calculation 데이터 삭제
|
|
self.db.query(PipeLengthCalculation).filter(
|
|
PipeLengthCalculation.file_id.in_(
|
|
self.db.query(File.id).filter(File.job_no == job_no)
|
|
)
|
|
).delete()
|
|
|
|
self.db.commit()
|
|
logger.info(f"Deleted {cutting_plan_count} cutting plan records for job {job_no}")
|
|
|
|
return cutting_plan_count
|
|
|
|
except Exception as e:
|
|
self.db.rollback()
|
|
logger.error(f"Failed to delete existing pipe data: {e}")
|
|
raise
|
|
|
|
def _extract_pipe_materials_from_bom(self, file_id: int) -> List[Dict[str, Any]]:
|
|
"""BOM 파일에서 PIPE 자재 추출 (리팩토링된 유틸리티 사용)"""
|
|
return PipeDataExtractor.extract_pipe_materials_from_file(self.db, file_id)
|
|
|
|
def _get_existing_cutting_plan_data(self, job_no: str) -> List[Dict[str, Any]]:
|
|
"""기존 Cutting Plan 데이터 조회"""
|
|
try:
|
|
cutting_plans = self.db.query(PipeCuttingPlan).filter(
|
|
PipeCuttingPlan.job_no == job_no
|
|
).all()
|
|
|
|
plan_data = []
|
|
for plan in cutting_plans:
|
|
plan_data.append({
|
|
"id": plan.id,
|
|
"area": plan.area or "",
|
|
"drawing_name": plan.drawing_name,
|
|
"line_no": plan.line_no,
|
|
"material_grade": plan.material_grade or "",
|
|
"schedule_spec": plan.schedule_spec or "",
|
|
"nominal_size": plan.nominal_size or "",
|
|
"length_mm": float(plan.length_mm or 0),
|
|
"end_preparation": plan.end_preparation or "무개선"
|
|
})
|
|
|
|
logger.info(f"Retrieved {len(plan_data)} cutting plan records for job {job_no}")
|
|
return plan_data
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get existing cutting plan data: {e}")
|
|
raise
|
|
|
|
def _compare_pipe_data_by_drawing(self, existing_plan: List[Dict], new_pipe_data: List[Dict]) -> List[Dict[str, Any]]:
|
|
"""도면별 PIPE 데이터 비교"""
|
|
try:
|
|
# 도면별로 데이터 그룹화
|
|
existing_by_drawing = self._group_by_drawing(existing_plan)
|
|
new_by_drawing = self._group_by_drawing(new_pipe_data)
|
|
|
|
# 모든 도면 목록
|
|
all_drawings = set(existing_by_drawing.keys()) | set(new_by_drawing.keys())
|
|
|
|
comparison_results = []
|
|
|
|
for drawing_name in sorted(all_drawings):
|
|
existing_segments = existing_by_drawing.get(drawing_name, [])
|
|
new_segments = new_by_drawing.get(drawing_name, [])
|
|
|
|
# 도면별 비교 수행
|
|
drawing_comparison = self._compare_drawing_segments(
|
|
drawing_name, existing_segments, new_segments
|
|
)
|
|
|
|
comparison_results.append(drawing_comparison)
|
|
|
|
return comparison_results
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to compare pipe data by drawing: {e}")
|
|
raise
|
|
|
|
def _group_by_drawing(self, data: List[Dict]) -> Dict[str, List[Dict]]:
|
|
"""데이터를 도면별로 그룹화"""
|
|
grouped = {}
|
|
for item in data:
|
|
drawing = item.get("drawing_name", "UNKNOWN")
|
|
if drawing not in grouped:
|
|
grouped[drawing] = []
|
|
grouped[drawing].append(item)
|
|
return grouped
|
|
|
|
def _compare_drawing_segments(self, drawing_name: str, existing: List[Dict], new: List[Dict]) -> Dict[str, Any]:
|
|
"""단일 도면의 세그먼트 비교"""
|
|
try:
|
|
# 세그먼트 매칭 (재질, 길이, 끝단가공 기준)
|
|
matched_pairs, added_segments, removed_segments = self._match_segments(existing, new)
|
|
|
|
# 변경사항 분석
|
|
unchanged_segments = []
|
|
modified_segments = []
|
|
|
|
for existing_seg, new_seg in matched_pairs:
|
|
if self._segments_are_identical(existing_seg, new_seg):
|
|
unchanged_segments.append({
|
|
"change_type": "unchanged",
|
|
"segment_data": new_seg,
|
|
"existing_data": existing_seg
|
|
})
|
|
else:
|
|
changes = self._get_segment_changes(existing_seg, new_seg)
|
|
modified_segments.append({
|
|
"change_type": "modified",
|
|
"segment_data": new_seg,
|
|
"existing_data": existing_seg,
|
|
"changes": changes
|
|
})
|
|
|
|
# 추가된 세그먼트
|
|
added_segment_data = [
|
|
{
|
|
"change_type": "added",
|
|
"segment_data": seg,
|
|
"existing_data": None
|
|
}
|
|
for seg in added_segments
|
|
]
|
|
|
|
# 삭제된 세그먼트
|
|
removed_segment_data = [
|
|
{
|
|
"change_type": "removed",
|
|
"segment_data": None,
|
|
"existing_data": seg
|
|
}
|
|
for seg in removed_segments
|
|
]
|
|
|
|
# 전체 세그먼트 목록
|
|
all_segments = unchanged_segments + modified_segments + added_segment_data + removed_segment_data
|
|
|
|
# 변경사항 여부 판단
|
|
has_changes = len(modified_segments) > 0 or len(added_segments) > 0 or len(removed_segments) > 0
|
|
|
|
return {
|
|
"drawing_name": drawing_name,
|
|
"has_changes": has_changes,
|
|
"segments": all_segments,
|
|
"summary": {
|
|
"total_segments": len(all_segments),
|
|
"unchanged_count": len(unchanged_segments),
|
|
"modified_count": len(modified_segments),
|
|
"added_count": len(added_segments),
|
|
"removed_count": len(removed_segments)
|
|
}
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to compare segments for drawing {drawing_name}: {e}")
|
|
raise
|
|
|
|
def _match_segments(self, existing: List[Dict], new: List[Dict]) -> Tuple[List[Tuple], List[Dict], List[Dict]]:
|
|
"""세그먼트 매칭 (재질, 길이 기준)"""
|
|
matched_pairs = []
|
|
remaining_new = new.copy()
|
|
remaining_existing = existing.copy()
|
|
|
|
# 정확히 일치하는 세그먼트 찾기
|
|
for existing_seg in existing.copy():
|
|
for new_seg in remaining_new.copy():
|
|
if self._segments_match_for_pairing(existing_seg, new_seg):
|
|
matched_pairs.append((existing_seg, new_seg))
|
|
remaining_existing.remove(existing_seg)
|
|
remaining_new.remove(new_seg)
|
|
break
|
|
|
|
# 남은 것들은 추가/삭제로 분류
|
|
added_segments = remaining_new
|
|
removed_segments = remaining_existing
|
|
|
|
return matched_pairs, added_segments, removed_segments
|
|
|
|
def _segments_match_for_pairing(self, seg1: Dict, seg2: Dict) -> bool:
|
|
"""세그먼트 매칭 기준 (재질과 길이가 유사한지 확인)"""
|
|
# 재질 비교
|
|
material1 = seg1.get("material_grade", "").strip()
|
|
material2 = seg2.get("material_grade", "").strip()
|
|
|
|
# 길이 비교 (허용 오차 1mm)
|
|
length1 = seg1.get("length_mm", seg1.get("length", 0))
|
|
length2 = seg2.get("length_mm", seg2.get("length", 0))
|
|
|
|
material_match = material1.lower() == material2.lower()
|
|
length_match = abs(float(length1) - float(length2)) <= 1.0
|
|
|
|
return material_match and length_match
|
|
|
|
def _segments_are_identical(self, seg1: Dict, seg2: Dict) -> bool:
|
|
"""세그먼트 완전 동일성 검사"""
|
|
# 주요 속성들 비교
|
|
material_match = seg1.get("material_grade", "").strip().lower() == seg2.get("material_grade", "").strip().lower()
|
|
|
|
length1 = seg1.get("length_mm", seg1.get("length", 0))
|
|
length2 = seg2.get("length_mm", seg2.get("length", 0))
|
|
length_match = abs(float(length1) - float(length2)) <= 0.1
|
|
|
|
end_prep1 = seg1.get("end_preparation", "무개선")
|
|
end_prep2 = seg2.get("end_preparation", "무개선")
|
|
end_prep_match = end_prep1 == end_prep2
|
|
|
|
return material_match and length_match and end_prep_match
|
|
|
|
def _get_segment_changes(self, existing: Dict, new: Dict) -> List[Dict[str, Any]]:
|
|
"""세그먼트 변경사항 상세 분석"""
|
|
changes = []
|
|
|
|
# 재질 변경
|
|
old_material = existing.get("material_grade", "").strip()
|
|
new_material = new.get("material_grade", "").strip()
|
|
if old_material.lower() != new_material.lower():
|
|
changes.append({
|
|
"field": "material_grade",
|
|
"old_value": old_material,
|
|
"new_value": new_material
|
|
})
|
|
|
|
# 길이 변경
|
|
old_length = existing.get("length_mm", existing.get("length", 0))
|
|
new_length = new.get("length_mm", new.get("length", 0))
|
|
if abs(float(old_length) - float(new_length)) > 0.1:
|
|
changes.append({
|
|
"field": "length",
|
|
"old_value": f"{old_length}mm",
|
|
"new_value": f"{new_length}mm"
|
|
})
|
|
|
|
# 끝단가공 변경
|
|
old_end_prep = existing.get("end_preparation", "무개선")
|
|
new_end_prep = new.get("end_preparation", "무개선")
|
|
if old_end_prep != new_end_prep:
|
|
changes.append({
|
|
"field": "end_preparation",
|
|
"old_value": old_end_prep,
|
|
"new_value": new_end_prep
|
|
})
|
|
|
|
return changes
|
|
|
|
def _save_comparison_result(self, job_no: str, new_file_id: int, comparison_result: List[Dict]) -> int:
|
|
"""비교 결과를 데이터베이스에 저장"""
|
|
try:
|
|
# 이전 파일 ID 조회
|
|
previous_file = self._get_previous_file(job_no, new_file_id)
|
|
previous_file_id = previous_file.id if previous_file else None
|
|
|
|
# 통계 계산
|
|
total_drawings = len(comparison_result)
|
|
changed_drawings = len([d for d in comparison_result if d["has_changes"]])
|
|
unchanged_drawings = total_drawings - changed_drawings
|
|
|
|
total_segments = sum(d["summary"]["total_segments"] for d in comparison_result)
|
|
added_segments = sum(d["summary"]["added_count"] for d in comparison_result)
|
|
removed_segments = sum(d["summary"]["removed_count"] for d in comparison_result)
|
|
modified_segments = sum(d["summary"]["modified_count"] for d in comparison_result)
|
|
unchanged_segments = sum(d["summary"]["unchanged_count"] for d in comparison_result)
|
|
|
|
# 비교 결과 저장
|
|
comparison = PipeRevisionComparison(
|
|
job_no=job_no,
|
|
current_file_id=new_file_id,
|
|
previous_cutting_plan_id=None, # 추후 구현
|
|
total_drawings=total_drawings,
|
|
changed_drawings=changed_drawings,
|
|
unchanged_drawings=unchanged_drawings,
|
|
total_segments=total_segments,
|
|
added_segments=added_segments,
|
|
removed_segments=removed_segments,
|
|
modified_segments=modified_segments,
|
|
unchanged_segments=unchanged_segments,
|
|
created_by="system"
|
|
)
|
|
|
|
self.db.add(comparison)
|
|
self.db.flush() # ID 생성을 위해
|
|
|
|
# 상세 변경사항 저장
|
|
for drawing_data in comparison_result:
|
|
if drawing_data["has_changes"]:
|
|
for segment in drawing_data["segments"]:
|
|
if segment["change_type"] != "unchanged":
|
|
change = PipeRevisionChange(
|
|
comparison_id=comparison.id,
|
|
drawing_name=drawing_data["drawing_name"],
|
|
change_type=segment["change_type"]
|
|
)
|
|
|
|
# 기존 데이터
|
|
if segment["existing_data"]:
|
|
existing = segment["existing_data"]
|
|
change.old_line_no = existing.get("line_no")
|
|
change.old_material_grade = existing.get("material_grade")
|
|
change.old_schedule_spec = existing.get("schedule_spec")
|
|
change.old_nominal_size = existing.get("nominal_size")
|
|
change.old_length_mm = existing.get("length_mm", existing.get("length"))
|
|
change.old_end_preparation = existing.get("end_preparation")
|
|
|
|
# 새 데이터
|
|
if segment["segment_data"]:
|
|
new_data = segment["segment_data"]
|
|
change.new_line_no = new_data.get("line_no")
|
|
change.new_material_grade = new_data.get("material_grade")
|
|
change.new_schedule_spec = new_data.get("schedule_spec")
|
|
change.new_nominal_size = new_data.get("nominal_size")
|
|
change.new_length_mm = new_data.get("length_mm", new_data.get("length"))
|
|
change.new_end_preparation = new_data.get("end_preparation")
|
|
|
|
self.db.add(change)
|
|
|
|
self.db.commit()
|
|
logger.info(f"Saved comparison result with ID {comparison.id}")
|
|
|
|
return comparison.id
|
|
|
|
except Exception as e:
|
|
self.db.rollback()
|
|
logger.error(f"Failed to save comparison result: {e}")
|
|
raise
|
|
|
|
def _generate_comparison_summary(self, comparison_result: List[Dict]) -> Dict[str, Any]:
|
|
"""비교 결과 요약 생성"""
|
|
total_drawings = len(comparison_result)
|
|
changed_drawings = [d for d in comparison_result if d["has_changes"]]
|
|
changed_drawings_count = len(changed_drawings)
|
|
|
|
total_segments = sum(d["summary"]["total_segments"] for d in comparison_result)
|
|
added_segments = sum(d["summary"]["added_count"] for d in comparison_result)
|
|
removed_segments = sum(d["summary"]["removed_count"] for d in comparison_result)
|
|
modified_segments = sum(d["summary"]["modified_count"] for d in comparison_result)
|
|
unchanged_segments = sum(d["summary"]["unchanged_count"] for d in comparison_result)
|
|
|
|
return {
|
|
"total_drawings": total_drawings,
|
|
"changed_drawings_count": changed_drawings_count,
|
|
"unchanged_drawings_count": total_drawings - changed_drawings_count,
|
|
"total_segments": total_segments,
|
|
"added_segments": added_segments,
|
|
"removed_segments": removed_segments,
|
|
"modified_segments": modified_segments,
|
|
"unchanged_segments": unchanged_segments,
|
|
"change_percentage": round((changed_drawings_count / total_drawings * 100) if total_drawings > 0 else 0, 1)
|
|
}
|
|
|
|
|
|
def get_pipe_revision_service(db: Session = None) -> PipeRevisionService:
|
|
"""PipeRevisionService 인스턴스 생성"""
|
|
if db is None:
|
|
db = next(get_db())
|
|
return PipeRevisionService(db)
|