Files
X-Financial/web/src/views/scripts/budgetAssistantReportModel.js
caoxiaozhu 92444e7eae feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
2026-06-01 17:07:14 +08:00

316 lines
11 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.
const QUARTER_NAME_MAP = {
1: 'Q1',
2: 'Q2',
3: 'Q3',
4: 'Q4'
}
const CHINESE_QUARTER_MAP = {
: 1,
: 2,
: 3,
: 4
}
const BUDGET_REPORT_COLORS = {
travel: 'var(--theme-primary)',
meal: 'var(--theme-secondary)',
office: 'var(--warning)',
communication: 'var(--info)'
}
const PREVIOUS_QUARTER_SPEND = [
{
key: 'travel',
name: '差旅',
value: 468000,
previousValue: 395600,
recommendedBudget: 550000,
color: BUDGET_REPORT_COLORS.travel,
drivers: ['客户现场实施增加', '跨区域项目支持', '住宿单价上浮'],
risk: 'Q2 差旅占比最高Q3 如果继续集中出差,建议把提醒阈值放在 75%。',
suggestion: '建议 Q3 编制 52-56 万,优先锁定核心项目差旅,非项目型出行走事前说明。'
},
{
key: 'meal',
name: '招待费',
value: 286000,
previousValue: 255100,
recommendedBudget: 320000,
color: BUDGET_REPORT_COLORS.meal,
drivers: ['重点客户拜访', '渠道活动增多', '单次人均金额偏高'],
risk: '招待费增长快于整体费用,容易触发合规说明和客户关联材料补充。',
suggestion: '建议 Q3 编制 30-32 万,并按客户拓展活动拆分额度,避免月底集中消耗。'
},
{
key: 'office',
name: '办公用品',
value: 181600,
previousValue: 172000,
recommendedBudget: 200000,
color: BUDGET_REPORT_COLORS.office,
drivers: ['新员工入职物资', '会议设备补充', '季度集中采购'],
risk: '办公用品整体平稳,但集中采购会造成单月占用偏高。',
suggestion: '建议 Q3 编制 19-20 万,采用月度采购节奏,把一次性采购纳入占用预留。'
},
{
key: 'communication',
name: '通信',
value: 98000,
previousValue: 102800,
recommendedBudget: 110000,
color: BUDGET_REPORT_COLORS.communication,
drivers: ['固定通讯补贴', '项目远程协同', '少量专线费用'],
risk: '通信费用占比较低且略有下降,适合维持刚性额度。',
suggestion: '建议 Q3 编制 10-11 万,保留 8% 弹性池用于项目临时通讯支出。'
}
]
const currency = (value) =>
Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
const compactCurrency = (value) => `¥${(Number(value || 0) / 10000).toFixed(1)}`
const percent = (value, total) => {
const denominator = Number(total || 0)
if (!denominator) return '0.0%'
return `${((Number(value || 0) / denominator) * 100).toFixed(1)}%`
}
function normalizeBudgetText(rawText) {
return String(rawText || '')
.replace(/\s+/g, '')
.toLowerCase()
}
function parseQuarter(rawText) {
const text = String(rawText || '')
const qMatch = text.match(/[qQ]\s*([1-4])/)
if (qMatch) return Number(qMatch[1])
const numberMatch = text.match(/第?\s*([1-4])\s*(?:季|季度)/)
if (numberMatch) return Number(numberMatch[1])
const chineseMatch = text.match(/第?\s*([一二三四])\s*(?:季|季度)/)
if (chineseMatch) return CHINESE_QUARTER_MAP[chineseMatch[1]] || 0
return 0
}
function parseYear(rawText) {
const match = String(rawText || '').match(/(20\d{2})/)
return match ? Number(match[1]) : 2026
}
function hasExplicitYear(rawText) {
return /(20\d{2})/.test(String(rawText || ''))
}
function resolvePreviousPeriod(year, quarter) {
if (quarter > 1) {
return { year, quarter: quarter - 1 }
}
return { year: year - 1, quarter: 4 }
}
export function shouldUseBudgetCompileReport(rawText, options = {}) {
if (String(options.sessionType || '').trim() !== 'budget') {
return false
}
const text = normalizeBudgetText(rawText)
const hasTargetPeriod = parseQuarter(rawText) || hasExplicitYear(rawText)
return Boolean(
text &&
/(预算|budget)/.test(text) &&
/(编制|制定|测算|生成|规划|预算一下|compile|create|plan)/.test(text) &&
hasTargetPeriod
)
}
export function buildBudgetCompileReport(rawText, user = {}) {
const targetYear = parseYear(rawText)
const parsedQuarter = parseQuarter(rawText)
const isAnnualBudget = !parsedQuarter
const targetQuarter = parsedQuarter || 1
const previous = isAnnualBudget
? { year: targetYear - 1, quarter: 0 }
: resolvePreviousPeriod(targetYear, targetQuarter)
const periodMultiplier = isAnnualBudget ? 4 : 1
const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value * periodMultiplier, 0)
const totalBudget = 1320000 * periodMultiplier
const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget * periodMultiplier, 0)
const departmentName = String(user.departmentName || user.department || '').trim() || '当前部门'
const items = PREVIOUS_QUARTER_SPEND.map((item) => {
const value = item.value * periodMultiplier
const previousValue = item.previousValue * periodMultiplier
const recommendedBudget = item.recommendedBudget * periodMultiplier
const trendValue = item.previousValue
? ((value - previousValue) / previousValue) * 100
: 0
return {
...item,
value,
previousValue,
recommendedBudget,
amountDisplay: compactCurrency(value),
display: percent(value, totalSpend),
share: percent(value, totalSpend),
trend: `${trendValue >= 0 ? '+' : ''}${trendValue.toFixed(1)}%`,
trendTone: trendValue >= 10 ? 'risk' : trendValue >= 0 ? 'warn' : 'stable',
recommendedDisplay: compactCurrency(recommendedBudget),
editableBudget: recommendedBudget,
reminderThreshold: item.key === 'communication' || item.key === 'office' ? 60 : 70,
alertThreshold: item.key === 'communication' || item.key === 'office' ? 70 : 80,
riskThreshold: item.key === 'communication' || item.key === 'office' ? 80 : 90,
editNote: item.suggestion
}
})
const topItem = [...items].sort((a, b) => b.value - a.value)[0]
const growthItem = [...items].sort((a, b) => {
const aGrowth = a.previousValue ? (a.value - a.previousValue) / a.previousValue : 0
const bGrowth = b.previousValue ? (b.value - b.previousValue) / b.previousValue : 0
return bGrowth - aGrowth
})[0]
return {
type: 'budget_compile_analysis',
title: isAnnualBudget
? `${targetYear}年度预算编制前置分析报告`
: `${targetYear}${targetQuarter}季度预算编制前置分析报告`,
subtitle: isAnnualBudget
? `基于${previous.year}年度预算执行模拟数据`
: `基于${previous.year}${previous.quarter}季度预算执行模拟数据`,
departmentName,
targetPeriod: isAnnualBudget ? `${targetYear}年度` : `${targetYear}${QUARTER_NAME_MAP[targetQuarter]}`,
basePeriod: isAnnualBudget ? `${previous.year}年度` : `${previous.year}${QUARTER_NAME_MAP[previous.quarter]}`,
periodType: isAnnualBudget ? '年度预算' : '季度预算',
centerValue: compactCurrency(totalSpend),
centerLabel: isAnnualBudget ? '去年开销' : '上季度开销',
summary: {
totalBudget: compactCurrency(totalBudget),
totalSpend: compactCurrency(totalSpend),
usageRate: percent(totalSpend, totalBudget),
recommendedTotal: compactCurrency(recommendedTotal)
},
macroInsights: [
`${isAnnualBudget ? `${previous.year}年度` : `${previous.year}${previous.quarter}季度`}实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`,
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}预算编制的第一优先级。`,
`${growthItem.name}环比增长 ${growthItem.trend},需要在预算说明中提前解释业务驱动,避免后续报销阶段反复补充材料。`
],
items,
editableDraft: {
status: 'editing',
rows: items.map((item) => ({
key: item.key,
name: item.name,
budgetAmount: item.editableBudget,
reminderThreshold: item.reminderThreshold,
alertThreshold: item.alertThreshold,
riskThreshold: item.riskThreshold,
note: item.editNote
}))
},
recommendations: [
`建议${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
'差旅和招待费采用更早的提醒阈值,通信和办公用品保持稳定额度,避免把预算过度分散到低波动项目。',
'正式编制时建议把重点项目、客户活动和集中采购计划写入预算说明,后续费用控制会更容易解释。'
],
generatedAt: '模拟数据 · 用于 Demo 预览'
}
}
export async function handleBudgetCompileReportSubmit(runtime) {
const {
adjustComposerTextareaHeight,
clearAttachedFiles,
completeFlowStep,
composerBusinessTimeDraftTouched,
composerBusinessTimeTags,
composerDraft,
createMessage,
currentUser,
fileInputRef,
fileNames,
messages,
nextTick,
options,
persistSessionState,
rawText,
replaceMessage,
resetFlowRun,
scrollToBottom,
startFlowStep,
submitting,
userText
} = runtime
const analysisStartedAt = Date.now()
resetFlowRun()
startFlowStep('budget-prior-quarter-analysis', {
title: '上季度预算开销分析',
tool: 'budget.analysis.previous_quarter',
detail: '正在汇总上季度费用占比、增长点和下一季度编制建议...'
})
startFlowStep('budget-compile-guidance', {
title: '预算编制建议生成',
tool: 'budget.compile.recommendation',
detail: '正在生成预算编制前置分析报告...'
})
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
const pendingMessage = createMessage(
'assistant',
'我先不直接进入预算表单,先执行上季度预算开销结构分析,再给您一版下一季度预算编制建议。',
[],
{ meta: ['预算分析中'] }
)
messages.value.push(pendingMessage)
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
submitting.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
try {
await new Promise((resolve) => setTimeout(resolve, 360))
const budgetReport = buildBudgetCompileReport(rawText, currentUser.value || {})
completeFlowStep(
'budget-prior-quarter-analysis',
'已完成上季度费用占比、增长点和风险点分析',
Date.now() - analysisStartedAt
)
completeFlowStep(
'budget-compile-guidance',
'已生成下一季度预算编制建议',
Date.now() - analysisStartedAt
)
replaceMessage(pendingMessage.id, createMessage(
'assistant',
'下面先按上季度模拟数据做一版预算编制前置分析。正式接入预算池后,这里会替换成真实部门预算和报销消耗数据。',
[],
{
meta: ['预算分析报告', '模拟数据'],
budgetReport
}
))
persistSessionState()
} finally {
submitting.value = false
nextTick(scrollToBottom)
}
return null
}