feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -3,6 +3,11 @@ import {
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from '../../utils/riskFlags.js'
import {
resolveRiskActionability,
resolveRiskDomain,
resolveRiskVisibilityScope
} from '../../utils/riskVisibility.js'
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
@@ -28,6 +33,121 @@ function uniqueTexts(values) {
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
}
function normalizeBusinessStage(value) {
const stage = normalizeText(value).toLowerCase()
if ([
'expense_application',
'application',
'apply',
'pre_apply',
'pre_application',
'budget_application'
].includes(stage)) {
return 'expense_application'
}
if ([
'reimbursement',
'expense_reimbursement',
'claim',
'expense_claim',
'expense_report'
].includes(stage)) {
return 'reimbursement'
}
return ''
}
function resolveFlagBusinessStage(flag, fallback = 'reimbursement') {
if (!flag || typeof flag !== 'object') {
return resolveRiskTextBusinessStage(flag, fallback)
}
const explicitStage = normalizeBusinessStage(
flag.businessStage
|| flag.business_stage
|| flag.controlStage
|| flag.control_stage
)
if (explicitStage) {
return explicitStage
}
const source = normalizeText(flag.source).toLowerCase()
const eventType = normalizeText(flag.event_type || flag.eventType).toLowerCase()
if (source === 'attachment_analysis' || /expense_claim|reimbursement|payment/.test(eventType)) {
return 'reimbursement'
}
if (/application/.test(source) || /expense_application/.test(eventType)) {
return 'expense_application'
}
return resolveRiskTextBusinessStage(cardLikeText(flag), fallback)
}
function resolveRiskTextBusinessStage(value, fallback = 'reimbursement') {
const text = normalizeText(value)
if (/报销|附件|单据|票据|发票|OCR|识别|付款|支付|酒店|住宿票|交通票/.test(text)) {
return 'reimbursement'
}
if (/申请|预算|额度|事前|预估|申请金额|申请事由/.test(text)) {
return 'expense_application'
}
return fallback
}
function cardLikeText(card = {}) {
return [
card.label,
card.title,
card.risk,
card.message,
card.summary,
card.suggestion,
card.description,
card.detail
].map((item) => normalizeText(item)).join(' ')
}
function resolveRequestBusinessStage(request = {}) {
const explicitStage = normalizeBusinessStage(
request?.businessStage
|| request?.business_stage
|| request?.controlStage
|| request?.control_stage
)
if (explicitStage) {
return explicitStage
}
const documentType = normalizeText(
request?.documentTypeCode
|| request?.document_type_code
|| request?.documentType
|| request?.document_type
).toLowerCase()
if (['application', 'expense_application'].includes(documentType)) {
return 'expense_application'
}
const claimNo = normalizeText(
request?.claimNo
|| request?.claim_no
|| request?.documentNo
|| request?.document_no
|| request?.id
).toUpperCase()
if (claimNo.startsWith('AP-') || claimNo.startsWith('APP-')) {
return 'expense_application'
}
const typeCode = normalizeText(request?.typeCode || request?.expense_type).toLowerCase()
if (typeCode === 'application' || typeCode.endsWith('_application')) {
return 'expense_application'
}
return 'reimbursement'
}
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (tone === 'pass') return 'pass'
@@ -37,6 +157,14 @@ function normalizeTone(value) {
return 'medium'
}
function resolveRiskLevelLabel(tone) {
const normalizedTone = normalizeTone(tone)
if (normalizedTone === 'high') return '高风险'
if (normalizedTone === 'medium') return '中风险'
if (normalizedTone === 'low') return '低风险'
return '风险提示'
}
export function normalizeRiskTone(value) {
return normalizeTone(value)
}
@@ -143,12 +271,34 @@ export function resolveRiskTags(card = {}) {
}
function withRiskTags(card) {
const businessStage = normalizeBusinessStage(
card.businessStage
|| card.business_stage
|| card.controlStage
|| card.control_stage
)
const riskDomain = resolveRiskDomain(card)
const actionability = resolveRiskActionability(card, { businessStage, riskDomain })
const visibilityScope = resolveRiskVisibilityScope(card, { businessStage, riskDomain, actionability })
return {
...card,
...(businessStage ? { businessStage } : {}),
riskDomain,
risk_domain: riskDomain,
actionability,
visibilityScope,
visibility_scope: visibilityScope,
tags: resolveRiskTags(card)
}
}
export function filterRiskCardsByBusinessStage(cards = [], businessStage = 'reimbursement') {
const targetStage = normalizeBusinessStage(businessStage) || 'reimbursement'
return (Array.isArray(cards) ? cards : []).filter(
(card) => resolveFlagBusinessStage(card, targetStage) === targetStage
)
}
function resolveDocumentTypeLabel(value) {
return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other
}
@@ -286,21 +436,24 @@ function buildCardSuggestion(analysis, insight) {
)
}
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }) {
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis, businessStage = 'reimbursement' }) {
const tone = normalizeTone(analysis?.severity)
const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '风险')
const title = normalizeText(analysis?.headline) || normalizeText(analysis?.label) || normalizeText(item?.name) || '附件风险'
return withRiskTags({
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
businessStage: normalizeBusinessStage(businessStage) || 'reimbursement',
tone,
label,
title: `${index + 1} 条:${normalizeText(analysis?.headline) || normalizeText(item?.name) || '附件风险'}`,
label: resolveRiskLevelLabel(tone),
title: `${index + 1} 条:${title}`,
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
suggestion: buildCardSuggestion(analysis, insight),
itemType: normalizeText(item?.itemType),
documentType: normalizeText(insight?.documentTypeLabel)
documentType: normalizeText(insight?.documentTypeLabel),
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter'
})
}
@@ -334,7 +487,7 @@ function resolveLatestManualReturnFlag(flags) {
}, manualReturnFlags[0])
}
function buildManualReturnRiskCard(flag) {
function buildManualReturnRiskCard(flag, businessStage = 'reimbursement') {
if (!flag) {
return null
}
@@ -355,21 +508,27 @@ function buildManualReturnRiskCard(flag) {
return withRiskTags({
id: `manual-return-${returnCount || 'latest'}`,
businessStage: resolveFlagBusinessStage(flag, normalizeBusinessStage(businessStage) || 'reimbursement'),
tone: 'medium',
label: '退回原因',
title: returnCount ? `${returnCount} 次退回` : '审批退回',
risk,
summary: normalizeText(flag.reason),
ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'],
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。'
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。',
risk_domain: flag.risk_domain || flag.riskDomain || 'workflow',
visibility_scope: flag.visibility_scope || flag.visibilityScope || 'submitter',
actionability: flag.actionability || 'fixable_by_submitter'
})
}
export function buildAttachmentRiskCards({
expenseItems = [],
attachmentMetaByItemId = {},
claimRiskFlags = []
claimRiskFlags = [],
businessStage = 'reimbursement'
} = {}) {
const normalizedBusinessStage = normalizeBusinessStage(businessStage) || 'reimbursement'
const attachmentRiskItemIds = new Set()
const attachmentCards = expenseItems.flatMap((item, index) => {
if (!item?.invoiceId) {
@@ -393,17 +552,31 @@ export function buildAttachmentRiskCards({
: [analysis.summary || analysis.headline || analysis.label]
return points
.map((point, pointIndex) => buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }))
.map((point, pointIndex) => buildRiskCardFromPoint({
item,
index,
point,
pointIndex,
insight,
analysis,
businessStage: normalizedBusinessStage
}))
.filter((card) => card.risk)
})
const normalizedClaimRiskFlags = Array.isArray(claimRiskFlags) ? claimRiskFlags : []
const latestManualReturnCard = buildManualReturnRiskCard(resolveLatestManualReturnFlag(normalizedClaimRiskFlags))
const latestManualReturnCard = buildManualReturnRiskCard(
resolveLatestManualReturnFlag(normalizedClaimRiskFlags),
normalizedBusinessStage
)
const claimCards = normalizedClaimRiskFlags
.flatMap((flag, index) => {
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return') {
return []
}
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'ai_pre_review') {
return []
}
if (!flag || typeof flag !== 'object') {
if (!isActionableRiskFlag(flag)) {
@@ -414,6 +587,7 @@ export function buildAttachmentRiskCards({
return risk
? [withRiskTags({
id: `claim-risk-${index}`,
businessStage: resolveRiskTextBusinessStage(risk, normalizedBusinessStage),
tone: 'medium',
label: '单据风险',
title: '单据风险提示',
@@ -457,13 +631,17 @@ export function buildAttachmentRiskCards({
return risks.map((risk, pointIndex) => withRiskTags({
id: `claim-risk-${index}-${pointIndex}`,
businessStage: resolveFlagBusinessStage(flag, normalizedBusinessStage),
tone,
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
title: normalizeText(flag.label) || '单据风险提示',
label: resolveRiskLevelLabel(tone),
title: normalizeText(flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code) || '单据风险提示',
risk,
summary,
ruleBasis,
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary })
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }),
risk_domain: flag.risk_domain || flag.riskDomain,
visibility_scope: flag.visibility_scope || flag.visibilityScope,
actionability: flag.actionability
}))
})
.filter(Boolean)
@@ -504,11 +682,13 @@ export function buildClaimSummaryRiskCards(request = {}) {
if (!isRiskTone(tone)) {
return []
}
const businessStage = resolveRiskTextBusinessStage(summary, resolveRequestBusinessStage(request))
return [withRiskTags({
id: 'claim-risk-summary',
businessStage,
tone,
label: tone === 'high' ? '高风险' : '中风险',
label: resolveRiskLevelLabel(tone),
title: '单据风险提示',
risk: summary,
summary,
@@ -524,6 +704,7 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = riskCards.filter(Boolean)
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards)
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
const items = [
@@ -553,8 +734,9 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
if (normalizedRiskCards.length) {
sections.push({
kind: 'risk',
title: '已知存在风险',
items: normalizedRiskCards
title: `已知存在风险${normalizedRiskCards.length}项)`,
items: sortedRiskCards,
totalCount: normalizedRiskCards.length
})
}
@@ -562,10 +744,25 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : '待核对',
summary: normalizedRiskCards.length
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议`
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示`
: '建议先补齐必填信息,完成后即可提交审批。',
items: normalizedCompletionItems,
riskCards: normalizedRiskCards,
sections
}
}
function sortRiskCardsByTone(cards) {
const toneWeight = {
high: 0,
medium: 1,
low: 2,
normal: 3,
pass: 4
}
return [...cards].sort((left, right) => {
const leftWeight = toneWeight[normalizeText(left?.tone).toLowerCase()] ?? 9
const rightWeight = toneWeight[normalizeText(right?.tone).toLowerCase()] ?? 9
return leftWeight - rightWeight
})
}