Files
X-Financial/web/src/utils/workbenchSummary.js
caoxiaozhu 0cde1f8990 feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源
- 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore
  及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿
- 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局
- 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
2026-06-18 22:12:24 +08:00

676 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isApplicationRequestLike } from './documentClassification.js'
import { canProcessApprovalRequest } from './approvalInbox.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)
}
function belongsToWorkbenchProgressScope(request, currentUser) {
if (belongsToCurrentUser(request, currentUser)) {
return true
}
return normalizeText(request?.approvalKey) === 'in_progress'
&& canProcessApprovalRequest(request, currentUser)
}
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 formatMonthKey(date) {
return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}`
}
function formatMonthLabel(date) {
return `${date.getMonth() + 1}`
}
function shiftMonth(date, offset) {
return new Date(date.getFullYear(), date.getMonth() + offset, 1)
}
function resolveMonthStart(date) {
return new Date(date.getFullYear(), date.getMonth(), 1)
}
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 resolveApplicantLabel(request) {
return normalizeText(
request?.person
|| request?.employeeName
|| request?.employee_name
|| request?.applicant
|| request?.profileName
) || '待补充'
}
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)
}))
}
const FALLBACK_APPLICATION_PROGRESS_LABELS = Object.freeze([
'创建申请',
'直属领导审批',
'关联单据状态',
'已归档'
])
const FALLBACK_REIMBURSEMENT_PROGRESS_LABELS = Object.freeze([
'关联单据',
'待提交',
'直属领导审批',
'财务审批',
'待付款',
'已付款',
'已归档'
])
function resolveFallbackProgressCurrentIndex(labels, request, documentTypeLabel) {
const approvalKey = normalizeText(request?.approvalKey)
const status = normalizeText(request?.approvalStatus || request?.status || request?.workflowNode)
if (documentTypeLabel === '申请单') {
if (/归档/.test(status)) return labels.indexOf('已归档')
if (approvalKey === 'completed' || /完成|通过/.test(status)) return labels.indexOf('关联单据状态')
if (/直属领导|领导审批|负责人/.test(status)) return labels.indexOf('直属领导审批')
return 0
}
if (approvalKey === 'completed' || /归档|已付款|完成/.test(status)) return labels.indexOf('已归档')
if (approvalKey === 'pending_payment' || /待付款|待支付/.test(status)) return labels.indexOf('待付款')
if (/财务/.test(status)) return labels.indexOf('财务审批')
if (/直属领导|领导审批|负责人/.test(status)) return labels.indexOf('直属领导审批')
return labels.indexOf('待提交')
}
function buildFallbackProgressSteps(request, documentTypeLabel) {
const labels = documentTypeLabel === '申请单'
? FALLBACK_APPLICATION_PROGRESS_LABELS
: FALLBACK_REIMBURSEMENT_PROGRESS_LABELS
const currentIndex = Math.max(0, resolveFallbackProgressCurrentIndex(labels, request, documentTypeLabel))
return labels.map((label, index) => ({
label,
rawLabel: label,
done: index < currentIndex,
active: index <= currentIndex,
current: index === currentIndex,
title: index === currentIndex ? '进行中' : index < currentIndex ? '已完成' : '待处理'
}))
}
function buildProgressItems(ownedRequests) {
return ownedRequests
.map((request) => {
const requestId = resolveRequestIdentity(request)
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
const documentTypeLabel = resolveDocumentTypeLabel(request, requestId, title)
const sourceSteps = Array.isArray(request?.progressSteps) && request.progressSteps.length
? request.progressSteps
: buildFallbackProgressSteps(request, documentTypeLabel)
const steps = buildAdjacentProgressSteps(sourceSteps, 4)
const currentStep = steps.find((step) => step.current)
const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中'
return {
id: requestId,
requestId,
title,
documentTypeLabel,
applicantLabel: resolveApplicantLabel(request),
expenseTypeLabel: resolveExpenseCategory(request),
amount: formatCurrency(request?.amount),
status,
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), status),
updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt || request?.applyTime),
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)
}
function buildMonthlyAmountMap(ownedRequests) {
const rows = new Map()
for (const request of ownedRequests) {
const date = toDate(resolveClaimDate(request))
if (!date) {
continue
}
const key = formatMonthKey(date)
rows.set(key, (rows.get(key) || 0) + parseNumber(request?.amount))
}
return rows
}
function resolveTrendAnchorDate(ownedRequests) {
const dates = ownedRequests
.map((request) => toDate(resolveClaimDate(request)))
.filter(Boolean)
.sort((left, right) => right.getTime() - left.getTime())
return resolveMonthStart(dates[0] || new Date())
}
function buildReimbursementTrendRows(ownedRequests) {
const monthlyAmountMap = buildMonthlyAmountMap(ownedRequests)
const anchor = resolveTrendAnchorDate(ownedRequests)
return Array.from({ length: 6 }, (_, index) => {
const month = shiftMonth(anchor, index - 5)
const previousMonth = shiftMonth(month, -12)
const key = formatMonthKey(month)
const previousKey = formatMonthKey(previousMonth)
const amount = monthlyAmountMap.get(key) || 0
const previousAmount = monthlyAmountMap.get(previousKey) || 0
return {
key,
label: formatMonthLabel(month),
amount,
amountLabel: formatCurrency(amount),
previousKey,
previousAmount,
previousAmountLabel: formatCurrency(previousAmount)
}
})
}
export function buildWorkbenchSummary(requests, currentUser) {
const allRequests = Array.isArray(requests)
? requests
: []
const ownedRequests = allRequests.filter((item) => belongsToCurrentUser(item, currentUser))
const progressRequests = allRequests.filter((item) => belongsToWorkbenchProgressScope(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(progressRequests)
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,
reimbursementTrendRows: buildReimbursementTrendRows(ownedRequests),
notifications,
expenseStatsDetail,
unreadNotificationCount: notifications.filter((item) => item.unread).length
}
}