""" 리비전 처리 로직 서비스 구매 상태와 카테고리별 특성을 고려한 스마트 리비전 관리 """ from sqlalchemy.orm import Session from sqlalchemy import text, and_, or_ from typing import List, Dict, Any, Optional, Tuple from decimal import Decimal import hashlib from datetime import datetime from ..models import Material, File from ..utils.logger import get_logger from .database_service import DatabaseService logger = get_logger(__name__) class RevisionLogicService: """리비전 처리 로직 서비스""" def __init__(self, db: Session): self.db = db self.db_service = DatabaseService(db) def process_revision_by_purchase_status( self, job_no: str, current_file_id: int, previous_file_id: Optional[int] = None ) -> Dict[str, Any]: """ 구매 상태별 리비전 처리 Returns: { "needs_revision_page": bool, # 리비전 페이지 필요 여부 "can_use_bom_page": bool, # 기존 BOM 페이지 사용 가능 여부 "processing_results": dict, # 처리 결과 "revision_materials": list, # 리비전 페이지에서 관리할 자재 "inventory_materials": list, # 재고로 분류할 자재 "deleted_materials": list # 삭제할 자재 } """ if not previous_file_id: previous_file_id = self._get_previous_file_id(job_no, current_file_id) if not previous_file_id: return self._handle_first_revision(current_file_id) # 이전/현재 자재 조회 previous_materials = self._get_materials_with_details(previous_file_id) current_materials = self._get_materials_with_details(current_file_id) # 카테고리별 처리 processing_results = {} revision_materials = [] inventory_materials = [] deleted_materials = [] # 각 카테고리별로 처리 categories = ['PIPE', 'FITTING', 'FLANGE', 'VALVE', 'GASKET', 'BOLT', 'SUPPORT', 'SPECIAL'] for category in categories: category_result = self._process_category_revision( category, previous_materials, current_materials ) processing_results[category] = category_result # 자재 분류 revision_materials.extend(category_result['revision_materials']) inventory_materials.extend(category_result['inventory_materials']) deleted_materials.extend(category_result['deleted_materials']) return { "needs_revision_page": True, # 리비전이면 항상 리비전 페이지 필요 "can_use_bom_page": False, # 리비전이면 기존 BOM 페이지 사용 불가 "processing_results": processing_results, "revision_materials": revision_materials, "inventory_materials": inventory_materials, "deleted_materials": deleted_materials, "summary": self._generate_revision_summary(processing_results) } def _process_category_revision( self, category: str, previous_materials: Dict[str, Dict], current_materials: Dict[str, Dict] ) -> Dict[str, Any]: """카테고리별 리비전 처리""" # 카테고리별 자재 필터링 prev_category_materials = { k: v for k, v in previous_materials.items() if v.get('classified_category') == category } curr_category_materials = { k: v for k, v in current_materials.items() if v.get('classified_category') == category } result = { "category": category, "revision_materials": [], "inventory_materials": [], "deleted_materials": [], "unchanged_materials": [], "processing_summary": { "purchased_unchanged": 0, "purchased_excess": 0, "purchased_insufficient": 0, "unpurchased_deleted": 0, "unpurchased_unchanged": 0, "unpurchased_updated": 0, "new_materials": 0 } } # 이전 자재 기준으로 비교 for key, prev_material in prev_category_materials.items(): if key in curr_category_materials: curr_material = curr_category_materials[key] # GASKET, BOLT는 규칙 적용 전 수량으로 비교 if category in ['GASKET', 'BOLT']: comparison = self._compare_materials_pre_calculation(prev_material, curr_material, category) else: comparison = self._compare_materials_standard(prev_material, curr_material, category) # 구매 완료 자재 처리 if prev_material.get('purchase_confirmed', False): processed = self._process_purchased_material(prev_material, curr_material, comparison, category) else: # 구매 미완료 자재 처리 processed = self._process_unpurchased_material(prev_material, curr_material, comparison, category) # 결과 분류 if processed['action'] == 'revision_management': result['revision_materials'].append(processed) elif processed['action'] == 'inventory': result['inventory_materials'].append(processed) elif processed['action'] == 'unchanged': result['unchanged_materials'].append(processed) # 통계 업데이트 result['processing_summary'][processed['summary_key']] += 1 else: # 삭제된 자재 (현재 리비전에 없음) if prev_material.get('purchase_confirmed', False): # 구매 완료된 자재가 삭제됨 → 재고로 분류 result['inventory_materials'].append({ 'material': prev_material, 'action': 'inventory', 'reason': 'purchased_but_removed_in_revision', 'category': category }) else: # 구매 미완료 자재가 삭제됨 → 완전 삭제 result['deleted_materials'].append({ 'material': prev_material, 'action': 'delete', 'reason': 'no_longer_needed', 'category': category }) result['processing_summary']['unpurchased_deleted'] += 1 # 신규 자재 처리 for key, curr_material in curr_category_materials.items(): if key not in prev_category_materials: result['revision_materials'].append({ 'material': curr_material, 'action': 'revision_management', 'reason': 'new_material', 'category': category, 'summary_key': 'new_materials' }) result['processing_summary']['new_materials'] += 1 return result def _process_purchased_material( self, prev_material: Dict, curr_material: Dict, comparison: Dict, category: str ) -> Dict[str, Any]: """구매 완료 자재 처리""" if comparison['change_type'] == 'no_change': # 변동 없음 → 구매 완료 상태 유지, 더 이상 관리 불필요 return { 'material': curr_material, 'action': 'unchanged', 'reason': 'purchased_no_change', 'category': category, 'summary_key': 'purchased_unchanged' } elif comparison['change_type'] == 'decreased': # 수량 감소/불필요 → 재고 자재로 분류 excess_quantity = abs(comparison['quantity_change']) return { 'material': prev_material, # 이전 자재 정보 사용 'action': 'inventory', 'reason': 'purchased_excess', 'category': category, 'excess_quantity': excess_quantity, 'current_needed': curr_material.get('quantity', 0), 'summary_key': 'purchased_excess' } else: # increased # 수량 부족 → 리비전 페이지에서 추가 구매 관리 additional_needed = comparison['quantity_change'] return { 'material': curr_material, 'action': 'revision_management', 'reason': 'purchased_insufficient', 'category': category, 'additional_needed': additional_needed, 'already_purchased': prev_material.get('quantity', 0), 'summary_key': 'purchased_insufficient' } def _process_unpurchased_material( self, prev_material: Dict, curr_material: Dict, comparison: Dict, category: str ) -> Dict[str, Any]: """구매 미완료 자재 처리""" if comparison['change_type'] == 'no_change': # 수량 동일 → 리비전 페이지에서 구매 관리 계속 return { 'material': curr_material, 'action': 'revision_management', 'reason': 'unpurchased_unchanged', 'category': category, 'summary_key': 'unpurchased_unchanged' } else: # 수량 변경 → 필요 수량만큼 리비전 페이지에서 관리 return { 'material': curr_material, 'action': 'revision_management', 'reason': 'unpurchased_quantity_changed', 'category': category, 'quantity_change': comparison['quantity_change'], 'previous_quantity': prev_material.get('quantity', 0), 'summary_key': 'unpurchased_updated' } def _compare_materials_standard( self, prev_material: Dict, curr_material: Dict, category: str ) -> Dict[str, Any]: """표준 자재 비교 (PIPE 제외)""" prev_qty = float(prev_material.get('quantity', 0)) curr_qty = float(curr_material.get('quantity', 0)) quantity_change = curr_qty - prev_qty if abs(quantity_change) < 0.001: # 부동소수점 오차 고려 change_type = 'no_change' elif quantity_change > 0: change_type = 'increased' else: change_type = 'decreased' return { 'quantity_change': quantity_change, 'change_type': change_type, 'previous_quantity': prev_qty, 'current_quantity': curr_qty } def _compare_materials_pre_calculation( self, prev_material: Dict, curr_material: Dict, category: str ) -> Dict[str, Any]: """규칙 적용 전 수량으로 비교 (GASKET, BOLT)""" # 원본 수량 (규칙 적용 전)으로 비교 prev_original_qty = float(prev_material.get('original_quantity', prev_material.get('quantity', 0))) curr_original_qty = float(curr_material.get('original_quantity', curr_material.get('quantity', 0))) quantity_change = curr_original_qty - prev_original_qty if abs(quantity_change) < 0.001: change_type = 'no_change' elif quantity_change > 0: change_type = 'increased' else: change_type = 'decreased' # 최종 계산된 수량도 포함 final_prev_qty = float(prev_material.get('quantity', 0)) final_curr_qty = float(curr_material.get('quantity', 0)) return { 'quantity_change': quantity_change, 'change_type': change_type, 'previous_quantity': prev_original_qty, 'current_quantity': curr_original_qty, 'final_previous_quantity': final_prev_qty, 'final_current_quantity': final_curr_qty, 'calculation_rule_applied': True } def _get_materials_with_details(self, file_id: int) -> Dict[str, Dict]: """파일의 자재를 상세 정보와 함께 조회""" query = """ SELECT m.id, m.original_description, m.classified_category, m.material_grade, m.schedule, m.size_spec, m.main_nom, m.red_nom, m.quantity, m.unit, m.length, m.drawing_name, m.line_no, m.purchase_confirmed, m.confirmed_quantity, m.purchase_status, m.material_hash, m.revision_status, m.brand, m.user_requirement, -- PIPE 자재 특별 키 생성 CASE WHEN m.classified_category = 'PIPE' THEN CONCAT(m.drawing_name, '|', m.line_no, '|', COALESCE(m.length, 0)) ELSE m.material_hash END as comparison_key FROM materials m WHERE m.file_id = :file_id AND m.is_active = true ORDER BY m.line_number """ result = self.db_service.execute_query(query, {"file_id": file_id}) materials = {} for row in result.fetchall(): row_dict = dict(row._mapping) comparison_key = row_dict['comparison_key'] # PIPE 자재의 경우 도면-라인넘버별로 길이 합산 if row_dict['classified_category'] == 'PIPE': if comparison_key in materials: # 기존 자재에 길이 합산 materials[comparison_key]['quantity'] += row_dict['quantity'] materials[comparison_key]['total_length'] = ( materials[comparison_key].get('total_length', 0) + (row_dict['length'] or 0) * row_dict['quantity'] ) else: row_dict['total_length'] = (row_dict['length'] or 0) * row_dict['quantity'] materials[comparison_key] = row_dict else: materials[comparison_key] = row_dict return materials def _get_previous_file_id(self, job_no: str, current_file_id: int) -> Optional[int]: """이전 파일 ID 자동 탐지""" query = """ SELECT id, revision FROM files WHERE job_no = :job_no AND id != :current_file_id AND is_active = true ORDER BY upload_date DESC LIMIT 1 """ result = self.db_service.execute_query(query, { "job_no": job_no, "current_file_id": current_file_id }) row = result.fetchone() return row.id if row else None def _handle_first_revision(self, current_file_id: int) -> Dict[str, Any]: """첫 번째 리비전 처리""" materials = self._get_materials_with_details(current_file_id) return { "needs_revision_page": True, # 첫 리비전은 항상 리비전 페이지 필요 "can_use_bom_page": False, "processing_results": {}, "revision_materials": [{"material": mat, "action": "revision_management", "reason": "first_revision"} for mat in materials.values()], "inventory_materials": [], "deleted_materials": [], "summary": { "is_first_revision": True, "total_materials": len(materials) } } def _generate_revision_summary(self, processing_results: Dict) -> Dict[str, Any]: """리비전 처리 요약 생성""" summary = { "total_categories": len(processing_results), "total_revision_materials": 0, "total_inventory_materials": 0, "total_deleted_materials": 0, "by_category": {} } for category, result in processing_results.items(): summary["total_revision_materials"] += len(result['revision_materials']) summary["total_inventory_materials"] += len(result['inventory_materials']) summary["total_deleted_materials"] += len(result['deleted_materials']) summary["by_category"][category] = { "revision_count": len(result['revision_materials']), "inventory_count": len(result['inventory_materials']), "deleted_count": len(result['deleted_materials']), "processing_summary": result['processing_summary'] } return summary def should_redirect_to_revision_page( self, job_no: str, current_file_id: int, previous_file_id: Optional[int] = None ) -> Tuple[bool, str]: """ 리비전 페이지로 리다이렉트해야 하는지 판단 실제 변경사항이 있을 때만 리비전 페이지로 이동 Returns: (should_redirect: bool, reason: str) """ try: # 이전 파일이 있는지 확인 (리비전 여부 판단) if not previous_file_id: previous_file_id = self._get_previous_file_id(job_no, current_file_id) if not previous_file_id: # 첫 번째 파일 (리비전 아님) → 기존 BOM 페이지 사용 return False, "첫 번째 BOM 파일이므로 기존 페이지에서 관리합니다." # 실제 변경사항이 있는지 확인 processing_results = self.process_revision_by_purchase_status( job_no, current_file_id, previous_file_id ) # 변경사항 통계 확인 summary = processing_results.get('summary', {}) total_changes = ( summary.get('revision_materials', 0) + summary.get('inventory_materials', 0) + summary.get('deleted_materials', 0) ) if total_changes > 0: # 실제 변경사항이 있으면 리비전 페이지로 return True, f"리비전 변경사항이 감지되었습니다 (변경: {total_changes}개). 리비전 페이지에서 관리해야 합니다." else: # 변경사항이 없으면 기존 BOM 페이지 사용 return False, "리비전 파일이지만 변경사항이 없어 기존 페이지에서 관리합니다." except Exception as e: logger.error(f"Failed to determine revision redirect: {e}") return False, "리비전 상태 확인 실패 - 기존 페이지 사용"