211 lines
7.2 KiB
Python
211 lines
7.2 KiB
Python
"""
|
||
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,
|
||
}
|