feat: 新增预算费控模型与报销审批流引擎

后端新增预算费控服务和报销单审批流模块,引入申请人费用画像
算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常
量和明细同步,更新差旅报销规则电子表格,前端新增预算分析
组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧
边栏和顶栏样式,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-27 17:31:27 +08:00
parent cbb98f4469
commit d4d5d40569
75 changed files with 5393 additions and 686 deletions

View File

@@ -0,0 +1,263 @@
const DIGITAL_EMPLOYEE_AGENT = 'hermes'
const TASK_TYPE_LABELS = {
daily_risk_scan: '每日风险巡检',
global_risk_scan: '全局风险巡检',
weekly_ar_summary: '周度应收账龄汇总',
weekly_expense_report: '周度费用洞察',
rule_review_digest: '规则待审摘要',
knowledge_index_sync: '知识库归集',
x_financial_callback: '任务回调上报'
}
const CONTENT_LABELS = {
task_type: '技能类型',
schedule: '执行计划',
cron: '调度表达式',
folder: '归集范围',
changed_only: '仅处理变更',
force: '强制重建',
index_engine: '索引引擎',
callback_type: '回调类型',
status: '回写状态',
summary: '结果摘要'
}
const HIDDEN_CONTENT_KEYS = new Set([
'agent',
'target_agent',
'callback_token',
'token',
'api_key',
'authorization'
])
export function normalizeDigitalEmployeeText(value) {
return String(value ?? '').trim()
}
export function sanitizeDigitalEmployeeText(value, fallback = '') {
const text = normalizeDigitalEmployeeText(value)
.replace(/hermes/gi, '数字员工')
.replace(/赫尔墨斯/g, '数字员工')
.replace(/\s+/g, ' ')
.trim()
return text || fallback
}
export function sanitizeDigitalEmployeeName(value, fallback = '数字员工技能') {
const text = sanitizeDigitalEmployeeText(value, fallback)
.replace(/^数字员工[\s·:-]*/i, '')
.trim()
return text || fallback
}
export function parseDigitalEmployeeContent(value) {
if (!value) {
return {}
}
if (typeof value === 'object' && !Array.isArray(value)) {
return value
}
if (typeof value !== 'string') {
return {}
}
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
export function resolveDigitalEmployeeTaskType(source = {}, content = {}) {
const config = source.config_json || source.configJson || {}
const raw =
normalizeDigitalEmployeeText(content.task_type) ||
normalizeDigitalEmployeeText(config.task_type) ||
normalizeDigitalEmployeeText(source.task_type) ||
normalizeDigitalEmployeeText(source.code).replace(/^task\.hermes\./i, '')
return raw.replace(/[-.]/g, '_')
}
export function isDigitalEmployeeAsset(source = {}) {
const config = source.config_json || source.configJson || {}
const haystack = [
source.asset_type,
source.code,
source.name,
source.description,
config.agent,
config.target_agent,
config.worker,
config.runtime_agent
]
.map((item) => normalizeDigitalEmployeeText(item).toLowerCase())
.filter(Boolean)
.join(' ')
return (
normalizeDigitalEmployeeText(source.asset_type) === 'task' &&
(haystack.includes(DIGITAL_EMPLOYEE_AGENT) || haystack.includes('task.hermes.'))
)
}
export function formatDigitalEmployeeCron(value) {
const raw = normalizeDigitalEmployeeText(value)
if (!raw) {
return '手动触发'
}
const parts = raw.split(/\s+/)
if (parts.length < 5) {
return sanitizeDigitalEmployeeText(raw)
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
const hourNumber = Number(hour)
const minuteNumber = Number(minute)
const timeLabel =
Number.isFinite(hourNumber) && Number.isFinite(minuteNumber)
? `${String(hourNumber).padStart(2, '0')}:${String(minuteNumber).padStart(2, '0')}`
: `${hour}:${minute}`
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
return `每天 ${timeLabel}`
}
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
const weekdayLabels = {
'0': '周日',
'1': '周一',
'2': '周二',
'3': '周三',
'4': '周四',
'5': '周五',
'6': '周六',
'7': '周日'
}
return `${weekdayLabels[dayOfWeek] || `${dayOfWeek}`} ${timeLabel}`
}
return sanitizeDigitalEmployeeText(raw)
}
export function resolveDigitalEmployeeSchedule(source = {}, content = {}) {
const config = source.config_json || source.configJson || {}
const raw =
normalizeDigitalEmployeeText(content.schedule) ||
normalizeDigitalEmployeeText(config.cron) ||
normalizeDigitalEmployeeText(config.schedule) ||
normalizeDigitalEmployeeText(config.cron_expression)
return {
value: raw,
label: formatDigitalEmployeeCron(raw)
}
}
export function resolveDigitalEmployeeEnabled(source = {}) {
const config = source.config_json || source.configJson || {}
if (config.enabled === false || config.is_enabled === false) {
return false
}
if (source.enabled === false || source.is_enabled === false) {
return false
}
return normalizeDigitalEmployeeText(source.status || 'active') === 'active'
}
export function resolveDigitalEmployeeDisplayCode(source = {}, content = {}) {
const taskType = resolveDigitalEmployeeTaskType(source, content)
return taskType ? `digital.${taskType}` : 'digital.skill'
}
function formatDigitalEmployeeValue(value) {
if (typeof value === 'boolean') {
return value ? '是' : '否'
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeDigitalEmployeeText(item)).filter(Boolean).join('、') || '-'
}
if (value && typeof value === 'object') {
return sanitizeDigitalEmployeeText(JSON.stringify(value, null, 2))
}
return sanitizeDigitalEmployeeText(value, '-')
}
export function buildDigitalEmployeeContentRows(content = {}) {
return Object.entries(content)
.filter(([key]) => !HIDDEN_CONTENT_KEYS.has(normalizeDigitalEmployeeText(key).toLowerCase()))
.map(([key, value]) => ({
key,
label: CONTENT_LABELS[key] || key,
value: formatDigitalEmployeeValue(value)
}))
}
export function buildDigitalEmployeeContentPreview(content = {}) {
const visiblePayload = {}
for (const [key, value] of Object.entries(content)) {
if (HIDDEN_CONTENT_KEYS.has(normalizeDigitalEmployeeText(key).toLowerCase())) {
continue
}
visiblePayload[key] = value
}
return sanitizeDigitalEmployeeText(JSON.stringify(visiblePayload, null, 2))
}
export function buildDigitalEmployeeListMeta(source = {}) {
const content = parseDigitalEmployeeContent(source.current_version_content)
const taskType = resolveDigitalEmployeeTaskType(source, content)
const schedule = resolveDigitalEmployeeSchedule(source, content)
const enabled = resolveDigitalEmployeeEnabled(source)
const fallbackName = TASK_TYPE_LABELS[taskType] || '数字员工技能'
return {
name: sanitizeDigitalEmployeeName(source.name, fallbackName),
code: resolveDigitalEmployeeDisplayCode(source, content),
summary: sanitizeDigitalEmployeeText(source.description, '面向后台自动执行的数字员工技能。'),
category: '数字员工',
owner: sanitizeDigitalEmployeeText(source.owner, '平台运营'),
reviewer: sanitizeDigitalEmployeeText(source.reviewer, '系统'),
scope: schedule.label,
scheduleLabel: schedule.label,
executionMode: schedule.value ? '定时执行' : '手动触发',
enabled,
enabledLabel: enabled ? '已启动' : '未启动',
enabledTone: enabled ? 'success' : 'disabled',
taskType
}
}
export function buildDigitalEmployeeDetailMeta(source = {}) {
const content = parseDigitalEmployeeContent(source.current_version_content)
const listMeta = buildDigitalEmployeeListMeta({
...source,
current_version_content: content
})
const schedule = resolveDigitalEmployeeSchedule(source, content)
const contentRows = buildDigitalEmployeeContentRows(content)
return {
...listMeta,
rawCode: normalizeDigitalEmployeeText(source.code),
description: sanitizeDigitalEmployeeText(
source.description,
'该技能由后台数字员工按计划执行,并把结果沉淀到对应业务资产或运行日志中。'
),
contentRows,
contentPreview: buildDigitalEmployeeContentPreview(content),
scheduleRows: [
{ label: '执行计划', value: schedule.label },
{ label: '调度表达式', value: schedule.value || '手动触发' },
{ label: '启动状态', value: listMeta.enabledLabel, tone: listMeta.enabledTone },
{ label: '执行方式', value: listMeta.executionMode }
],
overviewRows: [
{ label: '能力编号', value: listMeta.code },
{ label: '业务归口', value: listMeta.owner },
{ label: '当前版本', value: source.working_version || source.current_version || '-' },
{ label: '最近更新', value: source.updated_at || '-' }
]
}
}