feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和 提取器支持多种格式,增强编排器报销查询的多维度检索,优 化本体规则和用户代理审核消息,前端完善报销创建和审批详 情交互细节,补充单元测试覆盖。
This commit is contained in:
100
web/tests/app-shell-detail-alerts.test.mjs
Normal file
100
web/tests/app-shell-detail-alerts.test.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildDetailAlerts,
|
||||
hasMissingAttachment,
|
||||
hasPendingInfo
|
||||
} from '../src/utils/detailAlerts.js'
|
||||
|
||||
test('detail topbar ignores system allowance rows when checking missing tickets', () => {
|
||||
const request = {
|
||||
node: '直属领导审批',
|
||||
approvalKey: 'in_progress',
|
||||
typeCode: 'travel',
|
||||
typeLabel: '差旅费',
|
||||
reason: '上海项目出差',
|
||||
location: '上海',
|
||||
city: '上海',
|
||||
occurredDisplay: '2026-05-13 至 2026-05-15',
|
||||
amountValue: 1008,
|
||||
profilePosition: '待补充',
|
||||
profileGrade: '待补充',
|
||||
profileManager: '待补充',
|
||||
expenseItems: [
|
||||
{
|
||||
id: 'outbound-train',
|
||||
itemType: 'train_ticket',
|
||||
itemReason: '广州南-上海虹桥',
|
||||
itemLocation: '上海',
|
||||
itemDate: '2026-05-13',
|
||||
itemAmount: 354,
|
||||
invoiceId: 'outbound.png'
|
||||
},
|
||||
{
|
||||
id: 'hotel',
|
||||
itemType: 'hotel_ticket',
|
||||
itemReason: '上海中心酒店',
|
||||
itemLocation: '上海',
|
||||
itemDate: '2026-05-14',
|
||||
itemAmount: 354,
|
||||
invoiceId: 'hotel.png'
|
||||
},
|
||||
{
|
||||
id: 'allowance',
|
||||
itemType: 'travel_allowance',
|
||||
itemReason: '系统自动计算出差补贴',
|
||||
itemLocation: '上海',
|
||||
itemDate: '2026-05-15',
|
||||
itemAmount: 300,
|
||||
invoiceId: '',
|
||||
isSystemGenerated: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const alerts = buildDetailAlerts(request).map((item) => item.label)
|
||||
|
||||
assert.equal(hasMissingAttachment(request), false)
|
||||
assert.equal(hasPendingInfo(request), false)
|
||||
assert.deepEqual(alerts, ['直属领导审批'])
|
||||
})
|
||||
|
||||
test('detail topbar still flags real manual rows without required ticket info', () => {
|
||||
const request = {
|
||||
node: '待提交',
|
||||
approvalKey: 'draft',
|
||||
typeCode: 'travel',
|
||||
typeLabel: '差旅费',
|
||||
reason: '待补充',
|
||||
location: '待补充',
|
||||
city: '待补充',
|
||||
occurredDisplay: '待补充',
|
||||
amountValue: 0,
|
||||
expenseItems: [
|
||||
{
|
||||
id: 'manual-train',
|
||||
itemType: 'train_ticket',
|
||||
itemReason: '',
|
||||
itemLocation: '',
|
||||
itemDate: '',
|
||||
itemAmount: 0,
|
||||
invoiceId: ''
|
||||
},
|
||||
{
|
||||
id: 'allowance',
|
||||
itemType: 'travel_allowance',
|
||||
itemReason: '系统自动计算出差补贴',
|
||||
itemAmount: 300,
|
||||
invoiceId: '',
|
||||
isSystemGenerated: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const alerts = buildDetailAlerts(request).map((item) => item.label)
|
||||
|
||||
assert.equal(hasMissingAttachment(request), true)
|
||||
assert.equal(hasPendingInfo(request), true)
|
||||
assert.deepEqual(alerts, ['待提交', '缺少票据', '待补信息'])
|
||||
})
|
||||
102
web/tests/archive-center-list-filters.test.mjs
Normal file
102
web/tests/archive-center-list-filters.test.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
applyArchiveListFilters,
|
||||
buildArchiveMonthFilterOptions,
|
||||
buildDepartmentFilterOptions,
|
||||
buildTypeFilterOptions,
|
||||
countClaimRisks,
|
||||
extractArchiveMonth,
|
||||
formatArchiveMonthLabel,
|
||||
formatArchiveRiskCountLabel,
|
||||
hasActiveArchiveListFilters
|
||||
} from '../src/utils/archiveCenterListFilters.js'
|
||||
|
||||
const sampleRows = [
|
||||
{
|
||||
id: 'EXP-001',
|
||||
typeCode: 'travel',
|
||||
type: '差旅费',
|
||||
department: '研发部',
|
||||
archiveMonth: '2026-05',
|
||||
archiveMonthLabel: '2026年05月',
|
||||
archiveTab: '差旅报销',
|
||||
hasRisk: true,
|
||||
riskTone: 'high',
|
||||
risk: '2条',
|
||||
riskCount: 2
|
||||
},
|
||||
{
|
||||
id: 'EXP-002',
|
||||
typeCode: 'entertainment',
|
||||
type: '业务招待费',
|
||||
department: '销售部',
|
||||
archiveMonth: '2026-04',
|
||||
archiveMonthLabel: '2026年04月',
|
||||
archiveTab: '招待报销',
|
||||
hasRisk: false,
|
||||
riskTone: 'none',
|
||||
risk: '0条',
|
||||
riskCount: 0
|
||||
}
|
||||
]
|
||||
|
||||
test('countClaimRisks counts flag points and summary fallback', () => {
|
||||
assert.equal(
|
||||
countClaimRisks([
|
||||
{ severity: 'high', points: ['酒店超标', '缺少水单'] },
|
||||
{ severity: 'info', message: '提示信息' }
|
||||
], '无'),
|
||||
2
|
||||
)
|
||||
assert.equal(countClaimRisks([], '发票抬头不一致'), 1)
|
||||
assert.equal(formatArchiveRiskCountLabel(3), '3条')
|
||||
})
|
||||
|
||||
test('countClaimRisks ignores approval opinions and completed flow logs', () => {
|
||||
assert.equal(
|
||||
countClaimRisks([
|
||||
{ source: 'manual_approval', severity: 'info', message: '同意' },
|
||||
{ source: 'finance_approval', severity: 'info', message: '周晓彤 已完成财务审核,进入归档入账。' }
|
||||
], '无'),
|
||||
0
|
||||
)
|
||||
assert.equal(countClaimRisks([], '同意'), 0)
|
||||
assert.equal(countClaimRisks([], '周晓彤 已完成财务审核,进入归档入账。'), 0)
|
||||
})
|
||||
|
||||
test('extractArchiveMonth parses iso timestamps', () => {
|
||||
assert.equal(extractArchiveMonth('2026-05-20T08:00:00.000Z'), '2026-05')
|
||||
})
|
||||
|
||||
test('applyArchiveListFilters supports department and archive month', () => {
|
||||
const filtered = applyArchiveListFilters(sampleRows, {
|
||||
department: '销售部',
|
||||
archiveMonth: '2026-04'
|
||||
})
|
||||
|
||||
assert.equal(filtered.length, 1)
|
||||
assert.equal(filtered[0].id, 'EXP-002')
|
||||
})
|
||||
|
||||
test('build filter options are derived from loaded rows', () => {
|
||||
const typeLabels = buildTypeFilterOptions(sampleRows).map((item) => item.label)
|
||||
const departmentLabels = buildDepartmentFilterOptions(sampleRows).map((item) => item.label)
|
||||
const monthOptions = buildArchiveMonthFilterOptions(sampleRows)
|
||||
|
||||
assert.equal(typeLabels[0], '全部类型')
|
||||
assert.ok(typeLabels.includes('差旅费'))
|
||||
assert.ok(typeLabels.includes('业务招待费'))
|
||||
assert.equal(departmentLabels[0], '全部部门')
|
||||
assert.ok(departmentLabels.includes('研发部'))
|
||||
assert.ok(departmentLabels.includes('销售部'))
|
||||
assert.equal(formatArchiveMonthLabel('2026-05'), '2026年05月')
|
||||
assert.equal(monthOptions[0].label, '全部月份')
|
||||
assert.ok(monthOptions.some((item) => item.value === '2026-05'))
|
||||
})
|
||||
|
||||
test('hasActiveArchiveListFilters detects active criteria', () => {
|
||||
assert.equal(hasActiveArchiveListFilters({ risk: 'high' }), true)
|
||||
assert.equal(hasActiveArchiveListFilters({ risk: 'all', type: 'all' }), false)
|
||||
})
|
||||
130
web/tests/assistant-session-draft-delete.test.mjs
Normal file
130
web/tests/assistant-session-draft-delete.test.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
clearAssistantSessionSnapshotForDraftClaim,
|
||||
readAssistantSessionSnapshot,
|
||||
writeAssistantSessionSnapshot
|
||||
} from '../src/utils/assistantSessionSnapshot.js'
|
||||
|
||||
function installWindowStub() {
|
||||
const store = new Map()
|
||||
const events = []
|
||||
|
||||
globalThis.CustomEvent = class CustomEvent {
|
||||
constructor(type, options = {}) {
|
||||
this.type = type
|
||||
this.detail = options.detail || {}
|
||||
}
|
||||
}
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem(key) {
|
||||
return store.has(key) ? store.get(key) : null
|
||||
},
|
||||
setItem(key, value) {
|
||||
store.set(key, String(value))
|
||||
},
|
||||
removeItem(key) {
|
||||
store.delete(key)
|
||||
}
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
events.push(event)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return { events }
|
||||
}
|
||||
|
||||
test('assistant snapshot is cleared only when it belongs to the deleted draft claim', () => {
|
||||
const { events } = installWindowStub()
|
||||
|
||||
writeAssistantSessionSnapshot('emp-1', 'expense', {
|
||||
draftClaimId: 'claim-1',
|
||||
messages: [{ role: 'assistant', text: '已保存草稿 EXP-001' }]
|
||||
})
|
||||
|
||||
assert.equal(clearAssistantSessionSnapshotForDraftClaim('emp-1', 'claim-2', 'expense'), false)
|
||||
assert.equal(readAssistantSessionSnapshot('emp-1', 'expense')?.state?.draftClaimId, 'claim-1')
|
||||
|
||||
assert.equal(clearAssistantSessionSnapshotForDraftClaim('emp-1', 'claim-1', 'expense'), true)
|
||||
assert.equal(readAssistantSessionSnapshot('emp-1', 'expense'), null)
|
||||
assert.equal(events.at(-1)?.detail?.action, 'clear')
|
||||
})
|
||||
|
||||
test('claim delete flow invalidates the matching financial assistant session', () => {
|
||||
const appShellScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const appShellRouteView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.match(appShellScript, /clearAssistantSessionSnapshotForDraftClaim/)
|
||||
assert.match(appShellScript, /async function handleRequestDeleted\(payload = \{\}\)/)
|
||||
assert.match(appShellScript, /smartEntryInvalidatedDraftClaimId\.value = deletedClaimId/)
|
||||
assert.match(appShellRouteView, /:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"/)
|
||||
assert.match(createViewScript, /invalidatedDraftClaimId/)
|
||||
assert.match(createViewScript, /function clearExpenseSessionForDeletedClaim\(claimId\)/)
|
||||
assert.match(createViewScript, /toast\('该草稿单据已删除,相关财务助手会话已清空。'\)/)
|
||||
})
|
||||
|
||||
test('saving a draft keeps the financial assistant open for continued work', () => {
|
||||
const appShellScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const handleDraftSavedBlock = appShellScript.match(
|
||||
/async function handleDraftSaved\(payload = \{\}\) \{[\s\S]*?\r?\n \}\r?\n\r?\n function openRequestDetail/
|
||||
)?.[0]
|
||||
|
||||
assert.ok(handleDraftSavedBlock)
|
||||
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*smartEntryOpen\.value = false/)
|
||||
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: 'app-requests' \}\)/)
|
||||
assert.match(handleDraftSavedBlock, /return[\s\S]*单据已保存为草稿,可继续上传票据或补充信息。/)
|
||||
|
||||
const draftSuccessIndex = handleDraftSavedBlock.indexOf('单据已保存为草稿,可继续上传票据或补充信息。')
|
||||
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', draftSuccessIndex), -1)
|
||||
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-requests' })", draftSuccessIndex), -1)
|
||||
})
|
||||
|
||||
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {
|
||||
const detailViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const appShellScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const sessionStateScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.match(detailViewScript, /restoreLatestConversation:\s*false/)
|
||||
assert.match(detailViewScript, /scope:\s*claimId[\s\S]*type:\s*'claim'[\s\S]*claimId/)
|
||||
assert.match(appShellScript, /function isDetailClaimScopedPayload\(payload = \{\}\)/)
|
||||
assert.match(appShellScript, /if \(isDetailClaimScopedPayload\(payload\)\) \{[\s\S]*return null[\s\S]*\}/)
|
||||
assert.match(sessionStateScript, /const shouldPersistLocalSnapshot = props\.entrySource !== 'detail'/)
|
||||
assert.match(sessionStateScript, /if \(!shouldPersistLocalSnapshot\) \{[\s\S]*return[\s\S]*\}/)
|
||||
assert.match(submitComposerScript, /function resolveDetailScopedClaimId\(\)/)
|
||||
assert.match(submitComposerScript, /const detailScopedUpload = Boolean\(detailScopedClaimId && files\.length\)/)
|
||||
assert.match(submitComposerScript, /draft_claim_id: detailScopedClaimId/)
|
||||
assert.match(submitComposerScript, /detail_scope_claim_id: detailScopedClaimId/)
|
||||
assert.match(submitComposerScript, /detailScopedUpload/)
|
||||
})
|
||||
@@ -1,10 +1,21 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||
buildAttachmentAssociationConfirmationMessage
|
||||
buildAttachmentAssociationConfirmationMessage,
|
||||
buildOcrFilePreviews,
|
||||
buildReviewFilePreviewsFromReviewPayload
|
||||
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
buildDraftAssociationQueryPayload,
|
||||
buildExpenseQueryHint,
|
||||
EXPENSE_CENTER_HREF,
|
||||
normalizeExpenseQueryPayload
|
||||
} from '../src/views/scripts/travelReimbursementExpenseQueryModel.js'
|
||||
import { renderMarkdown } from '../src/utils/markdown.js'
|
||||
|
||||
test('attachment association prompt prints recognized receipt details before confirmation link', () => {
|
||||
const message = buildAttachmentAssociationConfirmationMessage({
|
||||
@@ -26,9 +37,165 @@ test('attachment association prompt prints recognized receipt details before con
|
||||
})
|
||||
|
||||
assert.match(message, /已识别附件信息:/)
|
||||
assert.match(message, /> \*\*附件 1:train-ticket\.pdf\*\*/)
|
||||
assert.match(message, /附件类型:差旅票据/)
|
||||
assert.match(message, /行程:武汉-上海/)
|
||||
assert.match(message, /票价:354.00/)
|
||||
assert.match(message, /草稿单号:EXP-202605-001/)
|
||||
assert.match(message, new RegExp(`\\[确认\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)`))
|
||||
assert.match(message, new RegExp(`\\n\\n\\n如果 \\*\\*\\[确认\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)\\*\\* 该信息`))
|
||||
|
||||
const rendered = renderMarkdown(message)
|
||||
assert.match(rendered, /<blockquote class="markdown-attachment-card">/)
|
||||
const questionIndex = rendered.indexOf('请问是否确定将票据信息归集到单据')
|
||||
const attachmentCardCloseIndex = rendered.indexOf('</blockquote>')
|
||||
assert.ok(attachmentCardCloseIndex > -1 && questionIndex > attachmentCardCloseIndex)
|
||||
const attachmentCardHtml = rendered.slice(
|
||||
rendered.indexOf('<blockquote class="markdown-attachment-card">'),
|
||||
attachmentCardCloseIndex
|
||||
)
|
||||
assert.doesNotMatch(attachmentCardHtml, /请问是否确定将票据信息归集到单据/)
|
||||
assert.match(rendered, /<p class="markdown-action-paragraph">/)
|
||||
assert.match(rendered, /<strong><a href="#confirm-attachment-association" class="markdown-action-link markdown-action-link-confirm">确认<\/a><\/strong>/)
|
||||
})
|
||||
|
||||
test('multiple recognized attachments render as separated attachment cards', () => {
|
||||
const message = buildAttachmentAssociationConfirmationMessage({
|
||||
claimNo: 'EXP-202605-001',
|
||||
ocrDocuments: [
|
||||
{
|
||||
filename: '2月20 武汉-上海.pdf',
|
||||
document_type: 'train_ticket',
|
||||
document_type_label: '火车/高铁票',
|
||||
document_fields: [
|
||||
{ key: 'amount', label: '金额', value: '354元' },
|
||||
{ key: 'route', label: '行程', value: '武汉-上海' }
|
||||
]
|
||||
},
|
||||
{
|
||||
filename: '2月23 上海-武汉.pdf',
|
||||
document_type: 'train_ticket',
|
||||
document_type_label: '火车/高铁票',
|
||||
document_fields: [
|
||||
{ key: 'amount', label: '金额', value: '354元' },
|
||||
{ key: 'route', label: '行程', value: '上海-武汉' }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const rendered = renderMarkdown(message)
|
||||
|
||||
assert.equal((rendered.match(/class="markdown-attachment-card"/g) || []).length, 2)
|
||||
assert.match(rendered, /<strong>附件 1:2月20 武汉-上海\.pdf<\/strong>/)
|
||||
assert.match(rendered, /<strong>附件 2:2月23 上海-武汉\.pdf<\/strong>/)
|
||||
assert.match(rendered, /本次待归集附件:2 份/)
|
||||
})
|
||||
|
||||
test('attachment upload association uses conversation selection instead of legacy modal', () => {
|
||||
const viewSource = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const submitComposerSource = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.doesNotMatch(viewSource, /检测到你已有单据事件|uploadDecisionDialogOpen|continueExistingUpload|createNewUploadDocument/)
|
||||
assert.doesNotMatch(submitComposerSource, /uploadDecisionDialogOpen|hasExistingDocumentEvent|skipUploadDecisionPrompt/)
|
||||
assert.doesNotMatch(submitComposerSource, /查询可关联草稿失败,已继续按新单据识别/)
|
||||
assert.match(
|
||||
submitComposerSource,
|
||||
/const claims = await fetchExpenseClaims\(\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/
|
||||
)
|
||||
assert.match(submitComposerSource, /meta: \['单据查询失败'\][\s\S]*return null/)
|
||||
assert.match(
|
||||
submitComposerSource,
|
||||
/files\.length[\s\S]*!resolvedUploadDisposition[\s\S]*!options\.skipDraftAssociationPrompt[\s\S]*!reviewAction/
|
||||
)
|
||||
})
|
||||
|
||||
test('OCR preview builders keep hotel receipt image previews when preview kind is omitted', () => {
|
||||
const dataUrl = 'data:image/png;base64,abc123'
|
||||
const ocrPreviews = buildOcrFilePreviews({
|
||||
documents: [
|
||||
{
|
||||
filename: 'hotel.png',
|
||||
preview_data_url: dataUrl
|
||||
}
|
||||
]
|
||||
})
|
||||
const reviewPreviews = buildReviewFilePreviewsFromReviewPayload({
|
||||
document_cards: [
|
||||
{
|
||||
filename: 'hotel.png',
|
||||
preview_url: dataUrl
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(ocrPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
|
||||
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
|
||||
})
|
||||
|
||||
test('draft association query keeps a single candidate selectable in the conversation', () => {
|
||||
const payload = buildDraftAssociationQueryPayload([
|
||||
{
|
||||
id: 'claim-1',
|
||||
claim_no: 'EXP-202605-001',
|
||||
status: 'draft',
|
||||
expense_type: 'travel',
|
||||
reason: '上海出差',
|
||||
amount: 1280
|
||||
}
|
||||
])
|
||||
|
||||
assert.equal(payload.selectionMode, 'draft_association')
|
||||
assert.equal(payload.title, '选择关联草稿')
|
||||
assert.equal(payload.records.length, 1)
|
||||
assert.equal(payload.records[0].claimId, 'claim-1')
|
||||
})
|
||||
|
||||
test('expense query payload keeps structured risk items for claim-level risk drilldown', () => {
|
||||
const payload = normalizeExpenseQueryPayload({
|
||||
result_type: 'expense_claim_list',
|
||||
records: [
|
||||
{
|
||||
claim_id: 'claim-risk',
|
||||
claim_no: 'EXP-202605-009',
|
||||
amount: 880,
|
||||
risk_flags: [
|
||||
{
|
||||
key: 'hotel-limit',
|
||||
level: 'high',
|
||||
level_label: '高风险',
|
||||
title: '酒店超标',
|
||||
summary: '住宿金额超过城市标准',
|
||||
detail: '上海 P5 住宿标准为 600 元,本次 880 元。'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(payload.records[0].riskItems.length, 1)
|
||||
assert.equal(payload.records[0].riskItems[0].levelLabel, '高风险')
|
||||
assert.equal(payload.records[0].riskItems[0].summary, '住宿金额超过城市标准')
|
||||
})
|
||||
|
||||
test('expense query hint guides users to the reimbursement center after the top five results', () => {
|
||||
const payload = normalizeExpenseQueryPayload({
|
||||
result_type: 'expense_claim_list',
|
||||
title: '最近 5 条你的归档报销单',
|
||||
scope_label: '你的归档报销单',
|
||||
record_count: 8,
|
||||
preview_count: 5,
|
||||
preview_limit: 5,
|
||||
records: [
|
||||
{ claim_id: 'claim-1', claim_no: 'EXP-1', amount: 100 }
|
||||
]
|
||||
})
|
||||
const hint = buildExpenseQueryHint(payload)
|
||||
|
||||
assert.match(hint, /最近的 5 条记录/)
|
||||
assert.match(hint, new RegExp(`\\[\\*\\*这里\\*\\*\\]\\(${EXPENSE_CENTER_HREF}\\)`))
|
||||
})
|
||||
|
||||
34
web/tests/expense-claim-archive.test.mjs
Normal file
34
web/tests/expense-claim-archive.test.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { isArchivedExpenseClaim } from '../src/utils/expenseClaimArchive.js'
|
||||
|
||||
test('isArchivedExpenseClaim recognizes finance archive stage', () => {
|
||||
assert.equal(
|
||||
isArchivedExpenseClaim({ status: 'approved', approval_stage: '归档入账' }),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('isArchivedExpenseClaim ignores in-progress claims', () => {
|
||||
assert.equal(
|
||||
isArchivedExpenseClaim({ status: 'submitted', approval_stage: '财务审批' }),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('archive center is wired into navigation and api client', () => {
|
||||
const navigationScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useNavigation.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reimbursementsService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.match(navigationScript, /id:\s*'archive'/)
|
||||
assert.match(reimbursementsService, /\/reimbursements\/claims\/archives/)
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { resolveInitialKnowledgeFolder } from '../src/views/scripts/knowledgeFolderSelection.js'
|
||||
import {
|
||||
resolveInitialKnowledgeFolder,
|
||||
resolveKnowledgeFolderIcon
|
||||
} from '../src/views/scripts/knowledgeFolderSelection.js'
|
||||
|
||||
function testFallsBackToFirstFolderWhenCurrentFolderDoesNotExist() {
|
||||
const folders = [{ name: '财务知识库' }, { name: '制度政策' }, { name: '差旅规范' }]
|
||||
@@ -18,10 +21,22 @@ function testReturnsEmptyStringWhenFoldersAreEmpty() {
|
||||
assert.equal(resolveInitialKnowledgeFolder([], '差旅规范'), '')
|
||||
}
|
||||
|
||||
function testUsesOpenIconForActiveFolderOnly() {
|
||||
assert.equal(
|
||||
resolveKnowledgeFolderIcon({ name: '差旅规范', icon: 'mdi mdi-folder' }, '差旅规范'),
|
||||
'mdi mdi-folder-open'
|
||||
)
|
||||
assert.equal(
|
||||
resolveKnowledgeFolderIcon({ name: '制度政策', icon: 'mdi mdi-folder-open' }, '差旅规范'),
|
||||
'mdi mdi-folder'
|
||||
)
|
||||
}
|
||||
|
||||
function run() {
|
||||
testFallsBackToFirstFolderWhenCurrentFolderDoesNotExist()
|
||||
testKeepsCurrentFolderWhenItStillExists()
|
||||
testReturnsEmptyStringWhenFoldersAreEmpty()
|
||||
testUsesOpenIconForActiveFolderOnly()
|
||||
console.log('knowledge folder selection tests passed')
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ test('progress steps show approval operator time and current stay duration', ()
|
||||
const aiStep = request.progressSteps.find((step) => step.label === 'AI预审')
|
||||
const firstStep = request.progressSteps[0]
|
||||
|
||||
assert.equal(request.riskSummary, '无')
|
||||
assert.equal(firstStep.label, '创建单据')
|
||||
assert.equal(leaderStep.time, '李经理通过')
|
||||
assert.match(leaderStep.detail, /2026-05-20/)
|
||||
@@ -159,10 +160,57 @@ test('travel expense items describe departure return and lodging time below the
|
||||
assert.equal(request.expenseItems.find((item) => item.id === 'outbound-train')?.dayLabel, '出发时间')
|
||||
assert.equal(request.expenseItems.find((item) => item.id === 'return-train')?.dayLabel, '返回时间')
|
||||
assert.equal(request.expenseItems.find((item) => item.id === 'hotel')?.dayLabel, '住宿时间')
|
||||
assert.equal(request.expenseItems.find((item) => item.id === 'outbound-train')?.detail, '起始地-目的地')
|
||||
assert.equal(request.expenseItems.find((item) => item.id === 'return-train')?.detail, '起始地-目的地')
|
||||
assert.equal(request.expenseItems.find((item) => item.id === 'hotel')?.detail, '目的地酒店')
|
||||
assert.equal(request.expenseItems.at(-1)?.id, 'allowance')
|
||||
assert.equal(request.expenseItems.at(-1)?.dayLabel, '系统自动计算')
|
||||
})
|
||||
|
||||
test('ticket description helper does not show the destination city as detail text', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-ticket-detail-helper',
|
||||
claim_no: 'EXP-202605-ROUTE',
|
||||
employee_name: '张三',
|
||||
department_name: '市场部',
|
||||
expense_type: 'travel',
|
||||
reason: '上海项目出差',
|
||||
location: '上海',
|
||||
amount: 520,
|
||||
invoice_count: 2,
|
||||
occurred_at: '2026-05-13T01:00:00.000Z',
|
||||
created_at: '2026-05-13T01:30:00.000Z',
|
||||
updated_at: '2026-05-13T03:30:00.000Z',
|
||||
status: 'draft',
|
||||
approval_stage: '待提交',
|
||||
risk_flags_json: [],
|
||||
items: [
|
||||
{
|
||||
id: 'flight',
|
||||
item_type: 'flight_ticket',
|
||||
item_reason: '广州白云-上海虹桥',
|
||||
item_location: '上海',
|
||||
item_date: '2026-05-13',
|
||||
item_amount: 320,
|
||||
invoice_id: 'flight.png'
|
||||
},
|
||||
{
|
||||
id: 'ship',
|
||||
item_type: 'ship_ticket',
|
||||
item_reason: '上海港-舟山港',
|
||||
item_location: '舟山',
|
||||
item_date: '2026-05-14',
|
||||
item_amount: 200,
|
||||
invoice_id: 'ship.png'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(request.expenseItems.find((item) => item.id === 'flight')?.detail, '起始地-目的地')
|
||||
assert.equal(request.expenseItems.find((item) => item.id === 'ship')?.detail, '起始地-目的地')
|
||||
assert.equal(request.expenseItems.find((item) => item.id === 'ship')?.name, '轮船票')
|
||||
})
|
||||
|
||||
test('completed finance approval marks finance and archive progress steps', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-finance-completed',
|
||||
@@ -202,6 +250,7 @@ test('completed finance approval marks finance and archive progress steps', () =
|
||||
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
|
||||
const archiveStep = request.progressSteps.find((step) => step.label === '归档入账')
|
||||
|
||||
assert.equal(request.riskSummary, '无')
|
||||
assert.equal(request.workflowNode, '归档入账')
|
||||
assert.equal(financeStep.time, '财务复核通过')
|
||||
assert.match(financeStep.detail, /2026-05-20/)
|
||||
|
||||
40
web/tests/travel-reimbursement-composer-tools.test.mjs
Normal file
40
web/tests/travel-reimbursement-composer-tools.test.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
buildStructuredComposerSubmitText
|
||||
} from '../src/views/scripts/useTravelReimbursementComposerTools.js'
|
||||
|
||||
const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('composer formats date-picker expense text into readable structured fields', () => {
|
||||
const formatted = buildStructuredComposerSubmitText(
|
||||
'发生时间:2026-05-20 至 2026-05-23,去上海支撑上海国电的服务器部署,出差3天',
|
||||
{
|
||||
mode: 'range',
|
||||
start_date: '2026-05-20',
|
||||
end_date: '2026-05-23',
|
||||
business_time: '2026-05-20 至 2026-05-23'
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
formatted,
|
||||
[
|
||||
'发生时间:2026-05-20 至 2026-05-23',
|
||||
'地点:上海',
|
||||
'事由:支撑上海国电的服务器部署',
|
||||
'天数:3天'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
test('composer keeps backend raw text but displays structured user message', () => {
|
||||
assert.match(submitComposerScript, /const rawText = resolveComposerSubmitText\(options\.rawText\)\.trim\(\)/)
|
||||
assert.match(submitComposerScript, /resolveComposerDisplaySubmitText\(rawText\)/)
|
||||
})
|
||||
@@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { buildReviewPlainFollowupCopy } from '../src/views/scripts/travelReimbursementReviewModel.js'
|
||||
|
||||
const createViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -19,6 +21,10 @@ const reviewActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reviewDrawerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewDrawer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -29,7 +35,7 @@ const attachmentsScript = readFileSync(
|
||||
)
|
||||
|
||||
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
|
||||
assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
|
||||
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewOverviewDrawerAvailable"[\s\S]*title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
|
||||
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewDocumentDrawerAvailable"[\s\S]*title="单据识别"/)
|
||||
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewRiskDrawerAvailable"[\s\S]*title="显示风险"/)
|
||||
assert.match(createViewTemplate, /title="调用流程"/)
|
||||
@@ -91,13 +97,22 @@ test('review risk drawer lists risk briefs without score and posts details into
|
||||
createViewScript,
|
||||
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
|
||||
)
|
||||
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
|
||||
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
|
||||
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
|
||||
assert.match(createViewTemplate, /class="expense-query-risk-row"[\s\S]*appendExpenseQueryRiskToConversation\(record, risk\)/)
|
||||
assert.match(createViewScript, /function appendExpenseQueryRiskToConversation\(record, risk\) \{[\s\S]*进入 \$\{claimNo\} 详情重新填写/)
|
||||
})
|
||||
|
||||
test('review payload with risks opens risk drawer and travel overview uses travel-specific fields', () => {
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/reviewDrawerMode\.value = resolveReviewRiskBriefs\(payload\)\.length[\s\S]*\? REVIEW_DRAWER_MODE_RISK[\s\S]*: REVIEW_DRAWER_MODE_REVIEW/
|
||||
)
|
||||
test('review drawer default mode is scoped by the current action and travel overview uses travel-specific fields', () => {
|
||||
assert.match(reviewDrawerScript, /activeReviewPanelScope/)
|
||||
assert.match(reviewDrawerScript, /const reviewOverviewDrawerAvailable = computed\(\(\) => normalizedReviewPanelScope\.value === 'overview'\)/)
|
||||
assert.match(reviewDrawerScript, /scope === 'documents' && hasDocuments[\s\S]*REVIEW_DRAWER_MODE_DOCUMENTS/)
|
||||
assert.match(reviewDrawerScript, /scope === 'risk' && hasRisks[\s\S]*REVIEW_DRAWER_MODE_RISK/)
|
||||
assert.match(reviewDrawerScript, /scope === 'overview'[\s\S]*REVIEW_DRAWER_MODE_REVIEW/)
|
||||
assert.match(createViewScript, /function normalizeReviewPanelScope\(scope\)/)
|
||||
assert.match(createViewScript, /canExposeReviewPanelScope\(item\.reviewPanelScope\)/)
|
||||
assert.match(createViewScript, /currentInsight\.value\.intent === 'agent' && agent[\s\S]*return null/)
|
||||
assert.match(createViewScript, /function isTravelReviewPayload\(reviewPayload/)
|
||||
assert.match(createViewScript, /function resolveReviewTravelTransportType\(reviewPayload/)
|
||||
assert.match(createViewScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
|
||||
@@ -107,6 +122,51 @@ test('review payload with risks opens risk drawer and travel overview uses trave
|
||||
assert.match(createViewTemplate, /wide: item\.wide/)
|
||||
})
|
||||
|
||||
test('submit composer scopes the side panel to intent overview, document upload, or triggered risk only', () => {
|
||||
assert.match(submitComposerScript, /function resolveReviewPanelScope\(\{[\s\S]*reviewPayload = null/)
|
||||
assert.match(submitComposerScript, /fileCount > 0 && documentCount > 0[\s\S]*return 'documents'/)
|
||||
assert.match(submitComposerScript, /riskCount > 0 && \(asksRisk \|\| \['next_step', 'submit', 'submit_claim'\]\.includes\(normalizedAction\)\)[\s\S]*return 'risk'/)
|
||||
assert.match(submitComposerScript, /!normalizedAction && fileCount === 0[\s\S]*return 'overview'/)
|
||||
assert.match(submitComposerScript, /reviewPanelScope: resolveReviewPanelScope\(\{/)
|
||||
assert.match(submitComposerScript, /nextInsight\.agent\.reviewPanelScope = assistantMessage\.reviewPanelScope/)
|
||||
})
|
||||
|
||||
test('expense query answers keep one clear result structure with reimbursement center jump link', () => {
|
||||
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.meta\?\.length/)
|
||||
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.suggestedActions\?\.length/)
|
||||
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.citations\?\.length/)
|
||||
assert.match(createViewTemplate, /message\.queryPayload\.title \|\| \(message\.queryPayload\.selectionMode === 'draft_association' \? '选择关联草稿' : '最近 5 条筛选结果'\)/)
|
||||
assert.match(createViewTemplate, /v-html="renderMarkdown\(buildExpenseQueryHint\(message\.queryPayload\)\)"/)
|
||||
assert.match(createViewScript, /href\.startsWith\('\/app\/'\)[\s\S]*router\.push\(href\)/)
|
||||
})
|
||||
|
||||
test('backend query response suppresses generic query actions and supports archived filter title', () => {
|
||||
const responseScript = readFileSync(
|
||||
fileURLToPath(new URL('../../server/src/app/services/user_agent_response.py', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const queryScript = readFileSync(
|
||||
fileURLToPath(new URL('../../server/src/app/services/orchestrator_expense_query.py', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.match(responseScript, /if payload\.ontology\.intent in \{"query", "compare"\}:[\s\S]*return \[\]/)
|
||||
assert.match(responseScript, /下面先列出最近 \{query_payload\.preview_count\} 条记录/)
|
||||
assert.match(queryScript, /EXPENSE_QUERY_PREVIEW_LIMIT = 5/)
|
||||
assert.match(queryScript, /"归档"[\s\S]*"archived"/)
|
||||
assert.match(queryScript, /ExpenseClaim\.approval_stage\.ilike\("%归档%"\)/)
|
||||
assert.match(queryScript, /"title": f"最近 \{len\(preview_claims\)\} 条\{scope_label\}"/)
|
||||
})
|
||||
|
||||
test('closing the assistant while OCR is running defers unmount until the current flow finishes', () => {
|
||||
assert.match(createViewScript, /const closeAfterBusy = ref\(false\)/)
|
||||
assert.match(createViewScript, /function isWorkbenchBusy\(\) \{[\s\S]*submitting\.value \|\| reviewActionBusy\.value \|\| sessionSwitchBusy\.value/)
|
||||
assert.match(createViewScript, /function maybeFinalizeDeferredClose\(\) \{[\s\S]*!closeAfterBusy\.value \|\| workbenchVisible\.value \|\| isWorkbenchBusy\(\)/)
|
||||
assert.match(createViewScript, /function requestCloseWorkbench\(\) \{[\s\S]*closeAfterBusy\.value = isWorkbenchBusy\(\)[\s\S]*workbenchVisible\.value = false/)
|
||||
assert.match(createViewScript, /function emitCloseAfterLeave\(\) \{[\s\S]*closeAfterBusy\.value && isWorkbenchBusy\(\)[\s\S]*return/)
|
||||
assert.match(createViewScript, /\[submitting\.value, reviewActionBusy\.value, sessionSwitchBusy\.value, workbenchVisible\.value\][\s\S]*maybeFinalizeDeferredClose\(\)/)
|
||||
})
|
||||
|
||||
test('composer exposes travel calculator and posts spreadsheet-backed result into conversation', () => {
|
||||
assert.match(createViewTemplate, /class="tool-btn composer-side-btn travel-calculator-trigger"[\s\S]*差旅计算器/)
|
||||
assert.match(createViewTemplate, /class="travel-calculator-popover"[\s\S]*v-model="travelCalculatorForm\.days"[\s\S]*v-model="travelCalculatorForm\.location"/)
|
||||
@@ -188,3 +248,26 @@ test('review summary renders markdown and save draft relies on backend response
|
||||
/messages\.value\.push\(\s*createMessage\('assistant', actionConfig\.successMessage/
|
||||
)
|
||||
})
|
||||
|
||||
test('saved draft review messages stop showing the save-draft prompt', () => {
|
||||
const reviewPayload = {
|
||||
slot_cards: [
|
||||
{ key: 'amount', label: '金额', title: '金额', status: 'missing', required: true },
|
||||
{ key: 'attachments', label: '票据状态', title: '票据状态', status: 'missing', required: true }
|
||||
],
|
||||
missing_slots: ['金额', '票据附件'],
|
||||
risk_briefs: [],
|
||||
confirmation_actions: [
|
||||
{ label: '保存为草稿', action_type: 'save_draft' }
|
||||
]
|
||||
}
|
||||
const followup = buildReviewPlainFollowupCopy(reviewPayload, { savedDraft: true })
|
||||
|
||||
assert.equal(followup.lead, '补充信息:')
|
||||
assert.match(followup.summary, /草稿/)
|
||||
assert.match(followup.summary, /关联|补充|提交/)
|
||||
assert.doesNotMatch(followup.summary, /点击|点“草稿”|保存为草稿|临时保存|暂存/)
|
||||
assert.match(createViewTemplate, /buildReviewPlainFollowupForMessage\(message\)/)
|
||||
assert.match(createViewScript, /function isDraftSavedReviewMessage\(message\)/)
|
||||
assert.match(createViewScript, /function canUseInlineSaveDraft\(message\)[\s\S]*isDraftSavedReviewMessage\(message\)/)
|
||||
})
|
||||
|
||||
@@ -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