""" 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, }