Files
fund-tracer/backend/app/services/report_service.py

490 lines
21 KiB
Python
Raw Normal View History

2026-03-13 14:48:32 +08:00
"""Report generation: Excel / PDF."""
2026-03-11 16:28:04 +08:00
import uuid
from pathlib import Path
from uuid import UUID
2026-03-13 14:48:32 +08:00
from datetime import datetime
2026-03-11 16:28:04 +08:00
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.assessment import FraudAssessment, ReviewStatus
2026-03-13 14:48:32 +08:00
from app.models.case import Case
2026-03-11 16:28:04 +08:00
from app.models.transaction import TransactionRecord
from app.models.report import ExportReport, ReportType
from app.schemas.report import ReportCreate
2026-03-13 14:48:32 +08:00
from app.services.flow_service import build_flow_graph
2026-03-11 16:28:04 +08:00
async def generate_report(case_id: UUID, body: ReportCreate, db: AsyncSession) -> ExportReport:
result = await db.execute(
select(ExportReport)
.where(ExportReport.case_id == case_id, ExportReport.report_type == body.report_type)
)
existing = list(result.scalars().all())
version = len(existing) + 1
report_dir = settings.upload_path / str(case_id) / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
if body.report_type == ReportType.excel:
2026-03-12 19:05:48 +08:00
file_path = await _gen_excel(case_id, report_dir, body, db)
2026-03-13 14:48:32 +08:00
elif body.report_type == ReportType.pdf:
2026-03-12 19:05:48 +08:00
file_path = await _gen_pdf(case_id, report_dir, body, db)
2026-03-13 14:48:32 +08:00
else:
raise ValueError(f"Unsupported report_type: {body.report_type}")
2026-03-11 16:28:04 +08:00
relative = str(file_path.relative_to(settings.upload_path))
snap_result = await db.execute(
select(FraudAssessment).where(
FraudAssessment.case_id == case_id,
FraudAssessment.review_status == ReviewStatus.confirmed,
)
)
snapshot = [
{"amount": float(a.assessed_amount), "reason": a.reason}
for a in snap_result.scalars().all()
]
report = ExportReport(
case_id=case_id,
report_type=body.report_type,
file_path=relative,
version=version,
content_snapshot={"assessments": snapshot},
)
db.add(report)
await db.flush()
await db.refresh(report)
return report
2026-03-12 19:05:48 +08:00
# ── helpers ──
2026-03-11 16:28:04 +08:00
2026-03-12 19:05:48 +08:00
async def _get_confirmed(case_id: UUID, db: AsyncSession) -> list[FraudAssessment]:
r = await db.execute(
2026-03-11 16:28:04 +08:00
select(FraudAssessment).where(
FraudAssessment.case_id == case_id,
FraudAssessment.review_status == ReviewStatus.confirmed,
)
)
2026-03-12 19:05:48 +08:00
return list(r.scalars().all())
async def _get_all_assessments(case_id: UUID, db: AsyncSession) -> dict:
r = await db.execute(select(FraudAssessment).where(FraudAssessment.case_id == case_id))
return {a.transaction_id: a for a in r.scalars().all()}
2026-03-11 16:28:04 +08:00
2026-03-12 19:05:48 +08:00
async def _get_all_transactions(case_id: UUID, db: AsyncSession) -> list[TransactionRecord]:
r = await db.execute(
2026-03-11 16:28:04 +08:00
select(TransactionRecord).where(TransactionRecord.case_id == case_id)
)
2026-03-12 19:05:48 +08:00
return list(r.scalars().all())
async def _get_inquiry_suggestions(case_id: UUID, db: AsyncSession) -> list[str]:
from app.services.assessment_service import generate_inquiry_suggestions
return await generate_inquiry_suggestions(case_id, db)
2026-03-13 14:48:32 +08:00
def _fmt_dt(value: datetime | None, with_seconds: bool = False) -> str:
if not value:
return "-"
return value.strftime("%Y-%m-%d %H:%M:%S" if with_seconds else "%Y-%m-%d %H:%M")
def _mask_text(value: str, keep: int = 12) -> str:
text = (value or "").strip()
if len(text) <= keep:
return text
return f"{text[:keep]}..."
async def _build_report_dataset(case_id: UUID, db: AsyncSession) -> dict:
case_obj = await db.get(Case, case_id)
transactions = await _get_all_transactions(case_id, db)
transactions_sorted = sorted(transactions, key=lambda x: x.trade_time or datetime.min)
assessment_by_tx = await _get_all_assessments(case_id, db)
all_assessments = list(assessment_by_tx.values())
confirmed = [a for a in all_assessments if a.review_status == ReviewStatus.confirmed and float(a.assessed_amount) > 0]
pending = [a for a in all_assessments if a.review_status in {ReviewStatus.pending, ReviewStatus.needs_info}]
rejected = [a for a in all_assessments if a.review_status == ReviewStatus.rejected]
confirmed_total = sum(float(a.assessed_amount) for a in confirmed)
pending_total = sum(float(a.assessed_amount) for a in pending if float(a.assessed_amount) > 0)
rejected_total = sum(float(a.assessed_amount) for a in rejected if float(a.assessed_amount) > 0)
valid_for_timeline = [tx for tx in transactions_sorted if not tx.is_duplicate]
start_time = valid_for_timeline[0].trade_time if valid_for_timeline else None
end_time = valid_for_timeline[-1].trade_time if valid_for_timeline else None
level_count = {"high": 0, "medium": 0, "low": 0}
for a in all_assessments:
key = a.confidence_level.value
if key in level_count:
level_count[key] += 1
flow_graph = await build_flow_graph(case_id, db)
return {
"case": case_obj,
"transactions": transactions_sorted,
"assessment_by_tx": assessment_by_tx,
"confirmed": confirmed,
"pending": pending,
"rejected": rejected,
"confirmed_total": confirmed_total,
"pending_total": pending_total,
"rejected_total": rejected_total,
"level_count": level_count,
"start_time": start_time,
"end_time": end_time,
"flow_graph": flow_graph,
}
2026-03-12 19:05:48 +08:00
# ── Excel ──
async def _gen_excel(case_id: UUID, report_dir: Path, body: ReportCreate, db: AsyncSession) -> Path:
from openpyxl import Workbook
wb = Workbook()
sheet_created = False
if body.include_summary:
ws = wb.active if not sheet_created else wb.create_sheet()
ws.title = "被骗金额汇总"
ws.append(["交易时间", "金额(元)", "方向", "对方", "来源APP", "备注", "置信度", "认定理由"])
for a in await _get_confirmed(case_id, db):
tx = await db.get(TransactionRecord, a.transaction_id)
if tx:
ws.append([
tx.trade_time.strftime("%Y-%m-%d %H:%M:%S"),
float(a.assessed_amount),
"支出" if tx.direction.value == "out" else "收入",
tx.counterparty_name,
tx.source_app.value,
tx.remark,
tx.confidence,
a.reason[:100],
])
sheet_created = True
if body.include_transactions:
ws2 = wb.active if not sheet_created else wb.create_sheet()
if not sheet_created:
ws2.title = "交易明细"
else:
ws2.title = "交易明细"
2026-03-11 16:28:04 +08:00
ws2.append([
2026-03-12 19:05:48 +08:00
"交易ID", "证据图片ID", "交易时间", "金额(元)", "方向", "来源APP",
"对方名称", "对方账号", "本方账户尾号", "订单号", "备注", "识别置信度",
"是否重复", "是否中转", "聚类ID", "记录创建时间",
"认定置信等级", "认定金额(元)", "认定理由", "排除条件",
"复核状态", "复核备注", "复核人", "复核时间",
2026-03-11 16:28:04 +08:00
])
2026-03-12 19:05:48 +08:00
assessment_by_tx = await _get_all_assessments(case_id, db)
for tx in await _get_all_transactions(case_id, db):
fa = assessment_by_tx.get(tx.id)
ws2.append([
str(tx.id),
str(tx.evidence_image_id) if tx.evidence_image_id else "",
tx.trade_time.strftime("%Y-%m-%d %H:%M:%S"),
float(tx.amount),
tx.direction.value,
tx.source_app.value,
tx.counterparty_name,
tx.counterparty_account,
tx.self_account_tail_no,
tx.order_no,
tx.remark,
float(tx.confidence),
"" if tx.is_duplicate else "",
"" if tx.is_transit else "",
str(tx.cluster_id) if tx.cluster_id else "",
tx.created_at.strftime("%Y-%m-%d %H:%M:%S") if tx.created_at else "",
fa.confidence_level.value if fa else "",
float(fa.assessed_amount) if fa else "",
fa.reason if fa else "",
fa.exclude_reason if fa else "",
fa.review_status.value if fa else "",
fa.review_note if fa else "",
fa.reviewed_by if fa else "",
fa.reviewed_at.strftime("%Y-%m-%d %H:%M:%S") if (fa and fa.reviewed_at) else "",
])
sheet_created = True
if body.include_reasons:
ws3 = wb.active if not sheet_created else wb.create_sheet()
ws3.title = "认定理由与排除说明"
ws3.append(["交易时间", "金额(元)", "置信等级", "认定理由", "排除条件", "复核状态"])
assessment_by_tx = await _get_all_assessments(case_id, db)
for tx in await _get_all_transactions(case_id, db):
fa = assessment_by_tx.get(tx.id)
if not fa:
continue
ws3.append([
tx.trade_time.strftime("%Y-%m-%d %H:%M:%S"),
float(tx.amount),
fa.confidence_level.value,
fa.reason,
fa.exclude_reason,
fa.review_status.value,
])
sheet_created = True
if body.include_inquiry:
ws4 = wb.active if not sheet_created else wb.create_sheet()
ws4.title = "笔录辅助问询建议"
ws4.append(["序号", "问询建议"])
suggestions = await _get_inquiry_suggestions(case_id, db)
for i, s in enumerate(suggestions, 1):
ws4.append([i, s])
sheet_created = True
if not sheet_created:
ws = wb.active
ws.title = "报告"
ws.append(["未选择任何报告内容"])
2026-03-11 16:28:04 +08:00
file_path = report_dir / f"report_{uuid.uuid4().hex[:8]}.xlsx"
wb.save(file_path)
return file_path
2026-03-12 19:05:48 +08:00
# ── PDF ──
async def _gen_pdf(case_id: UUID, report_dir: Path, body: ReportCreate, db: AsyncSession) -> Path:
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Table as RLTable, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
2026-03-14 00:17:25 +08:00
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
2026-03-12 19:05:48 +08:00
import os
font_registered = False
for font_path in [
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Medium.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
]:
if os.path.exists(font_path):
try:
pdfmetrics.registerFont(TTFont("ChineseFont", font_path, subfontIndex=0))
font_registered = True
break
except Exception:
continue
2026-03-14 00:17:25 +08:00
if not font_registered:
# Docker images often do not ship Chinese system fonts.
# Use built-in CID font as a portable fallback.
try:
pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light"))
font_name = "STSong-Light"
font_registered = True
except Exception:
font_name = "Helvetica"
else:
font_name = "ChineseFont"
2026-03-12 19:05:48 +08:00
styles = getSampleStyleSheet()
title_style = styles["Title"]
title_style.fontName = font_name
normal_style = styles["Normal"]
normal_style.fontName = font_name
h2_style = styles["Heading2"]
h2_style.fontName = font_name
2026-03-11 16:28:04 +08:00
file_path = report_dir / f"report_{uuid.uuid4().hex[:8]}.pdf"
2026-03-12 19:05:48 +08:00
doc = SimpleDocTemplate(str(file_path), pagesize=A4,
leftMargin=15 * mm, rightMargin=15 * mm,
topMargin=20 * mm, bottomMargin=20 * mm)
elements = []
2026-03-13 14:48:32 +08:00
dataset = await _build_report_dataset(case_id, db)
case_obj = dataset["case"]
transactions = dataset["transactions"]
tx_by_id = {tx.id: tx for tx in transactions}
assessment_by_tx = dataset["assessment_by_tx"]
confirmed = dataset["confirmed"]
pending = dataset["pending"]
rejected = dataset["rejected"]
flow_graph = dataset["flow_graph"]
level_count = dataset["level_count"]
2026-03-12 19:05:48 +08:00
def _make_table(header, rows, col_widths):
data = [header] + rows
tbl = RLTable(data, colWidths=col_widths, repeatRows=1)
tbl.setStyle(TableStyle([
("FONTNAME", (0, 0), (-1, -1), font_name),
("FONTSIZE", (0, 0), (-1, -1), 8),
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1677ff")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
return tbl
2026-03-13 14:48:32 +08:00
# Section 1: cover and case meta
elements.append(Paragraph("受害人被骗金额归集与认定报告", title_style))
elements.append(Spacer(1, 4 * mm))
elements.append(Paragraph(f"案件编号:{case_obj.case_no if case_obj else '-'}", normal_style))
elements.append(Paragraph(f"案件名称:{case_obj.title if case_obj else '-'}", normal_style))
elements.append(Paragraph(f"受害人:{case_obj.victim_name if case_obj else '-'} 承办人:{case_obj.handler if case_obj else '-'}", normal_style))
elements.append(Paragraph(f"报告版本v{len((await db.execute(select(ExportReport).where(ExportReport.case_id == case_id, ExportReport.report_type == ReportType.pdf))).scalars().all()) + 1}", normal_style))
elements.append(Paragraph(f"生成时间:{_fmt_dt(datetime.now(), with_seconds=True)}", normal_style))
elements.append(Spacer(1, 5 * mm))
# Section 2: scope and statistical boundaries
elements.append(Paragraph("统计口径与数据范围", h2_style))
elements.append(Paragraph(
f"证据截图数:{case_obj.image_count if case_obj else 0} 张;交易记录数:{len(transactions)} 笔;统计时间范围:{_fmt_dt(dataset['start_time'])}{_fmt_dt(dataset['end_time'])}",
normal_style,
))
elements.append(Paragraph("纳入口径:复核状态为“已确认”且认定金额大于 0 的交易。", normal_style))
elements.append(Paragraph("排除口径:复核状态为“已排除”或规则判定为中转/收入等不应计损交易。", normal_style))
elements.append(Paragraph("待复核口径:状态为“待复核/需补充”的交易,暂不纳入最终被骗金额。", normal_style))
elements.append(Paragraph("说明PDF 报告不包含原始截图附件,仅在明细中保留证据索引字段。", normal_style))
elements.append(Spacer(1, 5 * mm))
# Section 3: executive summary
2026-03-12 19:05:48 +08:00
if body.include_summary:
2026-03-13 14:48:32 +08:00
elements.append(Paragraph("核心结论摘要", h2_style))
elements.append(Paragraph(f"已确认被骗金额:¥{dataset['confirmed_total']:,.2f}", normal_style))
elements.append(Paragraph(f"待复核金额:¥{dataset['pending_total']:,.2f}", normal_style))
elements.append(Paragraph(f"已排除金额:¥{dataset['rejected_total']:,.2f}", normal_style))
elements.append(
Paragraph(
f"已确认 {len(confirmed)} 笔,待复核 {len(pending)} 笔,已排除 {len(rejected)} 笔;置信分布:高 {level_count['high']} / 中 {level_count['medium']} / 低 {level_count['low']}",
normal_style,
)
)
summary_header = ["交易时间", "认定金额(元)", "对方", "认定理由"]
summary_rows = []
for a in confirmed[:30]:
tx = tx_by_id.get(a.transaction_id)
summary_rows.append([
_fmt_dt(tx.trade_time if tx else None),
2026-03-12 19:05:48 +08:00
f"{float(a.assessed_amount):,.2f}",
2026-03-13 14:48:32 +08:00
_mask_text(tx.counterparty_name if tx else "", 14),
Paragraph(_mask_text(a.reason, 80), normal_style),
2026-03-12 19:05:48 +08:00
])
2026-03-13 14:48:32 +08:00
if summary_rows:
elements.append(_make_table(summary_header, summary_rows, [36 * mm, 28 * mm, 34 * mm, 72 * mm]))
2026-03-12 19:05:48 +08:00
else:
2026-03-13 14:48:32 +08:00
elements.append(Paragraph("暂无已确认被骗交易。", normal_style))
elements.append(Spacer(1, 6 * mm))
# Section 4: flow summary (textual)
if body.include_flow_chart:
elements.append(Paragraph("资金路径分析", h2_style))
elements.append(
Paragraph(
f"节点数:{len(flow_graph.nodes)},路径边数:{len(flow_graph.edges)}。以下列出关键资金流向按金额降序最多10条",
normal_style,
)
)
node_label_map = {n.id: n.label for n in flow_graph.nodes}
sorted_edges = sorted(flow_graph.edges, key=lambda e: float(e.amount), reverse=True)[:10]
edge_rows = [
[
_mask_text(node_label_map.get(e.source, e.source), 12),
_mask_text(node_label_map.get(e.target, e.target), 12),
f"{float(e.amount):,.2f}",
str(e.count),
e.trade_time or "-",
]
for e in sorted_edges
]
if edge_rows:
elements.append(_make_table(["来源节点", "目标节点", "累计金额", "交易次数", "首笔时间"], edge_rows, [34 * mm, 34 * mm, 28 * mm, 20 * mm, 54 * mm]))
else:
elements.append(Paragraph("暂无可用的资金路径数据。", normal_style))
elements.append(Spacer(1, 6 * mm))
# Section 5: timeline
if body.include_timeline:
elements.append(Paragraph("交易时间轴", h2_style))
timeline_rows = []
for tx in [x for x in transactions if not x.is_duplicate][:80]:
fa = assessment_by_tx.get(tx.id)
status = fa.review_status.value if fa else "pending"
timeline_rows.append([
_fmt_dt(tx.trade_time),
f"{float(tx.amount):,.2f}",
"支出" if tx.direction.value == "out" else "收入",
_mask_text(tx.counterparty_name, 14),
tx.source_app.value,
status,
])
if timeline_rows:
elements.append(_make_table(["时间", "金额(元)", "方向", "对方", "来源APP", "状态"], timeline_rows, [34 * mm, 24 * mm, 15 * mm, 34 * mm, 22 * mm, 28 * mm]))
else:
elements.append(Paragraph("暂无可生成时间轴的交易记录。", normal_style))
elements.append(Spacer(1, 6 * mm))
2026-03-12 19:05:48 +08:00
2026-03-13 14:48:32 +08:00
# Section 6: detailed transaction table
2026-03-12 19:05:48 +08:00
if body.include_transactions:
2026-03-13 14:48:32 +08:00
elements.append(Paragraph("重点交易明细(含证据索引)", h2_style))
2026-03-12 19:05:48 +08:00
elements.append(Spacer(1, 4 * mm))
2026-03-13 14:48:32 +08:00
header = ["交易时间", "金额", "方向", "来源APP", "对方", "证据索引", "状态"]
2026-03-12 19:05:48 +08:00
rows = []
2026-03-13 14:48:32 +08:00
for tx in transactions[:120]:
fa = assessment_by_tx.get(tx.id)
2026-03-12 19:05:48 +08:00
rows.append([
2026-03-13 14:48:32 +08:00
_fmt_dt(tx.trade_time),
2026-03-12 19:05:48 +08:00
f"{float(tx.amount):,.2f}",
"" if tx.direction.value == "out" else "",
2026-03-13 14:48:32 +08:00
tx.source_app.value,
_mask_text(tx.counterparty_name, 12),
str(tx.evidence_image_id)[:12] if tx.evidence_image_id else "-",
fa.review_status.value if fa else "pending",
2026-03-12 19:05:48 +08:00
])
if rows:
2026-03-13 14:48:32 +08:00
elements.append(_make_table(header, rows, [31 * mm, 19 * mm, 12 * mm, 20 * mm, 30 * mm, 35 * mm, 24 * mm]))
2026-03-12 19:05:48 +08:00
elements.append(Spacer(1, 8 * mm))
2026-03-13 14:48:32 +08:00
# Section 7: reasons and exclusion
2026-03-12 19:05:48 +08:00
if body.include_reasons:
elements.append(Paragraph("认定理由与排除说明", h2_style))
elements.append(Spacer(1, 4 * mm))
2026-03-13 14:48:32 +08:00
for tx in transactions:
2026-03-12 19:05:48 +08:00
fa = assessment_by_tx.get(tx.id)
if not fa:
continue
line = (
2026-03-13 14:48:32 +08:00
f"[{_fmt_dt(tx.trade_time)}] ¥{float(tx.amount):,.2f} "
f"| 置信:{fa.confidence_level.value} | 状态:{fa.review_status.value} | 理由:{_mask_text(fa.reason, 80)}"
2026-03-12 19:05:48 +08:00
)
elements.append(Paragraph(line, normal_style))
if fa.exclude_reason:
2026-03-13 14:48:32 +08:00
elements.append(Paragraph(f" 排除说明: {_mask_text(fa.exclude_reason, 100)}", normal_style))
if fa.review_status in {ReviewStatus.pending, ReviewStatus.needs_info}:
elements.append(Paragraph(" 待补证提示: 建议补充问询与对账凭证后再作最终认定。", normal_style))
2026-03-12 19:05:48 +08:00
elements.append(Spacer(1, 8 * mm))
2026-03-13 14:48:32 +08:00
# Section 8: inquiry suggestions
2026-03-12 19:05:48 +08:00
if body.include_inquiry:
2026-03-13 14:48:32 +08:00
elements.append(Paragraph("待复核事项与笔录辅助问询建议", h2_style))
2026-03-12 19:05:48 +08:00
elements.append(Spacer(1, 4 * mm))
2026-03-13 14:48:32 +08:00
if pending:
elements.append(Paragraph(f"当前待复核交易共 {len(pending)} 笔,涉及待复核金额约 ¥{dataset['pending_total']:,.2f}", normal_style))
2026-03-12 19:05:48 +08:00
suggestions = await _get_inquiry_suggestions(case_id, db)
for i, s in enumerate(suggestions, 1):
elements.append(Paragraph(f"{i}. {s}", normal_style))
elements.append(Spacer(1, 8 * mm))
if not elements or len(elements) <= 2:
elements.append(Paragraph("未选择任何报告内容。", normal_style))
doc.build(elements)
2026-03-11 16:28:04 +08:00
return file_path