Files
TK-BOM-Project/backend/app/services/pipe_revision_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

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)