import { isApplicationRequestLike } from './documentClassification.js' function parseNumber(value) { const nextValue = Number(value) return Number.isFinite(nextValue) ? nextValue : 0 } function normalizeText(value) { return String(value ?? '').trim() } 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)) } function formatPercent(value) { const percent = Number(value) if (!Number.isFinite(percent)) { return '0%' } return `${percent.toFixed(percent >= 10 || percent === 0 ? 0 : 1)}%` } function padDatePart(value) { return String(value).padStart(2, '0') } function formatDateTimeLabel(value) { if (value instanceof Date) { return [ value.getFullYear(), padDatePart(value.getMonth() + 1), padDatePart(value.getDate()) ].join('-') + ` ${padDatePart(value.getHours())}:${padDatePart(value.getMinutes())}` } const text = normalizeText(value) if (!text) { return '暂无时间' } const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{2}):(\d{2}))?/.exec(text) if (match) { return match[4] ? `${match[1]}-${match[2]}-${match[3]} ${match[4]}:${match[5]}` : `${match[1]}-${match[2]}-${match[3]}` } return text } function formatDurationLabel(milliseconds) { const duration = Number(milliseconds) if (!Number.isFinite(duration) || duration <= 0) { return '暂无耗时' } const minute = 60 * 1000 const hour = 60 * minute const day = 24 * hour if (duration < hour) { return `${Math.max(1, Math.round(duration / minute))}分钟` } if (duration < day) { return `${Math.max(1, Math.round(duration / hour))}小时` } return `${Math.ceil(duration / day)}天` } 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 resolveDocumentTypeLabel(request, requestId, title) { const explicitLabel = normalizeText(request?.documentTypeLabel || request?.document_type_label) const normalizedRequestId = normalizeText(requestId).toUpperCase() if ( isApplicationRequestLike(request) || explicitLabel.includes('申请') || title.includes('申请') || normalizedRequestId.startsWith('SQ') || normalizedRequestId.startsWith('CL') ) { return '申请单' } return explicitLabel || '报销单' } 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, statusText = '') { const status = String(statusText || '').trim() if (approvalKey === 'completed' || /完成|结束|通过/i.test(status)) return 'muted' if (approvalKey === 'pending_payment' || /付款|支付/i.test(status)) return 'warning' if (approvalKey === 'supplement' || approvalKey === 'rejected' || /退回|驳回|修改/i.test(status)) 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) || '费用单据' const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中' const documentTypeLabel = resolveDocumentTypeLabel(request, requestId, title) return { id: requestId, requestId, title, documentTypeLabel, expenseTypeLabel: resolveExpenseCategory(request), amount: formatCurrency(request?.amount), status, statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), status), 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] } function resolveExpenseCategory(request) { const explicitCategory = normalizeText( request?.expenseCategory || request?.expense_category || request?.category || request?.expenseType || request?.expense_type || request?.type ) if (explicitCategory) { return explicitCategory } const text = normalizeText([ request?.sceneLabel, request?.title, request?.note, request?.description ].filter(Boolean).join(' ')) if (/差旅|出差|交通|机票|火车|高铁|出租|网约车|住宿|酒店/i.test(text)) { return '差旅交通' } if (/招待|客户|餐饮|宴请|接待/i.test(text)) { return '业务招待' } if (/办公|采购|用品|设备|耗材/i.test(text)) { return '办公采购' } if (/培训|学习|课程|会议/i.test(text)) { return '培训会议' } if (/市场|活动|推广|宣传/i.test(text)) { return '市场活动' } return '其他费用' } function buildExpenseDistributionRows(ownedRequests) { const groups = new Map() for (const request of ownedRequests) { const category = resolveExpenseCategory(request) const amount = parseNumber(request?.amount) const group = groups.get(category) || { key: category, label: category, count: 0, amount: 0 } group.count += 1 group.amount += amount groups.set(category, group) } const totalAmount = Array.from(groups.values()).reduce((sum, item) => sum + item.amount, 0) const rows = Array.from(groups.values()) .sort((left, right) => right.amount - left.amount || right.count - left.count) .slice(0, 6) return rows.map((item) => { const percent = totalAmount > 0 ? (item.amount / totalAmount) * 100 : 0 return { ...item, amountLabel: formatCurrency(item.amount), percent, percentLabel: formatPercent(percent) } }) } function collectRequestDates(request) { const candidates = [ request?.createdAt, request?.submittedAt, request?.updatedAt, request?.applyTime, request?.occurredAt ] const steps = Array.isArray(request?.progressSteps) ? request.progressSteps : [] for (const step of steps) { candidates.push(step?.time, step?.createdAt, step?.updatedAt, step?.timestamp) } return candidates .map((item) => toDate(item)) .filter(Boolean) .sort((left, right) => left.getTime() - right.getTime()) } function buildExpenseProcessingRows(ownedRequests) { return ownedRequests .map((request) => { const dates = collectRequestDates(request) const requestId = resolveRequestIdentity(request) const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据' const startedAt = dates[0] || toDate(request?.createdAt || request?.submittedAt) const latestAt = dates[dates.length - 1] || toDate(request?.updatedAt || request?.submittedAt || request?.createdAt) const stepCount = Array.isArray(request?.progressSteps) ? request.progressSteps.length : 0 const status = normalizeText(request?.approvalStatus || request?.status) || '处理中' return { id: requestId || title, requestId, title, status, statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), status), startedAt: startedAt ? formatDateTimeLabel(startedAt) : '暂无开始时间', updatedAt: latestAt ? formatDateTimeLabel(latestAt) : '暂无更新时间', durationLabel: startedAt && latestAt ? formatDurationLabel(latestAt.getTime() - startedAt.getTime()) : '暂无耗时', stepCount, sortTime: latestAt ? latestAt.getTime() : 0 } }) .sort((left, right) => right.sortTime - left.sortTime) .slice(0, 6) .map(({ sortTime, ...item }) => item) } function buildExpenseOperationRows(todoItems, notifications, progressItems) { const rows = [] for (const item of todoItems) { rows.push({ id: `todo:${item.requestId || item.description}`, action: item.status || item.title, detail: item.description, time: item.due, tone: item.statusTone || 'info', source: '待办' }) } for (const item of notifications) { rows.push({ id: `notice:${item.id}`, action: item.title, detail: item.description, time: item.time, tone: item.tone || 'info', source: item.unread ? '未读提醒' : '系统提醒' }) } for (const item of progressItems.slice(0, 6)) { rows.push({ id: `progress:${item.requestId || item.id}`, action: item.status, detail: `${item.requestId || item.id || '单据'} · ${item.title}`, time: item.updatedAt || '最近更新', tone: item.statusTone || 'info', source: '进度' }) } const seen = new Set() return rows .filter((item) => { const key = [item.action, item.detail, item.time].join('|') if (seen.has(key)) { return false } seen.add(key) return true }) .sort((left, right) => normalizeText(right.time).localeCompare(normalizeText(left.time))) .slice(0, 8) } 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) 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 const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length const todoItems = buildTodoItems(ownedRequests) const progressItems = buildProgressItems(ownedRequests) const notifications = buildNotifications(todoItems, progressItems) const expenseStatsDetail = { distributionRows: buildExpenseDistributionRows(ownedRequests), processingRows: buildExpenseProcessingRows(ownedRequests), operationRows: buildExpenseOperationRows(todoItems, notifications, progressItems) } return { monthlyCount, monthlyAmount, monthlyAmountLabel: formatCurrency(monthlyAmount), totalCount, totalAmount, totalAmountLabel: formatCurrency(totalAmount), inReviewCount, pendingPaymentCount, completedCount, returnCount, highRiskCount, todoItems, progressItems, notifications, expenseStatsDetail, unreadNotificationCount: notifications.filter((item) => item.unread).length } }