feat: 完善文档中心与报销申请交互及侧边栏重构
后端优化编排器报销查询和本体检测精度,增强报销单草稿保 存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导 航,完善文档中心状态筛选和详情提示,报销创建和审批详情 页优化会话管理和费用明细交互,新增助手应用服务和预设动 作工具函数,补充单元测试覆盖。
This commit is contained in:
@@ -98,3 +98,29 @@ test('detail topbar still flags real manual rows without required ticket info',
|
||||
assert.equal(hasPendingInfo(request), true)
|
||||
assert.deepEqual(alerts, ['待提交', '缺少票据', '待补信息'])
|
||||
})
|
||||
|
||||
test('application detail topbar does not ask for receipt attachments', () => {
|
||||
const request = {
|
||||
id: 'APP-20260525-ABC123',
|
||||
claimNo: 'APP-20260525-ABC123',
|
||||
documentTypeCode: 'application',
|
||||
node: '直属领导审批',
|
||||
approvalKey: 'in_progress',
|
||||
typeCode: 'travel_application',
|
||||
typeLabel: '差旅费用申请',
|
||||
reason: '支撑国网服务器上线部署',
|
||||
location: '上海',
|
||||
city: '上海',
|
||||
occurredDisplay: '2026-05-25 ~ 2026-05-28',
|
||||
amountValue: 12000,
|
||||
attachmentSummary: '申请单',
|
||||
secondaryStatusValue: '已进入审批流程',
|
||||
expenseItems: []
|
||||
}
|
||||
|
||||
const alerts = buildDetailAlerts(request).map((item) => item.label)
|
||||
|
||||
assert.equal(hasMissingAttachment(request), false)
|
||||
assert.equal(alerts.includes('缺少票据'), false)
|
||||
assert.deepEqual(alerts, ['直属领导审批'])
|
||||
})
|
||||
|
||||
71
web/tests/app-shell-financial-assistant-entry.test.mjs
Normal file
71
web/tests/app-shell-financial-assistant-entry.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
buildWelcomeInsight,
|
||||
buildWelcomeMessage
|
||||
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||
|
||||
const appShellRouteView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const appShellComposable = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const assistantScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const assistantTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('application and reimbursement entries open the same financial assistant modal', () => {
|
||||
assert.match(appShellRouteView, /<TravelReimbursementCreateView[\s\S]*:entry-source="smartEntryContext\.source"/)
|
||||
assert.match(appShellRouteView, /@create-request="openTravelCreate"/)
|
||||
assert.match(appShellRouteView, /@create-application="openExpenseApplicationCreate"/)
|
||||
assert.match(appShellRouteView, /@new-application="openExpenseApplicationCreate"/)
|
||||
assert.doesNotMatch(appShellRouteView, /ExpenseApplicationDialog/)
|
||||
})
|
||||
|
||||
test('application entry keeps its own assistant source without creating a separate dialog', () => {
|
||||
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
|
||||
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)
|
||||
assert.match(appShellComposable, /function openTravelCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_REIMBURSEMENT\)/)
|
||||
assert.match(appShellComposable, /openExpenseApplicationCreate,/)
|
||||
assert.match(assistantScript, /activeSessionType\.value === SESSION_TYPE_APPLICATION[\s\S]*我想先申请一笔差旅费用/)
|
||||
})
|
||||
|
||||
test('financial assistant toolbar renders four isolated assistant sessions', () => {
|
||||
assert.match(assistantScript, /ASSISTANT_SESSION_MODE_OPTIONS\.map/)
|
||||
assert.match(assistantScript, /targetSessionType:\s*mode\.key/)
|
||||
assert.match(assistantScript, /active:\s*mode\.key === activeSessionType\.value/)
|
||||
assert.match(assistantTemplate, /:class="\{ active: shortcut\.active \}"/)
|
||||
assert.match(assistantTemplate, /:aria-pressed="shortcut\.active \? 'true' : 'false'"/)
|
||||
assert.match(assistantTemplate, /:disabled="shortcut\.active \|\| submitting/)
|
||||
})
|
||||
|
||||
test('financial assistant welcome copy differentiates application intent from reimbursement entry', () => {
|
||||
const user = { name: '李文静', username: 'wenjing.li', grade: 'P5' }
|
||||
const applicationWelcome = buildWelcomeMessage('application', null, SESSION_TYPE_APPLICATION, user)
|
||||
const reimbursementWelcome = buildWelcomeMessage('topbar', null, SESSION_TYPE_EXPENSE, user)
|
||||
const knowledgeWelcome = buildWelcomeMessage('topbar', null, SESSION_TYPE_KNOWLEDGE, user)
|
||||
const applicationInsight = buildWelcomeInsight('application', null, SESSION_TYPE_APPLICATION, user)
|
||||
|
||||
assert.match(applicationWelcome, /申请助手/)
|
||||
assert.match(applicationWelcome, /费用申请、报销申请还是其他财务事项/)
|
||||
assert.match(reimbursementWelcome, /报销助手/)
|
||||
assert.match(reimbursementWelcome, /报销发起、票据识别、草稿归集、报销信息核对/)
|
||||
assert.match(knowledgeWelcome, /财务知识助手/)
|
||||
assert.notEqual(applicationWelcome, reimbursementWelcome)
|
||||
assert.equal(applicationInsight.metricValue, '申请助手')
|
||||
assert.equal(applicationInsight.title, '申请助手')
|
||||
})
|
||||
@@ -90,12 +90,12 @@ test('saving a draft keeps the financial assistant open for continued work', ()
|
||||
|
||||
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, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: activeView\.value === 'documents' \? 'app-documents' : '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)
|
||||
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })", draftSuccessIndex), -1)
|
||||
})
|
||||
|
||||
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {
|
||||
|
||||
43
web/tests/assistant-suggested-action-prefill.test.mjs
Normal file
43
web/tests/assistant-suggested-action-prefill.test.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
mergeComposerPrefill,
|
||||
resolveSuggestedActionPrefill
|
||||
} from '../src/utils/assistantSuggestedActionPrefill.js'
|
||||
|
||||
test('suggested action prefill uses backend prompt payload', () => {
|
||||
assert.equal(
|
||||
resolveSuggestedActionPrefill({
|
||||
action_type: 'prefill_composer',
|
||||
payload: {
|
||||
application_field: 'time',
|
||||
prompt_prefill: '申请时间段:'
|
||||
}
|
||||
}),
|
||||
'申请时间段:'
|
||||
)
|
||||
})
|
||||
|
||||
test('suggested action prefill falls back to application field templates', () => {
|
||||
assert.equal(
|
||||
resolveSuggestedActionPrefill({
|
||||
action_type: 'prefill_composer',
|
||||
payload: { application_field: 'amount' }
|
||||
}),
|
||||
'预计总费用:'
|
||||
)
|
||||
assert.equal(
|
||||
resolveSuggestedActionPrefill({
|
||||
action_type: 'ask_clarification',
|
||||
payload: { application_field: 'amount' }
|
||||
}),
|
||||
''
|
||||
)
|
||||
})
|
||||
|
||||
test('composer prefill appends to existing draft without duplication', () => {
|
||||
assert.equal(mergeComposerPrefill('', '事由:'), '事由:')
|
||||
assert.equal(mergeComposerPrefill('地点:上海', '事由:'), '地点:上海\n事由:')
|
||||
assert.equal(mergeComposerPrefill('地点:上海\n事由:', '事由:'), '地点:上海\n事由:')
|
||||
})
|
||||
60
web/tests/document-center-new-state.test.mjs
Normal file
60
web/tests/document-center-new-state.test.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
countNewDocuments,
|
||||
isNewDocument,
|
||||
markDocumentViewed,
|
||||
readDocumentScope,
|
||||
readViewedDocumentKeys,
|
||||
resolveDocumentNewKey,
|
||||
writeDocumentScope
|
||||
} from '../src/utils/documentCenterNewState.js'
|
||||
|
||||
function createMemoryStorage(initial = {}) {
|
||||
const store = new Map(Object.entries(initial))
|
||||
return {
|
||||
getItem(key) {
|
||||
return store.has(key) ? store.get(key) : null
|
||||
},
|
||||
setItem(key, value) {
|
||||
store.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('document center new state resolves source scoped document keys', () => {
|
||||
assert.equal(resolveDocumentNewKey({ source: 'archive', claimId: 'claim-1' }), 'archive:claim-1')
|
||||
assert.equal(resolveDocumentNewKey({ source: 'approval', documentNo: 'EXP-1' }), 'approval:EXP-1')
|
||||
})
|
||||
|
||||
test('document center new state counts unseen documents and persists viewed rows', () => {
|
||||
const storage = createMemoryStorage()
|
||||
const rows = [
|
||||
{ source: 'archive', claimId: 'claim-1' },
|
||||
{ source: 'archive', claimId: 'claim-2' }
|
||||
]
|
||||
let viewedKeys = readViewedDocumentKeys(storage)
|
||||
|
||||
assert.equal(countNewDocuments(rows, viewedKeys), 2)
|
||||
assert.equal(isNewDocument(rows[0], viewedKeys), true)
|
||||
|
||||
viewedKeys = markDocumentViewed(rows[0], viewedKeys, storage)
|
||||
|
||||
assert.equal(countNewDocuments(rows, viewedKeys), 1)
|
||||
assert.equal(isNewDocument(rows[0], viewedKeys), false)
|
||||
assert.deepEqual([...readViewedDocumentKeys(storage)], ['archive:claim-1'])
|
||||
})
|
||||
|
||||
test('document center scope state restores only allowed tabs', () => {
|
||||
const storage = createMemoryStorage()
|
||||
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']
|
||||
|
||||
assert.equal(readDocumentScope('全部', scopes, storage), '全部')
|
||||
|
||||
writeDocumentScope('归档', scopes, storage)
|
||||
assert.equal(readDocumentScope('全部', scopes, storage), '归档')
|
||||
|
||||
writeDocumentScope('不存在', scopes, storage)
|
||||
assert.equal(readDocumentScope('全部', scopes, storage), '归档')
|
||||
})
|
||||
@@ -25,24 +25,28 @@ test('documents center keeps only the top scope tabs and renders status as a dro
|
||||
assert.match(documentsCenterView, /@click="selectStatusTab\(option\.value\)"/)
|
||||
})
|
||||
|
||||
test('documents center top tabs start from application and show document category labels', () => {
|
||||
assert.doesNotMatch(documentsCenterView, /const DOCUMENT_SCOPE_ALL = '全部'/)
|
||||
test('documents center top tabs start from all and show document category labels', () => {
|
||||
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ALL = '全部'/)
|
||||
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_APPLICATION = '申请单'/)
|
||||
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'/)
|
||||
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REVIEW = '审核单'/)
|
||||
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ARCHIVE = '归档'/)
|
||||
assert.match(documentsCenterView, /const activeScopeTab = ref\(DOCUMENT_SCOPE_APPLICATION\)/)
|
||||
assert.match(documentsCenterView, /const activeScopeTab = ref\(readDocumentScope\(DOCUMENT_SCOPE_ALL, scopeTabs\)\)/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
|
||||
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_ALL[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
|
||||
)
|
||||
assert.doesNotMatch(documentsCenterView, /DOCUMENT_SCOPE_ALL/)
|
||||
})
|
||||
|
||||
test('documents center category tabs map to the intended row sources', () => {
|
||||
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION/
|
||||
/activeScopeTab\.value === DOCUMENT_SCOPE_ALL[\s\S]*return nonArchivedRows\.value/
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*nonArchivedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION/
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
@@ -56,7 +60,22 @@ test('documents center category tabs map to the intended row sources', () => {
|
||||
documentsCenterView,
|
||||
/activeScopeTab\.value === DOCUMENT_SCOPE_ARCHIVE[\s\S]*return archiveRows\.value/
|
||||
)
|
||||
assert.match(documentsCenterView, /return allSummaryRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION\)/)
|
||||
assert.match(documentsCenterView, /return nonArchivedRows\.value/)
|
||||
})
|
||||
|
||||
test('documents center preserves application document type from mapped requests', () => {
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/const documentTypeCode = normalized\.documentTypeCode \|\| DOCUMENT_TYPE_REIMBURSEMENT/
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/documentTypeCode === DOCUMENT_TYPE_APPLICATION \? '申请单' : '报销单'/
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
documentsCenterView,
|
||||
/documentTypeCode:\s*DOCUMENT_TYPE_REIMBURSEMENT,[\s\S]*documentTypeLabel:\s*'报销单'/
|
||||
)
|
||||
})
|
||||
|
||||
test('documents center list shows created time and conditional stay time columns', () => {
|
||||
@@ -91,25 +110,70 @@ test('documents center action buttons are scoped to application and reimbursemen
|
||||
})
|
||||
|
||||
test('documents center category tabs render bubble counts for new documents', () => {
|
||||
assert.match(documentsCenterView, /readViewedDocumentKeys/)
|
||||
assert.match(documentsCenterView, /const viewedDocumentKeys = ref\(readViewedDocumentKeys\(\)\)/)
|
||||
assert.match(documentsCenterView, /v-for="tab in scopeTabItems"/)
|
||||
assert.match(documentsCenterView, /<span v-if="tab\.badgeCount > 0" class="scope-tab-badge"/)
|
||||
assert.match(documentsCenterView, /tab\.badgeCount > 99 \? '99\+' : tab\.badgeCount/)
|
||||
assert.match(documentsCenterView, /const scopeNewCountMap = computed\(\(\) => \(\{/)
|
||||
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ALL\]: countNewDocuments\(nonArchivedRows\.value, viewedDocumentKeys\.value\)/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\)\.length/
|
||||
/\[DOCUMENT_SCOPE_APPLICATION\]: countNewDocuments\(nonArchivedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION\), viewedDocumentKeys\.value\)/
|
||||
)
|
||||
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: approvalRows\.value\.length/)
|
||||
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: archiveRows\.value\.length/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: countNewDocuments\(ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\), viewedDocumentKeys\.value\)/
|
||||
)
|
||||
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: countNewDocuments\(approvalRows\.value, viewedDocumentKeys\.value\)/)
|
||||
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: countNewDocuments\(archiveRows\.value, viewedDocumentKeys\.value\)/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/const scopeTabItems = computed\(\(\) =>[\s\S]*badgeCount: scopeNewCountMap\.value\[tab\] \|\| 0/
|
||||
)
|
||||
})
|
||||
|
||||
test('documents center rows show NEW marker until the row is opened', () => {
|
||||
assert.match(documentsCenterView, /<span v-if="row\.isNewDocument" class="new-document-badge">NEW<\/span>/)
|
||||
assert.match(documentsCenterView, /isNewDocument: isNewDocument\(/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/
|
||||
)
|
||||
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*background:\s*#fff5f5;/)
|
||||
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*border:\s*1px solid #fecaca;/)
|
||||
assert.match(documentsCenterStyles, /\.new-document-badge::before\s*\{[\s\S]*background:\s*#ef4444;/)
|
||||
assert.doesNotMatch(documentsCenterStyles, /newDocumentPulse/)
|
||||
})
|
||||
|
||||
test('documents center empty states stay emerald across all scope tabs', () => {
|
||||
const emptyStateBlock = documentsCenterView.match(/const emptyState = computed\(\(\) => \{[\s\S]*?\n\}\)/)?.[0] || ''
|
||||
|
||||
assert.match(emptyStateBlock, /eyebrow: '申请单'[\s\S]*tone: 'emerald'/)
|
||||
assert.match(emptyStateBlock, /title: filtered \? '没有符合当前条件的单据'[\s\S]*tone: 'emerald'/)
|
||||
assert.doesNotMatch(emptyStateBlock, /tone:\s*'sky'/)
|
||||
assert.doesNotMatch(emptyStateBlock, /tone:\s*'slate'/)
|
||||
assert.doesNotMatch(emptyStateBlock, /tone:\s*'amber'/)
|
||||
})
|
||||
|
||||
test('documents center empty states do not render small action buttons', () => {
|
||||
const emptyStateBlock = documentsCenterView.match(/const emptyState = computed\(\(\) => \{[\s\S]*?\n\}\)/)?.[0] || ''
|
||||
|
||||
assert.match(emptyStateBlock, /actionLabel:\s*''/)
|
||||
assert.match(emptyStateBlock, /actionIcon:\s*''/)
|
||||
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*filtered/)
|
||||
assert.doesNotMatch(emptyStateBlock, /actionIcon:\s*filtered/)
|
||||
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*'发起申请'/)
|
||||
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*'发起报销'/)
|
||||
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*'清空筛选'/)
|
||||
})
|
||||
|
||||
test('documents center switches filter conditions by category tab', () => {
|
||||
assert.match(documentsCenterView, /const FILTER_CONFIG_BY_SCOPE = \{/)
|
||||
assert.doesNotMatch(documentsCenterView, /\[DOCUMENT_SCOPE_ALL\]: \{/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/\[DOCUMENT_SCOPE_ALL\]: \{[\s\S]*sceneFallbackLabel: '单据场景'[\s\S]*statusTitle: '单据状态'[\s\S]*showDocumentType: true/
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/\[DOCUMENT_SCOPE_APPLICATION\]: \{[\s\S]*sceneFallbackLabel: '申请场景'[\s\S]*statusTitle: '申请状态'[\s\S]*showDocumentType: false/
|
||||
|
||||
69
web/tests/expense-application-ontology.test.mjs
Normal file
69
web/tests/expense-application-ontology.test.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildApplicationFieldsFromOntology,
|
||||
expandApplicationTimeWithDays,
|
||||
resolveApplicationReason,
|
||||
resolveApplicationTimeRange,
|
||||
resolvePromptField
|
||||
} from '../src/utils/expenseApplicationOntology.js'
|
||||
|
||||
const structuredApplicationPrompt = [
|
||||
'发生时间:2026-05-25',
|
||||
'地点:上海',
|
||||
'事由:支撑国网服务器部署',
|
||||
'天数:3天'
|
||||
].join('\n')
|
||||
|
||||
test('expense application fields use labeled reason and filter resolved missing slots', () => {
|
||||
const fields = buildApplicationFieldsFromOntology(
|
||||
{
|
||||
scenario: 'expense',
|
||||
intent: 'draft',
|
||||
entities: [],
|
||||
time_range: {},
|
||||
missing_slots: ['time_range', 'location', 'reason', 'amount']
|
||||
},
|
||||
structuredApplicationPrompt,
|
||||
{ name: '申请员工', departmentName: '交付部' }
|
||||
)
|
||||
|
||||
assert.equal(fields.timeRange, '2026-05-25 至 2026-05-28')
|
||||
assert.equal(fields.location, '上海')
|
||||
assert.equal(fields.reason, '支撑国网服务器部署')
|
||||
assert.deepEqual(
|
||||
fields.missingSlots.map((item) => item.key),
|
||||
['amount']
|
||||
)
|
||||
})
|
||||
|
||||
test('expense application prompt field parser supports multiline labels', () => {
|
||||
assert.equal(resolvePromptField(structuredApplicationPrompt, ['事由']), '支撑国网服务器部署')
|
||||
assert.equal(resolveApplicationReason(structuredApplicationPrompt), '支撑国网服务器部署')
|
||||
})
|
||||
|
||||
test('expense application expands a single selected date with natural days', () => {
|
||||
const prompt = [
|
||||
'发生时间:2026-05-25',
|
||||
'去上海出差3天,支撑国网服务器部署'
|
||||
].join('\n')
|
||||
|
||||
assert.equal(expandApplicationTimeWithDays('2026-05-25', 3), '2026-05-25 至 2026-05-28')
|
||||
assert.equal(resolveApplicationTimeRange({ time_range: {} }, prompt), '2026-05-25 至 2026-05-28')
|
||||
})
|
||||
|
||||
test('expense application keeps explicit time range before applying days', () => {
|
||||
assert.equal(
|
||||
resolveApplicationTimeRange(
|
||||
{
|
||||
time_range: {
|
||||
start_date: '2026-05-25',
|
||||
end_date: '2026-05-27'
|
||||
}
|
||||
},
|
||||
'去上海出差3天,支撑国网服务器部署'
|
||||
),
|
||||
'2026-05-25 至 2026-05-27'
|
||||
)
|
||||
})
|
||||
46
web/tests/expense-application-submit-rich-confirm.test.mjs
Normal file
46
web/tests/expense-application-submit-rich-confirm.test.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { renderMarkdown } from '../src/utils/markdown.js'
|
||||
|
||||
const createViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('expense application submit uses rich text link and confirm dialog', () => {
|
||||
const copy = '请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。'
|
||||
const rendered = renderMarkdown(copy)
|
||||
|
||||
assert.match(
|
||||
rendered,
|
||||
/<a href="#application-submit" class="markdown-action-link markdown-action-link-confirm">确认<\/a>/
|
||||
)
|
||||
assert.match(createViewTemplate, /:open="applicationSubmitConfirmDialog\.open"/)
|
||||
assert.match(createViewTemplate, /title="确认提交当前费用申请?"/)
|
||||
assert.match(createViewTemplate, /description="提交后申请将进入领导审核流程,并同步纳入预算管理口径/)
|
||||
assert.match(createViewTemplate, /@confirm="confirmApplicationSubmit"/)
|
||||
assert.match(createViewScript, /const APPLICATION_SUBMIT_HREF = '#application-submit'/)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/href === APPLICATION_SUBMIT_HREF[\s\S]*openApplicationSubmitConfirm\(message\)/
|
||||
)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/async function confirmApplicationSubmit\(\)[\s\S]*rawText: '确认提交'[\s\S]*systemGenerated: true/
|
||||
)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = await submitComposer/
|
||||
)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
|
||||
)
|
||||
})
|
||||
@@ -11,7 +11,8 @@ const workbench = readFileSync(
|
||||
test('workbench assistant greets the current employee without the old helper tag', () => {
|
||||
assert.doesNotMatch(workbench, /assistant-tag/)
|
||||
assert.doesNotMatch(workbench, /AI 报销助手/)
|
||||
assert.match(workbench, /嗨,\{\{ assistantGreetingName \}\},描述费用或上传票据,AI 直接帮你判断怎么报/)
|
||||
assert.match(workbench, /嗨,\{\{ assistantGreetingName \}\},描述您想做的事,AI 会直接帮您处理/)
|
||||
assert.match(workbench, /我会自动识别您的意图,协助完成费用申请、报销、查询和制度问答等业务工作/)
|
||||
assert.match(workbench, /const assistantGreetingName = computed/)
|
||||
assert.match(workbench, /user\.name/)
|
||||
})
|
||||
|
||||
@@ -79,6 +79,15 @@ test('business activity without expense intent asks for reimbursement confirmati
|
||||
assert.equal(shouldRequestExpenseSceneSelection(businessMessage), false)
|
||||
})
|
||||
|
||||
test('non-reimbursement assistant sessions do not trigger reimbursement scene selection', () => {
|
||||
const ambiguousMessage = '业务发生时间:2026-02-20 至 2026-02-23,去上海支持上海电力部署项目,申请报销'
|
||||
|
||||
assert.equal(shouldRequestExpenseSceneSelection(ambiguousMessage, { sessionType: 'application' }), false)
|
||||
assert.equal(shouldRequestExpenseIntentConfirmation('去上海电力支撑项目部署', { sessionType: 'approval' }), false)
|
||||
assert.match(buildLocalIntentPreview(ambiguousMessage, 'application'), /费用申请事项/)
|
||||
assert.match(buildLocalIntentPreview('查一下待我审核的单据', 'approval'), /审核处理事项/)
|
||||
})
|
||||
|
||||
test('explicit technical operation does not ask for reimbursement confirmation', () => {
|
||||
const operationMessage = '去上海电力支撑项目部署,帮我整理服务器部署步骤'
|
||||
|
||||
|
||||
@@ -3,6 +3,83 @@ import test from 'node:test'
|
||||
|
||||
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
|
||||
|
||||
test('application claims are mapped as application documents', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-1',
|
||||
claim_no: 'APP-20260525-ABC123',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
expense_type: 'travel_application',
|
||||
reason: '支撑国网服务器上线部署',
|
||||
location: '上海',
|
||||
amount: 12000,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||
created_at: '2026-05-25T01:30:00.000Z',
|
||||
updated_at: '2026-05-25T02:00:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: '直属领导审批',
|
||||
risk_flags_json: [],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.equal(request.documentTypeCode, 'application')
|
||||
assert.equal(request.documentTypeLabel, '申请单')
|
||||
assert.equal(request.typeLabel, '差旅费用申请')
|
||||
assert.equal(request.secondaryStatusLabel, '申请材料')
|
||||
assert.equal(request.secondaryStatusValue, '已进入审批流程')
|
||||
assert.equal(request.expenseTableSummary, '预计金额已纳入预算管理口径')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
['创建申请', '直属领导审批', '审批完成']
|
||||
)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.current, true)
|
||||
})
|
||||
|
||||
test('approved application claims complete after direct manager approval only', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-approved',
|
||||
claim_no: 'APP-20260525-DONE01',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
manager_name: '李经理',
|
||||
expense_type: 'travel_application',
|
||||
reason: '支撑国网服务器上线部署',
|
||||
location: '上海',
|
||||
amount: 12000,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||
created_at: '2026-05-25T01:30:00.000Z',
|
||||
updated_at: '2026-05-25T03:00:00.000Z',
|
||||
status: 'approved',
|
||||
approval_stage: '审批完成',
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
event_type: 'expense_application_approval',
|
||||
operator: '李经理',
|
||||
previous_approval_stage: '直属领导审批',
|
||||
next_approval_stage: '审批完成',
|
||||
created_at: '2026-05-25T03:00:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.equal(request.documentTypeCode, 'application')
|
||||
assert.equal(request.workflowNode, '审批完成')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
['创建申请', '直属领导审批', '审批完成']
|
||||
)
|
||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过')
|
||||
})
|
||||
|
||||
test('progress steps show approval operator time and current stay duration', () => {
|
||||
const originalNow = Date.now
|
||||
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()
|
||||
|
||||
41
web/tests/sidebar-collapse.test.mjs
Normal file
41
web/tests/sidebar-collapse.test.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const appShell = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const sidebar = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const appStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/app.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('sidebar supports smooth animated collapsed layout', () => {
|
||||
assert.match(appShell, /sidebarCollapsed = ref\(true\)/)
|
||||
assert.match(appShell, /:class="\{ 'sidebar-collapsed': sidebarCollapsed \}"/)
|
||||
assert.match(appShell, /:collapsed="sidebarCollapsed"/)
|
||||
assert.match(appShell, /class="app-sidebar"/)
|
||||
assert.match(appShell, /@toggle-collapse="toggleSidebarCollapsed"/)
|
||||
assert.match(appShell, /function toggleSidebarCollapsed\(\)/)
|
||||
assert.match(appShell, /sidebarCollapsed\.value = !sidebarCollapsed\.value/)
|
||||
|
||||
assert.match(sidebar, /collapsed:\s*\{\s*type: Boolean/)
|
||||
assert.match(sidebar, /'toggle-collapse'/)
|
||||
assert.match(sidebar, /rail-collapsed/)
|
||||
assert.match(sidebar, /折叠侧边栏/)
|
||||
assert.match(sidebar, /展开侧边栏/)
|
||||
assert.match(sidebar, /--rail-motion-duration: 320ms/)
|
||||
assert.match(sidebar, /opacity var\(--rail-fade-duration\)/)
|
||||
|
||||
assert.match(appStyles, /--sidebar-collapsed-width: 64px/)
|
||||
assert.match(appStyles, /\.app-sidebar\s*\{[^}]*transition:\s*width var\(--sidebar-motion\)/)
|
||||
assert.match(appStyles, /\.app\.sidebar-collapsed\s+\.app-sidebar\s*\{\s*width:\s*var\(--sidebar-collapsed-width\)/)
|
||||
})
|
||||
@@ -34,6 +34,28 @@ test('composer formats date-picker expense text into readable structured fields'
|
||||
)
|
||||
})
|
||||
|
||||
test('composer extracts destination and reason from compact travel text', () => {
|
||||
const formatted = buildStructuredComposerSubmitText(
|
||||
'出差上海,支撑国网服务器上线部署',
|
||||
{
|
||||
mode: 'single',
|
||||
start_date: '2026-05-25',
|
||||
end_date: '2026-05-25',
|
||||
business_time: '2026-05-25'
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
formatted,
|
||||
[
|
||||
'发生时间:2026-05-25',
|
||||
'地点:上海',
|
||||
'事由:支撑国网服务器上线部署',
|
||||
'天数:1天'
|
||||
].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\)/)
|
||||
|
||||
@@ -4,7 +4,12 @@ import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
APPLICATION_WELCOME_QUICK_ACTIONS,
|
||||
APPROVAL_WELCOME_QUICK_ACTIONS,
|
||||
ASSISTANT_SESSION_MODE_OPTIONS,
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS,
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_APPROVAL,
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
buildWelcomeQuickActions
|
||||
@@ -40,6 +45,10 @@ import {
|
||||
selectGuidedQueryMode,
|
||||
shouldConfirmGuidedInterruption
|
||||
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
|
||||
import {
|
||||
ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||
resolveAssistantScopeGuard
|
||||
} from '../src/utils/assistantSessionScope.js'
|
||||
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
@@ -53,8 +62,16 @@ 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'
|
||||
)
|
||||
|
||||
test('welcome quick actions are reduced to three guided local actions', () => {
|
||||
test('assistant session modes expose independent quick actions', () => {
|
||||
assert.deepEqual(
|
||||
ASSISTANT_SESSION_MODE_OPTIONS.map((item) => item.label),
|
||||
['申请助手', '报销助手', '审核助手', '财务知识助手']
|
||||
)
|
||||
assert.deepEqual(
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||||
['快速发起报销', '查询单据状态', '差旅计算器']
|
||||
@@ -69,6 +86,14 @@ test('welcome quick actions are reduced to three guided local actions', () => {
|
||||
)
|
||||
assert.ok(EXPENSE_WELCOME_QUICK_ACTIONS.every((item) => !item.prompt))
|
||||
assert.equal(buildWelcomeQuickActions(SESSION_TYPE_EXPENSE).length, 3)
|
||||
assert.deepEqual(
|
||||
buildWelcomeQuickActions(SESSION_TYPE_APPLICATION).map((item) => item.label),
|
||||
APPLICATION_WELCOME_QUICK_ACTIONS.map((item) => item.label)
|
||||
)
|
||||
assert.deepEqual(
|
||||
buildWelcomeQuickActions(SESSION_TYPE_APPROVAL).map((item) => item.label),
|
||||
APPROVAL_WELCOME_QUICK_ACTIONS.map((item) => item.label)
|
||||
)
|
||||
assert.notDeepEqual(
|
||||
buildWelcomeQuickActions(SESSION_TYPE_KNOWLEDGE).map((item) => item.label),
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||||
@@ -76,6 +101,34 @@ test('welcome quick actions are reduced to three guided local actions', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('assistant session scope guard keeps business boundaries isolated', () => {
|
||||
const expenseInApplication = resolveAssistantScopeGuard('我想报销的士票', SESSION_TYPE_APPLICATION)
|
||||
assert.equal(expenseInApplication.targetSessionType, SESSION_TYPE_EXPENSE)
|
||||
assert.match(expenseInApplication.text, /申请助手/)
|
||||
assert.match(expenseInApplication.text, /报销助手/)
|
||||
assert.equal(expenseInApplication.suggestedActions[0].action_type, ASSISTANT_SCOPE_ACTION_SWITCH)
|
||||
assert.equal(expenseInApplication.suggestedActions[0].payload.session_type, SESSION_TYPE_EXPENSE)
|
||||
assert.equal(expenseInApplication.suggestedActions[0].payload.carry_text, '我想报销的士票')
|
||||
|
||||
assert.equal(resolveAssistantScopeGuard('我想发起一笔费用申请', SESSION_TYPE_APPLICATION), null)
|
||||
assert.equal(
|
||||
resolveAssistantScopeGuard('帮我查询待我审核的单据', SESSION_TYPE_EXPENSE).targetSessionType,
|
||||
SESSION_TYPE_APPROVAL
|
||||
)
|
||||
assert.equal(
|
||||
resolveAssistantScopeGuard('差旅住宿标准是多少', SESSION_TYPE_EXPENSE).targetSessionType,
|
||||
SESSION_TYPE_KNOWLEDGE
|
||||
)
|
||||
assert.equal(
|
||||
resolveAssistantScopeGuard('报销标准是多少', SESSION_TYPE_EXPENSE).targetSessionType,
|
||||
SESSION_TYPE_KNOWLEDGE
|
||||
)
|
||||
assert.equal(
|
||||
resolveAssistantScopeGuard('解释这张单据酒店超标风险', SESSION_TYPE_EXPENSE, { hasActiveReviewPayload: true }),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('guided reimbursement asks type first and walks travel fields in order', () => {
|
||||
const typeActions = buildGuidedExpenseTypeActions()
|
||||
assert.deepEqual(
|
||||
@@ -177,6 +230,9 @@ test('guided flow state is serializable and restored through session state', ()
|
||||
assert.match(sessionStateScript, /guidedFlowState,\s*\n\s*insightPanelCollapsed/)
|
||||
assert.match(sessionStateScript, /function refreshWelcomeQuickActions/)
|
||||
assert.match(sessionStateScript, /buildWelcomeQuickActions\(/)
|
||||
assert.match(sessionStateScript, /ASSISTANT_SESSION_TYPES\.reduce/)
|
||||
assert.match(sessionStateScript, /props\.entrySource === 'application' \? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE/)
|
||||
assert.match(sessionStateScript, /const canRestorePersistedInitialState =[\s\S]*shouldPersistLocalSnapshot/)
|
||||
})
|
||||
|
||||
test('guided flow is local until final confirmation or collected query handoff', () => {
|
||||
@@ -184,6 +240,10 @@ test('guided flow is local until final confirmation or collected query handoff',
|
||||
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
|
||||
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
|
||||
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
||||
assert.match(createViewScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
||||
assert.match(createViewScript, /actionPayload\.carry_text/)
|
||||
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
|
||||
assert.match(submitComposerScript, /skipScopeGuard/)
|
||||
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
||||
assert.match(guidedFlowScript, /submitExistingComposer\(\{[\s\S]*pendingText:\s*'正在查询单据状态\.\.\.'/)
|
||||
})
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
} from '../src/views/scripts/travelRequestDetailInsights.js'
|
||||
import {
|
||||
buildExpenseItemViewModel,
|
||||
buildDraftBlockingIssues
|
||||
buildDraftBlockingIssues,
|
||||
buildOptionalTravelReceiptRiskCards,
|
||||
isApplicationDocumentRequest
|
||||
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
|
||||
|
||||
const detailViewTemplate = readFileSync(
|
||||
@@ -412,7 +414,7 @@ test('expense attachment actions keep preview as the only recognition entry poin
|
||||
})
|
||||
|
||||
test('expense detail table shows the amount total below detail rows', () => {
|
||||
assert.match(detailViewTemplate, /<div class="detail-expense-table">/)
|
||||
assert.match(detailViewTemplate, /<div[^>]*class="detail-expense-table"/)
|
||||
assert.match(detailViewTemplate, /当前还没有费用明细/)
|
||||
assert.doesNotMatch(detailViewTemplate, /class="total-row"/)
|
||||
assert.match(detailViewTemplate, /class="expense-total-under-table"[\s\S]*金额合计[\s\S]*\{\{ expenseTotal \}\}/)
|
||||
@@ -421,7 +423,10 @@ test('expense detail table shows the amount total below detail rows', () => {
|
||||
})
|
||||
|
||||
test('additional note is shown above expense details as travel purpose text', () => {
|
||||
assert.ok(detailViewTemplate.indexOf('<h3>附加说明</h3>') < detailViewTemplate.indexOf('<h3>费用明细</h3>'))
|
||||
assert.ok(
|
||||
detailViewTemplate.indexOf('<h3>附加说明</h3>')
|
||||
< detailViewTemplate.indexOf("isApplicationDocument ? '申请预算' : '费用明细'")
|
||||
)
|
||||
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
|
||||
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
|
||||
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
|
||||
@@ -514,6 +519,7 @@ test('expense detail edit keeps delete but removes cancel and allows draft place
|
||||
|
||||
test('travel detail AI advice adds low risk reminders for optional receipts', () => {
|
||||
assert.match(detailViewScript, /function buildOptionalTravelReceiptRiskCards\(requestModel, items\)/)
|
||||
assert.match(detailViewScript, /isApplicationDocumentRequest\(requestModel\)[\s\S]*return \[\]/)
|
||||
assert.match(detailViewScript, /id: 'travel-optional-hotel-ticket'[\s\S]*tone: 'low'[\s\S]*住宿票据提醒/)
|
||||
assert.match(detailViewScript, /不要忘记补充酒店住宿票据/)
|
||||
assert.match(detailViewScript, /id: 'travel-optional-ride-ticket'[\s\S]*tone: 'low'[\s\S]*乘车票据提醒/)
|
||||
@@ -539,6 +545,33 @@ test('expense detail save is blocked while attachment recognition is running', (
|
||||
)
|
||||
})
|
||||
|
||||
test('application detail uses application labels instead of reimbursement labels', () => {
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '申请进度'/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '申请预算' : '费用明细'/)
|
||||
assert.match(detailViewTemplate, /无需补充任何报销票据/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '申请类型' : '报销类型'/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '预计金额' : '报销金额'/)
|
||||
assert.match(detailViewTemplate, /isApplicationDocument \? '退回申请' : '退回单据'/)
|
||||
assert.match(detailViewTemplate, /当前申请单已进入流程,详情页仅展示状态与申请信息。/)
|
||||
})
|
||||
|
||||
test('application detail does not show optional travel receipt reminders', () => {
|
||||
const request = {
|
||||
documentTypeCode: 'application',
|
||||
claimNo: 'APP-20260525-ABC123',
|
||||
typeCode: 'travel_application',
|
||||
detailVariant: 'travel'
|
||||
}
|
||||
|
||||
assert.equal(isApplicationDocumentRequest(request), true)
|
||||
assert.deepEqual(
|
||||
buildOptionalTravelReceiptRiskCards(request, [
|
||||
{ id: 'allowance', itemType: 'travel_allowance', isSystemGenerated: true, invoiceId: '' }
|
||||
]),
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
test('draft submit validation uses expense detail date and amount when claim summary is stale', () => {
|
||||
const issues = buildDraftBlockingIssues(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user