🔧 완전한 스키마 자동화 시스템 구축
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:
Hyungi Ahn
2025-10-21 10:34:45 +09:00
parent 9d7165bbf9
commit 8f42a1054e
55 changed files with 22443 additions and 0 deletions

View 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"]
})

View 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)

View 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)

View 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)

View 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)

View 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

View 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, "리비전 상태 확인 실패 - 기존 페이지 사용"

View 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": "자재 처리 적용 중 오류 발생"
}

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