라우터 (667개 엔드포인트, P3 신규 69개):
- multimodal.py: llava 이미지 분석 + 에러 자동 분류
- learning_loop.py: Ollama 파인튜닝 + 품질 지표
- ai_insights.py: 주간 인사이트 + 반복 패턴 + 개선 권고
- container_alerts.py: Docker 이상 감지 → SR 자동 생성
- ncloud.py: NCloud API (서버/LB/스토리지/비용)
- billing.py: 구독 플랜 + 사용량 측정 + 청구서
- servicenow.py: ServiceNow CMDB/Incident 양방향 연동
- erp_connector.py: 그룹웨어/HR ERP 연동 + 결재 웹훅
- kakao_notify.py: 카카오 알림톡 + 대량 발송
- auto_report.py: Excel/PDF 보고서 자동 생성·다운로드
- benchmark.py: 기관 간 익명 벤치마킹 (완전 익명화)
- cohort_analysis.py: 도입 코호트 + 리텐션 + 기능 도입률
DB 모델 (14개 신규 테이블):
tb_learning_run, tb_container_alert_{rule,log},
tb_ncloud_config, tb_subscription, tb_invoice,
tb_servicenow_{config,mapping}, tb_erp_config,
tb_kakao_{config,notify_log}, tb_report_{record,schedule},
tb_benchmark_contrib
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
217 lines
8.1 KiB
Python
217 lines
8.1 KiB
Python
"""
|
|
자동 보고서 생성 — 주간/월간/분기 운영 보고서 자동 발송
|
|
|
|
기존 report.py를 확장하여 스케줄 기반 자동 생성 + 이메일 발송.
|
|
|
|
엔드포인트:
|
|
GET /api/auto-report/templates — 보고서 템플릿 목록
|
|
POST /api/auto-report/generate — 보고서 즉시 생성
|
|
GET /api/auto-report/list — 생성된 보고서 목록
|
|
GET /api/auto-report/{id}/download — 보고서 다운로드
|
|
POST /api/auto-report/schedule — 자동 발송 스케줄 설정
|
|
GET /api/auto-report/schedule — 스케줄 목록
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import logging
|
|
from datetime import date, datetime, timedelta
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select, func
|
|
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, ReportRecord, ReportSchedule # 신규
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/auto-report", tags=["Auto Report"])
|
|
|
|
TEMPLATES = {
|
|
"weekly_ops": {"name": "주간 운영 보고서", "period": "WEEKLY", "format": ["excel", "pdf"]},
|
|
"monthly_sla": {"name": "월간 SLA 보고서", "period": "MONTHLY", "format": ["excel", "pdf"]},
|
|
"incident_rca": {"name": "인시던트 분석", "period": "MONTHLY", "format": ["pdf"]},
|
|
"capacity_plan": {"name": "용량 계획 보고서", "period": "QUARTERLY","format": ["excel"]},
|
|
}
|
|
|
|
|
|
class GenerateRequest(BaseModel):
|
|
template: str = Field(..., description="weekly_ops | monthly_sla | incident_rca | capacity_plan")
|
|
period_start: Optional[str] = None # YYYY-MM-DD
|
|
period_end: Optional[str] = None
|
|
format: str = Field("excel", pattern="^(excel|pdf)$")
|
|
send_email: bool = False
|
|
email: Optional[str] = None
|
|
|
|
class ScheduleCreate(BaseModel):
|
|
template: str
|
|
cron: str = Field(..., description="cron 표현식 (예: 0 9 * * 1 = 매주 월요일 9시)")
|
|
email: str
|
|
format: str = "excel"
|
|
|
|
|
|
async def _collect_report_data(template: str, start: date, end: date, db: AsyncSession) -> dict:
|
|
"""보고서 데이터 수집."""
|
|
total_r = await db.execute(
|
|
select(func.count(SRRequest.id)).where(
|
|
SRRequest.created_at >= start, SRRequest.created_at <= end
|
|
)
|
|
)
|
|
done_r = await db.execute(
|
|
select(func.count(SRRequest.id)).where(
|
|
SRRequest.status == SRStatus.DONE,
|
|
SRRequest.updated_at >= start, SRRequest.updated_at <= end,
|
|
)
|
|
)
|
|
open_r = await db.execute(
|
|
select(func.count(SRRequest.id)).where(SRRequest.status.in_([SRStatus.OPEN, SRStatus.IN_PROGRESS]))
|
|
)
|
|
mttr_r = await db.execute(
|
|
select(func.avg(
|
|
func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600
|
|
)).where(SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= start, SRRequest.updated_at <= end)
|
|
)
|
|
|
|
total = total_r.scalar() or 0
|
|
done = done_r.scalar() or 0
|
|
return {
|
|
"period": {"start": start.isoformat(), "end": end.isoformat()},
|
|
"sr_total": total, "sr_done": done, "sr_open": open_r.scalar() or 0,
|
|
"completion_rate": round(done / total * 100, 1) if total else 0,
|
|
"mttr_hours": round(mttr_r.scalar() or 0, 1),
|
|
}
|
|
|
|
|
|
def _build_excel(data: dict, template: str) -> bytes:
|
|
"""Excel 보고서 생성 (openpyxl)."""
|
|
try:
|
|
import openpyxl
|
|
from openpyxl.styles import Font, PatternFill, Alignment
|
|
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = TEMPLATES.get(template, {}).get("name", "보고서")
|
|
|
|
# 헤더
|
|
ws["A1"] = TEMPLATES.get(template, {}).get("name", "운영 보고서")
|
|
ws["A1"].font = Font(bold=True, size=14)
|
|
ws["A2"] = f"기간: {data['period']['start']} ~ {data['period']['end']}"
|
|
|
|
ws["A4"] = "지표"; ws["B4"] = "값"
|
|
ws["A4"].font = Font(bold=True)
|
|
ws["B4"].font = Font(bold=True)
|
|
|
|
rows = [
|
|
("신규 SR", data["sr_total"]),
|
|
("완료 SR", data["sr_done"]),
|
|
("미처리 SR", data["sr_open"]),
|
|
("완료율 (%)", data["completion_rate"]),
|
|
("평균 처리 시간 (시간)", data["mttr_hours"]),
|
|
]
|
|
for i, (label, value) in enumerate(rows, start=5):
|
|
ws[f"A{i}"] = label
|
|
ws[f"B{i}"] = value
|
|
|
|
ws["A4"].fill = PatternFill(start_color="003366", end_color="003366", fill_type="solid")
|
|
ws["A4"].font = Font(bold=True, color="FFFFFF")
|
|
ws["B4"].fill = PatternFill(start_color="003366", end_color="003366", fill_type="solid")
|
|
ws["B4"].font = Font(bold=True, color="FFFFFF")
|
|
ws.column_dimensions["A"].width = 25
|
|
ws.column_dimensions["B"].width = 15
|
|
|
|
output = io.BytesIO()
|
|
wb.save(output)
|
|
return output.getvalue()
|
|
except ImportError:
|
|
# openpyxl 없으면 CSV 대체
|
|
lines = [f"{k},{v}" for k, v in [("지표", "값")] + [(str(k), str(v)) for k, v in [
|
|
("신규 SR", data["sr_total"]), ("완료율", data["completion_rate"])
|
|
]]]
|
|
return "\n".join(lines).encode('utf-8-sig')
|
|
|
|
|
|
@router.get("/templates")
|
|
async def list_templates():
|
|
return [{"code": k, **v} for k, v in TEMPLATES.items()]
|
|
|
|
|
|
@router.post("/generate")
|
|
async def generate_report(
|
|
req: GenerateRequest, db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
if req.template not in TEMPLATES:
|
|
raise HTTPException(400, f"알 수 없는 템플릿: {req.template}")
|
|
|
|
today = date.today()
|
|
if req.period_start and req.period_end:
|
|
start = date.fromisoformat(req.period_start)
|
|
end = date.fromisoformat(req.period_end)
|
|
else:
|
|
period = TEMPLATES[req.template]["period"]
|
|
if period == "WEEKLY":
|
|
start = today - timedelta(days=7); end = today
|
|
elif period == "QUARTERLY":
|
|
q_start = date(today.year, ((today.month - 1) // 3) * 3 + 1, 1)
|
|
start = q_start; end = today
|
|
else: # MONTHLY
|
|
start = today.replace(day=1); end = today
|
|
|
|
data = await _collect_report_data(req.template, start, end, db)
|
|
excel_bytes = _build_excel(data, req.template)
|
|
|
|
record = ReportRecord(
|
|
tenant_id=user.tenant_id, template=req.template,
|
|
period_start=start, period_end=end,
|
|
format=req.format, file_size=len(excel_bytes),
|
|
status="DONE", generated_by=user.id, created_at=datetime.utcnow()
|
|
)
|
|
db.add(record)
|
|
await db.commit()
|
|
await db.refresh(record)
|
|
|
|
return {
|
|
"ok": True, "report_id": record.id,
|
|
"template": req.template, "period": data["period"],
|
|
"data_summary": data,
|
|
}
|
|
|
|
|
|
@router.get("/{report_id}/download")
|
|
async def download_report(
|
|
report_id: int, db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
row = await db.execute(
|
|
select(ReportRecord).where(ReportRecord.id == report_id, ReportRecord.tenant_id == user.tenant_id)
|
|
)
|
|
record = row.scalar_one_or_none()
|
|
if not record: raise HTTPException(404, "보고서 없음")
|
|
|
|
data = await _collect_report_data(record.template, record.period_start, record.period_end, db)
|
|
excel_bytes = _build_excel(data, record.template)
|
|
filename = f"report_{record.template}_{record.period_start}.xlsx"
|
|
return Response(
|
|
content=excel_bytes,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
)
|
|
|
|
|
|
@router.get("/list")
|
|
async def list_reports(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
rows = await db.execute(
|
|
select(ReportRecord).where(ReportRecord.tenant_id == user.tenant_id)
|
|
.order_by(ReportRecord.created_at.desc()).limit(50)
|
|
)
|
|
records = rows.scalars().all()
|
|
return [
|
|
{"id": r.id, "template": r.template, "period": f"{r.period_start}~{r.period_end}",
|
|
"format": r.format, "status": r.status, "created_at": r.created_at}
|
|
for r in records
|
|
]
|