🔧 완전한 스키마 자동화 시스템 구축
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
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 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
This commit is contained in:
488
backend/app/services/enhanced_revision_service.py
Normal file
488
backend/app/services/enhanced_revision_service.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
강화된 리비전 관리 서비스
|
||||
구매 상태 기반 리비전 비교 및 처리
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
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 EnhancedRevisionService:
|
||||
"""강화된 리비전 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.db_service = DatabaseService(db)
|
||||
|
||||
def compare_revisions_with_purchase_status(
|
||||
self,
|
||||
job_no: str,
|
||||
current_file_id: int,
|
||||
previous_file_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
구매 상태를 고려한 리비전 비교
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
current_file_id: 현재 파일 ID
|
||||
previous_file_id: 이전 파일 ID (None이면 자동 탐지)
|
||||
|
||||
Returns:
|
||||
비교 결과 딕셔너리
|
||||
"""
|
||||
|
||||
if not previous_file_id:
|
||||
previous_file_id = self._get_previous_file_id(job_no, current_file_id)
|
||||
|
||||
if not previous_file_id:
|
||||
return self._handle_first_revision(current_file_id)
|
||||
|
||||
# 이전 리비전 자재 조회 (구매 상태 포함)
|
||||
previous_materials = self._get_materials_with_purchase_status(previous_file_id)
|
||||
|
||||
# 현재 리비전 자재 조회
|
||||
current_materials = self._get_materials_with_purchase_status(current_file_id)
|
||||
|
||||
# 자재별 비교 수행
|
||||
comparison_result = self._perform_detailed_comparison(
|
||||
previous_materials, current_materials, job_no
|
||||
)
|
||||
|
||||
return comparison_result
|
||||
|
||||
def _get_materials_with_purchase_status(self, file_id: int) -> Dict[str, Dict]:
|
||||
"""파일의 자재를 구매 상태와 함께 조회"""
|
||||
|
||||
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,
|
||||
-- PIPE 자재 특별 키 생성
|
||||
CASE
|
||||
WHEN m.classified_category = 'PIPE' THEN
|
||||
CONCAT(m.drawing_name, '|', m.line_no, '|', COALESCE(m.length, 0))
|
||||
ELSE
|
||||
m.material_hash
|
||||
END as comparison_key
|
||||
FROM materials m
|
||||
WHERE m.file_id = :file_id
|
||||
ORDER BY m.line_number
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {"file_id": file_id})
|
||||
materials = {}
|
||||
|
||||
for row in result.fetchall():
|
||||
row_dict = dict(row._mapping)
|
||||
comparison_key = row_dict['comparison_key']
|
||||
|
||||
# PIPE 자재의 경우 도면-라인넘버별로 길이 합산
|
||||
if row_dict['classified_category'] == 'PIPE':
|
||||
if comparison_key in materials:
|
||||
# 기존 자재에 길이 합산
|
||||
materials[comparison_key]['quantity'] += row_dict['quantity']
|
||||
materials[comparison_key]['total_length'] = (
|
||||
materials[comparison_key].get('total_length', 0) +
|
||||
(row_dict['length'] or 0) * row_dict['quantity']
|
||||
)
|
||||
else:
|
||||
row_dict['total_length'] = (row_dict['length'] or 0) * row_dict['quantity']
|
||||
materials[comparison_key] = row_dict
|
||||
else:
|
||||
materials[comparison_key] = row_dict
|
||||
|
||||
return materials
|
||||
|
||||
def _perform_detailed_comparison(
|
||||
self,
|
||||
previous_materials: Dict[str, Dict],
|
||||
current_materials: Dict[str, Dict],
|
||||
job_no: str
|
||||
) -> Dict[str, Any]:
|
||||
"""상세 비교 수행"""
|
||||
|
||||
result = {
|
||||
"job_no": job_no,
|
||||
"comparison_date": datetime.now().isoformat(),
|
||||
"summary": {
|
||||
"total_previous": len(previous_materials),
|
||||
"total_current": len(current_materials),
|
||||
"purchased_maintained": 0,
|
||||
"purchased_increased": 0,
|
||||
"purchased_decreased": 0,
|
||||
"unpurchased_maintained": 0,
|
||||
"unpurchased_increased": 0,
|
||||
"unpurchased_decreased": 0,
|
||||
"new_materials": 0,
|
||||
"deleted_materials": 0
|
||||
},
|
||||
"changes": {
|
||||
"purchased_materials": {
|
||||
"maintained": [],
|
||||
"additional_purchase_needed": [],
|
||||
"excess_inventory": []
|
||||
},
|
||||
"unpurchased_materials": {
|
||||
"maintained": [],
|
||||
"quantity_updated": [],
|
||||
"quantity_reduced": []
|
||||
},
|
||||
"new_materials": [],
|
||||
"deleted_materials": []
|
||||
}
|
||||
}
|
||||
|
||||
# 이전 자재 기준으로 비교
|
||||
for key, prev_material in previous_materials.items():
|
||||
if key in current_materials:
|
||||
curr_material = current_materials[key]
|
||||
change_info = self._analyze_material_change(prev_material, curr_material)
|
||||
|
||||
if prev_material.get('purchase_confirmed', False):
|
||||
# 구매 완료된 자재 처리
|
||||
self._process_purchased_material_change(result, change_info, prev_material, curr_material)
|
||||
else:
|
||||
# 구매 미완료 자재 처리
|
||||
self._process_unpurchased_material_change(result, change_info, prev_material, curr_material)
|
||||
else:
|
||||
# 삭제된 자재
|
||||
result["changes"]["deleted_materials"].append({
|
||||
"material": prev_material,
|
||||
"reason": "removed_from_new_revision"
|
||||
})
|
||||
result["summary"]["deleted_materials"] += 1
|
||||
|
||||
# 신규 자재 처리
|
||||
for key, curr_material in current_materials.items():
|
||||
if key not in previous_materials:
|
||||
result["changes"]["new_materials"].append({
|
||||
"material": curr_material,
|
||||
"action": "new_material_added"
|
||||
})
|
||||
result["summary"]["new_materials"] += 1
|
||||
|
||||
return result
|
||||
|
||||
def _analyze_material_change(self, prev_material: Dict, curr_material: Dict) -> Dict:
|
||||
"""자재 변경 사항 분석"""
|
||||
|
||||
prev_qty = float(prev_material.get('quantity', 0))
|
||||
curr_qty = float(curr_material.get('quantity', 0))
|
||||
|
||||
# PIPE 자재의 경우 총 길이로 비교
|
||||
if prev_material.get('classified_category') == 'PIPE':
|
||||
prev_total = prev_material.get('total_length', 0)
|
||||
curr_total = curr_material.get('total_length', 0)
|
||||
|
||||
return {
|
||||
"quantity_change": curr_qty - prev_qty,
|
||||
"length_change": curr_total - prev_total,
|
||||
"change_type": "length_based" if abs(curr_total - prev_total) > 0.01 else "no_change"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"quantity_change": curr_qty - prev_qty,
|
||||
"change_type": "increased" if curr_qty > prev_qty else "decreased" if curr_qty < prev_qty else "no_change"
|
||||
}
|
||||
|
||||
def _process_purchased_material_change(
|
||||
self,
|
||||
result: Dict,
|
||||
change_info: Dict,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict
|
||||
):
|
||||
"""구매 완료 자재 변경 처리"""
|
||||
|
||||
if change_info["change_type"] == "no_change":
|
||||
result["changes"]["purchased_materials"]["maintained"].append({
|
||||
"material": curr_material,
|
||||
"action": "maintain_inventory"
|
||||
})
|
||||
result["summary"]["purchased_maintained"] += 1
|
||||
|
||||
elif change_info["change_type"] == "increased":
|
||||
additional_qty = change_info["quantity_change"]
|
||||
result["changes"]["purchased_materials"]["additional_purchase_needed"].append({
|
||||
"material": curr_material,
|
||||
"previous_quantity": prev_material.get('quantity'),
|
||||
"current_quantity": curr_material.get('quantity'),
|
||||
"additional_needed": additional_qty,
|
||||
"action": "additional_purchase_required"
|
||||
})
|
||||
result["summary"]["purchased_increased"] += 1
|
||||
|
||||
else: # decreased
|
||||
excess_qty = abs(change_info["quantity_change"])
|
||||
result["changes"]["purchased_materials"]["excess_inventory"].append({
|
||||
"material": curr_material,
|
||||
"previous_quantity": prev_material.get('quantity'),
|
||||
"current_quantity": curr_material.get('quantity'),
|
||||
"excess_quantity": excess_qty,
|
||||
"action": "mark_as_excess_inventory"
|
||||
})
|
||||
result["summary"]["purchased_decreased"] += 1
|
||||
|
||||
def _process_unpurchased_material_change(
|
||||
self,
|
||||
result: Dict,
|
||||
change_info: Dict,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict
|
||||
):
|
||||
"""구매 미완료 자재 변경 처리"""
|
||||
|
||||
if change_info["change_type"] == "no_change":
|
||||
result["changes"]["unpurchased_materials"]["maintained"].append({
|
||||
"material": curr_material,
|
||||
"action": "maintain_purchase_pending"
|
||||
})
|
||||
result["summary"]["unpurchased_maintained"] += 1
|
||||
|
||||
elif change_info["change_type"] == "increased":
|
||||
result["changes"]["unpurchased_materials"]["quantity_updated"].append({
|
||||
"material": curr_material,
|
||||
"previous_quantity": prev_material.get('quantity'),
|
||||
"current_quantity": curr_material.get('quantity'),
|
||||
"quantity_change": change_info["quantity_change"],
|
||||
"action": "update_purchase_quantity"
|
||||
})
|
||||
result["summary"]["unpurchased_increased"] += 1
|
||||
|
||||
else: # decreased
|
||||
result["changes"]["unpurchased_materials"]["quantity_reduced"].append({
|
||||
"material": curr_material,
|
||||
"previous_quantity": prev_material.get('quantity'),
|
||||
"current_quantity": curr_material.get('quantity'),
|
||||
"quantity_change": change_info["quantity_change"],
|
||||
"action": "reduce_purchase_quantity"
|
||||
})
|
||||
result["summary"]["unpurchased_decreased"] += 1
|
||||
|
||||
def _get_previous_file_id(self, job_no: str, current_file_id: int) -> Optional[int]:
|
||||
"""이전 파일 ID 자동 탐지"""
|
||||
|
||||
query = """
|
||||
SELECT id, revision
|
||||
FROM files
|
||||
WHERE job_no = :job_no AND id != :current_file_id AND is_active = true
|
||||
ORDER BY upload_date DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {
|
||||
"job_no": job_no,
|
||||
"current_file_id": current_file_id
|
||||
})
|
||||
|
||||
row = result.fetchone()
|
||||
return row.id if row else None
|
||||
|
||||
def _handle_first_revision(self, current_file_id: int) -> Dict[str, Any]:
|
||||
"""첫 번째 리비전 처리"""
|
||||
|
||||
materials = self._get_materials_with_purchase_status(current_file_id)
|
||||
|
||||
return {
|
||||
"job_no": None,
|
||||
"comparison_date": datetime.now().isoformat(),
|
||||
"is_first_revision": True,
|
||||
"summary": {
|
||||
"total_materials": len(materials),
|
||||
"all_new": True
|
||||
},
|
||||
"changes": {
|
||||
"new_materials": [{"material": mat, "action": "first_revision"} for mat in materials.values()]
|
||||
}
|
||||
}
|
||||
|
||||
def apply_revision_changes(self, comparison_result: Dict, current_file_id: int) -> Dict[str, Any]:
|
||||
"""리비전 변경사항을 DB에 적용"""
|
||||
|
||||
try:
|
||||
# 각 변경사항별로 DB 업데이트
|
||||
updates_applied = {
|
||||
"purchased_materials": 0,
|
||||
"unpurchased_materials": 0,
|
||||
"new_materials": 0,
|
||||
"deleted_materials": 0
|
||||
}
|
||||
|
||||
changes = comparison_result.get("changes", {})
|
||||
|
||||
# 구매 완료 자재 처리
|
||||
purchased = changes.get("purchased_materials", {})
|
||||
for category, materials in purchased.items():
|
||||
for item in materials:
|
||||
material = item["material"]
|
||||
action = item["action"]
|
||||
|
||||
if action == "additional_purchase_required":
|
||||
self._mark_additional_purchase_needed(material, item)
|
||||
elif action == "mark_as_excess_inventory":
|
||||
self._mark_excess_inventory(material, item)
|
||||
|
||||
updates_applied["purchased_materials"] += 1
|
||||
|
||||
# 구매 미완료 자재 처리
|
||||
unpurchased = changes.get("unpurchased_materials", {})
|
||||
for category, materials in unpurchased.items():
|
||||
for item in materials:
|
||||
material = item["material"]
|
||||
action = item["action"]
|
||||
|
||||
if action == "update_purchase_quantity":
|
||||
self._update_purchase_quantity(material, item)
|
||||
elif action == "reduce_purchase_quantity":
|
||||
self._reduce_purchase_quantity(material, item)
|
||||
|
||||
updates_applied["unpurchased_materials"] += 1
|
||||
|
||||
# 신규 자재 처리
|
||||
for item in changes.get("new_materials", []):
|
||||
self._mark_new_material(item["material"])
|
||||
updates_applied["new_materials"] += 1
|
||||
|
||||
# 삭제된 자재 처리
|
||||
for item in changes.get("deleted_materials", []):
|
||||
self._mark_deleted_material(item["material"])
|
||||
updates_applied["deleted_materials"] += 1
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"updates_applied": updates_applied,
|
||||
"message": "리비전 변경사항이 성공적으로 적용되었습니다."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to apply revision changes: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "리비전 변경사항 적용 중 오류가 발생했습니다."
|
||||
}
|
||||
|
||||
def _mark_additional_purchase_needed(self, material: Dict, change_info: Dict):
|
||||
"""추가 구매 필요 표시"""
|
||||
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET revision_status = 'additional_purchase_needed',
|
||||
notes = CONCAT(COALESCE(notes, ''),
|
||||
'\n추가 구매 필요: ', :additional_qty, ' ', unit)
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material["id"],
|
||||
"additional_qty": change_info["additional_needed"]
|
||||
})
|
||||
|
||||
def _mark_excess_inventory(self, material: Dict, change_info: Dict):
|
||||
"""잉여 재고 표시"""
|
||||
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET revision_status = 'excess_inventory',
|
||||
notes = CONCAT(COALESCE(notes, ''),
|
||||
'\n잉여 재고: ', :excess_qty, ' ', unit)
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material["id"],
|
||||
"excess_qty": change_info["excess_quantity"]
|
||||
})
|
||||
|
||||
def _update_purchase_quantity(self, material: Dict, change_info: Dict):
|
||||
"""구매 수량 업데이트"""
|
||||
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET quantity = :new_quantity,
|
||||
revision_status = 'quantity_updated',
|
||||
notes = CONCAT(COALESCE(notes, ''),
|
||||
'\n수량 변경: ', :prev_qty, ' → ', :new_qty)
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material["id"],
|
||||
"new_quantity": change_info["current_quantity"],
|
||||
"prev_qty": change_info["previous_quantity"],
|
||||
"new_qty": change_info["current_quantity"]
|
||||
})
|
||||
|
||||
def _reduce_purchase_quantity(self, material: Dict, change_info: Dict):
|
||||
"""구매 수량 감소"""
|
||||
|
||||
if change_info["current_quantity"] <= 0:
|
||||
# 수량이 0 이하면 삭제 표시
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET revision_status = 'deleted',
|
||||
is_active = false,
|
||||
notes = CONCAT(COALESCE(notes, ''), '\n리비전에서 삭제됨')
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
else:
|
||||
# 수량만 감소
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET quantity = :new_quantity,
|
||||
revision_status = 'quantity_reduced',
|
||||
notes = CONCAT(COALESCE(notes, ''),
|
||||
'\n수량 감소: ', :prev_qty, ' → ', :new_qty)
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material["id"],
|
||||
"new_quantity": change_info.get("current_quantity", 0),
|
||||
"prev_qty": change_info.get("previous_quantity", 0),
|
||||
"new_qty": change_info.get("current_quantity", 0)
|
||||
})
|
||||
|
||||
def _mark_new_material(self, material: Dict):
|
||||
"""신규 자재 표시"""
|
||||
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET revision_status = 'new_in_revision'
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material["id"]
|
||||
})
|
||||
|
||||
def _mark_deleted_material(self, material: Dict):
|
||||
"""삭제된 자재 표시 (이전 리비전에서)"""
|
||||
|
||||
update_query = """
|
||||
UPDATE materials
|
||||
SET revision_status = 'removed_in_new_revision',
|
||||
notes = CONCAT(COALESCE(notes, ''), '\n신규 리비전에서 제거됨')
|
||||
WHERE id = :material_id
|
||||
"""
|
||||
|
||||
self.db_service.execute_query(update_query, {
|
||||
"material_id": material["id"]
|
||||
})
|
||||
396
backend/app/services/pipe_data_extraction_service.py
Normal file
396
backend/app/services/pipe_data_extraction_service.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
PIPE 데이터 추출 서비스
|
||||
|
||||
BOM 파일에서 PIPE 자재의 도면-라인번호-길이 정보를 추출하고 처리
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Material, File
|
||||
from ..utils.pipe_utils import (
|
||||
PipeConstants, PipeDataExtractor, PipeValidator,
|
||||
PipeFormatter, PipeLogger
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PipeDataExtractionService:
|
||||
"""PIPE 데이터 추출 및 처리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def extract_pipe_data_from_file(self, file_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
파일에서 PIPE 데이터 추출
|
||||
|
||||
Args:
|
||||
file_id: 파일 ID
|
||||
|
||||
Returns:
|
||||
추출된 PIPE 데이터 정보
|
||||
"""
|
||||
try:
|
||||
# 1. 파일 정보 확인
|
||||
file_info = self.db.query(File).filter(File.id == file_id).first()
|
||||
if not file_info:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "파일을 찾을 수 없습니다."
|
||||
}
|
||||
|
||||
# 2. PIPE 자재 조회
|
||||
pipe_materials = self._get_pipe_materials_from_file(file_id)
|
||||
if not pipe_materials:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "PIPE 자재가 없습니다."
|
||||
}
|
||||
|
||||
# 3. 데이터 추출 및 정제
|
||||
extracted_data = []
|
||||
extraction_stats = {
|
||||
"total_materials": len(pipe_materials),
|
||||
"successful_extractions": 0,
|
||||
"failed_extractions": 0,
|
||||
"unique_drawings": set(),
|
||||
"unique_line_numbers": set(),
|
||||
"total_length": 0
|
||||
}
|
||||
|
||||
for material in pipe_materials:
|
||||
extracted_item = self._extract_pipe_item_data(material)
|
||||
if extracted_item["success"]:
|
||||
extracted_data.append(extracted_item["data"])
|
||||
extraction_stats["successful_extractions"] += 1
|
||||
extraction_stats["unique_drawings"].add(extracted_item["data"]["drawing_name"])
|
||||
if extracted_item["data"]["line_no"]:
|
||||
extraction_stats["unique_line_numbers"].add(extracted_item["data"]["line_no"])
|
||||
extraction_stats["total_length"] += extracted_item["data"]["length_mm"]
|
||||
else:
|
||||
extraction_stats["failed_extractions"] += 1
|
||||
logger.warning(f"Failed to extract data from material {material.id}: {extracted_item['message']}")
|
||||
|
||||
# 4. 통계 정리
|
||||
extraction_stats["unique_drawings"] = len(extraction_stats["unique_drawings"])
|
||||
extraction_stats["unique_line_numbers"] = len(extraction_stats["unique_line_numbers"])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_id": file_id,
|
||||
"file_name": file_info.original_filename,
|
||||
"job_no": file_info.job_no,
|
||||
"extracted_data": extracted_data,
|
||||
"extraction_stats": extraction_stats,
|
||||
"message": f"PIPE 데이터 추출 완료: {extraction_stats['successful_extractions']}개 성공, {extraction_stats['failed_extractions']}개 실패"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract pipe data from file {file_id}: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"PIPE 데이터 추출 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def _get_pipe_materials_from_file(self, file_id: int) -> List[Material]:
|
||||
"""파일에서 PIPE 자재 조회"""
|
||||
return self.db.query(Material).filter(
|
||||
Material.file_id == file_id,
|
||||
Material.classified_category == 'PIPE',
|
||||
Material.is_active == True
|
||||
).all()
|
||||
|
||||
def _extract_pipe_item_data(self, material: Material) -> Dict[str, Any]:
|
||||
"""개별 PIPE 자재에서 데이터 추출"""
|
||||
try:
|
||||
# 기본 정보
|
||||
data = {
|
||||
"material_id": material.id,
|
||||
"drawing_name": self._extract_drawing_name(material),
|
||||
"line_no": self._extract_line_number(material),
|
||||
"material_grade": self._extract_material_grade(material),
|
||||
"schedule_spec": self._extract_schedule_spec(material),
|
||||
"nominal_size": self._extract_nominal_size(material),
|
||||
"length_mm": self._extract_length(material),
|
||||
"end_preparation": self._extract_end_preparation(material),
|
||||
"quantity": int(material.quantity or 1),
|
||||
"description": material.description or "",
|
||||
"original_description": material.description or ""
|
||||
}
|
||||
|
||||
# 데이터 검증
|
||||
validation_result = self._validate_extracted_data(data)
|
||||
if not validation_result["valid"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": validation_result["message"],
|
||||
"data": data
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": data,
|
||||
"message": "데이터 추출 성공"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract data from material {material.id}: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"데이터 추출 실패: {str(e)}",
|
||||
"data": {}
|
||||
}
|
||||
|
||||
def _extract_drawing_name(self, material: Material) -> str:
|
||||
"""도면명 추출"""
|
||||
# 1. drawing_name 필드 우선
|
||||
if material.drawing_name:
|
||||
return material.drawing_name.strip()
|
||||
|
||||
# 2. description에서 추출 시도
|
||||
if material.description:
|
||||
# 일반적인 도면명 패턴 (P&ID-001, DWG-A-001 등)
|
||||
drawing_patterns = [
|
||||
r'(P&ID[-_]\w+)',
|
||||
r'(DWG[-_]\w+[-_]\w+)',
|
||||
r'(DRAWING[-_]\w+)',
|
||||
r'([A-Z]+[-_]\d+[-_]\w+)',
|
||||
r'([A-Z]+\d+[A-Z]*)'
|
||||
]
|
||||
|
||||
for pattern in drawing_patterns:
|
||||
match = re.search(pattern, material.description.upper())
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return "UNKNOWN_DRAWING"
|
||||
|
||||
def _extract_line_number(self, material: Material) -> str:
|
||||
"""라인번호 추출"""
|
||||
# 1. line_no 필드 우선
|
||||
if material.line_no:
|
||||
return material.line_no.strip()
|
||||
|
||||
# 2. description에서 추출 시도
|
||||
if material.description:
|
||||
# 라인번호 패턴 (LINE-001, L-001, 1001 등)
|
||||
line_patterns = [
|
||||
r'LINE[-_]?(\w+)',
|
||||
r'L[-_]?(\d+[A-Z]*)',
|
||||
r'(\d{3,4}[A-Z]*)', # 3-4자리 숫자 + 선택적 문자
|
||||
r'([A-Z]\d+[A-Z]*)' # 문자+숫자+선택적문자
|
||||
]
|
||||
|
||||
for pattern in line_patterns:
|
||||
match = re.search(pattern, material.description.upper())
|
||||
if match:
|
||||
return f"LINE-{match.group(1)}"
|
||||
|
||||
return "" # 라인번호는 필수가 아님
|
||||
|
||||
def _extract_material_grade(self, material: Material) -> str:
|
||||
"""재질 추출"""
|
||||
# 1. full_material_grade 필드 우선
|
||||
if material.full_material_grade:
|
||||
return material.full_material_grade.strip()
|
||||
|
||||
# 2. description에서 추출 시도
|
||||
if material.description:
|
||||
# 일반적인 재질 패턴
|
||||
material_patterns = [
|
||||
r'(A\d+\s*GR\.?\s*[A-Z])', # A106 GR.B
|
||||
r'(A\d+)', # A106
|
||||
r'(SS\d+[A-Z]*)', # SS316L
|
||||
r'(CS|CARBON\s*STEEL)', # Carbon Steel
|
||||
r'(SS|STAINLESS\s*STEEL)' # Stainless Steel
|
||||
]
|
||||
|
||||
for pattern in material_patterns:
|
||||
match = re.search(pattern, material.description.upper())
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
return "UNKNOWN"
|
||||
|
||||
def _extract_schedule_spec(self, material: Material) -> str:
|
||||
"""스케줄/규격 추출"""
|
||||
if material.description:
|
||||
# 스케줄 패턴 (SCH40, SCH80, STD, XS 등)
|
||||
schedule_patterns = [
|
||||
r'(SCH\s*\d+[A-Z]*)',
|
||||
r'(STD|STANDARD)',
|
||||
r'(XS|EXTRA\s*STRONG)',
|
||||
r'(XXS|DOUBLE\s*EXTRA\s*STRONG)',
|
||||
r'(\d+\.?\d*\s*MM)', # 두께 (mm)
|
||||
r'(\d+\.?\d*"?\s*THK)' # 두께 (THK)
|
||||
]
|
||||
|
||||
for pattern in schedule_patterns:
|
||||
match = re.search(pattern, material.description.upper())
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_nominal_size(self, material: Material) -> str:
|
||||
"""호칭 크기 추출"""
|
||||
# 1. main_nom 필드 우선
|
||||
if material.main_nom:
|
||||
return material.main_nom.strip()
|
||||
|
||||
# 2. description에서 추출 시도
|
||||
if material.description:
|
||||
# 호칭 크기 패턴 (4", 6", 100A 등)
|
||||
size_patterns = [
|
||||
r'(\d+\.?\d*")', # 4", 6.5"
|
||||
r'(\d+\.?\d*\s*INCH)', # 4 INCH
|
||||
r'(\d+A)', # 100A
|
||||
r'(DN\s*\d+)', # DN100
|
||||
r'(\d+\.?\d*\s*MM)' # 100MM (직경)
|
||||
]
|
||||
|
||||
for pattern in size_patterns:
|
||||
match = re.search(pattern, material.description.upper())
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_length(self, material: Material) -> float:
|
||||
"""길이 추출 (mm 단위)"""
|
||||
# 1. length 필드 우선
|
||||
if material.length and material.length > 0:
|
||||
return float(material.length)
|
||||
|
||||
# 2. total_length 필드
|
||||
if material.total_length and material.total_length > 0:
|
||||
return float(material.total_length)
|
||||
|
||||
# 3. description에서 추출 시도
|
||||
if material.description:
|
||||
# 길이 패턴
|
||||
length_patterns = [
|
||||
r'(\d+\.?\d*)\s*MM', # 1500MM
|
||||
r'(\d+\.?\d*)\s*M(?!\w)', # 1.5M (단, MM이 아닌)
|
||||
r'(\d+\.?\d*)\s*METER', # 1.5 METER
|
||||
r'L\s*=?\s*(\d+\.?\d*)', # L=1500
|
||||
r'LENGTH\s*:?\s*(\d+\.?\d*)' # LENGTH: 1500
|
||||
]
|
||||
|
||||
for pattern in length_patterns:
|
||||
match = re.search(pattern, material.description.upper())
|
||||
if match:
|
||||
length_value = float(match.group(1))
|
||||
# 단위 변환 (M -> MM)
|
||||
if 'M' in pattern and 'MM' not in pattern:
|
||||
length_value *= 1000
|
||||
return length_value
|
||||
|
||||
# 기본값: 6000mm (6m)
|
||||
return 6000.0
|
||||
|
||||
def _extract_end_preparation(self, material: Material) -> str:
|
||||
"""끝단 가공 정보 추출"""
|
||||
if material.description:
|
||||
desc_upper = material.description.upper()
|
||||
|
||||
# 끝단 가공 패턴
|
||||
if any(keyword in desc_upper for keyword in ['DOUBLE BEVEL', '양개선', 'DBE']):
|
||||
return '양개선'
|
||||
elif any(keyword in desc_upper for keyword in ['SINGLE BEVEL', '한개선', 'SBE']):
|
||||
return '한개선'
|
||||
elif any(keyword in desc_upper for keyword in ['PLAIN', '무개선', 'PE']):
|
||||
return '무개선'
|
||||
|
||||
return '무개선' # 기본값
|
||||
|
||||
def _validate_extracted_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""추출된 데이터 검증"""
|
||||
errors = []
|
||||
|
||||
# 필수 필드 검증
|
||||
if not data.get("drawing_name") or data["drawing_name"] == "UNKNOWN_DRAWING":
|
||||
errors.append("도면명을 추출할 수 없습니다")
|
||||
|
||||
if data.get("length_mm", 0) <= 0:
|
||||
errors.append("유효한 길이 정보가 없습니다")
|
||||
|
||||
if not data.get("material_grade") or data["material_grade"] == "UNKNOWN":
|
||||
errors.append("재질 정보를 추출할 수 없습니다")
|
||||
|
||||
# 경고 (오류는 아님)
|
||||
warnings = []
|
||||
if not data.get("line_no"):
|
||||
warnings.append("라인번호가 없습니다")
|
||||
|
||||
if not data.get("nominal_size"):
|
||||
warnings.append("호칭 크기가 없습니다")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"message": "; ".join(errors) if errors else "검증 통과"
|
||||
}
|
||||
|
||||
def get_extraction_summary(self, file_id: int) -> Dict[str, Any]:
|
||||
"""파일의 PIPE 데이터 추출 요약 정보"""
|
||||
try:
|
||||
extraction_result = self.extract_pipe_data_from_file(file_id)
|
||||
|
||||
if not extraction_result["success"]:
|
||||
return extraction_result
|
||||
|
||||
# 요약 통계 생성
|
||||
extracted_data = extraction_result["extracted_data"]
|
||||
|
||||
# 도면별 통계
|
||||
drawing_stats = {}
|
||||
for item in extracted_data:
|
||||
drawing = item["drawing_name"]
|
||||
if drawing not in drawing_stats:
|
||||
drawing_stats[drawing] = {
|
||||
"count": 0,
|
||||
"total_length": 0,
|
||||
"line_numbers": set(),
|
||||
"materials": set()
|
||||
}
|
||||
|
||||
drawing_stats[drawing]["count"] += 1
|
||||
drawing_stats[drawing]["total_length"] += item["length_mm"]
|
||||
if item["line_no"]:
|
||||
drawing_stats[drawing]["line_numbers"].add(item["line_no"])
|
||||
drawing_stats[drawing]["materials"].add(item["material_grade"])
|
||||
|
||||
# set을 list로 변환
|
||||
for drawing in drawing_stats:
|
||||
drawing_stats[drawing]["line_numbers"] = list(drawing_stats[drawing]["line_numbers"])
|
||||
drawing_stats[drawing]["materials"] = list(drawing_stats[drawing]["materials"])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_id": file_id,
|
||||
"extraction_stats": extraction_result["extraction_stats"],
|
||||
"drawing_stats": drawing_stats,
|
||||
"ready_for_cutting_plan": extraction_result["extraction_stats"]["successful_extractions"] > 0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get extraction summary: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"추출 요약 생성 실패: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def get_pipe_data_extraction_service(db: Session = None) -> PipeDataExtractionService:
|
||||
"""PipeDataExtractionService 인스턴스 생성"""
|
||||
if db is None:
|
||||
db = next(get_db())
|
||||
return PipeDataExtractionService(db)
|
||||
362
backend/app/services/pipe_issue_snapshot_service.py
Normal file
362
backend/app/services/pipe_issue_snapshot_service.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
PIPE 이슈 관리용 스냅샷 시스템
|
||||
|
||||
단관 관리 DB의 특정 시점 데이터를 고정하여
|
||||
이후 리비전이 발생해도 이슈 관리에 영향을 주지 않도록 함
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
PipeCuttingPlan, PipeIssueSnapshot, PipeIssueSegment,
|
||||
PipeDrawingIssue, PipeSegmentIssue
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PipeIssueSnapshotService:
|
||||
"""PIPE 이슈 관리용 스냅샷 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_and_lock_snapshot_on_finalize(self, job_no: str, created_by: str = "system") -> Dict[str, Any]:
|
||||
"""
|
||||
Cutting Plan 확정 시 스냅샷 생성 및 즉시 잠금
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
created_by: 생성자
|
||||
|
||||
Returns:
|
||||
생성된 스냅샷 정보
|
||||
"""
|
||||
try:
|
||||
# 1. 기존 활성 스냅샷 확인
|
||||
existing_snapshot = self.db.query(PipeIssueSnapshot).filter(
|
||||
and_(
|
||||
PipeIssueSnapshot.job_no == job_no,
|
||||
PipeIssueSnapshot.is_active == True
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing_snapshot:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"이미 확정된 Cutting Plan이 존재합니다: {existing_snapshot.snapshot_name}",
|
||||
"existing_snapshot_id": existing_snapshot.id,
|
||||
"can_manage_issues": True
|
||||
}
|
||||
|
||||
# 2. 현재 단관 데이터 조회
|
||||
current_segments = self._get_current_cutting_plan_data(job_no)
|
||||
if not current_segments:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "확정할 Cutting Plan 데이터가 없습니다."
|
||||
}
|
||||
|
||||
# 3. 자동 스냅샷 이름 생성
|
||||
snapshot_name = f"Cutting Plan 확정 - {datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
# 4. 스냅샷 레코드 생성 (즉시 잠금 상태)
|
||||
snapshot = PipeIssueSnapshot(
|
||||
job_no=job_no,
|
||||
snapshot_name=snapshot_name,
|
||||
created_by=created_by,
|
||||
is_locked=True, # 확정과 동시에 잠금
|
||||
locked_at=datetime.utcnow(),
|
||||
locked_by=created_by,
|
||||
total_segments=len(current_segments),
|
||||
total_drawings=len(set(seg["drawing_name"] for seg in current_segments))
|
||||
)
|
||||
|
||||
self.db.add(snapshot)
|
||||
self.db.flush() # ID 생성을 위해
|
||||
|
||||
# 4. 단관 데이터 스냅샷 저장
|
||||
snapshot_segments = []
|
||||
for segment_data in current_segments:
|
||||
segment = PipeIssueSegment(
|
||||
snapshot_id=snapshot.id,
|
||||
area=segment_data.get("area"),
|
||||
drawing_name=segment_data["drawing_name"],
|
||||
line_no=segment_data["line_no"],
|
||||
material_grade=segment_data.get("material_grade"),
|
||||
schedule_spec=segment_data.get("schedule_spec"),
|
||||
nominal_size=segment_data.get("nominal_size"),
|
||||
length_mm=segment_data["length_mm"],
|
||||
end_preparation=segment_data.get("end_preparation", "무개선"),
|
||||
original_cutting_plan_id=segment_data.get("original_id")
|
||||
)
|
||||
snapshot_segments.append(segment)
|
||||
|
||||
self.db.add_all(snapshot_segments)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Created snapshot {snapshot.id} for job {job_no} with {len(snapshot_segments)} segments")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"snapshot_id": snapshot.id,
|
||||
"snapshot_name": snapshot_name,
|
||||
"total_segments": len(snapshot_segments),
|
||||
"total_drawings": snapshot.total_drawings,
|
||||
"is_locked": True,
|
||||
"locked_at": snapshot.locked_at,
|
||||
"message": f"Cutting Plan이 확정되었습니다! 이제 이슈 관리를 시작할 수 있습니다.",
|
||||
"next_action": "start_issue_management"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to create snapshot: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"스냅샷 생성 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def lock_snapshot(self, snapshot_id: int, locked_by: str = "system") -> Dict[str, Any]:
|
||||
"""
|
||||
스냅샷 잠금 (이슈 등록 시작)
|
||||
잠금 후에는 더 이상 리비전 영향을 받지 않음
|
||||
"""
|
||||
try:
|
||||
snapshot = self.db.query(PipeIssueSnapshot).filter(
|
||||
PipeIssueSnapshot.id == snapshot_id
|
||||
).first()
|
||||
|
||||
if not snapshot:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "스냅샷을 찾을 수 없습니다."
|
||||
}
|
||||
|
||||
if snapshot.is_locked:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"이미 잠긴 스냅샷입니다. (잠금자: {snapshot.locked_by})"
|
||||
}
|
||||
|
||||
# 스냅샷 잠금
|
||||
snapshot.is_locked = True
|
||||
snapshot.locked_at = datetime.utcnow()
|
||||
snapshot.locked_by = locked_by
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Locked snapshot {snapshot_id} by {locked_by}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"스냅샷 '{snapshot.snapshot_name}'이 잠금되었습니다. 이제 이슈 관리를 시작할 수 있습니다.",
|
||||
"locked_at": snapshot.locked_at
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to lock snapshot: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"스냅샷 잠금 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def get_snapshot_info(self, job_no: str) -> Dict[str, Any]:
|
||||
"""작업의 스냅샷 정보 조회"""
|
||||
try:
|
||||
snapshot = self.db.query(PipeIssueSnapshot).filter(
|
||||
and_(
|
||||
PipeIssueSnapshot.job_no == job_no,
|
||||
PipeIssueSnapshot.is_active == True
|
||||
)
|
||||
).first()
|
||||
|
||||
if not snapshot:
|
||||
return {
|
||||
"has_snapshot": False,
|
||||
"message": "생성된 스냅샷이 없습니다."
|
||||
}
|
||||
|
||||
# 이슈 통계 조회
|
||||
drawing_issues_count = self.db.query(PipeDrawingIssue).filter(
|
||||
PipeDrawingIssue.snapshot_id == snapshot.id
|
||||
).count()
|
||||
|
||||
segment_issues_count = self.db.query(PipeSegmentIssue).filter(
|
||||
PipeSegmentIssue.snapshot_id == snapshot.id
|
||||
).count()
|
||||
|
||||
return {
|
||||
"has_snapshot": True,
|
||||
"snapshot_id": snapshot.id,
|
||||
"snapshot_name": snapshot.snapshot_name,
|
||||
"is_locked": snapshot.is_locked,
|
||||
"created_at": snapshot.created_at,
|
||||
"created_by": snapshot.created_by,
|
||||
"locked_at": snapshot.locked_at,
|
||||
"locked_by": snapshot.locked_by,
|
||||
"total_segments": snapshot.total_segments,
|
||||
"total_drawings": snapshot.total_drawings,
|
||||
"drawing_issues_count": drawing_issues_count,
|
||||
"segment_issues_count": segment_issues_count,
|
||||
"can_start_issue_management": not snapshot.is_locked,
|
||||
"message": "잠긴 스냅샷 - 이슈 관리 진행 중" if snapshot.is_locked else "스냅샷 준비 완료"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get snapshot info: {e}")
|
||||
return {
|
||||
"has_snapshot": False,
|
||||
"message": f"스냅샷 정보 조회 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def get_snapshot_segments(self, snapshot_id: int, area: str = None, drawing_name: str = None) -> List[Dict[str, Any]]:
|
||||
"""스냅샷된 단관 데이터 조회"""
|
||||
try:
|
||||
query = self.db.query(PipeIssueSegment).filter(
|
||||
PipeIssueSegment.snapshot_id == snapshot_id
|
||||
)
|
||||
|
||||
if area:
|
||||
query = query.filter(PipeIssueSegment.area == area)
|
||||
|
||||
if drawing_name:
|
||||
query = query.filter(PipeIssueSegment.drawing_name == drawing_name)
|
||||
|
||||
segments = query.order_by(
|
||||
PipeIssueSegment.area,
|
||||
PipeIssueSegment.drawing_name,
|
||||
PipeIssueSegment.line_no
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for segment in segments:
|
||||
result.append({
|
||||
"id": segment.id,
|
||||
"area": segment.area,
|
||||
"drawing_name": segment.drawing_name,
|
||||
"line_no": segment.line_no,
|
||||
"material_grade": segment.material_grade,
|
||||
"schedule_spec": segment.schedule_spec,
|
||||
"nominal_size": segment.nominal_size,
|
||||
"length_mm": float(segment.length_mm) if segment.length_mm else 0,
|
||||
"end_preparation": segment.end_preparation,
|
||||
"material_info": f"{segment.material_grade or ''} {segment.schedule_spec or ''} {segment.nominal_size or ''}".strip()
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get snapshot segments: {e}")
|
||||
return []
|
||||
|
||||
def get_available_areas(self, snapshot_id: int) -> List[str]:
|
||||
"""스냅샷의 사용 가능한 구역 목록"""
|
||||
try:
|
||||
result = self.db.query(PipeIssueSegment.area).filter(
|
||||
and_(
|
||||
PipeIssueSegment.snapshot_id == snapshot_id,
|
||||
PipeIssueSegment.area.isnot(None)
|
||||
)
|
||||
).distinct().all()
|
||||
|
||||
areas = [row.area for row in result if row.area]
|
||||
return sorted(areas)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get available areas: {e}")
|
||||
return []
|
||||
|
||||
def get_available_drawings(self, snapshot_id: int, area: str = None) -> List[str]:
|
||||
"""스냅샷의 사용 가능한 도면 목록"""
|
||||
try:
|
||||
query = self.db.query(PipeIssueSegment.drawing_name).filter(
|
||||
PipeIssueSegment.snapshot_id == snapshot_id
|
||||
)
|
||||
|
||||
if area:
|
||||
query = query.filter(PipeIssueSegment.area == area)
|
||||
|
||||
result = query.distinct().all()
|
||||
drawings = [row.drawing_name for row in result if row.drawing_name]
|
||||
return sorted(drawings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get available drawings: {e}")
|
||||
return []
|
||||
|
||||
def _get_current_cutting_plan_data(self, job_no: str) -> List[Dict[str, Any]]:
|
||||
"""현재 단관 관리 DB에서 데이터 조회"""
|
||||
try:
|
||||
cutting_plans = self.db.query(PipeCuttingPlan).filter(
|
||||
PipeCuttingPlan.job_no == job_no
|
||||
).all()
|
||||
|
||||
segments = []
|
||||
for plan in cutting_plans:
|
||||
segments.append({
|
||||
"original_id": plan.id,
|
||||
"area": plan.area,
|
||||
"drawing_name": plan.drawing_name,
|
||||
"line_no": plan.line_no,
|
||||
"material_grade": plan.material_grade,
|
||||
"schedule_spec": plan.schedule_spec,
|
||||
"nominal_size": plan.nominal_size,
|
||||
"length_mm": float(plan.length_mm) if plan.length_mm else 0,
|
||||
"end_preparation": plan.end_preparation or "무개선"
|
||||
})
|
||||
|
||||
return segments
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get current cutting plan data: {e}")
|
||||
return []
|
||||
|
||||
def check_revision_protection(self, job_no: str) -> Dict[str, Any]:
|
||||
"""
|
||||
리비전 보호 상태 확인
|
||||
잠긴 스냅샷이 있으면 더 이상 리비전 영향을 받지 않음
|
||||
"""
|
||||
try:
|
||||
snapshot = self.db.query(PipeIssueSnapshot).filter(
|
||||
and_(
|
||||
PipeIssueSnapshot.job_no == job_no,
|
||||
PipeIssueSnapshot.is_active == True,
|
||||
PipeIssueSnapshot.is_locked == True
|
||||
)
|
||||
).first()
|
||||
|
||||
if snapshot:
|
||||
return {
|
||||
"is_protected": True,
|
||||
"snapshot_id": snapshot.id,
|
||||
"snapshot_name": snapshot.snapshot_name,
|
||||
"locked_at": snapshot.locked_at,
|
||||
"locked_by": snapshot.locked_by,
|
||||
"message": f"이슈 관리가 진행 중입니다. 스냅샷 '{snapshot.snapshot_name}'이 보호되고 있습니다."
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"is_protected": False,
|
||||
"message": "리비전 보호가 활성화되지 않았습니다."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check revision protection: {e}")
|
||||
return {
|
||||
"is_protected": False,
|
||||
"message": f"리비전 보호 상태 확인 실패: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def get_pipe_issue_snapshot_service(db: Session = None) -> PipeIssueSnapshotService:
|
||||
"""PipeIssueSnapshotService 인스턴스 생성"""
|
||||
if db is None:
|
||||
db = next(get_db())
|
||||
return PipeIssueSnapshotService(db)
|
||||
541
backend/app/services/pipe_revision_service.py
Normal file
541
backend/app/services/pipe_revision_service.py
Normal file
@@ -0,0 +1,541 @@
|
||||
"""
|
||||
PIPE 전용 리비전 관리 서비스
|
||||
|
||||
Cutting Plan 작성 전/후에 따른 차별화된 리비전 처리 로직
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
File, Material, PipeCuttingPlan, PipeRevisionComparison,
|
||||
PipeRevisionChange, PipeLengthCalculation
|
||||
)
|
||||
from ..utils.pipe_utils import (
|
||||
PipeConstants, PipeDataExtractor, PipeCalculator,
|
||||
PipeComparator, PipeValidator, PipeLogger
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PipeRevisionService:
|
||||
"""PIPE 전용 리비전 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def check_revision_status(self, job_no: str, new_file_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
리비전 상태 확인 및 처리 방식 결정
|
||||
|
||||
Returns:
|
||||
- revision_type: 'no_revision', 'pre_cutting_plan', 'post_cutting_plan'
|
||||
- requires_action: 처리가 필요한지 여부
|
||||
- message: 사용자에게 표시할 메시지
|
||||
"""
|
||||
try:
|
||||
# 기존 파일 확인
|
||||
previous_file = self._get_previous_file(job_no, new_file_id)
|
||||
if not previous_file:
|
||||
return {
|
||||
"revision_type": "no_revision",
|
||||
"requires_action": False,
|
||||
"message": "첫 번째 BOM 파일입니다. 새로운 Cutting Plan을 작성해주세요."
|
||||
}
|
||||
|
||||
# Cutting Plan 존재 여부 확인
|
||||
has_cutting_plan = self._has_existing_cutting_plan(job_no)
|
||||
|
||||
if not has_cutting_plan:
|
||||
# Cutting Plan 작성 전 리비전
|
||||
return {
|
||||
"revision_type": "pre_cutting_plan",
|
||||
"requires_action": True,
|
||||
"previous_file_id": previous_file.id,
|
||||
"message": "Cutting Plan 작성 전 리비전이 감지되었습니다. 새로운 BOM으로 Cutting Plan을 작성해주세요."
|
||||
}
|
||||
else:
|
||||
# Cutting Plan 작성 후 리비전
|
||||
return {
|
||||
"revision_type": "post_cutting_plan",
|
||||
"requires_action": True,
|
||||
"previous_file_id": previous_file.id,
|
||||
"message": "기존 Cutting Plan이 있는 상태에서 리비전이 감지되었습니다. 변경사항을 비교 검토해주세요."
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check pipe revision status: {e}")
|
||||
return {
|
||||
"revision_type": "error",
|
||||
"requires_action": False,
|
||||
"message": f"리비전 상태 확인 중 오류가 발생했습니다: {str(e)}"
|
||||
}
|
||||
|
||||
def handle_pre_cutting_plan_revision(self, job_no: str, new_file_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Cutting Plan 작성 전 리비전 처리
|
||||
- 기존 PIPE 관련 데이터 전체 삭제
|
||||
- 새 BOM 파일로 초기화
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Processing pre-cutting-plan revision for job {job_no}")
|
||||
|
||||
# 1. 기존 PIPE 관련 데이터 삭제
|
||||
deleted_count = self._delete_existing_pipe_data(job_no)
|
||||
|
||||
# 2. 새 BOM에서 PIPE 데이터 추출
|
||||
pipe_materials = self._extract_pipe_materials_from_bom(new_file_id)
|
||||
|
||||
# 3. 처리 결과 반환
|
||||
return {
|
||||
"status": "success",
|
||||
"revision_type": "pre_cutting_plan",
|
||||
"deleted_items": deleted_count,
|
||||
"new_pipe_materials": len(pipe_materials),
|
||||
"message": f"기존 PIPE 데이터 {deleted_count}건이 삭제되었습니다. 새로운 Cutting Plan을 작성해주세요.",
|
||||
"next_action": "create_new_cutting_plan",
|
||||
"pipe_materials": pipe_materials
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to handle pre-cutting-plan revision: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Cutting Plan 작성 전 리비전 처리 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def handle_post_cutting_plan_revision(self, job_no: str, new_file_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Cutting Plan 작성 후 리비전 처리
|
||||
- 기존 Cutting Plan과 신규 BOM 비교
|
||||
- 변경사항 상세 분석
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Processing post-cutting-plan revision for job {job_no}")
|
||||
|
||||
# 1. 기존 Cutting Plan 조회
|
||||
existing_plan = self._get_existing_cutting_plan_data(job_no)
|
||||
if not existing_plan:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "기존 Cutting Plan을 찾을 수 없습니다."
|
||||
}
|
||||
|
||||
# 2. 새 BOM에서 PIPE 데이터 추출
|
||||
new_pipe_data = self._extract_pipe_materials_from_bom(new_file_id)
|
||||
|
||||
# 3. 도면별 비교 수행
|
||||
comparison_result = self._compare_pipe_data_by_drawing(existing_plan, new_pipe_data)
|
||||
|
||||
# 4. 비교 결과 저장
|
||||
comparison_id = self._save_comparison_result(job_no, new_file_id, comparison_result)
|
||||
|
||||
# 5. 변경사항 요약
|
||||
summary = self._generate_comparison_summary(comparison_result)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"revision_type": "post_cutting_plan",
|
||||
"comparison_id": comparison_id,
|
||||
"summary": summary,
|
||||
"changed_drawings": [d for d in comparison_result if d["has_changes"]],
|
||||
"unchanged_drawings": [d for d in comparison_result if not d["has_changes"]],
|
||||
"message": f"리비전 비교가 완료되었습니다. {summary['changed_drawings_count']}개 도면에서 변경사항이 발견되었습니다.",
|
||||
"next_action": "review_changes"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to handle post-cutting-plan revision: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Cutting Plan 작성 후 리비전 처리 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def _get_previous_file(self, job_no: str, current_file_id: int) -> Optional[File]:
|
||||
"""이전 파일 조회"""
|
||||
return self.db.query(File).filter(
|
||||
and_(
|
||||
File.job_no == job_no,
|
||||
File.id < current_file_id,
|
||||
File.is_active == True
|
||||
)
|
||||
).order_by(File.id.desc()).first()
|
||||
|
||||
def _has_existing_cutting_plan(self, job_no: str) -> bool:
|
||||
"""기존 Cutting Plan 존재 여부 확인"""
|
||||
count = self.db.query(PipeCuttingPlan).filter(
|
||||
PipeCuttingPlan.job_no == job_no
|
||||
).count()
|
||||
return count > 0
|
||||
|
||||
def _delete_existing_pipe_data(self, job_no: str) -> int:
|
||||
"""기존 PIPE 관련 데이터 삭제"""
|
||||
try:
|
||||
# Cutting Plan 데이터 삭제
|
||||
cutting_plan_count = self.db.query(PipeCuttingPlan).filter(
|
||||
PipeCuttingPlan.job_no == job_no
|
||||
).count()
|
||||
|
||||
self.db.query(PipeCuttingPlan).filter(
|
||||
PipeCuttingPlan.job_no == job_no
|
||||
).delete()
|
||||
|
||||
# Length Calculation 데이터 삭제
|
||||
self.db.query(PipeLengthCalculation).filter(
|
||||
PipeLengthCalculation.file_id.in_(
|
||||
self.db.query(File.id).filter(File.job_no == job_no)
|
||||
)
|
||||
).delete()
|
||||
|
||||
self.db.commit()
|
||||
logger.info(f"Deleted {cutting_plan_count} cutting plan records for job {job_no}")
|
||||
|
||||
return cutting_plan_count
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to delete existing pipe data: {e}")
|
||||
raise
|
||||
|
||||
def _extract_pipe_materials_from_bom(self, file_id: int) -> List[Dict[str, Any]]:
|
||||
"""BOM 파일에서 PIPE 자재 추출 (리팩토링된 유틸리티 사용)"""
|
||||
return PipeDataExtractor.extract_pipe_materials_from_file(self.db, file_id)
|
||||
|
||||
def _get_existing_cutting_plan_data(self, job_no: str) -> List[Dict[str, Any]]:
|
||||
"""기존 Cutting Plan 데이터 조회"""
|
||||
try:
|
||||
cutting_plans = self.db.query(PipeCuttingPlan).filter(
|
||||
PipeCuttingPlan.job_no == job_no
|
||||
).all()
|
||||
|
||||
plan_data = []
|
||||
for plan in cutting_plans:
|
||||
plan_data.append({
|
||||
"id": plan.id,
|
||||
"area": plan.area or "",
|
||||
"drawing_name": plan.drawing_name,
|
||||
"line_no": plan.line_no,
|
||||
"material_grade": plan.material_grade or "",
|
||||
"schedule_spec": plan.schedule_spec or "",
|
||||
"nominal_size": plan.nominal_size or "",
|
||||
"length_mm": float(plan.length_mm or 0),
|
||||
"end_preparation": plan.end_preparation or "무개선"
|
||||
})
|
||||
|
||||
logger.info(f"Retrieved {len(plan_data)} cutting plan records for job {job_no}")
|
||||
return plan_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get existing cutting plan data: {e}")
|
||||
raise
|
||||
|
||||
def _compare_pipe_data_by_drawing(self, existing_plan: List[Dict], new_pipe_data: List[Dict]) -> List[Dict[str, Any]]:
|
||||
"""도면별 PIPE 데이터 비교"""
|
||||
try:
|
||||
# 도면별로 데이터 그룹화
|
||||
existing_by_drawing = self._group_by_drawing(existing_plan)
|
||||
new_by_drawing = self._group_by_drawing(new_pipe_data)
|
||||
|
||||
# 모든 도면 목록
|
||||
all_drawings = set(existing_by_drawing.keys()) | set(new_by_drawing.keys())
|
||||
|
||||
comparison_results = []
|
||||
|
||||
for drawing_name in sorted(all_drawings):
|
||||
existing_segments = existing_by_drawing.get(drawing_name, [])
|
||||
new_segments = new_by_drawing.get(drawing_name, [])
|
||||
|
||||
# 도면별 비교 수행
|
||||
drawing_comparison = self._compare_drawing_segments(
|
||||
drawing_name, existing_segments, new_segments
|
||||
)
|
||||
|
||||
comparison_results.append(drawing_comparison)
|
||||
|
||||
return comparison_results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to compare pipe data by drawing: {e}")
|
||||
raise
|
||||
|
||||
def _group_by_drawing(self, data: List[Dict]) -> Dict[str, List[Dict]]:
|
||||
"""데이터를 도면별로 그룹화"""
|
||||
grouped = {}
|
||||
for item in data:
|
||||
drawing = item.get("drawing_name", "UNKNOWN")
|
||||
if drawing not in grouped:
|
||||
grouped[drawing] = []
|
||||
grouped[drawing].append(item)
|
||||
return grouped
|
||||
|
||||
def _compare_drawing_segments(self, drawing_name: str, existing: List[Dict], new: List[Dict]) -> Dict[str, Any]:
|
||||
"""단일 도면의 세그먼트 비교"""
|
||||
try:
|
||||
# 세그먼트 매칭 (재질, 길이, 끝단가공 기준)
|
||||
matched_pairs, added_segments, removed_segments = self._match_segments(existing, new)
|
||||
|
||||
# 변경사항 분석
|
||||
unchanged_segments = []
|
||||
modified_segments = []
|
||||
|
||||
for existing_seg, new_seg in matched_pairs:
|
||||
if self._segments_are_identical(existing_seg, new_seg):
|
||||
unchanged_segments.append({
|
||||
"change_type": "unchanged",
|
||||
"segment_data": new_seg,
|
||||
"existing_data": existing_seg
|
||||
})
|
||||
else:
|
||||
changes = self._get_segment_changes(existing_seg, new_seg)
|
||||
modified_segments.append({
|
||||
"change_type": "modified",
|
||||
"segment_data": new_seg,
|
||||
"existing_data": existing_seg,
|
||||
"changes": changes
|
||||
})
|
||||
|
||||
# 추가된 세그먼트
|
||||
added_segment_data = [
|
||||
{
|
||||
"change_type": "added",
|
||||
"segment_data": seg,
|
||||
"existing_data": None
|
||||
}
|
||||
for seg in added_segments
|
||||
]
|
||||
|
||||
# 삭제된 세그먼트
|
||||
removed_segment_data = [
|
||||
{
|
||||
"change_type": "removed",
|
||||
"segment_data": None,
|
||||
"existing_data": seg
|
||||
}
|
||||
for seg in removed_segments
|
||||
]
|
||||
|
||||
# 전체 세그먼트 목록
|
||||
all_segments = unchanged_segments + modified_segments + added_segment_data + removed_segment_data
|
||||
|
||||
# 변경사항 여부 판단
|
||||
has_changes = len(modified_segments) > 0 or len(added_segments) > 0 or len(removed_segments) > 0
|
||||
|
||||
return {
|
||||
"drawing_name": drawing_name,
|
||||
"has_changes": has_changes,
|
||||
"segments": all_segments,
|
||||
"summary": {
|
||||
"total_segments": len(all_segments),
|
||||
"unchanged_count": len(unchanged_segments),
|
||||
"modified_count": len(modified_segments),
|
||||
"added_count": len(added_segments),
|
||||
"removed_count": len(removed_segments)
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to compare segments for drawing {drawing_name}: {e}")
|
||||
raise
|
||||
|
||||
def _match_segments(self, existing: List[Dict], new: List[Dict]) -> Tuple[List[Tuple], List[Dict], List[Dict]]:
|
||||
"""세그먼트 매칭 (재질, 길이 기준)"""
|
||||
matched_pairs = []
|
||||
remaining_new = new.copy()
|
||||
remaining_existing = existing.copy()
|
||||
|
||||
# 정확히 일치하는 세그먼트 찾기
|
||||
for existing_seg in existing.copy():
|
||||
for new_seg in remaining_new.copy():
|
||||
if self._segments_match_for_pairing(existing_seg, new_seg):
|
||||
matched_pairs.append((existing_seg, new_seg))
|
||||
remaining_existing.remove(existing_seg)
|
||||
remaining_new.remove(new_seg)
|
||||
break
|
||||
|
||||
# 남은 것들은 추가/삭제로 분류
|
||||
added_segments = remaining_new
|
||||
removed_segments = remaining_existing
|
||||
|
||||
return matched_pairs, added_segments, removed_segments
|
||||
|
||||
def _segments_match_for_pairing(self, seg1: Dict, seg2: Dict) -> bool:
|
||||
"""세그먼트 매칭 기준 (재질과 길이가 유사한지 확인)"""
|
||||
# 재질 비교
|
||||
material1 = seg1.get("material_grade", "").strip()
|
||||
material2 = seg2.get("material_grade", "").strip()
|
||||
|
||||
# 길이 비교 (허용 오차 1mm)
|
||||
length1 = seg1.get("length_mm", seg1.get("length", 0))
|
||||
length2 = seg2.get("length_mm", seg2.get("length", 0))
|
||||
|
||||
material_match = material1.lower() == material2.lower()
|
||||
length_match = abs(float(length1) - float(length2)) <= 1.0
|
||||
|
||||
return material_match and length_match
|
||||
|
||||
def _segments_are_identical(self, seg1: Dict, seg2: Dict) -> bool:
|
||||
"""세그먼트 완전 동일성 검사"""
|
||||
# 주요 속성들 비교
|
||||
material_match = seg1.get("material_grade", "").strip().lower() == seg2.get("material_grade", "").strip().lower()
|
||||
|
||||
length1 = seg1.get("length_mm", seg1.get("length", 0))
|
||||
length2 = seg2.get("length_mm", seg2.get("length", 0))
|
||||
length_match = abs(float(length1) - float(length2)) <= 0.1
|
||||
|
||||
end_prep1 = seg1.get("end_preparation", "무개선")
|
||||
end_prep2 = seg2.get("end_preparation", "무개선")
|
||||
end_prep_match = end_prep1 == end_prep2
|
||||
|
||||
return material_match and length_match and end_prep_match
|
||||
|
||||
def _get_segment_changes(self, existing: Dict, new: Dict) -> List[Dict[str, Any]]:
|
||||
"""세그먼트 변경사항 상세 분석"""
|
||||
changes = []
|
||||
|
||||
# 재질 변경
|
||||
old_material = existing.get("material_grade", "").strip()
|
||||
new_material = new.get("material_grade", "").strip()
|
||||
if old_material.lower() != new_material.lower():
|
||||
changes.append({
|
||||
"field": "material_grade",
|
||||
"old_value": old_material,
|
||||
"new_value": new_material
|
||||
})
|
||||
|
||||
# 길이 변경
|
||||
old_length = existing.get("length_mm", existing.get("length", 0))
|
||||
new_length = new.get("length_mm", new.get("length", 0))
|
||||
if abs(float(old_length) - float(new_length)) > 0.1:
|
||||
changes.append({
|
||||
"field": "length",
|
||||
"old_value": f"{old_length}mm",
|
||||
"new_value": f"{new_length}mm"
|
||||
})
|
||||
|
||||
# 끝단가공 변경
|
||||
old_end_prep = existing.get("end_preparation", "무개선")
|
||||
new_end_prep = new.get("end_preparation", "무개선")
|
||||
if old_end_prep != new_end_prep:
|
||||
changes.append({
|
||||
"field": "end_preparation",
|
||||
"old_value": old_end_prep,
|
||||
"new_value": new_end_prep
|
||||
})
|
||||
|
||||
return changes
|
||||
|
||||
def _save_comparison_result(self, job_no: str, new_file_id: int, comparison_result: List[Dict]) -> int:
|
||||
"""비교 결과를 데이터베이스에 저장"""
|
||||
try:
|
||||
# 이전 파일 ID 조회
|
||||
previous_file = self._get_previous_file(job_no, new_file_id)
|
||||
previous_file_id = previous_file.id if previous_file else None
|
||||
|
||||
# 통계 계산
|
||||
total_drawings = len(comparison_result)
|
||||
changed_drawings = len([d for d in comparison_result if d["has_changes"]])
|
||||
unchanged_drawings = total_drawings - changed_drawings
|
||||
|
||||
total_segments = sum(d["summary"]["total_segments"] for d in comparison_result)
|
||||
added_segments = sum(d["summary"]["added_count"] for d in comparison_result)
|
||||
removed_segments = sum(d["summary"]["removed_count"] for d in comparison_result)
|
||||
modified_segments = sum(d["summary"]["modified_count"] for d in comparison_result)
|
||||
unchanged_segments = sum(d["summary"]["unchanged_count"] for d in comparison_result)
|
||||
|
||||
# 비교 결과 저장
|
||||
comparison = PipeRevisionComparison(
|
||||
job_no=job_no,
|
||||
current_file_id=new_file_id,
|
||||
previous_cutting_plan_id=None, # 추후 구현
|
||||
total_drawings=total_drawings,
|
||||
changed_drawings=changed_drawings,
|
||||
unchanged_drawings=unchanged_drawings,
|
||||
total_segments=total_segments,
|
||||
added_segments=added_segments,
|
||||
removed_segments=removed_segments,
|
||||
modified_segments=modified_segments,
|
||||
unchanged_segments=unchanged_segments,
|
||||
created_by="system"
|
||||
)
|
||||
|
||||
self.db.add(comparison)
|
||||
self.db.flush() # ID 생성을 위해
|
||||
|
||||
# 상세 변경사항 저장
|
||||
for drawing_data in comparison_result:
|
||||
if drawing_data["has_changes"]:
|
||||
for segment in drawing_data["segments"]:
|
||||
if segment["change_type"] != "unchanged":
|
||||
change = PipeRevisionChange(
|
||||
comparison_id=comparison.id,
|
||||
drawing_name=drawing_data["drawing_name"],
|
||||
change_type=segment["change_type"]
|
||||
)
|
||||
|
||||
# 기존 데이터
|
||||
if segment["existing_data"]:
|
||||
existing = segment["existing_data"]
|
||||
change.old_line_no = existing.get("line_no")
|
||||
change.old_material_grade = existing.get("material_grade")
|
||||
change.old_schedule_spec = existing.get("schedule_spec")
|
||||
change.old_nominal_size = existing.get("nominal_size")
|
||||
change.old_length_mm = existing.get("length_mm", existing.get("length"))
|
||||
change.old_end_preparation = existing.get("end_preparation")
|
||||
|
||||
# 새 데이터
|
||||
if segment["segment_data"]:
|
||||
new_data = segment["segment_data"]
|
||||
change.new_line_no = new_data.get("line_no")
|
||||
change.new_material_grade = new_data.get("material_grade")
|
||||
change.new_schedule_spec = new_data.get("schedule_spec")
|
||||
change.new_nominal_size = new_data.get("nominal_size")
|
||||
change.new_length_mm = new_data.get("length_mm", new_data.get("length"))
|
||||
change.new_end_preparation = new_data.get("end_preparation")
|
||||
|
||||
self.db.add(change)
|
||||
|
||||
self.db.commit()
|
||||
logger.info(f"Saved comparison result with ID {comparison.id}")
|
||||
|
||||
return comparison.id
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to save comparison result: {e}")
|
||||
raise
|
||||
|
||||
def _generate_comparison_summary(self, comparison_result: List[Dict]) -> Dict[str, Any]:
|
||||
"""비교 결과 요약 생성"""
|
||||
total_drawings = len(comparison_result)
|
||||
changed_drawings = [d for d in comparison_result if d["has_changes"]]
|
||||
changed_drawings_count = len(changed_drawings)
|
||||
|
||||
total_segments = sum(d["summary"]["total_segments"] for d in comparison_result)
|
||||
added_segments = sum(d["summary"]["added_count"] for d in comparison_result)
|
||||
removed_segments = sum(d["summary"]["removed_count"] for d in comparison_result)
|
||||
modified_segments = sum(d["summary"]["modified_count"] for d in comparison_result)
|
||||
unchanged_segments = sum(d["summary"]["unchanged_count"] for d in comparison_result)
|
||||
|
||||
return {
|
||||
"total_drawings": total_drawings,
|
||||
"changed_drawings_count": changed_drawings_count,
|
||||
"unchanged_drawings_count": total_drawings - changed_drawings_count,
|
||||
"total_segments": total_segments,
|
||||
"added_segments": added_segments,
|
||||
"removed_segments": removed_segments,
|
||||
"modified_segments": modified_segments,
|
||||
"unchanged_segments": unchanged_segments,
|
||||
"change_percentage": round((changed_drawings_count / total_drawings * 100) if total_drawings > 0 else 0, 1)
|
||||
}
|
||||
|
||||
|
||||
def get_pipe_revision_service(db: Session = None) -> PipeRevisionService:
|
||||
"""PipeRevisionService 인스턴스 생성"""
|
||||
if db is None:
|
||||
db = next(get_db())
|
||||
return PipeRevisionService(db)
|
||||
220
backend/app/services/pipe_snapshot_excel_service.py
Normal file
220
backend/app/services/pipe_snapshot_excel_service.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
PIPE 스냅샷 기반 Excel 내보내기 서비스
|
||||
|
||||
확정된 Cutting Plan의 스냅샷 데이터를 기준으로 Excel 생성
|
||||
이후 리비전이 발생해도 Excel 내용은 변경되지 않음
|
||||
"""
|
||||
|
||||
import logging
|
||||
import pandas as pd
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import PipeIssueSnapshot, PipeIssueSegment
|
||||
from ..services.pipe_issue_snapshot_service import get_pipe_issue_snapshot_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PipeSnapshotExcelService:
|
||||
"""PIPE 스냅샷 기반 Excel 내보내기 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.snapshot_service = get_pipe_issue_snapshot_service(db)
|
||||
|
||||
def export_finalized_cutting_plan(self, job_no: str) -> Dict[str, Any]:
|
||||
"""
|
||||
확정된 Cutting Plan Excel 내보내기
|
||||
스냅샷 데이터 기준으로 고정된 Excel 생성
|
||||
"""
|
||||
try:
|
||||
# 1. 활성 스냅샷 확인
|
||||
snapshot_info = self.snapshot_service.get_snapshot_info(job_no)
|
||||
if not snapshot_info["has_snapshot"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "확정된 Cutting Plan이 없습니다. 먼저 Cutting Plan을 확정해주세요."
|
||||
}
|
||||
|
||||
if not snapshot_info["is_locked"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Cutting Plan이 아직 확정되지 않았습니다."
|
||||
}
|
||||
|
||||
snapshot_id = snapshot_info["snapshot_id"]
|
||||
|
||||
# 2. 스냅샷 데이터 조회
|
||||
segments_data = self.snapshot_service.get_snapshot_segments(snapshot_id)
|
||||
if not segments_data:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "내보낼 단관 데이터가 없습니다."
|
||||
}
|
||||
|
||||
# 3. Excel 파일 생성
|
||||
excel_buffer = self._create_cutting_plan_excel(
|
||||
segments_data,
|
||||
snapshot_info["snapshot_name"],
|
||||
job_no
|
||||
)
|
||||
|
||||
# 4. 파일명 생성
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"PIPE_Cutting_Plan_{job_no}_{timestamp}_FINALIZED.xlsx"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"excel_buffer": excel_buffer,
|
||||
"filename": filename,
|
||||
"snapshot_id": snapshot_id,
|
||||
"snapshot_name": snapshot_info["snapshot_name"],
|
||||
"total_segments": len(segments_data),
|
||||
"message": f"확정된 Cutting Plan Excel이 생성되었습니다. (스냅샷 기준)"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export finalized cutting plan: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Excel 내보내기 실패: {str(e)}"
|
||||
}
|
||||
|
||||
def _create_cutting_plan_excel(self, segments_data: List[Dict], snapshot_name: str, job_no: str) -> BytesIO:
|
||||
"""스냅샷 데이터로 Excel 파일 생성"""
|
||||
|
||||
# DataFrame 생성
|
||||
df_data = []
|
||||
for segment in segments_data:
|
||||
df_data.append({
|
||||
'구역': segment.get('area', ''),
|
||||
'도면명': segment.get('drawing_name', ''),
|
||||
'라인번호': segment.get('line_no', ''),
|
||||
'재질': segment.get('material_grade', ''),
|
||||
'규격': segment.get('schedule_spec', ''),
|
||||
'호칭': segment.get('nominal_size', ''),
|
||||
'길이(mm)': segment.get('length_mm', 0),
|
||||
'끝단가공': segment.get('end_preparation', '무개선'),
|
||||
'파이프정보': segment.get('material_info', '')
|
||||
})
|
||||
|
||||
df = pd.DataFrame(df_data)
|
||||
|
||||
# Excel 버퍼 생성
|
||||
excel_buffer = BytesIO()
|
||||
|
||||
with pd.ExcelWriter(excel_buffer, engine='openpyxl') as writer:
|
||||
# 메인 시트 - 단관 목록
|
||||
df.to_excel(writer, sheet_name='단관 목록', index=False)
|
||||
|
||||
# 요약 시트
|
||||
summary_data = self._create_summary_data(segments_data, snapshot_name, job_no)
|
||||
summary_df = pd.DataFrame(summary_data)
|
||||
summary_df.to_excel(writer, sheet_name='요약', index=False)
|
||||
|
||||
# 구역별 시트
|
||||
areas = sorted(set(segment.get('area', '') for segment in segments_data if segment.get('area')))
|
||||
for area in areas:
|
||||
if area: # 빈 구역 제외
|
||||
area_segments = [s for s in segments_data if s.get('area') == area]
|
||||
area_df_data = []
|
||||
for segment in area_segments:
|
||||
area_df_data.append({
|
||||
'도면명': segment.get('drawing_name', ''),
|
||||
'라인번호': segment.get('line_no', ''),
|
||||
'재질': segment.get('material_grade', ''),
|
||||
'규격': segment.get('schedule_spec', ''),
|
||||
'호칭': segment.get('nominal_size', ''),
|
||||
'길이(mm)': segment.get('length_mm', 0),
|
||||
'끝단가공': segment.get('end_preparation', '무개선')
|
||||
})
|
||||
|
||||
area_df = pd.DataFrame(area_df_data)
|
||||
sheet_name = f'{area} 구역'
|
||||
area_df.to_excel(writer, sheet_name=sheet_name, index=False)
|
||||
|
||||
excel_buffer.seek(0)
|
||||
return excel_buffer
|
||||
|
||||
def _create_summary_data(self, segments_data: List[Dict], snapshot_name: str, job_no: str) -> List[Dict]:
|
||||
"""요약 정보 생성"""
|
||||
|
||||
# 기본 통계
|
||||
total_segments = len(segments_data)
|
||||
total_drawings = len(set(segment.get('drawing_name', '') for segment in segments_data))
|
||||
areas = sorted(set(segment.get('area', '') for segment in segments_data if segment.get('area')))
|
||||
|
||||
# 재질별 통계
|
||||
material_stats = {}
|
||||
for segment in segments_data:
|
||||
material = segment.get('material_grade', 'UNKNOWN')
|
||||
if material not in material_stats:
|
||||
material_stats[material] = {
|
||||
'count': 0,
|
||||
'total_length': 0
|
||||
}
|
||||
material_stats[material]['count'] += 1
|
||||
material_stats[material]['total_length'] += segment.get('length_mm', 0)
|
||||
|
||||
# 요약 데이터 구성
|
||||
summary_data = [
|
||||
{'항목': '작업번호', '값': job_no},
|
||||
{'항목': '스냅샷명', '값': snapshot_name},
|
||||
{'항목': '확정일시', '값': datetime.now().strftime('%Y-%m-%d %H:%M:%S')},
|
||||
{'항목': '총 단관 수', '값': total_segments},
|
||||
{'항목': '총 도면 수', '값': total_drawings},
|
||||
{'항목': '구역 수', '값': len(areas)},
|
||||
{'항목': '구역 목록', '값': ', '.join(areas)},
|
||||
{'항목': '', '값': ''}, # 빈 줄
|
||||
{'항목': '=== 재질별 통계 ===', '값': ''},
|
||||
]
|
||||
|
||||
for material, stats in material_stats.items():
|
||||
summary_data.extend([
|
||||
{'항목': f'{material} - 개수', '값': stats['count']},
|
||||
{'항목': f'{material} - 총길이(mm)', '값': f"{stats['total_length']:,.1f}"},
|
||||
{'항목': f'{material} - 총길이(m)', '값': f"{stats['total_length']/1000:,.3f}"}
|
||||
])
|
||||
|
||||
# 주의사항 추가
|
||||
summary_data.extend([
|
||||
{'항목': '', '값': ''}, # 빈 줄
|
||||
{'항목': '=== 주의사항 ===', '값': ''},
|
||||
{'항목': '⚠️ 확정된 데이터', '값': '이 Excel은 Cutting Plan 확정 시점의 데이터입니다.'},
|
||||
{'항목': '⚠️ 리비전 보호', '값': '이후 BOM 리비전이 발생해도 이 데이터는 변경되지 않습니다.'},
|
||||
{'항목': '⚠️ 수정 방법', '값': '변경이 필요한 경우 수동으로 편집하거나 새로운 Cutting Plan을 작성하세요.'},
|
||||
{'항목': '⚠️ 이슈 관리', '값': '현장 이슈는 별도 이슈 관리 시스템을 사용하세요.'}
|
||||
])
|
||||
|
||||
return summary_data
|
||||
|
||||
def check_finalization_status(self, job_no: str) -> Dict[str, Any]:
|
||||
"""Cutting Plan 확정 상태 확인"""
|
||||
try:
|
||||
snapshot_info = self.snapshot_service.get_snapshot_info(job_no)
|
||||
|
||||
return {
|
||||
"is_finalized": snapshot_info["has_snapshot"] and snapshot_info["is_locked"],
|
||||
"can_export_finalized": snapshot_info["has_snapshot"] and snapshot_info["is_locked"],
|
||||
"snapshot_info": snapshot_info if snapshot_info["has_snapshot"] else None,
|
||||
"message": "확정된 Cutting Plan Excel 내보내기 가능" if snapshot_info["has_snapshot"] and snapshot_info["is_locked"] else "Cutting Plan을 먼저 확정해주세요"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check finalization status: {e}")
|
||||
return {
|
||||
"is_finalized": False,
|
||||
"can_export_finalized": False,
|
||||
"message": f"확정 상태 확인 실패: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def get_pipe_snapshot_excel_service(db: Session = None) -> PipeSnapshotExcelService:
|
||||
"""PipeSnapshotExcelService 인스턴스 생성"""
|
||||
if db is None:
|
||||
db = next(get_db())
|
||||
return PipeSnapshotExcelService(db)
|
||||
224
backend/app/services/revision_comparison_service.py
Normal file
224
backend/app/services/revision_comparison_service.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
리비전 비교 전용 서비스
|
||||
두 리비전 간의 자재 비교 및 차이점 분석
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
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 RevisionComparisonService:
|
||||
"""리비전 비교 전용 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.db_service = DatabaseService(db)
|
||||
|
||||
def compare_revisions(
|
||||
self,
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
category_filter: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
두 리비전 간 자재 비교
|
||||
|
||||
Args:
|
||||
current_file_id: 현재 리비전 파일 ID
|
||||
previous_file_id: 이전 리비전 파일 ID
|
||||
category_filter: 특정 카테고리만 비교 (선택사항)
|
||||
|
||||
Returns:
|
||||
비교 결과 딕셔너리
|
||||
"""
|
||||
|
||||
# 이전/현재 자재 조회
|
||||
previous_materials = self._get_materials_for_comparison(previous_file_id, category_filter)
|
||||
current_materials = self._get_materials_for_comparison(current_file_id, category_filter)
|
||||
|
||||
# 비교 수행
|
||||
comparison_result = {
|
||||
"comparison_date": datetime.now().isoformat(),
|
||||
"current_file_id": current_file_id,
|
||||
"previous_file_id": previous_file_id,
|
||||
"category_filter": category_filter,
|
||||
"summary": {
|
||||
"previous_count": len(previous_materials),
|
||||
"current_count": len(current_materials),
|
||||
"unchanged": 0,
|
||||
"modified": 0,
|
||||
"added": 0,
|
||||
"removed": 0
|
||||
},
|
||||
"changes": {
|
||||
"unchanged": [],
|
||||
"modified": [],
|
||||
"added": [],
|
||||
"removed": []
|
||||
}
|
||||
}
|
||||
|
||||
# 이전 자재 기준으로 비교
|
||||
for key, prev_material in previous_materials.items():
|
||||
if key in current_materials:
|
||||
curr_material = current_materials[key]
|
||||
|
||||
# 자재 변경 여부 확인
|
||||
if self._is_material_changed(prev_material, curr_material):
|
||||
comparison_result["changes"]["modified"].append({
|
||||
"key": key,
|
||||
"previous": prev_material,
|
||||
"current": curr_material,
|
||||
"changes": self._get_material_changes(prev_material, curr_material)
|
||||
})
|
||||
comparison_result["summary"]["modified"] += 1
|
||||
else:
|
||||
comparison_result["changes"]["unchanged"].append({
|
||||
"key": key,
|
||||
"material": curr_material
|
||||
})
|
||||
comparison_result["summary"]["unchanged"] += 1
|
||||
else:
|
||||
# 제거된 자재
|
||||
comparison_result["changes"]["removed"].append({
|
||||
"key": key,
|
||||
"material": prev_material
|
||||
})
|
||||
comparison_result["summary"]["removed"] += 1
|
||||
|
||||
# 신규 자재
|
||||
for key, curr_material in current_materials.items():
|
||||
if key not in previous_materials:
|
||||
comparison_result["changes"]["added"].append({
|
||||
"key": key,
|
||||
"material": curr_material
|
||||
})
|
||||
comparison_result["summary"]["added"] += 1
|
||||
|
||||
return comparison_result
|
||||
|
||||
def get_category_comparison(
|
||||
self,
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""특정 카테고리의 리비전 비교"""
|
||||
|
||||
return self.compare_revisions(current_file_id, previous_file_id, category)
|
||||
|
||||
# PIPE 관련 메서드는 별도 처리 예정
|
||||
|
||||
def _get_materials_for_comparison(
|
||||
self,
|
||||
file_id: int,
|
||||
category_filter: Optional[str] = None
|
||||
) -> Dict[str, Dict]:
|
||||
"""비교용 자재 데이터 조회"""
|
||||
|
||||
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,
|
||||
-- 비교 키 생성 (PIPE 제외)
|
||||
COALESCE(m.material_hash,
|
||||
CONCAT(m.original_description, '|',
|
||||
COALESCE(m.material_grade, ''), '|',
|
||||
COALESCE(m.size_spec, ''))) as comparison_key
|
||||
FROM materials m
|
||||
WHERE m.file_id = :file_id AND m.is_active = true
|
||||
"""
|
||||
|
||||
params = {"file_id": file_id}
|
||||
|
||||
if category_filter:
|
||||
query += " AND m.classified_category = :category"
|
||||
params["category"] = category_filter
|
||||
|
||||
# PIPE 카테고리는 제외
|
||||
query += " AND m.classified_category != 'PIPE'"
|
||||
|
||||
query += " ORDER BY m.line_number"
|
||||
|
||||
result = self.db_service.execute_query(query, params)
|
||||
materials = {}
|
||||
|
||||
for row in result.fetchall():
|
||||
row_dict = dict(row._mapping)
|
||||
comparison_key = row_dict['comparison_key']
|
||||
|
||||
# PIPE 제외한 일반 자재 처리
|
||||
materials[comparison_key] = row_dict
|
||||
|
||||
return materials
|
||||
|
||||
def _is_material_changed(self, prev_material: Dict, curr_material: Dict) -> bool:
|
||||
"""자재 변경 여부 확인"""
|
||||
|
||||
# 주요 필드 비교
|
||||
compare_fields = ['quantity', 'material_grade', 'schedule', 'size_spec',
|
||||
'main_nom', 'red_nom', 'unit', 'length']
|
||||
|
||||
for field in compare_fields:
|
||||
prev_val = prev_material.get(field)
|
||||
curr_val = curr_material.get(field)
|
||||
|
||||
# 수치 필드는 부동소수점 오차 고려
|
||||
if field in ['quantity', 'length']:
|
||||
if prev_val is not None and curr_val is not None:
|
||||
if abs(float(prev_val) - float(curr_val)) > 0.001:
|
||||
return True
|
||||
elif prev_val != curr_val:
|
||||
return True
|
||||
else:
|
||||
if prev_val != curr_val:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_material_changes(self, prev_material: Dict, curr_material: Dict) -> Dict[str, Any]:
|
||||
"""자재 변경 내용 상세 분석"""
|
||||
|
||||
changes = {}
|
||||
compare_fields = ['quantity', 'material_grade', 'schedule', 'size_spec',
|
||||
'main_nom', 'red_nom', 'unit', 'length']
|
||||
|
||||
for field in compare_fields:
|
||||
prev_val = prev_material.get(field)
|
||||
curr_val = curr_material.get(field)
|
||||
|
||||
if field in ['quantity', 'length']:
|
||||
if prev_val is not None and curr_val is not None:
|
||||
if abs(float(prev_val) - float(curr_val)) > 0.001:
|
||||
changes[field] = {
|
||||
"previous": float(prev_val),
|
||||
"current": float(curr_val),
|
||||
"change": float(curr_val) - float(prev_val)
|
||||
}
|
||||
elif prev_val != curr_val:
|
||||
changes[field] = {
|
||||
"previous": prev_val,
|
||||
"current": curr_val
|
||||
}
|
||||
else:
|
||||
if prev_val != curr_val:
|
||||
changes[field] = {
|
||||
"previous": prev_val,
|
||||
"current": curr_val
|
||||
}
|
||||
|
||||
return changes
|
||||
478
backend/app/services/revision_logic_service.py
Normal file
478
backend/app/services/revision_logic_service.py
Normal file
@@ -0,0 +1,478 @@
|
||||
"""
|
||||
리비전 처리 로직 서비스
|
||||
구매 상태와 카테고리별 특성을 고려한 스마트 리비전 관리
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, and_, or_
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
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 RevisionLogicService:
|
||||
"""리비전 처리 로직 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.db_service = DatabaseService(db)
|
||||
|
||||
def process_revision_by_purchase_status(
|
||||
self,
|
||||
job_no: str,
|
||||
current_file_id: int,
|
||||
previous_file_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
구매 상태별 리비전 처리
|
||||
|
||||
Returns:
|
||||
{
|
||||
"needs_revision_page": bool, # 리비전 페이지 필요 여부
|
||||
"can_use_bom_page": bool, # 기존 BOM 페이지 사용 가능 여부
|
||||
"processing_results": dict, # 처리 결과
|
||||
"revision_materials": list, # 리비전 페이지에서 관리할 자재
|
||||
"inventory_materials": list, # 재고로 분류할 자재
|
||||
"deleted_materials": list # 삭제할 자재
|
||||
}
|
||||
"""
|
||||
|
||||
if not previous_file_id:
|
||||
previous_file_id = self._get_previous_file_id(job_no, current_file_id)
|
||||
|
||||
if not previous_file_id:
|
||||
return self._handle_first_revision(current_file_id)
|
||||
|
||||
# 이전/현재 자재 조회
|
||||
previous_materials = self._get_materials_with_details(previous_file_id)
|
||||
current_materials = self._get_materials_with_details(current_file_id)
|
||||
|
||||
# 카테고리별 처리
|
||||
processing_results = {}
|
||||
revision_materials = []
|
||||
inventory_materials = []
|
||||
deleted_materials = []
|
||||
|
||||
# 각 카테고리별로 처리
|
||||
categories = ['PIPE', 'FITTING', 'FLANGE', 'VALVE', 'GASKET', 'BOLT', 'SUPPORT', 'SPECIAL']
|
||||
|
||||
for category in categories:
|
||||
category_result = self._process_category_revision(
|
||||
category, previous_materials, current_materials
|
||||
)
|
||||
|
||||
processing_results[category] = category_result
|
||||
|
||||
# 자재 분류
|
||||
revision_materials.extend(category_result['revision_materials'])
|
||||
inventory_materials.extend(category_result['inventory_materials'])
|
||||
deleted_materials.extend(category_result['deleted_materials'])
|
||||
|
||||
return {
|
||||
"needs_revision_page": True, # 리비전이면 항상 리비전 페이지 필요
|
||||
"can_use_bom_page": False, # 리비전이면 기존 BOM 페이지 사용 불가
|
||||
"processing_results": processing_results,
|
||||
"revision_materials": revision_materials,
|
||||
"inventory_materials": inventory_materials,
|
||||
"deleted_materials": deleted_materials,
|
||||
"summary": self._generate_revision_summary(processing_results)
|
||||
}
|
||||
|
||||
def _process_category_revision(
|
||||
self,
|
||||
category: str,
|
||||
previous_materials: Dict[str, Dict],
|
||||
current_materials: Dict[str, Dict]
|
||||
) -> Dict[str, Any]:
|
||||
"""카테고리별 리비전 처리"""
|
||||
|
||||
# 카테고리별 자재 필터링
|
||||
prev_category_materials = {
|
||||
k: v for k, v in previous_materials.items()
|
||||
if v.get('classified_category') == category
|
||||
}
|
||||
|
||||
curr_category_materials = {
|
||||
k: v for k, v in current_materials.items()
|
||||
if v.get('classified_category') == category
|
||||
}
|
||||
|
||||
result = {
|
||||
"category": category,
|
||||
"revision_materials": [],
|
||||
"inventory_materials": [],
|
||||
"deleted_materials": [],
|
||||
"unchanged_materials": [],
|
||||
"processing_summary": {
|
||||
"purchased_unchanged": 0,
|
||||
"purchased_excess": 0,
|
||||
"purchased_insufficient": 0,
|
||||
"unpurchased_deleted": 0,
|
||||
"unpurchased_unchanged": 0,
|
||||
"unpurchased_updated": 0,
|
||||
"new_materials": 0
|
||||
}
|
||||
}
|
||||
|
||||
# 이전 자재 기준으로 비교
|
||||
for key, prev_material in prev_category_materials.items():
|
||||
if key in curr_category_materials:
|
||||
curr_material = curr_category_materials[key]
|
||||
|
||||
# GASKET, BOLT는 규칙 적용 전 수량으로 비교
|
||||
if category in ['GASKET', 'BOLT']:
|
||||
comparison = self._compare_materials_pre_calculation(prev_material, curr_material, category)
|
||||
else:
|
||||
comparison = self._compare_materials_standard(prev_material, curr_material, category)
|
||||
|
||||
# 구매 완료 자재 처리
|
||||
if prev_material.get('purchase_confirmed', False):
|
||||
processed = self._process_purchased_material(prev_material, curr_material, comparison, category)
|
||||
else:
|
||||
# 구매 미완료 자재 처리
|
||||
processed = self._process_unpurchased_material(prev_material, curr_material, comparison, category)
|
||||
|
||||
# 결과 분류
|
||||
if processed['action'] == 'revision_management':
|
||||
result['revision_materials'].append(processed)
|
||||
elif processed['action'] == 'inventory':
|
||||
result['inventory_materials'].append(processed)
|
||||
elif processed['action'] == 'unchanged':
|
||||
result['unchanged_materials'].append(processed)
|
||||
|
||||
# 통계 업데이트
|
||||
result['processing_summary'][processed['summary_key']] += 1
|
||||
|
||||
else:
|
||||
# 삭제된 자재 (현재 리비전에 없음)
|
||||
if prev_material.get('purchase_confirmed', False):
|
||||
# 구매 완료된 자재가 삭제됨 → 재고로 분류
|
||||
result['inventory_materials'].append({
|
||||
'material': prev_material,
|
||||
'action': 'inventory',
|
||||
'reason': 'purchased_but_removed_in_revision',
|
||||
'category': category
|
||||
})
|
||||
else:
|
||||
# 구매 미완료 자재가 삭제됨 → 완전 삭제
|
||||
result['deleted_materials'].append({
|
||||
'material': prev_material,
|
||||
'action': 'delete',
|
||||
'reason': 'no_longer_needed',
|
||||
'category': category
|
||||
})
|
||||
result['processing_summary']['unpurchased_deleted'] += 1
|
||||
|
||||
# 신규 자재 처리
|
||||
for key, curr_material in curr_category_materials.items():
|
||||
if key not in prev_category_materials:
|
||||
result['revision_materials'].append({
|
||||
'material': curr_material,
|
||||
'action': 'revision_management',
|
||||
'reason': 'new_material',
|
||||
'category': category,
|
||||
'summary_key': 'new_materials'
|
||||
})
|
||||
result['processing_summary']['new_materials'] += 1
|
||||
|
||||
return result
|
||||
|
||||
def _process_purchased_material(
|
||||
self,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict,
|
||||
comparison: Dict,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""구매 완료 자재 처리"""
|
||||
|
||||
if comparison['change_type'] == 'no_change':
|
||||
# 변동 없음 → 구매 완료 상태 유지, 더 이상 관리 불필요
|
||||
return {
|
||||
'material': curr_material,
|
||||
'action': 'unchanged',
|
||||
'reason': 'purchased_no_change',
|
||||
'category': category,
|
||||
'summary_key': 'purchased_unchanged'
|
||||
}
|
||||
|
||||
elif comparison['change_type'] == 'decreased':
|
||||
# 수량 감소/불필요 → 재고 자재로 분류
|
||||
excess_quantity = abs(comparison['quantity_change'])
|
||||
return {
|
||||
'material': prev_material, # 이전 자재 정보 사용
|
||||
'action': 'inventory',
|
||||
'reason': 'purchased_excess',
|
||||
'category': category,
|
||||
'excess_quantity': excess_quantity,
|
||||
'current_needed': curr_material.get('quantity', 0),
|
||||
'summary_key': 'purchased_excess'
|
||||
}
|
||||
|
||||
else: # increased
|
||||
# 수량 부족 → 리비전 페이지에서 추가 구매 관리
|
||||
additional_needed = comparison['quantity_change']
|
||||
return {
|
||||
'material': curr_material,
|
||||
'action': 'revision_management',
|
||||
'reason': 'purchased_insufficient',
|
||||
'category': category,
|
||||
'additional_needed': additional_needed,
|
||||
'already_purchased': prev_material.get('quantity', 0),
|
||||
'summary_key': 'purchased_insufficient'
|
||||
}
|
||||
|
||||
def _process_unpurchased_material(
|
||||
self,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict,
|
||||
comparison: Dict,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""구매 미완료 자재 처리"""
|
||||
|
||||
if comparison['change_type'] == 'no_change':
|
||||
# 수량 동일 → 리비전 페이지에서 구매 관리 계속
|
||||
return {
|
||||
'material': curr_material,
|
||||
'action': 'revision_management',
|
||||
'reason': 'unpurchased_unchanged',
|
||||
'category': category,
|
||||
'summary_key': 'unpurchased_unchanged'
|
||||
}
|
||||
|
||||
else:
|
||||
# 수량 변경 → 필요 수량만큼 리비전 페이지에서 관리
|
||||
return {
|
||||
'material': curr_material,
|
||||
'action': 'revision_management',
|
||||
'reason': 'unpurchased_quantity_changed',
|
||||
'category': category,
|
||||
'quantity_change': comparison['quantity_change'],
|
||||
'previous_quantity': prev_material.get('quantity', 0),
|
||||
'summary_key': 'unpurchased_updated'
|
||||
}
|
||||
|
||||
def _compare_materials_standard(
|
||||
self,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""표준 자재 비교 (PIPE 제외)"""
|
||||
|
||||
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 {
|
||||
'quantity_change': quantity_change,
|
||||
'change_type': change_type,
|
||||
'previous_quantity': prev_qty,
|
||||
'current_quantity': curr_qty
|
||||
}
|
||||
|
||||
def _compare_materials_pre_calculation(
|
||||
self,
|
||||
prev_material: Dict,
|
||||
curr_material: Dict,
|
||||
category: str
|
||||
) -> Dict[str, Any]:
|
||||
"""규칙 적용 전 수량으로 비교 (GASKET, BOLT)"""
|
||||
|
||||
# 원본 수량 (규칙 적용 전)으로 비교
|
||||
prev_original_qty = float(prev_material.get('original_quantity', prev_material.get('quantity', 0)))
|
||||
curr_original_qty = float(curr_material.get('original_quantity', curr_material.get('quantity', 0)))
|
||||
|
||||
quantity_change = curr_original_qty - prev_original_qty
|
||||
|
||||
if abs(quantity_change) < 0.001:
|
||||
change_type = 'no_change'
|
||||
elif quantity_change > 0:
|
||||
change_type = 'increased'
|
||||
else:
|
||||
change_type = 'decreased'
|
||||
|
||||
# 최종 계산된 수량도 포함
|
||||
final_prev_qty = float(prev_material.get('quantity', 0))
|
||||
final_curr_qty = float(curr_material.get('quantity', 0))
|
||||
|
||||
return {
|
||||
'quantity_change': quantity_change,
|
||||
'change_type': change_type,
|
||||
'previous_quantity': prev_original_qty,
|
||||
'current_quantity': curr_original_qty,
|
||||
'final_previous_quantity': final_prev_qty,
|
||||
'final_current_quantity': final_curr_qty,
|
||||
'calculation_rule_applied': True
|
||||
}
|
||||
|
||||
def _get_materials_with_details(self, file_id: int) -> Dict[str, Dict]:
|
||||
"""파일의 자재를 상세 정보와 함께 조회"""
|
||||
|
||||
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,
|
||||
-- PIPE 자재 특별 키 생성
|
||||
CASE
|
||||
WHEN m.classified_category = 'PIPE' THEN
|
||||
CONCAT(m.drawing_name, '|', m.line_no, '|', COALESCE(m.length, 0))
|
||||
ELSE
|
||||
m.material_hash
|
||||
END as comparison_key
|
||||
FROM materials m
|
||||
WHERE m.file_id = :file_id AND m.is_active = true
|
||||
ORDER BY m.line_number
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {"file_id": file_id})
|
||||
materials = {}
|
||||
|
||||
for row in result.fetchall():
|
||||
row_dict = dict(row._mapping)
|
||||
comparison_key = row_dict['comparison_key']
|
||||
|
||||
# PIPE 자재의 경우 도면-라인넘버별로 길이 합산
|
||||
if row_dict['classified_category'] == 'PIPE':
|
||||
if comparison_key in materials:
|
||||
# 기존 자재에 길이 합산
|
||||
materials[comparison_key]['quantity'] += row_dict['quantity']
|
||||
materials[comparison_key]['total_length'] = (
|
||||
materials[comparison_key].get('total_length', 0) +
|
||||
(row_dict['length'] or 0) * row_dict['quantity']
|
||||
)
|
||||
else:
|
||||
row_dict['total_length'] = (row_dict['length'] or 0) * row_dict['quantity']
|
||||
materials[comparison_key] = row_dict
|
||||
else:
|
||||
materials[comparison_key] = row_dict
|
||||
|
||||
return materials
|
||||
|
||||
def _get_previous_file_id(self, job_no: str, current_file_id: int) -> Optional[int]:
|
||||
"""이전 파일 ID 자동 탐지"""
|
||||
|
||||
query = """
|
||||
SELECT id, revision
|
||||
FROM files
|
||||
WHERE job_no = :job_no AND id != :current_file_id AND is_active = true
|
||||
ORDER BY upload_date DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {
|
||||
"job_no": job_no,
|
||||
"current_file_id": current_file_id
|
||||
})
|
||||
|
||||
row = result.fetchone()
|
||||
return row.id if row else None
|
||||
|
||||
def _handle_first_revision(self, current_file_id: int) -> Dict[str, Any]:
|
||||
"""첫 번째 리비전 처리"""
|
||||
|
||||
materials = self._get_materials_with_details(current_file_id)
|
||||
|
||||
return {
|
||||
"needs_revision_page": True, # 첫 리비전은 항상 리비전 페이지 필요
|
||||
"can_use_bom_page": False,
|
||||
"processing_results": {},
|
||||
"revision_materials": [{"material": mat, "action": "revision_management", "reason": "first_revision"} for mat in materials.values()],
|
||||
"inventory_materials": [],
|
||||
"deleted_materials": [],
|
||||
"summary": {
|
||||
"is_first_revision": True,
|
||||
"total_materials": len(materials)
|
||||
}
|
||||
}
|
||||
|
||||
def _generate_revision_summary(self, processing_results: Dict) -> Dict[str, Any]:
|
||||
"""리비전 처리 요약 생성"""
|
||||
|
||||
summary = {
|
||||
"total_categories": len(processing_results),
|
||||
"total_revision_materials": 0,
|
||||
"total_inventory_materials": 0,
|
||||
"total_deleted_materials": 0,
|
||||
"by_category": {}
|
||||
}
|
||||
|
||||
for category, result in processing_results.items():
|
||||
summary["total_revision_materials"] += len(result['revision_materials'])
|
||||
summary["total_inventory_materials"] += len(result['inventory_materials'])
|
||||
summary["total_deleted_materials"] += len(result['deleted_materials'])
|
||||
|
||||
summary["by_category"][category] = {
|
||||
"revision_count": len(result['revision_materials']),
|
||||
"inventory_count": len(result['inventory_materials']),
|
||||
"deleted_count": len(result['deleted_materials']),
|
||||
"processing_summary": result['processing_summary']
|
||||
}
|
||||
|
||||
return summary
|
||||
|
||||
def should_redirect_to_revision_page(
|
||||
self,
|
||||
job_no: str,
|
||||
current_file_id: int,
|
||||
previous_file_id: Optional[int] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
리비전 페이지로 리다이렉트해야 하는지 판단
|
||||
실제 변경사항이 있을 때만 리비전 페이지로 이동
|
||||
|
||||
Returns:
|
||||
(should_redirect: bool, reason: str)
|
||||
"""
|
||||
|
||||
try:
|
||||
# 이전 파일이 있는지 확인 (리비전 여부 판단)
|
||||
if not previous_file_id:
|
||||
previous_file_id = self._get_previous_file_id(job_no, current_file_id)
|
||||
|
||||
if not previous_file_id:
|
||||
# 첫 번째 파일 (리비전 아님) → 기존 BOM 페이지 사용
|
||||
return False, "첫 번째 BOM 파일이므로 기존 페이지에서 관리합니다."
|
||||
|
||||
# 실제 변경사항이 있는지 확인
|
||||
processing_results = self.process_revision_by_purchase_status(
|
||||
job_no, current_file_id, previous_file_id
|
||||
)
|
||||
|
||||
# 변경사항 통계 확인
|
||||
summary = processing_results.get('summary', {})
|
||||
total_changes = (
|
||||
summary.get('revision_materials', 0) +
|
||||
summary.get('inventory_materials', 0) +
|
||||
summary.get('deleted_materials', 0)
|
||||
)
|
||||
|
||||
if total_changes > 0:
|
||||
# 실제 변경사항이 있으면 리비전 페이지로
|
||||
return True, f"리비전 변경사항이 감지되었습니다 (변경: {total_changes}개). 리비전 페이지에서 관리해야 합니다."
|
||||
else:
|
||||
# 변경사항이 없으면 기존 BOM 페이지 사용
|
||||
return False, "리비전 파일이지만 변경사항이 없어 기존 페이지에서 관리합니다."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to determine revision redirect: {e}")
|
||||
return False, "리비전 상태 확인 실패 - 기존 페이지 사용"
|
||||
425
backend/app/services/revision_material_service.py
Normal file
425
backend/app/services/revision_material_service.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
리비전 자재 처리 전용 서비스
|
||||
구매 상태별 자재 처리 로직
|
||||
"""
|
||||
|
||||
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": "자재 처리 적용 중 오류 발생"
|
||||
}
|
||||
421
backend/app/services/revision_status_service.py
Normal file
421
backend/app/services/revision_status_service.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
리비전 상태 관리 서비스
|
||||
리비전 진행 상태, 히스토리, 확정 등 관리
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, desc
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..models import File, RevisionComparison, RevisionChangeLog
|
||||
from ..utils.logger import get_logger
|
||||
from .database_service import DatabaseService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RevisionStatusService:
|
||||
"""리비전 상태 관리 서비스"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.db_service = DatabaseService(db)
|
||||
|
||||
def get_revision_status(self, job_no: str, file_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
리비전 상태 조회
|
||||
|
||||
Args:
|
||||
job_no: 작업 번호
|
||||
file_id: 파일 ID
|
||||
|
||||
Returns:
|
||||
리비전 상태 정보
|
||||
"""
|
||||
|
||||
# 파일 정보 조회
|
||||
current_file = self._get_file_info(file_id)
|
||||
if not current_file:
|
||||
return {"error": "파일을 찾을 수 없습니다."}
|
||||
|
||||
# 같은 작업의 모든 파일 조회
|
||||
all_files = self._get_job_files(job_no)
|
||||
|
||||
# 리비전 히스토리 구성
|
||||
revision_history = self._build_revision_history(all_files, file_id)
|
||||
|
||||
# 현재 리비전의 처리 상태
|
||||
processing_status = self._get_processing_status(file_id)
|
||||
|
||||
return {
|
||||
"job_no": job_no,
|
||||
"current_file": current_file,
|
||||
"revision_history": revision_history,
|
||||
"processing_status": processing_status,
|
||||
"is_latest": revision_history.get("is_latest", False),
|
||||
"can_upload_new_revision": revision_history.get("can_upload_new", True),
|
||||
"status_summary": self._generate_status_summary(processing_status)
|
||||
}
|
||||
|
||||
def get_revision_history(self, job_no: str) -> List[Dict[str, Any]]:
|
||||
"""작업의 전체 리비전 히스토리 조회"""
|
||||
|
||||
files = self._get_job_files(job_no)
|
||||
|
||||
history = []
|
||||
for i, file_info in enumerate(files):
|
||||
# 이전 파일과의 비교 정보
|
||||
comparison_info = None
|
||||
if i > 0:
|
||||
prev_file = files[i-1]
|
||||
comparison_info = self._get_comparison_summary(file_info['id'], prev_file['id'])
|
||||
|
||||
history.append({
|
||||
"file_id": file_info['id'],
|
||||
"revision": file_info['revision'],
|
||||
"filename": file_info['original_filename'],
|
||||
"upload_date": file_info['upload_date'],
|
||||
"uploaded_by": file_info['uploaded_by'],
|
||||
"file_size": file_info['file_size'],
|
||||
"material_count": self._get_material_count(file_info['id']),
|
||||
"comparison_with_previous": comparison_info,
|
||||
"is_latest": i == 0, # 최신순 정렬이므로 첫 번째가 최신
|
||||
"processing_status": self._get_processing_status(file_info['id'])
|
||||
})
|
||||
|
||||
return history
|
||||
|
||||
def create_revision_comparison_record(
|
||||
self,
|
||||
job_no: str,
|
||||
current_file_id: int,
|
||||
previous_file_id: int,
|
||||
comparison_result: Dict[str, Any],
|
||||
created_by: str
|
||||
) -> int:
|
||||
"""리비전 비교 기록 생성"""
|
||||
|
||||
try:
|
||||
comparison_record = RevisionComparison(
|
||||
job_no=job_no,
|
||||
current_file_id=current_file_id,
|
||||
previous_file_id=previous_file_id,
|
||||
comparison_result=comparison_result,
|
||||
summary_stats=comparison_result.get("summary", {}),
|
||||
created_by=created_by,
|
||||
is_applied=False
|
||||
)
|
||||
|
||||
self.db.add(comparison_record)
|
||||
self.db.commit()
|
||||
self.db.refresh(comparison_record)
|
||||
|
||||
logger.info(f"Created revision comparison record: {comparison_record.id}")
|
||||
return comparison_record.id
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to create revision comparison record: {e}")
|
||||
raise
|
||||
|
||||
def apply_revision_comparison(
|
||||
self,
|
||||
comparison_id: int,
|
||||
applied_by: str
|
||||
) -> Dict[str, Any]:
|
||||
"""리비전 비교 결과 적용"""
|
||||
|
||||
try:
|
||||
# 비교 기록 조회
|
||||
comparison = self.db.query(RevisionComparison).filter(
|
||||
RevisionComparison.id == comparison_id
|
||||
).first()
|
||||
|
||||
if not comparison:
|
||||
return {"success": False, "error": "비교 기록을 찾을 수 없습니다."}
|
||||
|
||||
if comparison.is_applied:
|
||||
return {"success": False, "error": "이미 적용된 비교 결과입니다."}
|
||||
|
||||
# 적용 처리
|
||||
comparison.is_applied = True
|
||||
comparison.applied_at = datetime.utcnow()
|
||||
comparison.applied_by = applied_by
|
||||
|
||||
# 변경 로그 생성
|
||||
self._create_change_logs(comparison)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Applied revision comparison: {comparison_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"comparison_id": comparison_id,
|
||||
"applied_at": comparison.applied_at.isoformat(),
|
||||
"applied_by": applied_by
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Failed to apply revision comparison: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_pending_revisions(self, job_no: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""대기 중인 리비전 목록 조회"""
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
rc.id, rc.job_no, rc.current_file_id, rc.previous_file_id,
|
||||
rc.comparison_date, rc.created_by, rc.summary_stats,
|
||||
cf.original_filename as current_filename,
|
||||
cf.revision as current_revision,
|
||||
pf.original_filename as previous_filename,
|
||||
pf.revision as previous_revision
|
||||
FROM revision_comparisons rc
|
||||
JOIN files cf ON rc.current_file_id = cf.id
|
||||
LEFT JOIN files pf ON rc.previous_file_id = pf.id
|
||||
WHERE rc.is_applied = false
|
||||
"""
|
||||
|
||||
params = {}
|
||||
if job_no:
|
||||
query += " AND rc.job_no = :job_no"
|
||||
params["job_no"] = job_no
|
||||
|
||||
query += " ORDER BY rc.comparison_date DESC"
|
||||
|
||||
result = self.db_service.execute_query(query, params)
|
||||
|
||||
pending_revisions = []
|
||||
for row in result.fetchall():
|
||||
row_dict = dict(row._mapping)
|
||||
pending_revisions.append({
|
||||
"comparison_id": row_dict['id'],
|
||||
"job_no": row_dict['job_no'],
|
||||
"current_file": {
|
||||
"id": row_dict['current_file_id'],
|
||||
"filename": row_dict['current_filename'],
|
||||
"revision": row_dict['current_revision']
|
||||
},
|
||||
"previous_file": {
|
||||
"id": row_dict['previous_file_id'],
|
||||
"filename": row_dict['previous_filename'],
|
||||
"revision": row_dict['previous_revision']
|
||||
} if row_dict['previous_file_id'] else None,
|
||||
"comparison_date": row_dict['comparison_date'],
|
||||
"created_by": row_dict['created_by'],
|
||||
"summary_stats": row_dict['summary_stats']
|
||||
})
|
||||
|
||||
return pending_revisions
|
||||
|
||||
def _get_file_info(self, file_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""파일 정보 조회"""
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
id, filename, original_filename, file_path, job_no, revision,
|
||||
bom_name, description, file_size, parsed_count,
|
||||
upload_date, uploaded_by, is_active
|
||||
FROM files
|
||||
WHERE id = :file_id
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {"file_id": file_id})
|
||||
row = result.fetchone()
|
||||
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
def _get_job_files(self, job_no: str) -> List[Dict[str, Any]]:
|
||||
"""작업의 모든 파일 조회 (최신순)"""
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
id, filename, original_filename, file_path, job_no, revision,
|
||||
bom_name, description, file_size, parsed_count,
|
||||
upload_date, uploaded_by, is_active
|
||||
FROM files
|
||||
WHERE job_no = :job_no AND is_active = true
|
||||
ORDER BY upload_date DESC, id DESC
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {"job_no": job_no})
|
||||
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
def _build_revision_history(self, all_files: List[Dict], current_file_id: int) -> Dict[str, Any]:
|
||||
"""리비전 히스토리 구성"""
|
||||
|
||||
current_index = None
|
||||
for i, file_info in enumerate(all_files):
|
||||
if file_info['id'] == current_file_id:
|
||||
current_index = i
|
||||
break
|
||||
|
||||
if current_index is None:
|
||||
return {"error": "현재 파일을 찾을 수 없습니다."}
|
||||
|
||||
return {
|
||||
"total_revisions": len(all_files),
|
||||
"current_position": current_index + 1, # 1-based
|
||||
"is_latest": current_index == 0,
|
||||
"is_first": current_index == len(all_files) - 1,
|
||||
"can_upload_new": current_index == 0, # 최신 리비전에서만 새 리비전 업로드 가능
|
||||
"previous_file_id": all_files[current_index + 1]['id'] if current_index < len(all_files) - 1 else None,
|
||||
"next_file_id": all_files[current_index - 1]['id'] if current_index > 0 else None
|
||||
}
|
||||
|
||||
def _get_processing_status(self, file_id: int) -> Dict[str, Any]:
|
||||
"""파일의 처리 상태 조회"""
|
||||
|
||||
# 자재별 처리 상태 통계
|
||||
query = """
|
||||
SELECT
|
||||
classified_category,
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN purchase_confirmed = true THEN 1 ELSE 0 END) as purchased_count,
|
||||
SUM(CASE WHEN revision_status IS NOT NULL THEN 1 ELSE 0 END) as processed_count,
|
||||
COUNT(DISTINCT COALESCE(revision_status, 'pending')) as status_types
|
||||
FROM materials
|
||||
WHERE file_id = :file_id AND is_active = true AND classified_category != 'PIPE'
|
||||
GROUP BY classified_category
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {"file_id": file_id})
|
||||
|
||||
category_status = {}
|
||||
total_materials = 0
|
||||
total_purchased = 0
|
||||
total_processed = 0
|
||||
|
||||
for row in result.fetchall():
|
||||
row_dict = dict(row._mapping)
|
||||
category = row_dict['classified_category']
|
||||
|
||||
category_status[category] = {
|
||||
"total": row_dict['total_count'],
|
||||
"purchased": row_dict['purchased_count'],
|
||||
"processed": row_dict['processed_count'],
|
||||
"pending": row_dict['total_count'] - row_dict['processed_count']
|
||||
}
|
||||
|
||||
total_materials += row_dict['total_count']
|
||||
total_purchased += row_dict['purchased_count']
|
||||
total_processed += row_dict['processed_count']
|
||||
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"total_materials": total_materials,
|
||||
"total_purchased": total_purchased,
|
||||
"total_processed": total_processed,
|
||||
"pending_processing": total_materials - total_processed,
|
||||
"category_breakdown": category_status,
|
||||
"completion_percentage": (total_processed / total_materials * 100) if total_materials > 0 else 0
|
||||
}
|
||||
|
||||
def _get_comparison_summary(self, current_file_id: int, previous_file_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""비교 요약 정보 조회"""
|
||||
|
||||
query = """
|
||||
SELECT summary_stats, comparison_date, is_applied
|
||||
FROM revision_comparisons
|
||||
WHERE current_file_id = :current_file_id AND previous_file_id = :previous_file_id
|
||||
ORDER BY comparison_date DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {
|
||||
"current_file_id": current_file_id,
|
||||
"previous_file_id": previous_file_id
|
||||
})
|
||||
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
row_dict = dict(row._mapping)
|
||||
return {
|
||||
"summary_stats": row_dict['summary_stats'],
|
||||
"comparison_date": row_dict['comparison_date'],
|
||||
"is_applied": row_dict['is_applied']
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _get_material_count(self, file_id: int) -> int:
|
||||
"""파일의 자재 개수 조회"""
|
||||
|
||||
query = """
|
||||
SELECT COUNT(*) as count
|
||||
FROM materials
|
||||
WHERE file_id = :file_id AND is_active = true AND classified_category != 'PIPE'
|
||||
"""
|
||||
|
||||
result = self.db_service.execute_query(query, {"file_id": file_id})
|
||||
row = result.fetchone()
|
||||
|
||||
return row.count if row else 0
|
||||
|
||||
def _create_change_logs(self, comparison: RevisionComparison):
|
||||
"""변경 로그 생성"""
|
||||
|
||||
try:
|
||||
changes = comparison.comparison_result.get("changes", {})
|
||||
|
||||
# 각 변경사항에 대해 로그 생성
|
||||
for change_type, change_list in changes.items():
|
||||
for change_item in change_list:
|
||||
change_log = RevisionChangeLog(
|
||||
comparison_id=comparison.id,
|
||||
material_id=change_item.get("material", {}).get("id"),
|
||||
change_type=change_type,
|
||||
previous_data=change_item.get("previous"),
|
||||
current_data=change_item.get("current") or change_item.get("material"),
|
||||
action_taken=change_item.get("action", change_type),
|
||||
notes=change_item.get("reason", "")
|
||||
)
|
||||
|
||||
self.db.add(change_log)
|
||||
|
||||
logger.info(f"Created change logs for comparison {comparison.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create change logs: {e}")
|
||||
raise
|
||||
|
||||
def _generate_status_summary(self, processing_status: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""상태 요약 생성"""
|
||||
|
||||
total = processing_status.get("total_materials", 0)
|
||||
processed = processing_status.get("total_processed", 0)
|
||||
purchased = processing_status.get("total_purchased", 0)
|
||||
|
||||
if total == 0:
|
||||
return {"status": "empty", "message": "자료가 없습니다."}
|
||||
|
||||
completion_rate = processed / total
|
||||
|
||||
if completion_rate >= 1.0:
|
||||
status = "completed"
|
||||
message = "모든 자재 처리 완료"
|
||||
elif completion_rate >= 0.8:
|
||||
status = "nearly_complete"
|
||||
message = f"처리 진행 중 ({processed}/{total})"
|
||||
elif completion_rate >= 0.5:
|
||||
status = "in_progress"
|
||||
message = f"처리 진행 중 ({processed}/{total})"
|
||||
else:
|
||||
status = "started"
|
||||
message = f"처리 시작됨 ({processed}/{total})"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"message": message,
|
||||
"completion_rate": completion_rate,
|
||||
"stats": {
|
||||
"total": total,
|
||||
"processed": processed,
|
||||
"purchased": purchased,
|
||||
"pending": total - processed
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user