SpringBoot3+Vue3 进项发票池:OCR 识别、票面预览与报销占用怎么落地
🌐 文档地址:https://ruoyioffice.com
📦 源码1·GitHub:https://github.com/yuqing2026/ruoyi-office
📦 源码2·GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office
📦 源码3·Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬 微信:17156169080(备注「RuoYi Office」)
同一张专票出现在两张报销里,财务月底才发现。差的不是认真,是票没有先入池再占用。RuoYi Office 把影像识别成票号和价税,进项落自己的表,票池做统一台账;报销挂票时行锁写入占用,已被别的单锁住就拦。OCR 只是连接器,占用才是账。

▲ 票面 → Vue3 上传 / OCR 回填票号税号 → SpringBoot3 occupy 行锁 → 报销单上锁。第二单挂同一张票会被拦;驳回 release 后票夹回待报销
引言:票若只活在报销明细行里,重复报销拦不住
费用预算管的是额度公式,上一篇已经写过。发票管的是另一件事:这一张纸被哪张单锁住。 两套账对成同一张表,财务会同时骂「超预算」和「重复报销」,却对不上号。
| 报销单手填票号 | 不用票夹 | 同一张专票挂两单,查验对不上 |
| OCR 成功才允许保存 | 显得智能 | 识别失败全员无法报,黄牛票更没法录 |
| 票池和进项一张大宽表 | 少一张表 | 销项购方、进项销方字段互相污染 |
| 占用只改一个「已用」标记 | 少字段 | 驳回后不知道释放给谁,金额也对不回票面 |
正确的形状是:影像先上传,OCR 能回填就回填,失败改手工;进项进 finance_input_invoice;票池把进项和销项在内存里合并给台账;报销或应付提交时 occupy,驳回 release。认证状态、票夹状态、占用单据,是三列,不是一列。
连接性也要画清:Vue3 负责上传影像和回填表单,OCR 只是把票号、税号、价税填进字段,SpringBoot3 的 occupy 才上锁。识别通了但没行锁,重复报销仍拦不住;锁上了但驳回不 release,票会永远躺在已锁定。
本文不讲预算可用公式,也不讲开票申请怎么走审批。只讲进项票怎么从影像变成可占用的池内对象。
一、先给出能点的定义
1.1 进项发票是什么
进项发票是「票面要素 + 影像 + 认证 + 票夹状态 + 占用指针」的一张主数据。 票面要素至少有票种、代码、号码、开票日、销方名称税号、金额、税额、价税合计。影像必传,否则保存直接警告。
录入来源:手工 10,OCR 20。OCR 成功会把来源写成 20,方便事后解释「这几个字段是识别出来的」。识别失败仍可手工改,不要把来源锁死成「必须机器说了算」。
1.2 票池不是第三张业务表
销项、进项分表存储。票池服务按方向过滤后,在内存里合并、按创建时间倒序、再分页。V1 台账数据量可控,不必先上宽表或搜索引擎。
票夹是票池的个人视图:只看当前用户待报销、未占用的进项。报销单从票夹勾票,不要从全公司票池随便捞。
1.3 三种票夹状态,占用另写指针
| walletStatus | 名称 | 谁改 |
|:—|:—|:—|:—|
| 10 | 待报销 | 录入默认;release 后回到这里 |
| 20 | 已报销 | 报销办结后 |
| 30 | 已锁定 | occupy 成功 |
占用指针:occupySourceType(报销单 1 / 应付单 2)、occupySourceId、occupyAmount。已被其他来源占用,isOccupiedByOther 为真,编辑按钮消失,再 occupy 抛「发票已被占用」。
认证状态是税务侧:待认证 / 已认证 / 不可抵扣。它不替代票夹状态。已认证仍可以待报销;已锁定也可以尚未认证。混成一列,抵扣台账和报销台账会一起错。
二、系统设计
| OCR 服务 | 上传后识别,落处理记录 | 录票人 |
| 进项服务 | 保存、认证、占用、释放 | 财务 / 报销 |
| 票池服务 | 进项 + 销项合并分页 | 财务台账 |
| 票面预览 | 影像 + 价税 | 审核人 |
| 报销挂票 | 从票夹勾选并 occupy | 员工 |
核心决策:
| OCR 可失败 | 识别记录失败,表单仍可手改 | 黄牛票、折叠票不能锁死录入 |
| 进项销项分表 | 票池只读合并 | 购方销方语义相反 |
| 占用行锁 | selectByIdForUpdate | 两张报销同时勾同一张 |
| 占用金额默认价税合计 | 显式传入不得超票面 | 防止拆票超占 |
| 未占用才能改票 | wallet=待报销且无占用指针 | 锁住的票不能再改税号 |
OCR 提供商由 Spring 注入的 InvoiceOcrClient 列表按配置选取。换厂商只换客户端,不换占用逻辑。识别先插一条 status=0 记录,成功改 1 并截断原始 JSON,失败写原因。连接器挂了,票还在,账还在。
三、PC:录入、预览、票池
进项列表在 /finance/invoice/input-invoice。工具栏「录入发票」打开表单弹层,不是跳到另一张空白详情。已占用的行没有编辑和删除,只剩认证类操作(待认证时)。

▲ 录入弹层:票种、代码、号码、开票日、销方、价税。没有影像不能保存
OCR 组件接受 jpg / png / pdf,上限 20MB。上传走统一文件服务,私有桶预览用预签名 URL。点识别后回填票号和金额;回填错了允许改,不要做成只读。

▲ 上传影像后识别。成功回填字段,来源记成 OCR;失败仍可手工录入
票面预览用在报销详情和录票处:左边影像,右边价税和销方。审核人要对的是「图上的数」和「单上的数」,不是再看一张发票列表。

▲ 预览认影像和价税合计。列表页最多当入口,不靠表格证明票是真的
票池 /finance/invoice/invoice-pool 可切「全票池 / 个人票夹」。全票池是财务台账,票夹是报销勾选源。这一张是本篇唯一允许的列表。

▲ 台账看出方向、票号、占用金额、占用单号。占用列为空才是还没被锁
四、报销怎么挂这张票
报销明细不是再手填一组票面,而是引用进项 id。提交时对每张票 occupy(invoiceId, REIMBURSE, reimburseId, amount)。金额空则用价税合计。超过票面抛超占。
应付单也可以占用(sourceType=2)。采购票进成本时走应付,差旅票进费用时走报销。同一张进项不能同时被两者锁。
驳回、取消、删报销走 release(sourceType, sourceId):只处理已锁定的行,票夹回到待报销,占用三字段清空。不要在 release 里改认证状态,税务侧没变。
预算占用和发票占用不要对账。预算问「这个费用类型还有没有额度」;发票问「这张纸是不是已经被别的单拿走」。一张招待费报销可能同时:占预算明细一行,占进项票一张。失败原因要分开提示,不要合成一句「提交失败」。
五、后端核心实现
5.1 OCR:先落处理中,再回填
public FinanceOcrRecognizeRespVO recognize(FinanceOcrRecognizeReqVO reqVO) {
if (StrUtil.isBlank(reqVO.getFileUrl())) {
throw exception(INVOICE_OCR_FILE_EMPTY);
}
InvoiceOcrClient client = getClient();
FinanceOcrRecordDO record = new FinanceOcrRecordDO();
record.setFileUrl(reqVO.getFileUrl());
record.setBizType(reqVO.getBizType());
record.setProvider(client.getProvider());
record.setStatus(0);
ocrRecordMapper.insert(record);
InvoiceOcrResult result = client.recognize(reqVO.getFileUrl());
// 成功:status=1,原始 JSON 截断后入库,返回回填字段
return BeanUtils.toBean(result, FinanceOcrRecognizeRespVO.class);
}
前端 handleRecognized 只 setValues 票种、代码、号码、日期、销方、三项金额。税号识别错了,录票人改完再保存。不要在 OCR 成功后立刻 occupy。
5.2 占用:行锁 + 他人占用判断
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);
}
updateObj.setWalletStatus(FinanceWalletStatusEnum.LOCKED.getStatus());
updateObj.setOccupySourceType(sourceType);
updateObj.setOccupySourceId(sourceId);
updateObj.setOccupyAmount(occupyAmount);
inputInvoiceMapper.updateById(updateObj);
}
同一张报销重复提交,应视为本单占用,不要当成他人占用。isOccupiedByOther 排除「来源类型 + 来源 id 都是自己」的情况。连点提交靠这层,不靠按钮禁用。
5.3 票池:分表查询,内存合并
public List<FinanceInvoicePoolRespVO> getInvoicePoolList(FinanceInvoicePoolPageReqVO reqVO) {
List<FinanceInvoicePoolRespVO> result = new ArrayList<>();
if (!Objects.equals(reqVO.getDirection(), DIRECTION_INPUT)) {
result.addAll(queryOutput(reqVO));
}
if (!Objects.equals(reqVO.getDirection(), DIRECTION_OUTPUT)) {
result.addAll(queryInput(reqVO));
}
result.sort(Comparator.comparing(FinanceInvoicePoolRespVO::getCreateTime,
Comparator.nullsLast(Comparator.reverseOrder())));
return result;
}
进项行会再补占用视图:占用金额、占用单号。台账上看见单号,才能从票跳回报销,而不是只看见「已锁定」。
六、和预算强控差在哪
| 对象 | 编制明细行 | 一张进项票 |
| 公式 | 预算+调整+结转−占用−已发生 | 占用金额 ≤ 价税合计 |
| 策略 | 强控 / 预警 / 不控 | 没有预警档,占不了就抛错 |
| 来源 | 申请 / 报销 / 付款 | 报销 / 应付 |
| 释放 | 流水记负数 | 清空指针,票夹回待报销 |
不要把发票占用金额累加进预算已发生。票是合规凭证,预算是盘子。一张票报销 500,预算占 500,发票占 500,对的是两本账,不是一个数字写两次。
开票申请是销项侧的审批单,流程 key finance-invoice-apply-audit,和进项池不是一张单。客户说「发票模块」,先问他是开出去还是收进来。
七、数据结构
finance_input_invoice:编号、代码、号码、票种、开票日、销方名称税号、购方名称税号、金额、税额、价税合计、认证状态、来源、供应商、归属人、票夹状态、占用类型 / 单据 / 金额、影像、备注。
finance_input_invoice_item:明细行,税率和货物名称。OCR 能回填多少写多少,不够再手工。
finance_ocr_record:文件地址、业务类型、提供商、状态、原始结果、解析结果。它不是票,不能拿记录 id 去 occupy。
票池没有自己的表。占用视图是查询拼出来的 FinanceInvoiceOccupyView。不要为了台账再复制一份票面,复制一次就和进项表开始漂移。
编辑门禁:walletStatus == 待报销 且没有占用指针。已锁定、已报销、或占用单号还在,表单不开放。
八、技术亮点
| OCR 可失败 | 记录失败 + 表单手改 | 识别挂了也能录 |
| 来源分 10 / 20 | 保存时带 source | 事后知道谁填的 |
| 进项销项分表 | 票池内存合并 | 字段语义不打架 |
| 占用行锁 | FOR UPDATE | 防两单抢一张 |
| 占用排除本单 | isOccupiedByOther | 连点不误伤 |
| 超占校验 | 金额 ≤ 价税合计 | 不能拆出比票面大的数 |
| 票夹三态 | 待报销 / 已锁定 / 已报销 | 和认证状态拆开 |
| 释放不改认证 | 只清占用指针 | 税务侧不被报销驳回带跑 |
| 影像必传 | 无 URL 不保存 | 审核能对图 |
| 预览预签名 | 私有桶临时地址 | 不把对象存成永久公网 |
| 票夹与票池分视图 | wallet-page / page | 员工只能勾自己的待报销 |
| 和预算分账 | 不同服务不同异常 | 超预算和票被占提示分开 |
九、快速体验
在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
演示站公共数据只看、少改。编号前缀以页面为准,认的是「一张未占用进项 + 报销挂票后出现占用单号」,不是死背 id。
源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
十、结语
进项发票的难度不在会不会调用识别接口,而在票有没有先成为池内对象,以及占用有没有行锁。RuoYi Office 用 OCR 回填、分表入池、票面预览、报销 / 应付占用,把「手填票号」收成可对账的动作。识别失败可以手改,占用失败必须抛错,两件事不要做成同一个开关。
读者能带走的三句:OCR 是连接器;票池是只读台账;占用看行锁。预算管额度,发票管这一张纸,两本账分开问。
你们现在重复报销是月底对票,还是提交就被拦?评论区对照一下票夹三态,比空泛「上了发票模块」有用。
相关阅读:同目录财务预算强控讲占用公式;OA 出差报销讲双单联动。那些文章不替代本页的进项池。要把票真正管住,录入、预览、挂票、释放必须认同一张进项 id。
💡 想要体验 RuoYi Office 的强大功能?
🌐 在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
📦 源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬 技术咨询:添加微信 17156169080,备注「RuoYi Office」
⭐ 如果觉得不错,请给个 Star 支持一下!






