feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
@@ -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: '财务' }
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
54
web/tests/application-approval-info.test.mjs
Normal file
54
web/tests/application-approval-info.test.mjs
Normal 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'
|
||||
)
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 条你的归档报销单',
|
||||
|
||||
50
web/tests/document-center-archived-scope.test.mjs
Normal file
50
web/tests/document-center-archived-scope.test.mjs
Normal 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'])
|
||||
})
|
||||
@@ -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 = ['全部', '申请单', '报销单', '审核单', '归档']
|
||||
|
||||
@@ -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/
|
||||
|
||||
291
web/tests/expense-application-fast-preview.test.mjs
Normal file
291
web/tests/expense-application-fast-preview.test.mjs
Normal 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)
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, /'其他费用'/)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '李经理',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
43
web/tests/sidebar-document-unread-dot.test.mjs
Normal file
43
web/tests/sidebar-document-unread-dot.test.mjs
Normal 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\)\)/)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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, /匹配到您要出差的地区为/)
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
27
web/tests/travel-request-detail-responsive.test.mjs
Normal file
27
web/tests/travel-request-detail-responsive.test.mjs
Normal 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*'当前状态'/)
|
||||
})
|
||||
@@ -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 \? '退回申请' : '退回单据'/)
|
||||
|
||||
@@ -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"/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user