290 lines
10 KiB
Python
290 lines
10 KiB
Python
"""
|
||
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
|
||
],
|
||
}
|