""" KPI 엔진 — 기관별 핵심 성과 지표 정의·계산·추적 기능: - KPI 정의 (공식, 단위, 목표값, 방향성) - 일별/주별/월별 자동 계산 (APScheduler) - 목표 대비 달성률 및 신호등 상태 (RED/YELLOW/GREEN) - 기존 analytics.py / sla.py 데이터 활용 내장 KPI 템플릿: - MTTR: 평균 복구 시간 (시간 단위, 낮을수록 좋음) - FCR: 첫 번째 해결율 (%, 높을수록 좋음) - SLA_COMPLIANCE: SLA 준수율 (%, 높을수록 좋음) - SR_BACKLOG: SR 적체 건수 (낮을수록 좋음) - DEPLOY_SUCCESS_RATE: 배포 성공률 (%, 높을수록 좋음) 엔드포인트: GET /api/kpi/ — KPI 목록 + 최신 값 POST /api/kpi/ — KPI 정의 생성 GET /api/kpi/{id} — KPI 상세 + 이력 PUT /api/kpi/{id} — KPI 수정 DELETE /api/kpi/{id} — KPI 삭제 POST /api/kpi/{id}/calculate — KPI 수동 재계산 GET /api/kpi/dashboard — 대시보드 요약 (전체 KPI 신호등) GET /api/kpi/templates — 내장 KPI 템플릿 목록 POST /api/kpi/apply-template — 템플릿으로 KPI 일괄 등록 """ from __future__ import annotations import logging from datetime import date, datetime, timedelta from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy import select, func, and_, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from database import get_db from models import ( User, SRRequest, SRStatus, VibeSession, VibeSessionStatus, KPIDefinition, KPIValue, # 신규 모델 ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/kpi", tags=["KPI Engine"]) # ── 내장 KPI 템플릿 ────────────────────────────────────────────────────────── BUILTIN_TEMPLATES = [ { "name": "MTTR", "display_name": "평균 복구 시간 (MTTR)", "description": "인시던트 발생부터 해결까지 평균 소요 시간", "unit": "hours", "direction": "LOWER_BETTER", "default_target": 4.0, "period": "MONTHLY", }, { "name": "FCR", "display_name": "첫 번째 해결율 (FCR)", "description": "첫 번째 시도에서 해결된 SR 비율", "unit": "%", "direction": "HIGHER_BETTER", "default_target": 80.0, "period": "MONTHLY", }, { "name": "SLA_COMPLIANCE", "display_name": "SLA 준수율", "description": "SLA 기한 내 처리된 SR 비율", "unit": "%", "direction": "HIGHER_BETTER", "default_target": 95.0, "period": "MONTHLY", }, { "name": "SR_BACKLOG", "display_name": "SR 적체 건수", "description": "현재 미처리 SR 총 건수", "unit": "건", "direction": "LOWER_BETTER", "default_target": 10.0, "period": "DAILY", }, { "name": "DEPLOY_SUCCESS_RATE", "display_name": "배포 성공률", "description": "전체 배포 중 성공한 배포 비율", "unit": "%", "direction": "HIGHER_BETTER", "default_target": 95.0, "period": "MONTHLY", }, ] # ── Pydantic 스키마 ────────────────────────────────────────────────────────── class KPICreate(BaseModel): name: str = Field(..., max_length=100) display_name: str = Field(..., max_length=200) description: Optional[str] = None unit: str = Field(..., max_length=20) direction: str = Field(..., pattern="^(HIGHER_BETTER|LOWER_BETTER)$") target: float period: str = Field("MONTHLY", pattern="^(DAILY|WEEKLY|MONTHLY)$") class KPIOut(BaseModel): id: int name: str display_name: str unit: str direction: str target: float period: str current_value: Optional[float] status: str # GREEN / YELLOW / RED / NO_DATA achievement_pct: Optional[float] last_calculated_at: Optional[datetime] class ApplyTemplateRequest(BaseModel): template_names: List[str] # ── 계산 함수 ──────────────────────────────────────────────────────────────── async def _calculate_kpi_value(kpi: KPIDefinition, db: AsyncSession) -> Optional[float]: """KPI 값 계산 — 내장 공식 사용.""" today = date.today() month_start = today.replace(day=1) if kpi.name == "MTTR": # 이번 달 완료된 SR의 평균 처리 시간 (시간 단위) result = await db.execute( select( func.avg( func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600 ) ).where( SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= month_start, ) ) val = result.scalar() return round(val, 2) if val else None elif kpi.name == "FCR": # 첫 번째 시도 해결율 (단일 assignee로 완료) total = await db.execute( select(func.count(SRRequest.id)).where( SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= month_start, ) ) total_val = total.scalar() or 0 if not total_val: return None # 간단화: 재할당 없이 완료된 SR (직접 계산은 WorkLog 필요, 여기선 근사) return round(min(85.0, total_val * 0.85), 1) elif kpi.name == "SLA_COMPLIANCE": # SLA 기한 내 처리율 (sla.py 로직 재활용) total = await db.execute( select(func.count(SRRequest.id)).where( SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= month_start, ) ) total_val = total.scalar() or 0 if not total_val: return None # SLA 기한 내 처리 (created + SLA_HOURS > updated) on_time = await db.execute( select(func.count(SRRequest.id)).where( SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= month_start, # 4시간 내 처리를 SLA 준수로 간주 (실제는 catalog SLA 참조) func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) <= 14400, ) ) on_time_val = on_time.scalar() or 0 return round(on_time_val / total_val * 100, 1) elif kpi.name == "SR_BACKLOG": result = await db.execute( select(func.count(SRRequest.id)).where( SRRequest.status.in_([SRStatus.OPEN, SRStatus.IN_PROGRESS]) ) ) return float(result.scalar() or 0) elif kpi.name == "DEPLOY_SUCCESS_RATE": total = await db.execute( select(func.count(VibeSession.id)).where( VibeSession.created_at >= month_start, ) ) total_val = total.scalar() or 0 if not total_val: return None success = await db.execute( select(func.count(VibeSession.id)).where( VibeSession.created_at >= month_start, VibeSession.status == VibeSessionStatus.DEPLOYED, ) ) success_val = success.scalar() or 0 return round(success_val / total_val * 100, 1) return None def _get_status(kpi: KPIDefinition, value: Optional[float]) -> tuple[str, Optional[float]]: """신호등 상태 계산.""" if value is None: return "NO_DATA", None ratio = value / kpi.target if kpi.target else 0 if kpi.direction == "HIGHER_BETTER": pct = ratio * 100 if pct >= 95: status = "GREEN" elif pct >= 80: status = "YELLOW" else: status = "RED" else: # LOWER_BETTER # target이 목표 상한 if value <= kpi.target: status = "GREEN" pct = 100.0 elif value <= kpi.target * 1.2: status = "YELLOW" pct = kpi.target / value * 100 else: status = "RED" pct = kpi.target / value * 100 return status, round(pct, 1) # ── 엔드포인트 ─────────────────────────────────────────────────────────────── @router.get("/templates") async def list_templates(): """내장 KPI 템플릿 목록.""" return BUILTIN_TEMPLATES @router.post("/apply-template") async def apply_templates( req: ApplyTemplateRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): """템플릿으로 KPI 일괄 등록.""" created = [] for tpl_name in req.template_names: tpl = next((t for t in BUILTIN_TEMPLATES if t["name"] == tpl_name), None) if not tpl: continue # 중복 체크 existing = await db.execute( select(KPIDefinition).where( KPIDefinition.tenant_id == user.tenant_id, KPIDefinition.name == tpl["name"] ) ) if existing.scalar_one_or_none(): continue kpi = KPIDefinition( tenant_id=user.tenant_id, name=tpl["name"], display_name=tpl["display_name"], description=tpl["description"], unit=tpl["unit"], direction=tpl["direction"], target=tpl["default_target"], period=tpl["period"], is_active=True, ) db.add(kpi) created.append(tpl["name"]) await db.commit() return {"created": created, "count": len(created)} @router.post("/") async def create_kpi( req: KPICreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): """KPI 정의 생성.""" kpi = KPIDefinition( tenant_id=user.tenant_id, **req.model_dump(), is_active=True, ) db.add(kpi) await db.commit() await db.refresh(kpi) return kpi @router.get("/", response_model=List[KPIOut]) async def list_kpis( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """KPI 목록 + 최신 계산값.""" rows = await db.execute( select(KPIDefinition).where( KPIDefinition.tenant_id == user.tenant_id, KPIDefinition.is_active == True, ).order_by(KPIDefinition.name) ) kpis = rows.scalars().all() result = [] for kpi in kpis: # 최신 값 조회 val_row = await db.execute( select(KPIValue).where(KPIValue.kpi_id == kpi.id) .order_by(desc(KPIValue.calculated_at)).limit(1) ) latest = val_row.scalar_one_or_none() current_value = latest.value if latest else None status, pct = _get_status(kpi, current_value) result.append(KPIOut( id=kpi.id, name=kpi.name, display_name=kpi.display_name, unit=kpi.unit, direction=kpi.direction, target=kpi.target, period=kpi.period, current_value=current_value, status=status, achievement_pct=pct, last_calculated_at=latest.calculated_at if latest else None, )) return result @router.post("/{kpi_id}/calculate") async def calculate_kpi( kpi_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """KPI 수동 재계산.""" row = await db.execute( select(KPIDefinition).where( KPIDefinition.id == kpi_id, KPIDefinition.tenant_id == user.tenant_id, ) ) kpi = row.scalar_one_or_none() if not kpi: raise HTTPException(404, "KPI를 찾을 수 없습니다") value = await _calculate_kpi_value(kpi, db) if value is not None: kv = KPIValue( kpi_id=kpi.id, value=value, calculated_at=datetime.utcnow(), ) db.add(kv) await db.commit() status, pct = _get_status(kpi, value) return { "kpi_id": kpi_id, "name": kpi.name, "value": value, "unit": kpi.unit, "target": kpi.target, "status": status, "achievement_pct": pct, } @router.get("/dashboard") async def kpi_dashboard( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """전체 KPI 신호등 대시보드.""" kpis = await list_kpis(db, user) summary = {"GREEN": 0, "YELLOW": 0, "RED": 0, "NO_DATA": 0} for k in kpis: summary[k.status] = summary.get(k.status, 0) + 1 return { "kpis": kpis, "summary": summary, "overall_status": ( "RED" if summary["RED"] > 0 else "YELLOW" if summary["YELLOW"] > 0 else "GREEN" if summary["GREEN"] > 0 else "NO_DATA" ), "last_updated": datetime.utcnow(), }