🔧 완전한 스키마 자동화 시스템 구축
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
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 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
This commit is contained in:
583
backend/app/utils/pipe_utils.py
Normal file
583
backend/app/utils/pipe_utils.py
Normal file
@@ -0,0 +1,583 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user