2026-05-20 21:00:47 +08:00
|
|
|
|
function parseNumber(value) {
|
|
|
|
|
|
const nextValue = Number(value)
|
|
|
|
|
|
return Number.isFinite(nextValue) ? nextValue : 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 09:25:23 +08:00
|
|
|
|
function normalizeText(value) {
|
|
|
|
|
|
return String(value ?? '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
function toDate(value) {
|
|
|
|
|
|
if (!value) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const nextDate = new Date(value)
|
|
|
|
|
|
return Number.isNaN(nextDate.getTime()) ? null : nextDate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isCurrentMonth(dateValue) {
|
|
|
|
|
|
const date = toDate(dateValue)
|
|
|
|
|
|
if (!date) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveClaimDate(request) {
|
|
|
|
|
|
return request?.submittedAt || request?.createdAt || request?.occurredAt || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function belongsToCurrentUser(request, currentUser) {
|
|
|
|
|
|
const person = String(request?.person || request?.employeeName || '').trim()
|
|
|
|
|
|
if (!person) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const names = [
|
|
|
|
|
|
String(currentUser?.name || '').trim(),
|
|
|
|
|
|
String(currentUser?.username || '').trim()
|
|
|
|
|
|
].filter(Boolean)
|
|
|
|
|
|
|
|
|
|
|
|
return names.some((name) => name === person)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function hasHighRiskFlag(request) {
|
|
|
|
|
|
const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : []
|
|
|
|
|
|
|
|
|
|
|
|
if (riskFlags.some((item) => String(item?.severity || '').trim().toLowerCase() === 'high')) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const summary = String(request?.riskSummary || '').trim()
|
|
|
|
|
|
return summary.includes('高')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatCurrency(value) {
|
|
|
|
|
|
return new Intl.NumberFormat('zh-CN', {
|
|
|
|
|
|
style: 'currency',
|
|
|
|
|
|
currency: 'CNY',
|
|
|
|
|
|
minimumFractionDigits: 0,
|
|
|
|
|
|
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
|
|
|
|
|
|
}).format(parseNumber(value))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 09:25:23 +08:00
|
|
|
|
function resolveRequestIdentity(request) {
|
|
|
|
|
|
return normalizeText(request?.claimNo || request?.claim_no || request?.id || request?.claimId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveRequestTarget(request) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
type: 'document',
|
|
|
|
|
|
id: normalizeText(request?.claimId || request?.id),
|
|
|
|
|
|
claimNo: resolveRequestIdentity(request)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveStatusTone(approvalKey) {
|
|
|
|
|
|
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
|
|
|
|
|
|
if (approvalKey === 'draft') return 'success'
|
|
|
|
|
|
if (approvalKey === 'pending_payment') return 'warning'
|
|
|
|
|
|
if (approvalKey === 'in_progress') return 'info'
|
|
|
|
|
|
return 'muted'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveTodoAction(request) {
|
|
|
|
|
|
const approvalKey = normalizeText(request?.approvalKey)
|
|
|
|
|
|
const status = normalizeText(request?.status || request?.approvalStatus)
|
|
|
|
|
|
|
|
|
|
|
|
if (approvalKey === 'supplement' || approvalKey === 'rejected') {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: '补充或修改单据',
|
|
|
|
|
|
status: approvalKey === 'rejected' ? '退回修改' : '待补充',
|
|
|
|
|
|
statusTone: 'danger',
|
|
|
|
|
|
iconKey: 'receipts',
|
|
|
|
|
|
color: 'var(--danger)',
|
|
|
|
|
|
accent: 'var(--danger-soft)'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (approvalKey === 'draft' || /draft|草稿|待提交/i.test(status)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: '提交草稿单据',
|
|
|
|
|
|
status: '待提交',
|
|
|
|
|
|
statusTone: 'success',
|
|
|
|
|
|
iconKey: 'travelDraft',
|
|
|
|
|
|
color: 'var(--theme-primary)',
|
|
|
|
|
|
accent: 'var(--theme-primary-soft)'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildTodoItems(ownedRequests) {
|
|
|
|
|
|
return ownedRequests
|
|
|
|
|
|
.map((request) => {
|
|
|
|
|
|
const action = resolveTodoAction(request)
|
|
|
|
|
|
if (!action) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const requestId = resolveRequestIdentity(request)
|
|
|
|
|
|
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || requestId
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...action,
|
|
|
|
|
|
id: requestId,
|
|
|
|
|
|
requestId,
|
|
|
|
|
|
title: action.title,
|
|
|
|
|
|
description: `${requestId || '单据'} · ${title || '费用单据'}`,
|
|
|
|
|
|
due: normalizeText(request?.updatedAt || request?.applyTime || request?.submittedAt) || '待处理',
|
|
|
|
|
|
target: resolveRequestTarget(request),
|
|
|
|
|
|
prompt: `帮我处理 ${requestId || title}:${action.status}`
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.sort((left, right) => normalizeText(right.due).localeCompare(normalizeText(left.due)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveProgressStatusTone(approvalKey) {
|
|
|
|
|
|
if (approvalKey === 'completed') return 'muted'
|
|
|
|
|
|
if (approvalKey === 'pending_payment') return 'warning'
|
|
|
|
|
|
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger'
|
|
|
|
|
|
return 'success'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveCurrentProgressIndex(steps) {
|
|
|
|
|
|
const currentIndex = steps.findIndex((step) => step?.current)
|
|
|
|
|
|
if (currentIndex >= 0) {
|
|
|
|
|
|
return currentIndex
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const activeIndex = steps.findLastIndex((step) => step?.active || step?.done)
|
|
|
|
|
|
return Math.max(0, activeIndex)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildAdjacentProgressSteps(steps = [], windowSize = 4) {
|
|
|
|
|
|
const rows = Array.isArray(steps) ? steps : []
|
|
|
|
|
|
if (!rows.length) {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const currentIndex = resolveCurrentProgressIndex(rows)
|
|
|
|
|
|
const safeWindowSize = Math.max(1, Number(windowSize) || 4)
|
|
|
|
|
|
let start = Math.max(0, currentIndex - 1)
|
|
|
|
|
|
let end = Math.min(rows.length, start + safeWindowSize)
|
|
|
|
|
|
|
|
|
|
|
|
if (end - start < safeWindowSize) {
|
|
|
|
|
|
start = Math.max(0, end - safeWindowSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return rows.slice(start, end).map((step) => ({
|
|
|
|
|
|
label: normalizeText(step.label || step.rawLabel),
|
|
|
|
|
|
done: Boolean(step.done),
|
|
|
|
|
|
current: Boolean(step.current),
|
|
|
|
|
|
title: normalizeText(step.title || step.time || step.detail)
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildProgressItems(ownedRequests) {
|
|
|
|
|
|
return ownedRequests
|
|
|
|
|
|
.filter((request) => Array.isArray(request?.progressSteps) && request.progressSteps.length)
|
|
|
|
|
|
.map((request) => {
|
|
|
|
|
|
const requestId = resolveRequestIdentity(request)
|
|
|
|
|
|
const steps = buildAdjacentProgressSteps(request.progressSteps, 4)
|
|
|
|
|
|
const currentStep = steps.find((step) => step.current)
|
|
|
|
|
|
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: requestId,
|
|
|
|
|
|
requestId,
|
|
|
|
|
|
title,
|
|
|
|
|
|
amount: formatCurrency(request?.amount),
|
|
|
|
|
|
status: normalizeText(request?.approvalStatus || currentStep?.label) || '处理中',
|
|
|
|
|
|
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)),
|
|
|
|
|
|
updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt),
|
|
|
|
|
|
steps,
|
|
|
|
|
|
target: resolveRequestTarget(request),
|
|
|
|
|
|
prompt: `查询 ${requestId || title} 的费用进度`
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.sort((left, right) => normalizeText(right.updatedAt).localeCompare(normalizeText(left.updatedAt)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildNotifications(todoItems, progressItems) {
|
|
|
|
|
|
const todoNotifications = todoItems.map((item) => ({
|
|
|
|
|
|
id: `todo:${item.requestId || item.description}`,
|
|
|
|
|
|
title: item.status,
|
|
|
|
|
|
description: item.description,
|
|
|
|
|
|
time: item.due,
|
|
|
|
|
|
unread: true,
|
|
|
|
|
|
tone: item.statusTone,
|
|
|
|
|
|
target: item.target,
|
|
|
|
|
|
prompt: item.prompt
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
const progressNotifications = progressItems
|
|
|
|
|
|
.filter((item) => ['danger', 'warning'].includes(item.statusTone))
|
|
|
|
|
|
.map((item) => ({
|
|
|
|
|
|
id: `progress:${item.requestId || item.title}`,
|
|
|
|
|
|
title: item.status,
|
|
|
|
|
|
description: `${item.requestId || '单据'} · ${item.title}`,
|
|
|
|
|
|
time: item.updatedAt || '最近更新',
|
|
|
|
|
|
unread: false,
|
|
|
|
|
|
tone: item.statusTone,
|
|
|
|
|
|
target: item.target,
|
|
|
|
|
|
prompt: item.prompt
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
return [...todoNotifications, ...progressNotifications]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
export function buildWorkbenchSummary(requests, currentUser) {
|
|
|
|
|
|
const ownedRequests = Array.isArray(requests)
|
|
|
|
|
|
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
|
|
|
|
|
|
: []
|
|
|
|
|
|
|
|
|
|
|
|
const monthlyClaims = ownedRequests.filter((item) => isCurrentMonth(resolveClaimDate(item)))
|
|
|
|
|
|
|
|
|
|
|
|
const monthlyCount = monthlyClaims.length
|
|
|
|
|
|
const monthlyAmount = monthlyClaims.reduce((sum, item) => sum + parseNumber(item.amount), 0)
|
2026-05-28 09:30:34 +08:00
|
|
|
|
const totalCount = ownedRequests.length
|
|
|
|
|
|
const totalAmount = ownedRequests.reduce((sum, item) => sum + parseNumber(item.amount), 0)
|
|
|
|
|
|
const inReviewCount = ownedRequests.filter((item) => item.approvalKey === 'in_progress').length
|
|
|
|
|
|
const pendingPaymentCount = ownedRequests.filter((item) => {
|
|
|
|
|
|
const status = String(item.status || item.approvalStatus || '').trim()
|
|
|
|
|
|
return status.includes('待付款') || status.includes('待支付')
|
|
|
|
|
|
}).length
|
|
|
|
|
|
const completedCount = ownedRequests.filter((item) => item.approvalKey === 'completed').length
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length
|
|
|
|
|
|
const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length
|
2026-06-03 09:25:23 +08:00
|
|
|
|
const todoItems = buildTodoItems(ownedRequests)
|
|
|
|
|
|
const progressItems = buildProgressItems(ownedRequests)
|
|
|
|
|
|
const notifications = buildNotifications(todoItems, progressItems)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
monthlyCount,
|
|
|
|
|
|
monthlyAmount,
|
|
|
|
|
|
monthlyAmountLabel: formatCurrency(monthlyAmount),
|
2026-05-28 09:30:34 +08:00
|
|
|
|
totalCount,
|
|
|
|
|
|
totalAmount,
|
|
|
|
|
|
totalAmountLabel: formatCurrency(totalAmount),
|
|
|
|
|
|
inReviewCount,
|
|
|
|
|
|
pendingPaymentCount,
|
|
|
|
|
|
completedCount,
|
2026-05-20 21:00:47 +08:00
|
|
|
|
returnCount,
|
2026-06-03 09:25:23 +08:00
|
|
|
|
highRiskCount,
|
|
|
|
|
|
todoItems,
|
|
|
|
|
|
progressItems,
|
|
|
|
|
|
notifications,
|
|
|
|
|
|
unreadNotificationCount: notifications.filter((item) => item.unread).length
|
2026-05-20 21:00:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|