feat(approval): add task workflow and waiver decisions
This commit is contained in:
535
web/src/services/approvalTasks.js
Normal file
535
web/src/services/approvalTasks.js
Normal file
@@ -0,0 +1,535 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
const APPROVAL_TASKS_PATH = '/approval-tasks'
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
const MAX_PAGE_SIZE = 200
|
||||
const DEFAULT_TIMEOUT_MS = 5000
|
||||
|
||||
function toObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
function toArray(value) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function toText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function toNumber(value, fallback = 0) {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number : fallback
|
||||
}
|
||||
|
||||
function toInteger(value, fallback = 0) {
|
||||
return Math.trunc(toNumber(value, fallback))
|
||||
}
|
||||
|
||||
function toPositiveInteger(value, fallback = 1, maximum = Number.MAX_SAFE_INTEGER) {
|
||||
return Math.min(maximum, Math.max(1, toInteger(value, fallback)))
|
||||
}
|
||||
|
||||
function toNullableObject(value) {
|
||||
const object = toObject(value)
|
||||
return Object.keys(object).length ? object : null
|
||||
}
|
||||
|
||||
function uniqueTexts(value) {
|
||||
return [...new Set(toArray(value).map(toText).filter(Boolean))]
|
||||
}
|
||||
|
||||
function normalizePriorityReason(item = {}) {
|
||||
return {
|
||||
code: toText(item.code),
|
||||
label: toText(item.label),
|
||||
weight: toNumber(item.weight),
|
||||
tone: toText(item.tone || 'normal') || 'normal'
|
||||
}
|
||||
}
|
||||
|
||||
export function createApprovalTaskRequestId(action = 'action', taskId = 'task') {
|
||||
const normalizedAction = toText(action).replace(/[^a-z0-9_-]+/gi, '-') || 'action'
|
||||
const normalizedTaskId = toText(taskId).replace(/[^a-z0-9_-]+/gi, '-') || 'task'
|
||||
const suffix = typeof globalThis.crypto?.randomUUID === 'function'
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
return `approval:${normalizedAction}:${normalizedTaskId}:${suffix}`.slice(0, 120)
|
||||
}
|
||||
|
||||
export function normalizeApprovalTask(item = {}) {
|
||||
const task = toObject(item?.task || item)
|
||||
if (!toText(task.id)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(task.id),
|
||||
tenantId: toText(task.tenant_id ?? task.tenantId),
|
||||
claimId: toText(task.claim_id ?? task.claimId),
|
||||
expenseCaseId: toText(task.expense_case_id ?? task.expenseCaseId),
|
||||
nodeInstanceId: toText(task.node_instance_id ?? task.nodeInstanceId),
|
||||
nodeEntryKey: toText(task.node_entry_key ?? task.nodeEntryKey),
|
||||
parentTaskId: toText(task.parent_task_id ?? task.parentTaskId),
|
||||
taskKind: toText(task.task_kind ?? task.taskKind ?? 'root') || 'root',
|
||||
nodeKey: toText(task.node_key ?? task.nodeKey),
|
||||
nodeLabel: toText(task.node_label ?? task.nodeLabel),
|
||||
nodeSequence: toInteger(task.node_sequence ?? task.nodeSequence),
|
||||
sequenceOrder: toInteger(task.sequence_order ?? task.sequenceOrder),
|
||||
coordinationMode: toText(task.coordination_mode ?? task.coordinationMode ?? 'single') || 'single',
|
||||
ownerKind: toText(task.owner_kind ?? task.ownerKind),
|
||||
ownerKey: toText(task.owner_key ?? task.ownerKey),
|
||||
ownerEmployeeId: toText(task.owner_employee_id ?? task.ownerEmployeeId),
|
||||
ownerName: toText(task.owner_name ?? task.ownerName),
|
||||
assigneeKind: toText(task.assignee_kind ?? task.assigneeKind),
|
||||
assigneeKey: toText(task.assignee_key ?? task.assigneeKey),
|
||||
assigneeEmployeeId: toText(task.assignee_employee_id ?? task.assigneeEmployeeId),
|
||||
assigneeName: toText(task.assignee_name ?? task.assigneeName),
|
||||
delegatedBy: toText(task.delegated_by ?? task.delegatedBy),
|
||||
delegationExpiresAt: toText(task.delegation_expires_at ?? task.delegationExpiresAt),
|
||||
status: toText(task.status),
|
||||
decision: toText(task.decision),
|
||||
opinion: toText(task.opinion),
|
||||
version: Math.max(0, toInteger(task.version)),
|
||||
claimStatusSnapshot: toText(task.claim_status_snapshot ?? task.claimStatusSnapshot),
|
||||
claimStageSnapshot: toText(task.claim_stage_snapshot ?? task.claimStageSnapshot),
|
||||
enteredAt: toText(task.entered_at ?? task.enteredAt),
|
||||
enteredAtSource: toText(task.entered_at_source ?? task.enteredAtSource),
|
||||
activatedAt: toText(task.activated_at ?? task.activatedAt),
|
||||
slaHoursSnapshot: Math.max(0, toInteger(task.sla_hours_snapshot ?? task.slaHoursSnapshot)),
|
||||
dueAt: toText(task.due_at ?? task.dueAt),
|
||||
completedAt: toText(task.completed_at ?? task.completedAt),
|
||||
cancelledAt: toText(task.cancelled_at ?? task.cancelledAt),
|
||||
escalationLevel: Math.max(0, toInteger(task.escalation_level ?? task.escalationLevel)),
|
||||
escalatedAt: toText(task.escalated_at ?? task.escalatedAt),
|
||||
nextEscalationAt: toText(task.next_escalation_at ?? task.nextEscalationAt),
|
||||
priorityScore: Math.max(0, Math.min(100, toNumber(task.priority_score ?? task.priorityScore))),
|
||||
priorityTier: toText(task.priority_tier ?? task.priorityTier ?? 'normal') || 'normal',
|
||||
priorityReasons: toArray(task.priority_reasons_json ?? task.priorityReasons)
|
||||
.map(normalizePriorityReason)
|
||||
.filter((reason) => reason.code || reason.label),
|
||||
riskLevel: toText(task.risk_level ?? task.riskLevel ?? 'low') || 'low',
|
||||
openRiskCount: Math.max(0, toInteger(task.open_risk_count ?? task.openRiskCount)),
|
||||
evidenceCompleteness: Math.max(
|
||||
0,
|
||||
Math.min(1, toNumber(task.evidence_completeness ?? task.evidenceCompleteness))
|
||||
),
|
||||
batchEligible: Boolean(task.batch_eligible ?? task.batchEligible),
|
||||
batchBlockReasons: uniqueTexts(task.batch_block_reasons_json ?? task.batchBlockReasons),
|
||||
projectionUpdatedAt: toText(task.projection_updated_at ?? task.projectionUpdatedAt),
|
||||
createdAt: toText(task.created_at ?? task.createdAt),
|
||||
updatedAt: toText(task.updated_at ?? task.updatedAt),
|
||||
canAct: Boolean(task.can_act ?? task.canAct),
|
||||
availableActions: uniqueTexts(task.available_actions ?? task.availableActions),
|
||||
readOnlyReason: toText(task.read_only_reason ?? task.readOnlyReason)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskQueueItem(item = {}) {
|
||||
const source = toObject(item)
|
||||
const task = normalizeApprovalTask(source.task || source)
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
task,
|
||||
claim: toNullableObject(source.claim),
|
||||
id: task.id,
|
||||
claimId: task.claimId
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskEvent(item = {}) {
|
||||
const event = toObject(item)
|
||||
if (!toText(event.id)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: toText(event.id),
|
||||
tenantId: toText(event.tenant_id ?? event.tenantId),
|
||||
taskId: toText(event.task_id ?? event.taskId),
|
||||
nodeInstanceId: toText(event.node_instance_id ?? event.nodeInstanceId),
|
||||
eventType: toText(event.event_type ?? event.eventType),
|
||||
actorId: toText(event.actor_id ?? event.actorId),
|
||||
actorName: toText(event.actor_name ?? event.actorName),
|
||||
actorType: toText(event.actor_type ?? event.actorType),
|
||||
requestId: toText(event.request_id ?? event.requestId),
|
||||
expectedTaskVersion: Math.max(
|
||||
0,
|
||||
toInteger(event.expected_task_version ?? event.expectedTaskVersion)
|
||||
),
|
||||
resultTaskVersion: Math.max(
|
||||
0,
|
||||
toInteger(event.result_task_version ?? event.resultTaskVersion)
|
||||
),
|
||||
payload: toObject(event.payload_json ?? event.payload),
|
||||
before: toObject(event.before_json ?? event.before),
|
||||
after: toObject(event.after_json ?? event.after),
|
||||
approvalActionLedgerId: toText(
|
||||
event.approval_action_ledger_id ?? event.approvalActionLedgerId
|
||||
),
|
||||
businessEventId: toText(event.business_event_id ?? event.businessEventId),
|
||||
correlationId: toText(event.correlation_id ?? event.correlationId),
|
||||
causationId: toText(event.causation_id ?? event.causationId),
|
||||
occurredAt: toText(event.occurred_at ?? event.occurredAt)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskMutation(payload = {}) {
|
||||
const envelope = toObject(payload)
|
||||
const mutation = toObject(envelope.mutation || envelope)
|
||||
return {
|
||||
task: normalizeApprovalTask(mutation.task),
|
||||
event: normalizeApprovalTaskEvent(mutation.event),
|
||||
relatedTasks: toArray(mutation.related_tasks ?? mutation.relatedTasks)
|
||||
.map(normalizeApprovalTask)
|
||||
.filter(Boolean),
|
||||
replayed: Boolean(mutation.replayed),
|
||||
claim: toNullableObject(envelope.claim ?? mutation.claim)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskList(payload = {}) {
|
||||
const source = toObject(payload)
|
||||
const items = toArray(source.items).map(normalizeApprovalTaskQueueItem).filter(Boolean)
|
||||
const total = Math.max(items.length, toInteger(source.total, items.length))
|
||||
const pageSize = toPositiveInteger(
|
||||
source.page_size ?? source.pageSize,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
MAX_PAGE_SIZE
|
||||
)
|
||||
const page = toPositiveInteger(source.page, 1)
|
||||
const totalPages = Math.max(
|
||||
total ? 1 : 0,
|
||||
toInteger(source.total_pages ?? source.totalPages, Math.ceil(total / pageSize))
|
||||
)
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
generatedAt: toText(source.generated_at ?? source.generatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskCandidate(item = {}) {
|
||||
const source = toObject(item)
|
||||
const employeeId = toText(source.employee_id ?? source.employeeId)
|
||||
if (!employeeId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
employeeId,
|
||||
employeeNo: toText(source.employee_no ?? source.employeeNo),
|
||||
name: toText(source.name),
|
||||
email: toText(source.email),
|
||||
qualified: Boolean(source.qualified),
|
||||
reason: toText(source.reason)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskCandidateList(payload = {}) {
|
||||
const source = Array.isArray(payload) ? { items: payload } : toObject(payload)
|
||||
const items = toArray(source.items).map(normalizeApprovalTaskCandidate).filter(Boolean)
|
||||
return {
|
||||
items,
|
||||
total: Math.max(items.length, toInteger(source.total, items.length))
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeApprovalTaskBatchResult(payload = {}) {
|
||||
const source = toObject(payload)
|
||||
const items = toArray(source.items).map((item) => ({
|
||||
taskId: toText(item?.task_id ?? item?.taskId),
|
||||
claimId: toText(item?.claim_id ?? item?.claimId),
|
||||
status: toText(item?.status || 'failed') || 'failed',
|
||||
code: toText(item?.code),
|
||||
message: toText(item?.message),
|
||||
claim: toNullableObject(item?.claim)
|
||||
})).filter((item) => item.taskId)
|
||||
const countStatus = (status) => items.filter((item) => item.status === status).length
|
||||
return {
|
||||
batchRequestId: toText(source.batch_request_id ?? source.batchRequestId),
|
||||
status: toText(source.status || 'failed') || 'failed',
|
||||
succeededCount: Math.max(0, toInteger(source.succeeded_count ?? source.succeededCount, countStatus('succeeded'))),
|
||||
replayedCount: Math.max(0, toInteger(source.replayed_count ?? source.replayedCount, countStatus('replayed'))),
|
||||
conflictCount: Math.max(0, toInteger(source.conflict_count ?? source.conflictCount, countStatus('conflict'))),
|
||||
blockedCount: Math.max(0, toInteger(source.blocked_count ?? source.blockedCount, countStatus('blocked'))),
|
||||
forbiddenCount: Math.max(0, toInteger(source.forbidden_count ?? source.forbiddenCount, countStatus('forbidden'))),
|
||||
failedCount: Math.max(0, toInteger(source.failed_count ?? source.failedCount, countStatus('failed'))),
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
function appendQueryValue(search, key, value) {
|
||||
if (Array.isArray(value)) {
|
||||
value.map(toText).filter(Boolean).forEach((item) => search.append(key, item))
|
||||
return
|
||||
}
|
||||
const normalized = toText(value)
|
||||
if (normalized) {
|
||||
search.set(key, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
function buildApprovalTaskListQuery(params = {}) {
|
||||
const search = new URLSearchParams()
|
||||
search.set('page', String(toPositiveInteger(params.page, 1)))
|
||||
search.set(
|
||||
'page_size',
|
||||
String(toPositiveInteger(params.pageSize ?? params.page_size, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE))
|
||||
)
|
||||
appendQueryValue(search, 'status', params.status)
|
||||
appendQueryValue(search, 'node_key', params.nodeKey ?? params.node_key)
|
||||
appendQueryValue(search, 'task_kind', params.taskKind ?? params.task_kind)
|
||||
appendQueryValue(search, 'risk_level', params.riskLevel ?? params.risk_level)
|
||||
appendQueryValue(search, 'sla_state', params.slaState ?? params.sla_state)
|
||||
appendQueryValue(search, 'keyword', params.keyword)
|
||||
appendQueryValue(search, 'sort', params.sort)
|
||||
appendQueryValue(search, 'assignee', params.assignee)
|
||||
if (typeof (params.batchEligible ?? params.batch_eligible) === 'boolean') {
|
||||
search.set(
|
||||
'batch_eligible',
|
||||
String(Boolean(params.batchEligible ?? params.batch_eligible))
|
||||
)
|
||||
}
|
||||
return search.toString()
|
||||
}
|
||||
|
||||
function requestOptions(options, timeoutMessage) {
|
||||
return {
|
||||
...options,
|
||||
timeoutMs: toNumber(options?.timeoutMs, DEFAULT_TIMEOUT_MS),
|
||||
timeoutMessage
|
||||
}
|
||||
}
|
||||
|
||||
function requireTaskId(taskId) {
|
||||
const normalized = toText(taskId)
|
||||
if (!normalized) {
|
||||
throw new Error('审批任务缺少任务标识。')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function buildBaseActionPayload(action, taskId, payload = {}) {
|
||||
const expectedTaskVersion = toInteger(
|
||||
payload.expectedTaskVersion ?? payload.expected_task_version
|
||||
)
|
||||
if (expectedTaskVersion < 1) {
|
||||
throw new Error('审批动作缺少有效的任务版本。')
|
||||
}
|
||||
const reason = toText(payload.reason)
|
||||
if (reason.length < 2) {
|
||||
throw new Error('审批动作需要填写至少 2 个字的原因。')
|
||||
}
|
||||
return {
|
||||
request_id: toText(payload.requestId ?? payload.request_id)
|
||||
|| createApprovalTaskRequestId(action, taskId),
|
||||
expected_task_version: expectedTaskVersion,
|
||||
reason
|
||||
}
|
||||
}
|
||||
|
||||
function buildAssignmentActionPayload(action, taskId, payload) {
|
||||
const targetEmployeeId = toText(payload.targetEmployeeId ?? payload.target_employee_id)
|
||||
if (!targetEmployeeId) {
|
||||
throw new Error('审批任务需要选择目标员工。')
|
||||
}
|
||||
const body = {
|
||||
...buildBaseActionPayload(action, taskId, payload),
|
||||
target_employee_id: targetEmployeeId
|
||||
}
|
||||
const expiresAt = toText(payload.expiresAt ?? payload.expires_at)
|
||||
if (expiresAt) {
|
||||
body.expires_at = expiresAt
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
function buildParticipantsActionPayload(action, taskId, payload) {
|
||||
const participantEmployeeIds = uniqueTexts(
|
||||
payload.participantEmployeeIds ?? payload.participant_employee_ids
|
||||
)
|
||||
if (!participantEmployeeIds.length) {
|
||||
throw new Error('审批任务需要至少选择一名参与人。')
|
||||
}
|
||||
return {
|
||||
...buildBaseActionPayload(action, taskId, payload),
|
||||
participant_employee_ids: participantEmployeeIds
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateApprovalTask(taskId, path, payload, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
const response = await apiRequest(
|
||||
`${APPROVAL_TASKS_PATH}/${encodeURIComponent(normalizedTaskId)}/${path}`,
|
||||
{
|
||||
...requestOptions(options, '审批任务操作超时,请刷新任务状态后重试。'),
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
)
|
||||
return normalizeApprovalTaskMutation(response)
|
||||
}
|
||||
|
||||
export async function fetchApprovalTasks(params = {}, options = {}) {
|
||||
const payload = await apiRequest(
|
||||
`${APPROVAL_TASKS_PATH}?${buildApprovalTaskListQuery(params)}`,
|
||||
requestOptions(options, '审批任务列表加载超时,请稍后重试。')
|
||||
)
|
||||
return normalizeApprovalTaskList(payload)
|
||||
}
|
||||
|
||||
export async function fetchApprovalTaskDetail(taskId, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
const payload = await apiRequest(
|
||||
`${APPROVAL_TASKS_PATH}/${encodeURIComponent(normalizedTaskId)}`,
|
||||
requestOptions(options, '审批任务详情加载超时,请稍后重试。')
|
||||
)
|
||||
return normalizeApprovalTaskQueueItem(payload)
|
||||
}
|
||||
|
||||
export async function fetchApprovalTaskCandidates(taskId, params = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
const search = new URLSearchParams()
|
||||
appendQueryValue(search, 'action', params.action)
|
||||
appendQueryValue(search, 'keyword', params.keyword)
|
||||
search.set('limit', String(toPositiveInteger(params.limit, 20, 100)))
|
||||
const payload = await apiRequest(
|
||||
`${APPROVAL_TASKS_PATH}/${encodeURIComponent(normalizedTaskId)}/candidates?${search}`,
|
||||
requestOptions(options, '审批候选人加载超时,请稍后重试。')
|
||||
)
|
||||
return normalizeApprovalTaskCandidateList(payload)
|
||||
}
|
||||
|
||||
export function delegateApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
return mutateApprovalTask(
|
||||
normalizedTaskId,
|
||||
'delegate',
|
||||
buildAssignmentActionPayload('delegate', normalizedTaskId, payload),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function revokeApprovalTaskDelegation(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
return mutateApprovalTask(
|
||||
normalizedTaskId,
|
||||
'delegation/revoke',
|
||||
buildBaseActionPayload('delegation-revoke', normalizedTaskId, payload),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function transferApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
return mutateApprovalTask(
|
||||
normalizedTaskId,
|
||||
'transfer',
|
||||
buildAssignmentActionPayload('transfer', normalizedTaskId, payload),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function addSignApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
return mutateApprovalTask(
|
||||
normalizedTaskId,
|
||||
'add-sign',
|
||||
buildParticipantsActionPayload('add-sign', normalizedTaskId, payload),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function countersignApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
return mutateApprovalTask(
|
||||
normalizedTaskId,
|
||||
'countersign',
|
||||
buildParticipantsActionPayload('countersign', normalizedTaskId, payload),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function escalateApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
return mutateApprovalTask(
|
||||
normalizedTaskId,
|
||||
'escalate',
|
||||
buildBaseActionPayload('sla-escalate', normalizedTaskId, payload),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export function approveApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
const body = buildBaseActionPayload('approve', normalizedTaskId, payload)
|
||||
const opinion = toText(payload.opinion)
|
||||
if (opinion) {
|
||||
body.opinion = opinion
|
||||
}
|
||||
return mutateApprovalTask(normalizedTaskId, 'approve', body, options)
|
||||
}
|
||||
|
||||
export function returnApprovalTask(taskId, payload = {}, options = {}) {
|
||||
const normalizedTaskId = requireTaskId(taskId)
|
||||
const body = {
|
||||
...buildBaseActionPayload('return', normalizedTaskId, payload),
|
||||
reason_codes: uniqueTexts(payload.reasonCodes ?? payload.reason_codes)
|
||||
}
|
||||
return mutateApprovalTask(normalizedTaskId, 'return', body, options)
|
||||
}
|
||||
|
||||
function normalizeBatchApproveItem(item = {}) {
|
||||
const source = toObject(item?.task || item)
|
||||
const taskId = toText(item.task_id ?? item.taskId ?? source.id)
|
||||
const expectedTaskVersion = toInteger(
|
||||
item.expected_task_version ?? item.expectedTaskVersion ?? source.version
|
||||
)
|
||||
const expectedStatus = toText(
|
||||
item.expected_status ?? item.expectedStatus
|
||||
?? source.claim_status_snapshot ?? source.claimStatusSnapshot
|
||||
)
|
||||
const expectedApprovalStage = toText(
|
||||
item.expected_approval_stage ?? item.expectedApprovalStage
|
||||
?? source.claim_stage_snapshot ?? source.claimStageSnapshot
|
||||
)
|
||||
if (!taskId || expectedTaskVersion < 1 || !expectedStatus || !expectedApprovalStage) {
|
||||
throw new Error('批量审批项缺少任务、版本、单据状态或审批节点快照。')
|
||||
}
|
||||
const body = {
|
||||
task_id: taskId,
|
||||
expected_task_version: expectedTaskVersion,
|
||||
expected_status: expectedStatus,
|
||||
expected_approval_stage: expectedApprovalStage
|
||||
}
|
||||
const opinion = toText(item.opinion)
|
||||
if (opinion) {
|
||||
body.opinion = opinion
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
export async function batchApproveApprovalTasks(payload = {}, options = {}) {
|
||||
const items = toArray(payload.items).map(normalizeBatchApproveItem)
|
||||
if (!items.length || items.length > 20) {
|
||||
throw new Error('批量审批每次需要选择 1 至 20 项任务。')
|
||||
}
|
||||
if (new Set(items.map((item) => item.task_id)).size !== items.length) {
|
||||
throw new Error('批量审批不能包含重复任务。')
|
||||
}
|
||||
const batchRequestId = toText(payload.batchRequestId ?? payload.batch_request_id)
|
||||
|| createApprovalTaskRequestId('batch-approve', 'batch').slice(0, 80)
|
||||
const response = await apiRequest(`${APPROVAL_TASKS_PATH}/batch-approve`, {
|
||||
...requestOptions(options, '批量审批超时,请刷新任务状态后查看实际结果。'),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
batch_request_id: batchRequestId,
|
||||
items
|
||||
})
|
||||
})
|
||||
return normalizeApprovalTaskBatchResult(response)
|
||||
}
|
||||
@@ -13,6 +13,14 @@ function toObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
function uniqueTexts(value, limit = Number.POSITIVE_INFINITY) {
|
||||
return [...new Set(
|
||||
toArray(value)
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
)].slice(0, limit)
|
||||
}
|
||||
|
||||
export function createRiskDispositionRequestId(action, observationId) {
|
||||
const normalizedAction = String(action || 'action').trim() || 'action'
|
||||
const normalizedObservationId = String(observationId || 'risk').trim() || 'risk'
|
||||
@@ -35,14 +43,48 @@ export function normalizeRiskDisposition(item = {}) {
|
||||
assignee: String(item.assignee || '').trim(),
|
||||
dueAt: String(item.due_at || item.dueAt || '').trim(),
|
||||
resolution: String(item.resolution || '').trim(),
|
||||
availableActions: uniqueTexts(item.available_actions ?? item.availableActions),
|
||||
readOnlyReason: String(item.read_only_reason ?? item.readOnlyReason ?? '').trim(),
|
||||
waiverRequesterId: String(
|
||||
item.waiver_requester_id || item.waiverRequesterId || ''
|
||||
).trim(),
|
||||
waiverRequesterName: String(
|
||||
item.waiver_requester_name || item.waiverRequesterName || ''
|
||||
).trim(),
|
||||
waiverRequestedAt: String(
|
||||
item.waiver_requested_at || item.waiverRequestedAt || ''
|
||||
).trim(),
|
||||
waiverReason: String(item.waiver_reason || item.waiverReason || '').trim(),
|
||||
waiverScope: String(item.waiver_scope || item.waiverScope || '').trim(),
|
||||
waiverExpiresAt: String(
|
||||
item.waiver_expires_at || item.waiverExpiresAt || ''
|
||||
).trim(),
|
||||
waiverConditions: uniqueTexts(
|
||||
item.waiver_conditions_json
|
||||
?? item.waiver_conditions
|
||||
?? item.waiverConditions
|
||||
),
|
||||
waiverDecision: String(item.waiver_decision || item.waiverDecision || '').trim(),
|
||||
waiverDeciderId: String(item.waiver_decider_id || item.waiverDeciderId || '').trim(),
|
||||
waiverDeciderName: String(
|
||||
item.waiver_decider_name || item.waiverDeciderName || ''
|
||||
).trim(),
|
||||
waiverDecidedAt: String(item.waiver_decided_at || item.waiverDecidedAt || '').trim(),
|
||||
waiverDecisionReason: String(
|
||||
item.waiver_decision_reason || item.waiverDecisionReason || ''
|
||||
).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)),
|
||||
actorId: String(event?.actor_id || event?.actorId || '').trim(),
|
||||
actorName: String(event?.actor_name || event?.actorName || '').trim(),
|
||||
requestId: String(event?.request_id || event?.requestId || '').trim(),
|
||||
comment: String(event?.comment || '').trim(),
|
||||
beforeState: toObject(event?.before_json || event?.beforeState),
|
||||
afterState: toObject(event?.after_json || event?.afterState),
|
||||
createdAt: String(event?.created_at || event?.createdAt || '').trim()
|
||||
}))
|
||||
}
|
||||
@@ -91,6 +133,8 @@ export function normalizeRiskObservation(item = {}) {
|
||||
),
|
||||
feedbackItems: toArray(item.feedback_items || item.feedbackItems),
|
||||
disposition: normalizeRiskDisposition(item.disposition),
|
||||
availableActions: uniqueTexts(item.available_actions ?? item.availableActions),
|
||||
readOnlyReason: String(item.read_only_reason ?? item.readOnlyReason ?? '').trim(),
|
||||
createdAt: String(item.created_at || item.createdAt || '').trim(),
|
||||
updatedAt: String(item.updated_at || item.updatedAt || '').trim()
|
||||
}
|
||||
@@ -211,10 +255,23 @@ export async function executeRiskDispositionAction(observationId, payload = {})
|
||||
const resolution = String(payload.resolution || '').trim()
|
||||
const assignee = String(payload.assignee || '').trim()
|
||||
const dueAt = String(payload.dueAt || payload.due_at || '').trim()
|
||||
const waiverReason = String(payload.waiverReason || payload.waiver_reason || '').trim()
|
||||
const waiverScope = String(payload.waiverScope || payload.waiver_scope || '').trim()
|
||||
const waiverExpiresAt = String(
|
||||
payload.waiverExpiresAt || payload.waiver_expires_at || ''
|
||||
).trim()
|
||||
const waiverConditions = uniqueTexts(
|
||||
payload.waiverConditions ?? payload.waiver_conditions,
|
||||
20
|
||||
)
|
||||
if (comment) body.comment = comment
|
||||
if (resolution) body.resolution = resolution
|
||||
if (assignee) body.assignee = assignee
|
||||
if (dueAt) body.due_at = dueAt
|
||||
if (waiverReason) body.waiver_reason = waiverReason
|
||||
if (waiverScope) body.waiver_scope = waiverScope
|
||||
if (waiverExpiresAt) body.waiver_expires_at = waiverExpiresAt
|
||||
if (waiverConditions.length) body.waiver_conditions = waiverConditions
|
||||
|
||||
const result = await apiRequest(
|
||||
`/risk-observations/${encodeURIComponent(normalizedObservationId)}/disposition/actions`,
|
||||
@@ -232,3 +289,55 @@ export async function executeRiskDispositionAction(observationId, payload = {})
|
||||
requestId
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRiskDispositionError(error) {
|
||||
const code = String(error?.code || '').trim()
|
||||
const status = Number(error?.status || 0)
|
||||
const serverMessage = String(error?.message || '').trim()
|
||||
if (code === 'RISK_DISPOSITION_VERSION_CONFLICT') {
|
||||
return {
|
||||
code,
|
||||
title: '风险状态已更新',
|
||||
message: serverMessage || '其他处理人已更新该风险,请刷新后重新确认。',
|
||||
shouldRefresh: true
|
||||
}
|
||||
}
|
||||
if (code === 'RISK_WAIVER_DECISION_FORBIDDEN' || status === 403) {
|
||||
return {
|
||||
code: code || 'RISK_DISPOSITION_FORBIDDEN',
|
||||
title: '当前账号不能执行此操作',
|
||||
message: serverMessage || '服务端未授予当前风险处置动作。',
|
||||
shouldRefresh: true
|
||||
}
|
||||
}
|
||||
if (code === 'REQUEST_TIMEOUT') {
|
||||
return {
|
||||
code,
|
||||
title: '提交结果暂未确认',
|
||||
message: serverMessage || '请求已超时,请刷新状态后再决定是否重试。',
|
||||
shouldRefresh: true
|
||||
}
|
||||
}
|
||||
if (status === 409) {
|
||||
return {
|
||||
code: code || 'RISK_DISPOSITION_CONFLICT',
|
||||
title: '当前状态不允许该操作',
|
||||
message: serverMessage || '风险处置状态发生冲突,请刷新后重试。',
|
||||
shouldRefresh: true
|
||||
}
|
||||
}
|
||||
if (status === 422) {
|
||||
return {
|
||||
code: code || 'RISK_DISPOSITION_VALIDATION_FAILED',
|
||||
title: '提交内容不完整',
|
||||
message: serverMessage || '请检查必填项、有效期和输入长度。',
|
||||
shouldRefresh: false
|
||||
}
|
||||
}
|
||||
return {
|
||||
code: code || 'RISK_DISPOSITION_REQUEST_FAILED',
|
||||
title: '风险处置提交失败',
|
||||
message: serverMessage || '请稍后重试;如持续失败,请联系系统管理员。',
|
||||
shouldRefresh: false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user