feat(approval): add safe risk disposition workflow
This commit is contained in:
@@ -29,7 +29,7 @@ test('approval center reuses reimbursement detail view instead of its old detail
|
||||
assert.doesNotMatch(approvalTemplate, /<ConfirmDialog/)
|
||||
|
||||
assert.match(approvalScript, /import TravelRequestDetailView from '\.\.\/TravelRequestDetailView\.vue'/)
|
||||
assert.match(approvalScript, /fetchApprovalExpenseClaims/)
|
||||
assert.match(approvalScript, /fetchApprovalWorkbenchItems/)
|
||||
assert.doesNotMatch(approvalScript, /fetchExpenseClaims/)
|
||||
assert.doesNotMatch(approvalScript, /import ConfirmDialog/)
|
||||
assert.doesNotMatch(approvalScript, /import ReturnReasonDialog/)
|
||||
|
||||
66
web/tests/approval-workbench.test.mjs
Normal file
66
web/tests/approval-workbench.test.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { normalizeApprovalWorkbenchItem } from '../src/services/approvalWorkbench.js'
|
||||
|
||||
const approvalTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/ApprovalCenterView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const approvalScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/ApprovalCenterView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('approval workbench normalizes score, evidence and advisory suggestion', () => {
|
||||
const item = normalizeApprovalWorkbenchItem({
|
||||
task_key: 'claim-1:manager',
|
||||
claim: { id: 'claim-1', claim_no: 'RE-001' },
|
||||
priority_score: 138,
|
||||
priority_tier: 'urgent',
|
||||
priority_reasons: [{ code: 'open_risk', label: '高风险待复核', weight: 30 }],
|
||||
risk_level: 'high',
|
||||
waiting_hours: 26.25,
|
||||
sla_overdue: true,
|
||||
evidence: {
|
||||
completeness: 0.5,
|
||||
present_count: 1,
|
||||
required_count: 2,
|
||||
missing_labels: ['发票'],
|
||||
historical_labels: ['历史已确认,仅供复核']
|
||||
},
|
||||
suggestion: {
|
||||
action: 'manual_review',
|
||||
label: '人工复核风险',
|
||||
reason: '存在开放风险',
|
||||
advisory_only: true
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(item.priorityScore, 100)
|
||||
assert.equal(item.priorityReasons[0].label, '高风险待复核')
|
||||
assert.deepEqual(item.evidence.missingLabels, ['发票'])
|
||||
assert.deepEqual(item.evidence.historicalLabels, ['历史已确认,仅供复核'])
|
||||
assert.equal(item.suggestion.advisoryOnly, true)
|
||||
})
|
||||
|
||||
test('approval center renders explainable priority, evidence and advisory columns', () => {
|
||||
assert.match(approvalTemplate, />优先级</)
|
||||
assert.match(approvalTemplate, />证据</)
|
||||
assert.match(approvalTemplate, />AI建议</)
|
||||
assert.match(approvalTemplate, /row\.priorityTitle/)
|
||||
assert.match(approvalTemplate, /row\.evidenceTitle/)
|
||||
assert.match(approvalTemplate, /row\.suggestionReason/)
|
||||
assert.match(approvalScript, /fetchApprovalWorkbenchItems\(\{ limit: 100 \}\)/)
|
||||
assert.match(approvalScript, /listPendingApprovalRequests/)
|
||||
assert.doesNotMatch(approvalScript, /autoApprove|batchApprove|批量通过/)
|
||||
})
|
||||
|
||||
test('approval center keeps backend status separate from display status', () => {
|
||||
assert.match(approvalScript, /statusLabel: statusTone === 'urgent'/)
|
||||
assert.doesNotMatch(approvalScript, /status: statusTone === 'urgent'/)
|
||||
assert.match(approvalTemplate, /row\.statusLabel/)
|
||||
assert.doesNotMatch(approvalTemplate, /\{\{ row\.status \}\}/)
|
||||
})
|
||||
37
web/tests/expense-claim-action-protocol.test.mjs
Normal file
37
web/tests/expense-claim-action-protocol.test.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const service = fs.readFileSync('web/src/services/reimbursements.js', 'utf8')
|
||||
const approvalFlow = fs.readFileSync(
|
||||
'web/src/views/scripts/useTravelRequestDetailApprovalFlow.js',
|
||||
'utf8'
|
||||
)
|
||||
const paymentFlow = fs.readFileSync(
|
||||
'web/src/views/scripts/travelRequestDetailPaymentFlow.js',
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('approval actions keep one request id across an uncertain retry', () => {
|
||||
assert.match(service, /createExpenseClaimActionRequestId/)
|
||||
assert.match(approvalFlow, /const returnRequestId = ref\(''\)/)
|
||||
assert.match(approvalFlow, /const approveRequestId = ref\(''\)/)
|
||||
assert.match(approvalFlow, /request_id: returnRequestId\.value/)
|
||||
assert.match(approvalFlow, /request_id: approveRequestId\.value/)
|
||||
assert.doesNotMatch(
|
||||
approvalFlow.match(/async function confirmApproveRequest\(\)[\s\S]*?\n }/)?.[0] || '',
|
||||
/createExpenseClaimActionRequestId/
|
||||
)
|
||||
})
|
||||
|
||||
test('approve return and pay send optimistic status and stage preconditions', () => {
|
||||
assert.match(approvalFlow, /returnExpectedStatus\.value = String\(request\.value\.status/)
|
||||
assert.match(approvalFlow, /expected_status: returnExpectedStatus\.value/)
|
||||
assert.match(approvalFlow, /expected_approval_stage: approveExpectedStage\.value/)
|
||||
assert.match(paymentFlow, /const payRequestId = ref\(''\)/)
|
||||
assert.match(paymentFlow, /payExpenseClaim\(request\.value\.claimId, \{/)
|
||||
assert.match(paymentFlow, /request_id: payRequestId\.value/)
|
||||
assert.match(paymentFlow, /payExpectedStatus\.value = String\(request\.value\.status/)
|
||||
assert.match(paymentFlow, /expected_status: payExpectedStatus\.value/)
|
||||
assert.match(paymentFlow, /expected_approval_stage: payExpectedStage\.value/)
|
||||
})
|
||||
@@ -29,8 +29,8 @@ test('workbench and document list refreshes use preview pagination', () => {
|
||||
assert.doesNotMatch(documentsCenter, /fetchAllApprovalExpenseClaims\(\)/)
|
||||
assert.doesNotMatch(documentsCenter, /fetchAllArchivedExpenseClaims\(\)/)
|
||||
|
||||
assert.match(approvalCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.doesNotMatch(approvalCenter, /fetchApprovalExpenseClaims\(\)/)
|
||||
assert.match(approvalCenter, /fetchApprovalWorkbenchItems\(\{ limit: 100 \}\)/)
|
||||
assert.doesNotMatch(approvalCenter, /fetchApprovalExpenseClaims/)
|
||||
|
||||
assert.match(archiveCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.doesNotMatch(archiveCenter, /fetchArchivedExpenseClaims\(\)/)
|
||||
|
||||
66
web/tests/risk-disposition-ui.test.mjs
Normal file
66
web/tests/risk-disposition-ui.test.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
createRiskDispositionRequestId,
|
||||
normalizeRiskDisposition,
|
||||
normalizeRiskObservation
|
||||
} from '../src/services/riskObservations.js'
|
||||
|
||||
const evidenceCard = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/RiskObservationEvidenceCard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('risk observation normalizes persisted json evidence and typed disposition', () => {
|
||||
const observation = normalizeRiskObservation({
|
||||
id: 'risk-1',
|
||||
contribution_scores_json: { S_rule: 90 },
|
||||
evidence_json: [{ title: '重复票据' }],
|
||||
graph_node_keys_json: ['claim:1'],
|
||||
similar_case_claim_ids_json: ['redacted-for-reviewer-only'],
|
||||
decision_trace_json: { action: 'manual_review' },
|
||||
disposition: {
|
||||
id: 'disposition-1',
|
||||
observation_id: 'risk-1',
|
||||
adjudication: 'confirmed',
|
||||
lifecycle_status: 'remediation_in_progress',
|
||||
version: 2,
|
||||
events: [{ id: 'event-1', action: 'confirm', version: 1 }]
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(observation.contributionScores.S_rule, 90)
|
||||
assert.equal(observation.evidence[0].title, '重复票据')
|
||||
assert.deepEqual(observation.graphNodeKeys, ['claim:1'])
|
||||
assert.equal(observation.decisionTrace.action, 'manual_review')
|
||||
assert.equal(observation.disposition.lifecycleStatus, 'remediation_in_progress')
|
||||
assert.equal(observation.disposition.events[0].action, 'confirm')
|
||||
})
|
||||
|
||||
test('risk disposition normalizer rejects absent projection and keeps version', () => {
|
||||
assert.equal(normalizeRiskDisposition(null), null)
|
||||
assert.equal(normalizeRiskDisposition({}), null)
|
||||
assert.equal(normalizeRiskDisposition({ id: 'd1', version: 4 }).version, 4)
|
||||
})
|
||||
|
||||
test('risk evidence card exposes typed actions and never offers direct approval', () => {
|
||||
assert.match(evidenceCard, /executeRiskDispositionAction/)
|
||||
assert.match(evidenceCard, /确认风险/)
|
||||
assert.match(evidenceCard, /标记误报/)
|
||||
assert.match(evidenceCard, /请求补材料/)
|
||||
assert.match(evidenceCard, /启动整改/)
|
||||
assert.match(evidenceCard, /申请豁免/)
|
||||
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'/)
|
||||
})
|
||||
|
||||
test('risk action request ids bind action and observation', () => {
|
||||
const requestId = createRiskDispositionRequestId('confirm', 'risk-1')
|
||||
assert.match(requestId, /^risk:confirm:risk-1:/)
|
||||
})
|
||||
@@ -1928,7 +1928,9 @@ test('return reason dialog is wired into approval and detail return actions', ()
|
||||
assert.match(detailViewTemplate, /<TravelRequestReturnDialog/)
|
||||
assert.match(detailViewTemplate, /:application="isApplicationDocument"/)
|
||||
assert.doesNotMatch(approvalCenterScript, /returnExpenseClaim/)
|
||||
assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, payload\)/)
|
||||
assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, \{/)
|
||||
assert.match(detailViewScript, /request_id: returnRequestId\.value/)
|
||||
assert.match(detailViewScript, /expected_status: returnExpectedStatus\.value/)
|
||||
assert.doesNotMatch(approvalCenterScript, /审批中心退回/)
|
||||
assert.doesNotMatch(detailViewScript, /详情页退回/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user