update: docs

This commit is contained in:
2026-03-13 14:48:32 +08:00
parent e0a40ceff0
commit b7e973e2b6
31 changed files with 2183 additions and 196 deletions

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.models.report import ExportReport
from app.models.report import ReportType
from app.repositories.case_repo import CaseRepository
from app.schemas.report import ReportCreate, ReportOut, ReportListOut
@@ -20,6 +21,8 @@ async def generate_report(case_id: UUID, body: ReportCreate, db: AsyncSession =
case = await repo.get(case_id)
if not case:
raise HTTPException(404, "案件不存在")
if body.report_type == ReportType.word:
raise HTTPException(400, "当前仅支持 PDF 和 Excel 报告导出")
from app.services.report_service import generate_report as gen
report = await gen(case_id, body, db)
@@ -30,7 +33,7 @@ async def generate_report(case_id: UUID, body: ReportCreate, db: AsyncSession =
async def list_reports(case_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(ExportReport)
.where(ExportReport.case_id == case_id)
.where(ExportReport.case_id == case_id, ExportReport.report_type != ReportType.word)
.order_by(ExportReport.created_at.desc())
)
items = list(result.scalars().all())

View File

@@ -13,7 +13,6 @@ class ReportCreate(CamelModel):
include_timeline: bool = True
include_reasons: bool = True
include_inquiry: bool = False
include_screenshots: bool = False
class ReportOut(CamelModel):

View File

@@ -1,16 +1,19 @@
"""Report generation: Excel / Word / PDF."""
"""Report generation: Excel / PDF."""
import uuid
from pathlib import Path
from uuid import UUID
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.assessment import FraudAssessment, ReviewStatus
from app.models.case import Case
from app.models.transaction import TransactionRecord
from app.models.report import ExportReport, ReportType
from app.schemas.report import ReportCreate
from app.services.flow_service import build_flow_graph
async def generate_report(case_id: UUID, body: ReportCreate, db: AsyncSession) -> ExportReport:
@@ -26,10 +29,10 @@ async def generate_report(case_id: UUID, body: ReportCreate, db: AsyncSession) -
if body.report_type == ReportType.excel:
file_path = await _gen_excel(case_id, report_dir, body, db)
elif body.report_type == ReportType.word:
file_path = await _gen_word(case_id, report_dir, body, db)
else:
elif body.report_type == ReportType.pdf:
file_path = await _gen_pdf(case_id, report_dir, body, db)
else:
raise ValueError(f"Unsupported report_type: {body.report_type}")
relative = str(file_path.relative_to(settings.upload_path))
@@ -86,6 +89,63 @@ async def _get_inquiry_suggestions(case_id: UUID, db: AsyncSession) -> list[str]
return await generate_inquiry_suggestions(case_id, db)
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,
}
# ── Excel ──
async def _gen_excel(case_id: UUID, report_dir: Path, body: ReportCreate, db: AsyncSession) -> Path:
@@ -195,71 +255,6 @@ async def _gen_excel(case_id: UUID, report_dir: Path, body: ReportCreate, db: As
return file_path
# ── Word ──
async def _gen_word(case_id: UUID, report_dir: Path, body: ReportCreate, db: AsyncSession) -> Path:
from docx import Document
doc = Document()
doc.add_heading("受害人被骗金额汇总报告", level=1)
if body.include_summary:
confirmed = await _get_confirmed(case_id, db)
total = sum(float(a.assessed_amount) for a in confirmed)
doc.add_paragraph(f"已确认被骗金额: ¥{total:,.2f}")
doc.add_paragraph(f"已确认交易笔数: {len(confirmed)}")
table = doc.add_table(rows=1, cols=4)
table.style = "Table Grid"
hdr = table.rows[0].cells
hdr[0].text, hdr[1].text, hdr[2].text, hdr[3].text = "交易时间", "金额(元)", "对方", "认定理由"
for a in confirmed:
tx = await db.get(TransactionRecord, a.transaction_id)
row = table.add_row().cells
row[0].text = tx.trade_time.strftime("%Y-%m-%d %H:%M") if tx else ""
row[1].text = f"{float(a.assessed_amount):,.2f}"
row[2].text = tx.counterparty_name if tx else ""
row[3].text = a.reason[:80]
if body.include_transactions:
doc.add_heading("交易明细", level=2)
table2 = doc.add_table(rows=1, cols=6)
table2.style = "Table Grid"
for i, h in enumerate(["交易时间", "金额", "方向", "对方", "订单号", "备注"]):
table2.rows[0].cells[i].text = h
for tx in await _get_all_transactions(case_id, db):
row = table2.add_row().cells
row[0].text = tx.trade_time.strftime("%Y-%m-%d %H:%M")
row[1].text = f"{float(tx.amount):,.2f}"
row[2].text = tx.direction.value
row[3].text = tx.counterparty_name[:20]
row[4].text = tx.order_no[:20]
row[5].text = tx.remark[:30]
if body.include_reasons:
doc.add_heading("认定理由与排除说明", level=2)
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
doc.add_paragraph(
f"[{tx.trade_time.strftime('%Y-%m-%d %H:%M')}] ¥{float(tx.amount):,.2f} "
f"{fa.confidence_level.value}{fa.reason[:100]}"
)
if fa.exclude_reason:
doc.add_paragraph(f" 排除条件: {fa.exclude_reason[:100]}")
if body.include_inquiry:
doc.add_heading("笔录辅助问询建议", level=2)
suggestions = await _get_inquiry_suggestions(case_id, db)
for i, s in enumerate(suggestions, 1):
doc.add_paragraph(f"{i}. {s}")
file_path = report_dir / f"report_{uuid.uuid4().hex[:8]}.docx"
doc.save(file_path)
return file_path
# ── PDF ──
async def _gen_pdf(case_id: UUID, report_dir: Path, body: ReportCreate, db: AsyncSession) -> Path:
@@ -301,8 +296,16 @@ async def _gen_pdf(case_id: UUID, report_dir: Path, body: ReportCreate, db: Asyn
leftMargin=15 * mm, rightMargin=15 * mm,
topMargin=20 * mm, bottomMargin=20 * mm)
elements = []
elements.append(Paragraph("受害人被骗金额汇总报告", title_style))
elements.append(Spacer(1, 10 * mm))
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"]
def _make_table(header, rows, col_widths):
data = [header] + rows
@@ -319,65 +322,150 @@ async def _gen_pdf(case_id: UUID, report_dir: Path, body: ReportCreate, db: Asyn
]))
return tbl
if body.include_summary:
confirmed = await _get_confirmed(case_id, db)
total = sum(float(a.assessed_amount) for a in confirmed)
elements.append(Paragraph(f"已确认被骗金额: ¥{total:,.2f}", normal_style))
elements.append(Paragraph(f"已确认交易笔数: {len(confirmed)}", normal_style))
elements.append(Spacer(1, 6 * mm))
header = ["交易时间", "金额(元)", "对方", "认定理由"]
rows = []
for a in confirmed:
tx = await db.get(TransactionRecord, a.transaction_id)
rows.append([
tx.trade_time.strftime("%Y-%m-%d %H:%M") if tx else "",
f"{float(a.assessed_amount):,.2f}",
(tx.counterparty_name if tx else "")[:20],
Paragraph(a.reason[:120], normal_style),
])
if rows:
elements.append(_make_table(header, rows, [38*mm, 25*mm, 35*mm, 72*mm]))
else:
elements.append(Paragraph("暂无已确认交易记录。", normal_style))
elements.append(Spacer(1, 8 * mm))
# 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
if body.include_summary:
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),
f"{float(a.assessed_amount):,.2f}",
_mask_text(tx.counterparty_name if tx else "", 14),
Paragraph(_mask_text(a.reason, 80), normal_style),
])
if summary_rows:
elements.append(_make_table(summary_header, summary_rows, [36 * mm, 28 * mm, 34 * mm, 72 * mm]))
else:
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))
# Section 6: detailed transaction table
if body.include_transactions:
elements.append(Paragraph("交易明细", h2_style))
elements.append(Paragraph("重点交易明细(含证据索引)", h2_style))
elements.append(Spacer(1, 4 * mm))
header = ["交易时间", "金额", "方向", "对方", "订单号"]
header = ["交易时间", "金额", "方向", "来源APP", "对方", "证据索引", "状态"]
rows = []
for tx in await _get_all_transactions(case_id, db):
for tx in transactions[:120]:
fa = assessment_by_tx.get(tx.id)
rows.append([
tx.trade_time.strftime("%Y-%m-%d %H:%M"),
_fmt_dt(tx.trade_time),
f"{float(tx.amount):,.2f}",
"" if tx.direction.value == "out" else "",
tx.counterparty_name[:16],
tx.order_no[:16],
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",
])
if rows:
elements.append(_make_table(header, rows, [38*mm, 22*mm, 12*mm, 48*mm, 48*mm]))
elements.append(_make_table(header, rows, [31 * mm, 19 * mm, 12 * mm, 20 * mm, 30 * mm, 35 * mm, 24 * mm]))
elements.append(Spacer(1, 8 * mm))
# Section 7: reasons and exclusion
if body.include_reasons:
elements.append(Paragraph("认定理由与排除说明", h2_style))
elements.append(Spacer(1, 4 * mm))
assessment_by_tx = await _get_all_assessments(case_id, db)
for tx in await _get_all_transactions(case_id, db):
for tx in transactions:
fa = assessment_by_tx.get(tx.id)
if not fa:
continue
line = (
f"[{tx.trade_time.strftime('%m-%d %H:%M')}] ¥{float(tx.amount):,.2f} "
f"| {fa.confidence_level.value} | {fa.reason[:80]}"
f"[{_fmt_dt(tx.trade_time)}] ¥{float(tx.amount):,.2f} "
f"| 置信:{fa.confidence_level.value} | 状态:{fa.review_status.value} | 理由:{_mask_text(fa.reason, 80)}"
)
elements.append(Paragraph(line, normal_style))
if fa.exclude_reason:
elements.append(Paragraph(f" 排除: {fa.exclude_reason[:80]}", normal_style))
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))
elements.append(Spacer(1, 8 * mm))
# Section 8: inquiry suggestions
if body.include_inquiry:
elements.append(Paragraph("笔录辅助问询建议", h2_style))
elements.append(Paragraph("待复核事项与笔录辅助问询建议", h2_style))
elements.append(Spacer(1, 4 * mm))
if pending:
elements.append(Paragraph(f"当前待复核交易共 {len(pending)} 笔,涉及待复核金额约 ¥{dataset['pending_total']:,.2f}", normal_style))
suggestions = await _get_inquiry_suggestions(case_id, db)
for i, s in enumerate(suggestions, 1):
elements.append(Paragraph(f"{i}. {s}", normal_style))