Files
TK-BOM-Project/backend/app/utils/pipe_utils.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

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)