2026-05-27 12:27:17 +08:00
|
|
|
|
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)',
|
2026-05-28 16:24:59 +08:00
|
|
|
|
meal: 'var(--theme-secondary)',
|
|
|
|
|
|
office: 'var(--warning)',
|
|
|
|
|
|
communication: 'var(--info)'
|
2026-05-27 12:27:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 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)
|
|
|
|
|
|
return Boolean(
|
|
|
|
|
|
text &&
|
|
|
|
|
|
/(预算|budget)/.test(text) &&
|
|
|
|
|
|
/(编制|制定|测算|生成|规划|预算一下|compile|create|plan)/.test(text) &&
|
|
|
|
|
|
parseQuarter(rawText)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildBudgetCompileReport(rawText, user = {}) {
|
|
|
|
|
|
const targetYear = parseYear(rawText)
|
|
|
|
|
|
const targetQuarter = parseQuarter(rawText) || 3
|
|
|
|
|
|
const previous = resolvePreviousPeriod(targetYear, targetQuarter)
|
|
|
|
|
|
const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value, 0)
|
|
|
|
|
|
const totalBudget = 1320000
|
|
|
|
|
|
const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget, 0)
|
|
|
|
|
|
const departmentName = String(user.departmentName || user.department || '').trim() || '当前部门'
|
|
|
|
|
|
|
|
|
|
|
|
const items = PREVIOUS_QUARTER_SPEND.map((item) => {
|
|
|
|
|
|
const trendValue = item.previousValue
|
|
|
|
|
|
? ((item.value - item.previousValue) / item.previousValue) * 100
|
|
|
|
|
|
: 0
|
|
|
|
|
|
return {
|
|
|
|
|
|
...item,
|
|
|
|
|
|
amountDisplay: compactCurrency(item.value),
|
|
|
|
|
|
display: percent(item.value, totalSpend),
|
|
|
|
|
|
share: percent(item.value, totalSpend),
|
|
|
|
|
|
trend: `${trendValue >= 0 ? '+' : ''}${trendValue.toFixed(1)}%`,
|
|
|
|
|
|
trendTone: trendValue >= 10 ? 'risk' : trendValue >= 0 ? 'warn' : 'stable',
|
|
|
|
|
|
recommendedDisplay: compactCurrency(item.recommendedBudget)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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: `${targetYear}年${targetQuarter}季度预算编制前置分析报告`,
|
|
|
|
|
|
subtitle: `基于${previous.year}年${previous.quarter}季度预算执行模拟数据`,
|
|
|
|
|
|
departmentName,
|
|
|
|
|
|
targetPeriod: `${targetYear}年${QUARTER_NAME_MAP[targetQuarter]}`,
|
|
|
|
|
|
basePeriod: `${previous.year}年${QUARTER_NAME_MAP[previous.quarter]}`,
|
|
|
|
|
|
centerValue: compactCurrency(totalSpend),
|
|
|
|
|
|
centerLabel: '上季度开销',
|
|
|
|
|
|
summary: {
|
|
|
|
|
|
totalBudget: compactCurrency(totalBudget),
|
|
|
|
|
|
totalSpend: compactCurrency(totalSpend),
|
|
|
|
|
|
usageRate: percent(totalSpend, totalBudget),
|
|
|
|
|
|
recommendedTotal: compactCurrency(recommendedTotal)
|
|
|
|
|
|
},
|
|
|
|
|
|
macroInsights: [
|
|
|
|
|
|
`${previous.year}年${previous.quarter}季度实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`,
|
|
|
|
|
|
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${targetYear}年${targetQuarter}季度预算编制的第一优先级。`,
|
|
|
|
|
|
`${growthItem.name}环比增长 ${growthItem.trend},需要在预算说明中提前解释业务驱动,避免后续报销阶段反复补充材料。`
|
|
|
|
|
|
],
|
|
|
|
|
|
items,
|
|
|
|
|
|
recommendations: [
|
|
|
|
|
|
`建议${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
|
|
|
|
|
|
}
|