feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和 提取器支持多种格式,增强编排器报销查询的多维度检索,优 化本体规则和用户代理审核消息,前端完善报销创建和审批详 情交互细节,补充单元测试覆盖。
This commit is contained in:
@@ -7,11 +7,14 @@ import {
|
||||
buildAiAdviceViewModel,
|
||||
buildAttachmentInsightViewModel,
|
||||
buildAttachmentRiskCards,
|
||||
buildClaimSummaryRiskCards,
|
||||
buildItemClaimRiskState,
|
||||
extractRiskTagsFromText,
|
||||
resolveRiskTags,
|
||||
resolveRiskTagTone
|
||||
} from '../src/views/scripts/travelRequestDetailInsights.js'
|
||||
import {
|
||||
buildExpenseItemViewModel,
|
||||
buildDraftBlockingIssues
|
||||
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
|
||||
|
||||
@@ -148,6 +151,124 @@ test('AI advice splits claim attachment risk flags into specific points', () =>
|
||||
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('风险汇总')))
|
||||
})
|
||||
|
||||
test('AI advice keeps visible risk flags when backend uses tone instead of severity', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'submission_review',
|
||||
tone: 'medium',
|
||||
label: '中风险',
|
||||
message: '直属领导缺失,当前单据需审批环节补充分配。'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
assert.equal(riskCards[0].tone, 'medium')
|
||||
assert.equal(riskCards[0].risk, '直属领导缺失,当前单据需审批环节补充分配。')
|
||||
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('审批链校验')))
|
||||
assert.ok(riskCards[0].suggestion.includes('员工档案'))
|
||||
})
|
||||
|
||||
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].tone, 'medium')
|
||||
assert.equal(riskCards[0].label, '中风险')
|
||||
assert.match(riskCards[0].risk, /中风险附件/)
|
||||
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('风险汇总')))
|
||||
assert.ok(riskCards[0].suggestion.includes('附件预览'))
|
||||
})
|
||||
|
||||
test('AI advice ignores approval opinions and flow logs as risks', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
severity: 'info',
|
||||
label: '领导审批通过',
|
||||
message: '同意'
|
||||
},
|
||||
{
|
||||
source: 'finance_approval',
|
||||
severity: 'info',
|
||||
label: '财务审核通过',
|
||||
message: '周晓彤 已完成财务审核,进入归档入账。'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(riskCards, [])
|
||||
assert.deepEqual(buildClaimSummaryRiskCards({ riskSummary: '同意' }), [])
|
||||
assert.deepEqual(buildClaimSummaryRiskCards({ riskSummary: '周晓彤 已完成财务审核,进入归档入账。' }), [])
|
||||
})
|
||||
|
||||
test('expense row risk state falls back to claim item risk flags', () => {
|
||||
const state = buildItemClaimRiskState(
|
||||
{
|
||||
id: 'hotel-item',
|
||||
name: '住宿费'
|
||||
},
|
||||
[
|
||||
{
|
||||
source: 'attachment_analysis',
|
||||
item_id: 'hotel-item',
|
||||
severity: 'high',
|
||||
label: '高风险',
|
||||
message: '费用明细第 2 条:住宿标准:当前酒店识别金额约 880.00 元/晚。',
|
||||
summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
|
||||
points: ['住宿标准:当前酒店识别金额约 880.00 元/晚。']
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert.equal(state.tone, 'high')
|
||||
assert.equal(state.label, '高风险')
|
||||
assert.match(state.summary, /住宿票据金额超过/)
|
||||
assert.deepEqual(state.points, ['住宿标准:当前酒店识别金额约 880.00 元/晚。'])
|
||||
})
|
||||
|
||||
test('attachment risk cards do not duplicate claim fallback flags for the same item', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: [
|
||||
{
|
||||
id: 'hotel-item',
|
||||
name: '住宿费',
|
||||
invoiceId: 'hotel-risk.png'
|
||||
}
|
||||
],
|
||||
attachmentMetaByItemId: {
|
||||
'hotel-item': {
|
||||
analysis: {
|
||||
severity: 'high',
|
||||
label: '高风险',
|
||||
headline: 'AI提示:住宿金额超出报销标准',
|
||||
summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
|
||||
points: ['住宿标准:当前酒店识别金额约 880.00 元/晚。'],
|
||||
suggestion: '请补充超标说明。'
|
||||
}
|
||||
}
|
||||
},
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'attachment_analysis',
|
||||
item_id: 'hotel-item',
|
||||
severity: 'high',
|
||||
label: '高风险',
|
||||
message: '费用明细第 1 条:住宿标准:当前酒店识别金额约 880.00 元/晚。',
|
||||
summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
|
||||
points: ['住宿标准:当前酒店识别金额约 880.00 元/晚。']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
assert.equal(riskCards[0].risk, '住宿标准:当前酒店识别金额约 880.00 元/晚。')
|
||||
})
|
||||
|
||||
test('AI advice view model exposes grouped completion and risk sections', () => {
|
||||
const advice = buildAiAdviceViewModel({
|
||||
completionItems: ['补充业务地点', '补充报销金额'],
|
||||
@@ -207,6 +328,11 @@ test('AI advice view model omits empty sections', () => {
|
||||
})
|
||||
|
||||
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(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\}/)
|
||||
@@ -220,13 +346,15 @@ 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, /v-if="card\.tags\?\.length" class="risk-card-tag-list"/)
|
||||
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(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-card\.low/)
|
||||
assert.match(detailViewStyle, /\.risk-advice-card\.low/)
|
||||
assert.match(detailViewStyle, /\.risk-note-tag\.high/)
|
||||
assert.match(detailViewStyle, /\.risk-note-tag\.hotel/)
|
||||
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;/)
|
||||
})
|
||||
|
||||
@@ -235,6 +363,7 @@ test('expense rows show a major-risk warning icon before time', () => {
|
||||
assert.match(detailViewTemplate, /class="mdi mdi-alert expense-risk-indicator"/)
|
||||
assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
|
||||
assert.match(detailViewScript, /function isMajorExpenseRisk\(item\)/)
|
||||
assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/)
|
||||
})
|
||||
|
||||
test('AI advice shows only the latest manual return while preserving return count context', () => {
|
||||
@@ -296,10 +425,12 @@ test('additional note is shown above expense details as travel purpose text', ()
|
||||
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
|
||||
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
|
||||
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
|
||||
assert.match(detailViewTemplate, /v-model="detailNoteEditor"/)
|
||||
assert.match(detailViewTemplate, /v-model="detailNoteEditorView"/)
|
||||
assert.match(detailViewTemplate, /提交后将作为明确说明展示/)
|
||||
assert.match(detailViewScript, /const canEditDetailNote = computed\(\(\) => isDraftRequest\.value\)/)
|
||||
assert.match(detailViewScript, /function normalizeDetailNoteDraftValue\(value\)/)
|
||||
assert.match(detailViewScript, /function stripRiskTagsForDisplay\(value\)/)
|
||||
assert.match(detailViewScript, /function mergeVisibleNoteWithHiddenTags\(visibleText, rawText\)/)
|
||||
assert.match(detailViewScript, /const detailNoteSource = computed\(\(\) => normalizeDetailNoteDraftValue\(request\.value\.note\)\)/)
|
||||
assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId/)
|
||||
assert.match(detailViewScript, /emit\('request-updated', \{ claimId: request\.value\.claimId \}\)/)
|
||||
@@ -337,8 +468,8 @@ test('travel item date caption distinguishes departure return and trip events',
|
||||
})
|
||||
|
||||
test('expense detail table shows each item filled time from item creation time', () => {
|
||||
assert.match(detailViewTemplate, /<th class="col-filled-at">填写时间<\/th>/)
|
||||
assert.match(detailViewTemplate, /<td class="expense-filled-at col-filled-at">[\s\S]*\{\{ item\.filledAt \}\}/)
|
||||
assert.match(detailViewTemplate, /<th class="col-filled-at">填写时间<\/th>[\s\S]*<th class="col-time">发生时间<\/th>/)
|
||||
assert.match(detailViewTemplate, /<td class="expense-filled-at col-filled-at">[\s\S]*\{\{ item\.filledAt \}\}[\s\S]*<td :class="\['expense-time col-time'/)
|
||||
assert.match(detailViewTemplate, /<span>条款填写时间<\/span>/)
|
||||
assert.match(detailViewScript, /function formatExpenseFilledTime\(value\)/)
|
||||
assert.match(detailViewScript, /source\?\.filledAt[\s\S]*source\?\.created_at/)
|
||||
@@ -439,6 +570,35 @@ test('draft submit validation uses expense detail date and amount when claim sum
|
||||
})
|
||||
|
||||
test('transport ticket descriptions use route format and invalid format becomes risk advice', () => {
|
||||
const routeItem = buildExpenseItemViewModel(
|
||||
{
|
||||
id: 'route-item',
|
||||
itemType: 'train_ticket',
|
||||
itemReason: '广州南-上海虹桥',
|
||||
itemLocation: '上海',
|
||||
itemAmount: 354,
|
||||
invoiceId: 'train-ticket.png'
|
||||
},
|
||||
0,
|
||||
{ claimId: 'claim-route', detailVariant: 'travel' }
|
||||
)
|
||||
const shipItem = buildExpenseItemViewModel(
|
||||
{
|
||||
id: 'ship-item',
|
||||
itemType: 'ship_ticket',
|
||||
itemReason: '上海港-舟山港',
|
||||
itemLocation: '舟山',
|
||||
itemAmount: 120,
|
||||
invoiceId: 'ship-ticket.png'
|
||||
},
|
||||
1,
|
||||
{ claimId: 'claim-route', detailVariant: 'travel' }
|
||||
)
|
||||
|
||||
assert.equal(routeItem.desc, '广州南-上海虹桥')
|
||||
assert.equal(routeItem.detail, '起始地-目的地')
|
||||
assert.equal(shipItem.name, '轮船票')
|
||||
assert.equal(shipItem.detail, '起始地-目的地')
|
||||
assert.match(detailViewScript, /const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'\]\)/)
|
||||
assert.match(detailViewScript, /const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set\(\['hotel_ticket'\]\)/)
|
||||
assert.match(detailViewScript, /const ROUTE_DESCRIPTION_PATTERN = \/\^\[A-Za-z0-9\\u4e00-\\u9fa5/)
|
||||
|
||||
Reference in New Issue
Block a user