feat(approval): add task workflow and waiver decisions
This commit is contained in:
48
web/tests/approval-task-components-compile.test.mjs
Normal file
48
web/tests/approval-task-components-compile.test.mjs
Normal 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, [])
|
||||
}
|
||||
})
|
||||
}
|
||||
201
web/tests/approval-task-queue-composable.test.mjs
Normal file
201
web/tests/approval-task-queue-composable.test.mjs
Normal 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])
|
||||
})
|
||||
127
web/tests/approval-task-queue-state.test.mjs
Normal file
127
web/tests/approval-task-queue-state.test.mjs
Normal 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']
|
||||
)
|
||||
})
|
||||
89
web/tests/approval-task-queue-view-model.test.mjs
Normal file
89
web/tests/approval-task-queue-view-model.test.mjs
Normal 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' } }),
|
||||
''
|
||||
)
|
||||
})
|
||||
67
web/tests/approval-task-retry-and-route.test.mjs
Normal file
67
web/tests/approval-task-retry-and-route.test.mjs
Normal 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: '' }
|
||||
})
|
||||
})
|
||||
44
web/tests/approval-task-workspace.test.mjs
Normal file
44
web/tests/approval-task-workspace.test.mjs
Normal 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\)/)
|
||||
})
|
||||
233
web/tests/approval-tasks-service.test.mjs
Normal file
233
web/tests/approval-tasks-service.test.mjs
Normal 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')
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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\(\)/)
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
215
web/tests/risk-waiver-flow.test.mjs
Normal file
215
web/tests/risk-waiver-flow.test.mjs
Normal 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/)
|
||||
})
|
||||
Reference in New Issue
Block a user