126 lines
4.6 KiB
Python
126 lines
4.6 KiB
Python
|
|
"""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
|