[PMS 완성] - core/si_report.py: 일/주/월 보고서 (Excel/HTML/PDF/DOCX/PPTX) - routers/si_report.py: daily|weekly|monthly + 메신저 발송 - routers/deliverables.py: 산출물 CRUD + 제출/검토 - si_issues.py: 이슈→SR 자동 연결 - scheduler.py: 일일 18:00 + 주간 금 17:00 자동 보고서 - models.py: Deliverable 모델 [준수성 자동 점검] - core/compliance_check.py: SC-8개/WA-7개/PI-6개 규칙 - routers/compliance.py: 스캔 + HTML/Excel 보고서 [JMeter 성능 테스트] - routers/jmeter.py: JTL 업로드 + 내장 부하 테스트 + 보고서 [공공기관 필수 기능] - routers/public_checklist.py: 행안부 기준 19개 항목 [UI/브랜드] - 로고(ziologo.png) + Copyright 2026 All Rights Reserved - Nifty 계층형 사이드바 (PMS 서브메뉴) - X-Powered-By + X-Copyright 응답 헤더 - manual/15_UI_Nifty_가이드.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
684 lines
28 KiB
Python
684 lines
28 KiB
Python
"""
|
|
SI 프로젝트 보고서 자동 생성 엔진
|
|
|
|
지원 형식:
|
|
- Excel (.xlsx): WBS 현황 + 이슈 목록 + 산출물 현황 + KPI
|
|
- HTML: 웹 보고서 (대시보드 내 표시)
|
|
- PDF: HTML → PDF 변환 (weasyprint)
|
|
- DOCX: Word 보고서 (python-docx)
|
|
- PPTX: PowerPoint 보고서 (python-pptx)
|
|
|
|
보고서 유형:
|
|
- daily: 일일 진행 현황 (당일 WBS 완료 + 이슈 발생)
|
|
- weekly: 주간 보고서 (완료율 + 이슈 + 위험 요약)
|
|
- monthly: 월간 보고서 (KPI + 예산 + 이달의 산출물)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import logging
|
|
import os
|
|
from datetime import date, datetime, timedelta
|
|
from typing import Any, Dict, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── 프로젝트 데이터 수집 ──────────────────────────────────────────────────────
|
|
|
|
async def collect_project_data(project_id: int, db) -> Dict[str, Any]:
|
|
"""보고서에 필요한 전체 프로젝트 데이터 수집."""
|
|
from models import (
|
|
SiProject, WbsItem, WbsStatus,
|
|
ProjectIssue, IssueStatus,
|
|
ProjectMilestone,
|
|
ProjectRisk, RiskLevel,
|
|
Deliverable, DeliverableStatus,
|
|
)
|
|
from sqlalchemy import select, func
|
|
|
|
proj = await db.get(SiProject, project_id)
|
|
if not proj:
|
|
raise ValueError(f"프로젝트 {project_id}를 찾을 수 없습니다.")
|
|
|
|
today = date.today()
|
|
|
|
# WBS 항목
|
|
wbs_items = (await db.execute(
|
|
select(WbsItem).where(WbsItem.project_id == project_id)
|
|
.order_by(WbsItem.wbs_code)
|
|
)).scalars().all()
|
|
|
|
leaf_items = [w for w in wbs_items if w.is_leaf]
|
|
completed = [w for w in leaf_items if w.completion_pct >= 100]
|
|
delayed = [w for w in leaf_items if w.planned_end and w.planned_end < today and w.completion_pct < 100]
|
|
|
|
# 이슈
|
|
issues = (await db.execute(
|
|
select(ProjectIssue).where(ProjectIssue.project_id == project_id)
|
|
.order_by(ProjectIssue.raised_date.desc())
|
|
)).scalars().all()
|
|
open_issues = [i for i in issues if i.status in (IssueStatus.OPEN, IssueStatus.IN_PROGRESS)]
|
|
closed_issues = [i for i in issues if i.status == IssueStatus.CLOSED]
|
|
|
|
# 마일스톤
|
|
milestones = (await db.execute(
|
|
select(ProjectMilestone).where(ProjectMilestone.project_id == project_id)
|
|
.order_by(ProjectMilestone.target_date)
|
|
)).scalars().all()
|
|
upcoming_milestones = [
|
|
m for m in milestones if m.target_date and m.target_date >= today
|
|
][:3]
|
|
|
|
# 위험
|
|
risks = (await db.execute(
|
|
select(ProjectRisk).where(ProjectRisk.project_id == project_id)
|
|
)).scalars().all()
|
|
high_risks = [r for r in risks if r.risk_level in (RiskLevel.HIGH, RiskLevel.CRITICAL)]
|
|
|
|
# 산출물
|
|
deliverables = (await db.execute(
|
|
select(Deliverable).where(Deliverable.project_id == project_id)
|
|
)).scalars().all()
|
|
overdue_deliverables = [
|
|
d for d in deliverables
|
|
if d.status == "PENDING" and d.due_date and d.due_date < today
|
|
]
|
|
|
|
# 진척률 계산
|
|
overall_progress = (
|
|
sum(w.completion_pct for w in leaf_items) / len(leaf_items)
|
|
if leaf_items else 0
|
|
)
|
|
|
|
# 예산 소진율
|
|
budget_pct = (
|
|
round(proj.budget_used / proj.budget_total * 100, 1)
|
|
if proj.budget_total else 0.0
|
|
)
|
|
|
|
return {
|
|
"project": proj,
|
|
"today": today,
|
|
"wbs_items": wbs_items,
|
|
"leaf_items": leaf_items,
|
|
"completed_wbs": completed,
|
|
"delayed_wbs": delayed,
|
|
"issues": issues,
|
|
"open_issues": open_issues,
|
|
"closed_issues": closed_issues,
|
|
"milestones": milestones,
|
|
"upcoming_milestones": upcoming_milestones,
|
|
"risks": risks,
|
|
"high_risks": high_risks,
|
|
"deliverables": deliverables,
|
|
"overdue_deliverables": overdue_deliverables,
|
|
"overall_progress": round(overall_progress, 1),
|
|
"budget_pct": budget_pct,
|
|
"wbs_total": len(leaf_items),
|
|
"wbs_done": len(completed),
|
|
"wbs_delayed": len(delayed),
|
|
"issue_open": len(open_issues),
|
|
"issue_closed": len(closed_issues),
|
|
}
|
|
|
|
|
|
# ── Excel 보고서 ──────────────────────────────────────────────────────────────
|
|
|
|
def generate_excel(data: Dict[str, Any], report_type: str = "weekly") -> bytes:
|
|
"""Excel (.xlsx) 보고서 생성."""
|
|
try:
|
|
import openpyxl
|
|
from openpyxl.styles import (
|
|
Font, PatternFill, Alignment, Border, Side, numbers
|
|
)
|
|
from openpyxl.utils import get_column_letter
|
|
except ImportError:
|
|
raise ValueError("openpyxl 미설치: pip install openpyxl")
|
|
|
|
proj = data["project"]
|
|
today = data["today"]
|
|
|
|
wb = openpyxl.Workbook()
|
|
wb.remove(wb.active)
|
|
|
|
# ── 공통 스타일 ────────────────────────────────────────────
|
|
HEADER_FILL = PatternFill("solid", fgColor="1E3A5F")
|
|
HEADER_FONT = Font(bold=True, color="FFFFFF", size=11)
|
|
TITLE_FONT = Font(bold=True, size=13, color="1E3A5F")
|
|
OK_FILL = PatternFill("solid", fgColor="D5F5E3")
|
|
WARN_FILL = PatternFill("solid", fgColor="FEF9E7")
|
|
DANGER_FILL = PatternFill("solid", fgColor="FADBD8")
|
|
CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
|
LEFT = Alignment(horizontal="left", vertical="center", wrap_text=True)
|
|
thin = Side(style="thin", color="CCCCCC")
|
|
BORDER = Border(left=thin, right=thin, top=thin, bottom=thin)
|
|
|
|
def header_row(ws, headers, row=1):
|
|
for col, h in enumerate(headers, 1):
|
|
c = ws.cell(row=row, column=col, value=h)
|
|
c.font = HEADER_FONT; c.fill = HEADER_FILL
|
|
c.alignment = CENTER; c.border = BORDER
|
|
|
|
def data_row(ws, values, row, fills=None):
|
|
for col, val in enumerate(values, 1):
|
|
c = ws.cell(row=row, column=col, value=val)
|
|
c.alignment = LEFT; c.border = BORDER
|
|
if fills and col-1 < len(fills) and fills[col-1]:
|
|
c.fill = fills[col-1]
|
|
|
|
# ── 시트 1: 프로젝트 개요 ─────────────────────────────────
|
|
ws1 = wb.create_sheet("프로젝트 개요")
|
|
ws1.column_dimensions["A"].width = 22
|
|
ws1.column_dimensions["B"].width = 35
|
|
|
|
ws1["A1"] = f"📋 {proj.project_name}"
|
|
ws1["A1"].font = TITLE_FONT
|
|
ws1.merge_cells("A1:D1")
|
|
|
|
kpi_rows = [
|
|
("프로젝트 코드", proj.project_code),
|
|
("현재 단계", proj.phase),
|
|
("전체 진척률", f"{data['overall_progress']}%"),
|
|
("건강 상태", proj.health_status),
|
|
("계획 기간", f"{proj.planned_start} ~ {proj.planned_end}"),
|
|
("PM", proj.pm_user or "—"),
|
|
("예산 소진율", f"{data['budget_pct']}%"),
|
|
("WBS 완료 수", f"{data['wbs_done']} / {data['wbs_total']}"),
|
|
("미결 이슈", f"{data['issue_open']}건"),
|
|
("지연 WBS", f"{data['wbs_delayed']}건"),
|
|
("보고일", str(today)),
|
|
]
|
|
for r, (k, v) in enumerate(kpi_rows, 3):
|
|
ws1.cell(row=r, column=1, value=k).font = Font(bold=True)
|
|
ws1.cell(row=r, column=2, value=v)
|
|
|
|
# ── 시트 2: WBS 현황 ──────────────────────────────────────
|
|
ws2 = wb.create_sheet("WBS 현황")
|
|
for col, w in zip("ABCDEFG", [10, 40, 20, 15, 15, 12, 25]):
|
|
ws2.column_dimensions[col].width = w
|
|
|
|
header_row(ws2, ["WBS 코드", "제목", "단계", "예정 시작", "예정 완료", "진척률(%)", "상태"])
|
|
for r, item in enumerate(data["wbs_items"], 2):
|
|
status = "완료" if item.completion_pct >= 100 else (
|
|
"지연" if item.planned_end and item.planned_end < today and item.completion_pct < 100
|
|
else "진행중"
|
|
)
|
|
fill = OK_FILL if status == "완료" else (DANGER_FILL if status == "지연" else None)
|
|
fills = [None, fill, None, None, None, fill, fill]
|
|
data_row(ws2, [
|
|
item.wbs_code, item.title, item.phase or "",
|
|
str(item.planned_start or ""), str(item.planned_end or ""),
|
|
item.completion_pct, status,
|
|
], r, fills)
|
|
|
|
# ── 시트 3: 이슈 목록 ─────────────────────────────────────
|
|
ws3 = wb.create_sheet("이슈 관리")
|
|
for col, w in zip("ABCDEFG", [18, 15, 35, 20, 12, 18, 30]):
|
|
ws3.column_dimensions[col].width = w
|
|
|
|
header_row(ws3, ["이슈 ID", "유형", "제목", "담당자", "심각도", "상태", "발생일"])
|
|
for r, iss in enumerate(data["issues"], 2):
|
|
severity_fill = DANGER_FILL if getattr(iss, "severity", "") in ("CRITICAL", "HIGH") else None
|
|
data_row(ws3, [
|
|
iss.issue_id, iss.issue_type, iss.title,
|
|
iss.assigned_to or "—", getattr(iss, "severity", "—"),
|
|
iss.status, str(iss.raised_date or ""),
|
|
], r, [None, None, None, None, severity_fill, None, None])
|
|
|
|
# ── 시트 4: 산출물 현황 ───────────────────────────────────
|
|
ws4 = wb.create_sheet("산출물 현황")
|
|
for col, w in zip("ABCDEF", [30, 15, 15, 15, 15, 20]):
|
|
ws4.column_dimensions[col].width = w
|
|
|
|
header_row(ws4, ["산출물명", "유형", "버전", "상태", "제출기한", "제출일"])
|
|
for r, dlv in enumerate(data["deliverables"], 2):
|
|
status_fill = (
|
|
OK_FILL if dlv.status == "APPROVED" else
|
|
DANGER_FILL if dlv.status == "REJECTED" else
|
|
WARN_FILL if dlv.status == "REVIEWING" else
|
|
DANGER_FILL if dlv.due_date and dlv.due_date < today and dlv.status == "PENDING"
|
|
else None
|
|
)
|
|
data_row(ws4, [
|
|
dlv.name, dlv.deliverable_type, dlv.version,
|
|
dlv.status,
|
|
str(dlv.due_date or ""),
|
|
str(dlv.submitted_at.date() if dlv.submitted_at else "—"),
|
|
], r, [None, None, None, status_fill, None, None])
|
|
|
|
# ── 시트 5: 위험 관리 ─────────────────────────────────────
|
|
ws5 = wb.create_sheet("위험 관리")
|
|
for col, w in zip("ABCDE", [18, 35, 12, 12, 40]):
|
|
ws5.column_dimensions[col].width = w
|
|
|
|
header_row(ws5, ["위험 ID", "제목", "레벨", "상태", "대응 계획"])
|
|
for r, risk in enumerate(data["risks"], 2):
|
|
fill = DANGER_FILL if getattr(risk, "risk_level", "") in ("HIGH", "CRITICAL") else WARN_FILL
|
|
data_row(ws5, [
|
|
risk.risk_id, risk.title,
|
|
getattr(risk, "risk_level", "—"), risk.status,
|
|
risk.mitigation or "",
|
|
], r, [None, None, fill, None, None])
|
|
|
|
buf = io.BytesIO()
|
|
wb.save(buf)
|
|
return buf.getvalue()
|
|
|
|
|
|
# ── HTML 보고서 ──────────────────────────────────────────────────────────────
|
|
|
|
def generate_html(data: Dict[str, Any], report_type: str = "weekly") -> str:
|
|
"""HTML 보고서 생성 (대시보드 표시 + PDF 변환용)."""
|
|
try:
|
|
from jinja2 import Template
|
|
except ImportError:
|
|
raise ValueError("jinja2 미설치: pip install jinja2")
|
|
|
|
proj = data["project"]
|
|
today = data["today"]
|
|
|
|
template_str = """<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<style>
|
|
body { font-family: 'Noto Sans KR', Arial, sans-serif; margin: 30px; color: #333; font-size: 13px; }
|
|
h1 { color: #1e3a5f; border-bottom: 3px solid #1e3a5f; padding-bottom: 8px; }
|
|
h2 { color: #2c5282; margin-top: 24px; font-size: 15px; }
|
|
.kpi-grid { display: flex; gap: 16px; flex-wrap: wrap; margin: 16px 0; }
|
|
.kpi-card { background: #ebf8ff; border-radius: 8px; padding: 12px 18px; min-width: 130px; text-align: center; }
|
|
.kpi-card .value { font-size: 24px; font-weight: bold; color: #2b6cb0; }
|
|
.kpi-card .label { font-size: 11px; color: #718096; margin-top: 4px; }
|
|
table { border-collapse: collapse; width: 100%; margin-bottom: 16px; }
|
|
th { background: #2d3748; color: white; padding: 8px; text-align: left; font-size: 12px; }
|
|
td { padding: 7px 8px; border-bottom: 1px solid #e2e8f0; }
|
|
tr:nth-child(even) { background: #f7fafc; }
|
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: bold; }
|
|
.ok { background: #c6f6d5; color: #276749; }
|
|
.warn { background: #fefcbf; color: #744210; }
|
|
.danger{ background: #fed7d7; color: #c53030; }
|
|
.meta { color: #718096; font-size: 12px; }
|
|
@media print { .no-print { display: none; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>{{ report_title }}</h1>
|
|
<p class="meta">프로젝트: {{ proj.project_name }} ({{ proj.project_code }}) | 보고일: {{ today }} | 단계: {{ proj.phase }}</p>
|
|
|
|
<h2>📊 KPI 현황</h2>
|
|
<div class="kpi-grid">
|
|
<div class="kpi-card"><div class="value">{{ data.overall_progress }}%</div><div class="label">전체 진척률</div></div>
|
|
<div class="kpi-card"><div class="value">{{ data.wbs_done }}/{{ data.wbs_total }}</div><div class="label">WBS 완료</div></div>
|
|
<div class="kpi-card"><div class="value {{ 'danger' if data.wbs_delayed > 0 else 'ok' }}">{{ data.wbs_delayed }}</div><div class="label">지연 항목</div></div>
|
|
<div class="kpi-card"><div class="value {{ 'danger' if data.issue_open > 0 else 'ok' }}">{{ data.issue_open }}</div><div class="label">미결 이슈</div></div>
|
|
<div class="kpi-card"><div class="value">{{ data.budget_pct }}%</div><div class="label">예산 소진율</div></div>
|
|
<div class="kpi-card"><div class="value {{ 'danger' if proj.health_status == 'RED' else 'warn' if proj.health_status == 'YELLOW' else 'ok' }}">{{ proj.health_status }}</div><div class="label">건강 상태</div></div>
|
|
</div>
|
|
|
|
<h2>📋 WBS 지연 현황 ({{ data.wbs_delayed }}건)</h2>
|
|
{% if data.delayed_wbs %}
|
|
<table>
|
|
<tr><th>WBS 코드</th><th>제목</th><th>예정 완료</th><th>진척률</th></tr>
|
|
{% for item in data.delayed_wbs %}
|
|
<tr>
|
|
<td>{{ item.wbs_code }}</td>
|
|
<td>{{ item.title }}</td>
|
|
<td><span class="badge danger">{{ item.planned_end }}</span></td>
|
|
<td>{{ item.completion_pct }}%</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
{% else %}<p style="color:#48bb78">✅ 지연 WBS 없음</p>{% endif %}
|
|
|
|
<h2>🔴 미결 이슈 ({{ data.issue_open }}건)</h2>
|
|
{% if data.open_issues %}
|
|
<table>
|
|
<tr><th>이슈 ID</th><th>제목</th><th>유형</th><th>담당자</th><th>발생일</th></tr>
|
|
{% for iss in data.open_issues[:10] %}
|
|
<tr>
|
|
<td>{{ iss.issue_id }}</td>
|
|
<td>{{ iss.title }}</td>
|
|
<td>{{ iss.issue_type }}</td>
|
|
<td>{{ iss.assigned_to or '—' }}</td>
|
|
<td>{{ iss.raised_date }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
{% else %}<p style="color:#48bb78">✅ 미결 이슈 없음</p>{% endif %}
|
|
|
|
<h2>📦 산출물 제출 현황</h2>
|
|
<table>
|
|
<tr><th>산출물명</th><th>유형</th><th>버전</th><th>상태</th><th>제출기한</th></tr>
|
|
{% for dlv in data.deliverables %}
|
|
<tr>
|
|
<td>{{ dlv.name }}</td>
|
|
<td>{{ dlv.deliverable_type }}</td>
|
|
<td>{{ dlv.version }}</td>
|
|
<td><span class="badge {{ 'ok' if dlv.status == 'APPROVED' else 'danger' if dlv.status == 'REJECTED' else 'warn' }}">{{ dlv.status }}</span></td>
|
|
<td>{{ dlv.due_date or '—' }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
|
|
{% if data.upcoming_milestones %}
|
|
<h2>🏁 다가오는 마일스톤</h2>
|
|
<table>
|
|
<tr><th>마일스톤</th><th>목표일</th><th>상태</th></tr>
|
|
{% for m in data.upcoming_milestones %}
|
|
<tr><td>{{ m.name }}</td><td>{{ m.target_date }}</td><td>{{ m.status }}</td></tr>
|
|
{% endfor %}
|
|
</table>
|
|
{% endif %}
|
|
|
|
{% if data.high_risks %}
|
|
<h2>⚠️ 고위험 요소 ({{ data.high_risks|length }}건)</h2>
|
|
<table>
|
|
<tr><th>위험 ID</th><th>제목</th><th>레벨</th><th>대응 계획</th></tr>
|
|
{% for r in data.high_risks %}
|
|
<tr>
|
|
<td>{{ r.risk_id }}</td>
|
|
<td>{{ r.title }}</td>
|
|
<td><span class="badge danger">{{ r.risk_level }}</span></td>
|
|
<td>{{ r.mitigation or '—' }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
{% endif %}
|
|
|
|
<p class="meta" style="margin-top:32px;border-top:1px solid #e2e8f0;padding-top:12px">
|
|
생성: GUARDiA ITSM | {{ today }}
|
|
</p>
|
|
</body>
|
|
</html>"""
|
|
|
|
title_map = {
|
|
"daily": f"일일 보고서 ({today})",
|
|
"weekly": f"주간 보고서 ({today} 기준)",
|
|
"monthly": f"월간 보고서 ({today.strftime('%Y년 %m월')})",
|
|
}
|
|
|
|
t = Template(template_str)
|
|
return t.render(
|
|
proj=proj,
|
|
data=data,
|
|
today=today,
|
|
report_title=title_map.get(report_type, "보고서"),
|
|
)
|
|
|
|
|
|
# ── PDF 보고서 ───────────────────────────────────────────────────────────────
|
|
|
|
def generate_pdf(html_content: str) -> bytes:
|
|
"""HTML → PDF 변환."""
|
|
try:
|
|
import weasyprint
|
|
pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
|
|
return pdf_bytes
|
|
except ImportError:
|
|
logger.warning("weasyprint 미설치 — HTML 반환")
|
|
return html_content.encode("utf-8")
|
|
except Exception as e:
|
|
logger.error("PDF 변환 오류: %s", e)
|
|
return html_content.encode("utf-8")
|
|
|
|
|
|
# ── DOCX 보고서 ──────────────────────────────────────────────────────────────
|
|
|
|
def generate_docx(data: Dict[str, Any], report_type: str = "weekly") -> bytes:
|
|
"""Word (.docx) 보고서 생성."""
|
|
try:
|
|
from docx import Document
|
|
from docx.shared import Pt, RGBColor, Cm
|
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
except ImportError:
|
|
raise ValueError("python-docx 미설치: pip install python-docx")
|
|
|
|
proj = data["project"]
|
|
today = data["today"]
|
|
doc = Document()
|
|
|
|
# 제목
|
|
rtype_kor = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(report_type, "")
|
|
title = doc.add_heading(
|
|
f"{proj.project_name} — {rtype_kor}보고서",
|
|
level=0
|
|
)
|
|
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
|
doc.add_paragraph(f"보고일: {today} | 단계: {proj.phase} | PM: {proj.pm_user or '—'}")
|
|
|
|
# KPI 테이블
|
|
doc.add_heading("KPI 현황", level=1)
|
|
kpi_table = doc.add_table(rows=1, cols=4)
|
|
kpi_table.style = "Table Grid"
|
|
for cell, text in zip(kpi_table.rows[0].cells, ["항목", "계획", "실적", "상태"]):
|
|
cell.text = text
|
|
|
|
kpi_rows = [
|
|
("전체 진척률", "—", f"{data['overall_progress']}%",
|
|
"양호" if data['overall_progress'] >= 80 else "주의"),
|
|
("WBS 완료율", f"{data['wbs_total']}건",
|
|
f"{data['wbs_done']}건 ({round(data['wbs_done']/data['wbs_total']*100 if data['wbs_total'] else 0, 1)}%)",
|
|
"양호" if data['wbs_delayed'] == 0 else "지연"),
|
|
("예산 소진율", "100%", f"{data['budget_pct']}%",
|
|
"양호" if data['budget_pct'] <= 80 else "주의"),
|
|
("미결 이슈", "0건", f"{data['issue_open']}건",
|
|
"양호" if data['issue_open'] == 0 else "주의"),
|
|
]
|
|
for row_data in kpi_rows:
|
|
row = kpi_table.add_row()
|
|
for cell, val in zip(row.cells, row_data):
|
|
cell.text = val
|
|
|
|
# 지연 WBS
|
|
if data["delayed_wbs"]:
|
|
doc.add_heading(f"WBS 지연 현황 ({data['wbs_delayed']}건)", level=1)
|
|
t = doc.add_table(rows=1, cols=4)
|
|
t.style = "Table Grid"
|
|
for c, h in zip(t.rows[0].cells, ["WBS 코드", "제목", "예정 완료", "진척률"]):
|
|
c.text = h
|
|
for item in data["delayed_wbs"]:
|
|
r = t.add_row()
|
|
for c, v in zip(r.cells, [item.wbs_code, item.title, str(item.planned_end), f"{item.completion_pct}%"]):
|
|
c.text = v
|
|
|
|
# 이슈
|
|
if data["open_issues"]:
|
|
doc.add_heading(f"미결 이슈 ({data['issue_open']}건)", level=1)
|
|
t = doc.add_table(rows=1, cols=4)
|
|
t.style = "Table Grid"
|
|
for c, h in zip(t.rows[0].cells, ["이슈 ID", "제목", "담당자", "발생일"]):
|
|
c.text = h
|
|
for iss in data["open_issues"][:10]:
|
|
r = t.add_row()
|
|
for c, v in zip(r.cells, [iss.issue_id, iss.title, iss.assigned_to or "—", str(iss.raised_date or "")]):
|
|
c.text = v
|
|
|
|
doc.add_paragraph(f"\n생성: GUARDiA ITSM | {today}")
|
|
|
|
buf = io.BytesIO()
|
|
doc.save(buf)
|
|
return buf.getvalue()
|
|
|
|
|
|
# ── PPTX 보고서 ──────────────────────────────────────────────────────────────
|
|
|
|
def generate_pptx(data: Dict[str, Any], report_type: str = "weekly") -> bytes:
|
|
"""PowerPoint (.pptx) 보고서 생성."""
|
|
try:
|
|
from pptx import Presentation
|
|
from pptx.util import Inches, Pt, Emu
|
|
from pptx.dml.color import RGBColor
|
|
from pptx.enum.text import PP_ALIGN
|
|
except ImportError:
|
|
raise ValueError("python-pptx 미설치: pip install python-pptx")
|
|
|
|
proj = data["project"]
|
|
today = data["today"]
|
|
prs = Presentation()
|
|
prs.slide_width = Inches(13.33)
|
|
prs.slide_height = Inches(7.5)
|
|
|
|
DARK_BLUE = RGBColor(0x1E, 0x3A, 0x5F)
|
|
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
|
|
|
|
def add_slide(layout_idx=5):
|
|
layout = prs.slide_layouts[layout_idx]
|
|
return prs.slides.add_slide(layout)
|
|
|
|
def set_bg(slide, color=RGBColor(0xF7, 0xFA, 0xFC)):
|
|
from pptx.util import Emu
|
|
fill = slide.background.fill
|
|
fill.solid()
|
|
fill.fore_color.rgb = color
|
|
|
|
def add_text_box(slide, text, left, top, width, height, font_size=14, bold=False, color=None):
|
|
txBox = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
|
|
tf = txBox.text_frame
|
|
tf.word_wrap = True
|
|
p = tf.paragraphs[0]
|
|
run = p.add_run()
|
|
run.text = text
|
|
run.font.size = Pt(font_size)
|
|
run.font.bold = bold
|
|
if color:
|
|
run.font.color.rgb = color
|
|
return txBox
|
|
|
|
# ── 슬라이드 1: 표지 ──────────────────────────────────────
|
|
slide1 = add_slide(6) # blank
|
|
set_bg(slide1, DARK_BLUE)
|
|
add_text_box(slide1, proj.project_name, 1, 2, 11, 1.5, font_size=32, bold=True, color=WHITE)
|
|
rtype_name = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(report_type, "")
|
|
add_text_box(slide1, f"{rtype_name} 보고서", 1, 3.8, 11, 0.8, font_size=20, color=RGBColor(0xBD, 0xE3, 0xFF))
|
|
add_text_box(slide1, f"보고일: {today} | 단계: {proj.phase}", 1, 5, 11, 0.6, font_size=14, color=RGBColor(0xA0, 0xC4, 0xFF))
|
|
|
|
# ── 슬라이드 2: KPI 요약 ──────────────────────────────────
|
|
slide2 = add_slide(6)
|
|
set_bg(slide2)
|
|
add_text_box(slide2, "KPI 현황", 0.5, 0.3, 12, 0.6, font_size=22, bold=True, color=DARK_BLUE)
|
|
|
|
kpis = [
|
|
("전체 진척률", f"{data['overall_progress']}%"),
|
|
("WBS 완료", f"{data['wbs_done']}/{data['wbs_total']}"),
|
|
("지연 항목", f"{data['wbs_delayed']}건"),
|
|
("미결 이슈", f"{data['issue_open']}건"),
|
|
("예산 소진", f"{data['budget_pct']}%"),
|
|
("건강 상태", proj.health_status),
|
|
]
|
|
for i, (label, value) in enumerate(kpis):
|
|
x = 0.5 + (i % 3) * 4.2
|
|
y = 1.5 + (i // 3) * 2.2
|
|
box = slide2.shapes.add_shape(
|
|
1, Inches(x), Inches(y), Inches(3.8), Inches(1.8)
|
|
)
|
|
box.fill.solid(); box.fill.fore_color.rgb = RGBColor(0xEB, 0xF8, 0xFF)
|
|
box.line.color.rgb = RGBColor(0x90, 0xCF, 0xF8)
|
|
tf = box.text_frame
|
|
tf.word_wrap = True
|
|
p1 = tf.paragraphs[0]
|
|
p1.alignment = PP_ALIGN.CENTER
|
|
r1 = p1.add_run(); r1.text = value
|
|
r1.font.size = Pt(28); r1.font.bold = True
|
|
r1.font.color.rgb = DARK_BLUE
|
|
p2 = tf.add_paragraph()
|
|
p2.alignment = PP_ALIGN.CENTER
|
|
r2 = p2.add_run(); r2.text = label
|
|
r2.font.size = Pt(12); r2.font.color.rgb = RGBColor(0x71, 0x80, 0x96)
|
|
|
|
# ── 슬라이드 3: WBS 현황 ──────────────────────────────────
|
|
if data["delayed_wbs"]:
|
|
slide3 = add_slide(6)
|
|
set_bg(slide3)
|
|
add_text_box(slide3, f"WBS 지연 현황 ({data['wbs_delayed']}건)", 0.5, 0.3, 12, 0.6, 22, True, DARK_BLUE)
|
|
|
|
from pptx.util import Inches as I
|
|
tbl = slide3.shapes.add_table(
|
|
min(len(data["delayed_wbs"]) + 1, 8), 4,
|
|
I(0.5), I(1.2), I(12), I(4.5)
|
|
).table
|
|
headers = ["WBS 코드", "제목", "예정 완료", "진척률"]
|
|
for col, h in enumerate(headers):
|
|
tbl.cell(0, col).text = h
|
|
for r, item in enumerate(data["delayed_wbs"][:6], 1):
|
|
vals = [item.wbs_code, item.title[:30], str(item.planned_end), f"{item.completion_pct}%"]
|
|
for col, v in enumerate(vals):
|
|
tbl.cell(r, col).text = v
|
|
|
|
# ── 슬라이드 4: 이슈 요약 ─────────────────────────────────
|
|
if data["open_issues"]:
|
|
slide4 = add_slide(6)
|
|
set_bg(slide4)
|
|
add_text_box(slide4, f"미결 이슈 ({data['issue_open']}건)", 0.5, 0.3, 12, 0.6, 22, True, DARK_BLUE)
|
|
|
|
from pptx.util import Inches as I
|
|
tbl = slide4.shapes.add_table(
|
|
min(len(data["open_issues"]) + 1, 8), 4,
|
|
I(0.5), I(1.2), I(12), I(4.5)
|
|
).table
|
|
headers = ["이슈 ID", "제목", "유형", "담당자"]
|
|
for col, h in enumerate(headers):
|
|
tbl.cell(0, col).text = h
|
|
for r, iss in enumerate(data["open_issues"][:6], 1):
|
|
vals = [iss.issue_id, iss.title[:30], iss.issue_type, iss.assigned_to or "—"]
|
|
for col, v in enumerate(vals):
|
|
tbl.cell(r, col).text = v
|
|
|
|
buf = io.BytesIO()
|
|
prs.save(buf)
|
|
return buf.getvalue()
|
|
|
|
|
|
# ── 보고서 유형 통합 생성 ─────────────────────────────────────────────────────
|
|
|
|
async def generate_report(
|
|
project_id: int,
|
|
report_type: str, # daily | weekly | monthly
|
|
output_fmt: str, # excel | html | pdf | docx | pptx
|
|
db,
|
|
) -> tuple[bytes, str, str]:
|
|
"""
|
|
보고서 생성 통합 함수.
|
|
|
|
Returns:
|
|
(content_bytes, media_type, filename)
|
|
"""
|
|
data = await collect_project_data(project_id, db)
|
|
proj = data["project"]
|
|
today = data["today"]
|
|
|
|
rtype = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(report_type, report_type)
|
|
base_name = f"{proj.project_code}_{rtype}보고서_{today}"
|
|
|
|
if output_fmt == "excel":
|
|
content = generate_excel(data, report_type)
|
|
mime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
filename = f"{base_name}.xlsx"
|
|
|
|
elif output_fmt == "html":
|
|
content = generate_html(data, report_type).encode("utf-8")
|
|
mime = "text/html; charset=utf-8"
|
|
filename = f"{base_name}.html"
|
|
|
|
elif output_fmt == "pdf":
|
|
html_str = generate_html(data, report_type)
|
|
content = generate_pdf(html_str)
|
|
mime = "application/pdf"
|
|
filename = f"{base_name}.pdf"
|
|
|
|
elif output_fmt == "docx":
|
|
content = generate_docx(data, report_type)
|
|
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
filename = f"{base_name}.docx"
|
|
|
|
elif output_fmt == "pptx":
|
|
content = generate_pptx(data, report_type)
|
|
mime = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
filename = f"{base_name}.pptx"
|
|
|
|
else:
|
|
raise ValueError(f"지원하지 않는 형식: {output_fmt}")
|
|
|
|
return content, mime, filename
|