- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
535 lines
16 KiB
JavaScript
535 lines
16 KiB
JavaScript
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
|
||
}
|
||
}
|