🔧 완전한 스키마 자동화 시스템 구축
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
✨ 주요 기능: - 완전한 데이터베이스 스키마 분석 및 자동 마이그레이션 시스템 - 44개 테이블 완전 지원 (운영 서버 43개 + 1개 추가) - 누락된 테이블/컬럼 자동 감지 및 생성 🔧 해결된 스키마 문제: - users.status 컬럼 누락 → 자동 추가 - files 테이블 4개 컬럼 누락 → 자동 추가 - materials 테이블 22개 컬럼 누락 → 자동 추가 - support_details, purchase_requests, purchase_request_items 테이블 누락 → 자동 생성 - material_purchase_tracking.description, purchase_status 컬럼 누락 → 자동 추가 🚀 자동화 도구: - schema_analyzer.py: 코드와 DB 스키마 비교 분석 - auto_migrator.py: 자동 마이그레이션 실행 - docker_migrator.py: Docker 환경용 간편 마이그레이션 - schema_monitor.py: 실시간 스키마 모니터링 📋 리비전 관리 시스템: - 8개 카테고리별 리비전 페이지 구현 - PIPE Cutting Plan 관리 시스템 - PIPE Issue Management 시스템 - 완전한 리비전 비교 및 추적 기능 🎯 사용법: docker exec tk-mp-backend python3 scripts/docker_migrator.py 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
This commit is contained in:
435
backend/app/routers/pipe_revision.py
Normal file
435
backend/app/routers/pipe_revision.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
PIPE 리비전 관리 API 라우터
|
||||
|
||||
Cutting Plan 전/후 리비전 처리 및 비교 기능
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..services.pipe_revision_service import get_pipe_revision_service, PipeRevisionService
|
||||
from ..models import PipeRevisionComparison, PipeRevisionChange
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/pipe-revision", tags=["pipe-revision"])
|
||||
|
||||
|
||||
# Pydantic 모델들
|
||||
class RevisionStatusRequest(BaseModel):
|
||||
job_no: str
|
||||
new_file_id: int
|
||||
|
||||
|
||||
class PreRevisionRequest(BaseModel):
|
||||
job_no: str
|
||||
new_file_id: int
|
||||
|
||||
|
||||
class PostRevisionRequest(BaseModel):
|
||||
job_no: str
|
||||
new_file_id: int
|
||||
|
||||
|
||||
class RevisionStatusResponse(BaseModel):
|
||||
revision_type: str
|
||||
requires_action: bool
|
||||
message: str
|
||||
previous_file_id: Optional[int] = None
|
||||
|
||||
|
||||
class PreRevisionResponse(BaseModel):
|
||||
status: str
|
||||
revision_type: str
|
||||
deleted_items: int
|
||||
new_pipe_materials: int
|
||||
message: str
|
||||
next_action: str
|
||||
pipe_materials: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class PostRevisionResponse(BaseModel):
|
||||
status: str
|
||||
revision_type: str
|
||||
comparison_id: int
|
||||
summary: Dict[str, Any]
|
||||
changed_drawings: List[Dict[str, Any]]
|
||||
unchanged_drawings: List[Dict[str, Any]]
|
||||
message: str
|
||||
next_action: str
|
||||
|
||||
|
||||
@router.post("/check-status", response_model=RevisionStatusResponse)
|
||||
async def check_revision_status(
|
||||
request: RevisionStatusRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
PIPE 리비전 상태 확인
|
||||
- 리비전 유형 판단 (pre/post cutting plan)
|
||||
- 필요한 처리 방식 결정
|
||||
"""
|
||||
try:
|
||||
service = get_pipe_revision_service(db)
|
||||
result = service.check_revision_status(request.job_no, request.new_file_id)
|
||||
|
||||
return RevisionStatusResponse(**result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check pipe revision status: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 상태 확인 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/handle-pre-cutting-plan", response_model=PreRevisionResponse)
|
||||
async def handle_pre_cutting_plan_revision(
|
||||
request: PreRevisionRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Cutting Plan 작성 전 리비전 처리
|
||||
- 기존 PIPE 데이터 삭제
|
||||
- 새 BOM 데이터로 초기화
|
||||
"""
|
||||
try:
|
||||
service = get_pipe_revision_service(db)
|
||||
result = service.handle_pre_cutting_plan_revision(request.job_no, request.new_file_id)
|
||||
|
||||
if result["status"] != "success":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["message"]
|
||||
)
|
||||
|
||||
return PreRevisionResponse(**result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to handle pre-cutting-plan revision: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Cutting Plan 작성 전 리비전 처리 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/handle-post-cutting-plan", response_model=PostRevisionResponse)
|
||||
async def handle_post_cutting_plan_revision(
|
||||
request: PostRevisionRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Cutting Plan 작성 후 리비전 처리
|
||||
- 기존 Cutting Plan과 신규 BOM 비교
|
||||
- 변경사항 상세 분석
|
||||
"""
|
||||
try:
|
||||
service = get_pipe_revision_service(db)
|
||||
result = service.handle_post_cutting_plan_revision(request.job_no, request.new_file_id)
|
||||
|
||||
if result["status"] != "success":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=result["message"]
|
||||
)
|
||||
|
||||
return PostRevisionResponse(**result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to handle post-cutting-plan revision: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Cutting Plan 작성 후 리비전 처리 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/comparison/{comparison_id}")
|
||||
async def get_revision_comparison(
|
||||
comparison_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
리비전 비교 결과 상세 조회
|
||||
"""
|
||||
try:
|
||||
# 비교 결과 조회
|
||||
comparison = db.query(PipeRevisionComparison).filter(
|
||||
PipeRevisionComparison.id == comparison_id
|
||||
).first()
|
||||
|
||||
if not comparison:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="비교 결과를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 상세 변경사항 조회
|
||||
changes = db.query(PipeRevisionChange).filter(
|
||||
PipeRevisionChange.comparison_id == comparison_id
|
||||
).all()
|
||||
|
||||
# 도면별로 변경사항 그룹화
|
||||
changes_by_drawing = {}
|
||||
for change in changes:
|
||||
drawing = change.drawing_name
|
||||
if drawing not in changes_by_drawing:
|
||||
changes_by_drawing[drawing] = []
|
||||
|
||||
changes_by_drawing[drawing].append({
|
||||
"id": change.id,
|
||||
"change_type": change.change_type,
|
||||
"old_data": {
|
||||
"line_no": change.old_line_no,
|
||||
"material_grade": change.old_material_grade,
|
||||
"schedule_spec": change.old_schedule_spec,
|
||||
"nominal_size": change.old_nominal_size,
|
||||
"length_mm": change.old_length_mm,
|
||||
"end_preparation": change.old_end_preparation
|
||||
} if change.old_line_no else None,
|
||||
"new_data": {
|
||||
"line_no": change.new_line_no,
|
||||
"material_grade": change.new_material_grade,
|
||||
"schedule_spec": change.new_schedule_spec,
|
||||
"nominal_size": change.new_nominal_size,
|
||||
"length_mm": change.new_length_mm,
|
||||
"end_preparation": change.new_end_preparation
|
||||
} if change.new_line_no else None,
|
||||
"change_reason": change.change_reason
|
||||
})
|
||||
|
||||
return {
|
||||
"comparison_id": comparison.id,
|
||||
"job_no": comparison.job_no,
|
||||
"comparison_date": comparison.comparison_date,
|
||||
"summary": {
|
||||
"total_drawings": comparison.total_drawings,
|
||||
"changed_drawings": comparison.changed_drawings,
|
||||
"unchanged_drawings": comparison.unchanged_drawings,
|
||||
"total_segments": comparison.total_segments,
|
||||
"added_segments": comparison.added_segments,
|
||||
"removed_segments": comparison.removed_segments,
|
||||
"modified_segments": comparison.modified_segments,
|
||||
"unchanged_segments": comparison.unchanged_segments
|
||||
},
|
||||
"changes_by_drawing": changes_by_drawing,
|
||||
"is_applied": comparison.is_applied,
|
||||
"applied_at": comparison.applied_at,
|
||||
"applied_by": comparison.applied_by
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get revision comparison: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 비교 결과 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/comparison/{comparison_id}/apply")
|
||||
async def apply_revision_changes(
|
||||
comparison_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
리비전 변경사항 적용
|
||||
"""
|
||||
try:
|
||||
# 비교 결과 조회
|
||||
comparison = db.query(PipeRevisionComparison).filter(
|
||||
PipeRevisionComparison.id == comparison_id
|
||||
).first()
|
||||
|
||||
if not comparison:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="비교 결과를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
if comparison.is_applied:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 적용된 리비전입니다."
|
||||
)
|
||||
|
||||
# 적용 상태 업데이트
|
||||
comparison.is_applied = True
|
||||
comparison.applied_at = datetime.utcnow()
|
||||
comparison.applied_by = "system" # 추후 사용자 정보로 변경
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "리비전 변경사항이 적용되었습니다.",
|
||||
"comparison_id": comparison_id,
|
||||
"applied_at": comparison.applied_at
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to apply revision changes: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 변경사항 적용 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/job/{job_no}/history")
|
||||
async def get_revision_history(
|
||||
job_no: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
작업별 PIPE 리비전 이력 조회
|
||||
"""
|
||||
try:
|
||||
comparisons = db.query(PipeRevisionComparison).filter(
|
||||
PipeRevisionComparison.job_no == job_no
|
||||
).order_by(PipeRevisionComparison.comparison_date.desc()).all()
|
||||
|
||||
history = []
|
||||
for comp in comparisons:
|
||||
history.append({
|
||||
"comparison_id": comp.id,
|
||||
"comparison_date": comp.comparison_date,
|
||||
"summary": {
|
||||
"total_drawings": comp.total_drawings,
|
||||
"changed_drawings": comp.changed_drawings,
|
||||
"total_segments": comp.total_segments,
|
||||
"added_segments": comp.added_segments,
|
||||
"removed_segments": comp.removed_segments,
|
||||
"modified_segments": comp.modified_segments
|
||||
},
|
||||
"is_applied": comp.is_applied,
|
||||
"applied_at": comp.applied_at,
|
||||
"applied_by": comp.applied_by,
|
||||
"created_by": comp.created_by
|
||||
})
|
||||
|
||||
return {
|
||||
"job_no": job_no,
|
||||
"total_revisions": len(history),
|
||||
"history": history
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get revision history: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"리비전 이력 조회 실패: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/comparison/{comparison_id}/purchase-impact")
|
||||
async def get_purchase_impact(
|
||||
comparison_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
리비전이 구매량에 미치는 영향 분석
|
||||
"""
|
||||
try:
|
||||
# 비교 결과 조회
|
||||
comparison = db.query(PipeRevisionComparison).filter(
|
||||
PipeRevisionComparison.id == comparison_id
|
||||
).first()
|
||||
|
||||
if not comparison:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="비교 결과를 찾을 수 없습니다."
|
||||
)
|
||||
|
||||
# 변경사항 조회
|
||||
changes = db.query(PipeRevisionChange).filter(
|
||||
PipeRevisionChange.comparison_id == comparison_id
|
||||
).all()
|
||||
|
||||
# 재질별 길이 변화 계산
|
||||
material_impacts = {}
|
||||
|
||||
for change in changes:
|
||||
# 재질 정보 추출
|
||||
material = None
|
||||
length_change = 0
|
||||
|
||||
if change.change_type == "added" and change.new_material_grade:
|
||||
material = change.new_material_grade
|
||||
length_change = change.new_length_mm or 0
|
||||
elif change.change_type == "removed" and change.old_material_grade:
|
||||
material = change.old_material_grade
|
||||
length_change = -(change.old_length_mm or 0)
|
||||
elif change.change_type == "modified":
|
||||
# 재질이 같은 경우만 길이 변화 계산
|
||||
if (change.old_material_grade == change.new_material_grade and
|
||||
change.old_material_grade):
|
||||
material = change.old_material_grade
|
||||
old_length = change.old_length_mm or 0
|
||||
new_length = change.new_length_mm or 0
|
||||
length_change = new_length - old_length
|
||||
|
||||
if material and length_change != 0:
|
||||
if material not in material_impacts:
|
||||
material_impacts[material] = {
|
||||
"material": material,
|
||||
"total_length_change": 0,
|
||||
"change_count": 0
|
||||
}
|
||||
|
||||
material_impacts[material]["total_length_change"] += length_change
|
||||
material_impacts[material]["change_count"] += 1
|
||||
|
||||
# 결과 정리
|
||||
impact_summary = []
|
||||
for material, impact in material_impacts.items():
|
||||
length_change_m = impact["total_length_change"] / 1000 # mm to m
|
||||
impact_summary.append({
|
||||
"material": material,
|
||||
"length_change_mm": impact["total_length_change"],
|
||||
"length_change_m": round(length_change_m, 3),
|
||||
"change_count": impact["change_count"],
|
||||
"impact_type": "increase" if length_change_m > 0 else "decrease",
|
||||
"requires_additional_purchase": length_change_m > 0
|
||||
})
|
||||
|
||||
# 전체 영향 요약
|
||||
total_additional_purchase = sum(
|
||||
impact["length_change_m"] for impact in impact_summary
|
||||
if impact["requires_additional_purchase"]
|
||||
)
|
||||
|
||||
return {
|
||||
"comparison_id": comparison_id,
|
||||
"job_no": comparison.job_no,
|
||||
"material_impacts": impact_summary,
|
||||
"summary": {
|
||||
"total_materials_affected": len(impact_summary),
|
||||
"materials_requiring_additional_purchase": len([
|
||||
i for i in impact_summary if i["requires_additional_purchase"]
|
||||
]),
|
||||
"total_additional_purchase_needed": total_additional_purchase > 0,
|
||||
"total_additional_length_m": round(total_additional_purchase, 3)
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get purchase impact: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"구매 영향 분석 실패: {str(e)}"
|
||||
)
|
||||
Reference in New Issue
Block a user