""" 리비전 비교 서비스 - 기존 확정 자재와 신규 자재 비교 - 변경된 자재만 분류 처리 - 리비전 업로드 최적화 """ from sqlalchemy.orm import Session from sqlalchemy import text from typing import List, Dict, Tuple, Optional import hashlib import logging logger = logging.getLogger(__name__) class RevisionComparator: """리비전 비교 및 차이 분석 클래스""" def __init__(self, db: Session): self.db = db def get_previous_confirmed_materials(self, job_no: str, current_revision: str) -> Optional[Dict]: """ 이전 확정된 자재 목록 조회 Args: job_no: 프로젝트 번호 current_revision: 현재 리비전 (예: Rev.1) Returns: 확정된 자재 정보 딕셔너리 또는 None """ try: # 현재 리비전 번호 추출 current_rev_num = self._extract_revision_number(current_revision) # 이전 리비전들 중 확정된 것 찾기 (역순으로 검색) for prev_rev_num in range(current_rev_num - 1, -1, -1): prev_revision = f"Rev.{prev_rev_num}" # 해당 리비전의 확정 데이터 조회 query = text(""" SELECT pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by, COUNT(cpi.id) as confirmed_items_count FROM purchase_confirmations pc LEFT JOIN confirmed_purchase_items cpi ON pc.id = cpi.confirmation_id WHERE pc.job_no = :job_no AND pc.revision = :revision AND pc.is_active = TRUE GROUP BY pc.id, pc.revision, pc.confirmed_at, pc.confirmed_by ORDER BY pc.confirmed_at DESC LIMIT 1 """) result = self.db.execute(query, { "job_no": job_no, "revision": prev_revision }).fetchone() if result and result.confirmed_items_count > 0: logger.info(f"이전 확정 자료 발견: {job_no} {prev_revision} ({result.confirmed_items_count}개 품목)") # 확정된 품목들 상세 조회 items_query = text(""" SELECT cpi.item_code, cpi.category, cpi.specification, cpi.size, cpi.material, cpi.bom_quantity, cpi.calculated_qty, cpi.unit, cpi.safety_factor FROM confirmed_purchase_items cpi WHERE cpi.confirmation_id = :confirmation_id ORDER BY cpi.category, cpi.specification """) items_result = self.db.execute(items_query, { "confirmation_id": result.id }).fetchall() return { "confirmation_id": result.id, "revision": result.revision, "confirmed_at": result.confirmed_at, "confirmed_by": result.confirmed_by, "items": [dict(item) for item in items_result], "items_count": len(items_result) } logger.info(f"이전 확정 자료 없음: {job_no} (현재: {current_revision})") return None except Exception as e: logger.error(f"이전 확정 자료 조회 실패: {str(e)}") return None def compare_materials(self, previous_confirmed: Dict, new_materials: List[Dict]) -> Dict: """ 기존 확정 자재와 신규 자재 비교 Args: previous_confirmed: 이전 확정 자재 정보 new_materials: 신규 업로드된 자재 목록 Returns: 비교 결과 딕셔너리 """ try: # 이전 확정 자재를 해시맵으로 변환 (빠른 검색을 위해) confirmed_materials = {} for item in previous_confirmed["items"]: material_hash = self._generate_material_hash( item["specification"], item["size"], item["material"] ) confirmed_materials[material_hash] = item # 신규 자재 분석 unchanged_materials = [] # 변경 없음 (분류 불필요) changed_materials = [] # 변경됨 (재분류 필요) new_materials_list = [] # 신규 추가 (분류 필요) for new_material in new_materials: # 자재 해시 생성 (description 기반) description = new_material.get("description", "") size = self._extract_size_from_description(description) material = self._extract_material_from_description(description) material_hash = self._generate_material_hash(description, size, material) if material_hash in confirmed_materials: confirmed_item = confirmed_materials[material_hash] # 수량 비교 new_qty = float(new_material.get("quantity", 0)) confirmed_qty = float(confirmed_item["bom_quantity"]) if abs(new_qty - confirmed_qty) > 0.001: # 수량 변경 changed_materials.append({ **new_material, "change_type": "QUANTITY_CHANGED", "previous_quantity": confirmed_qty, "previous_item": confirmed_item }) else: # 수량 동일 - 기존 분류 결과 재사용 unchanged_materials.append({ **new_material, "reuse_classification": True, "previous_item": confirmed_item }) else: # 신규 자재 new_materials_list.append({ **new_material, "change_type": "NEW_MATERIAL" }) # 삭제된 자재 찾기 (이전에는 있었지만 현재는 없는 것) new_material_hashes = set() for material in new_materials: description = material.get("description", "") size = self._extract_size_from_description(description) material_grade = self._extract_material_from_description(description) hash_key = self._generate_material_hash(description, size, material_grade) new_material_hashes.add(hash_key) removed_materials = [] for hash_key, confirmed_item in confirmed_materials.items(): if hash_key not in new_material_hashes: removed_materials.append({ "change_type": "REMOVED", "previous_item": confirmed_item }) comparison_result = { "has_previous_confirmation": True, "previous_revision": previous_confirmed["revision"], "previous_confirmed_at": previous_confirmed["confirmed_at"], "unchanged_count": len(unchanged_materials), "changed_count": len(changed_materials), "new_count": len(new_materials_list), "removed_count": len(removed_materials), "total_materials": len(new_materials), "classification_needed": len(changed_materials) + len(new_materials_list), "unchanged_materials": unchanged_materials, "changed_materials": changed_materials, "new_materials": new_materials_list, "removed_materials": removed_materials } logger.info(f"리비전 비교 완료: 변경없음 {len(unchanged_materials)}, " f"변경됨 {len(changed_materials)}, 신규 {len(new_materials_list)}, " f"삭제됨 {len(removed_materials)}") return comparison_result except Exception as e: logger.error(f"자재 비교 실패: {str(e)}") raise def _extract_revision_number(self, revision: str) -> int: """리비전 문자열에서 숫자 추출 (Rev.1 → 1)""" try: if revision.startswith("Rev."): return int(revision.replace("Rev.", "")) return 0 except ValueError: return 0 def _generate_material_hash(self, description: str, size: str, material: str) -> str: """자재 고유성 판단을 위한 해시 생성""" # RULES.md의 코딩 컨벤션 준수 hash_input = f"{description}|{size}|{material}".lower().strip() return hashlib.md5(hash_input.encode()).hexdigest() def _extract_size_from_description(self, description: str) -> str: """자재 설명에서 사이즈 정보 추출""" # 간단한 사이즈 패턴 추출 (실제로는 더 정교한 로직 필요) import re size_patterns = [ r'(\d+(?:\.\d+)?)\s*(?:mm|MM|인치|inch|")', r'(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)', r'DN\s*(\d+)', r'(\d+)\s*A' ] for pattern in size_patterns: match = re.search(pattern, description, re.IGNORECASE) if match: return match.group(0) return "" def _extract_material_from_description(self, description: str) -> str: """자재 설명에서 재질 정보 추출""" # 일반적인 재질 패턴 materials = ["SS304", "SS316", "SS316L", "A105", "WCB", "CF8M", "CF8", "CS"] description_upper = description.upper() for material in materials: if material in description_upper: return material return "" def get_revision_comparison(db: Session, job_no: str, current_revision: str, new_materials: List[Dict]) -> Dict: """ 리비전 비교 수행 (편의 함수) Args: db: 데이터베이스 세션 job_no: 프로젝트 번호 current_revision: 현재 리비전 new_materials: 신규 자재 목록 Returns: 비교 결과 또는 전체 분류 필요 정보 """ comparator = RevisionComparator(db) # 이전 확정 자료 조회 previous_confirmed = comparator.get_previous_confirmed_materials(job_no, current_revision) if previous_confirmed is None: # 이전 확정 자료가 없으면 전체 분류 필요 return { "has_previous_confirmation": False, "classification_needed": len(new_materials), "all_materials_need_classification": True, "materials_to_classify": new_materials, "message": "이전 확정 자료가 없어 전체 자재를 분류합니다." } # 이전 확정 자료가 있으면 비교 수행 return comparator.compare_materials(previous_confirmed, new_materials)