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,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/)
})