524 lines
22 KiB
Python
524 lines
22 KiB
Python
"""
|
||
报表生成服务 - PDF/Excel report generation for Tianpu EMS.
|
||
"""
|
||
import os
|
||
import io
|
||
from datetime import datetime, date, timedelta
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select, func, and_
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.models.device import Device
|
||
from app.models.energy import EnergyDailySummary
|
||
from app.models.alarm import AlarmEvent
|
||
from app.models.carbon import CarbonEmission
|
||
|
||
REPORTS_DIR = Path(__file__).resolve().parent.parent.parent / "reports"
|
||
REPORTS_DIR.mkdir(exist_ok=True)
|
||
|
||
PLATFORM_TITLE = "天普零碳园区智慧能源管理平台"
|
||
|
||
ENERGY_TYPE_LABELS = {
|
||
"electricity": "电力",
|
||
"heat": "热能",
|
||
"water": "水",
|
||
"gas": "天然气",
|
||
}
|
||
|
||
DEVICE_STATUS_LABELS = {
|
||
"online": "在线",
|
||
"offline": "离线",
|
||
"alarm": "告警",
|
||
"maintenance": "维护中",
|
||
}
|
||
|
||
SEVERITY_LABELS = {
|
||
"critical": "紧急",
|
||
"major": "重要",
|
||
"warning": "一般",
|
||
}
|
||
|
||
|
||
def _register_chinese_font():
|
||
"""Register a Chinese font for ReportLab PDF generation."""
|
||
from reportlab.pdfbase import pdfmetrics
|
||
from reportlab.pdfbase.ttfonts import TTFont
|
||
|
||
font_paths = [
|
||
"C:/Windows/Fonts/simsun.ttc",
|
||
"C:/Windows/Fonts/simhei.ttf",
|
||
"C:/Windows/Fonts/msyh.ttc",
|
||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||
"/System/Library/Fonts/PingFang.ttc",
|
||
]
|
||
for fp in font_paths:
|
||
if os.path.exists(fp):
|
||
try:
|
||
pdfmetrics.registerFont(TTFont("ChineseFont", fp))
|
||
return "ChineseFont"
|
||
except Exception:
|
||
continue
|
||
return "Helvetica"
|
||
|
||
|
||
class ReportGenerator:
|
||
"""Generates PDF and Excel reports from EMS data."""
|
||
|
||
def __init__(self, db: AsyncSession):
|
||
self.db = db
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Data fetching helpers
|
||
# ------------------------------------------------------------------ #
|
||
|
||
async def _fetch_energy_daily(
|
||
self, start_date: date, end_date: date, device_ids: list[int] | None = None
|
||
) -> list[dict]:
|
||
q = select(EnergyDailySummary).where(
|
||
and_(
|
||
func.date(EnergyDailySummary.date) >= start_date,
|
||
func.date(EnergyDailySummary.date) <= end_date,
|
||
)
|
||
)
|
||
if device_ids:
|
||
q = q.where(EnergyDailySummary.device_id.in_(device_ids))
|
||
q = q.order_by(EnergyDailySummary.date)
|
||
result = await self.db.execute(q)
|
||
rows = result.scalars().all()
|
||
return [
|
||
{
|
||
"date": str(r.date.date()) if r.date else "",
|
||
"device_id": r.device_id,
|
||
"energy_type": ENERGY_TYPE_LABELS.get(r.energy_type, r.energy_type),
|
||
"total_consumption": round(r.total_consumption or 0, 2),
|
||
"total_generation": round(r.total_generation or 0, 2),
|
||
"peak_power": round(r.peak_power or 0, 2),
|
||
"avg_power": round(r.avg_power or 0, 2),
|
||
"operating_hours": round(r.operating_hours or 0, 1),
|
||
"cost": round(r.cost or 0, 2),
|
||
"carbon_emission": round(r.carbon_emission or 0, 2),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
async def _fetch_devices(self) -> list[dict]:
|
||
result = await self.db.execute(
|
||
select(Device).where(Device.is_active == True).order_by(Device.id)
|
||
)
|
||
return [
|
||
{
|
||
"id": d.id,
|
||
"name": d.name,
|
||
"code": d.code,
|
||
"device_type": d.device_type,
|
||
"status": DEVICE_STATUS_LABELS.get(d.status, d.status),
|
||
"rated_power": d.rated_power or 0,
|
||
"location": d.location or "",
|
||
"last_data_time": str(d.last_data_time) if d.last_data_time else "N/A",
|
||
}
|
||
for d in result.scalars().all()
|
||
]
|
||
|
||
async def _fetch_alarms(self, start_date: date, end_date: date) -> list[dict]:
|
||
q = select(AlarmEvent).where(
|
||
and_(
|
||
func.date(AlarmEvent.triggered_at) >= start_date,
|
||
func.date(AlarmEvent.triggered_at) <= end_date,
|
||
)
|
||
).order_by(AlarmEvent.triggered_at.desc())
|
||
result = await self.db.execute(q)
|
||
return [
|
||
{
|
||
"id": a.id,
|
||
"device_id": a.device_id,
|
||
"severity": SEVERITY_LABELS.get(a.severity, a.severity),
|
||
"title": a.title,
|
||
"description": a.description or "",
|
||
"value": a.value,
|
||
"threshold": a.threshold,
|
||
"status": a.status,
|
||
"triggered_at": str(a.triggered_at) if a.triggered_at else "",
|
||
"resolved_at": str(a.resolved_at) if a.resolved_at else "",
|
||
}
|
||
for a in result.scalars().all()
|
||
]
|
||
|
||
async def _fetch_carbon(self, start_date: date, end_date: date) -> list[dict]:
|
||
q = select(CarbonEmission).where(
|
||
and_(
|
||
func.date(CarbonEmission.date) >= start_date,
|
||
func.date(CarbonEmission.date) <= end_date,
|
||
)
|
||
).order_by(CarbonEmission.date)
|
||
result = await self.db.execute(q)
|
||
return [
|
||
{
|
||
"date": str(c.date.date()) if c.date else "",
|
||
"scope": c.scope,
|
||
"category": c.category,
|
||
"emission": round(c.emission or 0, 2),
|
||
"reduction": round(c.reduction or 0, 2),
|
||
"energy_consumption": round(c.energy_consumption or 0, 2),
|
||
"energy_unit": c.energy_unit or "",
|
||
}
|
||
for c in result.scalars().all()
|
||
]
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Public generation methods
|
||
# ------------------------------------------------------------------ #
|
||
|
||
async def generate_energy_daily_report(
|
||
self,
|
||
start_date: date,
|
||
end_date: date,
|
||
device_ids: list[int] | None = None,
|
||
export_format: str = "xlsx",
|
||
) -> str:
|
||
data = await self._fetch_energy_daily(start_date, end_date, device_ids)
|
||
title = "每日能耗报表"
|
||
date_range_str = f"{start_date} ~ {end_date}"
|
||
summary = self._compute_energy_summary(data)
|
||
headers = ["日期", "设备ID", "能源类型", "消耗量", "产出量", "峰值功率(kW)", "平均功率(kW)", "运行时长(h)", "费用(元)", "碳排放(kgCO₂)"]
|
||
table_keys = ["date", "device_id", "energy_type", "total_consumption", "total_generation", "peak_power", "avg_power", "operating_hours", "cost", "carbon_emission"]
|
||
|
||
filename = f"energy_daily_{start_date}_{end_date}_{datetime.now().strftime('%H%M%S')}"
|
||
if export_format == "pdf":
|
||
return self._generate_pdf(title, date_range_str, summary, headers, table_keys, data, filename)
|
||
else:
|
||
return self._generate_excel(title, date_range_str, summary, headers, table_keys, data, filename)
|
||
|
||
async def generate_monthly_summary(
|
||
self, month: int, year: int, export_format: str = "xlsx"
|
||
) -> str:
|
||
start = date(year, month, 1)
|
||
if month == 12:
|
||
end = date(year + 1, 1, 1) - timedelta(days=1)
|
||
else:
|
||
end = date(year, month + 1, 1) - timedelta(days=1)
|
||
|
||
data = await self._fetch_energy_daily(start, end)
|
||
title = f"{year}年{month}月能耗月报"
|
||
date_range_str = f"{start} ~ {end}"
|
||
summary = self._compute_energy_summary(data)
|
||
headers = ["日期", "设备ID", "能源类型", "消耗量", "产出量", "峰值功率(kW)", "平均功率(kW)", "运行时长(h)", "费用(元)", "碳排放(kgCO₂)"]
|
||
table_keys = ["date", "device_id", "energy_type", "total_consumption", "total_generation", "peak_power", "avg_power", "operating_hours", "cost", "carbon_emission"]
|
||
|
||
filename = f"monthly_summary_{year}_{month:02d}"
|
||
if export_format == "pdf":
|
||
return self._generate_pdf(title, date_range_str, summary, headers, table_keys, data, filename)
|
||
else:
|
||
return self._generate_excel(title, date_range_str, summary, headers, table_keys, data, filename)
|
||
|
||
async def generate_device_status_report(self, export_format: str = "xlsx") -> str:
|
||
data = await self._fetch_devices()
|
||
title = "设备状态报表"
|
||
date_range_str = f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||
summary = self._compute_device_summary(data)
|
||
headers = ["设备ID", "设备名称", "设备编号", "设备类型", "状态", "额定功率(kW)", "位置", "最近数据时间"]
|
||
table_keys = ["id", "name", "code", "device_type", "status", "rated_power", "location", "last_data_time"]
|
||
|
||
filename = f"device_status_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||
if export_format == "pdf":
|
||
return self._generate_pdf(title, date_range_str, summary, headers, table_keys, data, filename)
|
||
else:
|
||
return self._generate_excel(title, date_range_str, summary, headers, table_keys, data, filename)
|
||
|
||
async def generate_alarm_report(
|
||
self, start_date: date, end_date: date, export_format: str = "xlsx"
|
||
) -> str:
|
||
data = await self._fetch_alarms(start_date, end_date)
|
||
title = "告警分析报表"
|
||
date_range_str = f"{start_date} ~ {end_date}"
|
||
summary = self._compute_alarm_summary(data)
|
||
headers = ["告警ID", "设备ID", "严重程度", "标题", "描述", "触发值", "阈值", "状态", "触发时间", "解决时间"]
|
||
table_keys = ["id", "device_id", "severity", "title", "description", "value", "threshold", "status", "triggered_at", "resolved_at"]
|
||
|
||
filename = f"alarm_report_{start_date}_{end_date}"
|
||
if export_format == "pdf":
|
||
return self._generate_pdf(title, date_range_str, summary, headers, table_keys, data, filename)
|
||
else:
|
||
return self._generate_excel(title, date_range_str, summary, headers, table_keys, data, filename)
|
||
|
||
async def generate_carbon_report(
|
||
self, start_date: date, end_date: date, export_format: str = "xlsx"
|
||
) -> str:
|
||
data = await self._fetch_carbon(start_date, end_date)
|
||
title = "碳排放分析报表"
|
||
date_range_str = f"{start_date} ~ {end_date}"
|
||
summary = self._compute_carbon_summary(data)
|
||
headers = ["日期", "范围", "类别", "排放量(kgCO₂e)", "减排量(kgCO₂e)", "能耗", "单位"]
|
||
table_keys = ["date", "scope", "category", "emission", "reduction", "energy_consumption", "energy_unit"]
|
||
|
||
filename = f"carbon_report_{start_date}_{end_date}"
|
||
if export_format == "pdf":
|
||
return self._generate_pdf(title, date_range_str, summary, headers, table_keys, data, filename)
|
||
else:
|
||
return self._generate_excel(title, date_range_str, summary, headers, table_keys, data, filename)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Summary computation helpers
|
||
# ------------------------------------------------------------------ #
|
||
|
||
@staticmethod
|
||
def _compute_energy_summary(data: list[dict]) -> list[tuple[str, str]]:
|
||
total_consumption = sum(r["total_consumption"] for r in data)
|
||
total_generation = sum(r["total_generation"] for r in data)
|
||
total_cost = sum(r["cost"] for r in data)
|
||
total_carbon = sum(r["carbon_emission"] for r in data)
|
||
return [
|
||
("数据条数", str(len(data))),
|
||
("总消耗量", f"{total_consumption:,.2f}"),
|
||
("总产出量", f"{total_generation:,.2f}"),
|
||
("总费用(元)", f"{total_cost:,.2f}"),
|
||
("总碳排放(kgCO₂)", f"{total_carbon:,.2f}"),
|
||
]
|
||
|
||
@staticmethod
|
||
def _compute_device_summary(data: list[dict]) -> list[tuple[str, str]]:
|
||
total = len(data)
|
||
online = sum(1 for d in data if d["status"] == "在线")
|
||
offline = sum(1 for d in data if d["status"] == "离线")
|
||
alarm = sum(1 for d in data if d["status"] == "告警")
|
||
return [
|
||
("设备总数", str(total)),
|
||
("在线", str(online)),
|
||
("离线", str(offline)),
|
||
("告警", str(alarm)),
|
||
]
|
||
|
||
@staticmethod
|
||
def _compute_alarm_summary(data: list[dict]) -> list[tuple[str, str]]:
|
||
total = len(data)
|
||
critical = sum(1 for a in data if a["severity"] == "紧急")
|
||
major = sum(1 for a in data if a["severity"] == "重要")
|
||
resolved = sum(1 for a in data if a["status"] == "resolved")
|
||
return [
|
||
("告警总数", str(total)),
|
||
("紧急", str(critical)),
|
||
("重要", str(major)),
|
||
("已解决", str(resolved)),
|
||
]
|
||
|
||
@staticmethod
|
||
def _compute_carbon_summary(data: list[dict]) -> list[tuple[str, str]]:
|
||
total_emission = sum(r["emission"] for r in data)
|
||
total_reduction = sum(r["reduction"] for r in data)
|
||
net = total_emission - total_reduction
|
||
return [
|
||
("数据条数", str(len(data))),
|
||
("总排放(kgCO₂e)", f"{total_emission:,.2f}"),
|
||
("总减排(kgCO₂e)", f"{total_reduction:,.2f}"),
|
||
("净排放(kgCO₂e)", f"{net:,.2f}"),
|
||
]
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# PDF generation (ReportLab)
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def _generate_pdf(
|
||
self,
|
||
title: str,
|
||
date_range_str: str,
|
||
summary: list[tuple[str, str]],
|
||
headers: list[str],
|
||
table_keys: list[str],
|
||
data: list[dict],
|
||
filename: str,
|
||
) -> str:
|
||
from reportlab.lib.pagesizes import A4
|
||
from reportlab.lib import colors
|
||
from reportlab.lib.styles import ParagraphStyle
|
||
from reportlab.lib.units import mm
|
||
from reportlab.platypus import (
|
||
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
|
||
)
|
||
|
||
font_name = _register_chinese_font()
|
||
filepath = str(REPORTS_DIR / f"{filename}.pdf")
|
||
|
||
doc = SimpleDocTemplate(
|
||
filepath, pagesize=A4,
|
||
topMargin=20 * mm, bottomMargin=20 * mm,
|
||
leftMargin=15 * mm, rightMargin=15 * mm,
|
||
)
|
||
|
||
title_style = ParagraphStyle(
|
||
"Title", fontName=font_name, fontSize=16, alignment=1, spaceAfter=6,
|
||
)
|
||
subtitle_style = ParagraphStyle(
|
||
"Subtitle", fontName=font_name, fontSize=10, alignment=1,
|
||
textColor=colors.grey, spaceAfter=4,
|
||
)
|
||
section_style = ParagraphStyle(
|
||
"Section", fontName=font_name, fontSize=12, spaceBefore=12, spaceAfter=6,
|
||
)
|
||
normal_style = ParagraphStyle(
|
||
"Normal", fontName=font_name, fontSize=9,
|
||
)
|
||
|
||
elements: list[Any] = []
|
||
|
||
# Header
|
||
elements.append(Paragraph(PLATFORM_TITLE, subtitle_style))
|
||
elements.append(Paragraph(title, title_style))
|
||
elements.append(Paragraph(date_range_str, subtitle_style))
|
||
elements.append(Spacer(1, 8 * mm))
|
||
|
||
# Summary section
|
||
elements.append(Paragraph("概要", section_style))
|
||
summary_data = [[Paragraph(k, normal_style), Paragraph(v, normal_style)] for k, v in summary]
|
||
summary_table = Table(summary_data, colWidths=[120, 200])
|
||
summary_table.setStyle(TableStyle([
|
||
("BACKGROUND", (0, 0), (0, -1), colors.Color(0.94, 0.94, 0.94)),
|
||
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
|
||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||
]))
|
||
elements.append(summary_table)
|
||
elements.append(Spacer(1, 8 * mm))
|
||
|
||
# Detail table
|
||
elements.append(Paragraph("明细数据", section_style))
|
||
if data:
|
||
page_width = A4[0] - 30 * mm
|
||
col_width = page_width / len(headers)
|
||
header_row = [Paragraph(h, ParagraphStyle("H", fontName=font_name, fontSize=7, alignment=1)) for h in headers]
|
||
table_data = [header_row]
|
||
cell_style = ParagraphStyle("Cell", fontName=font_name, fontSize=7)
|
||
for row in data[:500]: # limit rows for PDF
|
||
table_data.append([Paragraph(str(row.get(k, "")), cell_style) for k in table_keys])
|
||
detail_table = Table(table_data, colWidths=[col_width] * len(headers), repeatRows=1)
|
||
detail_table.setStyle(TableStyle([
|
||
("BACKGROUND", (0, 0), (-1, 0), colors.Color(0.2, 0.4, 0.7)),
|
||
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
||
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
|
||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.Color(0.96, 0.96, 0.96)]),
|
||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||
("LEFTPADDING", (0, 0), (-1, -1), 3),
|
||
("RIGHTPADDING", (0, 0), (-1, -1), 3),
|
||
("TOPPADDING", (0, 0), (-1, -1), 3),
|
||
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
||
]))
|
||
elements.append(detail_table)
|
||
if len(data) > 500:
|
||
elements.append(Spacer(1, 4 * mm))
|
||
elements.append(Paragraph(f"(共 {len(data)} 条记录,PDF 仅显示前500条)", normal_style))
|
||
else:
|
||
elements.append(Paragraph("暂无数据", normal_style))
|
||
|
||
# Footer
|
||
elements.append(Spacer(1, 10 * mm))
|
||
footer_style = ParagraphStyle("Footer", fontName=font_name, fontSize=8, textColor=colors.grey, alignment=2)
|
||
elements.append(Paragraph(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", footer_style))
|
||
|
||
doc.build(elements)
|
||
return filepath
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Excel generation (OpenPyXL)
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def _generate_excel(
|
||
self,
|
||
title: str,
|
||
date_range_str: str,
|
||
summary: list[tuple[str, str]],
|
||
headers: list[str],
|
||
table_keys: list[str],
|
||
data: list[dict],
|
||
filename: str,
|
||
) -> str:
|
||
from openpyxl import Workbook
|
||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||
|
||
filepath = str(REPORTS_DIR / f"{filename}.xlsx")
|
||
wb = Workbook()
|
||
|
||
header_font = Font(bold=True, color="FFFFFF", size=11)
|
||
header_fill = PatternFill(start_color="336699", end_color="336699", fill_type="solid")
|
||
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||
thin_border = Border(
|
||
left=Side(style="thin", color="CCCCCC"),
|
||
right=Side(style="thin", color="CCCCCC"),
|
||
top=Side(style="thin", color="CCCCCC"),
|
||
bottom=Side(style="thin", color="CCCCCC"),
|
||
)
|
||
title_font = Font(bold=True, size=14)
|
||
subtitle_font = Font(size=10, color="666666")
|
||
summary_key_fill = PatternFill(start_color="F0F0F0", end_color="F0F0F0", fill_type="solid")
|
||
|
||
# --- Summary sheet ---
|
||
ws_summary = wb.active
|
||
ws_summary.title = "概要"
|
||
ws_summary.append([PLATFORM_TITLE])
|
||
ws_summary.merge_cells("A1:D1")
|
||
ws_summary["A1"].font = title_font
|
||
|
||
ws_summary.append([title])
|
||
ws_summary.merge_cells("A2:D2")
|
||
ws_summary["A2"].font = Font(bold=True, size=12)
|
||
|
||
ws_summary.append([date_range_str])
|
||
ws_summary.merge_cells("A3:D3")
|
||
ws_summary["A3"].font = subtitle_font
|
||
|
||
ws_summary.append([])
|
||
ws_summary.append(["指标", "值"])
|
||
ws_summary["A5"].font = Font(bold=True)
|
||
ws_summary["B5"].font = Font(bold=True)
|
||
|
||
for label, value in summary:
|
||
row = ws_summary.max_row + 1
|
||
ws_summary.append([label, value])
|
||
ws_summary.cell(row=row, column=1).fill = summary_key_fill
|
||
|
||
ws_summary.column_dimensions["A"].width = 25
|
||
ws_summary.column_dimensions["B"].width = 30
|
||
|
||
# --- Detail sheet ---
|
||
ws_detail = wb.create_sheet("明细数据")
|
||
|
||
# Header row
|
||
for col_idx, h in enumerate(headers, 1):
|
||
cell = ws_detail.cell(row=1, column=col_idx, value=h)
|
||
cell.font = header_font
|
||
cell.fill = header_fill
|
||
cell.alignment = header_align
|
||
cell.border = thin_border
|
||
|
||
# Data rows
|
||
for row_idx, row_data in enumerate(data, 2):
|
||
for col_idx, key in enumerate(table_keys, 1):
|
||
val = row_data.get(key, "")
|
||
cell = ws_detail.cell(row=row_idx, column=col_idx, value=val)
|
||
cell.border = thin_border
|
||
if isinstance(val, float):
|
||
cell.number_format = "#,##0.00"
|
||
cell.alignment = Alignment(vertical="center")
|
||
|
||
# Auto-width columns
|
||
for col_idx in range(1, len(headers) + 1):
|
||
max_len = len(str(headers[col_idx - 1]))
|
||
for row_idx in range(2, min(len(data) + 2, 102)):
|
||
val = ws_detail.cell(row=row_idx, column=col_idx).value
|
||
if val:
|
||
max_len = max(max_len, len(str(val)))
|
||
ws_detail.column_dimensions[ws_detail.cell(row=1, column=col_idx).column_letter].width = min(max_len + 4, 40)
|
||
|
||
# Auto-filter
|
||
if data:
|
||
ws_detail.auto_filter.ref = f"A1:{ws_detail.cell(row=1, column=len(headers)).column_letter}{len(data) + 1}"
|
||
|
||
# Freeze header
|
||
ws_detail.freeze_panes = "A2"
|
||
|
||
wb.save(filepath)
|
||
return filepath
|