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

@@ -0,0 +1,66 @@
import { apiRequest } from './api.js'
function toNumber(value, fallback = 0) {
const number = Number(value)
return Number.isFinite(number) ? number : fallback
}
function toArray(value) {
return Array.isArray(value) ? value : []
}
export function normalizeApprovalWorkbenchItem(item = {}) {
const evidence = item.evidence && typeof item.evidence === 'object' ? item.evidence : {}
const suggestion = item.suggestion && typeof item.suggestion === 'object' ? item.suggestion : {}
return {
taskKey: String(item.task_key || item.taskKey || '').trim(),
claim: item.claim && typeof item.claim === 'object' ? item.claim : {},
priorityScore: Math.max(0, Math.min(toNumber(item.priority_score ?? item.priorityScore), 100)),
priorityTier: String(item.priority_tier || item.priorityTier || 'normal').trim(),
priorityReasons: toArray(item.priority_reasons || item.priorityReasons).map((reason) => ({
code: String(reason?.code || '').trim(),
label: String(reason?.label || '').trim(),
weight: toNumber(reason?.weight),
tone: String(reason?.tone || 'normal').trim()
})).filter((reason) => reason.label),
riskLevel: String(item.risk_level || item.riskLevel || 'low').trim(),
openRiskCount: Math.max(0, toNumber(item.open_risk_count ?? item.openRiskCount)),
budgetUsageRate: item.budget_usage_rate == null && item.budgetUsageRate == null
? null
: toNumber(item.budget_usage_rate ?? item.budgetUsageRate),
waitingHours: Math.max(0, toNumber(item.waiting_hours ?? item.waitingHours)),
slaDueAt: String(item.sla_due_at || item.slaDueAt || '').trim(),
slaOverdue: Boolean(item.sla_overdue ?? item.slaOverdue),
evidence: {
completeness: Math.max(0, Math.min(toNumber(evidence.completeness), 1)),
presentCount: Math.max(0, toNumber(evidence.present_count ?? evidence.presentCount)),
requiredCount: Math.max(0, toNumber(evidence.required_count ?? evidence.requiredCount)),
missingLabels: toArray(evidence.missing_labels || evidence.missingLabels)
.map((value) => String(value || '').trim())
.filter(Boolean),
historicalLabels: toArray(evidence.historical_labels || evidence.historicalLabels)
.map((value) => String(value || '').trim())
.filter(Boolean)
},
suggestion: {
action: String(suggestion.action || 'manual_review').trim(),
label: String(suggestion.label || '人工复核').trim(),
reason: String(suggestion.reason || '').trim(),
advisoryOnly: suggestion.advisory_only !== false && suggestion.advisoryOnly !== false
}
}
}
export async function fetchApprovalWorkbenchItems(options = {}) {
const limit = Math.max(1, Math.min(toNumber(options.limit || 100), 200))
const payload = await apiRequest(`/approval-workbench/items?limit=${limit}`, {
timeoutMs: toNumber(options.timeoutMs || 5000),
timeoutMessage: '审批优先队列加载超时,请稍后重试。'
})
return {
items: toArray(payload?.items).map(normalizeApprovalWorkbenchItem),
total: Math.max(0, toNumber(payload?.total)),
generatedAt: String(payload?.generated_at || payload?.generatedAt || '').trim(),
scoringVersion: String(payload?.scoring_version || payload?.scoringVersion || '').trim()
}
}

View File

@@ -231,6 +231,15 @@ function createPreReviewRequestId() {
return `pre-review:${Date.now()}:${Math.random().toString(16).slice(2)}`
}
export function createExpenseClaimActionRequestId(action, claimId) {
const normalizedAction = String(action || 'action').trim() || 'action'
const normalizedClaimId = String(claimId || 'claim').trim() || 'claim'
if (typeof globalThis.crypto?.randomUUID === 'function') {
return `${normalizedAction}:${normalizedClaimId}:${globalThis.crypto.randomUUID()}`
}
return `${normalizedAction}:${normalizedClaimId}:${Date.now()}:${Math.random().toString(16).slice(2)}`
}
export function preReviewExpenseClaim(claimId, requestId = '') {
const normalizedRequestId = String(requestId || '').trim() || createPreReviewRequestId()
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pre-review`, {
@@ -261,10 +270,10 @@ export function approveExpenseClaim(claimId, payload = {}) {
})
}
export function payExpenseClaim(claimId) {
export function payExpenseClaim(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pay`, {
method: 'POST',
body: JSON.stringify({})
body: JSON.stringify(payload)
})
}

View File

@@ -13,6 +13,41 @@ function toObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
export function createRiskDispositionRequestId(action, observationId) {
const normalizedAction = String(action || 'action').trim() || 'action'
const normalizedObservationId = String(observationId || 'risk').trim() || 'risk'
if (typeof globalThis.crypto?.randomUUID === 'function') {
return `risk:${normalizedAction}:${normalizedObservationId}:${globalThis.crypto.randomUUID()}`
}
return `risk:${normalizedAction}:${normalizedObservationId}:${Date.now()}:${Math.random().toString(16).slice(2)}`
}
export function normalizeRiskDisposition(item = {}) {
if (!item || typeof item !== 'object' || !String(item.id || '').trim()) {
return null
}
return {
id: String(item.id || '').trim(),
observationId: String(item.observation_id || item.observationId || '').trim(),
adjudication: String(item.adjudication || 'unreviewed').trim(),
lifecycleStatus: String(item.lifecycle_status || item.lifecycleStatus || 'open').trim(),
version: Math.max(0, toNumber(item.version)),
assignee: String(item.assignee || '').trim(),
dueAt: String(item.due_at || item.dueAt || '').trim(),
resolution: String(item.resolution || '').trim(),
createdAt: String(item.created_at || item.createdAt || '').trim(),
updatedAt: String(item.updated_at || item.updatedAt || '').trim(),
events: toArray(item.events).map((event) => ({
id: String(event?.id || '').trim(),
action: String(event?.action || '').trim(),
version: Math.max(0, toNumber(event?.version)),
actorName: String(event?.actor_name || event?.actorName || '').trim(),
comment: String(event?.comment || '').trim(),
createdAt: String(event?.created_at || event?.createdAt || '').trim()
}))
}
}
export function normalizeRiskObservation(item = {}) {
return {
id: String(item.id || '').trim(),
@@ -33,16 +68,29 @@ export function normalizeRiskObservation(item = {}) {
algorithmVersion: String(item.algorithm_version || item.algorithmVersion || '').trim(),
status: String(item.status || '').trim(),
feedbackStatus: String(item.feedback_status || item.feedbackStatus || '').trim(),
contributionScores: toObject(item.contribution_scores || item.contributionScores),
baseline: toObject(item.baseline),
evidence: toArray(item.evidence),
graphNodeKeys: toArray(item.graph_node_keys || item.graphNodeKeys),
graphEdgeKeys: toArray(item.graph_edge_keys || item.graphEdgeKeys),
policyRefs: toArray(item.policy_refs || item.policyRefs),
similarCaseClaimIds: toArray(item.similar_case_claim_ids || item.similarCaseClaimIds),
contributionScores: toObject(
item.contribution_scores_json || item.contribution_scores || item.contributionScores
),
baseline: toObject(item.baseline_json || item.baseline),
evidence: toArray(item.evidence_json || item.evidence),
graphNodeKeys: toArray(
item.graph_node_keys_json || item.graph_node_keys || item.graphNodeKeys
),
graphEdgeKeys: toArray(
item.graph_edge_keys_json || item.graph_edge_keys || item.graphEdgeKeys
),
policyRefs: toArray(item.policy_refs_json || item.policy_refs || item.policyRefs),
similarCaseClaimIds: toArray(
item.similar_case_claim_ids_json
|| item.similar_case_claim_ids
|| item.similarCaseClaimIds
),
ontology: toObject(item.ontology_json || item.ontologyJson),
decisionTrace: toObject(item.decision_trace || item.decisionTrace),
decisionTrace: toObject(
item.decision_trace_json || item.decision_trace || item.decisionTrace
),
feedbackItems: toArray(item.feedback_items || item.feedbackItems),
disposition: normalizeRiskDisposition(item.disposition),
createdAt: String(item.created_at || item.createdAt || '').trim(),
updatedAt: String(item.updated_at || item.updatedAt || '').trim()
}
@@ -145,3 +193,42 @@ export async function fetchRunRiskObservations(runId, options = {}) {
return toArray(payload.items || payload).map(normalizeRiskObservation)
}
export async function executeRiskDispositionAction(observationId, payload = {}) {
const normalizedObservationId = String(observationId || '').trim()
const action = String(payload.action || '').trim()
if (!normalizedObservationId || !action) {
throw new Error('风险处置缺少观察标识或动作。')
}
const requestId = String(payload.requestId || payload.request_id || '').trim()
|| createRiskDispositionRequestId(action, normalizedObservationId)
const body = {
action,
expected_version: Math.max(0, toNumber(payload.expectedVersion ?? payload.expected_version)),
request_id: requestId
}
const comment = String(payload.comment || '').trim()
const resolution = String(payload.resolution || '').trim()
const assignee = String(payload.assignee || '').trim()
const dueAt = String(payload.dueAt || payload.due_at || '').trim()
if (comment) body.comment = comment
if (resolution) body.resolution = resolution
if (assignee) body.assignee = assignee
if (dueAt) body.due_at = dueAt
const result = await apiRequest(
`/risk-observations/${encodeURIComponent(normalizedObservationId)}/disposition/actions`,
{
method: 'POST',
body: JSON.stringify(body),
timeoutMs: toNumber(payload.timeoutMs || 5000),
timeoutMessage: '风险处置提交超时,请刷新状态后重试。'
}
)
return {
disposition: normalizeRiskDisposition(result?.disposition),
event: result?.event && typeof result.event === 'object' ? result.event : {},
replayed: Boolean(result?.replayed),
requestId
}
}