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 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
584 lines
19 KiB
Python
584 lines
19 KiB
Python
"""
|
|
PIPE 시스템 공통 유틸리티
|
|
|
|
모든 PIPE 관련 서비스에서 공통으로 사용되는 함수들을 모아놓은 유틸리티 모듈
|
|
"""
|
|
|
|
import logging
|
|
import math
|
|
from typing import Dict, List, Optional, Any, Tuple
|
|
from decimal import Decimal
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import text
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ========== PIPE 상수 정의 ==========
|
|
|
|
class PipeConstants:
|
|
"""PIPE 시스템에서 사용되는 상수들"""
|
|
|
|
# 길이 관련
|
|
STANDARD_PIPE_LENGTH_MM = 6000 # 표준 파이프 길이 (6M)
|
|
CUTTING_LOSS_PER_CUT_MM = 2 # 절단당 손실 (2mm)
|
|
|
|
# 분류 관련
|
|
PIPE_CATEGORY = "PIPE"
|
|
|
|
# 끝단 처리 타입
|
|
END_PREPARATION_TYPES = {
|
|
"무개선": "PLAIN",
|
|
"한개선": "SINGLE_BEVEL",
|
|
"양개선": "DOUBLE_BEVEL"
|
|
}
|
|
|
|
# 상태 관련
|
|
REVISION_TYPES = {
|
|
"NO_REVISION": "no_revision",
|
|
"PRE_CUTTING_PLAN": "pre_cutting_plan",
|
|
"POST_CUTTING_PLAN": "post_cutting_plan"
|
|
}
|
|
|
|
CHANGE_TYPES = {
|
|
"ADDED": "added",
|
|
"REMOVED": "removed",
|
|
"MODIFIED": "modified",
|
|
"UNCHANGED": "unchanged"
|
|
}
|
|
|
|
|
|
# ========== 데이터 추출 유틸리티 ==========
|
|
|
|
class PipeDataExtractor:
|
|
"""PIPE 데이터 추출 관련 유틸리티"""
|
|
|
|
@staticmethod
|
|
def extract_pipe_materials_from_file(db: Session, file_id: int) -> List[Dict[str, Any]]:
|
|
"""
|
|
파일에서 PIPE 자재 데이터 추출
|
|
|
|
Args:
|
|
db: 데이터베이스 세션
|
|
file_id: 파일 ID
|
|
|
|
Returns:
|
|
PIPE 자재 리스트
|
|
"""
|
|
try:
|
|
query = text("""
|
|
SELECT
|
|
m.id,
|
|
m.drawing_name,
|
|
m.line_no,
|
|
m.description,
|
|
m.classified_category,
|
|
m.full_material_grade,
|
|
m.main_nom,
|
|
m.red_nom,
|
|
m.length,
|
|
m.total_length,
|
|
m.quantity,
|
|
m.row_number,
|
|
m.original_description
|
|
FROM materials m
|
|
WHERE m.file_id = :file_id
|
|
AND m.classified_category = 'PIPE'
|
|
AND m.is_active = true
|
|
ORDER BY m.drawing_name, m.line_no, m.row_number
|
|
""")
|
|
|
|
result = db.execute(query, {"file_id": file_id})
|
|
materials = []
|
|
|
|
for row in result:
|
|
materials.append({
|
|
"id": row.id,
|
|
"drawing_name": row.drawing_name or "UNKNOWN",
|
|
"line_no": row.line_no or "",
|
|
"description": row.description or "",
|
|
"original_description": row.original_description or "",
|
|
"material_grade": row.full_material_grade or "UNKNOWN",
|
|
"main_nom": row.main_nom or "",
|
|
"red_nom": row.red_nom or "",
|
|
"length": float(row.length or 0),
|
|
"total_length": float(row.total_length or 0),
|
|
"quantity": int(row.quantity or 1),
|
|
"row_number": row.row_number or 0
|
|
})
|
|
|
|
logger.info(f"✅ {len(materials)}개 PIPE 자재 추출 완료 (파일 ID: {file_id})")
|
|
return materials
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ PIPE 자재 추출 실패: {e}")
|
|
raise
|
|
|
|
@staticmethod
|
|
def parse_pipe_description(description: str) -> Dict[str, Any]:
|
|
"""
|
|
PIPE 설명에서 정보 추출
|
|
|
|
Args:
|
|
description: 자재 설명
|
|
|
|
Returns:
|
|
추출된 정보 딕셔너리
|
|
"""
|
|
# 기본값 설정
|
|
result = {
|
|
"material_grade": "UNKNOWN",
|
|
"schedule": "UNKNOWN",
|
|
"nominal_size": "UNKNOWN",
|
|
"length_info": None,
|
|
"end_preparation": "무개선"
|
|
}
|
|
|
|
if not description:
|
|
return result
|
|
|
|
# 간단한 파싱 로직 (실제로는 더 복잡할 수 있음)
|
|
description_upper = description.upper()
|
|
|
|
# 재질 추출 (A106, A53 등)
|
|
if "A106" in description_upper:
|
|
result["material_grade"] = "A106 GR.B"
|
|
elif "A53" in description_upper:
|
|
result["material_grade"] = "A53 GR.B"
|
|
|
|
# 스케줄 추출 (SCH40, SCH80 등)
|
|
if "SCH40" in description_upper:
|
|
result["schedule"] = "SCH40"
|
|
elif "SCH80" in description_upper:
|
|
result["schedule"] = "SCH80"
|
|
|
|
# 끝단 처리 추출
|
|
if "양개선" in description or "DOUBLE" in description_upper:
|
|
result["end_preparation"] = "양개선"
|
|
elif "한개선" in description or "SINGLE" in description_upper:
|
|
result["end_preparation"] = "한개선"
|
|
|
|
return result
|
|
|
|
|
|
# ========== 계산 유틸리티 ==========
|
|
|
|
class PipeCalculator:
|
|
"""PIPE 관련 계산 유틸리티"""
|
|
|
|
@staticmethod
|
|
def calculate_pipe_purchase_quantity(materials: List[Dict]) -> Dict[str, Any]:
|
|
"""
|
|
PIPE 구매 수량 계산
|
|
|
|
Args:
|
|
materials: PIPE 자재 리스트
|
|
|
|
Returns:
|
|
계산 결과
|
|
"""
|
|
total_bom_length = 0
|
|
cutting_count = 0
|
|
pipe_details = []
|
|
|
|
for material in materials:
|
|
# 길이 정보 추출 (Decimal 타입 처리)
|
|
length_mm = float(material.get('length', 0) or 0)
|
|
if not length_mm:
|
|
length_mm = float(material.get('length_mm', 0) or 0)
|
|
|
|
quantity = float(material.get('quantity', 1) or 1)
|
|
|
|
if length_mm > 0:
|
|
total_length = length_mm * quantity
|
|
total_bom_length += total_length
|
|
cutting_count += quantity
|
|
|
|
pipe_details.append({
|
|
'description': material.get('description', ''),
|
|
'original_description': material.get('original_description', ''),
|
|
'drawing_name': material.get('drawing_name', ''),
|
|
'line_no': material.get('line_no', ''),
|
|
'length_mm': length_mm,
|
|
'quantity': quantity,
|
|
'total_length': total_length
|
|
})
|
|
|
|
# 절단 손실 계산
|
|
cutting_loss = cutting_count * PipeConstants.CUTTING_LOSS_PER_CUT_MM
|
|
|
|
# 총 필요 길이
|
|
required_length = total_bom_length + cutting_loss
|
|
|
|
# 6M 단위로 올림 계산
|
|
pipes_needed = math.ceil(required_length / PipeConstants.STANDARD_PIPE_LENGTH_MM) if required_length > 0 else 0
|
|
total_purchase_length = pipes_needed * PipeConstants.STANDARD_PIPE_LENGTH_MM
|
|
waste_length = total_purchase_length - required_length if pipes_needed > 0 else 0
|
|
|
|
return {
|
|
'bom_quantity': total_bom_length,
|
|
'cutting_count': cutting_count,
|
|
'cutting_loss': cutting_loss,
|
|
'required_length': required_length,
|
|
'pipes_count': pipes_needed,
|
|
'calculated_qty': total_purchase_length,
|
|
'waste_length': waste_length,
|
|
'utilization_rate': (required_length / total_purchase_length * 100) if total_purchase_length > 0 else 0,
|
|
'unit': 'mm',
|
|
'pipe_details': pipe_details,
|
|
'summary': {
|
|
'total_materials': len(materials),
|
|
'total_drawings': len(set(m.get('drawing_name', '') for m in materials if m.get('drawing_name'))),
|
|
'average_length': total_bom_length / len(materials) if materials else 0
|
|
}
|
|
}
|
|
|
|
@staticmethod
|
|
def calculate_length_difference(old_length: float, new_length: float) -> Dict[str, Any]:
|
|
"""
|
|
길이 변화량 계산
|
|
|
|
Args:
|
|
old_length: 이전 길이
|
|
new_length: 새로운 길이
|
|
|
|
Returns:
|
|
변화량 정보
|
|
"""
|
|
difference = new_length - old_length
|
|
percentage = (difference / old_length * 100) if old_length > 0 else 0
|
|
|
|
return {
|
|
'old_length': old_length,
|
|
'new_length': new_length,
|
|
'difference': difference,
|
|
'percentage': percentage,
|
|
'change_type': 'increased' if difference > 0 else 'decreased' if difference < 0 else 'unchanged'
|
|
}
|
|
|
|
|
|
# ========== 비교 유틸리티 ==========
|
|
|
|
class PipeComparator:
|
|
"""PIPE 데이터 비교 유틸리티"""
|
|
|
|
@staticmethod
|
|
def compare_pipe_segments(old_segments: List[Dict], new_segments: List[Dict]) -> Dict[str, Any]:
|
|
"""
|
|
단관 데이터 비교
|
|
|
|
Args:
|
|
old_segments: 이전 단관 데이터
|
|
new_segments: 새로운 단관 데이터
|
|
|
|
Returns:
|
|
비교 결과
|
|
"""
|
|
# 키 생성 함수
|
|
def create_segment_key(segment):
|
|
return (
|
|
segment.get('drawing_name', ''),
|
|
segment.get('material_grade', ''),
|
|
segment.get('length', 0),
|
|
segment.get('end_preparation', '무개선')
|
|
)
|
|
|
|
# 기존 데이터를 키로 매핑
|
|
old_map = {}
|
|
for segment in old_segments:
|
|
key = create_segment_key(segment)
|
|
if key not in old_map:
|
|
old_map[key] = []
|
|
old_map[key].append(segment)
|
|
|
|
# 새로운 데이터를 키로 매핑
|
|
new_map = {}
|
|
for segment in new_segments:
|
|
key = create_segment_key(segment)
|
|
if key not in new_map:
|
|
new_map[key] = []
|
|
new_map[key].append(segment)
|
|
|
|
# 비교 결과 생성
|
|
changes = {
|
|
'added': [],
|
|
'removed': [],
|
|
'modified': [],
|
|
'unchanged': []
|
|
}
|
|
|
|
all_keys = set(old_map.keys()) | set(new_map.keys())
|
|
|
|
for key in all_keys:
|
|
old_count = len(old_map.get(key, []))
|
|
new_count = len(new_map.get(key, []))
|
|
|
|
if old_count == 0:
|
|
# 새로 추가된 항목
|
|
for segment in new_map[key]:
|
|
changes['added'].append({
|
|
**segment,
|
|
'change_type': 'added',
|
|
'quantity_change': new_count
|
|
})
|
|
elif new_count == 0:
|
|
# 삭제된 항목
|
|
for segment in old_map[key]:
|
|
changes['removed'].append({
|
|
**segment,
|
|
'change_type': 'removed',
|
|
'quantity_change': -old_count
|
|
})
|
|
elif old_count != new_count:
|
|
# 수량이 변경된 항목
|
|
base_segment = new_map[key][0] if new_map[key] else old_map[key][0]
|
|
changes['modified'].append({
|
|
**base_segment,
|
|
'change_type': 'modified',
|
|
'old_quantity': old_count,
|
|
'new_quantity': new_count,
|
|
'quantity_change': new_count - old_count
|
|
})
|
|
else:
|
|
# 변경되지 않은 항목
|
|
base_segment = new_map[key][0]
|
|
changes['unchanged'].append({
|
|
**base_segment,
|
|
'change_type': 'unchanged',
|
|
'quantity': old_count
|
|
})
|
|
|
|
# 통계 생성
|
|
stats = {
|
|
'total_old': len(old_segments),
|
|
'total_new': len(new_segments),
|
|
'added_count': len(changes['added']),
|
|
'removed_count': len(changes['removed']),
|
|
'modified_count': len(changes['modified']),
|
|
'unchanged_count': len(changes['unchanged']),
|
|
'changed_drawings': len(set(
|
|
item.get('drawing_name', '') for change_list in [changes['added'], changes['removed'], changes['modified']]
|
|
for item in change_list if item.get('drawing_name')
|
|
))
|
|
}
|
|
|
|
return {
|
|
'changes': changes,
|
|
'statistics': stats,
|
|
'has_changes': stats['added_count'] + stats['removed_count'] + stats['modified_count'] > 0
|
|
}
|
|
|
|
|
|
# ========== 검증 유틸리티 ==========
|
|
|
|
class PipeValidator:
|
|
"""PIPE 데이터 검증 유틸리티"""
|
|
|
|
@staticmethod
|
|
def validate_pipe_data(pipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
PIPE 데이터 유효성 검증
|
|
|
|
Args:
|
|
pipe_data: 검증할 PIPE 데이터
|
|
|
|
Returns:
|
|
검증 결과
|
|
"""
|
|
errors = []
|
|
warnings = []
|
|
|
|
# 필수 필드 검증
|
|
required_fields = ['drawing_name', 'material_grade', 'length']
|
|
for field in required_fields:
|
|
if not pipe_data.get(field):
|
|
errors.append(f"필수 필드 누락: {field}")
|
|
|
|
# 길이 검증
|
|
length = pipe_data.get('length', 0)
|
|
if length <= 0:
|
|
errors.append("길이는 0보다 커야 합니다")
|
|
elif length > 20000: # 20m 초과시 경고
|
|
warnings.append(f"길이가 비정상적으로 큽니다: {length}mm")
|
|
|
|
# 수량 검증
|
|
quantity = pipe_data.get('quantity', 1)
|
|
if quantity <= 0:
|
|
errors.append("수량은 0보다 커야 합니다")
|
|
|
|
# 도면명 검증
|
|
drawing_name = pipe_data.get('drawing_name', '')
|
|
if drawing_name == 'UNKNOWN':
|
|
warnings.append("도면명이 지정되지 않았습니다")
|
|
|
|
return {
|
|
'is_valid': len(errors) == 0,
|
|
'errors': errors,
|
|
'warnings': warnings,
|
|
'error_count': len(errors),
|
|
'warning_count': len(warnings)
|
|
}
|
|
|
|
@staticmethod
|
|
def validate_cutting_plan_data(cutting_plan: List[Dict]) -> Dict[str, Any]:
|
|
"""
|
|
Cutting Plan 데이터 전체 검증
|
|
|
|
Args:
|
|
cutting_plan: Cutting Plan 데이터 리스트
|
|
|
|
Returns:
|
|
검증 결과
|
|
"""
|
|
total_errors = []
|
|
total_warnings = []
|
|
valid_items = 0
|
|
|
|
for i, item in enumerate(cutting_plan):
|
|
validation = PipeValidator.validate_pipe_data(item)
|
|
|
|
if validation['is_valid']:
|
|
valid_items += 1
|
|
else:
|
|
for error in validation['errors']:
|
|
total_errors.append(f"항목 {i+1}: {error}")
|
|
|
|
for warning in validation['warnings']:
|
|
total_warnings.append(f"항목 {i+1}: {warning}")
|
|
|
|
return {
|
|
'is_valid': len(total_errors) == 0,
|
|
'total_items': len(cutting_plan),
|
|
'valid_items': valid_items,
|
|
'invalid_items': len(cutting_plan) - valid_items,
|
|
'errors': total_errors,
|
|
'warnings': total_warnings,
|
|
'validation_rate': (valid_items / len(cutting_plan) * 100) if cutting_plan else 0
|
|
}
|
|
|
|
|
|
# ========== 포맷팅 유틸리티 ==========
|
|
|
|
class PipeFormatter:
|
|
"""PIPE 데이터 포맷팅 유틸리티"""
|
|
|
|
@staticmethod
|
|
def format_length(length_mm: float, unit: str = 'mm') -> str:
|
|
"""
|
|
길이 포맷팅
|
|
|
|
Args:
|
|
length_mm: 길이 (mm)
|
|
unit: 표시 단위
|
|
|
|
Returns:
|
|
포맷된 길이 문자열
|
|
"""
|
|
if unit == 'm':
|
|
return f"{length_mm / 1000:.3f}m"
|
|
elif unit == 'mm':
|
|
return f"{length_mm:.0f}mm"
|
|
else:
|
|
return f"{length_mm}"
|
|
|
|
@staticmethod
|
|
def format_pipe_description(pipe_data: Dict[str, Any]) -> str:
|
|
"""
|
|
PIPE 설명 포맷팅
|
|
|
|
Args:
|
|
pipe_data: PIPE 데이터
|
|
|
|
Returns:
|
|
포맷된 설명
|
|
"""
|
|
parts = []
|
|
|
|
if pipe_data.get('material_grade'):
|
|
parts.append(pipe_data['material_grade'])
|
|
|
|
if pipe_data.get('main_nom'):
|
|
parts.append(f"{pipe_data['main_nom']}")
|
|
|
|
if pipe_data.get('schedule'):
|
|
parts.append(pipe_data['schedule'])
|
|
|
|
if pipe_data.get('length'):
|
|
parts.append(PipeFormatter.format_length(pipe_data['length']))
|
|
|
|
return " ".join(parts) if parts else "PIPE"
|
|
|
|
@staticmethod
|
|
def format_change_summary(changes: Dict[str, List]) -> str:
|
|
"""
|
|
변경사항 요약 포맷팅
|
|
|
|
Args:
|
|
changes: 변경사항 딕셔너리
|
|
|
|
Returns:
|
|
포맷된 요약 문자열
|
|
"""
|
|
summary_parts = []
|
|
|
|
if changes.get('added'):
|
|
summary_parts.append(f"추가 {len(changes['added'])}개")
|
|
|
|
if changes.get('removed'):
|
|
summary_parts.append(f"삭제 {len(changes['removed'])}개")
|
|
|
|
if changes.get('modified'):
|
|
summary_parts.append(f"수정 {len(changes['modified'])}개")
|
|
|
|
if not summary_parts:
|
|
return "변경사항 없음"
|
|
|
|
return ", ".join(summary_parts)
|
|
|
|
|
|
# ========== 로깅 유틸리티 ==========
|
|
|
|
class PipeLogger:
|
|
"""PIPE 시스템 전용 로거 유틸리티"""
|
|
|
|
@staticmethod
|
|
def log_pipe_operation(operation: str, job_no: str, details: Dict[str, Any] = None):
|
|
"""
|
|
PIPE 작업 로깅
|
|
|
|
Args:
|
|
operation: 작업 유형
|
|
job_no: 작업 번호
|
|
details: 상세 정보
|
|
"""
|
|
message = f"🔧 PIPE {operation} | Job: {job_no}"
|
|
|
|
if details:
|
|
detail_parts = []
|
|
for key, value in details.items():
|
|
detail_parts.append(f"{key}: {value}")
|
|
message += f" | {', '.join(detail_parts)}"
|
|
|
|
logger.info(message)
|
|
|
|
@staticmethod
|
|
def log_pipe_error(operation: str, job_no: str, error: Exception, context: Dict[str, Any] = None):
|
|
"""
|
|
PIPE 오류 로깅
|
|
|
|
Args:
|
|
operation: 작업 유형
|
|
job_no: 작업 번호
|
|
error: 오류 객체
|
|
context: 컨텍스트 정보
|
|
"""
|
|
message = f"❌ PIPE {operation} 실패 | Job: {job_no} | Error: {str(error)}"
|
|
|
|
if context:
|
|
context_parts = []
|
|
for key, value in context.items():
|
|
context_parts.append(f"{key}: {value}")
|
|
message += f" | Context: {', '.join(context_parts)}"
|
|
|
|
logger.error(message)
|