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

479 lines
19 KiB
Python

"""
리비전 처리 로직 서비스
구매 상태와 카테고리별 특성을 고려한 스마트 리비전 관리
"""
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, "리비전 상태 확인 실패 - 기존 페이지 사용"