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
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
font_name = "ChineseFont" if font_registered else "Helvetica"
|
|
|
|
|
|
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
|