feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -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/
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user