update: docs
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user