guardia-itsm/routers/assign.py
DESKTOP-TKLFCPRython 64c27c3509 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

211 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Engineer assignment router — skill matching + workload balancing.
알고리즘:
score = +3 (SR 유형 스킬 일치)
+ +2 (담당 기관 친화도 일치)
+ -1 × 현재 활성 SR 수 (워크로드 페널티)
가장 높은 점수의 엔지니어를 배정. max_workload 초과 시 제외.
"""
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
AuditLog, EngineerProfile, Institution, SRRequest, SRStatus,
User, UserRole, compute_log_hash,
)
router = APIRouter(prefix="/api/assign", tags=["assign"])
# 워크로드 계산에 포함할 활성 상태
_ACTIVE = [
SRStatus.RECEIVED.value,
SRStatus.PARSED.value,
SRStatus.PENDING_APPROVAL.value,
SRStatus.APPROVED.value,
SRStatus.IN_PROGRESS.value,
SRStatus.PENDING_PM_VALIDATION.value,
]
# ── Internal helpers ───────────────────────────────────────────────────────────
async def _write_audit(db: AsyncSession, sr_id: str,
actor: str, action: str, detail: str) -> None:
result = await db.execute(
select(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 auto_assign_engine(db: AsyncSession, sr: SRRequest) -> Optional[str]:
"""
스킬 매칭 + 워크로드 밸런싱 자동 배정 엔진.
배정된 엔지니어 username 반환. 적합한 엔지니어 없으면 None.
"""
res = await db.execute(
select(EngineerProfile).where(EngineerProfile.is_available == True)
)
profiles = res.scalars().all()
if not profiles:
return None
# 기관 코드 조회 (친화도 매칭용)
inst_code = None
if sr.inst_id:
r = await db.execute(select(Institution).where(Institution.id == sr.inst_id))
inst = r.scalars().first()
if inst:
inst_code = inst.inst_code
scored = []
for p in profiles:
wl = await db.execute(
select(func.count(SRRequest.id)).where(
SRRequest.assigned_to == p.username,
SRRequest.status.in_(_ACTIVE),
)
)
workload = wl.scalar() or 0
if workload >= p.max_workload:
continue # 포화 상태
score = 0
skills = [s.strip() for s in (p.skill_types or "").split(",") if s.strip()]
affinities = [a.strip() for a in (p.inst_affinity or "").split(",") if a.strip()]
if sr.sr_type in skills:
score += 3
if inst_code and inst_code in affinities:
score += 2
score -= workload # 워크로드 페널티
scored.append((score, workload, p.username))
if not scored:
return None
# 점수 내림차순, 워크로드 오름차순 정렬
scored.sort(key=lambda x: (-x[0], x[1]))
return scored[0][2]
# ── Endpoints ──────────────────────────────────────────────────────────────────
@router.get("/workload")
async def get_workload(
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""엔지니어별 현재 워크로드 현황."""
res = await db.execute(select(EngineerProfile))
profiles = res.scalars().all()
result = []
for p in profiles:
active_cnt = (await db.execute(
select(func.count(SRRequest.id)).where(
SRRequest.assigned_to == p.username,
SRRequest.status.in_(_ACTIVE),
)
)).scalar() or 0
done_cnt = (await db.execute(
select(func.count(SRRequest.id)).where(
SRRequest.assigned_to == p.username,
SRRequest.status == SRStatus.COMPLETED.value,
)
)).scalar() or 0
result.append({
"username": p.username,
"display_name": p.display_name or p.username,
"skill_types": p.skill_types or "",
"inst_affinity": p.inst_affinity or "",
"active": active_cnt,
"max_workload": p.max_workload,
"completed": done_cnt,
"is_available": p.is_available,
"utilization": round(active_cnt / p.max_workload * 100) if p.max_workload else 0,
})
return result
@router.get("/engineers")
async def list_engineers(
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""수동 배정용 엔지니어 목록."""
res = await db.execute(
select(EngineerProfile).where(EngineerProfile.is_available == True)
)
return [
{"username": p.username, "display_name": p.display_name or p.username}
for p in res.scalars().all()
]
@router.post("/{sr_id}")
async def assign_engineer(
sr_id: str,
engineer: Optional[str] = None, # None → 자동 배정
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
SR에 엔지니어 배정 또는 재배정.
- engineer 파라미터 없음 → 스킬 매칭 자동 배정
- engineer 지정 → 수동 배정 (ADMIN/PM만 가능)
"""
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
raise HTTPException(403, "배정 권한이 없습니다.")
r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
sr = r.scalars().first()
if not sr:
raise HTTPException(404, "SR을 찾을 수 없습니다.")
if engineer:
# 수동 배정 — ADMIN/PM만 허용
if current_user.role == UserRole.ENGINEER:
raise HTTPException(403, "수동 배정은 PM 또는 관리자만 가능합니다.")
ue = await db.execute(select(User).where(User.username == engineer))
u = ue.scalars().first()
if not u or u.role != UserRole.ENGINEER:
raise HTTPException(400, f"'{engineer}'는 엔지니어 사용자가 아닙니다.")
assigned = engineer
else:
assigned = await auto_assign_engine(db, sr)
if not assigned:
raise HTTPException(503, "현재 배정 가능한 엔지니어가 없습니다.")
prev = sr.assigned_to or "미배정"
actor = current_user.display_name or current_user.username
sr.assigned_to = assigned
sr.updated_at = datetime.now()
detail = f"엔지니어 배정: {prev}{assigned}"
await _write_audit(db, sr_id, actor, "ENGINEER_ASSIGNED", detail)
await db.commit()
await db.refresh(sr)
return {
"sr_id": sr_id,
"assigned_to": assigned,
"prev": prev,
"detail": detail,
}