"""SR / Task CRUD + status transition endpoints.""" from datetime import datetime from typing import Any, Dict, List, Optional from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from core.events import broadcast from database import get_db from models import ( AuditLog, Institution, SRCreate, SROut, SRRequest, SRStatus, SRStatusUpdate, SRType, User, compute_log_hash ) router = APIRouter(prefix="/api/tasks", tags=["tasks"]) # Valid state transitions _TRANSITIONS: dict[str, list[str]] = { SRStatus.RECEIVED: [SRStatus.PARSED, SRStatus.REJECTED], SRStatus.PARSED: [SRStatus.PENDING_APPROVAL, SRStatus.REJECTED], SRStatus.PENDING_APPROVAL: [SRStatus.APPROVED, SRStatus.REJECTED], SRStatus.APPROVED: [SRStatus.IN_PROGRESS, SRStatus.REJECTED], SRStatus.IN_PROGRESS: [SRStatus.PENDING_PM_VALIDATION, SRStatus.FAILED_ROLLBACK], SRStatus.PENDING_PM_VALIDATION:[SRStatus.COMPLETED, SRStatus.FAILED_ROLLBACK], SRStatus.COMPLETED: [], SRStatus.FAILED_ROLLBACK: [], SRStatus.REJECTED: [], } def _new_sr_id() -> str: return f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}" async def _write_audit(db: AsyncSession, sr_id: str, actor: str, action: str, detail: str) -> None: from sqlalchemy import select as sel result = await db.execute( sel(AuditLog).where(AuditLog.sr_id == sr_id) .order_by(AuditLog.id.desc()).limit(1) ) last = result.scalars().first() prev_hash = last.log_hash if last else None ts = datetime.now().isoformat() log_hash = compute_log_hash(prev_hash, actor, action, detail, ts) db.add(AuditLog( sr_id=sr_id, actor=actor, action=action, detail=detail, prev_hash=prev_hash, log_hash=log_hash )) async def _apply_role_filter(q, current_user: User, db: AsyncSession): """CUSTOMER 역할이면 자신의 기관 SR만 조회되도록 필터링.""" from models import UserRole if current_user.role == UserRole.CUSTOMER and current_user.inst_code: inst_r = await db.execute( select(Institution).where(Institution.inst_code == current_user.inst_code) ) inst = inst_r.scalars().first() if inst: q = q.where(SRRequest.inst_id == inst.id) else: # 기관 정보 없으면 빈 결과 보장 q = q.where(SRRequest.id == -1) return q @router.get("", response_model=List[SROut]) async def list_tasks( status: Optional[str] = Query(None), sr_type: Optional[str] = Query(None), priority: Optional[str]= Query(None), keyword: Optional[str] = Query(None), skip: int = 0, limit: int = 50, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): q = select(SRRequest).order_by(SRRequest.created_at.desc()) q = await _apply_role_filter(q, current_user, db) if status: q = q.where(SRRequest.status == status) if sr_type: q = q.where(SRRequest.sr_type == sr_type) if priority: q = q.where(SRRequest.priority == priority) if keyword: q = q.where(SRRequest.title.contains(keyword)) q = q.offset(skip).limit(limit) result = await db.execute(q) return result.scalars().all() @router.get("/stats") async def get_stats(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): # 기관 필터 (CUSTOMER) base_q = select(func.count(SRRequest.id)) filtered = await _apply_role_filter(select(SRRequest), current_user, db) # subquery approach: get allowed sr ids from sqlalchemy import and_ inst_filter = None from models import UserRole if current_user.role == UserRole.CUSTOMER and current_user.inst_code: inst_r = await db.execute( select(Institution).where(Institution.inst_code == current_user.inst_code) ) inst = inst_r.scalars().first() inst_filter = (SRRequest.inst_id == inst.id) if inst else (SRRequest.id == -1) def _cnt(extra=None): q = base_q if inst_filter is not None: q = q.where(inst_filter) if extra is not None: q = q.where(extra) return q total = (await db.execute(_cnt())).scalar() or 0 by_status: dict[str, int] = {} for s in SRStatus: cnt = (await db.execute(_cnt(SRRequest.status == s))).scalar() or 0 if cnt: by_status[s.value] = cnt by_type: dict[str, int] = {} for t in SRType: cnt = (await db.execute(_cnt(SRRequest.sr_type == t))).scalar() or 0 if cnt: by_type[t.value] = cnt return {"total": total, "by_status": by_status, "by_type": by_type} @router.get("/{sr_id}", response_model=SROut) async def get_task(sr_id: str, db: AsyncSession = Depends(get_db)): result = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id)) sr = result.scalars().first() if not sr: raise HTTPException(404, detail="SR을 찾을 수 없습니다.") return sr @router.post("", response_model=SROut, status_code=201) async def create_task(payload: SRCreate, db: AsyncSession = Depends(get_db)): inst_id = None if payload.inst_code: r = await db.execute( select(Institution).where(Institution.inst_code == payload.inst_code) ) inst = r.scalars().first() if inst: inst_id = inst.id sr = SRRequest( sr_id=_new_sr_id(), inst_id=inst_id, sr_type=payload.sr_type, title=payload.title, description=payload.description, status=SRStatus.RECEIVED, priority=payload.priority, requested_by=payload.requested_by, assigned_to=payload.assigned_to, target_server=payload.target_server, ) db.add(sr) await db.flush() await _write_audit(db, sr.sr_id, payload.requested_by, "SR_CREATED", f"SR 생성: {payload.title}") # 담당자가 미지정이면 자동 배정 시도 if not sr.assigned_to: from routers.assign import auto_assign_engine assigned = await auto_assign_engine(db, sr) if assigned: sr.assigned_to = assigned await _write_audit(db, sr.sr_id, "SYSTEM", "ENGINEER_ASSIGNED", f"자동 배정: {assigned}") # A-2: SLA 마감 시각 계산·저장 from core.sla import set_sla_on_create await set_sla_on_create(sr.sr_id, db) await db.refresh(sr) await db.commit() await db.refresh(sr) # 실시간 이벤트 브로드캐스트 await broadcast("sr_created", { "sr_id": sr.sr_id, "title": sr.title, "sr_type": sr.sr_type, "priority": sr.priority, "status": sr.status, "sla_deadline": sr.sla_deadline.isoformat() if sr.sla_deadline else None, }) # 알림 발송 (이메일 + 메신저) — 비동기 fire-and-forget import asyncio as _asyncio from core.notify import notify_sr_created as _notify_created _asyncio.create_task(_notify_created(sr)) # 학습 루프: 재발 패턴 감지 — fire-and-forget async def _detect_recurrence_bg(): from database import SessionLocal from core.learning import detect_recurrence try: async with SessionLocal() as _db: result = await detect_recurrence( db = _db, sr_id = sr.sr_id, title = sr.title, description = sr.description or "", sr_type = sr.sr_type or "OTHER", inst_id = sr.inst_id, ) if result.get("recurrence_found"): import logging logging.getLogger(__name__).info( "재발 감지: SR=%s 패턴=%d 횟수=%d%s", sr.sr_id, result["pattern_id"], result["occurrence_count"], " → Problem 격상" if result.get("escalated") else "", ) except Exception as _e: import logging logging.getLogger(__name__).debug("재발 감지 오류 (무시): %s", _e) _asyncio.create_task(_detect_recurrence_bg()) # G-7: AI 자동 분류 — fire-and-forget async def _apply_ai_classification_bg(): from database import SessionLocal from core.ticket_classifier import classify_ticket import json as _json try: suggestion = await classify_ticket(sr.title, sr.description or "") async with SessionLocal() as _db: _sr = (await _db.execute( select(SRRequest).where(SRRequest.sr_id == sr.sr_id) )).scalars().first() if _sr: _sr.ai_suggestion = _json.dumps(suggestion, ensure_ascii=False) await _db.commit() except Exception: pass _asyncio.create_task(_apply_ai_classification_bg()) return sr @router.patch("/{sr_id}/status", response_model=SROut) async def update_status(sr_id: str, payload: SRStatusUpdate, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user)): result = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id)) sr = result.scalars().first() if not sr: raise HTTPException(404, detail="SR을 찾을 수 없습니다.") allowed = _TRANSITIONS.get(SRStatus(sr.status), []) if payload.status not in allowed: raise HTTPException(400, detail=f"'{sr.status}' → '{payload.status}' 전이는 허용되지 않습니다.") old_status = sr.status sr.status = payload.status sr.updated_at = datetime.now() detail = payload.comment or f"상태 변경: {old_status} → {payload.status}" await _write_audit(db, sr_id, payload.actor, "STATUS_CHANGED", detail) await db.commit() await db.refresh(sr) # 실시간 이벤트 브로드캐스트 await broadcast("sr_updated", { "sr_id": sr_id, "title": sr.title, "old_status": old_status, "new_status": sr.status, "actor": payload.actor, }) # 알림 발송 (COMPLETED / REJECTED / FAILED_ROLLBACK 시) import asyncio as _asyncio from core.notify import notify_sr_status_changed as _notify_changed _asyncio.create_task(_notify_changed(sr, sr.status, payload.comment or "")) return sr # ── A-2: SLA 조회 엔드포인트 ────────────────────────────────────────────────── @router.get("/{sr_id}/sla") async def get_sla_status( sr_id: str, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """ 특정 SR의 SLA 현황 조회. Returns: { "sr_id": "SR-...", "priority": "HIGH", "sla_deadline": "2026-05-26T14:00:00", "sla_breached": false, "remaining_minutes": 47, "escalated_at": null, "escalated_to": null } """ from core.sla import sla_remaining_minutes result = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id)) sr = result.scalars().first() if not sr: raise HTTPException(404, "SR을 찾을 수 없습니다.") remaining = sla_remaining_minutes(sr.sla_deadline) return { "sr_id": sr.sr_id, "priority": sr.priority, "sla_deadline": sr.sla_deadline.isoformat() if sr.sla_deadline else None, "sla_breached": sr.sla_breached, "remaining_minutes": remaining, "escalated_at": sr.escalated_at.isoformat() if sr.escalated_at else None, "escalated_to": sr.escalated_to, } @router.get("/sla/violations") async def list_sla_violations( skip: int = 0, limit: int = 50, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ SLA 위반 중인 SR 목록. ADMIN / PM / ENGINEER 만 접근 가능. """ from models import UserRole from sqlalchemy import and_ from core.sla import sla_remaining_minutes if current_user.role == UserRole.CUSTOMER: raise HTTPException(403, "권한이 없습니다.") terminal = ["COMPLETED", "REJECTED", "FAILED_ROLLBACK"] now = datetime.now() q = ( select(SRRequest) .where( and_( SRRequest.sla_deadline.isnot(None), SRRequest.sla_deadline < now, SRRequest.status.notin_(terminal), ) ) .order_by(SRRequest.sla_deadline.asc()) .offset(skip).limit(limit) ) rows = (await db.execute(q)).scalars().all() return [ { "sr_id": r.sr_id, "title": r.title, "priority": r.priority, "status": r.status, "assigned_to": r.assigned_to, "sla_deadline": r.sla_deadline.isoformat() if r.sla_deadline else None, "overdue_minutes": abs(sla_remaining_minutes(r.sla_deadline) or 0), "sla_breached": r.sla_breached, "escalated_to": r.escalated_to, } for r in rows ] # ── G-7: AI 분류 결과 조회 ──────────────────────────────────────────────────── @router.get("/{sr_id}/ai-suggestion") async def get_ai_suggestion( sr_id: str, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """SR의 AI 자동 분류 제안 조회.""" import json as _json r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id)) sr = r.scalars().first() if not sr: raise HTTPException(404, "SR을 찾을 수 없습니다.") if not sr.ai_suggestion: # 즉시 분류 요청 from core.ticket_classifier import classify_ticket try: suggestion = await classify_ticket(sr.title, sr.description or "") sr.ai_suggestion = _json.dumps(suggestion, ensure_ascii=False) await db.commit() except Exception as e: return {"sr_id": sr_id, "suggestion": None, "message": f"AI 분류 실패: {str(e)[:100]}"} try: return {"sr_id": sr_id, "suggestion": _json.loads(sr.ai_suggestion)} except Exception: return {"sr_id": sr_id, "suggestion": None} # ── G-2: SR 대량 처리 ───────────────────────────────────────────────────────── class BulkActionRequest(BaseModel): sr_ids: List[str] action: str # STATUS_CHANGE | ASSIGN | CLOSE | PRIORITY_CHANGE params: Dict[str, Any] = {} @router.post("/bulk") async def bulk_sr_action( payload: BulkActionRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ SR 대량 작업 (최대 100건). action: STATUS_CHANGE | ASSIGN | CLOSE | PRIORITY_CHANGE """ from models import UserRole if current_user.role == UserRole.CUSTOMER: raise HTTPException(403, "대량 작업은 ADMIN/PM/ENGINEER만 가능합니다.") if not payload.sr_ids: raise HTTPException(400, "sr_ids가 비어 있습니다. 처리할 SR ID를 입력하세요.") if len(payload.sr_ids) > 100: raise HTTPException(400, "한 번에 최대 100건까지 처리 가능합니다.") results = [] success = 0 for sr_id in payload.sr_ids: try: r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id)) sr = r.scalars().first() if not sr: results.append({"sr_id": sr_id, "ok": False, "error": "SR을 찾을 수 없습니다."}) continue action = payload.action.upper() if action == "STATUS_CHANGE": new_status = payload.params.get("status") if not new_status: raise ValueError("status 파라미터 필요") allowed = _TRANSITIONS.get(SRStatus(sr.status), []) if new_status not in allowed: raise ValueError(f"'{sr.status}' → '{new_status}' 전이 불가") sr.status = new_status sr.updated_at = datetime.now() note = payload.params.get("note", f"대량 상태변경: {new_status}") await _write_audit(db, sr_id, current_user.username, "BULK_STATUS_CHANGE", note) elif action == "ASSIGN": assignee = payload.params.get("assignee") if not assignee: raise ValueError("assignee 파라미터 필요") sr.assigned_to = assignee sr.updated_at = datetime.now() await _write_audit(db, sr_id, current_user.username, "BULK_ASSIGN", f"대량 배정: {assignee}") elif action == "CLOSE": if sr.status in (SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK): raise ValueError(f"이미 종료된 SR: {sr.status}") sr.status = SRStatus.COMPLETED sr.updated_at = datetime.now() note = payload.params.get("note", "대량 완료 처리") await _write_audit(db, sr_id, current_user.username, "BULK_CLOSE", note) elif action == "PRIORITY_CHANGE": new_prio = payload.params.get("priority") if not new_prio: raise ValueError("priority 파라미터 필요") sr.priority = new_prio sr.updated_at = datetime.now() await _write_audit(db, sr_id, current_user.username, "BULK_PRIORITY_CHANGE", f"우선순위 변경: {new_prio}") else: raise ValueError(f"알 수 없는 action: {action}") await db.flush() results.append({"sr_id": sr_id, "ok": True, "error": None}) success += 1 except Exception as e: results.append({"sr_id": sr_id, "ok": False, "error": str(e)}) await db.commit() return { "total": len(payload.sr_ids), "success": success, "failed": len(payload.sr_ids) - success, "results": results, }