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 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
426 lines
16 KiB
Python
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": "자재 처리 적용 중 오류 발생"
|
|
}
|