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

426 lines
16 KiB
Python

"""
리비전 자재 처리 전용 서비스
구매 상태별 자재 처리 로직
"""
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import List, Dict, Any, Optional
from decimal import Decimal
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 RevisionMaterialService:
"""리비전 자재 처리 전용 서비스"""
def __init__(self, db: Session):
self.db = db
self.db_service = DatabaseService(db)
def process_material_by_purchase_status(
self,
prev_material: Dict,
curr_material: Dict,
category: str
) -> Dict[str, Any]:
"""
구매 상태별 자재 처리
Args:
prev_material: 이전 리비전 자재
curr_material: 현재 리비전 자재
category: 자재 카테고리
Returns:
처리 결과
"""
# 수량 변화 계산
quantity_change = self._calculate_quantity_change(prev_material, curr_material, category)
# 구매 완료 자재 처리
if prev_material.get('purchase_confirmed', False):
return self._process_purchased_material(prev_material, curr_material, quantity_change, category)
else:
# 구매 미완료 자재 처리
return self._process_unpurchased_material(prev_material, curr_material, quantity_change, category)
def process_new_material(self, material: Dict, category: str) -> Dict[str, Any]:
"""신규 자재 처리"""
return {
'material_id': material['id'],
'category': category,
'action': 'new_material',
'status': 'needs_purchase',
'quantity': material.get('quantity', 0),
'description': material.get('original_description', ''),
'processing_note': '신규 자재 - 구매 필요',
'ui_display': {
'show_in_revision_page': True,
'highlight_color': 'green',
'action_required': '구매 신청',
'badge': 'NEW'
}
}
def process_removed_material(self, material: Dict, category: str) -> Dict[str, Any]:
"""제거된 자재 처리"""
if material.get('purchase_confirmed', False):
# 구매 완료된 자재가 제거됨 → 재고로 분류
return {
'material_id': material['id'],
'category': category,
'action': 'move_to_inventory',
'status': 'excess_inventory',
'quantity': material.get('quantity', 0),
'description': material.get('original_description', ''),
'processing_note': '구매 완료 후 리비전에서 제거됨 - 재고 보관',
'ui_display': {
'show_in_revision_page': True,
'highlight_color': 'orange',
'action_required': '재고 관리',
'badge': 'INVENTORY'
}
}
else:
# 구매 미완료 자재가 제거됨 → 완전 삭제
return {
'material_id': material['id'],
'category': category,
'action': 'delete',
'status': 'deleted',
'quantity': material.get('quantity', 0),
'description': material.get('original_description', ''),
'processing_note': '리비전에서 제거됨 - 구매 불필요',
'ui_display': {
'show_in_revision_page': False, # 삭제된 자재는 표시 안함
'highlight_color': 'red',
'action_required': '삭제 완료',
'badge': 'DELETED'
}
}
def _process_purchased_material(
self,
prev_material: Dict,
curr_material: Dict,
quantity_change: Dict,
category: str
) -> Dict[str, Any]:
"""구매 완료 자재 처리"""
if quantity_change['change_type'] == 'no_change':
# 변동 없음 → 구매 완료 상태 유지
return {
'material_id': curr_material['id'],
'category': category,
'action': 'maintain_status',
'status': 'purchased_completed',
'quantity': curr_material.get('quantity', 0),
'description': curr_material.get('original_description', ''),
'processing_note': '구매 완료 - 변동 없음',
'ui_display': {
'show_in_revision_page': False, # 변동 없는 구매완료 자재는 숨김
'highlight_color': 'gray',
'action_required': '관리 불필요',
'badge': 'COMPLETED'
}
}
elif quantity_change['change_type'] == 'decreased':
# 수량 감소 → 재고 자재로 분류
excess_quantity = abs(quantity_change['quantity_change'])
return {
'material_id': curr_material['id'],
'category': category,
'action': 'partial_inventory',
'status': 'excess_inventory',
'quantity': curr_material.get('quantity', 0),
'excess_quantity': excess_quantity,
'purchased_quantity': prev_material.get('quantity', 0),
'description': curr_material.get('original_description', ''),
'processing_note': f'구매 완료 후 수량 감소 - 잉여 {excess_quantity}개 재고 보관',
'ui_display': {
'show_in_revision_page': True,
'highlight_color': 'orange',
'action_required': '잉여 재고 관리',
'badge': 'EXCESS'
}
}
else: # increased
# 수량 부족 → 추가 구매 필요
additional_needed = quantity_change['quantity_change']
return {
'material_id': curr_material['id'],
'category': category,
'action': 'additional_purchase',
'status': 'needs_additional_purchase',
'quantity': curr_material.get('quantity', 0),
'additional_needed': additional_needed,
'already_purchased': prev_material.get('quantity', 0),
'description': curr_material.get('original_description', ''),
'processing_note': f'구매 완료 후 수량 부족 - 추가 {additional_needed}개 구매 필요',
'ui_display': {
'show_in_revision_page': True,
'highlight_color': 'red',
'action_required': '추가 구매 신청',
'badge': 'ADDITIONAL'
}
}
def _process_unpurchased_material(
self,
prev_material: Dict,
curr_material: Dict,
quantity_change: Dict,
category: str
) -> Dict[str, Any]:
"""구매 미완료 자재 처리"""
if quantity_change['change_type'] == 'no_change':
# 수량 동일 → 구매 관리 계속
return {
'material_id': curr_material['id'],
'category': category,
'action': 'continue_purchase',
'status': 'pending_purchase',
'quantity': curr_material.get('quantity', 0),
'description': curr_material.get('original_description', ''),
'processing_note': '수량 변동 없음 - 구매 진행',
'ui_display': {
'show_in_revision_page': True,
'highlight_color': 'blue',
'action_required': '구매 신청',
'badge': 'PENDING'
}
}
else:
# 수량 변경 → 수량 업데이트 후 구매 관리
return {
'material_id': curr_material['id'],
'category': category,
'action': 'update_quantity',
'status': 'quantity_updated',
'quantity': curr_material.get('quantity', 0),
'previous_quantity': prev_material.get('quantity', 0),
'quantity_change': quantity_change['quantity_change'],
'description': curr_material.get('original_description', ''),
'processing_note': f'수량 변경: {prev_material.get("quantity", 0)}{curr_material.get("quantity", 0)}',
'ui_display': {
'show_in_revision_page': True,
'highlight_color': 'yellow',
'action_required': '수량 확인 후 구매 신청',
'badge': 'UPDATED'
}
}
def _calculate_quantity_change(
self,
prev_material: Dict,
curr_material: Dict,
category: str
) -> Dict[str, Any]:
"""수량 변화 계산"""
# GASKET, BOLT는 규칙 적용 전 수량으로 비교
if category in ['GASKET', 'BOLT']:
prev_qty = float(prev_material.get('original_quantity', prev_material.get('quantity', 0)))
curr_qty = float(curr_material.get('original_quantity', curr_material.get('quantity', 0)))
else:
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 {
'previous_quantity': prev_qty,
'current_quantity': curr_qty,
'quantity_change': quantity_change,
'change_type': change_type,
'is_gasket_bolt': category in ['GASKET', 'BOLT']
}
def get_category_materials_for_revision(
self,
file_id: int,
category: str,
include_processing_info: bool = True
) -> List[Dict[str, Any]]:
"""
리비전 페이지용 카테고리별 자재 조회
Args:
file_id: 파일 ID
category: 카테고리
include_processing_info: 처리 정보 포함 여부
Returns:
자재 목록 (처리 정보 포함)
"""
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,
m.line_number, m.is_active, m.notes,
-- 추가 정보
COALESCE(m.purchase_confirmed_at, m.created_at) as status_date,
COALESCE(m.purchase_confirmed_by, 'system') as status_by
FROM materials m
WHERE m.file_id = :file_id
AND m.classified_category = :category
AND m.classified_category != 'PIPE'
AND m.is_active = true
ORDER BY m.line_number
"""
result = self.db_service.execute_query(query, {
"file_id": file_id,
"category": category
})
materials = []
for row in result.fetchall():
material_dict = dict(row._mapping)
if include_processing_info:
# 처리 정보 추가
material_dict['processing_info'] = self._get_material_processing_info(material_dict)
materials.append(material_dict)
return materials
def _get_material_processing_info(self, material: Dict) -> Dict[str, Any]:
"""자재 처리 정보 생성"""
revision_status = material.get('revision_status', '')
purchase_confirmed = material.get('purchase_confirmed', False)
if revision_status == 'new_in_revision':
return {
'display_status': 'NEW',
'color': 'green',
'action': '신규 구매 필요',
'priority': 'high'
}
elif revision_status == 'additional_purchase_needed':
return {
'display_status': 'ADDITIONAL',
'color': 'red',
'action': '추가 구매 필요',
'priority': 'high'
}
elif revision_status == 'excess_inventory':
return {
'display_status': 'EXCESS',
'color': 'orange',
'action': '재고 관리',
'priority': 'medium'
}
elif revision_status == 'quantity_updated':
return {
'display_status': 'UPDATED',
'color': 'yellow',
'action': '수량 확인',
'priority': 'medium'
}
elif purchase_confirmed:
return {
'display_status': 'COMPLETED',
'color': 'gray',
'action': '완료',
'priority': 'low'
}
else:
return {
'display_status': 'PENDING',
'color': 'blue',
'action': '구매 대기',
'priority': 'medium'
}
def apply_material_processing_results(
self,
processing_results: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""자재 처리 결과를 DB에 적용"""
try:
applied_count = 0
error_count = 0
for result in processing_results:
try:
material_id = result['material_id']
action = result['action']
status = result['status']
if action == 'delete':
# 자재 비활성화
update_query = """
UPDATE materials
SET is_active = false,
revision_status = 'deleted',
notes = CONCAT(COALESCE(notes, ''), '\n', :note)
WHERE id = :material_id
"""
else:
# 자재 상태 업데이트
update_query = """
UPDATE materials
SET revision_status = :status,
notes = CONCAT(COALESCE(notes, ''), '\n', :note)
WHERE id = :material_id
"""
self.db_service.execute_query(update_query, {
"material_id": material_id,
"status": status,
"note": result.get('processing_note', '')
})
applied_count += 1
except Exception as e:
logger.error(f"Failed to apply processing result for material {result.get('material_id')}: {e}")
error_count += 1
self.db.commit()
return {
"success": True,
"applied_count": applied_count,
"error_count": error_count,
"message": f"자재 처리 완료: {applied_count}개 적용, {error_count}개 오류"
}
except Exception as e:
self.db.rollback()
logger.error(f"Failed to apply material processing results: {e}")
return {
"success": False,
"error": str(e),
"message": "자재 처리 적용 중 오류 발생"
}