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

@@ -195,6 +195,142 @@
line-height: 1.55;
}
.risk-disposition-panel {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid rgba(var(--theme-primary-rgb), .18);
border-radius: 6px;
background: linear-gradient(135deg, #fff, var(--theme-primary-soft));
}
.risk-disposition-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.risk-disposition-head > div:first-child {
display: grid;
gap: 3px;
}
.risk-disposition-head span {
color: #64748b;
font-size: 12px;
font-weight: 850;
}
.risk-disposition-head strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
line-height: 1.45;
}
.risk-disposition-badges,
.risk-disposition-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.risk-disposition-badges em {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 4px;
background: #e2e8f0;
color: #475569;
font-size: 11px;
font-style: normal;
font-weight: 850;
}
.risk-disposition-badges em.confirmed,
.risk-disposition-badges em.remediation_in_progress,
.risk-disposition-badges em.waiver_requested {
background: var(--warning-soft);
color: var(--warning-active);
}
.risk-disposition-badges em.false_positive,
.risk-disposition-badges em.resolved {
background: var(--success-soft);
color: var(--success);
}
.risk-disposition-panel textarea {
width: 100%;
min-height: 68px;
resize: vertical;
padding: 9px 10px;
border: 1px solid #dbe5ef;
border-radius: 4px;
background: #fff;
color: #0f172a;
font: inherit;
font-size: 12px;
line-height: 1.55;
}
.risk-disposition-panel textarea:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px var(--theme-focus-ring);
outline: 0;
}
.risk-disposition-actions button {
min-height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border: 1px solid #dbe5ef;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 12px;
font-weight: 850;
}
.risk-disposition-actions button.danger {
border-color: var(--danger-line);
color: var(--danger);
}
.risk-disposition-actions button.warning {
border-color: var(--warning-line);
color: var(--warning-active);
}
.risk-disposition-actions button.safe {
border-color: rgba(34, 197, 94, .28);
color: var(--success);
}
.risk-disposition-actions button:disabled {
cursor: not-allowed;
opacity: .66;
}
.risk-disposition-resolution,
.risk-disposition-complete,
.risk-disposition-error {
margin: 0;
color: #475569;
font-size: 12px;
font-weight: 700;
line-height: 1.55;
}
.risk-disposition-error {
color: var(--danger);
}
.risk-evidence-meta {
display: flex;
flex-wrap: wrap;

View File

@@ -181,7 +181,7 @@
font-weight: 760;
}
table { width: 100%; min-width: 1180px; border-collapse: collapse; table-layout: fixed; }
table { width: 100%; min-width: 1520px; border-collapse: collapse; table-layout: fixed; }
th, td {
padding: 15px 12px;
@@ -232,6 +232,60 @@ tbody tr:last-child td { border-bottom: 0; }
.risk-tag.medium { background: var(--warning-soft); color: var(--warning-active); }
.risk-tag.high { background: var(--danger-soft); color: var(--danger); }
.priority-tag,
.evidence-meta,
.ai-suggestion {
min-height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 100%;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 780;
white-space: nowrap;
}
.priority-tag {
min-width: 66px;
background: #f1f5f9;
color: #475569;
font-variant-numeric: tabular-nums;
}
.priority-tag.high {
border-color: var(--warning-line);
background: var(--warning-soft);
color: var(--warning-active);
}
.priority-tag.urgent {
border-color: var(--danger-line);
background: var(--danger-soft);
color: var(--danger);
}
.evidence-meta.safe {
background: var(--success-soft);
color: var(--success);
}
.evidence-meta.warning {
border-color: var(--warning-line);
background: var(--warning-soft);
color: var(--warning-active);
}
.ai-suggestion {
overflow: hidden;
border-color: rgba(var(--theme-primary-rgb), .18);
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
text-overflow: ellipsis;
}
.sla { font-size: 13px; font-weight: 850; }
.sla.safe { color: var(--success); }
.sla.warning { color: var(--warning-active); }
@@ -1473,4 +1527,3 @@ tbody tr:last-child td { border-bottom: 0; }
.opinion-wrap {
padding: 12px 14px;
}

View File

@@ -58,6 +58,50 @@
</div>
</div>
<section class="risk-disposition-panel" aria-label="风险处置">
<div class="risk-disposition-head">
<div>
<span>风险处置</span>
<strong>{{ dispositionStateLabel }}</strong>
</div>
<div class="risk-disposition-badges">
<em :class="currentDisposition.adjudication">
{{ formatAdjudication(currentDisposition.adjudication) }}
</em>
<em :class="currentDisposition.lifecycleStatus">
{{ formatLifecycleStatus(currentDisposition.lifecycleStatus) }}
</em>
<em>v{{ currentDisposition.version }}</em>
</div>
</div>
<p v-if="currentDisposition.resolution" class="risk-disposition-resolution">
{{ currentDisposition.resolution }}
</p>
<template v-if="dispositionActions.length">
<textarea
v-model="dispositionNote"
:disabled="dispositionBusy"
maxlength="1000"
placeholder="填写复核依据、补充要求或解决说明(误报、补材料、豁免和解决时必填)"
></textarea>
<div class="risk-disposition-actions">
<button
v-for="action in dispositionActions"
:key="action.action"
type="button"
:class="action.tone"
:disabled="dispositionBusy"
@click="submitDispositionAction(action.action)"
>
<i :class="dispositionBusyAction === action.action ? 'mdi mdi-loading mdi-spin' : action.icon"></i>
{{ action.label }}
</button>
</div>
</template>
<p v-else class="risk-disposition-complete">{{ dispositionCompleteMessage }}</p>
<p v-if="dispositionError" class="risk-disposition-error">{{ dispositionError }}</p>
</section>
<div class="risk-evidence-grid">
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">贡献分</span>
@@ -141,7 +185,11 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { fetchClaimRiskObservations } from '../../services/riskObservations.js'
import {
createRiskDispositionRequestId,
executeRiskDispositionAction,
fetchClaimRiskObservations
} from '../../services/riskObservations.js'
const props = defineProps({
claimId: { type: String, default: '' }
@@ -151,6 +199,10 @@ const observations = ref([])
const loading = ref(false)
const errorMessage = ref('')
const activeObservationKey = ref('')
const dispositionNote = ref('')
const dispositionBusyAction = ref('')
const dispositionError = ref('')
const dispositionRequest = ref({ key: '', requestId: '' })
const detailRegionId = 'risk-observation-active-detail'
let loadSequence = 0
@@ -175,6 +227,92 @@ const activeObservationPosition = computed(() => {
)
return activeIndex >= 0 ? `${activeIndex + 1} / ${observations.value.length}` : '未选择'
})
const currentDisposition = computed(() => {
if (mainObservation.value?.disposition) {
return mainObservation.value.disposition
}
const feedbackStatus = String(mainObservation.value?.feedbackStatus || '').trim()
const status = String(mainObservation.value?.status || '').trim()
return {
adjudication: ['confirmed', 'false_positive'].includes(feedbackStatus)
? feedbackStatus
: 'unreviewed',
lifecycleStatus: status === 'resolved' ? 'resolved' : 'open',
version: 0,
resolution: ''
}
})
const dispositionBusy = computed(() => Boolean(dispositionBusyAction.value))
const dispositionStateLabel = computed(() => {
if (currentDisposition.value.adjudication === 'false_positive') {
return '该观察已判定为误报,不再阻断审批'
}
if (currentDisposition.value.lifecycleStatus === 'resolved') {
return '风险已完成处置,不再阻断审批'
}
if (['high', 'critical'].includes(String(mainObservation.value?.riskLevel || ''))) {
return '高风险未关闭,将阻断审批通过'
}
return '请结合证据完成人工裁决'
})
const dispositionActions = computed(() => {
const adjudication = currentDisposition.value.adjudication
const lifecycle = currentDisposition.value.lifecycleStatus
if (adjudication === 'false_positive' || lifecycle === 'resolved') {
return []
}
if (adjudication !== 'confirmed') {
const actions = [
{ action: 'confirm', label: '确认风险', icon: 'mdi mdi-shield-check', tone: 'danger' },
{ action: 'false_positive', label: '标记误报', icon: 'mdi mdi-shield-off-outline', tone: 'safe' }
]
if (lifecycle !== 'supplement_requested') {
actions.push({
action: 'request_supplement',
label: '请求补材料',
icon: 'mdi mdi-file-plus-outline',
tone: 'warning'
})
}
return actions
}
return [
{
action: 'request_supplement',
label: '请求补材料',
icon: 'mdi mdi-file-plus-outline',
tone: 'warning'
},
{
action: 'start_remediation',
label: '启动整改',
icon: 'mdi mdi-tools',
tone: 'warning'
},
{
action: 'request_waiver',
label: '申请豁免',
icon: 'mdi mdi-file-sign',
tone: 'neutral'
},
{
action: 'resolve',
label: '确认已解决',
icon: 'mdi mdi-check-decagram-outline',
tone: 'safe'
}
].filter((item) => (
item.action === 'resolve'
|| (item.action === 'request_supplement' && lifecycle !== 'supplement_requested')
|| (item.action === 'start_remediation' && lifecycle !== 'remediation_in_progress')
|| (item.action === 'request_waiver' && lifecycle !== 'waiver_requested')
))
})
const dispositionCompleteMessage = computed(() => (
currentDisposition.value.adjudication === 'false_positive'
? '该风险已标记为误报;如需更正,请由管理员通过审计流程处理。'
: '该风险已解决,处置事件会保留在追加式审计链中。'
))
const scoreItems = computed(() => {
const scores = mainObservation.value?.contributionScores || {}
return Object.entries(scores).map(([key, value]) => {
@@ -287,6 +425,50 @@ function selectObservation(item, index = -1) {
const key = observationIdentity(item, index)
if (key) {
activeObservationKey.value = key
dispositionNote.value = ''
dispositionError.value = ''
dispositionRequest.value = { key: '', requestId: '' }
}
}
async function submitDispositionAction(action) {
const observationId = String(mainObservation.value?.id || '').trim()
if (!observationId || dispositionBusy.value) {
return
}
const note = dispositionNote.value.trim()
if (['false_positive', 'request_supplement', 'request_waiver', 'resolve'].includes(action) && !note) {
dispositionError.value = '该处置动作需要填写复核依据或处理说明。'
return
}
const requestKey = [observationId, action, currentDisposition.value.version, note].join(':')
if (dispositionRequest.value.key !== requestKey) {
dispositionRequest.value = {
key: requestKey,
requestId: createRiskDispositionRequestId(action, observationId)
}
}
dispositionBusyAction.value = action
dispositionError.value = ''
try {
await executeRiskDispositionAction(observationId, {
action,
expectedVersion: currentDisposition.value.version,
requestId: dispositionRequest.value.requestId,
comment: note,
resolution: action === 'resolve' ? note : ''
})
dispositionNote.value = ''
dispositionRequest.value = { key: '', requestId: '' }
await loadObservations()
} catch (error) {
dispositionError.value = error?.message || '风险处置失败,请刷新状态后重试。'
if (error?.code === 'RISK_DISPOSITION_VERSION_CONFLICT') {
dispositionRequest.value = { key: '', requestId: '' }
await loadObservations()
}
} finally {
dispositionBusyAction.value = ''
}
}
@@ -376,6 +558,26 @@ function formatFeedbackStatus(value) {
}
return labels[String(value || '').trim()] || '未复核'
}
function formatAdjudication(value) {
const labels = {
unreviewed: '待裁决',
confirmed: '风险成立',
false_positive: '误报'
}
return labels[String(value || '').trim()] || '待裁决'
}
function formatLifecycleStatus(value) {
const labels = {
open: '待处置',
supplement_requested: '待补材料',
remediation_in_progress: '整改中',
waiver_requested: '豁免申请中',
resolved: '已解决'
}
return labels[String(value || '').trim()] || '待处置'
}
</script>
<style scoped src="../../assets/styles/components/risk-observation-evidence-card.css"></style>

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
}
}

View File

@@ -48,18 +48,21 @@
<template #table>
<table>
<colgroup>
<col><col><col><col><col><col><col><col><col><col><col>
<col><col><col><col><col><col><col><col><col><col><col><col><col><col>
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>优先级</th>
<th>申请人</th>
<th>申请部门</th>
<th>报销类型</th>
<th>金额</th>
<th>提交时间 <i class="mdi mdi-sort"></i></th>
<th>风险等级</th>
<th>SLA剩余</th>
<th>等待 / SLA</th>
<th>证据</th>
<th>AI建议</th>
<th>当前节点</th>
<th>状态</th>
<th>操作</th>
@@ -68,6 +71,11 @@
<tbody>
<tr v-for="row in visibleRows" :key="row.id" :class="{ spotlight: row.spotlight }" @click="selectedRow = row">
<td><strong class="doc-id">{{ row.id }}</strong></td>
<td>
<span class="priority-tag" :class="row.priorityTier" :title="row.priorityTitle">
{{ row.priorityLabel }} {{ row.priorityScore }}
</span>
</td>
<td>
<span class="person">
<span class="avatar">{{ row.avatar }}</span>
@@ -80,8 +88,18 @@
<td>{{ row.time }}</td>
<td><span class="risk-tag" :class="row.riskTone">{{ row.risk }}</span></td>
<td><strong class="sla" :class="row.slaTone">{{ row.sla }}</strong></td>
<td>
<span class="evidence-meta" :class="row.evidenceTone" :title="row.evidenceTitle">
{{ row.evidenceLabel }}
</span>
</td>
<td>
<span class="ai-suggestion" :title="row.suggestionReason">
{{ row.suggestionLabel }}
</span>
</td>
<td>{{ row.node }}</td>
<td><span class="status-tag" :class="row.statusTone">{{ row.status }}</span></td>
<td><span class="status-tag" :class="row.statusTone">{{ row.statusLabel }}</span></td>
<td>
<button class="more-btn" type="button" aria-label="查看审批详情" @click.stop="selectedRow = row">
<i class="mdi mdi-dots-horizontal"></i>

View File

@@ -3,11 +3,7 @@ import { computed, ref, watch } from 'vue'
import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useSystemState } from '../../composables/useSystemState.js'
import {
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
extractExpenseClaimItems,
fetchApprovalExpenseClaims
} from '../../services/reimbursements.js'
import { fetchApprovalWorkbenchItems } from '../../services/approvalWorkbench.js'
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
import {
filterActionableRiskFlags,
@@ -91,10 +87,17 @@ function resolveSlaMeta(submittedAt) {
return { label, tone: 'safe', urgent: false }
}
function buildApprovalRow(request) {
const riskTone = resolveRiskTone(request.riskFlags, request.riskSummary)
function buildApprovalRow(request, workbench = {}) {
const resolvedRiskTone = workbench.riskLevel
|| resolveRiskTone(request.riskFlags, request.riskSummary)
const riskTone = resolvedRiskTone === 'critical' ? 'high' : resolvedRiskTone
const slaMeta = resolveSlaMeta(request.submittedAt || request.createdAt)
const statusTone = slaMeta.urgent ? 'urgent' : 'pending'
const statusTone = workbench.slaOverdue || slaMeta.urgent ? 'urgent' : 'pending'
const evidence = workbench.evidence || {}
const evidencePercent = Math.round(Number(evidence.completeness || 0) * 100)
const priorityReasons = Array.isArray(workbench.priorityReasons)
? workbench.priorityReasons.map((item) => item.label).filter(Boolean)
: []
return {
...request,
@@ -106,12 +109,33 @@ function buildApprovalRow(request) {
time: request.applyTime,
risk: RISK_LABELS[riskTone] || RISK_LABELS.low,
riskTone,
sla: slaMeta.label,
slaTone: slaMeta.tone,
priorityScore: Number(workbench.priorityScore || 0),
priorityTier: workbench.priorityTier || 'normal',
priorityLabel: workbench.priorityTier === 'urgent'
? '紧急'
: workbench.priorityTier === 'high'
? '优先'
: '常规',
priorityReasons,
priorityTitle: priorityReasons.join('') || '常规低风险待办',
sla: workbench.slaOverdue
? '已超时'
: workbench.waitingHours != null
? `已等待 ${Number(workbench.waitingHours || 0).toFixed(1)}h`
: slaMeta.label,
slaTone: workbench.slaOverdue ? 'danger' : slaMeta.tone,
evidenceLabel: `${evidencePercent}%`,
evidenceTone: evidencePercent < 100 ? 'warning' : 'safe',
evidenceTitle: evidence.missingLabels?.length
? `缺少:${evidence.missingLabels.join('、')}`
: '必要材料相对完整',
historicalLabels: evidence.historicalLabels || [],
suggestionLabel: workbench.suggestion?.label || '人工复核',
suggestionReason: workbench.suggestion?.reason || '',
node: request.workflowNode || '审批中',
status: statusTone === 'urgent' ? '即将超时' : '待审批',
statusLabel: statusTone === 'urgent' ? '即将超时' : '待审批',
statusTone,
spotlight: riskTone === 'high' || statusTone === 'urgent'
spotlight: workbench.priorityTier === 'urgent' || riskTone === 'high' || statusTone === 'urgent'
}
}
@@ -232,9 +256,14 @@ export default {
error.value = ''
try {
const payload = await fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
const pendingRequests = listPendingApprovalRequests(extractExpenseClaimItems(payload), currentUser.value)
const mappedRows = pendingRequests.map((item) => buildApprovalRow(item))
const payload = await fetchApprovalWorkbenchItems({ limit: 100 })
const mappedRows = payload.items.flatMap((workbenchItem) => {
const pendingRequests = listPendingApprovalRequests(
[workbenchItem.claim],
currentUser.value
)
return pendingRequests.map((item) => buildApprovalRow(item, workbenchItem))
})
rows.value = mappedRows
syncPendingClaimIds(mappedRows.map((item) => item.claimId))
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {

View File

@@ -1,6 +1,9 @@
import { computed, ref } from 'vue'
import { payExpenseClaim } from '../../services/reimbursements.js'
import {
createExpenseClaimActionRequestId,
payExpenseClaim
} from '../../services/reimbursements.js'
import {
canManageExpenseClaims,
isFinanceUser
@@ -16,6 +19,9 @@ export function useTravelRequestPaymentFlow({
}) {
const payBusy = ref(false)
const payConfirmDialogOpen = ref(false)
const payRequestId = ref('')
const payExpectedStatus = ref('')
const payExpectedStage = ref('')
const isPendingPaymentStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
@@ -50,6 +56,9 @@ export function useTravelRequestPaymentFlow({
return
}
payRequestId.value = createExpenseClaimActionRequestId('pay', request.value.claimId)
payExpectedStatus.value = String(request.value.status || '').trim()
payExpectedStage.value = String(request.value.node || request.value.approvalStage || '').trim()
payConfirmDialogOpen.value = true
}
@@ -59,6 +68,9 @@ export function useTravelRequestPaymentFlow({
}
payConfirmDialogOpen.value = false
payRequestId.value = ''
payExpectedStatus.value = ''
payExpectedStage.value = ''
}
async function confirmPayRequest() {
@@ -76,8 +88,15 @@ export function useTravelRequestPaymentFlow({
payBusy.value = true
try {
await payExpenseClaim(request.value.claimId)
await payExpenseClaim(request.value.claimId, {
request_id: payRequestId.value,
expected_status: payExpectedStatus.value,
expected_approval_stage: payExpectedStage.value
})
payConfirmDialogOpen.value = false
payRequestId.value = ''
payExpectedStatus.value = ''
payExpectedStage.value = ''
toast(`${request.value.id} 已确认付款。`)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {

View File

@@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
import {
approveExpenseClaim,
createExpenseClaimActionRequestId,
deleteExpenseClaim,
returnExpenseClaim
} from '../../services/reimbursements.js'
@@ -26,8 +27,14 @@ export function useTravelRequestDetailApprovalFlow({
const deleteDialogOpen = ref(false)
const returnBusy = ref(false)
const returnDialogOpen = ref(false)
const returnRequestId = ref('')
const returnExpectedStatus = ref('')
const returnExpectedStage = ref('')
const approveBusy = ref(false)
const approveConfirmDialogOpen = ref(false)
const approveRequestId = ref('')
const approveExpectedStatus = ref('')
const approveExpectedStage = ref('')
const approvalRiskConfirmed = ref(false)
const leaderOpinion = ref('')
@@ -191,6 +198,9 @@ export function useTravelRequestDetailApprovalFlow({
return
}
returnRequestId.value = createExpenseClaimActionRequestId('return', request.value.claimId)
returnExpectedStatus.value = String(request.value.status || '').trim()
returnExpectedStage.value = String(request.value.node || request.value.approvalStage || '').trim()
returnDialogOpen.value = true
}
@@ -200,6 +210,9 @@ export function useTravelRequestDetailApprovalFlow({
}
returnDialogOpen.value = false
returnRequestId.value = ''
returnExpectedStatus.value = ''
returnExpectedStage.value = ''
}
async function confirmReturnRequest(payload) {
@@ -210,8 +223,16 @@ export function useTravelRequestDetailApprovalFlow({
returnBusy.value = true
try {
await returnExpenseClaim(request.value.claimId, payload)
await returnExpenseClaim(request.value.claimId, {
...payload,
request_id: returnRequestId.value,
expected_status: returnExpectedStatus.value,
expected_approval_stage: returnExpectedStage.value
})
returnDialogOpen.value = false
returnRequestId.value = ''
returnExpectedStatus.value = ''
returnExpectedStage.value = ''
toast(`${request.value.id} 已退回待提交。`)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
@@ -232,6 +253,9 @@ export function useTravelRequestDetailApprovalFlow({
return
}
approveRequestId.value = createExpenseClaimActionRequestId('approve', request.value.claimId)
approveExpectedStatus.value = String(request.value.status || '').trim()
approveExpectedStage.value = String(request.value.node || request.value.approvalStage || '').trim()
approvalRiskConfirmed.value = !approvalRiskConfirmRequired.value
approveConfirmDialogOpen.value = true
}
@@ -242,6 +266,9 @@ export function useTravelRequestDetailApprovalFlow({
}
approveConfirmDialogOpen.value = false
approveRequestId.value = ''
approveExpectedStatus.value = ''
approveExpectedStage.value = ''
}
function resolveApproveErrorMessage(error) {
@@ -278,10 +305,16 @@ export function useTravelRequestDetailApprovalFlow({
approveBusy.value = true
try {
const responsePayload = await approveExpenseClaim(request.value.claimId, {
opinion: leaderOpinion.value.trim() || '同意'
opinion: leaderOpinion.value.trim() || '同意',
request_id: approveRequestId.value,
expected_status: approveExpectedStatus.value,
expected_approval_stage: approveExpectedStage.value
})
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
approveConfirmDialogOpen.value = false
approveRequestId.value = ''
approveExpectedStatus.value = ''
approveExpectedStage.value = ''
approvalRiskConfirmed.value = false
leaderOpinion.value = ''
toast(

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