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 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
489 lines
19 KiB
Python
489 lines
19 KiB
Python
"""
|
|
강화된 리비전 관리 서비스
|
|
구매 상태 기반 리비전 비교 및 처리
|
|
"""
|
|
|
|
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"]
|
|
})
|