返回笔记首页

第五章:ERP 报销与请假(Multi-Agent 审批流)

主题配置

源码

workmind-05-erp.zip


5.1 本章目标

学完这章你能做到:

  • 用户用自然语言描述报销/请假,AI 自动解析成结构化表单
  • 系统自动检测不合规的费用项目(超标、信息不全等)
  • 多个 Agent 扮演不同审批角色(主管、财务、HR、总监),进行真实感的审批对话
  • 前端实时展示审批进度和每条对话消息
  • 保存申请记录,支持查看历史

本章对应项目功能:模块五 — ERP 报销与请假


5.2 整体设计思路

这个模块要解决两个问题:

问题一:填表太麻烦

传统报销系统:找到报销单,一项一项填,费用类型、每个明细的金额和日期...,大概要5分钟。

我们的方案:说一句话,AI 自动填好。

javascript
用户说:"上周去上海出差,高铁来回980,住宿两晚共1100,餐饮三天共420"
AI 解析出:
  {
    type: 'travel',
    items: [
      { name: '高铁票', amount: 980, date: '2024-03-11' },
      { name: '住宿费', amount: 1100, date: '2024-03-11' },
      { name: '餐饮费', amount: 420, date: '2024-03-11' },
    ],
    totalAmount: 2500,
    reason: '上海出差'
  }

问题二:审批过程不透明

传统审批:提交后等邮件,不知道到哪一步了,也不知道审批人有什么疑问。

我们的方案:模拟真实审批对话,每个审批角色的问题和意见都实时可见。

javascript
主管:这次出差的目的是什么?预期产出是?
申请人:参加产品发布会,预期建立合作意向,下周跟进
主管:好的,费用合理,通过
财务:注意住宿费偏高,每晚550元在公司标准范围内,通过

5.3 自然语言解析:Structured Output 的应用

5.3.1 核心原理

把用户口语化的描述,转成程序能处理的结构化数据,关键在于 Zod Schema + withStructuredOutput

javascript
import { z } from 'zod'

// 定义我们期望得到的数据结构
const ExpenseSchema = z.object({
  type: z.enum(['travel', 'meal', 'office', 'training', 'other']),
  items: z.array(z.object({
    name:   z.string().describe('费用项目,如"高铁票""住宿费"'),
    amount: z.number().describe('金额,单位:元'),
    date:   z.string().optional().describe('日期,格式 YYYY-MM-DD'),
  })),
  totalAmount: z.number(),
  reason:      z.string().describe('报销事由,20字以内'),
  warnings:    z.array(z.string()).default([]),   // AI 自己发现的问题
})

// withStructuredOutput:保证模型的输出符合 Schema
const extractModel = model.withStructuredOutput(ExpenseSchema)

// 调用
const result = await extractModel.invoke([
  { role: 'system', content: '你是报销单填写助手,从用户描述中提取报销信息...' },
  { role: 'user',   content: '上周去上海出差,高铁来回980,住宿两晚共1100...' },
])

// result 就是符合 ExpenseSchema 的 JS 对象,不用手动解析 JSON
console.log(result.totalAmount)   // 2500
console.log(result.items.length)  // 3

5.3.2 Schema 的 describe 很重要

z.string().describe('费用项目,如"高铁票""住宿费"') 这里的 describe 不是给开发者看的注释, 它会被发送给模型,告诉模型这个字段填什么。

javascript
// 好的 describe:给模型明确的指引
type: z.enum(['travel', 'meal', 'office']).describe('费用类型'),
name: z.string().describe('费用项目名称,如"高铁票""住宿费""餐费"'),
date: z.string().optional().describe('发生日期,格式 YYYY-MM-DD,如果用户说"上周"则推算具体日期'),

// 差的 describe(太模糊)
type: z.string(),  // 没有描述,模型不知道填什么

5.3.3 System Prompt 里的业务规则

光靠 Schema 不够,还要在 System Prompt 里写清楚业务规则:

javascript
const systemPrompt = `你是报销单填写助手。
今天是 ${today}。
规则:
1. 如果用户说"上周",根据今天日期推算具体日期
2. 金额务必精确,提到"约""大概"时保留原数字
3. 如果描述中有金额超过单笔3000元的项目,在 warnings 里提示
4. 如果报销事由不明确,在 warnings 里提示需要补充
5. totalAmount 等于所有 items 的 amount 之和`

把今天日期传给模型是关键:模型的训练数据有截止日期,它不知道"今天"是几号。 只有告诉它今天是什么日期,"上周一到周三"这样的相对时间才能正确推算。

5.3.4 合规检查:AI 解析 + 规则引擎双重保障

解析完之后,再过一遍规则引擎检查合规性:

javascript
const EXPENSE_RULES = {
  travel: {
    hotelPerNight:  800,    // 酒店每晚上限
    maxSingleItem: 5000,    // 单笔最大金额
  },
  meal: { maxSingleItem: 500 },
}

function checkCompliance(form) {
  const alerts = []
  const rules  = EXPENSE_RULES[form.type]

  form.items.forEach(item => {
    if (item.amount > rules.maxSingleItem) {
      alerts.push(`"${item.name}" ¥${item.amount} 超过单笔限额 ¥${rules.maxSingleItem}`)
    }
  })

  return alerts
}

// 合并 AI 发现的问题 + 规则引擎的问题
form.warnings = [...form.warnings, ...checkCompliance(form)]

为什么要双重保障?

  • AI 擅长理解自然语言,比如发现"大概600的餐费"可能有问题
  • 规则引擎处理精确的数字比较,不受模型随机性影响
  • 两者结合,漏网之鱼最少

5.4 Multi-Agent 审批流设计

5.4.1 什么是 Multi-Agent

上一章的 Agent 是一个 Agent 用多个工具。 这章的 Multi-Agent 是多个 Agent 之间相互协作,每个 Agent 扮演不同角色。

javascript
单 Agent:一个 AI,一个人格,多个工具
Multi-Agent:多个 AI,多个人格,相互对话

5.4.2 审批流程规划

根据申请内容,动态决定需要哪些审批角色:

javascript
function planApprovalFlow(formData, formType) {
  const flow = ['manager']  // 主管是必须的

  if (formType === 'expense') {
    flow.push('finance')              // 报销必须过财务
    if (formData.totalAmount > 5000) {
      flow.push('director')           // 大额需要总监审批
    }
  } else {
    // 请假
    flow.push('hr')                   // 请假必须过 HR
    if (formData.workdays > 5) {
      flow.push('director')           // 长假需要总监
    }
  }

  return flow
}

报销 ¥2500 → 走:主管 → 财务(2个角色) 报销 ¥8000 → 走:主管 → 财务 → 总监(3个角色) 请假 3 天 → 走:主管 → HR(2个角色) 请假 8 天 → 走:主管 → HR → 总监(3个角色)

5.4.3 每个角色的人格设定

关键在于 System Prompt,让每个角色有不同的审核视角:

javascript
const systems = {
  manager: `你是直属主管,正在审核下属的报销申请。
申请内容:${JSON.stringify(formData)}
你的职责:
1. 判断这次报销是否有业务必要性
2. 金额和事由是否合理
3. 可以提问,然后给出批准/驳回/要求补充的意见
4. 语气严肃专业,像真实的主管。不超过80字。`,

  finance: `你是财务专员,负责审核报销合规性。
申请内容:${JSON.stringify(formData)}
公司规定:
- 差旅:酒店每晚不超过800元,机票必须经济舱
- 单笔超过3000元需附发票
你的职责:检查是否合规,发现问题要指出。不超过80字。`,

  hr: `你是 HR 专员,负责审核请假合规性。
假期规定:
- 年假:入职满1年后享有5天,每多1年增加1天,最多15天
- 事假:每年最多10天,超过3天影响年终绩效
你的职责:核实假期余额和规定。不超过80字。`,
}

注意这几个细节

  1. 每个角色都把完整的 formData 传进去,这样它们能看到申请的具体内容
  2. finance 把公司规定写进去,这样它知道800元/晚这个标准
  3. 字数限制("不超过80字")让回复简洁,否则模型可能写很长一段没用的话

5.4.4 对话流程的三个阶段

每个审批角色的对话分三步:

javascript
第一步:审批人看申请,给初步意见(可能有问题)
→ [主管]:这次出差的目的是什么?

第二步:如果审批人有问题,申请人回答
→ [申请人]:参加产品发布会,预期建立合作意向

第三步:审批人看到回答后,给最终决定
→ [主管]:理解了,费用合理,批准

代码实现:

javascript
async function runApproverTurn(roleId, formData, formType, history, onEvent) {
  // 第一步:审批人初步审查
  const firstResponse = await model.invoke([
    new SystemMessage(getRoleSystem(roleId, formData)),
    new HumanMessage('请审核这份申请,如有疑问可以提问'),
    ...history,
  ])

  onEvent('message', { from: roleId, content: firstResponse.content, type: 'question' })
  history.push(new AIMessage(`[${roleName}]:${firstResponse.content}`))

  // 判断是否有追问
  const hasQuestion = firstResponse.content.includes('?') || firstResponse.content.includes('?')

  if (hasQuestion) {
    // 第二步:申请人回答
    const applicantResponse = await model.invoke([
      new SystemMessage(getApplicantSystem(formData)),
      ...history,
      new HumanMessage('审批人提了问题,请以申请人身份回答'),
    ])

    onEvent('message', { from: 'applicant', content: applicantResponse.content, type: 'answer' })
    history.push(new AIMessage(`[申请人]:${applicantResponse.content}`))

    // 第三步:审批人给最终决定
    const decisionResponse = await model.invoke([
      new SystemMessage(getRoleSystem(roleId, formData)),
      ...history,
      new HumanMessage('申请人已回答,请给出最终审批意见(批准/驳回)'),
    ])

    onEvent('message', { from: roleId, content: decisionResponse.content, type: 'decision' })
    history.push(new AIMessage(`[${roleName}]:${decisionResponse.content}`))

    return { approved: isApproved(decisionResponse.content) }
  }

  // 没有追问,第一个回复就是最终决定
  return { approved: isApproved(firstResponse.content) }
}

5.4.5 全局对话历史

所有审批角色共享同一份对话历史,后面的审批人能看到前面的对话:

javascript
// 全局历史从一条初始消息开始
const conversationHistory = [
  new HumanMessage(`申请人提交了报销申请:\n${JSON.stringify(formData)}`),
]

// 每个审批人对话完,把对话加到历史里
// 这样财务看主管和申请人的对话,HR 看主管、财务和申请人的对话
for (const roleId of approverIds) {
  await runApproverTurn(roleId, formData, formType, conversationHistory, onEvent)
  // conversationHistory 在每轮里被修改,自动积累
}

这个设计让审批流更真实:财务不会重复问主管已经问过的问题,HR 能看到前面的完整上下文。

5.4.6 驳回即终止

任何一个审批角色驳回,整个流程停止:

javascript
for (const roleId of approverIds) {
  const { approved } = await runApproverTurn(...)

  if (!approved) {
    allApproved  = false
    finalComment = `被${role.name}驳回:...`
    break   // 跳出循环,不再继续后面的角色
  }
}

5.5 判断"是否通过"的技巧

模型的输出是自然语言,不是布尔值。我们用关键词匹配来判断:

javascript
function isApproved(text) {
  const rejectKeywords = ['驳回', '不批', '拒绝', '不同意', '不予批准', '无法批准']
  return !rejectKeywords.some(kw => text.includes(kw))
}

为什么这样判断是合理的?

因为我们在角色的 System Prompt 里要求它"给出批准/驳回的意见",如果它要驳回,一定会用类似"驳回"、"不批准"这样的词。如果没用这些词,几乎可以认为是通过的。

这是一个够用的工程近似方案,不需要完美。

更严格的方案(可选):用结构化输出让模型返回 JSON:

javascript
const DecisionSchema = z.object({
  approved: z.boolean(),
  reason:   z.string(),
})
const decisionModel = model.withStructuredOutput(DecisionSchema)
const result = await decisionModel.invoke([...messages])
// result.approved 是精确的布尔值

缺点是会多一次 API 调用,且输出不是自然语言,前端显示需要额外处理。


5.6 服务端实现要点

5.6.1 SSE 推送顺序

审批流的 SSE 事件顺序:

javascript
event: start            → { appId }
event: plan             → { approvers: [{id, name, icon}...], totalSteps: 2 }
event: approver_start   → { roleId: 'manager', role: {...} }
event: message          → { from: 'manager', content: '...', type: 'question' }
event: message          → { from: 'applicant', content: '...', type: 'answer' }
event: message          → { from: 'manager', content: '...', type: 'decision' }
event: approver_done    → { roleId: 'manager', approved: true }
event: approver_start   → { roleId: 'finance', role: {...} }
event: message          → { from: 'finance', content: '...', type: 'decision' }
event: approver_done    → { roleId: 'finance', approved: true }
event: final            → { approved: true, status: 'approved', approvedBy: ['主管', '财务'] }
event: done             → {}

5.6.2 申请记录的存储

javascript
// 用 Map 存(生产用数据库)
const applications = new Map()

// 提交时创建记录
const application = {
  id:        `APP${Date.now()}`,
  formType,
  formData,
  status:    'pending',      // pending → approved | rejected
  messages:  [],             // 存所有对话消息
  createdAt: new Date().toISOString(),
}
applications.set(appId, application)

// 审批过程中同步更新
application.messages.push(messageData)
application.status    = result.status

5.7 前端实现要点

5.7.1 SmartFormParser 的两个模式

文件里的 FormField 是一个内联定义的子组件。Vue3 支持在一个 .vue 文件里有多个 <script> 标签:

vue
<!-- 主逻辑:Composition API -->
<script setup>
const erpStore = useErpStore()
// ...
</script>

<!-- 子组件:Options API(也可以用 setup()) -->
<script>
const FormField = {
  props: ['label', 'value', 'highlight'],
  template: `<div class="form-field-item">...</div>`,
}
export default { components: { FormField } }
</script>

这种写法适合简单的、只在本文件用的小组件,不需要单独建 .vue 文件。

5.7.2 审批步骤的状态变化

approvalSteps 里每个步骤有 4 种状态,视觉上有明显区别:

javascript
// 1. pending:等待(灰色)
// 2. running:审核中(蓝色 + 动画)
// 3. approved:通过(绿色 ✓)
// 4. rejected:驳回(红色 ✕)

const step = approvalSteps.value.find(s => s.roleId === data.roleId)
if (step) step.status = data.approved ? 'approved' : 'rejected'

5.7.3 消息气泡的左右布局

审批人消息靠左,申请人消息靠右,通过 is-applicant 类控制:

vue
<div
  class="msg-bubble-wrap"
  :class="{ 'is-applicant': msg.from === 'applicant' }"
>
  <!-- 靠左:avatar 在前,content 在后 -->
  <template v-if="msg.from !== 'applicant'">
    <div class="msg-avatar">{{ msg.role?.icon }}</div>
    <div class="msg-content">...</div>
  </template>

  <!-- 靠右:content 在前,avatar 在后(视觉上右对齐) -->
  <template v-else>
    <div class="msg-content right">...</div>
    <div class="msg-avatar applicant">👤</div>
  </template>
</div>
css
.msg-bubble-wrap { display: flex; gap: 10px; align-items: flex-start; }
/* 申请人消息:flex 方向反转 → 右对齐 */
.msg-bubble-wrap.is-applicant { flex-direction: row-reverse; }

5.7.4 打字动画

三个点循环弹跳,表示"正在思考":

css
.typing-dots span {
  width: 6px; height: 6px;
  border-radius: 50%;
  background: var(--color-text-muted);
  animation: typing .8s infinite;
}
.typing-dots span:nth-child(2) { animation-delay: .15s; }
.typing-dots span:nth-child(3) { animation-delay: .3s; }

@keyframes typing {
  0%,100% { opacity: .3; transform: scale(1); }
  50%     { opacity: 1;  transform: scale(1.2); }
}

5.8 本章作业

✅****基础功能

  • 输入"上周去上海出差,高铁800,住宿两晚960,餐费300",能解析成表单
  • 表单展示费用明细列表,合计金额正确
  • 住宿费如果超标,warnings 里有提示
  • 点击"提交审批",能看到审批人逐个出现的步骤面板
  • 审批对话一条一条实时出现,区分左右气泡

✅****进阶功能

  • 请假模式:输入"下周一到周三请年假,去旅游",解析结果显示工作日天数
  • 提交一个大额报销(>5000元),审批流里出现总监角色
  • 提交一个长假申请(>5个工作日),审批流里出现总监
  • 审批完成后,左侧申请记录里出现这条记录
✅****测试场景
  1. 输入:购买了一本书,68元 → 应该只走主管→财务(无总监)
  2. 输入:出差报销,机票商务舱8000元 → 财务应该提出"机票必须经济舱"的问题
  3. 输入:请病假3天,感冒了 → HR 应该提示需要医院证明

5.9 常见问题

Q:解析结果的 totalAmount 和 items 的 amount 之和不一致?

在 System Prompt 里明确说明:totalAmount 等于所有 items 的 amount 之和。 如果还是不一致,可以在代码里强制修正:

javascript
result.totalAmount = result.items.reduce((sum, item) => sum + item.amount, 0)

Q:审批人的回复有时候太长,或者格式很奇怪?

在每个角色的 System Prompt 里加字数限制("不超过80字")和格式要求("不要使用 Markdown,直接说话")。

Q:怎么让审批人有时候驳回,不要总是通过?

在角色的 System Prompt 里加明确的驳回条件:

javascript
finance: `...
驳回情形:
1. 酒店超过800元/晚且无特殊说明
2. 机票不是经济舱
3. 餐饮超过500元/次
满足以上任一条件时,必须驳回并说明原因。`
Q:多个用户同时提交,审批流会不会混掉?

不会。每次提交都生成唯一的 appIdconversationHistory 是函数内的局部变量,不同请求的历史完全独立。

Q:想把审批结果真的发邮件/飞书通知怎么做?

runApprovalFlow 完成后,调用真实的通知 API:

javascript
const result = await runApprovalFlow(...)

if (result.approved) {
  await sendFeishuMessage(applicant.openId, `你的报销申请已通过,金额 ¥${formData.totalAmount}`)
} else {
  await sendFeishuMessage(applicant.openId, `你的报销申请已驳回:${result.comment}`)
}