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

@@ -10,6 +10,7 @@ import {
buildClaimSummaryRiskCards,
buildItemClaimRiskState,
extractRiskTagsFromText,
filterRiskCardsByBusinessStage,
resolveRiskTags,
resolveRiskTagTone
} from '../src/views/scripts/travelRequestDetailInsights.js'
@@ -28,6 +29,10 @@ const detailViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
const detailViewInsights = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailInsights.js', import.meta.url)),
'utf8'
)
const detailViewStyle = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8'
@@ -48,6 +53,10 @@ const returnReasonDialog = readFileSync(
fileURLToPath(new URL('../src/components/shared/ReturnReasonDialog.vue', import.meta.url)),
'utf8'
)
const stageRiskAdviceCard = readFileSync(
fileURLToPath(new URL('../src/components/travel/StageRiskAdviceCard.vue', import.meta.url)),
'utf8'
)
const attachmentMeta = {
file_name: 'taxi-invoice.pdf',
@@ -172,12 +181,31 @@ test('AI advice keeps visible risk flags when backend uses tone instead of sever
assert.ok(riskCards[0].suggestion.includes('员工档案'))
})
test('risk card badge only shows severity while title keeps business risk name', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
{
source: 'attachment_analysis',
severity: 'high',
label: '票据日期超出差旅行程高风险',
message: '酒店发票日期为 2 月,晚于已批准 6 月差旅行程范围。'
}
]
})
assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].tone, 'high')
assert.equal(riskCards[0].label, '高风险')
assert.equal(riskCards[0].title, '票据日期超出差旅行程高风险')
})
test('AI advice falls back to claim risk summary instead of showing an empty risk area', () => {
const riskCards = buildClaimSummaryRiskCards({
riskSummary: 'AI预审发现 1 条中风险附件,已随单流转给审批人复核。'
})
assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].businessStage, 'reimbursement')
assert.equal(riskCards[0].tone, 'medium')
assert.equal(riskCards[0].label, '中风险')
assert.match(riskCards[0].risk, /中风险附件/)
@@ -185,6 +213,81 @@ test('AI advice falls back to claim risk summary instead of showing an empty ris
assert.ok(riskCards[0].suggestion.includes('附件预览'))
})
test('risk cards carry structured business stage for approval advice filtering', () => {
const applicationCards = buildAttachmentRiskCards({
businessStage: 'expense_application',
claimRiskFlags: [
{
source: 'policy_review',
severity: 'medium',
label: '预算风险',
message: '申请金额可能占用预算余额,需要预算管理者复核。'
}
]
})
const reimbursementCards = buildAttachmentRiskCards({
businessStage: 'expense_application',
claimRiskFlags: [
{
source: 'attachment_analysis',
business_stage: 'reimbursement',
severity: 'high',
label: '票据风险',
message: '报销票据城市与行程城市不一致。'
}
]
})
const legacyAttachmentCards = buildAttachmentRiskCards({
businessStage: 'expense_application',
claimRiskFlags: [
{
source: 'attachment_analysis',
severity: 'medium',
label: '附件风险',
message: '差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。'
}
]
})
const summaryCards = buildClaimSummaryRiskCards({
documentTypeCode: 'application',
riskSummary: '预算余额不足,申请需要复核。'
})
const attachmentSummaryCards = buildClaimSummaryRiskCards({
documentTypeCode: 'application',
businessStage: 'expense_application',
riskSummary: '差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。'
})
assert.equal(applicationCards.length, 1)
assert.equal(applicationCards[0].businessStage, 'expense_application')
assert.equal(reimbursementCards.length, 1)
assert.equal(reimbursementCards[0].businessStage, 'reimbursement')
assert.equal(legacyAttachmentCards.length, 1)
assert.equal(legacyAttachmentCards[0].businessStage, 'reimbursement')
assert.deepEqual(
filterRiskCardsByBusinessStage(
[...applicationCards, ...reimbursementCards, ...legacyAttachmentCards],
'expense_application'
).map((card) => card.risk),
['申请金额可能占用预算余额,需要预算管理者复核。']
)
assert.equal(summaryCards.length, 1)
assert.equal(summaryCards[0].businessStage, 'expense_application')
assert.equal(attachmentSummaryCards.length, 1)
assert.equal(attachmentSummaryCards[0].businessStage, 'reimbursement')
assert.deepEqual(filterRiskCardsByBusinessStage(attachmentSummaryCards, 'expense_application'), [])
})
test('stage risk advice card exposes direct reviewer action suggestion', () => {
assert.match(stageRiskAdviceCard, /class="employee-risk-action"/)
assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/)
assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
assert.match(stageRiskAdviceCard, /compactAdviceItems/)
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
assert.match(stageRiskAdviceCard, /可按权限继续审批/)
})
test('AI advice ignores approval opinions and flow logs as risks', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
@@ -292,13 +395,32 @@ test('AI advice view model exposes grouped completion and risk sections', () =>
advice.sections.map((section) => ({ title: section.title, kind: section.kind })),
[
{ title: '建议补充字段', kind: 'completion' },
{ title: '已知存在风险', kind: 'risk' }
{ title: '已知存在风险1项', kind: 'risk' }
]
)
assert.deepEqual(advice.sections[0].items, ['补充业务地点', '补充报销金额'])
assert.equal(advice.sections[1].items.length, 1)
})
test('AI advice view model sorts and displays every risk card', () => {
const riskCards = [
{ id: 'risk-1', tone: 'medium', label: '中风险', title: '中风险一', risk: '中风险一。' },
{ id: 'risk-2', tone: 'high', label: '高风险', title: '高风险一', risk: '高风险一。' },
{ id: 'risk-3', tone: 'medium', label: '中风险', title: '中风险二', risk: '中风险二。' },
{ id: 'risk-4', tone: 'high', label: '高风险', title: '高风险二', risk: '高风险二。' }
]
const advice = buildAiAdviceViewModel({
completionItems: [],
riskCards
})
const riskSection = advice.sections.find((section) => section.kind === 'risk')
assert.equal(advice.riskCards.length, 4)
assert.equal(riskSection.items.length, 4)
assert.equal(riskSection.hiddenCount, undefined)
assert.deepEqual(riskSection.items.map((item) => item.id), ['risk-2', 'risk-4', 'risk-1', 'risk-3'])
})
test('AI advice view model omits empty sections', () => {
const readyAdvice = buildAiAdviceViewModel({
completionItems: [],
@@ -326,15 +448,25 @@ test('AI advice view model omits empty sections', () => {
assert.deepEqual(readyAdvice.sections, [])
assert.equal(readyAdvice.badge, '可直接提交')
assert.deepEqual(completionOnlyAdvice.sections.map((section) => section.title), ['建议补充字段'])
assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险'])
assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险1项'])
})
test('AI advice template renders grouped section titles with completion before risk', () => {
assert.match(detailViewTemplate, /v-if="showAiAdvicePanel" class="detail-card panel validation-card"/)
assert.match(detailViewTemplate, /<h3>\{\{ aiAdviceTitle \}\}<\/h3>/)
assert.match(detailViewTemplate, /<p>\{\{ aiAdviceHint \}\}<\/p>/)
assert.match(detailViewScript, /buildClaimSummaryRiskCards\(request\.value\)/)
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => isEditableRequest\.value \|\| aiAdvice\.value\.riskCards\.length > 0\)/)
assert.match(detailViewScript, /businessStage: currentBusinessStage/)
assert.match(detailViewScript, /filterRiskCardsByBusinessStage/)
assert.match(detailViewScript, /const summaryRiskCards = filterRiskCardsByBusinessStage/)
assert.match(detailViewScript, /buildClaimSummaryRiskCards\(\{/)
assert.match(detailViewScript, /const canViewApprovalRiskAdvice = computed/)
assert.match(detailViewScript, /!isCurrentApplicant\.value/)
assert.match(detailViewScript, /const hasVisibleRiskCards = computed/)
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => \(/)
assert.match(detailViewScript, /isCurrentApplicant\.value && !isApplicationDocument\.value && hasVisibleRiskCards\.value/)
assert.match(detailViewScript, /return '报销风险提示'/)
assert.match(detailViewScript, /canViewApprovalRiskAdvice\.value && aiAdvice\.value\.riskCards\.length > 0/)
assert.match(detailViewScript, /hasAiPreReviewResult\.value/)
assert.match(detailViewTemplate, /v-if="aiAdvice\.sections\.length" class="validation-sections"/)
assert.match(detailViewTemplate, /v-for="section in aiAdvice\.sections"/)
assert.match(detailViewTemplate, /validation-section--\$\{section\.kind\}/)
@@ -348,16 +480,24 @@ test('AI advice template renders grouped section titles with completion before r
test('AI advice risk section uses compact card styling hooks', () => {
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/)
assert.match(detailViewTemplate, /class="risk-advice-compact-meta"/)
assert.doesNotMatch(detailViewTemplate, /section\.hiddenCount/)
assert.doesNotMatch(detailViewTemplate, /risk-advice-more/)
assert.doesNotMatch(detailViewTemplate, /card\.tags\?\.length/)
assert.doesNotMatch(detailViewTemplate, /risk-card-tag-list/)
assert.doesNotMatch(detailViewTemplate, /risk-note-tag/)
assert.match(detailViewScript, /tags: resolveRiskTags\(card\)/)
assert.match(detailViewInsights, /const sortedRiskCards = sortRiskCardsByTone\(normalizedRiskCards\)/)
assert.doesNotMatch(detailViewInsights, /visibleRiskCards/)
assert.doesNotMatch(detailViewInsights, /hiddenCount/)
assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*display: grid;\s*gap: 8px;\s*padding: 12px 12px 11px;/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-list \{\s*display: grid;\s*gap: 8px;\s*max-height: 360px;/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*position: relative;\s*display: grid;\s*grid-template-columns: minmax\(0, 1\.1fr\) minmax\(220px, \.9fr\);/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.low/)
assert.match(detailViewStyle, /\.risk-advice-card\.low/)
assert.doesNotMatch(detailViewStyle, /\.risk-note-tag/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-meta ul,\s*\.validation-section--risk \.risk-advice-meta p \{\s*margin: 0;/)
assert.match(detailViewStyle, /\.risk-advice-compact-meta span,\s*\.risk-advice-compact-meta em \{\s*margin: 0;/)
assert.doesNotMatch(detailViewStyle, /\.risk-advice-more/)
})
test('expense rows show a major-risk warning icon before time', () => {
@@ -534,7 +674,7 @@ test('travel detail AI advice adds low risk reminders for optional receipts', ()
assert.match(detailViewScript, /可以继续补充票据报销/)
assert.match(
detailViewScript,
/\.\.\.buildOptionalTravelReceiptRiskCards\(request\.value, expenseItems\.value\)/
/const optionalRiskCards = filterRiskCardsByBusinessStage\([\s\S]*buildOptionalTravelReceiptRiskCards\(request\.value, expenseItems\.value\)[\s\S]*currentBusinessStage/
)
})