- 리비전 관리 라우터 및 서비스 추가 (revision_management.py, revision_comparison_service.py, revision_session_service.py) - 구매확정 기능 구현: materials 테이블에 purchase_confirmed 필드 추가 및 업데이트 로직 - 리비전 비교 로직 구현: 구매확정된 자재 기반으로 신규/변경 자재 자동 분류 - 데이터베이스 스키마 확장: revision_sessions, revision_material_changes, inventory_transfers 테이블 추가 - 구매신청 생성 시 자재 상세 정보 저장 및 purchase_confirmed 자동 업데이트 - 프론트엔드: 리비전 관리 컴포넌트 및 hooks 추가 - 파일 목록 조회 API 추가 (/files/list) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
458 lines
19 KiB
Python
458 lines
19 KiB
Python
"""
|
|
리비전 비교 및 변경 처리 서비스
|
|
- 자재 비교 로직 (구매된/미구매 자재 구분)
|
|
- 리비전 액션 결정 (추가구매, 재고이관, 수량변경 등)
|
|
- GASKET/BOLT 특별 처리 (BOM 원본 수량 기준)
|
|
"""
|
|
|
|
import logging
|
|
from typing import Dict, List, Optional, Any, Tuple
|
|
from decimal import Decimal
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import text, and_, or_
|
|
|
|
from ..models import Material
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RevisionComparisonService:
|
|
"""리비전 비교 및 변경 처리 서비스"""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
|
|
def compare_materials_by_category(
|
|
self,
|
|
current_file_id: int,
|
|
previous_file_id: int,
|
|
category: str,
|
|
session_id: int
|
|
) -> Dict[str, Any]:
|
|
"""카테고리별 자재 비교 및 변경사항 기록"""
|
|
|
|
try:
|
|
logger.info(f"카테고리 {category} 자재 비교 시작 (현재: {current_file_id}, 이전: {previous_file_id})")
|
|
|
|
# 현재 파일의 자재 조회
|
|
current_materials = self._get_materials_by_category(current_file_id, category)
|
|
previous_materials = self._get_materials_by_category(previous_file_id, category)
|
|
|
|
logger.info(f"현재 자재: {len(current_materials)}개, 이전 자재: {len(previous_materials)}개")
|
|
|
|
# 자재 그룹화 (동일 자재 식별)
|
|
current_grouped = self._group_materials_by_key(current_materials, category)
|
|
previous_grouped = self._group_materials_by_key(previous_materials, category)
|
|
|
|
# 비교 결과 저장
|
|
comparison_results = {
|
|
"added": [],
|
|
"removed": [],
|
|
"changed": [],
|
|
"unchanged": []
|
|
}
|
|
|
|
# 현재 자재 기준으로 비교
|
|
for key, current_group in current_grouped.items():
|
|
if key in previous_grouped:
|
|
previous_group = previous_grouped[key]
|
|
|
|
# 수량 비교 (GASKET/BOLT는 BOM 원본 수량 기준)
|
|
current_qty = self._get_comparison_quantity(current_group, category)
|
|
previous_qty = self._get_comparison_quantity(previous_group, category)
|
|
|
|
if current_qty != previous_qty:
|
|
# 수량 변경됨
|
|
change_record = self._create_change_record(
|
|
current_group, previous_group, "quantity_changed",
|
|
current_qty, previous_qty, category, session_id
|
|
)
|
|
comparison_results["changed"].append(change_record)
|
|
else:
|
|
# 수량 동일
|
|
unchanged_record = self._create_change_record(
|
|
current_group, previous_group, "unchanged",
|
|
current_qty, previous_qty, category, session_id
|
|
)
|
|
comparison_results["unchanged"].append(unchanged_record)
|
|
else:
|
|
# 새로 추가된 자재
|
|
current_qty = self._get_comparison_quantity(current_group, category)
|
|
added_record = self._create_change_record(
|
|
current_group, None, "added",
|
|
current_qty, 0, category, session_id
|
|
)
|
|
comparison_results["added"].append(added_record)
|
|
|
|
# 제거된 자재 확인
|
|
for key, previous_group in previous_grouped.items():
|
|
if key not in current_grouped:
|
|
previous_qty = self._get_comparison_quantity(previous_group, category)
|
|
removed_record = self._create_change_record(
|
|
None, previous_group, "removed",
|
|
0, previous_qty, category, session_id
|
|
)
|
|
comparison_results["removed"].append(removed_record)
|
|
|
|
# DB에 변경사항 저장
|
|
self._save_material_changes(comparison_results, session_id)
|
|
|
|
# 통계 정보
|
|
summary = {
|
|
"category": category,
|
|
"added_count": len(comparison_results["added"]),
|
|
"removed_count": len(comparison_results["removed"]),
|
|
"changed_count": len(comparison_results["changed"]),
|
|
"unchanged_count": len(comparison_results["unchanged"]),
|
|
"total_changes": len(comparison_results["added"]) + len(comparison_results["removed"]) + len(comparison_results["changed"])
|
|
}
|
|
|
|
logger.info(f"카테고리 {category} 비교 완료: {summary}")
|
|
|
|
return {
|
|
"summary": summary,
|
|
"changes": comparison_results
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"카테고리 {category} 자재 비교 실패: {e}")
|
|
raise
|
|
|
|
def _get_materials_by_category(self, file_id: int, category: str) -> List[Material]:
|
|
"""파일의 특정 카테고리 자재 조회"""
|
|
|
|
return self.db.query(Material).filter(
|
|
and_(
|
|
Material.file_id == file_id,
|
|
Material.classified_category == category,
|
|
Material.is_active == True
|
|
)
|
|
).all()
|
|
|
|
def _group_materials_by_key(self, materials: List[Material], category: str) -> Dict[str, Dict]:
|
|
"""자재를 고유 키로 그룹화"""
|
|
|
|
grouped = {}
|
|
|
|
for material in materials:
|
|
# 카테고리별 고유 키 생성 전략
|
|
if category == "PIPE":
|
|
# PIPE: description + material_grade + main_nom
|
|
key_parts = [
|
|
material.original_description.strip().upper(),
|
|
material.material_grade or '',
|
|
material.main_nom or ''
|
|
]
|
|
elif category in ["GASKET", "BOLT"]:
|
|
# GASKET/BOLT: description + main_nom (집계 공식 적용 전 원본 기준)
|
|
key_parts = [
|
|
material.original_description.strip().upper(),
|
|
material.main_nom or ''
|
|
]
|
|
else:
|
|
# 기타: description + drawing + main_nom + red_nom
|
|
key_parts = [
|
|
material.original_description.strip().upper(),
|
|
material.drawing_name or '',
|
|
material.main_nom or '',
|
|
material.red_nom or ''
|
|
]
|
|
|
|
key = "|".join(key_parts)
|
|
|
|
if key in grouped:
|
|
# 동일한 자재가 있으면 수량 합산
|
|
grouped[key]['total_quantity'] += float(material.quantity)
|
|
grouped[key]['materials'].append(material)
|
|
|
|
# 구매 확정 상태 확인 (하나라도 구매 확정되면 전체가 구매 확정)
|
|
if getattr(material, 'purchase_confirmed', False):
|
|
grouped[key]['purchase_confirmed'] = True
|
|
grouped[key]['purchase_confirmed_at'] = getattr(material, 'purchase_confirmed_at', None)
|
|
|
|
else:
|
|
grouped[key] = {
|
|
'key': key,
|
|
'representative_material': material,
|
|
'materials': [material],
|
|
'total_quantity': float(material.quantity),
|
|
'purchase_confirmed': getattr(material, 'purchase_confirmed', False),
|
|
'purchase_confirmed_at': getattr(material, 'purchase_confirmed_at', None),
|
|
'category': category
|
|
}
|
|
|
|
return grouped
|
|
|
|
def _get_comparison_quantity(self, material_group: Dict, category: str) -> Decimal:
|
|
"""비교용 수량 계산 (GASKET/BOLT는 집계 공식 적용 전 원본 수량)"""
|
|
|
|
if category in ["GASKET", "BOLT"]:
|
|
# GASKET/BOLT: BOM 원본 수량 기준 (집계 공식 적용 전)
|
|
# 실제 BOM에서 읽은 원본 수량을 사용
|
|
original_quantity = 0
|
|
for material in material_group['materials']:
|
|
# classification_details에서 원본 수량 추출 시도
|
|
details = getattr(material, 'classification_details', {})
|
|
if isinstance(details, dict) and 'original_quantity' in details:
|
|
original_quantity += float(details['original_quantity'])
|
|
else:
|
|
# 원본 수량 정보가 없으면 현재 수량 사용
|
|
original_quantity += float(material.quantity)
|
|
|
|
return Decimal(str(original_quantity))
|
|
else:
|
|
# 기타 카테고리: 현재 수량 사용
|
|
return Decimal(str(material_group['total_quantity']))
|
|
|
|
def _create_change_record(
|
|
self,
|
|
current_group: Optional[Dict],
|
|
previous_group: Optional[Dict],
|
|
change_type: str,
|
|
current_qty: Decimal,
|
|
previous_qty: Decimal,
|
|
category: str,
|
|
session_id: int
|
|
) -> Dict[str, Any]:
|
|
"""변경 기록 생성"""
|
|
|
|
# 대표 자재 정보
|
|
if current_group:
|
|
material = current_group['representative_material']
|
|
material_id = material.id
|
|
description = material.original_description
|
|
purchase_status = 'purchased' if current_group['purchase_confirmed'] else 'not_purchased'
|
|
purchase_confirmed_at = current_group.get('purchase_confirmed_at')
|
|
else:
|
|
material = previous_group['representative_material']
|
|
material_id = None # 제거된 자재는 현재 material_id가 없음
|
|
description = material.original_description
|
|
purchase_status = 'purchased' if previous_group['purchase_confirmed'] else 'not_purchased'
|
|
purchase_confirmed_at = previous_group.get('purchase_confirmed_at')
|
|
|
|
# 리비전 액션 결정
|
|
revision_action = self._determine_revision_action(
|
|
change_type, current_qty, previous_qty, purchase_status, category
|
|
)
|
|
|
|
return {
|
|
"session_id": session_id,
|
|
"material_id": material_id,
|
|
"previous_material_id": material.id if previous_group else None,
|
|
"material_description": description,
|
|
"category": category,
|
|
"change_type": change_type,
|
|
"current_quantity": float(current_qty),
|
|
"previous_quantity": float(previous_qty),
|
|
"quantity_difference": float(current_qty - previous_qty),
|
|
"purchase_status": purchase_status,
|
|
"purchase_confirmed_at": purchase_confirmed_at,
|
|
"revision_action": revision_action
|
|
}
|
|
|
|
def _determine_revision_action(
|
|
self,
|
|
change_type: str,
|
|
current_qty: Decimal,
|
|
previous_qty: Decimal,
|
|
purchase_status: str,
|
|
category: str
|
|
) -> str:
|
|
"""리비전 액션 결정 로직"""
|
|
|
|
if change_type == "added":
|
|
return "new_material"
|
|
elif change_type == "removed":
|
|
if purchase_status == "purchased":
|
|
return "inventory_transfer" # 구매된 자재 → 재고 이관
|
|
else:
|
|
return "purchase_cancel" # 미구매 자재 → 구매 취소
|
|
elif change_type == "quantity_changed":
|
|
quantity_diff = current_qty - previous_qty
|
|
|
|
if purchase_status == "purchased":
|
|
if quantity_diff > 0:
|
|
return "additional_purchase" # 구매된 자재 수량 증가 → 추가 구매
|
|
else:
|
|
return "inventory_transfer" # 구매된 자재 수량 감소 → 재고 이관
|
|
else:
|
|
return "quantity_update" # 미구매 자재 → 수량 업데이트
|
|
else:
|
|
return "maintain" # 변경 없음
|
|
|
|
def _save_material_changes(self, comparison_results: Dict, session_id: int):
|
|
"""변경사항을 DB에 저장"""
|
|
|
|
try:
|
|
all_changes = []
|
|
for change_type, changes in comparison_results.items():
|
|
all_changes.extend(changes)
|
|
|
|
if not all_changes:
|
|
return
|
|
|
|
# 배치 삽입
|
|
insert_query = """
|
|
INSERT INTO revision_material_changes (
|
|
session_id, material_id, previous_material_id, material_description,
|
|
category, change_type, current_quantity, previous_quantity,
|
|
quantity_difference, purchase_status, purchase_confirmed_at, revision_action
|
|
) VALUES (
|
|
:session_id, :material_id, :previous_material_id, :material_description,
|
|
:category, :change_type, :current_quantity, :previous_quantity,
|
|
:quantity_difference, :purchase_status, :purchase_confirmed_at, :revision_action
|
|
)
|
|
"""
|
|
|
|
self.db.execute(text(insert_query), all_changes)
|
|
self.db.commit()
|
|
|
|
logger.info(f"변경사항 {len(all_changes)}건 저장 완료 (세션: {session_id})")
|
|
|
|
except Exception as e:
|
|
self.db.rollback()
|
|
logger.error(f"변경사항 저장 실패: {e}")
|
|
raise
|
|
|
|
def get_session_changes(self, session_id: int, category: str = None) -> List[Dict[str, Any]]:
|
|
"""세션의 변경사항 조회"""
|
|
|
|
try:
|
|
query = """
|
|
SELECT
|
|
id, material_id, material_description, category,
|
|
change_type, current_quantity, previous_quantity, quantity_difference,
|
|
purchase_status, revision_action, action_status,
|
|
processed_by, processed_at, processing_notes
|
|
FROM revision_material_changes
|
|
WHERE session_id = :session_id
|
|
"""
|
|
params = {"session_id": session_id}
|
|
|
|
if category:
|
|
query += " AND category = :category"
|
|
params["category"] = category
|
|
|
|
query += " ORDER BY category, material_description"
|
|
|
|
changes = self.db.execute(text(query), params).fetchall()
|
|
|
|
return [dict(change._mapping) for change in changes]
|
|
|
|
except Exception as e:
|
|
logger.error(f"세션 변경사항 조회 실패: {e}")
|
|
raise
|
|
|
|
def process_revision_action(
|
|
self,
|
|
change_id: int,
|
|
action: str,
|
|
username: str,
|
|
notes: str = None
|
|
) -> Dict[str, Any]:
|
|
"""리비전 액션 처리"""
|
|
|
|
try:
|
|
# 변경사항 조회
|
|
change = self.db.execute(text("""
|
|
SELECT * FROM revision_material_changes WHERE id = :change_id
|
|
"""), {"change_id": change_id}).fetchone()
|
|
|
|
if not change:
|
|
raise ValueError(f"변경사항을 찾을 수 없습니다: {change_id}")
|
|
|
|
result = {"success": False, "message": ""}
|
|
|
|
# 액션별 처리
|
|
if action == "additional_purchase":
|
|
result = self._process_additional_purchase(change, username, notes)
|
|
elif action == "inventory_transfer":
|
|
result = self._process_inventory_transfer(change, username, notes)
|
|
elif action == "purchase_cancel":
|
|
result = self._process_purchase_cancel(change, username, notes)
|
|
elif action == "quantity_update":
|
|
result = self._process_quantity_update(change, username, notes)
|
|
else:
|
|
result = {"success": True, "message": "처리 완료"}
|
|
|
|
# 처리 상태 업데이트
|
|
status = "completed" if result["success"] else "failed"
|
|
self.db.execute(text("""
|
|
UPDATE revision_material_changes
|
|
SET action_status = :status, processed_by = :username,
|
|
processed_at = CURRENT_TIMESTAMP, processing_notes = :notes
|
|
WHERE id = :change_id
|
|
"""), {
|
|
"change_id": change_id,
|
|
"status": status,
|
|
"username": username,
|
|
"notes": notes or result["message"]
|
|
})
|
|
|
|
# 액션 로그 기록
|
|
self.db.execute(text("""
|
|
INSERT INTO revision_action_logs (
|
|
session_id, revision_change_id, action_type, action_description,
|
|
executed_by, result, result_message
|
|
) VALUES (
|
|
:session_id, :change_id, :action, :description,
|
|
:username, :result, :message
|
|
)
|
|
"""), {
|
|
"session_id": change.session_id,
|
|
"change_id": change_id,
|
|
"action": action,
|
|
"description": f"{change.material_description} - {action}",
|
|
"username": username,
|
|
"result": "success" if result["success"] else "failed",
|
|
"message": result["message"]
|
|
})
|
|
|
|
self.db.commit()
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
self.db.rollback()
|
|
logger.error(f"리비전 액션 처리 실패: {e}")
|
|
raise
|
|
|
|
def _process_additional_purchase(self, change, username: str, notes: str) -> Dict[str, Any]:
|
|
"""추가 구매 처리"""
|
|
# 구매 요청 생성 로직 구현
|
|
return {"success": True, "message": f"추가 구매 요청 생성: {change.quantity_difference}개"}
|
|
|
|
def _process_inventory_transfer(self, change, username: str, notes: str) -> Dict[str, Any]:
|
|
"""재고 이관 처리"""
|
|
# 재고 이관 로직 구현
|
|
try:
|
|
self.db.execute(text("""
|
|
INSERT INTO inventory_transfers (
|
|
revision_change_id, material_description, category,
|
|
quantity, unit, transferred_by, storage_notes
|
|
) VALUES (
|
|
:change_id, :description, :category,
|
|
:quantity, 'EA', :username, :notes
|
|
)
|
|
"""), {
|
|
"change_id": change.id,
|
|
"description": change.material_description,
|
|
"category": change.category,
|
|
"quantity": abs(change.quantity_difference),
|
|
"username": username,
|
|
"notes": notes
|
|
})
|
|
|
|
return {"success": True, "message": f"재고 이관 완료: {abs(change.quantity_difference)}개"}
|
|
|
|
except Exception as e:
|
|
return {"success": False, "message": f"재고 이관 실패: {str(e)}"}
|
|
|
|
def _process_purchase_cancel(self, change, username: str, notes: str) -> Dict[str, Any]:
|
|
"""구매 취소 처리"""
|
|
return {"success": True, "message": "구매 취소 완료"}
|
|
|
|
def _process_quantity_update(self, change, username: str, notes: str) -> Dict[str, Any]:
|
|
"""수량 업데이트 처리"""
|
|
return {"success": True, "message": f"수량 업데이트 완료: {change.current_quantity}개"}
|