guardia-itsm/routers/tasks.py
2026-06-07 04:13:36 +09:00

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