Files
X-Financial/web/src/utils/workbenchSummary.js
caoxiaozhu e124e4bbcb feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
2026-06-06 17:19:07 +08:00

535 lines
16 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'
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
}
}