差旅报销管理真相:90%的OA系统都必须考虑——出差申请+差旅报销双单联动全流程设计拆解

0 阅读16分钟

差旅报销管理真相:90%的OA系统都必须考虑——出差申请+差旅报销双单联动全流程设计拆解

🌐 文档地址ruoyioffice.com | 📦 源码1ruoyi-office-vben |📦 源码2ruoyi-office |📦 源码3ruoyi-office | 💬 :17156169080(备注「RuoYi Office」)

一家200人的制造企业,每月200+笔出差申请、150+笔差旅报销。Excel报销单在聊天软件群里转了三圈还没到财务手里,出差标准靠口头传达,同一张机票被报销两次直到年底审计才发现——这不是段子,这是中国中小企业差旅管理的日常。 差旅管理看似只是"填个表、贴个票",背后却是出差申请→审批→出行→报销→核验→支付的完整业务闭环。做好了,是降本增效的利器;做不好,就是财务黑洞。

引言:差旅管理到底难在哪?

如果你问一个中小企业的财务主管"最头疼的事是什么",大概率会得到两个答案:工资和报销。而差旅报销,又是报销中最复杂的那个——因为它不只是一张报销单的事。

一次完整的差旅业务涉及多个阶段和多个角色:

  • 📝 出差前:员工申请出差,填写行程计划,领导审批是否同意
  • ✈️ 出差中:按行程出行,产生交通、住宿、餐饮等各类费用
  • 💰 出差后:整理票据、填报费用明细、关联出差申请、提交报销审批
  • 审批后:部门确认→财务核验票据→打款→标记已支付

这些环节在很多中小企业里是割裂的:

环节现状痛点后果
出差申请口头请示或聊天软件报备,无正式记录事后扯皮——"我不知道你出差了"
行程管理没有行程明细,出差目的和路线不透明合规审计无法追溯
费用报销Excel表格+纸质贴票,流转靠手递效率低,周期长达2-4周
票据核验财务人工逐张比对发票和报销金额重复报销、假发票难以发现
出差↔报销关联两者独立,无系统联动同一次出差被多次报销,或出差了忘记报销

RuoYi Office 的差旅管理模块,就是为解决这些痛点而设计的。 它把出差申请和差旅报销作为一个业务闭环来设计——出差申请审批通过是报销的前提,报销审批通过自动回写出差单状态——用两张主表+两张子表、两套BPM流程、一个联动机制,覆盖了中小企业差旅管理80%的核心需求。


一、业务设计:出差申请与差旅报销的关系

1.1 两单联动的核心设计理念

差旅管理不是两个独立功能的简单堆砌,而是一个闭环业务。RuoYi Office 的核心设计理念是:

出差申请(审批通过)
│
├─ 报销单创建时 → 选择出差申请 → 自动填充事由/日期/天数
│
└─ 报销单审批通过 → 回写出差申请 reimburse_status = 已报销

设计原则

  • 出差申请是报销的"源头凭证":报销时可关联已审批通过的出差申请,系统自动带入出差事由、日期、天数等信息,减少重复填写
  • 报销状态反写出差单:报销审批通过后,自动更新对应出差申请的报销状态,防止同一次出差被多次报销
  • 允许独立报销:关联出差申请不是强制的——临时出差或补报场景下,可以不关联出差单直接报销

这种"有关联但不强制"的设计,既保证了正常流程的数据完整性,又兼顾了中小企业的灵活性需求。过度严格的系统会被员工绕过,反而不如一套"80%规范+20%灵活"的方案有效。

1.2 出差申请单功能概览

员工发起出差申请,填写出差事由、行程明细(支持多段行程),审批通过后可据此发起差旅报销。 trip-apply-list.png ▲ 出差申请列表页:展示单据编号、单据状态、出差事由、日期范围、天数、预计费用、报销状态等核心信息

列表页的设计亮点:

  • 报销状态列:直观展示每条出差申请是"未报销"还是"已报销",财务一目了然
  • 单据编号链接:点击编号直接跳转详情页查看完整信息
  • 字典渲染:单据状态、报销状态均通过字典标签渲染,颜色区分状态

1.3 差旅报销单功能概览

出差完成后,员工发起差旅报销,可选择关联已审批通过的出差申请单(系统自动带入行程信息),填写实际费用明细(交通、住宿、餐饮、其他),上传票据附件,提交审批。 trip-reimburse-list.png

▲ 差旅报销列表页:展示单据编号、关联出差单号、出差事由、日期范围、报销总金额、支付状态


二、数据模型设计:4张表搞定差旅闭环

2.1 ER 关系总览

┌──────────────────────────────────┐
│    oa_business_trip              │
│    (出差申请单 - 主表)            │
│  ├ bill_code (单据编号)          │
│  ├ trip_reason (出差事由)        │
│  ├ start_date / end_date        │
│  ├ total_days (自动计算)         │
│  ├ companion (同行人)            │
│  ├ estimated_cost (预计费用)     │
│  ├ reimburse_status (报销状态)   │ ← 报销审批通过后自动回写
│  └ process_instance_id          │
└──────────────┬───────────────────┘
│ 1 : N
▼
┌──────────────────────────────────┐
│    oa_business_trip_item         │
│    (行程明细 - 子表)              │
│  ├ trip_id → 出差申请单           │
│  ├ departure_city (出发城市)     │
│  ├ arrival_city (到达城市)       │
│  ├ start_date / end_date        │
│  ├ transport_type (交通方式)     │
│  └ remark (备注)                 │
└──────────────────────────────────┘

┌──────────────────────────────────┐
│    oa_travel_reimburse           │
│    (差旅报销单 - 主表)            │
│  ├ bill_code (单据编号)          │
│  ├ trip_id → 关联出差申请单       │ ← 联动核心
│  ├ trip_bill_code (冗余)         │
│  ├ trip_reason / start_date ... │
│  ├ total_amount (自动汇总)       │
│  ├ pay_status (支付状态-预留)    │
│  └ process_instance_id          │
└──────────────┬───────────────────┘
│ 1 : N
▼
┌──────────────────────────────────┐
│    oa_travel_reimburse_item      │
│    (费用明细 - 子表)              │
│  ├ reimburse_id → 报销单          │
│  ├ expense_type (费用类型)       │
│  ├ expense_date (发生日期)       │
│  ├ departure_city / arrival_city │
│  ├ amount (金额)                 │
│  ├ description (费用说明)        │
│  ├ invoice_code (发票代码-预留)  │
│  ├ invoice_no (发票号码-预留)    │
│  └ invoice_amount (发票金额-预留)│
└──────────────────────────────────┘

2.2 为什么用主子表而不是单表?

这是一个关键的设计决策。很多简易差旅系统把行程信息直接塞在出差申请主表里(比如用逗号分隔的字段存出发地和目的地),报销也只有一个总金额字段。这种设计在真实业务中根本不够用

设计方案出差"上海→北京→广州" 三段行程报销"交通+住宿+餐饮" 三类费用
单表方案出发城市=上海,北京 / 到达城市=北京,广州(逗号拼接,无法独立管理每段行程)总金额=5000(无法看到费用构成)
主子表方案三条行程记录,每段有独立的日期、交通方式、备注三条费用记录,每笔有类型、金额、说明

主子表的核心价值

  1. 数据粒度精确:每段行程、每笔费用都是独立记录,便于统计分析(如"全公司交通费占比多少")
  2. 前端交互友好:VxeGrid 表格支持增删行,操作直观,不需要解析逗号分隔字符串
  3. 发票预留:费用明细子表预留了 invoice_code/invoice_no/invoice_amount 字段,为未来的 AI 发票识别功能做准备
  4. 审计追溯:每笔费用都有明确的发生日期和说明,满足财务审计要求

2.3 核心字段设计解读

出差申请单核心字段

字段类型设计考量
trip_reasonvarchar(500)出差事由,同时作为流程变量 cause 传入 BPM
total_daysdecimal(5,1)自动计算(结束日期 - 开始日期 + 1),支持 0.5 天精度
estimated_costdecimal(12,2)可选填,作为流程变量用于条件分支(超过阈值加审批)
reimburse_statustinyint0-未报销 1-部分报销 2-已报销,报销审批通过后自动更新
companionvarchar(200)同行人,便于行政统计出差频次

差旅报销单核心字段

字段类型设计考量
trip_idbigint可为空——允许不关联出差申请直接报销
trip_bill_codevarchar(32)冗余存储出差单编号,列表页可直接展示,避免关联查询
total_amountdecimal(12,2)自动从费用明细汇总,作为流程变量用于审批条件分支
pay_statustinyint预留字段,审批通过后默认 0-未支付,后续对接支付模块

设计哲学:字段设计遵循"本期够用+远期可扩展"原则——核心业务字段完整实现,发票识别和支付对接通过预留字段为未来迭代做准备。


三、审批流程设计:精简但不简陋

3.1 出差申请审批流程

面向中小企业的精简审批流程,核心原则是"小额快审、大额严审":

申请人提交  部门经理审批 ──→ 审批通过

└──→ 出差天数 > 3  预计费用 > 5000
 分管领导审批  审批通过
节点审批人核心操作条件逻辑
申请人提交发起人(自动)填写出差申请单并提交
部门经理审批发起人直属上级审核出差事由和行程合理性天数 ≤ 3 且 费用 ≤ 5000 → 直接通过
分管领导审批上级部门负责人审批长期或高额出差天数 > 3 或 费用 > 5000 时触发

流程变量传递(代码实现):

Map<String, Object> processInstanceVariables =
    BpmProcessVariableUtils.buildBillVariables(saveReqVO);
processInstanceVariables.put("totalDays", saveReqVO.getTotalDays());
processInstanceVariables.put("estimatedCost", saveReqVO.getEstimatedCost());

天数和费用作为流程变量传入 Flowable,可在流程设计器中直接配置条件表达式 ${totalDays > 3 || estimatedCost > 5000}阈值可视化配置、零代码调整

3.2 差旅报销审批流程

报销审批比出差申请多一个财务审核节点,确保费用合规:

申请人提交 → 部门经理审批 ──→ 财务审核 → 审批通过
                          │
                          └──→ 报销金额 > 5000元
                               → 分管领导审批 → 财务审核 → 审批通过
节点审批人审核重点
部门经理审批直属上级出差事由真实性、费用合理性
分管领导审批分管领导大额费用的必要性(条件触发)
财务审核财务部指定人员发票合规性、金额准确性、报销标准

为什么报销多了财务审核? 因为出差申请是"意向审批"(同不同意你去出差),而报销是"实际支出审批"(这些钱该不该付)。前者只需要业务领导判断,后者必须有财务专业把关。

3.3 审批通过后的业务联动

差旅报销最核心的联动逻辑发生在审批通过的瞬间——系统自动回写出差申请的报销状态:

@Override
@Transactional(rollbackFor = Exception.class)
public void updateProcessStatus(String businessKey, Integer status) {
    Long id = Long.parseLong(businessKey);
    TravelReimburseDO bill = travelReimburseMapper.selectById(id);

    TravelReimburseDO updateObj = new TravelReimburseDO();
    updateObj.setId(id);
    updateObj.setProcessStatus(status);

    if (APPROVE.getStatus().equals(status)) {
        updateObj.setPayStatus(0); // 审批通过 → 标记为未支付
        if (bill.getTripId() != null) {
            // 核心联动:回写出差申请单的报销状态
            BusinessTripDO tripUpdate = new BusinessTripDO();
            tripUpdate.setId(bill.getTripId());
            tripUpdate.setReimburseStatus(2); // 已报销
            businessTripMapper.updateById(tripUpdate);
        }
    }

    travelReimburseMapper.updateById(updateObj);
}

这段代码的精妙之处

  1. 事务保证@Transactional 确保报销状态更新和出差单回写在同一个事务中,要么都成功要么都回滚
  2. 空值安全bill.getTripId() != null 判断——如果报销单没有关联出差申请,就不执行回写
  3. 支付预留:审批通过同时设置 payStatus = 0(未支付),为后续对接支付模块预留入口
  4. FlowBillService 回调:这是 RuoYi Office BPM 架构的标准回调方法,由 Flowable 引擎在流程状态变更时自动触发

四、前端实现:主子表交互的最佳实践

4.1 出差申请表单:BasicForm + 行程明细子表

trip-apply-info.png

▲ 出差申请表单详情:单据头(编号/申请人/日期/单位/部门)+ 基本信息 + 行程明细 + 附件

出差申请表单的核心交互设计:

1. 天数自动计算

const calculatedDays = computed(() => {
  const startDate = formData.value.startDate;
  const endDate = formData.value.endDate;
  if (startDate && endDate) {
    const diff = dayjs(endDate).diff(dayjs(startDate), 'day') + 1;
    return diff > 0 ? diff : undefined;
  }
  return undefined;
});

选择开始/结束日期后,天数字段自动计算并填充,无需手动输入。"+1"的设计符合出差天数的业务含义(含首尾两天)。

2. 行程明细 VxeGrid 子表

行程明细采用 VxeGrid 可编辑表格实现,每行包含出发城市、到达城市、日期范围、交通方式:

function handleAddItem() {
  const newItem: BusinessTripApi.BusinessTripItem = {
    departureCity: '',
    arrivalCity: '',
    startDate: formData.value.startDate,  // 自动继承主表开始日期
    endDate: formData.value.endDate,      // 自动继承主表结束日期
    transportType: undefined,
    remark: '',
  };
  itemList.value.push(newItem);
  gridApi.grid.reloadData(itemList.value);
}

交互细节:新增行程时,默认继承主表的开始/结束日期,减少重复输入。交通方式通过字典驱动(飞机/高铁/火车/汽车/自驾/其他),下拉选择。

3. 编辑/只读双模态

表单通过 readonly 变量控制所有字段和子表的编辑状态。在审批中/审批通过状态下,表单自动切换为只读模式:

<Input
  v-if="!readonly"
  v-model:value="row.departureCity"
  placeholder="出发城市"
  size="small"
/>
<span v-else>{{ row.departureCity }}</span>

每个子表单元格都有两种渲染模式——可编辑时显示输入组件,只读时显示纯文本。这种设计比简单的 disabled 属性体验更好(disabled 输入框样式灰暗,阅读体验差)。

4.2 差旅报销表单:联动选择 + 费用汇总

差旅报销表单是整个差旅模块最复杂的前端页面,核心亮点是出差申请选择联动费用自动汇总image.png

1. 出差申请选择弹窗

点击"关联出差申请"字段旁的选择按钮,弹出出差申请选择弹窗:

// 查询当前用户已审批通过 + 未完全报销的出差申请
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          const fullList = await getApprovedBusinessTripList();
          const filteredList = filterTrips(fullList, formValues);
          return {
            list: filteredList.slice(start, end),
            total: filteredList.length,
          };
        },
      },
    },
    radioConfig: { labelField: 'id', trigger: 'row' },  // 单选模式
  },
  gridEvents: {
    cellDblclick: ({ row }) => {
      state.selectedTrip = row;
      handleConfirm();  // 双击快速选择
    },
  },
});

弹窗支持搜索筛选(按单据编号、出差事由、报销状态),单选确认或双击快速选择。

2. 选择后自动填充

选择出差申请后,系统自动将出差单的事由、日期、天数等信息填充到报销单:

function handleTripSelect(trip: BusinessTripApi.BusinessTrip) {
  formData.value.tripId = trip.id;
  formData.value.tripBillCode = trip.billCode;
  formData.value.tripReason = trip.tripReason;
  formData.value.startDate = trip.startDate;
  formData.value.endDate = trip.endDate;
  formData.value.totalDays = trip.totalDays;

  if (basicFormRef.value) {
    basicFormRef.value.setFormValues({ ... }, false);
    basicFormRef.value.clearFieldError('tripBillCode');
  }
}

这个"选择即填充"的设计大幅减少了员工的填写工作量——出差事由、日期这些信息在出差申请时已经填过一遍了,报销时不应该再手动输入。

3. 费用明细自动汇总

报销总金额不需要手动输入,而是从费用明细子表实时汇总计算:

const totalAmount = computed(() => {
  return itemList.value.reduce((sum, item) => {
    return sum + (Number(item.amount) || 0);
  }, 0);
});

VxeGrid 表格底部还有合计行实时展示:

footerMethod: () => {
  return [['合计', '', '', '', ${totalAmount.value.toFixed(2)}`, '', '']];
},

4. 报销规范提示

表单底部的报销规范提示是一个贴心的设计——用 Ant Design Vue 的 Alert 组件展示 8 条报销规范,确保员工提交前知晓要求:

const reimburseNoticeContent = [
  '每笔费用须附对应发票或收据,附件中请备注发票金额',
  '发票抬头必须为公司全称,且与报销主体一致',
  '发票日期须在出差期间范围内(前后不超过 3 个工作日)',
  '交通费用须提供车票、机票行程单或电子客票',
  '住宿费须提供住宿发票,发票须注明入住日期和天数',
  '餐饮费每日限额以公司差旅标准为准',
  '同一行程不可重复报销,系统会自动检查关联出差单的报销状态',
  '报销金额须与发票金额一致,如有差异请在费用说明中注明原因',
];

image.png

为什么把规范写在表单里而不是弄个单独的公告? 因为"离场景最近的提示才是有效的提示"。员工在填报销单的时候看到规范,比在公告栏里发一条"关于差旅报销规范的通知"有效100倍。

5. 提交前校验

差旅报销提交前有三重校验,确保数据完整性:

if (itemList.value.length === 0) {
  message.warning('请添加费用明细');
  return;
}
const invalidAmountIndex = itemList.value.findIndex(
  (item) => item.amount === undefined || item.amount === null,
);
if (invalidAmountIndex !== -1) {
  message.warning(`第${invalidAmountIndex + 1}行费用明细金额不能为空`);
  return;
}
if (!formData.value.attachments?.length) {
  message.warning('请上传附件');
  return;
}

三重校验:① 费用明细不能为空;② 每行金额不能为空(精确到第几行);③ 必须上传附件(发票/收据)。后端也有对应的 validateSubmitData() 方法做二次校验。


五、后端架构:FlowBillService 标准范式

5.1 出差申请 Service:标准的 BPM 业务模块

出差申请的后端实现完美展示了 RuoYi Office 的业务模块接入 BPM 的标准范式

@Slf4j
@Service
@Validated
public class BusinessTripServiceImpl
        implements BusinessTripService, FlowBillService<OaBillTypeEnum> {

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long submitBusinessTrip(BusinessTripSaveReqVO saveReqVO) {
        // 1. 自动生成单据编号
        if (StringUtils.isBlank(saveReqVO.getBillCode())) {
            saveReqVO.setBillCode(BillCodeUtils.generateBillCode(
                SystemEnum.OA, OaBillTypeEnum.OA_BUSINESS_TRIP));
        }

        // 2. 保存主表 + 子表
        BusinessTripDO bill = BeanUtils.toBean(saveReqVO, BusinessTripDO.class)
                .setProcessStatus(BpmTaskStatusEnum.RUNNING.getStatus());
        businessTripMapper.insertOrUpdate(bill);
        saveItems(bill.getId(), saveReqVO.getItems());

        // 3. 构建流程变量(天数、费用用于条件分支)
        Map<String, Object> processInstanceVariables =
            BpmProcessVariableUtils.buildBillVariables(saveReqVO);
        processInstanceVariables.put("totalDays", saveReqVO.getTotalDays());
        processInstanceVariables.put("estimatedCost", saveReqVO.getEstimatedCost());

        // 4. 发起流程实例
        String processInstanceId = processInstanceApi.submitProcessInstance(
            Long.valueOf(saveReqVO.getCreator()),
            new BpmProcessInstanceCreateReqDTO()
                .setProcessDefinitionKey(
                    OaBillTypeEnum.OA_BUSINESS_TRIP.getProcessDefinitionKey())
                .setVariables(processInstanceVariables)
                .setBusinessKey(String.valueOf(bill.getId()))
        ).getCheckedData();

        // 5. 回写流程实例ID + 保存附件
        businessTripMapper.updateById(
            new BusinessTripDO().setId(bill.getId())
                .setProcessInstanceId(processInstanceId));
        if (saveReqVO.getAttachments() != null) {
            attachmentService.saveAttachmentList(
                OaBillTypeEnum.OA_BUSINESS_TRIP.getTypeCode(),
                bill.getId(), saveReqVO.getAttachments());
        }
        return bill.getId();
    }

    // FlowBillService 接口实现
    @Override
    public OaBillTypeEnum getSupportedBillType() {
        return OaBillTypeEnum.OA_BUSINESS_TRIP;
    }

    @Override
    public void updateProcessStatus(String businessKey, Integer status) {
        Long id = Long.parseLong(businessKey);
        businessTripMapper.updateById(
            new BusinessTripDO().setId(id).setProcessStatus(status));
    }
}

这个 Service 的代码结构就是 RuoYi Office 所有 BPM 业务模块的模板——新增一个业务单据(合同审批、采购申请等),照着这个结构实现就行。核心要点:

  1. implements FlowBillService<OaBillTypeEnum>:声明本模块支持的单据类型,框架自动注册到工厂
  2. insertOrUpdate():支持"暂存再提交"的两步操作
  3. BpmProcessVariableUtils.buildBillVariables():自动提取标准流程变量
  4. 自定义流程变量totalDays/estimatedCost 作为条件分支的判断依据
  5. 附件独立管理:通过 AttachmentService 统一管理,解耦业务逻辑

5.2 子表操作:先删后增的简洁策略

行程明细和费用明细的保存都采用**"先删后增"**策略:

private void saveItems(Long billId, List<BusinessTripItemVO> items) {
    businessTripItemMapper.deleteByTripId(billId);
    if (items != null && !items.isEmpty()) {
        List<BusinessTripItemDO> itemDOs = BeanUtils.toBean(items, BusinessTripItemDO.class);
        for (BusinessTripItemDO itemDO : itemDOs) {
            itemDO.setId(null);  // 清空ID,确保新增而非更新
            itemDO.setTripId(billId);
        }
        businessTripItemMapper.insertBatch(itemDOs);
    }
}

为什么用"先删后增"而不是逐条对比更新? 因为前端的子表操作场景是:用户可能增加、删除、修改任意行。逐条对比的代码复杂度是 O(n²)(新旧列表交叉比对),而先删后增只需 O(n),代码简洁且不容易出bug。对于子表通常只有几条到十几条记录的场景,性能完全可以接受。

5.3 报销审批校验:后端兜底

private void validateSubmitData(TravelReimburseSaveReqVO saveReqVO) {
    if (CollectionUtils.isEmpty(saveReqVO.getItems())) {
        throw invalidParamException("请添加费用明细");
    }
    for (int i = 0; i < saveReqVO.getItems().size(); i++) {
        TravelReimburseItemVO item = saveReqVO.getItems().get(i);
        if (item.getAmount() == null) {
            throw invalidParamException("第{}行费用明细金额不能为空", i + 1);
        }
    }
    if (CollectionUtils.isEmpty(saveReqVO.getAttachments())) {
        throw invalidParamException("请上传附件");
    }
}

前端校验可以被绕过(浏览器开发者工具),后端校验才是数据完整性的最后防线。错误信息精确到"第N行",帮助用户快速定位问题。


六、字典数据:零代码扩展的枚举管理

差旅模块涉及的所有枚举值均通过字典服务管理:

字典类型字典值使用场景
oa_transport_type飞机、高铁/动车、火车、长途汽车、自驾、其他行程明细交通方式选择
oa_expense_type交通费、住宿费、餐饮费、通讯费、市内交通、其他费用明细类型选择
oa_reimburse_status未报销、部分报销、已报销出差申请单报销状态
oa_pay_status未支付、已支付报销单支付状态(预留)

前端通过 getDictOptions(DICT_TYPE.OA_TRANSPORT_TYPE, 'number') 一行代码获取选项列表,管理员在后台添加新交通方式(如"网约车")无需改代码。


七、API 接口设计

出差申请 API

方法路径说明
POST/oa/business-trip/save保存草稿(可反复暂存)
POST/oa/business-trip/submit提交审批(触发流程)
GET/oa/business-trip/get?id=xxx获取详情(含行程明细+附件)
GET/oa/business-trip/page分页列表
DELETE/oa/business-trip/delete?id=xxx删除(含附件+子表+流程实例清理)
GET/oa/business-trip/get-approved-list获取已审批出差单(供报销选择弹窗)

差旅报销 API

方法路径说明
POST/oa/travel-reimburse/save保存草稿
POST/oa/travel-reimburse/submit提交审批(含数据校验)
GET/oa/travel-reimburse/get?id=xxx获取详情(含费用明细+附件)
GET/oa/travel-reimburse/page分页列表
DELETE/oa/travel-reimburse/delete?id=xxx删除

get-approved-list 是为联动设计的专属接口——只返回当前用户已审批通过且未完全报销的出差申请,供报销单选择弹窗使用。


八、前端目录结构

views/oa/trip/
  ├── tripapply/                          # 出差申请
  │   ├── list/
  │   │   ├── index.vue                   # 列表页(VxeGrid + 搜索表单)
  │   │   └── data.ts                     # 列定义 + 搜索表单 schema
  │   └── info/
  │       ├── index.vue                   # 表单页(BasicForm + 行程明细 + 附件)
  │       └── data.ts                     # 表单 schema + 交通方式字典
  ├── tripreimburse/                      # 差旅报销
  │   ├── list/
  │   │   ├── index.vue                   # 列表页
  │   │   └── data.ts                     # 列定义
  │   └── info/
  │       ├── index.vue                   # 表单页(BasicForm + 费用明细 + 附件 + 报销规范)
  │       └── data.ts                     # 表单 schema + 费用类型字典
  └── components/
      ├── trip-select-modal.vue           # 出差申请选择弹窗(报销关联用)
      ├── trip-select-data.ts             # 弹窗列定义 + 搜索 schema
      └── index.ts                        # 统一导出

api/oa/trip/
  ├── tripapply/index.ts                  # 出差申请 API + TypeScript 类型
  └── tripreimburse/index.ts              # 差旅报销 API + TypeScript 类型

这套目录结构完全遵循 RuoYi Office 的前端开发规范:API 层和页面层按模块同构组织,每个功能模块的 list/(列表)和 info/(表单)独立分包,组件抽离到 components/


九、事件驱动全景:BPM 与业务的完美协作

┌────────────────────────────────┐
                                       │  BusinessTripServiceImpl       │
                                       │    ├ getSupportedBillType()    │
                                       │    │  → OA_BUSINESS_TRIP       │
Flowable 引擎                           │    ├ updateProcessStatus()     │
    ↓                                  │    │  → 更新 processStatus     │
BpmProcessInstanceEventListener        │    └ (无 onProcessApproved)    │
    ↓                                  └────────────────────────────────┘
Spring ApplicationEvent    ─────────→
    ↓                                  ┌────────────────────────────────┐
OaLocalNotificationListener            │  TravelReimburseServiceImpl    │
    ↓                                  │    ├ getSupportedBillType()    │
OaFlowBillServiceFactory               │    │  → OA_TRAVEL_REIMBURSE   │
    ↓                                  │    ├ updateProcessStatus()     │
根据 processDefinitionKey             │    │  → 更新 processStatus     │
    匹配对应 Service                    │    │  → 审批通过时回写出差单   │
                                       │    │    reimburseStatus = 已报销│
                                       │    └ payStatus = 0 (未支付)    │
                                       └────────────────────────────────┘

仅需实现 FlowBillService 接口,出差申请和差旅报销就完成了与 BPM 引擎的全部集成。


十、远期规划:AI赋能差旅管理

当前版本聚焦核心 80% 需求,但数据模型和架构设计已经为未来迭代做好了准备:

10.1 发票智能识别(AI多模态)

项目已集成 Spring AI 1.1.2 + 多模态大模型(通义千问 qwen-vl / 智谱 glm-4v),未来可实现:

上传发票图片 → AI识别发票信息 → 自动填充 invoice_code/invoice_no/invoice_amount
                              → 与报销明细金额比对 → 不一致时弹出警告

费用明细子表已预留 invoice_codeinvoice_noinvoice_amount 三个字段,开箱即可对接。

10.2 财务支付对接

报销审批通过 → 生成 PayOrder(支付单)→ 财务确认打款
             → 更新 pay_status = 已支付 → 同步生成记账凭证

报销单的 pay_status(支付状态)和 pay_time(支付时间)字段已预留,后续对接 yudao-module-pay 即可打通。

10.3 出差标准管控

按职级/部门配置差旅标准(如"总监级住宿上限 800元/晚"),提交报销时自动比对:

费用明细金额 vs 差旅标准限额 → 超标提示 / 强制拒绝 / 审批加签

十一、总结:差旅模块的设计亮点

能力实现方式价值
双单联动报销单关联出差单,自动填充+状态回写减少填写、防止重复报销
主子表建模4张表:行程明细+费用明细独立管理数据粒度精确,利于统计分析
智能条件审批流程变量驱动条件分支小额快审、大额严审,零代码调整阈值
费用自动汇总computed 实时计算 + VxeGrid 合计行减少人工计算错误
报销规范内置Alert 组件嵌入表单离场景最近的提示
前后端双校验前端三重校验 + 后端兜底校验数据完整性有保障
字典驱动交通方式/费用类型/报销状态均字典管理管理员可自行扩展
事件驱动解耦FlowBillService + Spring Event业务模块与 BPM 零耦合
远期可扩展预留发票/支付字段 + Spring AI 集成AI 发票识别、自动支付开箱即用
多租户隔离全表带 tenant_idSaaS 场景开箱即用

十二、快速体验

在线体验

  • 演示地址ruoyioffice.com/web/
  • 账号密码:admin / admin123
  • 操作路径:OA 协同办公 → 出差管理 → 出差申请 / 差旅报销

源码获取

平台仓库地址
GitCode(后端)gitcode.com/zhouzhongya…
GitCode(前端)gitcode.com/zhouzhongya…
GitHub(后端)[github.com/yuqing2026/…github.com/yuqing2026/…