first commit
This commit is contained in:
125
backend/app/services/report.py
Normal file
125
backend/app/services/report.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Report generation: Excel and PDF export."""
|
||||
|
||||
from io import BytesIO
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
# WeasyPrint optional for PDF
|
||||
try:
|
||||
from weasyprint import HTML, CSS
|
||||
HAS_WEASYPRINT = True
|
||||
except ImportError:
|
||||
HAS_WEASYPRINT = False
|
||||
|
||||
|
||||
async def build_excel_report(case, transactions: list) -> bytes:
|
||||
"""Build Excel workbook: summary sheet + transaction detail sheet. Returns file bytes."""
|
||||
wb = Workbook()
|
||||
ws_summary = wb.active
|
||||
ws_summary.title = "汇总"
|
||||
ws_summary.append(["案件编号", case.case_number])
|
||||
ws_summary.append(["受害人", case.victim_name])
|
||||
ws_summary.append(["总损失", str(case.total_loss)])
|
||||
ws_summary.append(["交易笔数", len(transactions)])
|
||||
total_out = sum(
|
||||
(t.amount if isinstance(t.amount, Decimal) else Decimal(str(t.amount)))
|
||||
for t in transactions
|
||||
if t.transaction_type in ("转出", "消费", "付款", "提现") or "转出" in (t.transaction_type or "") or "消费" in (t.transaction_type or "")
|
||||
)
|
||||
total_in = sum(
|
||||
(t.amount if isinstance(t.amount, Decimal) else Decimal(str(t.amount)))
|
||||
for t in transactions
|
||||
if t.transaction_type in ("转入", "收款", "充值") or "转入" in (t.transaction_type or "") or "收款" in (t.transaction_type or "")
|
||||
)
|
||||
ws_summary.append(["转出合计", str(total_out)])
|
||||
ws_summary.append(["转入合计", str(total_in)])
|
||||
ws_summary.append(["净损失", str(total_out - total_in)])
|
||||
for row in range(1, 8):
|
||||
ws_summary.cell(row=row, column=1).font = Font(bold=True)
|
||||
|
||||
ws_detail = wb.create_sheet("交易明细")
|
||||
headers = ["APP来源", "类型", "金额", "币种", "对方名称", "对方账号", "订单号", "交易时间", "备注", "置信度"]
|
||||
ws_detail.append(headers)
|
||||
for t in transactions:
|
||||
ws_detail.append([
|
||||
t.app_source,
|
||||
t.transaction_type or "",
|
||||
str(t.amount),
|
||||
t.currency or "CNY",
|
||||
t.counterparty_name or "",
|
||||
t.counterparty_account or "",
|
||||
t.order_number or "",
|
||||
t.transaction_time.isoformat() if t.transaction_time else "",
|
||||
t.remark or "",
|
||||
t.confidence or "",
|
||||
])
|
||||
for col in range(1, len(headers) + 1):
|
||||
ws_detail.cell(row=1, column=col).font = Font(bold=True)
|
||||
for col in range(1, ws_detail.max_column + 1):
|
||||
ws_detail.column_dimensions[get_column_letter(col)].width = 16
|
||||
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _pdf_html(case, transactions: list) -> str:
|
||||
rows = []
|
||||
for t in transactions:
|
||||
time_str = t.transaction_time.strftime("%Y-%m-%d %H:%M") if t.transaction_time else ""
|
||||
rows.append(
|
||||
f"<tr><td>{t.app_source}</td><td>{t.transaction_type or ''}</td><td>{t.amount}</td>"
|
||||
f"<td>{t.counterparty_name or ''}</td><td>{t.counterparty_account or ''}</td><td>{time_str}</td></tr>"
|
||||
)
|
||||
table_rows = "\n".join(rows)
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"/><title>案件报告</title></head>
|
||||
<body>
|
||||
<h1>资金追踪报告</h1>
|
||||
<p><strong>案件编号:</strong>{case.case_number}</p>
|
||||
<p><strong>受害人:</strong>{case.victim_name}</p>
|
||||
<p><strong>总损失:</strong>{case.total_loss}</p>
|
||||
<p><strong>交易笔数:</strong>{len(transactions)}</p>
|
||||
<h2>交易明细</h2>
|
||||
<table border="1" cellpadding="4" cellspacing="0" style="border-collapse: collapse; width: 100%;">
|
||||
<thead><tr><th>APP</th><th>类型</th><th>金额</th><th>对方名称</th><th>对方账号</th><th>时间</th></tr></thead>
|
||||
<tbody>{table_rows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
async def build_pdf_report(case, transactions: list) -> bytes:
|
||||
"""Build PDF report. Returns file bytes. Falls back to empty PDF if weasyprint not available."""
|
||||
if not HAS_WEASYPRINT:
|
||||
return b"%PDF-1.4 (WeasyPrint not installed)"
|
||||
html_str = _pdf_html(case, transactions)
|
||||
html = HTML(string=html_str)
|
||||
buf = BytesIO()
|
||||
html.write_pdf(buf)
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
async def build_excel_report_path(case, transactions: list, path: str) -> str:
|
||||
"""Write Excel to file path; return path."""
|
||||
data = await build_excel_report(case, transactions)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
return path
|
||||
|
||||
|
||||
async def build_pdf_report_path(case, transactions: list, path: str) -> str:
|
||||
"""Write PDF to file path; return path."""
|
||||
data = await build_pdf_report(case, transactions)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
return path
|
||||
Reference in New Issue
Block a user