欢迎光临
我们一直在努力

SpringBoot3+Vue3 企业费用管理:从预算申请、发票报销到借款冲销与付款闭环

SpringBoot3+Vue3 企业费用管理:从预算申请、发票报销到借款冲销与付款闭环

🌐 文档地址:https://ruoyioffice.com
👇 文章底部获取源码和演示地址 👇
💬 :17156169080(获取产品咨询)

一笔费用花了 1,200 元,员工之前借了 800 元,公司最后只需再付 400 元。难点不是减法,而是让预算、发票、借款、审批与付款分别在正确的时点发生变化,最后还能逐项对上。

企业费用管理全景:预算与票据汇入报销单,借款冲抵后衔接审批、付款和核对

▲ 费用总额、借款冲抵和待付金额是三种口径;审批通过与资金结清是两个阶段。

一、费用系统为什么不能止步于“审批通过”

企业开始做报销数字化时,最容易先完成的是一张表单:填事由、传附件、选领导,领导同意之后显示绿色状态。小范围使用时,这已经比纸质单据方便。但当预算、员工借款和出纳付款一起接入,只有审批状态就不够用了。

员工关心自己还会收到多少钱,部门负责人关心这次活动用了多少预算,财务关心票据是否重复使用,出纳关心是否已经付款。这些问题指向同一笔业务,却不能用同一个金额、同一个状态回答。

例如,已经借出 800 元不表示员工已经报销 800 元;发票上传成功不表示发票可用;领导同意报销不表示银行已经扣款;付款单登记完成,也不等于凭证已经过账。把这些含义压进一个“完成”,后面每多接一个模块,都要重新解释一次。

本文以 RuoYi Office 的财务费用模块为实例,从费用申请进入报销,再连接票据、借款和付款。它不是 ERP 采购付款的替代流程,也不讨论如何用一张报销单包办所有财务业务。我们关注的是更通用的问题:一项业务横跨多个资源时,如何让它们各自有据可查,同时保持整体一致。

使用者需要解决的问题系统应给出的答案
员工 哪些费用能报、还会收到多少 费用依据、冲抵明细、剩余应付
部门负责人 是否在预算范围内 费用维度、预算占用与实际发生
财务审核人员 票据和借款能否用于本单 发票占用来源、可冲销余额
出纳与核算人员 钱是否付完、结果能否追溯 付款来源、资金流水、制证状态

下面的 1,200 元案例是为了说明规则而构造的算例;系统截图来自已有学习数据,并非同一笔案例的连续操作记录。文中“现有实现”对应已核对的源码,“建议”则是面向进一步工程化的设计,不把两者混为产品承诺。

二、先把一笔业务拆成几本不同的账

假设项目人员去客户现场实施,事前申请费用额度,借款 800 元。工作完成后汇总交通、住宿等费用,共计 1,200 元,票据占用金额同样为 1,200 元。本次选择冲抵借款 800 元,审批通过后,公司再向员工支付 400 元。

这一过程可以先写成三条约束:

  • 费用总额来自费用明细汇总,不能由前端自由传一个总数作为最终依据。
  • 冲抵金额不能大于本次费用,也不能超过所选借款的可用余额。
  • 应付金额等于费用总额减本次冲抵,累计付款再与应付金额比较。
口径本例金额表达的业务事实
费用总额 1,200 元 本次需要确认的费用规模
本次借款冲抵 800 元 用费用结算已经借出的资金
本次应付 400 元 公司本次还需支付的金额
累计已付 0→200→400 元 本次应付部分的付款进度

这里最容易出现的错误,是拿 400 元去计算费用预算。借款只改变结算方式,不会让这次活动从花费 1,200 元变成花费 400 元。同样,先支付 200 元也不意味着本次费用只发生了 200 元。

真实业务还可能有含税、不含税、可抵扣税额、费用分摊等口径。现有报销实现分别汇总相关金额,使用 BigDecimal 并按两位小数处理,费用明细提供价税字段时会检查勾稽关系。具体税务判断仍取决于业务制度和票据性质,系统字段不能替代审核结论。

一个实用的表单设计是:让费用明细成为金额来源,让合计区域显示推导结果,再把“冲抵后应付”单独突出。用户既能看到每项费用,也能立即理解为什么最终到账金额小于报销金额。

三、事前申请不是事后报销的复制品

费用申请解决“准备做什么、预计花多少、是否允许发生”;报销解决“实际发生了什么、凭什么确认、怎么结算”。二者关联,却不应该共享一个永远变化的金额字段。

在费用申请页,申请人先明确账套、部门、申请类型、日期、事由和明细。这里的重点不是把表单填得很长,而是把预算控制所需的维度带齐。缺少部门或费用类型,即使写了申请金额,系统也可能找不到正确的预算规则。

费用申请详情:账套、部门、申请事由和费用明细共同构成事前依据

▲ 申请记录保留事前依据;截图中的学习金额为 500 元,与全文算例分开理解。

进入报销时,不能简单地“申请占一遍、报销再占一遍”。否则同一项费用被算作两项承诺,预算可用额会被压低。也不能先永久释放申请占用,再单独启动报销,留下失败后的空档。

现有提交逻辑的关键动作是:保存报销并校验票据;有来源费用申请时,释放该申请的预算占用;再按照报销明细,以报销来源重新占用预算;最后发起审批并记录流程实例。

下面是 submitReimburse 中预算切换部分的摘录,省略后续流程启动与状态更新;事务注解位于完整方法上。

Long id = saveReimburse(saveReqVO);
validateInvoiceOccupyMatch(id);
FinanceReimburseDO bill = reimburseMapper.selectById(id);
if (!BpmProcessInstanceStatusEnum.isResubmittable(bill.getProcessStatus())) {
throw exception(REIMBURSE_PROCESS_EXISTS);
}
List<FinanceReimburseItemDO> items = itemMapper.selectListByBillId(id);
if (bill.getApplyId() != null) {
budgetControlService.release(bill.getBookId(),
FinanceBudgetOccupySourceTypeEnum.EXPENSE_APPLY.getType(),
bill.getApplyId());
}
budgetControlService.occupy(bill.getBookId(),
FinanceBudgetOccupySourceTypeEnum.REIMBURSE.getType(),
id, bill.getBillCode(), toOccupyLines(bill, items));
cacheHitIds(bill, items);

这段代码表达的不是“删掉一条记录再加一条记录”,而是把预算责任从预计支出转移到实际报销单。预算记录需要知道来源类型和来源 ID,否则驳回时无法精确释放,恢复申请占用时也难以定位。

如果事前申请 1,500 元、最后只报销 1,200 元,两者差额不应被自动解释为另外一笔费用。若一项申请允许多次报销,还需要明确剩余额度的保留规则、累计使用规则和并发控制。当前这里按申请来源执行释放,不能仅凭这段逻辑就断言“任意多单分批报销都天然正确”;这是实施与验收时必须单独覆盖的组合场景。

四、发票不只是附件,它还是会被占用的资源

员工从发票钱包选择票据时,看起来像在选择附件。实际上,文件只回答“原始材料在哪”,票据身份还要回答“这张票属于谁、被哪张单占用、占用多少、是否已经结算”。

如果只保存图片地址,同一张票重新上传一次就可能绕过判断。因此,报销单关联的是进项发票记录,再保存本次使用关系和金额,而不是把所有识别结果复制到一个附件 JSON 里就结束。

报销选择发票弹窗:选择可使用的票据并查看价税信息

▲ 选择器控制可见候选,服务端仍需再次校验归属、状态与占用来源。

现有报销保存会校验发票属于当前用户,拒绝同一报销单内重复选择同一票据,然后执行占用。这意味着:草稿保存就可能让发票变为锁定状态,不必等到审批提交。

这种策略能减少两张草稿同时把同一张票当成可用票据的情况,但也带来产品责任:用户需要知道票据被哪张草稿占着,撤销、删除或重选时必须有对应释放路径。不能把一张保存后长期不用的草稿,变成无从解释的资源黑洞。

票据占用方法中确实存在数据库行锁,下面摘录它的主要部分:

@Transactional(rollbackFor = Exception.class)
public void occupy(Long invoiceId, Integer sourceType,
Long sourceId, BigDecimal amount) {
FinanceInputInvoiceDO invoice =
inputInvoiceMapper.selectByIdForUpdate(invoiceId);
if (invoice == null) {
throw exception(INPUT_INVOICE_NOT_EXISTS);
}
if (isOccupiedByOther(invoice, sourceType, sourceId)) {
throw exception(INPUT_INVOICE_OCCUPIED);
}
BigDecimal occupyAmount = amount != null
? amount : nullToZero(invoice.getPriceTaxAmount());
if (occupyAmount.compareTo(nullToZero(invoice.getPriceTaxAmount())) > 0) {
throw exception(INPUT_INVOICE_OCCUPY_AMOUNT_EXCEED);
}
FinanceInputInvoiceDO updateObj = new FinanceInputInvoiceDO();
updateObj.setId(invoice.getId());
updateObj.setWalletStatus(FinanceWalletStatusEnum.LOCKED.getStatus());
updateObj.setOccupySourceType(sourceType);
updateObj.setOccupySourceId(sourceId);
updateObj.setOccupyAmount(occupyAmount);
inputInvoiceMapper.updateById(updateObj);
}

这里的锁针对某张发票,不代表整条费用链路都已经解决并发。预算、借款和付款仍有各自的竞争窗口。读源码时要把“这一处用了锁”与“所有相关资源都一致”分开。

提交时,系统还会汇总报销票据关联上的金额,与费用总额比较。我们的算例中应当核对 1,200 元,而不是冲抵后的 400 元。这个等式解决的是当前报销路径的金额匹配,不是对所有企业费用制度的普遍定义;无票费用、补票、定额补贴等需求,应有独立规则,不能通过填一条假票来绕过校验。

五、借款冲销要记录“用了哪一笔”,不能只减一个总数

假设员工名下有两笔借款:项目出差借款 800 元和长期备用金 2,000 元。报销时把“员工余额”直接减 800 元,看似能算出正确总数,却丢失了最重要的信息:本次结清的是哪笔债务,属于哪个账套,撤销时应该恢复到哪里。

因此,报销主单之外需要借款关联行。一行记录借款 ID 和本次冲抵金额,允许多笔借款共同组成冲抵,但不能把同一笔借款重复填入两行,借此绕开单行上限。

费用报销与发票、借款的局部实体关系图

▲ 中间关联记录保留“本次使用了多少”;原始票据和借款各自保留生命周期。

现有校验会根据账套、当前用户和当前报销 ID 查询可冲销借款,检查借款是否在候选范围、是否重复、金额是否为负、是否超过余额,最后再检查冲抵合计不超过报销总额。这比前端把输入框最大值设为余额更重要,因为用户提交时,余额可能已经发生变化。

借款详情:账套、借款人、金额与来源费用申请

▲ 借款是独立业务对象;报销引用它,而不是把借款金额当成普通费用行。

通过审批时,系统会实际生成借款冲抵记录,记录来源为本报销单,并更新借款累计已还金额与冲抵金额。普通借款会根据结果进入部分归还或已还清;备用金则有专门分支,不应机械套用一次性借款的结清语义。

这一设计让反向操作有依据:回退时可以按报销来源找到冲抵记录,而不是猜测应该从哪笔借款中加回金额。但它仍不自动等于幂等。若通过回调重复到达,是否会再次插入冲抵记录,必须结合回调入口和数据库约束验证,不能仅因为方法有事务就认为重复执行安全。

对于多人同时处理的高频场景,建议为冲抵明细建立明确的业务唯一性,并在借款余额更新时采用行锁或版本条件。报销校验通过只是一个瞬间的判断,审批真正执行冲抵时仍要检查余额;两次检查分别解决体验与最终一致性问题。

六、审批同意后,为什么有的单进入待付,有的直接结清

现在回到 1,200 元报销、800 元冲抵的主线。审批通过之后,费用的业务认可已经完成,借款也进入实际冲销,但还有 400 元需要支付。因此单据应进入“待付款”,而不是显示“全部完成”。

如果另一笔报销费用为 800 元,冲抵也是 800 元,应付就是零。此时不应该为了凑齐固定流程,再要求出纳建立一张零元付款单。现有实现有独立分支:无剩余应付时直接进入结清处理,将发票标记完成、预算占用转为实际,并进入凭证来源处理。

发生的动作本例资金结果应如何理解状态
保存草稿 尚未新增付款 单据未提交,但票据可能已占用
提交审批 尚未新增付款 预算责任切换,进入审批中
审批通过 应付 400,已付 0 借款冲销完成,仍待付款
首次付款 200 应付 400,已付 200 部分付款,不是已结清
再付 200 应付 400,已付 400 进入付款完成相关处理

费用报销详情:区分费用金额、借款冲抵与应付金额,收款账号已脱敏

▲ 学习数据中的 500 元报销、300 元冲抵、200 元应付,也体现同样的金额关系;收款账号已遮盖。

现有 addPaidAmount 的核心状态分支如下,省略资金账户回填和方法外围:

BigDecimal paid = nz(bill.getPaidAmount()).add(nz(amount));
FinanceReimburseDO update = new FinanceReimburseDO();
update.setId(id);
update.setPaidAmount(paid);
if (paid.compareTo(nz(bill.getPayAmount())) >= 0) {
update.setBillStatus(FinanceReimburseBillStatusEnum.PAID.getStatus());
markInvoicesDone(id);
budgetControlService.convertToActual(bill.getBookId(),
FinanceBudgetOccupySourceTypeEnum.REIMBURSE.getType(), id);
reimburseMapper.updateById(update);
enqueueVoucher(reimburseMapper.selectById(id));
return;
} else if (paid.compareTo(BigDecimal.ZERO) > 0) {
update.setBillStatus(FinanceReimburseBillStatusEnum.PART_PAY.getStatus());
}
reimburseMapper.updateById(update);

注意这里用 >= 判断完成,并不意味着业务上允许超付。超付限制、重复付款拦截与并发更新,需要在完整付款入口验证;这段读旧值再累加的代码本身不能证明已经实现了并发安全。

同样需要解释的是预算“实际”的时点:当前路径在零应付通过分支,或应付部分全部付清时执行转换。它是这套费用模块的执行口径,不能直接当成所有企业都应采用的会计确认规则。如果企业要求审批确认即记预算实际,需要把预算确认与资金执行进一步解耦。

七、付款闭环不等于银行通道闭环

从费用报销走到付款,需要把来源单据、付款对象、付款金额和资金账户连起来。否则出纳虽然登记了一条流水,报销单却不知道已付多少;或者报销单被手工改成“已付”,资金账户中没有对应记录。

现有付款服务会校验单据未付款、资金账户及收款信息,创建业务支出流水,再逐项回写来源单据,最后更新付款状态。对于报销来源,回写会调用前面讨论的累计已付逻辑。

费用报销主时序:保存占票、提交切预算、审批冲借款、付款回写

▲ 四个阶段分别改变不同资源;长链路不能被想象成一个横跨数天的大事务。

这里要明确一个能力边界:当前付款实现对某类在线转账通道明确返回“不可用”,因此本文的闭环指已经具备的付款登记、资金流水和来源回写,不宣称系统已自动连通银行并完成真实转账。

如果后续接入银行或支付平台,设计重点也不只是增加一个 HTTP 请求。至少需要区分待发送、处理中、成功、失败、结果未知等状态。请求超时只能说明没有及时拿到结果,不能据此直接再发一次付款;查询结果、业务流水号和回调核验都属于同一条资金链路。

幂等键建议绑定实际付款业务,而不是绑定一次浏览器请求。用户刷新页面后重新请求,如果只是生成了新的随机请求 ID,重复付款仍然可能穿透。适合的粒度通常是付款单、付款明细或外部交易号,具体取决于系统是否允许拆分付款,以及一个外部交易对应多少来源单。

这也解释了为什么“付款单已有已付状态检查”只是基础保护:两个并发请求可能同时读到未付。高风险执行入口还要配合原子状态抢占、唯一流水约束或锁,并明确外部成功而本地回写失败时的修复路径。

八、异常路径决定这是不是一套可持续运行的系统

正常路径只是业务的一半。员工会改票、领导会驳回、流程会取消,付款结果也可能需要补录。异常处理不能只把报销单状态改回草稿,因为预算、票据和借款不会因此自动恢复。

现有拒绝或取消处理包含释放报销预算、恢复来源申请占用、释放票据、回退借款冲抵和相关使用金额等动作。读者更值得借鉴的是“按业务来源清理影响”的方式,而不是照抄一个状态枚举。

场景应检查的资源最重要的验收结论
草稿更换票据 原票、新票及关联行 不遗留旧票占用,不抢用他人票
提交预算校验失败 申请占用、报销占用 不出现两边都占或两边都不占
审批拒绝或取消 预算、票据、借款 释放或恢复与该单实际影响对应
重复通过回调 冲抵记录、累计已还 同一业务效果不能重复累计
同时付款或重复登记 付款单、流水、来源已付 不超付、不重复流水、不丢更新
付款成功后撤销诉求 已付流水、冲销与凭证 走明确反向业务,不直接改回草稿

尤其不能把“审批中取消”和“已经付完款后退回”当成同一个按钮。前者主要处理尚未执行的资源预占,后者可能已经改变资金与账务事实,需要反向流水、退款或冲销等明确业务。状态回退不等于事实从未发生。

对日常运营,建议增加一组对账检查:报销费用合计是否等于明细合计;冲抵合计是否等于关联行;累计付款是否等于有效来源付款;锁定票据是否能找到仍有效的占用单;预算占用是否对应正确业务状态。检查发现异常时应先输出差异和来源,不要让“修复任务”直接覆盖金额。

可观测性也应围绕业务身份组织。一次报销链路中,报销 ID、流程实例 ID、借款 ID、付款单 ID 和资金流水来源是不同标识。日志只有一串“执行失败”不够,至少应该能从报销单追到每个关联对象,再判断失败是在校验前、事务内还是外部执行之后。

九、让验收从“能点通”升级为“能对账”

验收时先固定一个小案例,比一次导入几百条复杂数据更容易发现设计缺口。可以在独立测试账套中准备费用 1,200 元、可用借款 800 元和足额票据,记录操作前各资源的余额及状态,再逐阶段比较。

首先保存报销,核对票据已经关联且被正确占用,但借款的实际归还记录尚未因草稿而生成。然后提交,观察预算责任是否从申请转到报销,流程业务键能否回到原单。审批通过后应看到 800 元冲抵和 400 元待付,而不是 1,200 元待付。

接下来分两次各付 200 元,第一次应停在部分付款,第二次才进入付款完成相关处理。最后从付款来源追到报销,从冲抵记录追到借款,从票据占用来源追到报销,并确认不存在多算的一笔资金支出。

另一组反向案例则专门测试失败:同票提交两单、两单竞争同笔借款、重复发送审批结果、付款按钮连点、预算不足时提交。异常返回只是第一层验证,还要看失败后资源有没有残留。

界面体验可以沿“财务 → 费用管控 → 费用申请 / 费用报销 / 借款单”查看,再去预算执行台账和资金相关页面核对。公开演示用于理解页面和流程;资金、冲销和并发验收应在有权限的隔离测试环境完成,不在共享演示数据上反复做破坏性操作。

常见问题

1. 没有借款,是否还要经过冲销步骤?

不需要人为建立借款。冲抵合计为零时,应付就等于费用总额,后面继续走审批和付款。借款关联是按需使用的结算能力,不是所有报销必须补齐的形式步骤。

2. 发票金额足够,为什么还是不能提交?

要区分票面金额、本次关联金额和可用状态。票据可能属于其他人、被其他单据占用,或者本次选票金额与费用总额不一致。只看票面合计无法判断是否满足提交规则。

3. 审批通过就生成凭证是否更简单?

要先定义凭证来源与确认时点。零应付报销和需要实际付款的报销,在现有实现中有不同路径。不能把“进入制证队列”写成“凭证已审核过账”,更不能为了少一个步骤忽略企业自己的核算规则。

4. 给服务方法加事务,是不是就能保证整个闭环?

事务保护的是一个明确范围内的执行,不会自动覆盖跨天审批、外部付款和消息重复投递。需要结合业务幂等、并发控制、来源追踪与补偿检查,分别解决不同阶段的问题。

结语:让每次变化都有来源,让每个结果都能解释

费用系统真正需要连接的,不是菜单,而是几种不同的业务事实:事前获准、费用发生、票据使用、借款归还、资金支付与账务处理。

一张报销单可以成为这些事实的汇合点,但不能把它们压成一个总金额和一个最终状态。把金额口径拆清,把资源变化放到正确时点,把正向与反向操作都关联到来源单据,系统才能从“流程能走完”进化为“业务能对上”。

如果这篇对你有用,点个「在看」或收藏。

🌐 演示地址:https://ruoyioffice.com/web
📦 GitHub 源码:https://github.com/yuqing2026/ruoyi-office
📦 Gitee 源码:https://gitee.com/yqzy1688/ruoyi-office
💬 微信:17156169080(获取产品咨询)

RuoYi Office 企业管理一体化平台

打开演示地址直接查看系统。

赞(0)
未经允许不得转载:171主机测评 » SpringBoot3+Vue3 企业费用管理:从预算申请、发票报销到借款冲销与付款闭环
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址