- tasks.py: 빈 sr_ids 목록도 HTTP 400 반환 (이전: 200) - messenger.py: /sr 봇명령 _cmd_create_sr에서 db 파라미터 제거 (BackgroundTask 호환) - main.py: Windows cp949 인코딩 오류 제거 (em dash → ASCII 하이픈) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
502 lines
19 KiB
Python
502 lines
19 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
|
|
)
|
|
|
|
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,
|
|
}
|
|
|