feat(approval): add safe risk disposition workflow

This commit is contained in:
caoxiaozhu
2026-07-16 15:34:58 +08:00
parent ee88a36baf
commit 4940ebc419
64 changed files with 6602 additions and 631 deletions

View File

@@ -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/)

View 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 \}\}/)
})

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

View File

@@ -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\(\)/)

View 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:/)
})

View File

@@ -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, /详情页退回/)
})