🔧 완전한 스키마 자동화 시스템 구축
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,409 @@
"""
강화된 리비전 관리 API 엔드포인트
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Dict, Any, Optional
from datetime import datetime
from ..database import get_db
from ..auth.middleware import get_current_user
from ..services.enhanced_revision_service import EnhancedRevisionService
from ..auth.models import User
from ..models import RevisionComparison, RevisionChangeLog
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/enhanced-revision", tags=["Enhanced Revision Management"])
@router.post("/compare-revisions")
async def compare_revisions_enhanced(
job_no: str,
current_file_id: int,
previous_file_id: Optional[int] = None,
save_comparison: bool = True,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
강화된 리비전 비교 수행
Args:
job_no: 작업 번호
current_file_id: 현재 파일 ID
previous_file_id: 이전 파일 ID (None이면 자동 탐지)
save_comparison: 비교 결과 저장 여부
"""
try:
revision_service = EnhancedRevisionService(db)
# 리비전 비교 수행
comparison_result = revision_service.compare_revisions_with_purchase_status(
job_no=job_no,
current_file_id=current_file_id,
previous_file_id=previous_file_id
)
# 비교 결과 저장 (옵션)
if save_comparison:
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=current_user.username,
is_applied=False
)
db.add(comparison_record)
db.commit()
db.refresh(comparison_record)
comparison_result["comparison_id"] = comparison_record.id
logger.info(f"Enhanced revision comparison completed for job {job_no}")
return {
"success": True,
"data": comparison_result,
"message": "강화된 리비전 비교가 완료되었습니다."
}
except Exception as e:
logger.error(f"Enhanced revision comparison failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"리비전 비교 중 오류가 발생했습니다: {str(e)}"
)
@router.post("/apply-revision-changes/{comparison_id}")
async def apply_revision_changes(
comparison_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
리비전 변경사항을 실제 DB에 적용
Args:
comparison_id: 비교 결과 ID
"""
try:
# 비교 결과 조회
comparison = db.query(RevisionComparison).filter(
RevisionComparison.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="이미 적용된 비교 결과입니다."
)
revision_service = EnhancedRevisionService(db)
# 변경사항 적용
apply_result = revision_service.apply_revision_changes(
comparison_result=comparison.comparison_result,
current_file_id=comparison.current_file_id
)
if apply_result["success"]:
# 적용 완료 표시
comparison.is_applied = True
comparison.applied_at = datetime.utcnow()
comparison.applied_by = current_user.username
db.commit()
logger.info(f"Revision changes applied for comparison {comparison_id}")
return {
"success": True,
"data": apply_result,
"message": "리비전 변경사항이 성공적으로 적용되었습니다."
}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=apply_result.get("message", "변경사항 적용 중 오류가 발생했습니다.")
)
except HTTPException:
raise
except Exception as e:
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("/comparison-history/{job_no}")
async def get_comparison_history(
job_no: str,
limit: int = 10,
offset: int = 0,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
작업별 리비전 비교 이력 조회
Args:
job_no: 작업 번호
limit: 조회 개수 제한
offset: 조회 시작 위치
"""
try:
comparisons = db.query(RevisionComparison).filter(
RevisionComparison.job_no == job_no
).order_by(
RevisionComparison.comparison_date.desc()
).offset(offset).limit(limit).all()
result = []
for comp in comparisons:
result.append({
"id": comp.id,
"job_no": comp.job_no,
"current_file_id": comp.current_file_id,
"previous_file_id": comp.previous_file_id,
"comparison_date": comp.comparison_date.isoformat(),
"summary_stats": comp.summary_stats,
"created_by": comp.created_by,
"is_applied": comp.is_applied,
"applied_at": comp.applied_at.isoformat() if comp.applied_at else None,
"applied_by": comp.applied_by
})
return {
"success": True,
"data": result,
"total": len(result),
"message": "리비전 비교 이력을 조회했습니다."
}
except Exception as e:
logger.error(f"Failed to get comparison history: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"비교 이력 조회 중 오류가 발생했습니다: {str(e)}"
)
@router.get("/comparison-details/{comparison_id}")
async def get_comparison_details(
comparison_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
특정 비교 결과의 상세 정보 조회
Args:
comparison_id: 비교 결과 ID
"""
try:
comparison = db.query(RevisionComparison).filter(
RevisionComparison.id == comparison_id
).first()
if not comparison:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="비교 결과를 찾을 수 없습니다."
)
# 변경 로그도 함께 조회
change_logs = db.query(RevisionChangeLog).filter(
RevisionChangeLog.comparison_id == comparison_id
).all()
result = {
"comparison": {
"id": comparison.id,
"job_no": comparison.job_no,
"current_file_id": comparison.current_file_id,
"previous_file_id": comparison.previous_file_id,
"comparison_date": comparison.comparison_date.isoformat(),
"comparison_result": comparison.comparison_result,
"summary_stats": comparison.summary_stats,
"created_by": comparison.created_by,
"is_applied": comparison.is_applied,
"applied_at": comparison.applied_at.isoformat() if comparison.applied_at else None,
"applied_by": comparison.applied_by
},
"change_logs": [
{
"id": log.id,
"material_id": log.material_id,
"change_type": log.change_type,
"previous_data": log.previous_data,
"current_data": log.current_data,
"action_taken": log.action_taken,
"notes": log.notes,
"created_at": log.created_at.isoformat()
}
for log in change_logs
]
}
return {
"success": True,
"data": result,
"message": "비교 결과 상세 정보를 조회했습니다."
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get comparison details: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"비교 결과 조회 중 오류가 발생했습니다: {str(e)}"
)
@router.get("/pipe-length-summary/{file_id}")
async def get_pipe_length_summary(
file_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
PIPE 자재 길이 요약 정보 조회
Args:
file_id: 파일 ID
"""
try:
revision_service = EnhancedRevisionService(db)
# PIPE 자재만 조회하여 도면-라인넘버별 길이 집계
pipe_materials = revision_service._get_materials_with_purchase_status(file_id)
pipe_summary = {}
for key, material in pipe_materials.items():
if material.get('classified_category') == 'PIPE':
drawing_line = f"{material.get('drawing_name', 'Unknown')} - {material.get('line_no', 'Unknown')}"
if drawing_line not in pipe_summary:
pipe_summary[drawing_line] = {
"drawing_name": material.get('drawing_name'),
"line_no": material.get('line_no'),
"material_grade": material.get('material_grade'),
"schedule": material.get('schedule'),
"nominal_size": material.get('main_nom'),
"total_length": 0,
"segment_count": 0,
"purchase_status": "mixed"
}
pipe_summary[drawing_line]["total_length"] += material.get('total_length', 0)
pipe_summary[drawing_line]["segment_count"] += 1
# 구매 상태 확인
if material.get('purchase_confirmed'):
if pipe_summary[drawing_line]["purchase_status"] == "mixed":
pipe_summary[drawing_line]["purchase_status"] = "purchased"
else:
if pipe_summary[drawing_line]["purchase_status"] == "purchased":
pipe_summary[drawing_line]["purchase_status"] = "mixed"
elif pipe_summary[drawing_line]["purchase_status"] != "mixed":
pipe_summary[drawing_line]["purchase_status"] = "pending"
return {
"success": True,
"data": {
"file_id": file_id,
"pipe_lines": list(pipe_summary.values()),
"total_lines": len(pipe_summary),
"total_length": sum(line["total_length"] for line in pipe_summary.values())
},
"message": "PIPE 자재 길이 요약을 조회했습니다."
}
except Exception as e:
logger.error(f"Failed to get pipe length summary: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"PIPE 길이 요약 조회 중 오류가 발생했습니다: {str(e)}"
)
@router.post("/recalculate-pipe-lengths/{file_id}")
async def recalculate_pipe_lengths(
file_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
PIPE 자재 길이 재계산 및 업데이트
Args:
file_id: 파일 ID
"""
try:
revision_service = EnhancedRevisionService(db)
# PIPE 자재 조회 및 길이 재계산
pipe_materials = revision_service._get_materials_with_purchase_status(file_id)
updated_count = 0
for key, material in pipe_materials.items():
if material.get('classified_category') == 'PIPE':
# total_length 업데이트
total_length = material.get('total_length', 0)
update_query = """
UPDATE materials
SET total_length = :total_length,
updated_at = CURRENT_TIMESTAMP
WHERE id = :material_id
"""
revision_service.db_service.execute_query(update_query, {
"total_length": total_length,
"material_id": material["id"]
})
updated_count += 1
db.commit()
logger.info(f"Recalculated pipe lengths for {updated_count} materials in file {file_id}")
return {
"success": True,
"data": {
"file_id": file_id,
"updated_count": updated_count
},
"message": f"PIPE 자재 {updated_count}개의 길이를 재계산했습니다."
}
except Exception as e:
logger.error(f"Failed to recalculate pipe lengths: {e}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"PIPE 길이 재계산 중 오류가 발생했습니다: {str(e)}"
)

View File

@@ -0,0 +1,224 @@
"""
PIPE 스냅샷 Excel 내보내기 API 라우터
확정된 Cutting Plan의 고정된 Excel 내보내기 기능
"""
import logging
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from io import BytesIO
from ..database import get_db
from ..services.pipe_snapshot_excel_service import get_pipe_snapshot_excel_service, PipeSnapshotExcelService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/pipe-excel", tags=["pipe-excel"])
@router.get("/export-finalized/{job_no}")
async def export_finalized_cutting_plan(
job_no: str,
db: Session = Depends(get_db)
):
"""
확정된 Cutting Plan Excel 내보내기
- 스냅샷 데이터 기준으로 고정된 Excel 생성
- 리비전과 무관하게 동일한 데이터 제공
"""
try:
service = get_pipe_snapshot_excel_service(db)
result = service.export_finalized_cutting_plan(job_no)
if not result["success"]:
if "확정된 Cutting Plan이 없습니다" in result["message"]:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=result["message"]
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result["message"]
)
# Excel 파일 스트리밍 응답
excel_buffer = result["excel_buffer"]
filename = result["filename"]
# 파일 다운로드를 위한 헤더 설정
headers = {
'Content-Disposition': f'attachment; filename="{filename}"',
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
return StreamingResponse(
BytesIO(excel_buffer.getvalue()),
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers=headers
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to export finalized cutting plan: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"확정된 Excel 내보내기 실패: {str(e)}"
)
@router.get("/check-finalization/{job_no}")
async def check_finalization_status(
job_no: str,
db: Session = Depends(get_db)
):
"""
Cutting Plan 확정 상태 확인
- 확정된 Excel 내보내기 가능 여부 확인
"""
try:
service = get_pipe_snapshot_excel_service(db)
result = service.check_finalization_status(job_no)
return result
except Exception as e:
logger.error(f"Failed to check finalization status: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"확정 상태 확인 실패: {str(e)}"
)
@router.get("/preview-finalized/{job_no}")
async def preview_finalized_data(
job_no: str,
db: Session = Depends(get_db)
):
"""
확정된 데이터 미리보기
- Excel 생성 전 데이터 확인용
"""
try:
from ..services.pipe_issue_snapshot_service import get_pipe_issue_snapshot_service
# 스냅샷 상태 확인
snapshot_service = get_pipe_issue_snapshot_service(db)
snapshot_info = snapshot_service.get_snapshot_info(job_no)
if not snapshot_info["has_snapshot"] or not snapshot_info["is_locked"]:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="확정된 Cutting Plan이 없습니다."
)
# 스냅샷 데이터 조회
snapshot_id = snapshot_info["snapshot_id"]
segments = snapshot_service.get_snapshot_segments(snapshot_id)
# 구역별/도면별 통계
area_stats = {}
drawing_stats = {}
material_stats = {}
for segment in segments:
# 구역별 통계
area = segment.get("area", "미할당")
if area not in area_stats:
area_stats[area] = {"count": 0, "total_length": 0}
area_stats[area]["count"] += 1
area_stats[area]["total_length"] += segment.get("length_mm", 0)
# 도면별 통계
drawing = segment.get("drawing_name", "UNKNOWN")
if drawing not in drawing_stats:
drawing_stats[drawing] = {"count": 0, "total_length": 0}
drawing_stats[drawing]["count"] += 1
drawing_stats[drawing]["total_length"] += segment.get("length_mm", 0)
# 재질별 통계
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)
return {
"job_no": job_no,
"snapshot_info": snapshot_info,
"preview_data": {
"total_segments": len(segments),
"area_stats": area_stats,
"drawing_stats": drawing_stats,
"material_stats": material_stats
},
"sample_segments": segments[:10] if segments else [], # 처음 10개만 미리보기
"can_export": True,
"message": "확정된 데이터 미리보기가 준비되었습니다."
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to preview finalized data: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"데이터 미리보기 실패: {str(e)}"
)
@router.post("/export-temp/{job_no}")
async def export_temp_cutting_plan(
job_no: str,
db: Session = Depends(get_db)
):
"""
임시 Cutting Plan Excel 내보내기 (구현 예정)
- 현재 작업 중인 데이터 기준
- 리비전 시 변경될 수 있는 데이터
"""
try:
# TODO: 임시 Excel 내보내기 구현
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="임시 Excel 내보내기 기능은 구현 예정입니다."
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to export temp cutting plan: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"임시 Excel 내보내기 실패: {str(e)}"
)
@router.get("/download-history/{job_no}")
async def get_download_history(
job_no: str,
db: Session = Depends(get_db)
):
"""
Excel 다운로드 이력 조회 (구현 예정)
- 확정된 Excel 다운로드 기록
- 다운로드 시간 및 사용자 추적
"""
try:
# TODO: 다운로드 이력 추적 구현
return {
"job_no": job_no,
"download_history": [],
"message": "다운로드 이력 추적 기능은 구현 예정입니다."
}
except Exception as e:
logger.error(f"Failed to get download history: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"다운로드 이력 조회 실패: {str(e)}"
)

View File

@@ -0,0 +1,535 @@
"""
PIPE 이슈 관리 API 라우터
스냅샷 기반 도면별/단관별 이슈 관리 기능
"""
import logging
from typing import Dict, List, Optional, Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime
from ..database import get_db
from ..models import (
PipeIssueSnapshot, PipeIssueSegment,
PipeDrawingIssue, PipeSegmentIssue
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/pipe-issue", tags=["pipe-issue"])
# Pydantic 모델들
class CreateDrawingIssueRequest(BaseModel):
snapshot_id: int
area: str
drawing_name: str
issue_description: str
severity: str = 'medium' # low, medium, high, critical
reported_by: Optional[str] = 'user'
class CreateSegmentIssueRequest(BaseModel):
snapshot_id: int
segment_id: int
issue_description: str
issue_type: Optional[str] = 'other' # cutting, installation, material, routing, other
length_change: Optional[float] = None
new_length: Optional[float] = None
material_change: Optional[str] = None
severity: str = 'medium'
reported_by: Optional[str] = 'user'
class UpdateIssueStatusRequest(BaseModel):
status: str # open, in_progress, resolved
resolution_notes: Optional[str] = None
resolved_by: Optional[str] = None
class DrawingIssueResponse(BaseModel):
id: int
snapshot_id: int
area: str
drawing_name: str
issue_description: str
severity: str
status: str
resolution_notes: Optional[str] = None
resolved_by: Optional[str] = None
resolved_at: Optional[str] = None
reported_by: Optional[str] = None
reported_at: str
updated_at: str
class SegmentIssueResponse(BaseModel):
id: int
snapshot_id: int
segment_id: int
issue_description: str
issue_type: Optional[str] = None
length_change: Optional[float] = None
new_length: Optional[float] = None
material_change: Optional[str] = None
severity: str
status: str
resolution_notes: Optional[str] = None
resolved_by: Optional[str] = None
resolved_at: Optional[str] = None
reported_by: Optional[str] = None
reported_at: str
updated_at: str
@router.get("/snapshots/{job_no}")
async def get_job_snapshots(
job_no: str,
db: Session = Depends(get_db)
):
"""작업의 활성 스냅샷 조회"""
try:
snapshots = db.query(PipeIssueSnapshot).filter(
PipeIssueSnapshot.job_no == job_no,
PipeIssueSnapshot.is_active == True
).all()
result = []
for snapshot in snapshots:
result.append({
"snapshot_id": snapshot.id,
"snapshot_name": snapshot.snapshot_name,
"is_locked": snapshot.is_locked,
"total_segments": snapshot.total_segments,
"total_drawings": snapshot.total_drawings,
"created_at": snapshot.created_at.isoformat() if snapshot.created_at else None,
"locked_at": snapshot.locked_at.isoformat() if snapshot.locked_at else None
})
return {
"job_no": job_no,
"snapshots": result,
"total_count": len(result)
}
except Exception as e:
logger.error(f"Failed to get job snapshots: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"스냅샷 조회 실패: {str(e)}"
)
@router.get("/drawing-issues/{snapshot_id}")
async def get_drawing_issues(
snapshot_id: int,
area: Optional[str] = None,
drawing_name: Optional[str] = None,
status_filter: Optional[str] = None,
db: Session = Depends(get_db)
):
"""도면 이슈 목록 조회"""
try:
query = db.query(PipeDrawingIssue).filter(
PipeDrawingIssue.snapshot_id == snapshot_id
)
if area:
query = query.filter(PipeDrawingIssue.area == area)
if drawing_name:
query = query.filter(PipeDrawingIssue.drawing_name == drawing_name)
if status_filter:
query = query.filter(PipeDrawingIssue.status == status_filter)
issues = query.order_by(PipeDrawingIssue.reported_at.desc()).all()
result = []
for issue in issues:
result.append(DrawingIssueResponse(
id=issue.id,
snapshot_id=issue.snapshot_id,
area=issue.area,
drawing_name=issue.drawing_name,
issue_description=issue.issue_description,
severity=issue.severity,
status=issue.status,
resolution_notes=issue.resolution_notes,
resolved_by=issue.resolved_by,
resolved_at=issue.resolved_at.isoformat() if issue.resolved_at else None,
reported_by=issue.reported_by,
reported_at=issue.reported_at.isoformat() if issue.reported_at else '',
updated_at=issue.updated_at.isoformat() if issue.updated_at else ''
))
return {
"snapshot_id": snapshot_id,
"issues": result,
"total_count": len(result)
}
except Exception as e:
logger.error(f"Failed to get drawing issues: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"도면 이슈 조회 실패: {str(e)}"
)
@router.post("/drawing-issues", response_model=DrawingIssueResponse)
async def create_drawing_issue(
request: CreateDrawingIssueRequest,
db: Session = Depends(get_db)
):
"""도면 이슈 생성"""
try:
# 스냅샷 존재 확인
snapshot = db.query(PipeIssueSnapshot).filter(
PipeIssueSnapshot.id == request.snapshot_id
).first()
if not snapshot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="스냅샷을 찾을 수 없습니다."
)
# 이슈 생성
issue = PipeDrawingIssue(
snapshot_id=request.snapshot_id,
area=request.area,
drawing_name=request.drawing_name,
issue_description=request.issue_description,
severity=request.severity,
reported_by=request.reported_by
)
db.add(issue)
db.commit()
db.refresh(issue)
return DrawingIssueResponse(
id=issue.id,
snapshot_id=issue.snapshot_id,
area=issue.area,
drawing_name=issue.drawing_name,
issue_description=issue.issue_description,
severity=issue.severity,
status=issue.status,
resolution_notes=issue.resolution_notes,
resolved_by=issue.resolved_by,
resolved_at=issue.resolved_at.isoformat() if issue.resolved_at else None,
reported_by=issue.reported_by,
reported_at=issue.reported_at.isoformat() if issue.reported_at else '',
updated_at=issue.updated_at.isoformat() if issue.updated_at else ''
)
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to create drawing issue: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"도면 이슈 생성 실패: {str(e)}"
)
@router.get("/segment-issues/{snapshot_id}")
async def get_segment_issues(
snapshot_id: int,
segment_id: Optional[int] = None,
status_filter: Optional[str] = None,
db: Session = Depends(get_db)
):
"""단관 이슈 목록 조회"""
try:
query = db.query(PipeSegmentIssue).filter(
PipeSegmentIssue.snapshot_id == snapshot_id
)
if segment_id:
query = query.filter(PipeSegmentIssue.segment_id == segment_id)
if status_filter:
query = query.filter(PipeSegmentIssue.status == status_filter)
issues = query.order_by(PipeSegmentIssue.reported_at.desc()).all()
result = []
for issue in issues:
result.append(SegmentIssueResponse(
id=issue.id,
snapshot_id=issue.snapshot_id,
segment_id=issue.segment_id,
issue_description=issue.issue_description,
issue_type=issue.issue_type,
length_change=float(issue.length_change) if issue.length_change else None,
new_length=float(issue.new_length) if issue.new_length else None,
material_change=issue.material_change,
severity=issue.severity,
status=issue.status,
resolution_notes=issue.resolution_notes,
resolved_by=issue.resolved_by,
resolved_at=issue.resolved_at.isoformat() if issue.resolved_at else None,
reported_by=issue.reported_by,
reported_at=issue.reported_at.isoformat() if issue.reported_at else '',
updated_at=issue.updated_at.isoformat() if issue.updated_at else ''
))
return {
"snapshot_id": snapshot_id,
"issues": result,
"total_count": len(result)
}
except Exception as e:
logger.error(f"Failed to get segment issues: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"단관 이슈 조회 실패: {str(e)}"
)
@router.post("/segment-issues", response_model=SegmentIssueResponse)
async def create_segment_issue(
request: CreateSegmentIssueRequest,
db: Session = Depends(get_db)
):
"""단관 이슈 생성"""
try:
# 단관 존재 확인
segment = db.query(PipeIssueSegment).filter(
PipeIssueSegment.id == request.segment_id
).first()
if not segment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="단관을 찾을 수 없습니다."
)
# 이슈 생성
issue = PipeSegmentIssue(
snapshot_id=request.snapshot_id,
segment_id=request.segment_id,
issue_description=request.issue_description,
issue_type=request.issue_type,
length_change=request.length_change,
new_length=request.new_length,
material_change=request.material_change,
severity=request.severity,
reported_by=request.reported_by
)
db.add(issue)
db.commit()
db.refresh(issue)
return SegmentIssueResponse(
id=issue.id,
snapshot_id=issue.snapshot_id,
segment_id=issue.segment_id,
issue_description=issue.issue_description,
issue_type=issue.issue_type,
length_change=float(issue.length_change) if issue.length_change else None,
new_length=float(issue.new_length) if issue.new_length else None,
material_change=issue.material_change,
severity=issue.severity,
status=issue.status,
resolution_notes=issue.resolution_notes,
resolved_by=issue.resolved_by,
resolved_at=issue.resolved_at.isoformat() if issue.resolved_at else None,
reported_by=issue.reported_by,
reported_at=issue.reported_at.isoformat() if issue.reported_at else '',
updated_at=issue.updated_at.isoformat() if issue.updated_at else ''
)
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to create segment issue: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"단관 이슈 생성 실패: {str(e)}"
)
@router.put("/drawing-issues/{issue_id}/status")
async def update_drawing_issue_status(
issue_id: int,
request: UpdateIssueStatusRequest,
db: Session = Depends(get_db)
):
"""도면 이슈 상태 업데이트"""
try:
issue = db.query(PipeDrawingIssue).filter(
PipeDrawingIssue.id == issue_id
).first()
if not issue:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="이슈를 찾을 수 없습니다."
)
# 상태 업데이트
issue.status = request.status
if request.resolution_notes:
issue.resolution_notes = request.resolution_notes
if request.resolved_by:
issue.resolved_by = request.resolved_by
if request.status == 'resolved':
issue.resolved_at = datetime.utcnow()
db.commit()
return {"message": "이슈 상태가 업데이트되었습니다.", "issue_id": issue_id}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to update drawing issue status: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"이슈 상태 업데이트 실패: {str(e)}"
)
@router.put("/segment-issues/{issue_id}/status")
async def update_segment_issue_status(
issue_id: int,
request: UpdateIssueStatusRequest,
db: Session = Depends(get_db)
):
"""단관 이슈 상태 업데이트"""
try:
issue = db.query(PipeSegmentIssue).filter(
PipeSegmentIssue.id == issue_id
).first()
if not issue:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="이슈를 찾을 수 없습니다."
)
# 상태 업데이트
issue.status = request.status
if request.resolution_notes:
issue.resolution_notes = request.resolution_notes
if request.resolved_by:
issue.resolved_by = request.resolved_by
if request.status == 'resolved':
issue.resolved_at = datetime.utcnow()
db.commit()
return {"message": "이슈 상태가 업데이트되었습니다.", "issue_id": issue_id}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to update segment issue status: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"이슈 상태 업데이트 실패: {str(e)}"
)
@router.get("/report/{snapshot_id}")
async def generate_issue_report(
snapshot_id: int,
db: Session = Depends(get_db)
):
"""이슈 리포트 생성"""
try:
# 스냅샷 정보
snapshot = db.query(PipeIssueSnapshot).filter(
PipeIssueSnapshot.id == snapshot_id
).first()
if not snapshot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="스냅샷을 찾을 수 없습니다."
)
# 도면 이슈 통계
drawing_issues = db.query(PipeDrawingIssue).filter(
PipeDrawingIssue.snapshot_id == snapshot_id
).all()
# 단관 이슈 통계
segment_issues = db.query(PipeSegmentIssue).filter(
PipeSegmentIssue.snapshot_id == snapshot_id
).all()
# 통계 계산
drawing_stats = {
"total": len(drawing_issues),
"by_status": {},
"by_severity": {},
"by_area": {}
}
for issue in drawing_issues:
# 상태별
status = issue.status
drawing_stats["by_status"][status] = drawing_stats["by_status"].get(status, 0) + 1
# 심각도별
severity = issue.severity
drawing_stats["by_severity"][severity] = drawing_stats["by_severity"].get(severity, 0) + 1
# 구역별
area = issue.area
drawing_stats["by_area"][area] = drawing_stats["by_area"].get(area, 0) + 1
segment_stats = {
"total": len(segment_issues),
"by_status": {},
"by_severity": {},
"by_type": {}
}
for issue in segment_issues:
# 상태별
status = issue.status
segment_stats["by_status"][status] = segment_stats["by_status"].get(status, 0) + 1
# 심각도별
severity = issue.severity
segment_stats["by_severity"][severity] = segment_stats["by_severity"].get(severity, 0) + 1
# 유형별
issue_type = issue.issue_type or 'other'
segment_stats["by_type"][issue_type] = segment_stats["by_type"].get(issue_type, 0) + 1
return {
"snapshot_id": snapshot_id,
"snapshot_name": snapshot.snapshot_name,
"job_no": snapshot.job_no,
"report_generated_at": datetime.utcnow().isoformat(),
"drawing_issues": drawing_stats,
"segment_issues": segment_stats,
"total_issues": len(drawing_issues) + len(segment_issues)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to generate issue report: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"이슈 리포트 생성 실패: {str(e)}"
)

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

View File

@@ -0,0 +1,338 @@
"""
PIPE 스냅샷 관리 API 라우터
Cutting Plan 확정, 스냅샷 생성, 이슈 관리 준비 등의 기능 제공
"""
import logging
from typing import Dict, List, Optional, Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from ..database import get_db
from ..services.pipe_issue_snapshot_service import get_pipe_issue_snapshot_service, PipeIssueSnapshotService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/pipe-snapshot", tags=["pipe-snapshot"])
# Pydantic 모델들
class FinalizeCuttingPlanRequest(BaseModel):
job_no: str
created_by: Optional[str] = "system"
class SnapshotStatusResponse(BaseModel):
has_snapshot: bool
snapshot_id: Optional[int] = None
snapshot_name: Optional[str] = None
is_locked: bool = False
created_at: Optional[str] = None
created_by: Optional[str] = None
locked_at: Optional[str] = None
locked_by: Optional[str] = None
total_segments: int = 0
total_drawings: int = 0
drawing_issues_count: int = 0
segment_issues_count: int = 0
can_start_issue_management: bool = False
message: str
class SnapshotSegmentsResponse(BaseModel):
snapshot_id: int
segments: List[Dict[str, Any]]
total_count: int
areas: List[str]
drawings: List[str]
class FinalizeCuttingPlanResponse(BaseModel):
success: bool
snapshot_id: Optional[int] = None
snapshot_name: Optional[str] = None
total_segments: int = 0
total_drawings: int = 0
is_locked: bool = False
locked_at: Optional[str] = None
message: str
next_action: Optional[str] = None
@router.post("/finalize-cutting-plan", response_model=FinalizeCuttingPlanResponse)
async def finalize_cutting_plan(
request: FinalizeCuttingPlanRequest,
db: Session = Depends(get_db)
):
"""
Cutting Plan 확정 및 스냅샷 생성
- 현재 단관 데이터를 스냅샷으로 고정
- 이슈 관리 시작 가능 상태로 변경
"""
try:
service = get_pipe_issue_snapshot_service(db)
result = service.create_and_lock_snapshot_on_finalize(
request.job_no,
request.created_by
)
if not result["success"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result["message"]
)
return FinalizeCuttingPlanResponse(**result)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to finalize cutting plan: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Cutting Plan 확정 실패: {str(e)}"
)
@router.get("/status/{job_no}", response_model=SnapshotStatusResponse)
async def get_snapshot_status(
job_no: str,
db: Session = Depends(get_db)
):
"""
작업의 스냅샷 상태 조회
- 스냅샷 존재 여부
- 잠금 상태
- 이슈 관리 가능 여부
"""
try:
service = get_pipe_issue_snapshot_service(db)
result = service.get_snapshot_info(job_no)
return SnapshotStatusResponse(**result)
except Exception as e:
logger.error(f"Failed to get snapshot status: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"스냅샷 상태 조회 실패: {str(e)}"
)
@router.get("/segments/{snapshot_id}", response_model=SnapshotSegmentsResponse)
async def get_snapshot_segments(
snapshot_id: int,
area: Optional[str] = None,
drawing_name: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
스냅샷된 단관 정보 조회
- 구역별/도면별 필터링 가능
- 이슈 관리 페이지에서 사용
"""
try:
service = get_pipe_issue_snapshot_service(db)
# 단관 데이터 조회
segments = service.get_snapshot_segments(snapshot_id, area, drawing_name)
# 사용 가능한 구역/도면 목록
areas = service.get_available_areas(snapshot_id)
drawings = service.get_available_drawings(snapshot_id, area)
return SnapshotSegmentsResponse(
snapshot_id=snapshot_id,
segments=segments,
total_count=len(segments),
areas=areas,
drawings=drawings
)
except Exception as e:
logger.error(f"Failed to get snapshot segments: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"스냅샷 단관 조회 실패: {str(e)}"
)
@router.get("/areas/{snapshot_id}")
async def get_available_areas(
snapshot_id: int,
db: Session = Depends(get_db)
):
"""스냅샷의 사용 가능한 구역 목록 조회"""
try:
service = get_pipe_issue_snapshot_service(db)
areas = service.get_available_areas(snapshot_id)
return {
"snapshot_id": snapshot_id,
"areas": areas,
"total_count": len(areas)
}
except Exception as e:
logger.error(f"Failed to get available areas: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"구역 목록 조회 실패: {str(e)}"
)
@router.get("/drawings/{snapshot_id}")
async def get_available_drawings(
snapshot_id: int,
area: Optional[str] = None,
db: Session = Depends(get_db)
):
"""스냅샷의 사용 가능한 도면 목록 조회"""
try:
service = get_pipe_issue_snapshot_service(db)
drawings = service.get_available_drawings(snapshot_id, area)
return {
"snapshot_id": snapshot_id,
"area": area,
"drawings": drawings,
"total_count": len(drawings)
}
except Exception as e:
logger.error(f"Failed to get available drawings: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"도면 목록 조회 실패: {str(e)}"
)
@router.get("/revision-protection/{job_no}")
async def check_revision_protection(
job_no: str,
db: Session = Depends(get_db)
):
"""
리비전 보호 상태 확인
- 잠긴 스냅샷이 있으면 리비전 영향 받지 않음
"""
try:
service = get_pipe_issue_snapshot_service(db)
result = service.check_revision_protection(job_no)
return result
except Exception as e:
logger.error(f"Failed to check revision protection: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"리비전 보호 상태 확인 실패: {str(e)}"
)
@router.delete("/snapshot/{snapshot_id}")
async def delete_snapshot(
snapshot_id: int,
force: bool = False,
db: Session = Depends(get_db)
):
"""
스냅샷 삭제 (개발/테스트용)
- force=True: 강제 삭제 (이슈 데이터 포함)
- force=False: 이슈가 없는 경우만 삭제
"""
try:
from ..models import PipeIssueSnapshot, PipeDrawingIssue, PipeSegmentIssue
# 스냅샷 존재 확인
snapshot = db.query(PipeIssueSnapshot).filter(
PipeIssueSnapshot.id == snapshot_id
).first()
if not snapshot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="스냅샷을 찾을 수 없습니다."
)
# 이슈 데이터 확인
drawing_issues_count = db.query(PipeDrawingIssue).filter(
PipeDrawingIssue.snapshot_id == snapshot_id
).count()
segment_issues_count = db.query(PipeSegmentIssue).filter(
PipeSegmentIssue.snapshot_id == snapshot_id
).count()
total_issues = drawing_issues_count + segment_issues_count
if total_issues > 0 and not force:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"이슈 데이터가 {total_issues}개 있습니다. force=true로 강제 삭제하거나 이슈를 먼저 정리해주세요."
)
# 스냅샷 삭제 (CASCADE로 관련 데이터 자동 삭제)
db.delete(snapshot)
db.commit()
return {
"success": True,
"message": f"스냅샷 '{snapshot.snapshot_name}'이 삭제되었습니다.",
"deleted_snapshot_id": snapshot_id,
"deleted_issues_count": total_issues
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Failed to delete snapshot: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"스냅샷 삭제 실패: {str(e)}"
)
@router.get("/job/{job_no}/history")
async def get_snapshot_history(
job_no: str,
db: Session = Depends(get_db)
):
"""작업별 스냅샷 이력 조회"""
try:
from ..models import PipeIssueSnapshot
snapshots = db.query(PipeIssueSnapshot).filter(
PipeIssueSnapshot.job_no == job_no
).order_by(PipeIssueSnapshot.created_at.desc()).all()
history = []
for snapshot in snapshots:
history.append({
"snapshot_id": snapshot.id,
"snapshot_name": snapshot.snapshot_name,
"is_active": snapshot.is_active,
"is_locked": snapshot.is_locked,
"created_at": snapshot.created_at.isoformat() if snapshot.created_at else None,
"created_by": snapshot.created_by,
"locked_at": snapshot.locked_at.isoformat() if snapshot.locked_at else None,
"locked_by": snapshot.locked_by,
"total_segments": snapshot.total_segments,
"total_drawings": snapshot.total_drawings
})
return {
"job_no": job_no,
"total_snapshots": len(history),
"history": history
}
except Exception as e:
logger.error(f"Failed to get snapshot history: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"스냅샷 이력 조회 실패: {str(e)}"
)

View File

@@ -0,0 +1,97 @@
"""
리비전 비교 API 엔드포인트
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
from ..database import get_db
from ..auth.middleware import get_current_user
from ..services.revision_comparison_service import RevisionComparisonService
from ..auth.models import User
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/revision-comparison", tags=["Revision Comparison"])
@router.post("/compare")
async def compare_revisions(
current_file_id: int = Query(..., description="현재 파일 ID"),
previous_file_id: int = Query(..., description="이전 파일 ID"),
category_filter: Optional[str] = Query(None, description="카테고리 필터"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
두 리비전 간 자재 비교
"""
try:
comparison_service = RevisionComparisonService(db)
comparison_result = comparison_service.compare_revisions(
current_file_id=current_file_id,
previous_file_id=previous_file_id,
category_filter=category_filter
)
logger.info(f"Revision comparison completed: {current_file_id} vs {previous_file_id}")
return {
"success": True,
"data": comparison_result,
"message": "리비전 비교 완료"
}
except Exception as e:
logger.error(f"Failed to compare revisions: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"리비전 비교 중 오류가 발생했습니다: {str(e)}"
)
@router.get("/category/{current_file_id}/{previous_file_id}/{category}")
async def get_category_comparison(
current_file_id: int,
previous_file_id: int,
category: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
특정 카테고리의 리비전 비교
"""
if category == 'PIPE':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="PIPE 카테고리는 별도 처리가 필요합니다."
)
try:
comparison_service = RevisionComparisonService(db)
comparison_result = comparison_service.get_category_comparison(
current_file_id=current_file_id,
previous_file_id=previous_file_id,
category=category
)
logger.info(f"Category comparison completed: {category} ({current_file_id} vs {previous_file_id})")
return {
"success": True,
"data": comparison_result,
"message": f"{category} 카테고리 비교 완료"
}
except Exception as e:
logger.error(f"Failed to compare category {category}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"카테고리 비교 중 오류가 발생했습니다: {str(e)}"
)

View File

@@ -0,0 +1,199 @@
"""
리비전 자재 처리 API 엔드포인트
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Dict, Any
from pydantic import BaseModel
from ..database import get_db
from ..auth.middleware import get_current_user
from ..services.revision_material_service import RevisionMaterialService
from ..auth.models import User
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/revision-material", tags=["Revision Material"])
class ProcessingResultRequest(BaseModel):
processing_results: List[Dict[str, Any]]
class MaterialProcessRequest(BaseModel):
action: str
additional_data: Dict[str, Any] = {}
@router.get("/category/{file_id}/{category}")
async def get_category_materials(
file_id: int,
category: str,
include_processing_info: bool = True,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
리비전 페이지용 카테고리별 자재 조회
"""
if category == 'PIPE':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="PIPE 카테고리는 별도 처리가 필요합니다."
)
try:
material_service = RevisionMaterialService(db)
materials = material_service.get_category_materials_for_revision(
file_id=file_id,
category=category,
include_processing_info=include_processing_info
)
# 처리 정보 요약
processing_info = {
"total_materials": len(materials),
"by_status": {},
"by_priority": {"high": 0, "medium": 0, "low": 0}
}
for material in materials:
proc_info = material.get('processing_info', {})
status = proc_info.get('display_status', 'UNKNOWN')
priority = proc_info.get('priority', 'medium')
processing_info["by_status"][status] = processing_info["by_status"].get(status, 0) + 1
processing_info["by_priority"][priority] += 1
logger.info(f"Retrieved {len(materials)} materials for category {category} in file {file_id}")
return {
"success": True,
"data": {
"materials": materials,
"processing_info": processing_info
},
"message": f"{category} 카테고리 자재 조회 완료"
}
except Exception as e:
logger.error(f"Failed to get category materials: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"카테고리 자재 조회 중 오류가 발생했습니다: {str(e)}"
)
@router.post("/process/{material_id}")
async def process_material(
material_id: int,
request: MaterialProcessRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
개별 자재 처리
"""
try:
material_service = RevisionMaterialService(db)
# 자재 정보 조회
material_query = """
SELECT * FROM materials WHERE id = :material_id
"""
result = material_service.db_service.execute_query(
material_query, {"material_id": material_id}
)
material = result.fetchone()
if not material:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="자재를 찾을 수 없습니다."
)
material_dict = dict(material._mapping)
# 액션에 따른 처리
if request.action == "new_material":
processing_result = material_service.process_new_material(
material_dict,
material_dict.get('classified_category', 'UNKNOWN')
)
elif request.action == "remove_material":
processing_result = material_service.process_removed_material(
material_dict,
material_dict.get('classified_category', 'UNKNOWN')
)
else:
# 기본 처리 (구매 상태별)
# 이전 자재 정보가 필요한 경우 additional_data에서 가져옴
prev_material = request.additional_data.get('previous_material', material_dict)
processing_result = material_service.process_material_by_purchase_status(
prev_material,
material_dict,
material_dict.get('classified_category', 'UNKNOWN')
)
# 처리 결과 적용
apply_result = material_service.apply_material_processing_results([processing_result])
logger.info(f"Processed material {material_id} with action {request.action}")
return {
"success": True,
"data": {
"processing_result": processing_result,
"apply_result": apply_result
},
"message": "자재 처리 완료"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to process material {material_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"자재 처리 중 오류가 발생했습니다: {str(e)}"
)
@router.post("/apply-results")
async def apply_processing_results(
request: ProcessingResultRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
자재 처리 결과를 일괄 적용
"""
try:
material_service = RevisionMaterialService(db)
apply_result = material_service.apply_material_processing_results(
request.processing_results
)
logger.info(f"Applied {len(request.processing_results)} processing results")
return {
"success": True,
"data": apply_result,
"message": "처리 결과 적용 완료"
}
except Exception as e:
logger.error(f"Failed to apply processing results: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"처리 결과 적용 중 오류가 발생했습니다: {str(e)}"
)

View File

@@ -0,0 +1,130 @@
"""
리비전 리다이렉트 API 엔드포인트
BOM 페이지 접근 시 리비전 페이지로 리다이렉트 필요성 판단
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from ..database import get_db
from ..auth.middleware import get_current_user
from ..services.revision_logic_service import RevisionLogicService
from ..auth.models import User
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/revision-redirect", tags=["Revision Redirect"])
@router.get("/check/{job_no}/{file_id}")
async def check_revision_redirect(
job_no: str,
file_id: int,
previous_file_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
리비전 페이지 리다이렉트 필요성 확인
Args:
job_no: 작업 번호
file_id: 현재 파일 ID
previous_file_id: 이전 파일 ID (선택사항)
Returns:
{
"should_redirect": bool,
"reason": str,
"redirect_url": str,
"processing_summary": dict
}
"""
try:
revision_service = RevisionLogicService(db)
# 리다이렉트 필요성 판단
should_redirect, reason = revision_service.should_redirect_to_revision_page(
job_no=job_no,
current_file_id=file_id,
previous_file_id=previous_file_id
)
result = {
"should_redirect": should_redirect,
"reason": reason,
"redirect_url": f"/enhanced-revision?job_no={job_no}&current_file_id={file_id}",
"processing_summary": None
}
# 상세 처리 결과도 함께 반환 (필요시)
if should_redirect:
processing_result = revision_service.process_revision_by_purchase_status(
job_no=job_no,
current_file_id=file_id,
previous_file_id=previous_file_id
)
result["processing_summary"] = processing_result["summary"]
logger.info(f"Revision redirect check for {job_no}/{file_id}: {should_redirect}")
return {
"success": True,
"data": result,
"message": "리비전 리다이렉트 확인 완료"
}
except Exception as e:
logger.error(f"Failed to check revision redirect: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"리비전 리다이렉트 확인 중 오류가 발생했습니다: {str(e)}"
)
@router.post("/process-revision/{job_no}/{file_id}")
async def process_revision_logic(
job_no: str,
file_id: int,
previous_file_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
리비전 로직 처리 및 결과 반환
Args:
job_no: 작업 번호
file_id: 현재 파일 ID
previous_file_id: 이전 파일 ID (선택사항)
Returns:
전체 리비전 처리 결과
"""
try:
revision_service = RevisionLogicService(db)
processing_result = revision_service.process_revision_by_purchase_status(
job_no=job_no,
current_file_id=file_id,
previous_file_id=previous_file_id
)
logger.info(f"Revision processing completed for {job_no}/{file_id}")
return {
"success": True,
"data": processing_result,
"message": "리비전 처리 완료"
}
except Exception as e:
logger.error(f"Failed to process revision logic: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"리비전 처리 중 오류가 발생했습니다: {str(e)}"
)

View File

@@ -0,0 +1,258 @@
"""
리비전 상태 관리 API 엔드포인트
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
from pydantic import BaseModel
from ..database import get_db
from ..auth.middleware import get_current_user
from ..services.revision_status_service import RevisionStatusService
from ..auth.models import User
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/revision-status", tags=["Revision Status"])
class CreateComparisonRequest(BaseModel):
job_no: str
current_file_id: int
previous_file_id: int
comparison_result: dict
class RejectComparisonRequest(BaseModel):
reason: str = ""
@router.get("/{job_no}/{file_id}")
async def get_revision_status(
job_no: str,
file_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
리비전 상태 조회
"""
try:
status_service = RevisionStatusService(db)
revision_status = status_service.get_revision_status(job_no, file_id)
if "error" in revision_status:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=revision_status["error"]
)
logger.info(f"Retrieved revision status for {job_no}/{file_id}")
return {
"success": True,
"data": revision_status,
"message": "리비전 상태 조회 완료"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get revision status: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"리비전 상태 조회 중 오류가 발생했습니다: {str(e)}"
)
@router.get("/history/{job_no}")
async def get_revision_history(
job_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
작업의 전체 리비전 히스토리 조회
"""
try:
status_service = RevisionStatusService(db)
history = status_service.get_revision_history(job_no)
logger.info(f"Retrieved revision history for {job_no}: {len(history)} revisions")
return {
"success": True,
"data": history,
"message": "리비전 히스토리 조회 완료"
}
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.post("/create-comparison")
async def create_comparison_record(
request: CreateComparisonRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
리비전 비교 기록 생성
"""
try:
status_service = RevisionStatusService(db)
comparison_id = status_service.create_revision_comparison_record(
job_no=request.job_no,
current_file_id=request.current_file_id,
previous_file_id=request.previous_file_id,
comparison_result=request.comparison_result,
created_by=current_user.username
)
logger.info(f"Created revision comparison record: {comparison_id}")
return {
"success": True,
"data": {"comparison_id": comparison_id},
"message": "리비전 비교 기록 생성 완료"
}
except Exception as e:
logger.error(f"Failed to create comparison record: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"비교 기록 생성 중 오류가 발생했습니다: {str(e)}"
)
@router.post("/apply-comparison/{comparison_id}")
async def apply_comparison(
comparison_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
리비전 비교 결과 적용
"""
try:
status_service = RevisionStatusService(db)
apply_result = status_service.apply_revision_comparison(
comparison_id=comparison_id,
applied_by=current_user.username
)
if not apply_result["success"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=apply_result["error"]
)
logger.info(f"Applied revision comparison: {comparison_id}")
return {
"success": True,
"data": apply_result,
"message": "리비전 비교 결과 적용 완료"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to apply comparison {comparison_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"비교 결과 적용 중 오류가 발생했습니다: {str(e)}"
)
@router.get("/pending")
async def get_pending_revisions(
job_no: Optional[str] = Query(None, description="특정 작업의 대기 중인 리비전만 조회"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
대기 중인 리비전 목록 조회
"""
try:
status_service = RevisionStatusService(db)
pending_revisions = status_service.get_pending_revisions(job_no)
logger.info(f"Retrieved {len(pending_revisions)} pending revisions")
return {
"success": True,
"data": pending_revisions,
"message": "대기 중인 리비전 조회 완료"
}
except Exception as e:
logger.error(f"Failed to get pending revisions: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"대기 중인 리비전 조회 중 오류가 발생했습니다: {str(e)}"
)
@router.post("/reject-comparison/{comparison_id}")
async def reject_comparison(
comparison_id: int,
request: RejectComparisonRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
리비전 비교 결과 거부
"""
try:
# 비교 기록을 거부 상태로 업데이트
update_query = """
UPDATE revision_comparisons
SET is_applied = false,
notes = CONCAT(COALESCE(notes, ''), '\n거부됨: ', :reason, ' (by ', :rejected_by, ')')
WHERE id = :comparison_id
"""
from ..services.database_service import DatabaseService
db_service = DatabaseService(db)
db_service.execute_query(update_query, {
"comparison_id": comparison_id,
"reason": request.reason or "사유 없음",
"rejected_by": current_user.username
})
db.commit()
logger.info(f"Rejected revision comparison: {comparison_id}")
return {
"success": True,
"data": {"comparison_id": comparison_id, "reason": request.reason},
"message": "리비전 비교 결과 거부 완료"
}
except Exception as e:
logger.error(f"Failed to reject comparison {comparison_id}: {e}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"비교 결과 거부 중 오류가 발생했습니다: {str(e)}"
)