Files
TK-BOM-Project/backend/app/routers/pipe_revision.py
Hyungi Ahn 8f42a1054e
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

앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
2025-10-21 10:34:45 +09:00

436 lines
15 KiB
Python

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