feat(approval): add task workflow and waiver decisions

This commit is contained in:
caoxiaozhu
2026-07-16 16:52:12 +08:00
parent 28b834edd3
commit 242d68c36f
89 changed files with 16313 additions and 294 deletions

View File

@@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
compileScript,
compileStyle,
compileTemplate,
parse
} from '@vue/compiler-sfc'
const componentFiles = [
'../src/components/approval/ApprovalTaskQueue.vue',
'../src/components/approval/ApprovalTaskWorkspace.vue',
'../src/components/approval/ApprovalAssignmentDialog.vue',
'../src/components/approval/ApprovalParticipantsDialog.vue',
'../src/components/approval/ApprovalBatchResultPanel.vue'
]
for (const relativeFile of componentFiles) {
test(`${relativeFile} 可以独立编译`, () => {
const filename = fileURLToPath(new URL(relativeFile, import.meta.url))
const source = readFileSync(filename, 'utf8')
const parsed = parse(source, { filename })
assert.deepEqual(parsed.errors, [])
const id = `approval-${relativeFile.replace(/\W+/g, '-')}`
const script = compileScript(parsed.descriptor, { id })
const template = compileTemplate({
source: parsed.descriptor.template?.content || '',
filename,
id,
compilerOptions: { bindingMetadata: script.bindings }
})
assert.deepEqual(template.errors, [])
for (const style of parsed.descriptor.styles) {
const compiledStyle = compileStyle({
source: style.content,
filename,
id,
scoped: style.scoped
})
assert.deepEqual(compiledStyle.errors, [])
}
})
}

View File

@@ -0,0 +1,201 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { useApprovalTaskQueue } from '../src/composables/useApprovalTaskQueue.js'
function queueItem(id, overrides = {}) {
return {
task: {
id,
claimId: `claim-${id}`,
status: 'pending',
version: 1,
canAct: true,
availableActions: ['approve'],
batchEligible: true,
claimStatusSnapshot: 'submitted',
claimStageSnapshot: '直属领导审批',
...overrides
},
claim: { id: `claim-${id}`, claim_no: `BX-${id}` }
}
}
function listResult(items, page = 1) {
return {
items,
total: items.length,
page,
pageSize: 20,
totalPages: items.length ? 1 : 0,
generatedAt: '2026-07-16T10:00:00Z'
}
}
function deferred() {
let resolve
let reject
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
test('列表请求代次阻止慢请求覆盖新页结果', async () => {
const requests = []
const queue = useApprovalTaskQueue({
services: {
fetchApprovalTasks(params) {
const request = deferred()
requests.push({ params, request })
return request.promise
}
}
})
const firstLoad = queue.loadQueue()
await queue.setPage(2, { reload: false })
const secondLoad = queue.loadQueue()
requests[1].request.resolve(listResult([queueItem('task-new')], 2))
await secondLoad
requests[0].request.resolve(listResult([queueItem('task-stale')], 1))
const staleResult = await firstLoad
assert.equal(requests[0].params.page, 1)
assert.equal(requests[1].params.page, 2)
assert.equal(staleResult.ignored, true)
assert.equal(queue.items.value[0].task.id, 'task-new')
assert.equal(queue.page.value, 2)
})
test('筛选变化清空选择并回到第一页', async () => {
const queue = useApprovalTaskQueue({
services: {
async fetchApprovalTasks() {
return listResult([queueItem('task-001')])
}
}
})
await queue.loadQueue()
queue.toggleTask(queue.items.value[0], true)
await queue.setPage(3, { reload: false })
assert.deepEqual(queue.selectedTaskIds.value, ['task-001'])
await queue.setFilters({ riskLevel: 'high' }, { reload: false })
assert.equal(queue.page.value, 1)
assert.equal(queue.filters.value.riskLevel, 'high')
assert.deepEqual(queue.selectedTaskIds.value, [])
})
test('批量部分成功保留失败选择并使用逐项任务快照', async () => {
const batchCalls = []
let listCallCount = 0
const rows = [queueItem('task-001'), queueItem('task-002')]
const queue = useApprovalTaskQueue({
services: {
async fetchApprovalTasks() {
listCallCount += 1
return listResult(rows)
},
async batchApproveApprovalTasks(payload) {
batchCalls.push(payload)
return {
batchRequestId: payload.batchRequestId,
status: 'partial',
succeededCount: 1,
replayedCount: 0,
conflictCount: 0,
blockedCount: 1,
forbiddenCount: 0,
failedCount: 0,
items: [
{ taskId: 'task-001', status: 'succeeded' },
{ taskId: 'task-002', status: 'blocked', message: '存在开放高风险' }
]
}
}
}
})
await queue.loadQueue()
queue.toggleCurrentPage(true)
const result = await queue.batchApprove({ opinion: '批量核对完成' })
assert.equal(listCallCount, 2)
assert.equal(batchCalls.length, 1)
assert.equal(batchCalls[0].items[0].task.id, 'task-001')
assert.equal(batchCalls[0].items[0].opinion, '批量核对完成')
assert.equal(result.status, 'partial')
assert.deepEqual(queue.selectedTaskIds.value, ['task-002'])
assert.equal(queue.lastBatchResult.value.items[1].status, 'blocked')
assert.equal(queue.pendingBatchRequestId.value, '')
})
test('单项刷新采用服务端最新权限并移除失效选择', async () => {
const queue = useApprovalTaskQueue({
services: {
async fetchApprovalTasks() {
return listResult([queueItem('task-001')])
},
async fetchApprovalTaskDetail() {
return queueItem('task-001', {
version: 2,
canAct: false,
availableActions: [],
batchEligible: false,
readOnlyReason: '任务已转交给其他审批人'
})
}
}
})
await queue.loadQueue()
queue.toggleTask(queue.items.value[0], true)
await queue.refreshTask('task-001')
assert.equal(queue.items.value[0].task.version, 2)
assert.equal(queue.items.value[0].task.readOnlyReason, '任务已转交给其他审批人')
assert.deepEqual(queue.selectedTaskIds.value, [])
})
test('批量选择在前端基础层限制为服务端允许的 20 项', async () => {
const rows = Array.from({ length: 21 }, (_, index) => queueItem(`task-${index + 1}`))
const queue = useApprovalTaskQueue({
services: {
async fetchApprovalTasks() {
return listResult(rows)
}
}
})
await queue.loadQueue()
queue.toggleCurrentPage(true)
assert.equal(queue.selectedTaskIds.value.length, 20)
assert.match(queue.errorMessage.value, /最多选择 20 项/)
})
test('批量失败按相同负载复用幂等键,编辑意见后生成新键', async () => {
const requestIds = []
const queue = useApprovalTaskQueue({
services: {
async fetchApprovalTasks() {
return listResult([queueItem('task-retry')])
},
async batchApproveApprovalTasks(payload) {
requestIds.push(payload.batchRequestId)
throw new Error('模拟网络中断')
}
}
})
await queue.loadQueue()
queue.toggleCurrentPage(true)
await assert.rejects(queue.batchApprove({ opinion: '核对通过' }), /模拟网络中断/)
await assert.rejects(queue.batchApprove({ opinion: '核对通过' }), /模拟网络中断/)
await assert.rejects(queue.batchApprove({ opinion: '补充核对后通过' }), /模拟网络中断/)
assert.equal(requestIds[1], requestIds[0])
assert.notEqual(requestIds[2], requestIds[0])
})

View File

@@ -0,0 +1,127 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
createApprovalTaskSelectionState,
reconcileApprovalTaskSelectionAfterBatch,
reconcileApprovalTaskSelectionWithPage,
resolveApprovalTaskSelectionMeta,
resolveCurrentPageApprovalSelection,
syncApprovalTaskSelectionFilters,
toggleApprovalTaskSelection,
toggleCurrentPageApprovalSelection
} from '../src/views/scripts/approvalTaskQueueState.js'
function task(id, overrides = {}) {
return {
id,
status: 'pending',
version: 1,
canAct: true,
availableActions: ['approve'],
batchEligible: true,
...overrides
}
}
test('任务可选性优先使用服务端权限和批量阻断原因', () => {
assert.deepEqual(resolveApprovalTaskSelectionMeta(task('task-001')), {
taskId: 'task-001',
selectable: true,
reason: ''
})
assert.equal(
resolveApprovalTaskSelectionMeta(task('task-002', {
canAct: false,
readOnlyReason: '该任务已委托给李审批'
})).reason,
'该任务已委托给李审批'
)
assert.equal(
resolveApprovalTaskSelectionMeta(task('task-003', {
batchEligible: false,
batchBlockReasons: ['存在开放高风险', '审批意见必须单独填写']
})).reason,
'存在开放高风险;审批意见必须单独填写'
)
assert.equal(
resolveApprovalTaskSelectionMeta(task('task-004', {
availableActions: ['delegate']
})).selectable,
false
)
})
test('当前页全选和半选只处理服务端允许批量审批的任务', () => {
const rows = [
task('task-001'),
task('task-002'),
task('task-003', { batchEligible: false, batchBlockReasons: ['需单独核对'] })
]
let selected = toggleApprovalTaskSelection([], rows[0], true)
let pageState = resolveCurrentPageApprovalSelection(rows, selected)
assert.deepEqual(selected, ['task-001'])
assert.equal(pageState.indeterminate, true)
assert.equal(pageState.allSelected, false)
selected = toggleCurrentPageApprovalSelection(selected, rows, true)
pageState = resolveCurrentPageApprovalSelection(rows, selected)
assert.deepEqual(selected, ['task-001', 'task-002'])
assert.equal(pageState.allSelected, true)
assert.equal(pageState.eligibleCount, 2)
selected = toggleCurrentPageApprovalSelection(selected, rows, false)
assert.deepEqual(selected, [])
})
test('刷新后任务失去权限时从当前页选择中移除', () => {
const selected = ['off-page-task', 'task-001', 'task-002']
const rows = [
task('task-001'),
task('task-002', { canAct: false, readOnlyReason: '任务已转交' })
]
assert.deepEqual(
reconcileApprovalTaskSelectionWithPage(selected, rows),
['off-page-task', 'task-001']
)
})
test('筛选变化清空选择但翻页和每页条数变化不清空', () => {
let state = createApprovalTaskSelectionState({
selectedTaskIds: ['task-001', 'task-002'],
filters: { status: 'pending', riskLevel: 'high', page: 1, pageSize: 20 }
})
state = syncApprovalTaskSelectionFilters(state, {
status: 'pending',
riskLevel: 'high',
page: 2,
pageSize: 50
})
assert.deepEqual(state.selectedTaskIds, ['task-001', 'task-002'])
state = syncApprovalTaskSelectionFilters(state, {
status: 'pending',
riskLevel: 'medium',
page: 1,
pageSize: 20
})
assert.deepEqual(state.selectedTaskIds, [])
})
test('批量部分成功只移除成功和幂等重放项,失败项继续保留', () => {
const selected = ['task-001', 'task-002', 'task-003', 'task-004', 'task-005', 'task-unreported']
const result = {
status: 'partial',
items: [
{ taskId: 'task-001', status: 'succeeded' },
{ taskId: 'task-002', status: 'replayed' },
{ taskId: 'task-003', status: 'blocked' },
{ taskId: 'task-004', status: 'conflict' },
{ taskId: 'task-005', status: 'forbidden' }
]
}
assert.deepEqual(
reconcileApprovalTaskSelectionAfterBatch(selected, result),
['task-003', 'task-004', 'task-005', 'task-unreported']
)
})

View File

@@ -0,0 +1,89 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
resolveApprovalTaskActionItems,
resolveApprovalTaskKeyboardCommand,
resolveApprovalTaskRow,
resolveApprovalTaskSlaMeta
} from '../src/views/scripts/approvalTaskQueueViewModel.js'
function task(overrides = {}) {
return {
id: 'task-001',
claimId: 'claim-001',
nodeLabel: '直属领导审批',
canAct: true,
availableActions: ['approve', 'delegate', 'unknown_action'],
riskLevel: 'high',
openRiskCount: 2,
evidenceCompleteness: 0.75,
dueAt: '2026-07-16T12:00:00Z',
escalationLevel: 1,
nextEscalationAt: '2026-07-16T13:00:00Z',
...overrides
}
}
test('动作列表只展示服务端授权且前端认识的动作', () => {
assert.deepEqual(
resolveApprovalTaskActionItems(task()).map((item) => item.action),
['approve', 'delegate']
)
assert.deepEqual(
resolveApprovalTaskActionItems(task({
canAct: false,
availableActions: ['approve', 'return', 'transfer', 'sla_escalate'],
readOnlyReason: '管理员只能管理任务'
})).map((item) => item.action),
['transfer', 'sla_escalate']
)
})
test('SLA 展示使用服务端截止时间和升级等级', () => {
const nearDue = resolveApprovalTaskSlaMeta(task(), Date.parse('2026-07-16T11:00:00Z'))
assert.equal(nearDue.label, '剩余 1.0h')
assert.equal(nearDue.tone, 'warning')
assert.equal(nearDue.escalationLabel, 'L1')
assert.match(nearDue.title, /下次升级2026-07-16T13:00:00Z/)
const overdue = resolveApprovalTaskSlaMeta(task(), Date.parse('2026-07-16T14:00:00Z'))
assert.equal(overdue.label, '超时 2.0h')
assert.equal(overdue.overdue, true)
assert.equal(overdue.tone, 'danger')
})
test('任务行组合风险、证据、单据和只读原因', () => {
const row = resolveApprovalTaskRow({
task: task({
canAct: false,
availableActions: [],
readOnlyReason: '仅原审批人可操作'
}),
claim: {
claim_no: 'BX-2026-001',
employee_name: '张三',
department_name: '销售部',
amount: 1280.5
}
}, Date.parse('2026-07-16T11:00:00Z'))
assert.equal(row.claimNo, 'BX-2026-001')
assert.equal(row.applicant, '张三')
assert.equal(row.riskLabel, '高风险')
assert.equal(row.evidenceLabel, '75%')
assert.equal(row.readOnlyReason, '仅原审批人可操作')
assert.deepEqual(row.actions, [])
})
test('键盘模型支持 Enter、Space、J/K并在输入控件聚焦时禁用', () => {
const rowTarget = { tagName: 'TR', isContentEditable: false }
assert.equal(resolveApprovalTaskKeyboardCommand({ key: 'Enter', target: rowTarget }), 'open')
assert.equal(resolveApprovalTaskKeyboardCommand({ key: ' ', target: rowTarget }), 'toggle')
assert.equal(resolveApprovalTaskKeyboardCommand({ key: 'j', target: rowTarget }), 'next')
assert.equal(resolveApprovalTaskKeyboardCommand({ key: 'K', target: rowTarget }), 'previous')
assert.equal(
resolveApprovalTaskKeyboardCommand({ key: 'j', target: { tagName: 'INPUT' } }),
''
)
})

View File

@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
resolveApprovalTaskRetryKey
} from '../src/views/scripts/approvalTaskRetry.js'
import {
appendApprovalTaskRouteQuery,
readApprovalTaskRouteState
} from '../src/views/scripts/approvalTaskRouteState.js'
test('approval retry key is stable only while the idempotent payload stays identical', () => {
const first = resolveApprovalTaskRetryKey({
action: 'approve',
scopeId: 'task-1',
payload: { reason: '核对通过', expectedTaskVersion: 3 }
})
const samePayload = resolveApprovalTaskRetryKey({
action: 'approve',
scopeId: 'task-1',
payload: { expectedTaskVersion: 3, reason: '核对通过' },
previousFingerprint: first.fingerprint,
previousRequestId: first.requestId
})
const editedPayload = resolveApprovalTaskRetryKey({
action: 'approve',
scopeId: 'task-1',
payload: { expectedTaskVersion: 3, reason: '补充核对后通过' },
previousFingerprint: first.fingerprint,
previousRequestId: first.requestId
})
assert.equal(samePayload.requestId, first.requestId)
assert.notEqual(editedPayload.requestId, first.requestId)
})
test('approval task route state restores only supported filters and compact defaults', () => {
const state = readApprovalTaskRouteState({
dc_review_page: '3',
dc_review_page_size: '10',
dc_review_q: ' REIM-100 ',
dc_review_risk: 'high',
dc_review_sla: 'overdue'
})
assert.deepEqual(state, {
page: 3,
pageSize: 10,
filters: { keyword: 'REIM-100', riskLevel: 'high', slaState: 'overdue' }
})
assert.deepEqual(appendApprovalTaskRouteQuery({}, state), {
dc_review_page: '3',
dc_review_page_size: '10',
dc_review_q: 'REIM-100',
dc_review_risk: 'high',
dc_review_sla: 'overdue'
})
assert.deepEqual(readApprovalTaskRouteState({
dc_review_page: '-1',
dc_review_page_size: '200',
dc_review_risk: 'unknown',
dc_review_sla: 'near_due'
}), {
page: 1,
pageSize: 20,
filters: { keyword: '', riskLevel: '', slaState: '' }
})
})

View File

@@ -0,0 +1,44 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readSourceFile } from './helpers/sourceSurface.mjs'
const workspace = readSourceFile('components/approval/ApprovalTaskWorkspace.vue')
const documentsCenter = readSourceFile('views/DocumentsCenterView.vue')
const appShell = readSourceFile('views/AppShellRouteView.vue')
test('approval task workspace wires every server-authorized action to a real mutation', () => {
assert.match(workspace, /approveApprovalTask/)
assert.match(workspace, /returnApprovalTask/)
assert.match(workspace, /escalateApprovalTask/)
assert.match(workspace, /<ApprovalAssignmentDialog/)
assert.match(workspace, /<ApprovalParticipantsDialog/)
assert.match(workspace, /<ReturnReasonDialog/)
assert.doesNotMatch(workspace, /已批量通过 23 条|mock/i)
})
test('approval task decisions keep one request id across retries and enforce task version', () => {
assert.match(workspace, /resolveApprovalTaskRetryKey/)
assert.match(workspace, /previousFingerprint: requestFingerprint\.value/)
assert.match(workspace, /previousRequestId: requestId\.value/)
assert.match(workspace, /payload\.requestId = retryKey\.requestId/)
assert.match(workspace, /expectedTaskVersion: Number\(activeTask\.value\.version\)/)
assert.match(workspace, /mutation\?\.replayed[\s\S]*结果已安全重放/)
assert.match(workspace, /await queueRef\.value\?\.reload\?\.\(\)/)
})
test('documents review tab owns the task workspace and propagates updates to the app shell', () => {
assert.match(documentsCenter, /<ApprovalTaskWorkspace[\s\S]*v-show="activeScopeTab === DOCUMENT_SCOPE_REVIEW"/)
assert.match(documentsCenter, /@request-updated="emit\('request-updated', \$event\)"/)
assert.match(documentsCenter, /@loaded="handleApprovalTasksLoaded"/)
assert.doesNotMatch(documentsCenter, /fetchApprovalExpenseClaims|approvalRows/)
assert.match(appShell, /<DocumentsCenterView[\s\S]*@request-updated="handleRequestUpdated"/)
})
test('workspace separates the unfiltered pending summary from queue filters and refreshes batches', () => {
assert.match(workspace, /service\(\{ status: 'pending', page: 1, pageSize: 1 \}\)/)
assert.match(workspace, /@loaded="handleQueueLoaded"/)
assert.match(workspace, /void loadTaskSummary\(\)[\s\S]*emit\('batch-result', result\)/)
assert.match(workspace, /emit\('request-updated', \{ claim: claims\[0\], claimId: claimIds\[0\], claimIds \}\)/)
assert.match(workspace, /const workspaceError = computed\(\(\) => queueError\.value \|\| summaryError\.value\)/)
})

View File

@@ -0,0 +1,233 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
addSignApprovalTask,
approveApprovalTask,
batchApproveApprovalTasks,
countersignApprovalTask,
delegateApprovalTask,
escalateApprovalTask,
fetchApprovalTaskCandidates,
fetchApprovalTaskDetail,
fetchApprovalTasks,
returnApprovalTask,
revokeApprovalTaskDelegation,
transferApprovalTask
} from '../src/services/approvalTasks.js'
function jsonResponse(payload) {
return {
ok: true,
status: 200,
async json() {
return payload
}
}
}
function pendingTask(overrides = {}) {
return {
id: 'task-001',
claim_id: 'claim-001',
status: 'pending',
version: 3,
can_act: true,
available_actions: ['approve', 'delegate'],
batch_eligible: true,
claim_status_snapshot: 'submitted',
claim_stage_snapshot: '直属领导审批',
priority_reasons_json: [{ code: 'sla_near_due', label: '即将超时', weight: 16 }],
...overrides
}
}
test('审批任务列表发送服务端分页筛选参数并规范化任务', async () => {
let capturedUrl = ''
global.fetch = async (url) => {
capturedUrl = String(url)
return jsonResponse({
items: [{ task: pendingTask(), claim: { id: 'claim-001', claim_no: 'BX-001' } }],
total: 41,
page: 2,
page_size: 25,
total_pages: 2,
generated_at: '2026-07-16T10:00:00Z'
})
}
const result = await fetchApprovalTasks({
page: 2,
pageSize: 25,
status: 'pending',
riskLevel: 'high',
slaState: 'overdue',
nodeKey: 'direct_manager',
keyword: '张 三',
batchEligible: true,
sort: 'priority_desc'
})
const url = new URL(capturedUrl, 'http://local.test')
assert.equal(url.pathname, '/api/v1/approval-tasks')
assert.equal(url.searchParams.get('page'), '2')
assert.equal(url.searchParams.get('page_size'), '25')
assert.equal(url.searchParams.get('status'), 'pending')
assert.equal(url.searchParams.get('risk_level'), 'high')
assert.equal(url.searchParams.get('sla_state'), 'overdue')
assert.equal(url.searchParams.get('node_key'), 'direct_manager')
assert.equal(url.searchParams.get('keyword'), '张 三')
assert.equal(url.searchParams.get('batch_eligible'), 'true')
assert.equal(url.searchParams.get('sort'), 'priority_desc')
assert.equal(result.total, 41)
assert.equal(result.pageSize, 25)
assert.equal(result.items[0].task.claimId, 'claim-001')
assert.equal(result.items[0].task.priorityReasons[0].code, 'sla_near_due')
assert.equal(result.items[0].claim.claim_no, 'BX-001')
})
test('审批任务详情和候选人使用任务作用域接口', async () => {
const calls = []
global.fetch = async (url) => {
calls.push(String(url))
if (String(url).includes('/candidates?')) {
return jsonResponse({
items: [{
employee_id: 'employee-002',
employee_no: 'E002',
name: '李审批',
email: 'li@example.com',
qualified: false,
reason: '不属于当前部门'
}],
total: 1
})
}
return jsonResponse({ task: pendingTask(), claim: { id: 'claim-001' } })
}
const detail = await fetchApprovalTaskDetail('task/001')
const candidates = await fetchApprovalTaskCandidates('task/001', {
action: 'delegate',
keyword: '李 审批',
limit: 30
})
assert.equal(calls[0], '/api/v1/approval-tasks/task%2F001')
const candidateUrl = new URL(calls[1], 'http://local.test')
assert.equal(candidateUrl.pathname, '/api/v1/approval-tasks/task%2F001/candidates')
assert.equal(candidateUrl.searchParams.get('action'), 'delegate')
assert.equal(candidateUrl.searchParams.get('keyword'), '李 审批')
assert.equal(candidateUrl.searchParams.get('limit'), '30')
assert.equal(detail.task.id, 'task-001')
assert.equal(candidates.items[0].qualified, false)
assert.equal(candidates.items[0].reason, '不属于当前部门')
})
test('所有审批动作携带请求幂等键和任务版本', async () => {
const calls = []
global.fetch = async (url, options) => {
calls.push({ url: String(url), body: JSON.parse(options.body) })
return jsonResponse({ task: pendingTask({ version: 4 }), related_tasks: [], replayed: false })
}
const base = {
requestId: 'request-action-001',
expectedTaskVersion: 3,
reason: '业务审批处理'
}
await delegateApprovalTask('task-001', {
...base,
targetEmployeeId: 'employee-002',
expiresAt: '2026-07-18T10:00:00Z'
})
await revokeApprovalTaskDelegation('task-001', base)
await transferApprovalTask('task-001', { ...base, targetEmployeeId: 'employee-003' })
await addSignApprovalTask('task-001', {
...base,
participantEmployeeIds: ['employee-004', 'employee-005', 'employee-004']
})
await countersignApprovalTask('task-001', {
...base,
participantEmployeeIds: ['employee-006', 'employee-007']
})
await escalateApprovalTask('task-001', base)
await approveApprovalTask('task-001', { ...base, opinion: '同意通过' })
await returnApprovalTask('task-001', {
...base,
reasonCodes: ['missing_invoice', 'missing_invoice', 'amount_mismatch']
})
assert.deepEqual(calls.map((call) => call.url), [
'/api/v1/approval-tasks/task-001/delegate',
'/api/v1/approval-tasks/task-001/delegation/revoke',
'/api/v1/approval-tasks/task-001/transfer',
'/api/v1/approval-tasks/task-001/add-sign',
'/api/v1/approval-tasks/task-001/countersign',
'/api/v1/approval-tasks/task-001/escalate',
'/api/v1/approval-tasks/task-001/approve',
'/api/v1/approval-tasks/task-001/return'
])
calls.forEach((call) => {
assert.equal(call.body.request_id, 'request-action-001')
assert.equal(call.body.expected_task_version, 3)
assert.equal(call.body.reason, '业务审批处理')
})
assert.equal(calls[0].body.target_employee_id, 'employee-002')
assert.equal(calls[0].body.expires_at, '2026-07-18T10:00:00Z')
assert.deepEqual(calls[3].body.participant_employee_ids, ['employee-004', 'employee-005'])
assert.equal(calls[6].body.opinion, '同意通过')
assert.deepEqual(calls[7].body.reason_codes, ['missing_invoice', 'amount_mismatch'])
})
test('批量审批发送逐项快照并规范化部分成功结果', async () => {
let capturedUrl = ''
let capturedBody = null
global.fetch = async (url, options) => {
capturedUrl = String(url)
capturedBody = JSON.parse(options.body)
return jsonResponse({
batch_request_id: 'batch-request-001',
status: 'partial',
succeeded_count: 1,
blocked_count: 1,
conflict_count: 1,
items: [
{ task_id: 'task-001', claim_id: 'claim-001', status: 'succeeded', code: 'OK', message: '已通过' },
{ task_id: 'task-002', claim_id: 'claim-002', status: 'blocked', code: 'OPEN_RISK', message: '存在开放高风险' },
{ task_id: 'task-003', claim_id: 'claim-003', status: 'conflict', code: 'STALE_TASK', message: '任务已更新' }
]
})
}
const result = await batchApproveApprovalTasks({
batchRequestId: 'batch-request-001',
items: [
pendingTask({ id: 'task-001' }),
pendingTask({ id: 'task-002', version: 5 }),
{
taskId: 'task-003',
expectedTaskVersion: 2,
expectedStatus: 'submitted',
expectedApprovalStage: '财务审批',
opinion: '票据已核对'
}
]
})
assert.equal(capturedUrl, '/api/v1/approval-tasks/batch-approve')
assert.equal(capturedBody.batch_request_id, 'batch-request-001')
assert.deepEqual(capturedBody.items[0], {
task_id: 'task-001',
expected_task_version: 3,
expected_status: 'submitted',
expected_approval_stage: '直属领导审批'
})
assert.equal(capturedBody.items[1].expected_task_version, 5)
assert.equal(capturedBody.items[2].opinion, '票据已核对')
assert.equal(result.status, 'partial')
assert.equal(result.succeededCount, 1)
assert.equal(result.blockedCount, 1)
assert.equal(result.conflictCount, 1)
assert.equal(result.items[1].code, 'OPEN_RISK')
})

View File

@@ -14,6 +14,9 @@ const documentListSharedStyles = readSourceFile('assets/styles/components/docume
const tableLoadingState = readSourceFile('components/shared/TableLoadingState.vue')
const reimbursementService = readSourceFile('services/reimbursements.js')
const requestsComposable = readSourceFile('composables/useRequests.js')
const approvalTaskQueue = readSourceFile('components/approval/ApprovalTaskQueue.vue')
const approvalTaskService = readSourceFile('services/approvalTasks.js')
const archiveRowsComposable = readSourceFile('composables/useDocumentCenterArchiveRows.js')
test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => {
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
@@ -69,14 +72,17 @@ test('documents center persists pagination and filters in route query for detail
)
assert.match(
documentsCenterView,
/watch\(\s*\[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd\],[\s\S]*router\.replace\(\{ name: 'app-documents', query: nextQuery \}\)/
/watch\(\s*\[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd, approvalTaskRouteState\],[\s\S]*router\.replace\(\{ name: 'app-documents', query: nextQuery \}\)/
)
assert.match(documentsCenterView, /readApprovalTaskRouteState\(route\.query\)/)
assert.match(documentsCenterView, /appendApprovalTaskRouteQuery\(nextQuery, approvalTaskRouteState\.value\)/)
})
test('documents center category tabs map to the intended row sources', () => {
test('documents center keeps owned rows separate and delegates review scope to approval tasks', () => {
assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(ownedRows\.value\)\)/)
assert.match(documentsCenterView, /<ApprovalTaskWorkspace[\s\S]*v-show="activeScopeTab === DOCUMENT_SCOPE_REVIEW"/)
assert.doesNotMatch(documentsCenterView, /fetchApprovalExpenseClaims|approvalRows/)
assert.match(documentsCenterLogic, /import \{ sortDocumentRowsByLatestTime \} from '\.\/documentCenterSort\.js'/)
assert.match(documentsCenterLogic, /activeScopeTab !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/)
assert.match(
@@ -91,10 +97,6 @@ test('documents center category tabs map to the intended row sources', () => {
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*ownedRows\.value\.filter/
)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_REVIEW[\s\S]*return approvalRows\.value/
)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_ARCHIVE[\s\S]*return archiveRows\.value/
@@ -134,8 +136,9 @@ test('documents center preserves application document type from mapped requests'
)
})
test('documents center refresh token reloads supporting approval and archive rows', () => {
test('documents center refresh token reloads the task workspace and archive rows', () => {
assert.match(documentsCenterView, /refreshToken:\s*\{\s*type:\s*Number,\s*default:\s*0\s*\}/)
assert.match(documentsCenterView, /<ApprovalTaskWorkspace[\s\S]*:refresh-token="refreshToken"/)
assert.match(
documentsCenterView,
/watch\(\s*\(\) => props\.refreshToken,[\s\S]*if \(token && token !== previousToken\) \{[\s\S]*void loadSupportingRows\(\)/
@@ -143,15 +146,16 @@ test('documents center refresh token reloads supporting approval and archive row
assert.match(documentsCenterView, /function reloadAll\(\) \{[\s\S]*emit\('reload'\)[\s\S]*void loadSupportingRows\(\)/)
})
test('documents center fetches every paginated claim page for admin-scale lists', () => {
test('documents center review scope uses server pagination while archive stays a bounded preview', () => {
assert.match(reimbursementService, /export function fetchAllExpenseClaims/)
assert.match(reimbursementService, /async function fetchAllExpenseClaimPages/)
assert.match(reimbursementService, /payload\.has_next/)
assert.match(requestsComposable, /import \{ fetchAllExpenseClaims \} from '\.\.\/services\/reimbursements\.js'/)
assert.match(requestsComposable, /const payload = await fetchAllExpenseClaims\(\)/)
assert.match(documentsCenterView, /fetchAllApprovalExpenseClaims/)
assert.match(documentsCenterView, /fetchAllArchivedExpenseClaims/)
assert.doesNotMatch(documentsCenterView, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
assert.match(approvalTaskService, /search\.set\('page'/)
assert.match(approvalTaskService, /search\.set\([\s\S]*'page_size'/)
assert.match(approvalTaskQueue, /<EnterprisePagination/)
assert.match(archiveRowsComposable, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(documentsCenterView, /fetchApprovalExpenseClaims/)
assert.match(requestsComposable, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
})
test('documents center list shows created time and conditional stay time columns', () => {
@@ -164,10 +168,7 @@ test('documents center list shows created time and conditional stay time columns
assert.match(documentsCenterView, /<td data-label="创建时间">\{\{ row\.createdAtDisplay \}\}<\/td>/)
assert.match(documentsCenterView, /<td v-if="showStayTimeColumn" data-label="停留时间">\{\{ row\.stayTimeDisplay \}\}<\/td>/)
assert.match(documentsCenterView, /<td data-label="发起人">\{\{ row\.initiatorName \}\}<\/td>/)
assert.match(
documentsCenterView,
/const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/
)
assert.match(documentsCenterView, /const showStayTimeColumn = computed\(\(\) => activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION\)/)
assert.match(documentsCenterLogic, /createdAtDisplay: formatDocumentListTime\(createdAtSource\)/)
assert.match(documentsCenterLogic, /stayTimeDisplay: resolveDocumentStayTimeDisplay\(normalized\)/)
assert.match(documentsCenterLogic, /initiatorName,/)
@@ -212,7 +213,8 @@ test('documents center category tabs render bubble counts for new documents', ()
documentsCenterView,
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: countNewDocuments\(ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\), viewedDocumentKeys\.value\)/
)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: countNewDocuments\(approvalRows\.value, viewedDocumentKeys\.value\)/)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: approvalTaskTotal\.value/)
assert.match(documentsCenterView, /badgeLabel: tab === DOCUMENT_SCOPE_REVIEW \? '待处理审批任务数' : '新增单据数'/)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: countNewDocuments\(archiveRows\.value, viewedDocumentKeys\.value\)/)
assert.match(
documentsCenterView,
@@ -233,8 +235,9 @@ test('documents center can mark all unread documents as read from toolbar', () =
assert.match(documentsCenterView, /mergeNotificationStatesIntoViewedDocumentKeys/)
assert.match(
documentsCenterView,
/const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*approvalRows\.value/
/const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*DOCUMENT_TYPE_REIMBURSEMENT/
)
assert.doesNotMatch(documentsCenterView, /approvalRows/)
assert.match(documentsCenterView, /const totalNewDocumentCount = computed\(\(\) => countNewDocuments\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)\)/)
assert.match(documentsCenterView, /const showToolbarActions = computed\(\(\) => showCreateDocumentActions\.value \|\| totalNewDocumentCount\.value > 0\)/)
assert.match(

View File

@@ -1,18 +1,17 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
const root = process.cwd()
import { fileURLToPath } from 'node:url'
function readProjectFile(path) {
return readFileSync(join(root, path), 'utf8')
return readFileSync(fileURLToPath(new URL(`../${path.replace(/^web\//, '')}`, import.meta.url)), 'utf8')
}
test('workbench and document list refreshes use preview pagination', () => {
test('claim previews stay bounded while document review uses the approval task workspace', () => {
const useRequests = readProjectFile('web/src/composables/useRequests.js')
const useAppShell = readProjectFile('web/src/composables/useAppShell.js')
const documentsCenter = readProjectFile('web/src/views/DocumentsCenterView.vue')
const archiveRowsComposable = readProjectFile('web/src/composables/useDocumentCenterArchiveRows.js')
const approvalCenter = readProjectFile('web/src/views/scripts/ApprovalCenterView.js')
const archiveCenter = readProjectFile('web/src/views/scripts/ArchiveCenterView.js')
@@ -24,8 +23,9 @@ test('workbench and document list refreshes use preview pagination', () => {
assert.match(useAppShell, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(useAppShell, /fetchAllApprovalExpenseClaims\(\)/)
assert.match(documentsCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(documentsCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(archiveRowsComposable, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(documentsCenter, /<ApprovalTaskWorkspace/)
assert.doesNotMatch(documentsCenter, /fetchApprovalExpenseClaims/)
assert.doesNotMatch(documentsCenter, /fetchAllApprovalExpenseClaims\(\)/)
assert.doesNotMatch(documentsCenter, /fetchAllArchivedExpenseClaims\(\)/)

View File

@@ -28,6 +28,8 @@ test('risk observation normalizes persisted json evidence and typed disposition'
adjudication: 'confirmed',
lifecycle_status: 'remediation_in_progress',
version: 2,
available_actions: ['request_waiver'],
read_only_reason: null,
events: [{ id: 'event-1', action: 'confirm', version: 1 }]
}
})
@@ -37,6 +39,8 @@ test('risk observation normalizes persisted json evidence and typed disposition'
assert.deepEqual(observation.graphNodeKeys, ['claim:1'])
assert.equal(observation.decisionTrace.action, 'manual_review')
assert.equal(observation.disposition.lifecycleStatus, 'remediation_in_progress')
assert.deepEqual(observation.disposition.availableActions, ['request_waiver'])
assert.equal(observation.disposition.readOnlyReason, '')
assert.equal(observation.disposition.events[0].action, 'confirm')
})
@@ -56,8 +60,9 @@ test('risk evidence card exposes typed actions and never offers direct approval'
assert.match(evidenceCard, /确认已解决/)
assert.match(evidenceCard, /expectedVersion: currentDisposition\.value\.version/)
assert.doesNotMatch(evidenceCard, /自动审批|直接通过|batchApprove/)
assert.match(evidenceCard, /RISK_DISPOSITION_VERSION_CONFLICT/)
assert.match(evidenceCard, /lifecycle !== 'supplement_requested'/)
assert.match(evidenceCard, /normalizeRiskDispositionError/)
assert.match(evidenceCard, /currentDisposition\.value\.availableActions/)
assert.doesNotMatch(evidenceCard, /roleCodes|role_codes|isAdmin|is_admin/)
})
test('risk action request ids bind action and observation', () => {

View File

@@ -0,0 +1,215 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
executeRiskDispositionAction,
normalizeRiskDisposition,
normalizeRiskDispositionError,
normalizeRiskObservation
} from '../src/services/riskObservations.js'
const evidenceCard = readFileSync(
fileURLToPath(new URL('../src/components/travel/RiskObservationEvidenceCard.vue', import.meta.url)),
'utf8'
)
const waiverDialog = readFileSync(
fileURLToPath(new URL('../src/components/travel/RiskWaiverActionDialog.vue', import.meta.url)),
'utf8'
)
const waiverRecord = readFileSync(
fileURLToPath(new URL('../src/components/travel/RiskWaiverRecord.vue', import.meta.url)),
'utf8'
)
function jsonResponse(payload) {
return {
ok: true,
status: 200,
async json() {
return payload
}
}
}
test('risk disposition normalizes server-authorized actions and complete waiver audit fields', () => {
const disposition = normalizeRiskDisposition({
id: 'disposition-1',
observation_id: 'risk-1',
lifecycle_status: 'waived',
version: 3,
available_actions: ['approve_waiver', 'reject_waiver', 'approve_waiver'],
read_only_reason: null,
waiver_requester_id: 'employee-requester',
waiver_requester_name: '申请人甲',
waiver_requested_at: '2026-07-16T08:00:00Z',
waiver_reason: '客户现场暂时无法取得原件',
waiver_scope: '仅限本次单据的行程确认材料',
waiver_expires_at: '2026-07-20T08:00:00Z',
waiver_conditions_json: ['三日内补交原件', '到期前复核', '三日内补交原件'],
waiver_decision: 'approved',
waiver_decider_id: 'employee-decider',
waiver_decider_name: '决定人乙',
waiver_decided_at: '2026-07-16T09:00:00Z',
waiver_decision_reason: '补偿控制充分,同意限时豁免',
events: [{
id: 'waiver-event-1',
action: 'request_waiver',
version: 2,
actor_id: 'employee-requester',
actor_name: '申请人甲',
request_id: 'waiver-request-001',
comment: '客户现场暂时无法取得原件',
after_json: { waiver_scope: '仅限本次单据的行程确认材料' },
created_at: '2026-07-16T08:00:00Z'
}]
})
assert.deepEqual(disposition.availableActions, ['approve_waiver', 'reject_waiver'])
assert.equal(disposition.readOnlyReason, '')
assert.equal(disposition.waiverRequesterName, '申请人甲')
assert.equal(disposition.waiverReason, '客户现场暂时无法取得原件')
assert.equal(disposition.waiverScope, '仅限本次单据的行程确认材料')
assert.deepEqual(disposition.waiverConditions, ['三日内补交原件', '到期前复核'])
assert.equal(disposition.waiverDeciderName, '决定人乙')
assert.equal(disposition.waiverDecisionReason, '补偿控制充分,同意限时豁免')
assert.equal(disposition.events[0].actorId, 'employee-requester')
assert.equal(disposition.events[0].requestId, 'waiver-request-001')
assert.equal(
disposition.events[0].afterState.waiver_scope,
'仅限本次单据的行程确认材料'
)
})
test('observation keeps server actions when the first disposition has not been created', () => {
const observation = normalizeRiskDisposition(null)
assert.equal(observation, null)
const normalized = normalizeRiskObservation({
id: 'risk-first-action',
disposition: null,
available_actions: ['confirm', 'false_positive'],
read_only_reason: ''
})
assert.equal(normalized.disposition, null)
assert.deepEqual(normalized.availableActions, ['confirm', 'false_positive'])
assert.equal(normalized.readOnlyReason, '')
})
test('waiver request and decision payloads carry idempotency key and optimistic version', async (t) => {
const originalFetch = globalThis.fetch
const calls = []
t.after(() => {
globalThis.fetch = originalFetch
})
globalThis.fetch = async (url, options) => {
calls.push({ url: String(url), body: JSON.parse(options.body) })
return jsonResponse({
disposition: {
id: 'disposition-1',
observation_id: 'risk/1',
version: calls.length,
available_actions: []
},
event: { id: `event-${calls.length}` },
replayed: false
})
}
await executeRiskDispositionAction('risk/1', {
action: 'request_waiver',
expectedVersion: 2,
requestId: 'risk-request-waiver-001',
waiverReason: '材料暂缺',
waiverScope: '仅限当前单据',
waiverExpiresAt: '2026-07-20T08:00:00.000Z',
waiverConditions: ['三日内补件', '三日内补件', '', '财务复核']
})
await executeRiskDispositionAction('risk/1', {
action: 'approve_waiver',
expectedVersion: 3,
requestId: 'risk-approve-waiver-001',
comment: '补偿控制充分,同意限时豁免'
})
await executeRiskDispositionAction('risk/1', {
action: 'reject_waiver',
expectedVersion: 3,
requestId: 'risk-reject-waiver-001',
comment: '补偿控制不足,拒绝豁免'
})
assert.equal(calls[0].url, '/api/v1/risk-observations/risk%2F1/disposition/actions')
assert.deepEqual(calls[0].body, {
action: 'request_waiver',
expected_version: 2,
request_id: 'risk-request-waiver-001',
waiver_reason: '材料暂缺',
waiver_scope: '仅限当前单据',
waiver_expires_at: '2026-07-20T08:00:00.000Z',
waiver_conditions: ['三日内补件', '财务复核']
})
assert.deepEqual(calls[1].body, {
action: 'approve_waiver',
expected_version: 3,
request_id: 'risk-approve-waiver-001',
comment: '补偿控制充分,同意限时豁免'
})
assert.deepEqual(calls[2].body, {
action: 'reject_waiver',
expected_version: 3,
request_id: 'risk-reject-waiver-001',
comment: '补偿控制不足,拒绝豁免'
})
})
test('waiver errors are presented by conflict, permission, validation and timeout type', () => {
assert.deepEqual(
normalizeRiskDispositionError({
code: 'RISK_DISPOSITION_VERSION_CONFLICT',
message: '状态已变化'
}),
{
code: 'RISK_DISPOSITION_VERSION_CONFLICT',
title: '风险状态已更新',
message: '状态已变化',
shouldRefresh: true
}
)
assert.equal(
normalizeRiskDispositionError({ code: 'RISK_WAIVER_DECISION_FORBIDDEN' }).title,
'当前账号不能执行此操作'
)
assert.equal(normalizeRiskDispositionError({ status: 422 }).code, 'RISK_DISPOSITION_VALIDATION_FAILED')
assert.equal(normalizeRiskDispositionError({ code: 'REQUEST_TIMEOUT' }).shouldRefresh, true)
})
test('waiver UI is server-action driven and makes all three confirmations explicit', () => {
assert.match(evidenceCard, /currentDisposition\.value\.availableActions/)
assert.match(evidenceCard, /currentDisposition\.readOnlyReason/)
assert.match(evidenceCard, /批准豁免/)
assert.match(evidenceCard, /拒绝豁免/)
assert.match(evidenceCard, /风险豁免已拒绝,仍会阻断审批/)
assert.match(evidenceCard, /waiverExpiresAt/)
assert.match(evidenceCard, /scheduleWaiverExpiryRefresh/)
assert.match(evidenceCard, /onBeforeUnmount\(clearWaiverExpiryTimer\)/)
assert.doesNotMatch(evidenceCard, /roleCodes|role_codes|isAdmin|is_admin/)
assert.match(waiverDialog, /确认提交豁免申请/)
assert.match(waiverDialog, /确认批准豁免/)
assert.match(waiverDialog, /确认拒绝豁免/)
assert.match(waiverDialog, /请填写拒绝理由。/)
assert.match(waiverDialog, /补偿条件最多填写 20 项。/)
assert.match(waiverDialog, /拒绝后该风险仍会阻断审批/)
assert.match(waiverDialog, /:disabled="busy"/)
assert.match(waiverRecord, /申请人/)
assert.match(waiverRecord, /申请理由/)
assert.match(waiverRecord, /豁免范围/)
assert.match(waiverRecord, /有效期至/)
assert.match(waiverRecord, /补偿条件/)
assert.match(waiverRecord, /决定记录/)
assert.match(waiverRecord, /waiverDecisionReason/)
assert.match(waiverRecord, /豁免审计轨迹/)
assert.match(waiverRecord, /event\.requestId/)
})