""" 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)