feat: 增强风险规则生成引擎与预算中心页面

后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块,
优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强
报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图
组件,重构审计页面和风险规则测试对话框交互,完善文档中心
和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-26 09:15:14 +08:00
parent d0e946cf47
commit 0e861d8fa6
150 changed files with 14953 additions and 4099 deletions

View File

@@ -3,6 +3,8 @@ import test from 'node:test'
import {
canApproveLeaderExpenseClaims,
canAccessAppView,
canDeleteArchivedExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims
} from '../src/utils/accessControl.js'
@@ -28,6 +30,21 @@ test('finance can return and final approve, but only executives can manage delet
assert.equal(canManageExpenseClaims({ roleCodes: ['executive'] }), true)
})
test('archived claims can only be deleted by admin users', () => {
assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['executive'] }), false)
assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['finance'] }), false)
assert.equal(canDeleteArchivedExpenseClaims({ isAdmin: true, roleCodes: ['manager'] }), true)
})
test('legacy reimbursement approval and archive centers are no longer accessible app views', () => {
const adminUser = { isAdmin: true, roleCodes: ['manager', 'finance'] }
assert.equal(canAccessAppView(adminUser, 'requests'), false)
assert.equal(canAccessAppView(adminUser, 'approval'), false)
assert.equal(canAccessAppView(adminUser, 'archive'), false)
assert.equal(canAccessAppView(adminUser, 'documents'), true)
})
test('finance approval inbox only processes finance-stage requests', () => {
const financeUser = { roleCodes: ['finance'], name: '财务' }

View File

@@ -101,8 +101,8 @@ test('detail topbar still flags real manual rows without required ticket info',
test('application detail topbar does not ask for receipt attachments', () => {
const request = {
id: 'APP-20260525-ABC123',
claimNo: 'APP-20260525-ABC123',
id: 'AP-20260525103045-ABCDEFGH',
claimNo: 'AP-20260525103045-ABCDEFGH',
documentTypeCode: 'application',
node: '直属领导审批',
approvalKey: 'in_progress',

View File

@@ -53,6 +53,16 @@ test('financial assistant toolbar renders four isolated assistant sessions', ()
assert.match(assistantTemplate, /:disabled="shortcut\.active \|\| submitting/)
})
test('closing a busy assistant keeps the running instance recoverable', () => {
assert.match(appShellRouteView, /:reopen-token="smartEntryRevealToken"/)
assert.match(appShellComposable, /const smartEntryRevealToken = ref\(0\)/)
assert.match(appShellComposable, /if \(smartEntryOpen\.value\) \{\s*smartEntryRevealToken\.value \+= 1\s*return\s*\}/)
assert.match(appShellComposable, /smartEntryRevealToken,/)
assert.match(assistantScript, /reopenToken:\s*\{\s*type:\s*Number/)
assert.match(assistantScript, /closeAfterBusy\.value = false[\s\S]*workbenchVisible\.value = true/)
assert.match(assistantScript, /function emitCloseAfterLeave\(\) \{\s*if \(workbenchVisible\.value\)/)
})
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)

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildLeaderApprovalInfo,
resolveGeneratedDraftClaimNo
} from '../src/utils/applicationApproval.js'
test('buildLeaderApprovalInfo extracts leader opinion and generated reimbursement draft', () => {
const info = buildLeaderApprovalInfo({
profileManager: '王经理',
riskFlags: [
{
source: 'manual_return',
opinion: '需要补充预算口径',
created_at: '2026-05-24T09:00:00'
},
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: 'li.manager@example.com',
operator_name: '李经理',
opinion: '业务必要,同意申请。',
previous_approval_stage: '直属领导审批',
next_approval_stage: '审批完成',
generated_draft_claim_no: 'EXP-202605-0007',
created_at: '2026-05-25T10:15:00'
}
]
})
assert.deepEqual(info, {
opinion: '业务必要,同意申请。',
operator: '李经理',
time: '2026-05-25 10:15',
generatedDraftClaimNo: 'EXP-202605-0007'
})
})
test('resolveGeneratedDraftClaimNo reads approval response payload', () => {
assert.equal(
resolveGeneratedDraftClaimNo({
risk_flags_json: [
{
source: 'manual_approval',
event_type: 'expense_application_approval',
generated_draft_claim_no: 'EXP-202605-0012',
created_at: '2026-05-25T11:00:00'
}
]
}),
'EXP-202605-0012'
)
})

View File

@@ -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: activeView\.value === 'documents' \? 'app-documents' : 'app-requests' \}\)/)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: 'app-documents' \}\)/)
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: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })", draftSuccessIndex), -1)
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-documents' })", draftSuccessIndex), -1)
})
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {

View File

@@ -25,7 +25,7 @@ test('suggested action prefill falls back to application field templates', () =>
action_type: 'prefill_composer',
payload: { application_field: 'amount' }
}),
'预计总费用:'
'用户预估费用:'
)
assert.equal(
resolveSuggestedActionPrefill({

View File

@@ -257,7 +257,7 @@ test('expense query info items render as prompts instead of low risk', () => {
assert.notEqual(payload.records[0].riskItems[0].levelLabel, '低风险')
})
test('expense query hint guides users to the reimbursement center after the top five results', () => {
test('expense query hint guides users to the document center after the top five results', () => {
const payload = normalizeExpenseQueryPayload({
result_type: 'expense_claim_list',
title: '最近 5 条你的归档报销单',

View File

@@ -0,0 +1,50 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
excludeArchivedDocumentRows,
isArchivedDocumentRow
} from '../src/utils/documentCenterRows.js'
test('document center archived rows are detected from archive flag or request stage', () => {
assert.equal(isArchivedDocumentRow({ archived: true }), true)
assert.equal(
isArchivedDocumentRow({
rawRequest: { status: 'approved', approval_stage: '归档入账' }
}),
true
)
assert.equal(
isArchivedDocumentRow({
rawRequest: {
status: 'approved',
approval_stage: '审批完成',
claim_no: 'AP-20260525120000-ABCDEFGH',
expense_type: 'travel_application'
}
}),
true
)
assert.equal(
isArchivedDocumentRow({
rawRequest: { status: 'in_progress', approval_stage: '部门审批' }
}),
false
)
assert.equal(
isArchivedDocumentRow({
rawRequest: { status: 'approved', approval_stage: '部门审批', approvalKey: 'completed' }
}),
false
)
})
test('document center all scope excludes archived rows from merged lists', () => {
const rows = excludeArchivedDocumentRows([
{ claimId: 'a', archived: true },
{ claimId: 'b', rawRequest: { status: 'approved', approval_stage: '归档入账' } },
{ claimId: 'c', rawRequest: { status: 'submitted', approval_stage: '部门审批' } }
])
assert.deepEqual(rows.map((row) => row.claimId), ['c'])
})

View File

@@ -10,6 +10,7 @@ import {
resolveDocumentNewKey,
writeDocumentScope
} from '../src/utils/documentCenterNewState.js'
import { buildDocumentInboxRows } from '../src/composables/useDocumentCenterInbox.js'
function createMemoryStorage(initial = {}) {
const store = new Map(Object.entries(initial))
@@ -46,6 +47,16 @@ test('document center new state counts unseen documents and persists viewed rows
assert.deepEqual([...readViewedDocumentKeys(storage)], ['archive:claim-1'])
})
test('document center sidebar inbox shares source scoped document keys', () => {
const rows = buildDocumentInboxRows({
ownedClaims: [{ id: 'claim-1', claim_no: 'EXP-1' }],
approvalClaims: [{ id: 'claim-1', claim_no: 'EXP-1' }],
archivedClaims: [{ id: 'claim-2', claim_no: 'EXP-2' }]
})
assert.deepEqual(rows.map((row) => resolveDocumentNewKey(row)), ['approval:claim-1', 'archive:claim-2'])
})
test('document center scope state restores only allowed tabs', () => {
const storage = createMemoryStorage()
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']

View File

@@ -39,7 +39,10 @@ test('documents center top tabs start from all and show document category labels
})
test('documents center category tabs map to the intended row sources', () => {
assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
assert.match(documentsCenterView, /activeScopeTab\.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_ALL[\s\S]*return nonArchivedRows\.value/

View File

@@ -0,0 +1,291 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildApplicationPreviewFooterMessage,
buildApplicationPreviewRows,
buildApplicationPreviewSubmitText,
buildApplicationTemplatePreview,
applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreview,
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
normalizeApplicationPreview,
shouldUseLocalApplicationPreview
} from '../src/utils/expenseApplicationPreview.js'
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const createViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
)
const conversationModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
'utf8'
)
const previewEditorScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useApplicationPreviewEditor.js', import.meta.url)),
'utf8'
)
test('application intent uses local preview instead of immediate orchestrator call', () => {
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目出差3天高铁预计金额2358元'
assert.equal(
shouldUseLocalApplicationPreview(prompt, {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
true
)
assert.equal(
shouldUseLocalApplicationPreview('帮我查询申请状态', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
false
)
const preview = buildLocalApplicationPreview(prompt, { name: '李文静', departmentName: '财务部', grade: 'P5' })
assert.equal(preview.fields.applicationType, '差旅费用申请')
assert.equal(preview.fields.time, '2026-05-20 至 2026-05-23')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.fields.transportMode, '火车')
assert.equal(preview.fields.amount, '2358元')
assert.equal(preview.fields.grade, 'P5')
assert.equal(preview.readyToSubmit, true)
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
assert.match(buildApplicationPreviewFooterMessage(preview), /#application-submit/)
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
})
test('application preview renders ordered editable rows and submit text uses edited values', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆伊犁出差服务美团业务部署火车预计费用1800元', {
name: '李文静',
grade: 'P5'
})
assert.equal(preview.fields.location, '新疆,伊犁')
assert.equal(preview.fields.reason, '服务美团业务部署')
const editedPreview = normalizeApplicationPreview({
...preview,
fields: {
...preview.fields,
reason: '客户现场项目支持',
amount: '1900元'
}
})
const rows = buildApplicationPreviewRows(editedPreview)
assert.deepEqual(
rows.map((row) => row.label),
['申请类型', '职级', '发生时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '用户预估费用']
)
assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true)
assert.equal(rows.find((row) => row.key === 'lodgingDailyCap')?.editable, false)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /事由:客户现场项目支持/)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /用户预估费用1900元/)
})
test('application preview cleans empty time labels and keeps only business reason', () => {
const preview = buildLocalApplicationPreview('发生时间去九江出差3天服务美团业务部署预计费用1800元火车', {
name: '李文静',
grade: 'P5'
})
assert.equal(preview.fields.location, '九江')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.fields.reason, '服务美团业务部署')
assert.equal(preview.fields.transportMode, '火车')
assert.doesNotMatch(preview.fields.reason, /发生时间|去九江|出差3天/)
})
test('application preview can be refined by ontology model extraction', () => {
const rawText = '发生时间去九江出差3天服务美团业务部署预计费用1800元火车'
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
{
parse_strategy: 'llm_primary',
entities: [
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
{ type: 'location', value: '九江', normalized_value: '九江' },
{ type: 'reason', value: '服务美团业务部署', normalized_value: '服务美团业务部署' },
{ type: 'transport_mode', value: '火车', normalized_value: '火车' },
{ type: 'amount', value: '1800元', normalized_value: '1800' }
],
time_range: {},
missing_slots: []
},
rawText,
{ name: '李文静', grade: 'P5' }
)
assert.equal(refinedPreview.modelRefined, true)
assert.equal(refinedPreview.parseStrategy, 'llm_primary')
assert.equal(refinedPreview.modelReviewStatus, 'completed')
assert.equal(refinedPreview.fields.applicationType, '差旅费用申请')
assert.equal(refinedPreview.fields.time, '')
assert.equal(refinedPreview.fields.reason, '服务美团业务部署')
assert.equal(refinedPreview.fields.transportMode, '火车')
})
test('application preview keeps rule fallback distinct from model reviewed result', () => {
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署出差3天火车预计费用1800元'
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
const fallbackPreview = buildModelRefinedApplicationPreview(
localPreview,
{
parse_strategy: 'rule_fallback',
entities: [
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
{ type: 'location', value: '上海', normalized_value: '上海' },
{ type: 'amount', value: '1800元', normalized_value: '1800' }
],
time_range: {
start: '2026-05-20',
end: '2026-05-23'
},
missing_slots: []
},
rawText,
{ name: '李文静', grade: 'P5' }
)
const message = buildLocalApplicationPreviewMessage(fallbackPreview)
const footer = buildApplicationPreviewFooterMessage(fallbackPreview)
assert.equal(fallbackPreview.modelReviewStatus, 'fallback')
assert.match(message, /规则兜底/)
assert.match(footer, /规则兜底/)
assert.doesNotMatch(footer, /#application-submit/)
})
test('application preview with missing budget stays in chat and asks for补充信息', () => {
const preview = buildLocalApplicationPreview('我想申请去北京出差,高铁,但是不知道预算', {
name: '李文静',
grade: 'P5'
})
assert.equal(preview.fields.amount, '待测算')
assert.equal(preview.readyToSubmit, false)
assert.match(buildLocalApplicationPreviewMessage(preview), /下方表格/)
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /当前还需要补充/)
assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/)
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
})
test('application quick start renders a template without model review', () => {
const preview = buildApplicationTemplatePreview({
name: '李文静',
departmentName: '财务部',
grade: 'P5'
})
const message = buildLocalApplicationPreviewMessage(preview)
assert.equal(preview.modelReviewStatus, 'template')
assert.equal(preview.fields.applicationType, '费用申请')
assert.equal(preview.fields.applicant, '李文静')
assert.equal(preview.fields.department, '财务部')
assert.equal(preview.fields.grade, 'P5')
assert.match(message, /不调用大模型/)
assert.match(message, /点击对应行直接填写/)
assert.doesNotMatch(message, /#application-submit/)
assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/)
})
test('application session shows intent flow, persists preview, and supports inline table edit', () => {
assert.match(submitComposerScript, /shouldUseLocalApplicationPreview/)
assert.match(submitComposerScript, /buildLocalApplicationPreviewMessage/)
assert.match(submitComposerScript, /buildApplicationPreviewWithModelReview/)
assert.match(submitComposerScript, /fetchOntologyParse/)
assert.match(submitComposerScript, /calculateTravelReimbursement/)
assert.match(submitComposerScript, /buildApplicationPolicyEstimateRequest/)
assert.match(submitComposerScript, /模型复核中/)
assert.match(submitComposerScript, /startFlowStep\('intent'/)
assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/)
assert.match(submitComposerScript, /completeFlowStep\('intent'/)
assert.match(submitComposerScript, /insightPanelCollapsed\.value = true/)
assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/)
assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/)
assert.ok(
submitComposerScript.indexOf('shouldUseLocalApplicationPreview') <
submitComposerScript.indexOf('const payload = await runOrchestrator')
)
assert.match(createViewScript, /const isApplicationSession = computed/)
assert.match(createViewScript, /insightPanelCollapsed,/)
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
assert.match(createViewScript, /flowSteps\.value\.length > 0/)
assert.match(createViewScript, /useApplicationPreviewEditor/)
assert.match(createViewScript, /message-bubble-application-preview/)
assert.match(createViewScript, /buildApplicationPreviewFooterMessage/)
assert.match(createViewScript, /function buildApplicationPreviewFooterText\(message\)/)
assert.match(createViewScript, /buildApplicationPreviewSubmitText/)
assert.match(createViewScript, /user_input_text: applicationSubmitText/)
assert.match(conversationModelScript, /applicationPreview: null/)
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
assert.match(createViewTemplate, /class="application-preview-table"/)
assert.match(createViewTemplate, /class="application-preview-footer message-answer-content message-answer-markdown"/)
assert.match(createViewTemplate, /v-html="renderMarkdown\(buildApplicationPreviewFooterText\(message\)\)"/)
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
assert.match(createViewTemplate, /v-model="applicationPreviewEditor\.draftValue"/)
assert.match(createViewTemplate, /application-preview-select/)
assert.match(createViewTemplate, /resolveApplicationPreviewEditorOptions/)
assert.match(createViewTemplate, /row\.editable && !isApplicationPreviewEditing\(message, row\.key\).*openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
assert.match(createViewTemplate, /@keydown\.enter\.prevent="row\.editable && !isApplicationPreviewEditing\(message, row\.key\).*openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
assert.match(createViewTemplate, /@keydown\.stop="handleApplicationPreviewEditorKeydown\(\$event, message\)"/)
assert.match(createViewTemplate, /mdi mdi-pencil-outline/)
assert.match(createViewTemplate, /@click\.stop="openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
assert.match(createViewTemplate, /openApplicationPreviewEditor/)
assert.match(createViewTemplate, /commitApplicationPreviewEditor/)
assert.match(previewEditorScript, /normalizeApplicationPreview/)
assert.match(previewEditorScript, /APPLICATION_TRANSPORT_MODE_OPTIONS/)
assert.match(previewEditorScript, /buildLocalApplicationPreviewMessage/)
assert.match(previewEditorScript, /targetRow\.editable === false/)
assert.match(previewEditorScript, /\[editor\.fieldKey\]: nextValue/)
})
test('application preview merges rule center travel estimate into highlighted rows', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天服务项目部署火车预计费用1800元', {
name: '李文静',
grade: 'P5'
})
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5' })
assert.equal(request.canCalculate, true)
assert.deepEqual(request.payload, { days: 3, location: '上海', grade: 'P5' })
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
days: 3,
location: '上海',
matched_city: '上海',
grade: 'P5',
hotel_rate: 600,
hotel_amount: 1800,
total_allowance_rate: 120,
allowance_amount: 360,
total_amount: 2160,
rule_name: '公司差旅费报销规则',
rule_version: '2026版'
}, { grade: 'P5' })
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
assert.match(estimatedPreview.fields.transportPolicy, /实报实销/)
assert.match(estimatedPreview.fields.policyEstimate, /2,160元/)
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
})

View File

@@ -43,6 +43,22 @@ test('expense application prompt field parser supports multiline labels', () =>
assert.equal(resolveApplicationReason(structuredApplicationPrompt), '支撑国网服务器部署')
})
test('expense application reason prefers model entity and strips context fragments', () => {
assert.equal(
resolveApplicationReason(
'发生时间去九江出差3天服务美团业务部署',
{
entities: [
{ type: 'location', value: '九江', normalized_value: '九江' },
{ type: 'reason', value: '服务美团业务部署', normalized_value: '服务美团业务部署' }
]
}
),
'服务美团业务部署'
)
assert.equal(resolveApplicationReason('发生时间去九江出差3天服务美团业务部署'), '服务美团业务部署')
})
test('expense application expands a single selected date with natural days', () => {
const prompt = [
'发生时间2026-05-25',

View File

@@ -33,7 +33,7 @@ test('expense application submit uses rich text link and confirm dialog', () =>
)
assert.match(
createViewScript,
/async function confirmApplicationSubmit\(\)[\s\S]*rawText: '确认提交'[\s\S]*systemGenerated: true/
/async function confirmApplicationSubmit\(\)[\s\S]*const applicationSubmitText[\s\S]*rawText: applicationSubmitText[\s\S]*systemGenerated: true[\s\S]*skipScopeGuard: true/
)
assert.match(
createViewScript,

View File

@@ -10,6 +10,15 @@ test('isArchivedExpenseClaim recognizes finance archive stage', () => {
isArchivedExpenseClaim({ status: 'approved', approval_stage: '归档入账' }),
true
)
assert.equal(
isArchivedExpenseClaim({
status: 'approved',
approval_stage: '审批完成',
claim_no: 'AP-20260525120000-ABCDEFGH',
expense_type: 'travel_application'
}),
true
)
})
test('isArchivedExpenseClaim ignores in-progress claims', () => {
@@ -19,7 +28,7 @@ test('isArchivedExpenseClaim ignores in-progress claims', () => {
)
})
test('archive center is wired into navigation and api client', () => {
test('archive data stays available through api client but archive center is removed from navigation', () => {
const navigationScript = readFileSync(
fileURLToPath(new URL('../src/composables/useNavigation.js', import.meta.url)),
'utf8'
@@ -29,7 +38,7 @@ test('archive center is wired into navigation and api client', () => {
'utf8'
)
assert.match(navigationScript, /id:\s*'archive'/)
assert.doesNotMatch(navigationScript, /id:\s*'archive'/)
assert.match(reimbursementsService, /\/reimbursements\/claims\/archives/)
})
@@ -43,10 +52,13 @@ test('archive center uses generic archive category and type wording', () => {
'utf8'
)
assert.match(archiveScript, /const tabs = \[ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT\]/)
assert.match(archiveScript, /const tabs = \[ARCHIVE_TAB_ALL, ARCHIVE_TAB_APPLICATION, ARCHIVE_TAB_REIMBURSEMENT\]/)
assert.match(archiveScript, /const ARCHIVE_TAB_APPLICATION = '申请归档'/)
assert.match(archiveScript, /const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'/)
assert.match(archiveScript, /archiveType:\s*ARCHIVE_TYPE_REIMBURSEMENT/)
assert.match(archiveScript, /archiveTypeCode:\s*ARCHIVE_TYPE_REIMBURSEMENT_CODE/)
assert.match(archiveScript, /archiveType:\s*isApplicationDocument \? ARCHIVE_TYPE_APPLICATION : ARCHIVE_TYPE_REIMBURSEMENT/)
assert.match(archiveScript, /archiveTab:\s*isApplicationDocument \? ARCHIVE_TAB_APPLICATION : ARCHIVE_TAB_REIMBURSEMENT/)
assert.match(archiveScript, /const ARCHIVE_TYPE_REIMBURSEMENT = '报销'/)
assert.match(archiveScript, /const ARCHIVE_TYPE_APPLICATION = '申请'/)
assert.doesNotMatch(archiveScript, /'差旅报销'/)
assert.doesNotMatch(archiveScript, /'招待报销'/)
assert.doesNotMatch(archiveScript, /'其他费用'/)

View File

@@ -1,13 +1,15 @@
import assert from 'node:assert/strict'
import {
appViews,
navItems,
resolveAppViewFromRoute,
resolveTargetRouteName
} from '../src/composables/useNavigation.js'
function testDerivesViewFromRouteName() {
assert.equal(resolveAppViewFromRoute({ name: 'app-log-detail', meta: {} }), 'logs')
assert.equal(resolveAppViewFromRoute({ name: 'app-request-detail', meta: {} }), 'requests')
assert.equal(resolveAppViewFromRoute({ name: 'app-request-detail', meta: {} }), 'documents')
assert.equal(resolveAppViewFromRoute({ name: 'app-policies', meta: { appView: 'logs' } }), 'policies')
}
@@ -19,13 +21,24 @@ function testFallsBackToValidMeta() {
function testResolvesMainRouteNames() {
assert.equal(resolveTargetRouteName('logs'), 'app-logs')
assert.equal(resolveTargetRouteName('policies'), 'app-policies')
assert.equal(resolveTargetRouteName('requests'), 'app-overview')
assert.equal(resolveTargetRouteName('approval'), 'app-overview')
assert.equal(resolveTargetRouteName('archive'), 'app-overview')
assert.equal(resolveTargetRouteName('missing'), 'app-overview')
}
function testLegacyCentersAreRemovedFromNavigation() {
assert.equal(appViews.includes('requests'), false)
assert.equal(appViews.includes('approval'), false)
assert.equal(appViews.includes('archive'), false)
assert.equal(navItems.some((item) => ['requests', 'approval', 'archive'].includes(item.id)), false)
}
function run() {
testDerivesViewFromRouteName()
testFallsBackToValidMeta()
testResolvesMainRouteNames()
testLegacyCentersAreRemovedFromNavigation()
console.log('navigation route resolution tests passed')
}

View File

@@ -6,7 +6,7 @@ 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',
claim_no: 'AP-20260525103045-ABCDEFGH',
employee_name: '张三',
department_name: '交付部',
expense_type: 'travel_application',
@@ -42,7 +42,7 @@ test('application claims are mapped as application documents', () => {
test('approved application claims complete after direct manager approval only', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-approved',
claim_no: 'APP-20260525-DONE01',
claim_no: 'AP-20260525113045-HGFEDCBA',
employee_name: '张三',
department_name: '交付部',
manager_name: '李经理',

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { normalizeRequestForUi } from '../src/utils/requestViewModel.js'
import { isArchivedRequestView, normalizeRequestForUi } from '../src/utils/requestViewModel.js'
test('normalizes backend approval_stage for in-progress claim details', () => {
const request = normalizeRequestForUi({
@@ -45,3 +45,42 @@ test('does not show manager email as direct supervisor name', () => {
assert.equal(request.profileManager, '待补充')
})
test('detects archived claim view models for delete permission gating', () => {
assert.equal(
isArchivedRequestView({
status: 'approved',
approval_stage: '归档入账',
approvalKey: 'completed'
}),
true
)
assert.equal(
isArchivedRequestView({
status: 'submitted',
approval_stage: '财务审批',
approvalKey: 'in_progress'
}),
false
)
assert.equal(
isArchivedRequestView({
status: 'approved',
approval_stage: '审批完成',
claim_no: 'AP-20260525120000-ABCDEFGH',
expense_type: 'travel_application',
approvalKey: 'completed'
}),
true
)
assert.equal(
isArchivedRequestView({
status: 'approved',
approval_stage: '审批完成',
claim_no: 'RE-20260525120000-HGFEDCBA',
expense_type: 'travel',
approvalKey: 'completed'
}),
false
)
})

View File

@@ -0,0 +1,43 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const sidebar = readFileSync(
fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', import.meta.url)),
'utf8'
)
const documentInbox = readFileSync(
fileURLToPath(new URL('../src/composables/useDocumentCenterInbox.js', import.meta.url)),
'utf8'
)
const documentNewState = readFileSync(
fileURLToPath(new URL('../src/utils/documentCenterNewState.js', import.meta.url)),
'utf8'
)
test('sidebar renders a red dot for unread document center rows', () => {
assert.match(sidebar, /useDocumentCenterInbox/)
assert.match(sidebar, /hasUnread: documentInboxHasUnread/)
assert.match(sidebar, /<span v-if="item\.hasNewMessage" class="nav-unread-dot" aria-hidden="true"><\/span>/)
assert.match(sidebar, /hasNewMessage: item\.id === 'documents' \? documentInboxHasUnread\.value : false/)
assert.match(sidebar, /void refreshDocumentInbox\(\)/)
assert.match(sidebar, /startDocumentInboxPolling\(\)/)
assert.match(sidebar, /stopDocumentInboxPolling\(\)/)
assert.match(sidebar, /\.nav-unread-dot\s*\{[\s\S]*background:\s*#ef4444;/)
assert.match(sidebar, /\.rail-collapsed \.nav-unread-dot\s*\{[\s\S]*position:\s*absolute;/)
})
test('document inbox reuses document center viewed-key state', () => {
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
assert.match(documentInbox, /readViewedDocumentKeys/)
assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/)
assert.match(documentInbox, /fetchExpenseClaims/)
assert.match(documentInbox, /fetchApprovalExpenseClaims/)
assert.match(documentInbox, /fetchArchivedExpenseClaims/)
assert.match(documentInbox, /window\.addEventListener\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT, refreshViewedDocumentKeys\)/)
assert.match(documentNewState, /export const DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
assert.match(documentNewState, /window\.dispatchEvent\(new CustomEvent\(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT\)\)/)
})

View File

@@ -18,6 +18,7 @@ import {
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
GUIDED_ACTION_CONTINUE_FILLING,
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
GUIDED_ACTION_START_APPLICATION,
GUIDED_ACTION_PROCESS_INTERRUPTION,
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
GUIDED_ACTION_SELECT_QUERY_MODE,
@@ -90,6 +91,16 @@ test('assistant session modes expose independent quick actions', () => {
buildWelcomeQuickActions(SESSION_TYPE_APPLICATION).map((item) => item.label),
APPLICATION_WELCOME_QUICK_ACTIONS.map((item) => item.label)
)
assert.equal(buildWelcomeQuickActions(SESSION_TYPE_APPLICATION)[0].action, GUIDED_ACTION_START_APPLICATION)
assert.ok(!buildWelcomeQuickActions(SESSION_TYPE_APPLICATION)[0].prompt)
assert.ok(
buildWelcomeQuickActions(SESSION_TYPE_APPLICATION).every((item) => item.action !== GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR)
)
assert.ok(
buildWelcomeQuickActions(SESSION_TYPE_APPROVAL).every((item) => item.action !== GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR)
)
assert.match(guidedFlowScript, /GUIDED_ACTION_START_APPLICATION/)
assert.match(guidedFlowScript, /buildApplicationTemplatePreview/)
assert.deepEqual(
buildWelcomeQuickActions(SESSION_TYPE_APPROVAL).map((item) => item.label),
APPROVAL_WELCOME_QUICK_ACTIONS.map((item) => item.label)

View File

@@ -182,7 +182,7 @@ test('next step action uses rich text guidance and confirm dialog instead of foo
{ action_type: 'next_step', label: '继续下一步', emphasis: 'primary' }
]
}
const copy = buildReviewNextStepRichCopy(reviewPayload, { detailHref: '/app/requests/claim-1' })
const copy = buildReviewNextStepRichCopy(reviewPayload, { detailHref: '/app/documents/claim-1' })
const rendered = renderMarkdown(copy)
assert.match(copy, /系统识别您的单据已经填写完所有已知信息/)
@@ -192,7 +192,7 @@ test('next step action uses rich text guidance and confirm dialog instead of foo
assert.doesNotMatch(copy, /#review-risk-high/)
assert.match(copy, /\[右侧\]\(#review-risk-panel\) 风险信息提示窗/)
assert.match(copy, /\[继续下一步\]\(#review-next-step\)/)
assert.match(copy, /\[快速修改单据信息\]\(\/app\/requests\/claim-1\)/)
assert.match(copy, /\[快速修改单据信息\]\(\/app\/documents\/claim-1\)/)
assert.doesNotMatch(rendered, /markdown-risk-link-/)
assert.match(rendered, /<span class="markdown-risk-text-low">低风险<\/span>/)
assert.match(rendered, /<span class="markdown-risk-text-medium">中风险<\/span>/)
@@ -209,7 +209,7 @@ test('next step action uses rich text guidance and confirm dialog instead of foo
...reviewPayload,
risk_briefs: [{ level: 'high', title: '金额超标' }]
},
{ detailHref: '/app/requests/claim-1' }
{ detailHref: '/app/documents/claim-1' }
)
assert.doesNotMatch(highRiskCopy, /\[继续下一步\]\(#review-next-step\)/)
@@ -280,7 +280,7 @@ test('review risk drawer lists risk briefs without score and posts details into
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
)
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-document-detail'/)
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
assert.match(createViewTemplate, /class="expense-query-risk-row"[\s\S]*appendExpenseQueryRiskToConversation\(record, risk\)/)
@@ -314,7 +314,7 @@ test('submit composer scopes the side panel to intent overview, document upload,
assert.match(submitComposerScript, /nextInsight\.agent\.reviewPanelScope = assistantMessage\.reviewPanelScope/)
})
test('expense query answers keep one clear result structure with reimbursement center jump link', () => {
test('expense query answers keep one clear result structure with document 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/)
@@ -351,12 +351,17 @@ test('closing the assistant while OCR is running defers unmount until the curren
})
test('composer exposes travel calculator and posts spreadsheet-backed result into conversation', () => {
assert.match(createViewTemplate, /v-if="canShowTravelCalculator" class="travel-calculator-anchor"/)
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"/)
assert.doesNotMatch(createViewTemplate, /travel-calculator-modal/)
assert.doesNotMatch(createViewTemplate, /travelCalculatorResult\.total_amount/)
assert.match(createViewScript, /calculateTravelReimbursement/)
assert.match(createViewScript, /const canShowTravelCalculator = computed\(\(\) => activeSessionType\.value === SESSION_TYPE_EXPENSE\)/)
assert.match(createViewScript, /function openTravelCalculator\(\) \{[\s\S]*!canShowTravelCalculator\.value[\s\S]*closeTravelCalculator\(\)/)
assert.match(createViewScript, /function toggleTravelCalculator\(\)/)
assert.match(createViewScript, /function toggleTravelCalculator\(\) \{[\s\S]*!canShowTravelCalculator\.value[\s\S]*closeTravelCalculator\(\)/)
assert.match(createViewScript, /watch\(canShowTravelCalculator,[\s\S]*closeTravelCalculator\(\)/)
assert.match(createViewScript, /function submitTravelCalculator\(\) \{[\s\S]*calculateTravelReimbursement\(\{[\s\S]*grade: String\(user\.grade/)
assert.match(createViewScript, /根据您输入的地点和天数/)
assert.match(createViewScript, /匹配到您要出差的地区为/)

View File

@@ -11,6 +11,10 @@ const detailScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
const detailStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8'
)
const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8'
@@ -49,26 +53,44 @@ test('approval-mode detail collects leader opinion and confirms approval before
assert.match(detailScript, /approvalOpinionTitle/)
assert.match(detailScript, /approvalConfirmDescription/)
assert.match(detailScript, /approvalNextStage/)
assert.match(detailScript, /showApplicationLeaderOpinionInput/)
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => isDirectManagerApprovalStage\.value\)/)
assert.match(detailScript, /buildLeaderApprovalInfo/)
assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
assert.match(detailScript, /approveActionLabel/)
assert.match(detailScript, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
assert.match(detailScript, /请先填写领导意见,填写后才能确认审核。/)
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
assert.match(detailScript, /toast\(approvalSuccessToast\.value\)/)
assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/)
assert.match(detailTemplate, /v-if="showLeaderApprovalPanel"/)
assert.match(detailTemplate, /v-if="showApplicationLeaderOpinion"/)
assert.match(detailTemplate, /class="application-leader-opinion"/)
assert.match(detailTemplate, /领导意见/)
assert.match(detailTemplate, /\{\{ approvalOpinionTitle \}\}/)
assert.match(detailTemplate, /v-model="leaderOpinion"/)
assert.match(detailTemplate, /maxlength="500"\s+:required="requiresApprovalOpinion"/)
assert.match(detailTemplate, /:placeholder="approvalOpinionPlaceholder"/)
assert.match(detailTemplate, /@click="handleApproveRequest"/)
assert.match(detailTemplate, /\{\{ approveBusy \? approveBusyLabel : approveActionLabel \}\}/)
assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/)
assert.match(detailTemplate, /:badge="approvalConfirmBadge"/)
assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
assert.match(detailTemplate, /confirm-text="确认通过"/)
assert.match(detailTemplate, /:confirm-text="approveConfirmText"/)
assert.match(detailTemplate, /:busy-text="approveBusyText"/)
assert.match(detailTemplate, /\{\{ approvalNextStage \}\}/)
assert.match(detailTemplate, /@confirm="confirmApproveRequest"/)
assert.match(detailTemplate, /:description="returnDialogDescription"/)
const handleApproveRequest = extractFunction(detailScript, 'handleApproveRequest')
const confirmApproveRequest = extractFunction(detailScript, 'confirmApproveRequest')
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
assert.match(confirmApproveRequest, /approveExpenseClaim/)
assert.match(detailStyles, /\.detail-card-title-with-icon \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
assert.match(detailStyles, /\.detail-card-title-with-icon i \{[\s\S]*font-size: 18px;[\s\S]*line-height: 1;/)
assert.match(detailStyles, /\.application-leader-opinion-head span \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
assert.match(reimbursementService, /export function approveExpenseClaim\(claimId, payload = \{\}\)/)
assert.match(reimbursementService, /\/approve/)
})

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const detailStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8'
)
const responsiveStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view-part2.css', import.meta.url)),
'utf8'
)
const detailScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
test('detail hero facts keep document number and date on one row on laptop screens', () => {
assert.match(detailStyles, /\.hero-fact strong \{[\s\S]*overflow-wrap:\s*anywhere/)
assert.match(detailStyles, /\.hero-fact-grid \{[\s\S]*grid-template-columns:\s*minmax\(240px,\s*1\.25fr\) repeat\(3,\s*minmax\(0,\s*1fr\)\)/)
assert.match(responsiveStyles, /@media \(max-width:\s*1320px\) \{[\s\S]*\.hero-fact-grid \{[\s\S]*grid-template-columns:\s*minmax\(280px,\s*1\.4fr\) repeat\(3,\s*minmax\(0,\s*1fr\)\)/)
assert.match(responsiveStyles, /@media \(max-width:\s*1320px\) \{[\s\S]*\.hero-fact strong \{[\s\S]*white-space:\s*nowrap/)
assert.match(detailStyles, /\.application-detail-facts \{[\s\S]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/)
assert.match(detailStyles, /\.application-detail-fact \{[\s\S]*grid-template-columns:\s*minmax\(96px,\s*28%\) minmax\(0,\s*1fr\)/)
assert.doesNotMatch(detailScript, /key:\s*'status'[\s\S]*label:\s*'当前状态'/)
})

View File

@@ -425,8 +425,9 @@ 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("isApplicationDocument ? '申请预算' : '费用明细'")
< detailViewTemplate.indexOf("isApplicationDocument ? '申请详情' : '费用明细'")
)
assert.match(detailViewTemplate, /<article v-if="!isApplicationDocument" class="detail-card panel">/)
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
@@ -547,8 +548,12 @@ 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, /展示本次申请的事实信息、职级规则测算和用户预估费用/)
assert.match(detailViewTemplate, /class="application-detail-facts"/)
assert.match(detailViewTemplate, /applicationDetailFactItems/)
assert.match(detailViewScript, /buildApplicationDetailFactItems/)
assert.match(detailViewStyle, /\.application-detail-fact\.highlight strong/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请类型' : '报销类型'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '预计金额' : '报销金额'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '退回申请' : '退回单据'/)

View File

@@ -75,3 +75,11 @@ test('detail header and fallback progress use reimbursement wording', () => {
assert.match(detailExpenseModelScript, /label:\s*'创建单据'/)
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
})
test('archived detail delete action is gated by admin-only permission', () => {
assert.match(detailViewScript, /canDeleteArchivedExpenseClaims/)
assert.match(detailViewScript, /isArchivedRequestView/)
assert.match(detailViewScript, /if \(isArchivedRequest\.value\) {\s*return canDeleteArchivedExpenseClaims\(currentUser\.value\)/)
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canDeleteRequest"/)
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
})