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 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
479 lines
19 KiB
Python
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, "리비전 상태 확인 실패 - 기존 페이지 사용"
|