809 lines
29 KiB
Python
809 lines
29 KiB
Python
"""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,
|
|
SRSubscription, SRRating, SRSignature, SRCheckin,
|
|
)
|
|
|
|
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())
|
|
|
|
# SR 자동 리뷰 — fire-and-forget
|
|
# 분류 검증 → 관련 서버 tmux 스냅샷 → Ollama AI 리뷰 → TB_SR_AUTO_REVIEW 저장
|
|
async def _sr_auto_review_bg():
|
|
from routers.sr_auto_review import run_sr_review
|
|
await run_sr_review(sr.sr_id)
|
|
|
|
_asyncio.create_task(_sr_auto_review_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,
|
|
}
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|
# ── 모바일 100기능: SR 액션 + 통계 ──────────────────────────────────────────────
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|
|
|
class EscalateRequest(BaseModel):
|
|
reason: Optional[str] = None
|
|
escalate_to: Optional[str] = None # 미지정 시 온콜 에스컬레이션 체인 사용
|
|
|
|
|
|
class RatingRequest(BaseModel):
|
|
score: int
|
|
comment: Optional[str] = None
|
|
|
|
|
|
class SignatureRequest(BaseModel):
|
|
signature_base64: str
|
|
|
|
|
|
class SlaExceptionRequest(BaseModel):
|
|
reason: str
|
|
new_deadline: str # ISO datetime/date
|
|
|
|
|
|
class CheckinRequest(BaseModel):
|
|
lat: float
|
|
lng: float
|
|
|
|
|
|
async def _get_sr(sr_id: str, db: AsyncSession) -> SRRequest:
|
|
sr = (await db.execute(
|
|
select(SRRequest).where(SRRequest.sr_id == sr_id)
|
|
)).scalars().first()
|
|
if not sr:
|
|
raise HTTPException(404, detail="SR을 찾을 수 없습니다.")
|
|
return sr
|
|
|
|
|
|
@router.post("/{sr_id}/escalate")
|
|
async def escalate_task(
|
|
sr_id: str,
|
|
payload: EscalateRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""SR 에스컬레이션 — 상위 담당자로 재배정 + 알림 (#8)."""
|
|
from models import UserRole
|
|
if current_user.role == UserRole.CUSTOMER:
|
|
raise HTTPException(403, "에스컬레이션 권한이 없습니다.")
|
|
|
|
sr = await _get_sr(sr_id, db)
|
|
|
|
target = payload.escalate_to
|
|
if not target:
|
|
# 온콜 에스컬레이션 체인에서 대상 도출 시도
|
|
try:
|
|
from core.oncall_rotate import get_current_oncall
|
|
sched = await get_current_oncall(db)
|
|
if sched:
|
|
target = sched.escalation_to or sched.backup_engineer or sched.engineer
|
|
except Exception:
|
|
target = None
|
|
|
|
sr.escalated_at = datetime.now()
|
|
sr.escalated_to = target
|
|
if target:
|
|
sr.assigned_to = target
|
|
sr.updated_at = datetime.now()
|
|
await _write_audit(db, sr_id, current_user.username, "SR_ESCALATED",
|
|
f"에스컬레이션 → {target or '미정'} | 사유: {payload.reason or ''}")
|
|
await db.commit()
|
|
await broadcast("sla_escalated", {
|
|
"sr_id": sr_id, "escalated_to": target, "by": current_user.username,
|
|
})
|
|
return {
|
|
"sr_id": sr_id,
|
|
"escalated_to": target,
|
|
"escalated_at": sr.escalated_at.isoformat(),
|
|
"message": f"'{target or '대상 미정'}'(으)로 에스컬레이션되었습니다.",
|
|
}
|
|
|
|
|
|
@router.post("/{sr_id}/subscribe")
|
|
async def toggle_subscribe(
|
|
sr_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""SR 구독/팔로우 토글 (#14)."""
|
|
await _get_sr(sr_id, db)
|
|
existing = (await db.execute(
|
|
select(SRSubscription).where(
|
|
SRSubscription.task_id == sr_id,
|
|
SRSubscription.username == current_user.username,
|
|
)
|
|
)).scalars().first()
|
|
|
|
if existing:
|
|
await db.delete(existing)
|
|
await db.commit()
|
|
return {"sr_id": sr_id, "subscribed": False, "message": "구독을 해제했습니다."}
|
|
|
|
sub = SRSubscription(task_id=sr_id, username=current_user.username)
|
|
db.add(sub)
|
|
await db.commit()
|
|
return {"sr_id": sr_id, "subscribed": True, "message": "구독했습니다."}
|
|
|
|
|
|
@router.post("/{sr_id}/rating", status_code=201)
|
|
async def rate_task(
|
|
sr_id: str,
|
|
payload: RatingRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""완료 SR 만족도 평가 (#53)."""
|
|
if not (1 <= payload.score <= 5):
|
|
raise HTTPException(422, "score는 1~5 범위여야 합니다.")
|
|
sr = await _get_sr(sr_id, db)
|
|
if sr.status != SRStatus.COMPLETED:
|
|
raise HTTPException(400, "완료된 SR만 평가할 수 있습니다.")
|
|
|
|
rating = SRRating(
|
|
task_id=sr_id, rater=current_user.username,
|
|
score=payload.score, comment=payload.comment,
|
|
)
|
|
db.add(rating)
|
|
await _write_audit(db, sr_id, current_user.username, "SR_RATED",
|
|
f"만족도 {payload.score}점")
|
|
await db.commit()
|
|
await db.refresh(rating)
|
|
return {"sr_id": sr_id, "score": payload.score, "rating_id": rating.id}
|
|
|
|
|
|
@router.post("/{sr_id}/signature", status_code=201)
|
|
async def save_signature(
|
|
sr_id: str,
|
|
payload: SignatureRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""현장 전자서명 저장 (#70)."""
|
|
if not payload.signature_base64 or len(payload.signature_base64) < 10:
|
|
raise HTTPException(422, "signature_base64 데이터가 올바르지 않습니다.")
|
|
await _get_sr(sr_id, db)
|
|
|
|
sig = SRSignature(
|
|
task_id=sr_id, signed_by=current_user.username,
|
|
signature_b64=payload.signature_base64,
|
|
)
|
|
db.add(sig)
|
|
await _write_audit(db, sr_id, current_user.username, "SR_SIGNED", "현장 서명 등록")
|
|
await db.commit()
|
|
await db.refresh(sig)
|
|
return {"sr_id": sr_id, "signature_id": sig.id, "signed_by": current_user.username}
|
|
|
|
|
|
@router.post("/{sr_id}/sla-exception")
|
|
async def request_sla_exception(
|
|
sr_id: str,
|
|
payload: SlaExceptionRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""SLA 예외 승인 요청 — SLA 마감 연장 요청 (#94)."""
|
|
sr = await _get_sr(sr_id, db)
|
|
try:
|
|
new_dl = datetime.fromisoformat(payload.new_deadline)
|
|
except ValueError:
|
|
raise HTTPException(422, "new_deadline은 ISO 날짜/시간 형식이어야 합니다.")
|
|
|
|
old_dl = sr.sla_deadline
|
|
sr.sla_deadline = new_dl
|
|
sr.sla_breached = False
|
|
sr.updated_at = datetime.now()
|
|
await _write_audit(db, sr_id, current_user.username, "SLA_EXCEPTION_REQUESTED",
|
|
f"SLA 마감 {old_dl} → {new_dl} | 사유: {payload.reason}")
|
|
await db.commit()
|
|
return {
|
|
"sr_id": sr_id,
|
|
"old_deadline": old_dl.isoformat() if old_dl else None,
|
|
"new_deadline": new_dl.isoformat(),
|
|
"reason": payload.reason,
|
|
"message": "SLA 예외(마감 연장)가 적용되었습니다.",
|
|
}
|
|
|
|
|
|
@router.post("/{sr_id}/checkin", status_code=201)
|
|
async def checkin_task(
|
|
sr_id: str,
|
|
payload: CheckinRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""현장 체크인 — GPS 좌표 기록 (#93)."""
|
|
if not (-90 <= payload.lat <= 90) or not (-180 <= payload.lng <= 180):
|
|
raise HTTPException(422, "좌표 값이 올바르지 않습니다.")
|
|
await _get_sr(sr_id, db)
|
|
|
|
chk = SRCheckin(
|
|
task_id=sr_id, username=current_user.username,
|
|
lat=payload.lat, lng=payload.lng,
|
|
)
|
|
db.add(chk)
|
|
await _write_audit(db, sr_id, current_user.username, "SR_CHECKIN",
|
|
f"현장 체크인 ({payload.lat}, {payload.lng})")
|
|
await db.commit()
|
|
await db.refresh(chk)
|
|
return {
|
|
"sr_id": sr_id,
|
|
"checkin_id": chk.id,
|
|
"lat": payload.lat,
|
|
"lng": payload.lng,
|
|
"checked_in_at": chk.created_at.isoformat() if chk.created_at else None,
|
|
}
|
|
|
|
|
|
@router.get("/stats/mine")
|
|
async def my_sr_stats(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""내 SR 처리 통계 — total / closed / avg_resolve_hours / sla_met_rate (#3)."""
|
|
rows = (await db.execute(
|
|
select(SRRequest).where(SRRequest.assigned_to == current_user.username)
|
|
)).scalars().all()
|
|
|
|
total = len(rows)
|
|
terminal = {SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK}
|
|
closed_rows = [r for r in rows if r.status in terminal]
|
|
closed = len(closed_rows)
|
|
|
|
# 평균 해결 시간 (완료 건의 created_at→updated_at)
|
|
completed = [r for r in rows if r.status == SRStatus.COMPLETED
|
|
and r.created_at and r.updated_at]
|
|
avg_hours = 0.0
|
|
if completed:
|
|
secs = sum((r.updated_at - r.created_at).total_seconds() for r in completed)
|
|
avg_hours = round(secs / len(completed) / 3600, 2)
|
|
|
|
# SLA 준수율 (마감 시각이 있는 건 중 미위반 비율)
|
|
sla_rows = [r for r in rows if r.sla_deadline is not None]
|
|
sla_met = len([r for r in sla_rows if not r.sla_breached])
|
|
sla_met_rate = round(sla_met / len(sla_rows) * 100, 1) if sla_rows else 100.0
|
|
|
|
return {
|
|
"username": current_user.username,
|
|
"total": total,
|
|
"closed": closed,
|
|
"open": total - closed,
|
|
"avg_resolve_hours": avg_hours,
|
|
"sla_met_rate": sla_met_rate,
|
|
}
|
|
|
|
|
|
@router.get("/stats/by-institution")
|
|
async def stats_by_institution(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""기관별 SR 현황 비교 (#4). CUSTOMER는 자기 기관만."""
|
|
from models import UserRole
|
|
|
|
q = select(SRRequest)
|
|
cust_inst_id = None
|
|
if current_user.role == UserRole.CUSTOMER and current_user.inst_code:
|
|
inst = (await db.execute(
|
|
select(Institution).where(Institution.inst_code == current_user.inst_code)
|
|
)).scalars().first()
|
|
cust_inst_id = inst.id if inst else -1
|
|
q = q.where(SRRequest.inst_id == cust_inst_id)
|
|
|
|
srs = (await db.execute(q)).scalars().all()
|
|
|
|
# 기관 이름 매핑
|
|
insts = (await db.execute(select(Institution))).scalars().all()
|
|
name_map = {i.id: i.inst_name for i in insts}
|
|
|
|
terminal = {SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK}
|
|
agg: dict = {}
|
|
for s in srs:
|
|
key = s.inst_id
|
|
bucket = agg.setdefault(key, {
|
|
"inst_id": key,
|
|
"inst_name": name_map.get(key, "미지정"),
|
|
"total": 0, "closed": 0, "open": 0, "sla_breached": 0,
|
|
})
|
|
bucket["total"] += 1
|
|
if s.status in terminal:
|
|
bucket["closed"] += 1
|
|
else:
|
|
bucket["open"] += 1
|
|
if s.sla_breached:
|
|
bucket["sla_breached"] += 1
|
|
|
|
result = sorted(agg.values(), key=lambda x: x["total"], reverse=True)
|
|
return {"institutions": result, "count": len(result)}
|
|
|