""" BI 대시보드 API — 실시간 KPI 위젯 + 트렌드 데이터 기존 analytics.py / sla.py / report.py를 통합·고도화. Chart.js / D3.js 프론트엔드용 구조화된 데이터 반환. 엔드포인트: GET /api/bi/overview — 전체 현황 요약 (메인 대시보드) GET /api/bi/sr-trend — SR 트렌드 (일별/주별/월별) GET /api/bi/sla-heatmap — SLA 준수율 히트맵 GET /api/bi/engineer-load — 엔지니어별 워크로드 분포 GET /api/bi/category-pie — SR 카테고리별 분포 GET /api/bi/resolution-funnel — SR 처리 단계별 퍼널 GET /api/bi/mttr-trend — MTTR 트렌드 """ from __future__ import annotations from datetime import date, datetime, timedelta from typing import List, Optional from fastapi import APIRouter, Depends, Query from sqlalchemy import select, func, and_, case, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import SRRequest, SRStatus, User, WorkLog router = APIRouter(prefix="/api/bi", tags=["BI Dashboard"]) def _date_series(days: int) -> list[str]: return [(date.today() - timedelta(days=i)).isoformat() for i in range(days - 1, -1, -1)] @router.get("/overview") async def bi_overview( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """메인 대시보드 — 핵심 지표 카드 데이터.""" today = date.today() month_start = today.replace(day=1) week_start = today - timedelta(days=today.weekday()) async def count_sr(status=None, since=None): q = select(func.count(SRRequest.id)) filters = [] if status: filters.append(SRRequest.status == status) if since: filters.append(SRRequest.created_at >= since) if filters: q = q.where(and_(*filters)) return (await db.execute(q)).scalar() or 0 open_sr = await count_sr(status=SRStatus.OPEN) inprog_sr = await count_sr(status=SRStatus.IN_PROGRESS) done_month = await count_sr(status=SRStatus.DONE, since=month_start) done_week = await count_sr(status=SRStatus.DONE, since=week_start) # MTTR 이번 달 mttr_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, ) ) mttr = round(mttr_result.scalar() or 0, 1) # 전월 대비 증감 prev_month_start = (month_start - timedelta(days=1)).replace(day=1) done_prev = await count_sr(status=SRStatus.DONE, since=prev_month_start) done_prev_cnt = done_prev - done_month if done_prev > done_month else done_prev return { "cards": [ {"key": "open_sr", "label": "미처리 SR", "value": open_sr, "unit": "건", "color": "red"}, {"key": "inprog_sr", "label": "처리 중 SR", "value": inprog_sr, "unit": "건", "color": "orange"}, {"key": "done_month", "label": "이번 달 완료", "value": done_month, "unit": "건", "color": "green", "change": done_month - done_prev_cnt, "change_label": "전월 대비"}, {"key": "done_week", "label": "이번 주 완료", "value": done_week, "unit": "건", "color": "blue"}, {"key": "mttr", "label": "평균 처리 시간", "value": mttr, "unit": "시간", "color": "purple"}, ], "updated_at": datetime.utcnow().isoformat(), } @router.get("/sr-trend") async def sr_trend( period: str = Query("daily", pattern="^(daily|weekly|monthly)$"), days: int = Query(30, ge=7, le=365), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """SR 생성/완료 트렌드 (Chart.js line chart용).""" today = date.today() since = today - timedelta(days=days) # 날짜별 생성 건수 created = await db.execute( select( func.date(SRRequest.created_at).label("d"), func.count(SRRequest.id).label("cnt"), ).where(SRRequest.created_at >= since) .group_by(func.date(SRRequest.created_at)) .order_by("d") ) created_map = {str(r.d): r.cnt for r in created.all()} # 날짜별 완료 건수 resolved = await db.execute( select( func.date(SRRequest.updated_at).label("d"), func.count(SRRequest.id).label("cnt"), ).where( SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= since, ).group_by(func.date(SRRequest.updated_at)).order_by("d") ) resolved_map = {str(r.d): r.cnt for r in resolved.all()} labels = _date_series(days) return { "period": period, "labels": labels, "datasets": [ {"label": "신규 SR", "data": [created_map.get(d, 0) for d in labels], "color": "#003366"}, {"label": "완료 SR", "data": [resolved_map.get(d, 0) for d in labels], "color": "#10B981"}, ], } @router.get("/sla-heatmap") async def sla_heatmap( weeks: int = Query(12, ge=4, le=52), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """SLA 준수율 히트맵 (주별 × 카테고리).""" since = date.today() - timedelta(weeks=weeks) rows = await db.execute( select( func.date_trunc('week', SRRequest.created_at).label("week"), SRRequest.category.label("cat"), func.count(SRRequest.id).label("total"), func.sum( case( (func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) <= 14400, 1), else_=0 ) ).label("on_time"), ).where( SRRequest.status == SRStatus.DONE, SRRequest.created_at >= since, ).group_by("week", SRRequest.category).order_by("week") ) data = [] for r in rows.all(): rate = round(r.on_time / r.total * 100, 1) if r.total else 0 data.append({ "week": r.week.date().isoformat() if r.week else None, "category": r.cat or "기타", "total": r.total, "on_time": r.on_time, "compliance_pct": rate, }) return {"data": data} @router.get("/engineer-load") async def engineer_load( days: int = Query(30, ge=7, le=90), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """엔지니어별 SR 워크로드 분포 (bar chart용).""" since = date.today() - timedelta(days=days) rows = await db.execute( select( User.name.label("engineer"), func.count(SRRequest.id).label("total"), func.sum(case((SRRequest.status == SRStatus.DONE, 1), else_=0)).label("done"), func.sum(case((SRRequest.status.in_([SRStatus.OPEN, SRStatus.IN_PROGRESS]), 1), else_=0)).label("open"), ).join(User, SRRequest.assignee_id == User.id, isouter=True) .where(SRRequest.created_at >= since) .group_by(User.name).order_by(desc("total")).limit(20) ) data = [ {"engineer": r.engineer or "미배정", "total": r.total, "done": r.done, "open": r.open} for r in rows.all() ] return { "period_days": days, "labels": [d["engineer"] for d in data], "datasets": [ {"label": "완료", "data": [d["done"] for d in data], "color": "#10B981"}, {"label": "진행중", "data": [d["open"] for d in data], "color": "#F59E0B"}, ], } @router.get("/category-pie") async def category_pie( days: int = Query(30, ge=7, le=365), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """SR 카테고리별 분포 (pie chart용).""" since = date.today() - timedelta(days=days) rows = await db.execute( select( SRRequest.category.label("cat"), func.count(SRRequest.id).label("cnt"), ).where(SRRequest.created_at >= since) .group_by(SRRequest.category).order_by(desc("cnt")) ) data = [{"category": r.cat or "기타", "count": r.cnt} for r in rows.all()] total = sum(d["count"] for d in data) for d in data: d["pct"] = round(d["count"] / total * 100, 1) if total else 0 return {"period_days": days, "total": total, "data": data} @router.get("/mttr-trend") async def mttr_trend( months: int = Query(6, ge=3, le=24), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """MTTR 월별 트렌드.""" since = date.today() - timedelta(days=months * 30) rows = await db.execute( select( func.date_trunc('month', SRRequest.updated_at).label("month"), func.avg( func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600 ).label("mttr_hours"), func.count(SRRequest.id).label("cnt"), ).where( SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= since, ).group_by("month").order_by("month") ) data = [ { "month": r.month.strftime("%Y-%m") if r.month else None, "mttr_hours": round(r.mttr_hours or 0, 1), "count": r.cnt, } for r in rows.all() ] return { "labels": [d["month"] for d in data], "datasets": [{"label": "MTTR (시간)", "data": [d["mttr_hours"] for d in data], "color": "#6366F1"}], "raw": data, } @router.get("/resolution-funnel") async def resolution_funnel( days: int = Query(30, ge=7, le=90), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """SR 처리 단계별 퍼널 (funnel chart용).""" since = date.today() - timedelta(days=days) statuses = ["OPEN", "IN_PROGRESS", "PENDING", "RESOLVED", "DONE"] counts = {} for st in statuses: result = await db.execute( select(func.count(SRRequest.id)).where( SRRequest.created_at >= since, SRRequest.status == st, ) ) counts[st] = result.scalar() or 0 total = sum(counts.values()) return { "period_days": days, "total_created": total, "funnel": [ {"stage": st, "count": counts[st], "pct": round(counts[st] / total * 100, 1) if total else 0} for st in statuses ], }