feat(approval): add safe risk disposition workflow
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
66
web/src/services/approvalWorkbench.js
Normal file
66
web/src/services/approvalWorkbench.js
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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