fix: user
This commit is contained in:
@@ -51,7 +51,8 @@ def is_fee_tolerant_transit_pair(tx_a: TransactionRecord, tx_b: TransactionRecor
|
||||
amount_b = float(tx_b.amount or 0)
|
||||
if amount_a <= 0 or amount_b <= 0:
|
||||
return False
|
||||
if _amount_ratio_diff(amount_a, amount_b) > FEE_TOLERANCE_RATIO:
|
||||
diff_ratio = _amount_ratio_diff(amount_a, amount_b)
|
||||
if diff_ratio > FEE_TOLERANCE_RATIO:
|
||||
return False
|
||||
|
||||
time_a = tx_a.trade_time
|
||||
@@ -59,7 +60,8 @@ def is_fee_tolerant_transit_pair(tx_a: TransactionRecord, tx_b: TransactionRecor
|
||||
if not isinstance(time_a, datetime) or not isinstance(time_b, datetime):
|
||||
return False
|
||||
try:
|
||||
return abs((time_a - time_b).total_seconds()) <= FEE_TRANSIT_WINDOW_SECONDS
|
||||
seconds_gap = abs((time_a - time_b).total_seconds())
|
||||
return seconds_gap <= FEE_TRANSIT_WINDOW_SECONDS
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ class ReportCreate(CamelModel):
|
||||
include_timeline: bool = True
|
||||
include_reasons: bool = True
|
||||
include_inquiry: bool = False
|
||||
include_record_qa: bool = False
|
||||
|
||||
|
||||
class ReportOut(CamelModel):
|
||||
|
||||
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def assess_case(case_id: UUID, db: AsyncSession) -> list[FraudAssessment]:
|
||||
"""Run rule-based assessment on all non-duplicate transactions and replace old results."""
|
||||
"""Run rule-based assessment and replace old results."""
|
||||
# Replace mode: rerun analysis for the same case should overwrite prior assessments
|
||||
# instead of appending duplicated rows.
|
||||
await db.execute(
|
||||
@@ -26,23 +26,40 @@ async def assess_case(case_id: UUID, db: AsyncSession) -> list[FraudAssessment]:
|
||||
result = await db.execute(
|
||||
select(TransactionRecord)
|
||||
.where(TransactionRecord.case_id == case_id)
|
||||
.where(TransactionRecord.is_duplicate.is_(False))
|
||||
.order_by(TransactionRecord.trade_time.asc())
|
||||
)
|
||||
transactions = list(result.scalars().all())
|
||||
|
||||
assessments: list[FraudAssessment] = []
|
||||
for tx in transactions:
|
||||
if tx.is_duplicate:
|
||||
level = ConfidenceLevel.low
|
||||
reason = "该笔交易在交易归并中已标记为重复记录,属于同一笔交易的重复展示。"
|
||||
exclude_reason = "交易归并已判定重复(订单号一致或人工标记为重复),不重复计入被骗金额。"
|
||||
review_status = ReviewStatus.rejected
|
||||
assessed_amount = 0
|
||||
elif tx.is_transit:
|
||||
level = ConfidenceLevel.low
|
||||
reason = (
|
||||
f"该笔交易在交易归并中已标记为中转({tx.source_app.value} -> {tx.counterparty_name}),"
|
||||
"属于本人账户间资金流转。"
|
||||
)
|
||||
exclude_reason = "交易归并已判定中转(本人账户间互转),不直接计入被骗金额。"
|
||||
review_status = ReviewStatus.rejected
|
||||
assessed_amount = 0
|
||||
else:
|
||||
level, reason, exclude_reason = classify_transaction(tx)
|
||||
review_status = ReviewStatus.pending
|
||||
assessed_amount = float(tx.amount) if level != ConfidenceLevel.low else 0
|
||||
|
||||
fa = FraudAssessment(
|
||||
case_id=case_id,
|
||||
transaction_id=tx.id,
|
||||
confidence_level=level,
|
||||
assessed_amount=float(tx.amount) if level != ConfidenceLevel.low else 0,
|
||||
assessed_amount=assessed_amount,
|
||||
reason=reason,
|
||||
exclude_reason=exclude_reason,
|
||||
review_status=ReviewStatus.pending,
|
||||
review_status=review_status,
|
||||
)
|
||||
db.add(fa)
|
||||
assessments.append(fa)
|
||||
|
||||
@@ -102,6 +102,27 @@ def _mask_text(value: str, keep: int = 12) -> str:
|
||||
return f"{text[:keep]}..."
|
||||
|
||||
|
||||
def _build_record_qa_draft(transactions: list[TransactionRecord], assessment_by_tx: dict) -> str:
|
||||
candidates = [tx for tx in transactions if not tx.is_duplicate]
|
||||
if not candidates:
|
||||
return "问:具体的转账信息?\n答:目前暂无可确认的转账信息。"
|
||||
|
||||
candidates = sorted(candidates, key=lambda x: x.trade_time or datetime.min)
|
||||
lines: list[str] = []
|
||||
for idx, tx in enumerate(candidates, 1):
|
||||
fa = assessment_by_tx.get(tx.id)
|
||||
status = fa.review_status.value if fa else "pending"
|
||||
cp_account = f",对方账号{tx.counterparty_account}" if tx.counterparty_account else ""
|
||||
order_no = f",订单号{tx.order_no}" if tx.order_no else ""
|
||||
remark = f",备注“{tx.remark}”" if tx.remark else ""
|
||||
lines.append(
|
||||
f"第{idx}笔是{_fmt_dt(tx.trade_time, with_seconds=True)}通过{tx.source_app.value}"
|
||||
f"{'转出' if tx.direction.value == 'out' else '转入'}人民币{float(tx.amount):,.2f}元至{tx.counterparty_name}"
|
||||
f"{cp_account}{order_no}{remark},当前认定状态为{status}。"
|
||||
)
|
||||
return "问:具体的转账信息?\n答:" + "".join(lines)
|
||||
|
||||
|
||||
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)
|
||||
@@ -245,6 +266,14 @@ async def _gen_excel(case_id: UUID, report_dir: Path, body: ReportCreate, db: As
|
||||
ws4.append([i, s])
|
||||
sheet_created = True
|
||||
|
||||
if body.include_record_qa:
|
||||
ws5 = wb.active if not sheet_created else wb.create_sheet()
|
||||
ws5.title = "笔录问答草稿"
|
||||
ws5.append(["问答内容"])
|
||||
dataset = await _build_report_dataset(case_id, db)
|
||||
ws5.append([_build_record_qa_draft(dataset["transactions"], dataset["assessment_by_tx"])])
|
||||
sheet_created = True
|
||||
|
||||
if not sheet_created:
|
||||
ws = wb.active
|
||||
ws.title = "报告"
|
||||
@@ -482,6 +511,15 @@ async def _gen_pdf(case_id: UUID, report_dir: Path, body: ReportCreate, db: Asyn
|
||||
elements.append(Paragraph(f"{i}. {s}", normal_style))
|
||||
elements.append(Spacer(1, 8 * mm))
|
||||
|
||||
# Section 9: record QA draft
|
||||
if body.include_record_qa:
|
||||
elements.append(Paragraph("笔录问答草稿", h2_style))
|
||||
elements.append(Spacer(1, 4 * mm))
|
||||
qa_text = _build_record_qa_draft(transactions, assessment_by_tx)
|
||||
for line in qa_text.split("\n"):
|
||||
elements.append(Paragraph(line, normal_style))
|
||||
elements.append(Spacer(1, 8 * mm))
|
||||
|
||||
if not elements or len(elements) <= 2:
|
||||
elements.append(Paragraph("未选择任何报告内容。", normal_style))
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ type ContentKeys =
|
||||
| 'include_flow_chart'
|
||||
| 'include_timeline'
|
||||
| 'include_reasons'
|
||||
| 'include_inquiry';
|
||||
| 'include_inquiry'
|
||||
| 'include_record_qa';
|
||||
|
||||
const contentOptions: Array<{ key: ContentKeys; label: string; defaultOn: boolean }> = [
|
||||
{ key: 'include_summary', label: '被骗金额汇总表', defaultOn: true },
|
||||
@@ -46,17 +47,18 @@ const contentOptions: Array<{ key: ContentKeys; label: string; defaultOn: boolea
|
||||
{ key: 'include_timeline', label: '交易时间轴', defaultOn: true },
|
||||
{ key: 'include_reasons', label: '认定理由与排除说明', defaultOn: true },
|
||||
{ key: 'include_inquiry', label: '笔录辅助问询建议', defaultOn: false },
|
||||
{ key: 'include_record_qa', label: '笔录问答草稿', defaultOn: false },
|
||||
];
|
||||
|
||||
const STORAGE_PREFIX = 'report-content-';
|
||||
|
||||
const loadContentSelection = (caseId: string): Record<ContentKeys, boolean> => {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}${caseId}`);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch { /* ignore */ }
|
||||
const defaults: Record<string, boolean> = {};
|
||||
contentOptions.forEach((o) => { defaults[o.key] = o.defaultOn; });
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}${caseId}`);
|
||||
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
||||
} catch { /* ignore */ }
|
||||
return defaults as Record<ContentKeys, boolean>;
|
||||
};
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import type { FraudAssessment, ConfidenceLevel } from '../../types';
|
||||
import {
|
||||
fetchAssessments,
|
||||
fetchCase,
|
||||
submitReview,
|
||||
fetchInquirySuggestions,
|
||||
triggerAnalysis,
|
||||
@@ -200,6 +201,10 @@ const Review: React.FC = () => {
|
||||
queryFn: () => fetchAssessments(id),
|
||||
refetchInterval: () => (Date.now() < autoRefreshUntil ? 2000 : false),
|
||||
});
|
||||
const { data: currentCase } = useQuery({
|
||||
queryKey: ['case', id],
|
||||
queryFn: () => fetchCase(id),
|
||||
});
|
||||
const { data: suggestionsData, isLoading: suggestionsLoading, isFetching: suggestionsFetching } = useQuery({
|
||||
queryKey: ['suggestions', id],
|
||||
queryFn: () => fetchInquirySuggestions(id),
|
||||
@@ -213,6 +218,7 @@ const Review: React.FC = () => {
|
||||
|
||||
const allAssessments = assessData?.items ?? [];
|
||||
const suggestions = suggestionsData?.suggestions ?? [];
|
||||
const reviewedByName = (currentCase?.handler || '').trim() || 'demo_user';
|
||||
const hasNoAnalysisResult =
|
||||
suggestions.length === 1 && suggestions[0].includes('暂无分析结果');
|
||||
|
||||
@@ -412,7 +418,7 @@ const Review: React.FC = () => {
|
||||
body: {
|
||||
review_status: action,
|
||||
review_note: `操作:${aiSuggestionLabel[action]}`,
|
||||
reviewed_by: 'demo_user',
|
||||
reviewed_by: reviewedByName,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -996,7 +1002,7 @@ const Review: React.FC = () => {
|
||||
body: {
|
||||
review_status: reviewAction,
|
||||
review_note: reviewNote,
|
||||
reviewed_by: 'demo_user',
|
||||
reviewed_by: reviewedByName,
|
||||
},
|
||||
});
|
||||
}}
|
||||
@@ -1027,7 +1033,7 @@ const Review: React.FC = () => {
|
||||
body: {
|
||||
review_status: 'needs_info',
|
||||
review_note: supplementNote.trim(),
|
||||
reviewed_by: 'demo_user',
|
||||
reviewed_by: reviewedByName,
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { TransactionRecord, SourceApp } from '../../types';
|
||||
import { fetchImageDetail, fetchTransactions, updateTransaction } from '../../services/api';
|
||||
import { fetchImageDetail, fetchTransactions, triggerAnalysis, updateTransaction } from '../../services/api';
|
||||
|
||||
const appTag: Record<SourceApp, { label: string; color: string }> = {
|
||||
wechat: { label: '微信', color: 'green' },
|
||||
@@ -78,6 +78,36 @@ const Transactions: React.FC = () => {
|
||||
},
|
||||
onError: () => message.error('交易修改保存失败'),
|
||||
});
|
||||
const analysisMutation = useMutation({
|
||||
mutationFn: () => triggerAnalysis(id),
|
||||
onMutate: () => {
|
||||
message.open({
|
||||
key: 'transactions-analysis',
|
||||
type: 'loading',
|
||||
content: '正在执行资金分析...',
|
||||
duration: 0,
|
||||
});
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
message.open({
|
||||
key: 'transactions-analysis',
|
||||
type: 'success',
|
||||
content: res.message || '资金分析任务已提交',
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['transactions', id] });
|
||||
qc.invalidateQueries({ queryKey: ['assessments', id] });
|
||||
qc.invalidateQueries({ queryKey: ['flows', id] });
|
||||
qc.invalidateQueries({ queryKey: ['case', id] });
|
||||
qc.invalidateQueries({ queryKey: ['suggestions', id] });
|
||||
},
|
||||
onError: () => {
|
||||
message.open({
|
||||
key: 'transactions-analysis',
|
||||
type: 'error',
|
||||
content: '资金分析执行失败',
|
||||
});
|
||||
},
|
||||
});
|
||||
const allTransactions = txData?.items ?? [];
|
||||
const { data: detailImage, isFetching: detailImageFetching } = useQuery({
|
||||
queryKey: ['image-detail', detail?.evidenceImageId],
|
||||
@@ -356,6 +386,7 @@ const Transactions: React.FC = () => {
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Select
|
||||
value={filterDuplicate}
|
||||
onChange={setFilterDuplicate}
|
||||
@@ -366,6 +397,14 @@ const Transactions: React.FC = () => {
|
||||
{ label: '仅重复交易', value: 'duplicate' },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={analysisMutation.isPending}
|
||||
onClick={() => analysisMutation.mutate()}
|
||||
>
|
||||
{analysisMutation.isPending ? '分析中...' : '执行资金分析'}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
|
||||
@@ -298,6 +298,7 @@ export async function generateReport(
|
||||
include_timeline?: boolean;
|
||||
include_reasons?: boolean;
|
||||
include_inquiry?: boolean;
|
||||
include_record_qa?: boolean;
|
||||
},
|
||||
): Promise<ExportReport> {
|
||||
if (!(await isBackendUp())) return mockReports[0];
|
||||
|
||||
Reference in New Issue
Block a user