feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
@@ -121,6 +121,10 @@ function resolveDaysFromDateRange(rangeText) {
|
||||
return diffDays >= 0 ? `${diffDays + 1}天` : ''
|
||||
}
|
||||
|
||||
export function resolveApplicationDaysFromDateRange(rangeText) {
|
||||
return resolveDaysFromDateRange(rangeText)
|
||||
}
|
||||
|
||||
function resolvePreviewToday(options = {}) {
|
||||
const explicitToday = String(options.today || options.currentDate || '').trim()
|
||||
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
|
||||
|
||||
119
web/src/utils/travelApplicationPlanning.js
Normal file
119
web/src/utils/travelApplicationPlanning.js
Normal file
@@ -0,0 +1,119 @@
|
||||
export const TRAVEL_PLANNING_ACTION_GENERATE = 'generate_travel_application_plan'
|
||||
export const TRAVEL_PLANNING_ACTION_SKIP = 'skip_travel_application_plan'
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function isTravelApplication(applicationType = '') {
|
||||
return /差旅|出差/.test(normalizeText(applicationType))
|
||||
}
|
||||
|
||||
function extractDateParts(timeText = '') {
|
||||
const dates = normalizeText(timeText).match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
return {
|
||||
startDate: dates[0] || '',
|
||||
endDate: dates[dates.length - 1] || dates[0] || ''
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTravelPlanningContext(preview = {}, draftPayload = {}) {
|
||||
const fields = preview?.fields && typeof preview.fields === 'object' ? preview.fields : {}
|
||||
const applicationType = normalizeText(fields.applicationType)
|
||||
if (!isTravelApplication(applicationType)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const location = normalizeText(fields.location)
|
||||
const time = normalizeText(fields.time)
|
||||
if (!location || !time) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dates = extractDateParts(time)
|
||||
return {
|
||||
applicationType,
|
||||
location,
|
||||
time,
|
||||
startDate: dates.startDate,
|
||||
endDate: dates.endDate,
|
||||
days: normalizeText(fields.days),
|
||||
transportMode: normalizeText(fields.transportMode),
|
||||
reason: normalizeText(fields.reason),
|
||||
claimNo: normalizeText(draftPayload?.claim_no || draftPayload?.claimNo)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTravelPlanningNudgeMessage(preview = {}, draftPayload = {}) {
|
||||
const context = resolveTravelPlanningContext(preview, draftPayload)
|
||||
if (!context) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const timeCopy = context.startDate && context.endDate && context.startDate !== context.endDate
|
||||
? `${context.startDate} 至 ${context.endDate}`
|
||||
: context.time
|
||||
const transportCopy = context.transportMode ? `、${context.transportMode}时间窗口` : '、交通方式比选'
|
||||
return [
|
||||
`本次${context.location}差旅申请已经提交。`,
|
||||
`如果你愿意,我可以继续按 ${timeCopy} 帮你整理一版行程规划,包括出发/返程${transportCopy}、酒店区域建议和还需要确认的事项。`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildTravelPlanningSuggestedActions(preview = {}, draftPayload = {}) {
|
||||
const context = resolveTravelPlanningContext(preview, draftPayload)
|
||||
if (!context) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: '生成行程规划',
|
||||
action_type: TRAVEL_PLANNING_ACTION_GENERATE,
|
||||
description: '按本次申请的地点和时间给出交通、酒店和待确认事项。',
|
||||
icon: 'mdi mdi-map-clock-outline',
|
||||
emphasis: 'primary',
|
||||
payload: {
|
||||
context
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '暂不需要',
|
||||
action_type: TRAVEL_PLANNING_ACTION_SKIP,
|
||||
description: '保留申请结果,不继续生成规划。',
|
||||
icon: 'mdi mdi-check-outline',
|
||||
payload: {
|
||||
context
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildTravelPlanningRecommendation(preview = {}, draftPayload = {}) {
|
||||
const context = resolveTravelPlanningContext(preview, draftPayload)
|
||||
if (!context) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const outboundDate = context.startDate || '出发当天'
|
||||
const returnDate = context.endDate || '返回当天'
|
||||
const transport = context.transportMode || '火车/飞机'
|
||||
const reasonLine = context.reason ? `业务安排:${context.reason}` : '业务安排:以申请事由为准,出发前再确认具体到场时间。'
|
||||
const hotelArea = `${context.location}核心办公区、客户现场周边或交通枢纽 30 分钟通勤范围内`
|
||||
const claimLine = context.claimNo ? `关联申请单:${context.claimNo}` : ''
|
||||
|
||||
return [
|
||||
'可以,先给你一版轻量行程规划,后续你可以继续补充偏好。',
|
||||
'',
|
||||
claimLine,
|
||||
`行程时间:${context.time}${context.days ? `(${context.days})` : ''}`,
|
||||
reasonLine,
|
||||
'',
|
||||
`交通建议:${outboundDate} 优先看上午到中午抵达 ${context.location} 的${transport}班次,预留到达后 1.5 小时交通和现场准备时间;${returnDate} 优先看下午或晚间返程,避免压缩最后一天工作安排。`,
|
||||
`酒店建议:优先选择${hotelArea},同时关注可开发票、可取消、早餐和离现场距离。`,
|
||||
'需要确认:出发城市、客户现场地址、是否需要同行人、是否有指定住宿协议酒店、是否需要提前准备会议室或网络环境。',
|
||||
'',
|
||||
'你也可以继续告诉我出发城市、偏好的交通方式或预算,我再把规划细化成更具体的时间段。'
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ function parseNumber(value) {
|
||||
return Number.isFinite(nextValue) ? nextValue : 0
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function toDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
@@ -60,6 +64,174 @@ function formatCurrency(value) {
|
||||
}).format(parseNumber(value))
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
export function buildWorkbenchSummary(requests, currentUser) {
|
||||
const ownedRequests = Array.isArray(requests)
|
||||
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
|
||||
@@ -79,6 +251,9 @@ export function buildWorkbenchSummary(requests, currentUser) {
|
||||
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)
|
||||
|
||||
return {
|
||||
monthlyCount,
|
||||
@@ -91,6 +266,10 @@ export function buildWorkbenchSummary(requests, currentUser) {
|
||||
pendingPaymentCount,
|
||||
completedCount,
|
||||
returnCount,
|
||||
highRiskCount
|
||||
highRiskCount,
|
||||
todoItems,
|
||||
progressItems,
|
||||
notifications,
|
||||
unreadNotificationCount: notifications.filter((item) => item.unread).length
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user