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 앞으로 스키마 문제가 발생하면 위 명령 하나로 자동 해결!
536 lines
18 KiB
Python
536 lines
18 KiB
Python
"""
|
|
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)}"
|
|
)
|