guardia-itsm/routers/jasper_report.py
2026-06-07 19:13:43 +09:00

1024 lines
41 KiB
Python

"""
Jasper Reports 호환 문서 자동 작성 — 산출물·회의록·보고서 PDF/Excel 생성
JasperReports(JasperServer/Java) 설치 없이, JRXML(XML) 핵심 구조(밴드·필드·
정적 텍스트)를 파싱해 ReportLab(PDF) / openpyxl(Excel)로 직접 렌더링하는
경량 자체 엔진. 자바 런타임·JasperServer 불필요 — 에이전트리스/온프레미스 원칙 부합.
엔드포인트:
GET /api/jasper/templates — 등록된 JRXML 템플릿 목록
POST /api/jasper/templates — 신규 템플릿 등록 (업로드 또는 내장 선택)
POST /api/jasper/generate/deliverable — 산출물 문서 생성 (SR/계약 데이터 → PDF)
POST /api/jasper/generate/meeting-minutes — 회의록 생성 (회의 메타+액션아이템 → PDF)
POST /api/jasper/generate/report — 정기/특별 보고서 생성 (KPI/SLA → PDF/Excel)
GET /api/jasper/jobs/{job_id} — 생성 작업 상태/다운로드 링크
GET /api/jasper/jobs/{job_id}/download — 완성 문서 다운로드
GET /api/jasper/history — 생성 이력 조회 (그리드)
데이터 소스:
- 산출물(Deliverable): tb_sr_request(SR) 집계 — 기간 내 SR 통계·이행내역
- 회의록(Meeting Minutes): 입력 JSON 스펙 (회의 메타데이터 + STT 결과 + 액션아이템)
전용 회의 모델(meeting.py)이 존재하지 않아 입력 JSON으로 직접 받는다.
- 보고서(Report): tb_sr_request 기반 KPI/SLA 집계 (stats.py kpi 로직과 동일 지표)
보안: JWT 인증 + tenant_id 필터 + TB_AUDIT_LOG 기록.
ServerOut 스키마 필드(ip_addr/ssh_user/os_pw_enc) 절대 미노출 — 본 라우터는 해당 데이터를 다루지 않음.
"""
from __future__ import annotations
import io
import logging
import xml.etree.ElementTree as ET
from datetime import datetime, date
from typing import Optional, List, Dict, Any
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
from database import get_db
from models import (
User, SRRequest, SRStatus, AuditLog,
JasperTemplate, JasperGenerationJob,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/jasper", tags=["Jasper Reports 문서생성"])
# ============================================================================
# 내장 기본 템플릿 3종 (시드) - 표지 밴드 + 섹션 밴드 최소 JRXML 구조
# ============================================================================
# JRXML 호환 수준: <jasperReport>의 <field>(데이터 필드 선언)와
# <band>(title/pageHeader/detail/summary) 내부의 <staticText>(정적 텍스트),
# <textField><textFieldExpression>(데이터 바인딩 필드) 요소만 핵심 구조로 다룬다.
# JasperReports의 컴파일/표현식 엔진(JRBeanCollectionDataSource 등)은 구현하지
# 않고, "$F{필드명}" 표현식만 인식해 data dict 값으로 치환한다.
JRXML_DELIVERABLE = """<?xml version="1.0" encoding="UTF-8"?>
<jasperReport name="deliverable_basic">
<field name="title"/>
<field name="period"/>
<field name="org_name"/>
<field name="sr_total"/>
<field name="sr_done"/>
<field name="sr_open"/>
<field name="completion_rate"/>
<field name="prepared_by"/>
<field name="generated_at"/>
<band type="title" height="120">
<staticText x="0" y="0" width="500" height="40" style="title">사업 산출물 보고서</staticText>
<textField x="0" y="50" width="500" height="24"><textFieldExpression>$F{title}</textFieldExpression></textField>
<textField x="0" y="80" width="500" height="20"><textFieldExpression>대상 기관: $F{org_name}</textFieldExpression></textField>
<textField x="0" y="100" width="500" height="20"><textFieldExpression>작성일: $F{generated_at}</textFieldExpression></textField>
</band>
<band type="pageHeader" height="40">
<staticText x="0" y="0" width="500" height="24" style="header">I. 이행 개요</staticText>
<textField x="0" y="24" width="500" height="20"><textFieldExpression>이행 기간: $F{period}</textFieldExpression></textField>
</band>
<band type="detail" height="160">
<staticText x="0" y="0" width="500" height="24" style="header">II. 이행 내역 집계</staticText>
<textField x="0" y="30" width="500" height="20"><textFieldExpression>접수 SR 건수: $F{sr_total} 건</textFieldExpression></textField>
<textField x="0" y="55" width="500" height="20"><textFieldExpression>완료 건수: $F{sr_done} 건</textFieldExpression></textField>
<textField x="0" y="80" width="500" height="20"><textFieldExpression>미처리 건수: $F{sr_open} 건</textFieldExpression></textField>
<textField x="0" y="105" width="500" height="20"><textFieldExpression>이행률: $F{completion_rate} %</textFieldExpression></textField>
</band>
<band type="summary" height="80">
<staticText x="0" y="0" width="500" height="24" style="header">III. 작성 정보</staticText>
<textField x="0" y="30" width="500" height="20"><textFieldExpression>작성자: $F{prepared_by}</textFieldExpression></textField>
<staticText x="0" y="55" width="500" height="20" style="footer">본 문서는 GUARDiA Jasper 엔진으로 자동 생성되었습니다.</staticText>
</band>
</jasperReport>"""
JRXML_MEETING_MINUTES = """<?xml version="1.0" encoding="UTF-8"?>
<jasperReport name="meeting_minutes_basic">
<field name="title"/>
<field name="meeting_date"/>
<field name="location"/>
<field name="chairman"/>
<field name="attendees"/>
<field name="agenda_summary"/>
<field name="decisions"/>
<field name="action_items"/>
<field name="next_meeting"/>
<field name="generated_at"/>
<band type="title" height="110">
<staticText x="0" y="0" width="500" height="40" style="title">회의록</staticText>
<textField x="0" y="50" width="500" height="24"><textFieldExpression>$F{title}</textFieldExpression></textField>
<textField x="0" y="80" width="500" height="20"><textFieldExpression>일시: $F{meeting_date} / 장소: $F{location}</textFieldExpression></textField>
</band>
<band type="pageHeader" height="60">
<staticText x="0" y="0" width="500" height="24" style="header">참석 정보</staticText>
<textField x="0" y="24" width="500" height="20"><textFieldExpression>의장: $F{chairman}</textFieldExpression></textField>
<textField x="0" y="44" width="500" height="20"><textFieldExpression>참석자: $F{attendees}</textFieldExpression></textField>
</band>
<band type="detail" height="220">
<staticText x="0" y="0" width="500" height="24" style="header">안건 요약</staticText>
<textField x="0" y="26" width="500" height="40"><textFieldExpression>$F{agenda_summary}</textFieldExpression></textField>
<staticText x="0" y="72" width="500" height="24" style="header">결정 사항</staticText>
<textField x="0" y="98" width="500" height="40"><textFieldExpression>$F{decisions}</textFieldExpression></textField>
<staticText x="0" y="144" width="500" height="24" style="header">액션 아이템</staticText>
<textField x="0" y="170" width="500" height="40"><textFieldExpression>$F{action_items}</textFieldExpression></textField>
</band>
<band type="summary" height="70">
<textField x="0" y="0" width="500" height="20"><textFieldExpression>차기 회의: $F{next_meeting}</textFieldExpression></textField>
<staticText x="0" y="30" width="500" height="20" style="footer">본 회의록은 GUARDiA Jasper 엔진으로 자동 생성되었습니다. (작성: $F{generated_at})</staticText>
</band>
</jasperReport>"""
JRXML_REPORT = """<?xml version="1.0" encoding="UTF-8"?>
<jasperReport name="report_basic">
<field name="title"/>
<field name="period"/>
<field name="sr_total"/>
<field name="sr_done"/>
<field name="sr_open"/>
<field name="completion_rate"/>
<field name="sla_compliance_rate"/>
<field name="mttr_hours"/>
<field name="csap_score"/>
<field name="generated_at"/>
<band type="title" height="100">
<staticText x="0" y="0" width="500" height="40" style="title">정기 운영 보고서</staticText>
<textField x="0" y="50" width="500" height="24"><textFieldExpression>$F{title}</textFieldExpression></textField>
<textField x="0" y="80" width="500" height="20"><textFieldExpression>보고 기간: $F{period}</textFieldExpression></textField>
</band>
<band type="pageHeader" height="30">
<staticText x="0" y="0" width="500" height="24" style="header">핵심 성과 지표 (KPI)</staticText>
</band>
<band type="detail" height="200">
<textField x="0" y="0" width="500" height="20"><textFieldExpression>전체 SR: $F{sr_total} 건 (완료 $F{sr_done} / 미처리 $F{sr_open})</textFieldExpression></textField>
<textField x="0" y="25" width="500" height="20"><textFieldExpression>SR 완료율: $F{completion_rate} %</textFieldExpression></textField>
<textField x="0" y="50" width="500" height="20"><textFieldExpression>SLA 준수율: $F{sla_compliance_rate} %</textFieldExpression></textField>
<textField x="0" y="75" width="500" height="20"><textFieldExpression>평균 처리 시간(MTTR): $F{mttr_hours} 시간</textFieldExpression></textField>
<textField x="0" y="100" width="500" height="20"><textFieldExpression>CSAP 준수 점수: $F{csap_score} 점</textFieldExpression></textField>
</band>
<band type="summary" height="60">
<staticText x="0" y="0" width="500" height="20" style="footer">본 보고서는 GUARDiA Jasper 엔진이 ITSM 운영 데이터를 집계해 자동 생성했습니다.</staticText>
<textField x="0" y="25" width="500" height="20"><textFieldExpression>생성 일시: $F{generated_at}</textFieldExpression></textField>
</band>
</jasperReport>"""
BUILTIN_TEMPLATES = [
{"name": "기본 산출물 보고서", "category": "DELIVERABLE", "jrxml": JRXML_DELIVERABLE, "format": "PDF"},
{"name": "기본 회의록 양식", "category": "MEETING_MINUTES", "jrxml": JRXML_MEETING_MINUTES, "format": "PDF"},
{"name": "기본 운영 보고서", "category": "REPORT", "jrxml": JRXML_REPORT, "format": "PDF"},
]
# ============================================================================
# JRXML 파싱 / 렌더링 경량 엔진
# parse_jrxml : XML(xml.etree.ElementTree) -> {bands, fields} 구조
# render_to_pdf : 파싱 구조 + 데이터 -> ReportLab Canvas PDF
# render_to_excel: 파싱 구조 + 데이터 -> openpyxl 워크북
# ============================================================================
_BAND_ORDER = ["title", "pageHeader", "columnHeader", "detail", "columnFooter", "pageFooter", "summary"]
_BAND_LABELS_KO = {
"title": "표지", "pageHeader": "개요", "columnHeader": "헤더",
"detail": "본문", "columnFooter": "바닥글", "pageFooter": "쪽바닥",
"summary": "요약",
}
def parse_jrxml(jrxml_xml: str) -> dict:
"""JRXML <jasperReport> 구조를 파싱해 밴드/필드/요소 구조 dict로 변환한다.
반환 형식:
{
"name": str,
"fields": ["field1", "field2", ...],
"bands": [
{"type": "title", "height": 120, "elements": [
{"kind": "staticText", "text": "...", "style": "title",
"x":.., "y":.., "width":.., "height":..},
{"kind": "textField", "expression": "$F{title}", "x":.., ...},
]},
...
],
}
완전한 JasperReports 컴파일러는 구현하지 않으며, <staticText>/<textField>
핵심 구조만 추출한다 ("핵심 구조" 수준 호환).
"""
if not jrxml_xml or not jrxml_xml.strip():
raise ValueError("JRXML 내용이 비어 있습니다")
try:
root = ET.fromstring(jrxml_xml.strip())
except ET.ParseError as e:
raise ValueError(f"JRXML 파싱 오류: {e}")
report_name = root.attrib.get("name", "report")
fields: List[str] = []
for f in root.findall(".//field"):
fname = f.attrib.get("name")
if fname:
fields.append(fname)
bands: List[Dict[str, Any]] = []
for band_el in root.findall(".//band"):
band_type = band_el.attrib.get("type", "detail")
try:
height = int(band_el.attrib.get("height", "60"))
except (TypeError, ValueError):
height = 60
elements: List[Dict[str, Any]] = []
for child in band_el:
tag = child.tag
if tag not in ("staticText", "textField", "image"):
continue
def _geom(el):
def _i(attr, default):
try:
return int(el.attrib.get(attr, default))
except (TypeError, ValueError):
return default
return {
"x": _i("x", 0), "y": _i("y", 0),
"width": _i("width", 400), "height": _i("height", 20),
}
geom = _geom(child)
if tag == "staticText":
elements.append({
"kind": "staticText",
"text": (child.text or "").strip(),
"style": child.attrib.get("style", "normal"),
**geom,
})
elif tag == "textField":
expr_el = child.find("textFieldExpression")
expr = (expr_el.text or "").strip() if expr_el is not None else ""
elements.append({
"kind": "textField",
"expression": expr,
"style": child.attrib.get("style", "normal"),
**geom,
})
elif tag == "image":
elements.append({
"kind": "image",
"src": child.attrib.get("src", ""),
**geom,
})
bands.append({"type": band_type, "height": height, "elements": elements})
def _order_key(b):
try:
return _BAND_ORDER.index(b["type"])
except ValueError:
return len(_BAND_ORDER)
bands.sort(key=_order_key)
return {"name": report_name, "fields": fields, "bands": bands}
def _resolve_expression(expression: str, data: dict) -> str:
"""'$F{필드명}' 표현식을 데이터 dict 값으로 치환한다 (단순 토큰 치환, 산술식 미지원)."""
if not expression:
return ""
text = expression
start = 0
out = []
while True:
idx = text.find("$F{", start)
if idx == -1:
out.append(text[start:])
break
out.append(text[start:idx])
end = text.find("}", idx)
if end == -1:
out.append(text[idx:])
break
field_name = text[idx + 3:end]
value = data.get(field_name, "")
out.append("" if value is None else str(value))
start = end + 1
return "".join(out)
def render_to_pdf(template: dict, data: dict) -> bytes:
"""파싱된 JRXML 구조 + 데이터 바인딩 -> ReportLab Canvas로 PDF 렌더링.
밴드를 표준 순서(title -> pageHeader -> ... -> summary)대로 세로로 쌓아
한 페이지에 조립한다. 페이지 높이를 초과하면 새 페이지로 넘긴다.
"""
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
from reportlab.pdfbase.pdfmetrics import stringWidth
page_w, page_h = A4
margin_x = 20 * mm
margin_top = 25 * mm
margin_bottom = 20 * mm
content_w = page_w - 2 * margin_x
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
# 한글 폰트: ReportLab 내장 CID 폰트(HYSMyeongJo-Medium, 한국어 지원) 등록 시도.
# 실패하면 기본 Helvetica로 폴백한다 (영문/숫자는 정상 출력, 한글은 환경에
# 따라 깨질 수 있음 - 폐쇄망 배포 시 별도 한글 TTF 등록 권장).
font_normal = "Helvetica"
font_bold = "Helvetica-Bold"
try:
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.pdfbase import pdfmetrics
pdfmetrics.registerFont(UnicodeCIDFont("HYSMyeongJo-Medium"))
font_normal = "HYSMyeongJo-Medium"
font_bold = "HYSMyeongJo-Medium"
except Exception:
pass
style_font = {
"title": (font_bold, 16),
"header": (font_bold, 12),
"footer": (font_normal, 8),
"normal": (font_normal, 10),
}
def _wrap(text, font, size, max_w):
"""긴 텍스트를 폭에 맞춰 단순 줄바꿈."""
if not text:
return [""]
lines = []
cur = ""
for ch in text:
trial = cur + ch
if stringWidth(trial, font, size) > max_w and cur:
lines.append(cur)
cur = ch
else:
cur = trial
if cur:
lines.append(cur)
return lines or [""]
SCALE = 0.6 # JRXML 좌표(px 유사) -> PDF 포인트 스케일
y_cursor = page_h - margin_top
def _new_page():
nonlocal y_cursor
c.showPage()
y_cursor = page_h - margin_top
for band in template.get("bands", []):
band_height = band.get("height", 60) * SCALE
if y_cursor - band_height < margin_bottom:
_new_page()
band_top = y_cursor
for el in band.get("elements", []):
font, size = style_font.get(el.get("style", "normal"), style_font["normal"])
ex = margin_x + el.get("x", 0) * SCALE
ey = band_top - el.get("y", 0) * SCALE - size
max_w = min(content_w - el.get("x", 0) * SCALE, el.get("width", 400) * SCALE) or content_w
if el["kind"] == "staticText":
text = el.get("text", "")
elif el["kind"] == "textField":
text = _resolve_expression(el.get("expression", ""), data)
else:
continue
c.setFont(font, size)
for i, line in enumerate(_wrap(text, font, size, max_w)):
line_y = ey - i * (size + 2)
if line_y < margin_bottom:
break
c.drawString(ex, line_y, line)
y_cursor = band_top - band_height
c.showPage()
c.save()
return buf.getvalue()
def render_to_excel(template: dict, data: dict) -> bytes:
"""파싱된 JRXML 구조 + 데이터 -> openpyxl 워크시트 렌더링.
밴드 단위로 섹션 구분 행을 추가하고, staticText/textField 요소를
레이블-값 또는 단독 텍스트 행으로 기록한다.
"""
import openpyxl
from openpyxl.styles import Font, PatternFill
wb = openpyxl.Workbook()
ws = wb.active
ws.title = (template.get("name") or "report")[:31]
header_fill = PatternFill(start_color="003366", end_color="003366", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
title_font = Font(bold=True, size=14)
section_font = Font(bold=True, size=11, color="003366")
row = 1
for band in template.get("bands", []):
band_type = band.get("type", "detail")
label = _BAND_LABELS_KO.get(band_type, band_type)
cell = ws.cell(row=row, column=1, value=f"{label}")
if band_type == "title":
cell.font = title_font
elif band_type in ("pageHeader", "columnHeader"):
cell.font = header_font
cell.fill = header_fill
else:
cell.font = section_font
row += 1
for el in band.get("elements", []):
if el["kind"] == "staticText":
text = el.get("text", "")
elif el["kind"] == "textField":
text = _resolve_expression(el.get("expression", ""), data)
else:
continue
if not text:
continue
if ":" in text and len(text.split(":", 1)[0]) <= 30:
label_part, value_part = text.split(":", 1)
ws.cell(row=row, column=1, value=label_part.strip()).font = Font(bold=True)
ws.cell(row=row, column=2, value=value_part.strip())
else:
ws.cell(row=row, column=1, value=text)
row += 1
row += 1
ws.column_dimensions["A"].width = 28
ws.column_dimensions["B"].width = 50
ws.freeze_panes = "A2"
output = io.BytesIO()
wb.save(output)
return output.getvalue()
def _render(template_dict: dict, data: dict, output_format: str) -> bytes:
fmt = (output_format or "PDF").upper()
if fmt == "EXCEL":
return render_to_excel(template_dict, data)
return render_to_pdf(template_dict, data)
# ============================================================================
# 시드 - 라우터 초기화 시 내장 템플릿 3종 등록
# ============================================================================
_SEED_DONE = False
async def _ensure_seed_templates(db: AsyncSession) -> None:
global _SEED_DONE
if _SEED_DONE:
return
try:
existing = (await db.execute(
select(func.count(JasperTemplate.id)).where(JasperTemplate.is_builtin == True)
)).scalar_one()
if not existing:
for t in BUILTIN_TEMPLATES:
db.add(JasperTemplate(
tenant_id=1,
name=t["name"],
category=t["category"],
jrxml_content=t["jrxml"],
field_mapping={},
output_format=t["format"],
is_builtin=True,
))
await db.commit()
logger.info("Jasper 내장 템플릿 3종 시드 등록 완료")
_SEED_DONE = True
except Exception:
logger.exception("Jasper 템플릿 시드 등록 실패")
await db.rollback()
# ============================================================================
# 감사 로그 헬퍼
# ============================================================================
async def _audit(db: AsyncSession, user: User, action: str, detail: str, severity: str = "INFO") -> None:
try:
db.add(AuditLog(
actor=user.username, action=action, detail=detail,
entity_type="JASPER_REPORT", severity=severity,
))
await db.flush()
except Exception:
logger.exception("Jasper 감사로그 기록 실패")
# ============================================================================
# Pydantic 스키마
# ============================================================================
class TemplateCreate(BaseModel):
name: str
category: str = Field(..., pattern="^(DELIVERABLE|MEETING_MINUTES|REPORT)$")
jrxml_content: Optional[str] = None
use_builtin: Optional[str] = Field(None, description="내장 템플릿 카테고리에서 복제 (예: REPORT)")
field_mapping: Optional[dict] = None
output_format: str = Field("PDF", pattern="^(PDF|EXCEL)$")
class DeliverableGenerateRequest(BaseModel):
template_id: Optional[int] = None
title: str = Field(..., description="산출물 문서 제목")
org_name: str = Field("지오정보기술", description="대상 기관/고객사명")
period_start: Optional[str] = None
period_end: Optional[str] = None
prepared_by: Optional[str] = None
output_format: str = Field("PDF", pattern="^(PDF|EXCEL)$")
class MeetingMinutesRequest(BaseModel):
"""회의록 생성 입력 스펙 - 전용 회의 모델 부재로 입력 JSON 직접 수신."""
template_id: Optional[int] = None
title: str = Field(..., description="회의명")
meeting_date: str = Field(..., description="회의 일시 (YYYY-MM-DD HH:MM)")
location: Optional[str] = "온라인"
chairman: Optional[str] = None
attendees: List[str] = Field(default_factory=list)
agenda_summary: Optional[str] = ""
decisions: List[str] = Field(default_factory=list)
action_items: List[str] = Field(default_factory=list)
next_meeting: Optional[str] = "미정"
output_format: str = Field("PDF", pattern="^(PDF|EXCEL)$")
class ReportGenerateRequest(BaseModel):
template_id: Optional[int] = None
title: str = Field("정기 운영 보고서", description="보고서 제목")
period_start: Optional[str] = None
period_end: Optional[str] = None
output_format: str = Field("PDF", pattern="^(PDF|EXCEL)$")
# ============================================================================
# 데이터 수집 헬퍼 (3종 시나리오)
# ============================================================================
async def _collect_deliverable_data(db: AsyncSession, req: DeliverableGenerateRequest, user: User) -> dict:
"""산출물 데이터 - tb_sr_request(SR) 기간 집계."""
today = date.today()
start = date.fromisoformat(req.period_start) if req.period_start else today.replace(day=1)
end = date.fromisoformat(req.period_end) if req.period_end else today
total = (await db.execute(
select(func.count(SRRequest.id)).where(SRRequest.created_at >= start, SRRequest.created_at <= end)
)).scalar() or 0
done = (await db.execute(
select(func.count(SRRequest.id)).where(
SRRequest.status == SRStatus.COMPLETED,
SRRequest.created_at >= start, SRRequest.created_at <= end,
)
)).scalar() or 0
open_cnt = (await db.execute(
select(func.count(SRRequest.id)).where(
SRRequest.created_at >= start, SRRequest.created_at <= end,
SRRequest.status != SRStatus.COMPLETED,
)
)).scalar() or 0
return {
"title": req.title,
"period": f"{start.isoformat()} ~ {end.isoformat()}",
"org_name": req.org_name,
"sr_total": total,
"sr_done": done,
"sr_open": open_cnt,
"completion_rate": round(done / total * 100, 1) if total else 0,
"prepared_by": req.prepared_by or user.display_name or user.username,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
"_period_start": start.isoformat(),
"_period_end": end.isoformat(),
}
def _collect_meeting_data(req: MeetingMinutesRequest) -> dict:
"""회의록 데이터 - 입력 JSON 그대로 정형화 (STT 결과/액션아이템 리스트 -> 텍스트 결합)."""
return {
"title": req.title,
"meeting_date": req.meeting_date,
"location": req.location or "-",
"chairman": req.chairman or "-",
"attendees": ", ".join(req.attendees) if req.attendees else "-",
"agenda_summary": req.agenda_summary or "-",
"decisions": " / ".join(f"({i+1}) {d}" for i, d in enumerate(req.decisions)) if req.decisions else "-",
"action_items": " / ".join(f"[{i+1}] {a}" for i, a in enumerate(req.action_items)) if req.action_items else "-",
"next_meeting": req.next_meeting or "미정",
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
async def _collect_report_data(db: AsyncSession, req: ReportGenerateRequest) -> dict:
"""보고서 데이터 - KPI/SLA 집계 (stats.py kpi_dashboard와 동일 지표 산식)."""
today = date.today()
start = date.fromisoformat(req.period_start) if req.period_start else today.replace(day=1)
end = date.fromisoformat(req.period_end) if req.period_end else today
total = (await db.execute(
select(func.count(SRRequest.id)).where(SRRequest.created_at >= start, SRRequest.created_at <= end)
)).scalar() or 0
done = (await db.execute(
select(func.count(SRRequest.id)).where(
SRRequest.status == SRStatus.COMPLETED,
SRRequest.created_at >= start, SRRequest.created_at <= end,
)
)).scalar() or 0
open_cnt = (await db.execute(
select(func.count(SRRequest.id)).where(
SRRequest.created_at >= start, SRRequest.created_at <= end,
SRRequest.status != SRStatus.COMPLETED,
)
)).scalar() or 0
breach = (await db.execute(
select(func.count(SRRequest.id)).where(
SRRequest.created_at >= start, SRRequest.created_at <= end,
SRRequest.sla_breached == True,
)
)).scalar() or 0
mttr = (await db.execute(
select(func.avg(
func.extract("epoch", SRRequest.updated_at - SRRequest.created_at) / 3600
)).where(
SRRequest.status == SRStatus.COMPLETED,
SRRequest.created_at >= start, SRRequest.created_at <= end,
)
)).scalar()
return {
"title": req.title,
"period": f"{start.isoformat()} ~ {end.isoformat()}",
"sr_total": total,
"sr_done": done,
"sr_open": open_cnt,
"completion_rate": round(done / total * 100, 1) if total else 0,
"sla_compliance_rate": round((total - breach) / total * 100, 1) if total else 100,
"mttr_hours": round(mttr or 0, 1),
"csap_score": 82.5,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
"_period_start": start.isoformat(),
"_period_end": end.isoformat(),
}
# ============================================================================
# 템플릿 조회/등록
# ============================================================================
@router.get("/templates")
async def list_templates(
category: Optional[str] = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""등록된 JRXML 템플릿 목록 (산출물/회의록/보고서 카테고리)."""
await _ensure_seed_templates(db)
tenant_id = getattr(user, "tenant_id", 1)
stmt = select(JasperTemplate).where(
(JasperTemplate.tenant_id == tenant_id) | (JasperTemplate.is_builtin == True)
)
if category:
stmt = stmt.where(JasperTemplate.category == category.upper())
stmt = stmt.order_by(JasperTemplate.is_builtin.desc(), JasperTemplate.created_at.desc())
rows = (await db.execute(stmt)).scalars().all()
return {
"ok": True,
"count": len(rows),
"templates": [
{
"id": t.id,
"name": t.name,
"category": t.category,
"output_format": t.output_format,
"is_builtin": t.is_builtin,
"field_count": len(parse_jrxml(t.jrxml_content).get("fields", [])) if t.jrxml_content else 0,
"created_at": t.created_at,
}
for t in rows
],
"categories": ["DELIVERABLE", "MEETING_MINUTES", "REPORT"],
}
@router.post("/templates")
async def create_template(
req: TemplateCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""신규 JRXML 템플릿 등록 - 직접 업로드(jrxml_content) 또는 내장 템플릿 복제(use_builtin)."""
await _ensure_seed_templates(db)
jrxml = req.jrxml_content
if not jrxml and req.use_builtin:
builtin = next((t for t in BUILTIN_TEMPLATES if t["category"] == req.use_builtin.upper()), None)
if not builtin:
raise HTTPException(400, f"내장 템플릿 카테고리 없음: {req.use_builtin}")
jrxml = builtin["jrxml"]
if not jrxml:
raise HTTPException(400, "jrxml_content 또는 use_builtin 중 하나는 필수입니다")
try:
parsed = parse_jrxml(jrxml)
except ValueError as e:
raise HTTPException(400, str(e))
tenant_id = getattr(user, "tenant_id", 1)
tpl = JasperTemplate(
tenant_id=tenant_id,
name=req.name,
category=req.category,
jrxml_content=jrxml,
field_mapping=req.field_mapping or {},
output_format=req.output_format,
is_builtin=False,
)
db.add(tpl)
await db.flush()
await _audit(db, user, "JASPER_TEMPLATE_CREATE",
f"템플릿 '{req.name}' 등록 (카테고리={req.category}, 필드 {len(parsed['fields'])}개)")
await db.commit()
await db.refresh(tpl)
return {
"ok": True, "id": tpl.id, "name": tpl.name, "category": tpl.category,
"fields": parsed["fields"], "band_count": len(parsed["bands"]),
"message": "템플릿이 등록되었습니다",
}
# ============================================================================
# 문서 생성 3종
# ============================================================================
async def _pick_template(db: AsyncSession, template_id: Optional[int], category: str, tenant_id: int) -> JasperTemplate:
await _ensure_seed_templates(db)
if template_id:
row = (await db.execute(
select(JasperTemplate).where(
JasperTemplate.id == template_id,
(JasperTemplate.tenant_id == tenant_id) | (JasperTemplate.is_builtin == True),
)
)).scalar_one_or_none()
if not row:
raise HTTPException(404, "템플릿을 찾을 수 없습니다")
return row
row = (await db.execute(
select(JasperTemplate)
.where(JasperTemplate.category == category, JasperTemplate.is_builtin == True)
.order_by(JasperTemplate.id.asc())
.limit(1)
)).scalar_one_or_none()
if not row:
raise HTTPException(500, f"카테고리 {category}의 기본 템플릿이 없습니다 - 시드 등록 실패")
return row
async def _create_job_and_render(
db: AsyncSession, user: User, template: JasperTemplate,
category: str, title: str, data: dict, output_format: str,
) -> JasperGenerationJob:
tenant_id = getattr(user, "tenant_id", 1)
job = JasperGenerationJob(
tenant_id=tenant_id,
template_id=template.id,
category=category,
title=title,
data_source=data,
status="PENDING",
output_format=output_format,
requested_by=user.id,
)
db.add(job)
await db.flush()
try:
parsed = parse_jrxml(template.jrxml_content)
rendered = _render(parsed, data, output_format)
ext = "xlsx" if output_format.upper() == "EXCEL" else "pdf"
job.status = "DONE"
job.output_path = f"jasper/{category.lower()}/job_{job.id}.{ext}"
job.file_size = len(rendered)
except Exception as e:
logger.exception("Jasper 문서 렌더링 실패 (job_id=%s)", job.id)
job.status = "FAILED"
job.error_msg = str(e)[:500]
await _audit(
db, user,
f"JASPER_GENERATE_{category}",
f"문서 생성 [{title}] -> {job.status} (템플릿={template.name}, 형식={output_format})",
severity="INFO" if job.status == "DONE" else "WARN",
)
await db.commit()
await db.refresh(job)
return job
@router.post("/generate/deliverable")
async def generate_deliverable(
req: DeliverableGenerateRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""산출물 문서 생성 - SR 데이터 기간 집계 -> PDF/Excel."""
tenant_id = getattr(user, "tenant_id", 1)
template = await _pick_template(db, req.template_id, "DELIVERABLE", tenant_id)
data = await _collect_deliverable_data(db, req, user)
job = await _create_job_and_render(db, user, template, "DELIVERABLE", req.title, data, req.output_format)
return {
"ok": job.status == "DONE",
"job_id": job.id,
"status": job.status,
"title": job.title,
"output_format": job.output_format,
"data_summary": {k: v for k, v in data.items() if not k.startswith("_")},
"download_url": f"/api/jasper/jobs/{job.id}/download" if job.status == "DONE" else None,
"error": job.error_msg,
}
@router.post("/generate/meeting-minutes")
async def generate_meeting_minutes(
req: MeetingMinutesRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""회의록 생성 - 회의 메타데이터 + 액션아이템(입력 JSON) -> PDF/Excel."""
tenant_id = getattr(user, "tenant_id", 1)
template = await _pick_template(db, req.template_id, "MEETING_MINUTES", tenant_id)
data = _collect_meeting_data(req)
job = await _create_job_and_render(db, user, template, "MEETING_MINUTES", req.title, data, req.output_format)
return {
"ok": job.status == "DONE",
"job_id": job.id,
"status": job.status,
"title": job.title,
"output_format": job.output_format,
"data_summary": {k: v for k, v in data.items() if not k.startswith("_")},
"download_url": f"/api/jasper/jobs/{job.id}/download" if job.status == "DONE" else None,
"error": job.error_msg,
}
@router.post("/generate/report")
async def generate_report(
req: ReportGenerateRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""정기/특별 보고서 생성 - KPI/SLA 집계 데이터 -> PDF/Excel."""
tenant_id = getattr(user, "tenant_id", 1)
template = await _pick_template(db, req.template_id, "REPORT", tenant_id)
data = await _collect_report_data(db, req)
job = await _create_job_and_render(db, user, template, "REPORT", req.title, data, req.output_format)
return {
"ok": job.status == "DONE",
"job_id": job.id,
"status": job.status,
"title": job.title,
"output_format": job.output_format,
"data_summary": {k: v for k, v in data.items() if not k.startswith("_")},
"download_url": f"/api/jasper/jobs/{job.id}/download" if job.status == "DONE" else None,
"error": job.error_msg,
}
# ============================================================================
# 작업 상태 / 다운로드 / 이력
# ============================================================================
async def _get_job_for_user(db: AsyncSession, job_id: int, user: User) -> JasperGenerationJob:
tenant_id = getattr(user, "tenant_id", 1)
row = (await db.execute(
select(JasperGenerationJob).where(
JasperGenerationJob.id == job_id,
JasperGenerationJob.tenant_id == tenant_id,
)
)).scalar_one_or_none()
if not row:
raise HTTPException(404, "생성 작업을 찾을 수 없습니다")
return row
@router.get("/jobs/{job_id}")
async def get_job_status(
job_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""문서 생성 작업 상태 조회 - 완료 시 다운로드 링크 포함."""
job = await _get_job_for_user(db, job_id, user)
return {
"id": job.id,
"category": job.category,
"title": job.title,
"status": job.status,
"output_format": job.output_format,
"file_size": job.file_size,
"error": job.error_msg,
"created_at": job.created_at,
"download_url": f"/api/jasper/jobs/{job.id}/download" if job.status == "DONE" else None,
}
@router.get("/jobs/{job_id}/download")
async def download_job_output(
job_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""완성된 문서 다운로드 - 작업 시점 데이터 스냅샷으로 동일 결과 재렌더링."""
job = await _get_job_for_user(db, job_id, user)
if job.status != "DONE":
raise HTTPException(409, f"문서가 아직 준비되지 않았습니다 (상태={job.status})")
template = (await db.execute(
select(JasperTemplate).where(JasperTemplate.id == job.template_id)
)).scalar_one_or_none()
if not template:
raise HTTPException(404, "원본 템플릿을 찾을 수 없어 재생성이 불가합니다")
try:
parsed = parse_jrxml(template.jrxml_content)
rendered = _render(parsed, job.data_source or {}, job.output_format)
except Exception as e:
logger.exception("Jasper 다운로드 재렌더링 실패 (job_id=%s)", job.id)
raise HTTPException(500, "문서 재생성 중 오류가 발생했습니다") from e
await _audit(db, user, "JASPER_DOWNLOAD", f"문서 다운로드 [{job.title}] (job_id={job.id})")
await db.commit()
safe_title = "".join(c for c in (job.title or "document") if c.isalnum() or c in (" ", "_", "-")).strip() or "document"
if job.output_format.upper() == "EXCEL":
media = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
filename = f"{safe_title}_{job.id}.xlsx"
else:
media = "application/pdf"
filename = f"{safe_title}_{job.id}.pdf"
return Response(
content=rendered,
media_type=media,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/history")
async def generation_history(
category: Optional[str] = None,
status: Optional[str] = None,
limit: int = 50,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""문서 생성 이력 조회 (그리드) - 카테고리/상태 필터."""
tenant_id = getattr(user, "tenant_id", 1)
q = select(JasperGenerationJob).where(JasperGenerationJob.tenant_id == tenant_id)
if category:
q = q.where(JasperGenerationJob.category == category.upper())
if status:
q = q.where(JasperGenerationJob.status == status.upper())
q = q.order_by(JasperGenerationJob.created_at.desc()).limit(min(limit, 200))
rows = (await db.execute(q)).scalars().all()
return {
"total": len(rows),
"items": [
{
"id": r.id,
"category": r.category,
"title": r.title,
"status": r.status,
"output_format": r.output_format,
"file_size": r.file_size,
"created_at": r.created_at,
"download_url": f"/api/jasper/jobs/{r.id}/download" if r.status == "DONE" else None,
}
for r in rows
],
}