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)
|
amount_b = float(tx_b.amount or 0)
|
||||||
if amount_a <= 0 or amount_b <= 0:
|
if amount_a <= 0 or amount_b <= 0:
|
||||||
return False
|
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
|
return False
|
||||||
|
|
||||||
time_a = tx_a.trade_time
|
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):
|
if not isinstance(time_a, datetime) or not isinstance(time_b, datetime):
|
||||||
return False
|
return False
|
||||||
try:
|
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:
|
except TypeError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ReportCreate(CamelModel):
|
|||||||
include_timeline: bool = True
|
include_timeline: bool = True
|
||||||
include_reasons: bool = True
|
include_reasons: bool = True
|
||||||
include_inquiry: bool = False
|
include_inquiry: bool = False
|
||||||
|
include_record_qa: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ReportOut(CamelModel):
|
class ReportOut(CamelModel):
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def assess_case(case_id: UUID, db: AsyncSession) -> list[FraudAssessment]:
|
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
|
# Replace mode: rerun analysis for the same case should overwrite prior assessments
|
||||||
# instead of appending duplicated rows.
|
# instead of appending duplicated rows.
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -26,23 +26,40 @@ async def assess_case(case_id: UUID, db: AsyncSession) -> list[FraudAssessment]:
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(TransactionRecord)
|
select(TransactionRecord)
|
||||||
.where(TransactionRecord.case_id == case_id)
|
.where(TransactionRecord.case_id == case_id)
|
||||||
.where(TransactionRecord.is_duplicate.is_(False))
|
|
||||||
.order_by(TransactionRecord.trade_time.asc())
|
.order_by(TransactionRecord.trade_time.asc())
|
||||||
)
|
)
|
||||||
transactions = list(result.scalars().all())
|
transactions = list(result.scalars().all())
|
||||||
|
|
||||||
assessments: list[FraudAssessment] = []
|
assessments: list[FraudAssessment] = []
|
||||||
for tx in transactions:
|
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)
|
level, reason, exclude_reason = classify_transaction(tx)
|
||||||
|
review_status = ReviewStatus.pending
|
||||||
|
assessed_amount = float(tx.amount) if level != ConfidenceLevel.low else 0
|
||||||
|
|
||||||
fa = FraudAssessment(
|
fa = FraudAssessment(
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
transaction_id=tx.id,
|
transaction_id=tx.id,
|
||||||
confidence_level=level,
|
confidence_level=level,
|
||||||
assessed_amount=float(tx.amount) if level != ConfidenceLevel.low else 0,
|
assessed_amount=assessed_amount,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
exclude_reason=exclude_reason,
|
exclude_reason=exclude_reason,
|
||||||
review_status=ReviewStatus.pending,
|
review_status=review_status,
|
||||||
)
|
)
|
||||||
db.add(fa)
|
db.add(fa)
|
||||||
assessments.append(fa)
|
assessments.append(fa)
|
||||||
|
|||||||
@@ -102,6 +102,27 @@ def _mask_text(value: str, keep: int = 12) -> str:
|
|||||||
return f"{text[:keep]}..."
|
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:
|
async def _build_report_dataset(case_id: UUID, db: AsyncSession) -> dict:
|
||||||
case_obj = await db.get(Case, case_id)
|
case_obj = await db.get(Case, case_id)
|
||||||
transactions = await _get_all_transactions(case_id, db)
|
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])
|
ws4.append([i, s])
|
||||||
sheet_created = True
|
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:
|
if not sheet_created:
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "报告"
|
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(Paragraph(f"{i}. {s}", normal_style))
|
||||||
elements.append(Spacer(1, 8 * mm))
|
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:
|
if not elements or len(elements) <= 2:
|
||||||
elements.append(Paragraph("未选择任何报告内容。", normal_style))
|
elements.append(Paragraph("未选择任何报告内容。", normal_style))
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ type ContentKeys =
|
|||||||
| 'include_flow_chart'
|
| 'include_flow_chart'
|
||||||
| 'include_timeline'
|
| 'include_timeline'
|
||||||
| 'include_reasons'
|
| 'include_reasons'
|
||||||
| 'include_inquiry';
|
| 'include_inquiry'
|
||||||
|
| 'include_record_qa';
|
||||||
|
|
||||||
const contentOptions: Array<{ key: ContentKeys; label: string; defaultOn: boolean }> = [
|
const contentOptions: Array<{ key: ContentKeys; label: string; defaultOn: boolean }> = [
|
||||||
{ key: 'include_summary', label: '被骗金额汇总表', defaultOn: true },
|
{ 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_timeline', label: '交易时间轴', defaultOn: true },
|
||||||
{ key: 'include_reasons', label: '认定理由与排除说明', defaultOn: true },
|
{ key: 'include_reasons', label: '认定理由与排除说明', defaultOn: true },
|
||||||
{ key: 'include_inquiry', label: '笔录辅助问询建议', defaultOn: false },
|
{ key: 'include_inquiry', label: '笔录辅助问询建议', defaultOn: false },
|
||||||
|
{ key: 'include_record_qa', label: '笔录问答草稿', defaultOn: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STORAGE_PREFIX = 'report-content-';
|
const STORAGE_PREFIX = 'report-content-';
|
||||||
|
|
||||||
const loadContentSelection = (caseId: string): Record<ContentKeys, boolean> => {
|
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> = {};
|
const defaults: Record<string, boolean> = {};
|
||||||
contentOptions.forEach((o) => { defaults[o.key] = o.defaultOn; });
|
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>;
|
return defaults as Record<ContentKeys, boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import type { FraudAssessment, ConfidenceLevel } from '../../types';
|
import type { FraudAssessment, ConfidenceLevel } from '../../types';
|
||||||
import {
|
import {
|
||||||
fetchAssessments,
|
fetchAssessments,
|
||||||
|
fetchCase,
|
||||||
submitReview,
|
submitReview,
|
||||||
fetchInquirySuggestions,
|
fetchInquirySuggestions,
|
||||||
triggerAnalysis,
|
triggerAnalysis,
|
||||||
@@ -200,6 +201,10 @@ const Review: React.FC = () => {
|
|||||||
queryFn: () => fetchAssessments(id),
|
queryFn: () => fetchAssessments(id),
|
||||||
refetchInterval: () => (Date.now() < autoRefreshUntil ? 2000 : false),
|
refetchInterval: () => (Date.now() < autoRefreshUntil ? 2000 : false),
|
||||||
});
|
});
|
||||||
|
const { data: currentCase } = useQuery({
|
||||||
|
queryKey: ['case', id],
|
||||||
|
queryFn: () => fetchCase(id),
|
||||||
|
});
|
||||||
const { data: suggestionsData, isLoading: suggestionsLoading, isFetching: suggestionsFetching } = useQuery({
|
const { data: suggestionsData, isLoading: suggestionsLoading, isFetching: suggestionsFetching } = useQuery({
|
||||||
queryKey: ['suggestions', id],
|
queryKey: ['suggestions', id],
|
||||||
queryFn: () => fetchInquirySuggestions(id),
|
queryFn: () => fetchInquirySuggestions(id),
|
||||||
@@ -213,6 +218,7 @@ const Review: React.FC = () => {
|
|||||||
|
|
||||||
const allAssessments = assessData?.items ?? [];
|
const allAssessments = assessData?.items ?? [];
|
||||||
const suggestions = suggestionsData?.suggestions ?? [];
|
const suggestions = suggestionsData?.suggestions ?? [];
|
||||||
|
const reviewedByName = (currentCase?.handler || '').trim() || 'demo_user';
|
||||||
const hasNoAnalysisResult =
|
const hasNoAnalysisResult =
|
||||||
suggestions.length === 1 && suggestions[0].includes('暂无分析结果');
|
suggestions.length === 1 && suggestions[0].includes('暂无分析结果');
|
||||||
|
|
||||||
@@ -412,7 +418,7 @@ const Review: React.FC = () => {
|
|||||||
body: {
|
body: {
|
||||||
review_status: action,
|
review_status: action,
|
||||||
review_note: `操作:${aiSuggestionLabel[action]}`,
|
review_note: `操作:${aiSuggestionLabel[action]}`,
|
||||||
reviewed_by: 'demo_user',
|
reviewed_by: reviewedByName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -996,7 +1002,7 @@ const Review: React.FC = () => {
|
|||||||
body: {
|
body: {
|
||||||
review_status: reviewAction,
|
review_status: reviewAction,
|
||||||
review_note: reviewNote,
|
review_note: reviewNote,
|
||||||
reviewed_by: 'demo_user',
|
reviewed_by: reviewedByName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -1027,7 +1033,7 @@ const Review: React.FC = () => {
|
|||||||
body: {
|
body: {
|
||||||
review_status: 'needs_info',
|
review_status: 'needs_info',
|
||||||
review_note: supplementNote.trim(),
|
review_note: supplementNote.trim(),
|
||||||
reviewed_by: 'demo_user',
|
reviewed_by: reviewedByName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { TransactionRecord, SourceApp } from '../../types';
|
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 }> = {
|
const appTag: Record<SourceApp, { label: string; color: string }> = {
|
||||||
wechat: { label: '微信', color: 'green' },
|
wechat: { label: '微信', color: 'green' },
|
||||||
@@ -78,6 +78,36 @@ const Transactions: React.FC = () => {
|
|||||||
},
|
},
|
||||||
onError: () => message.error('交易修改保存失败'),
|
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 allTransactions = txData?.items ?? [];
|
||||||
const { data: detailImage, isFetching: detailImageFetching } = useQuery({
|
const { data: detailImage, isFetching: detailImageFetching } = useQuery({
|
||||||
queryKey: ['image-detail', detail?.evidenceImageId],
|
queryKey: ['image-detail', detail?.evidenceImageId],
|
||||||
@@ -356,6 +386,7 @@ const Transactions: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
|
<Space>
|
||||||
<Select
|
<Select
|
||||||
value={filterDuplicate}
|
value={filterDuplicate}
|
||||||
onChange={setFilterDuplicate}
|
onChange={setFilterDuplicate}
|
||||||
@@ -366,6 +397,14 @@ const Transactions: React.FC = () => {
|
|||||||
{ label: '仅重复交易', value: 'duplicate' },
|
{ label: '仅重复交易', value: 'duplicate' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={analysisMutation.isPending}
|
||||||
|
onClick={() => analysisMutation.mutate()}
|
||||||
|
>
|
||||||
|
{analysisMutation.isPending ? '分析中...' : '执行资金分析'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export async function generateReport(
|
|||||||
include_timeline?: boolean;
|
include_timeline?: boolean;
|
||||||
include_reasons?: boolean;
|
include_reasons?: boolean;
|
||||||
include_inquiry?: boolean;
|
include_inquiry?: boolean;
|
||||||
|
include_record_qa?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<ExportReport> {
|
): Promise<ExportReport> {
|
||||||
if (!(await isBackendUp())) return mockReports[0];
|
if (!(await isBackendUp())) return mockReports[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user