feat: 新增归档中心页面并完善知识库与报销查询能力

新增前端归档中心视图及相关工具函数,扩充知识库文档分类和
提取器支持多种格式,增强编排器报销查询的多维度检索,优
化本体规则和用户代理审核消息,前端完善报销创建和审批详
情交互细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 16:00:19 +08:00
parent 1f15699013
commit 88ff04bef8
120 changed files with 6236 additions and 643 deletions

View File

@@ -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/)