Files
TK-BOM-Project/backend/app/services/revision_comparison_service.py
Hyungi Ahn 17843e285f feat: 리비전 관리 시스템 및 구매확정 기능 구현
- 리비전 관리 라우터 및 서비스 추가 (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>
2025-12-06 07:36:44 +09:00

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}"}