feat(approval): add task workflow and waiver decisions
This commit is contained in:
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