☰
SpringBoot3+Vue3 进项发票池:OCR 识别、票面预览与报销占用怎么落地
2026/11/6 4:57:09 网站建设 项目流程

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:先落处理中,再回填

publicFinanceOcrRecognizeRespVOrecognize(FinanceOcrRecognizeReqVOreqVO){if(StrUtil.isBlank(reqVO.getFileUrl())){throwexception(INVOICE_OCR_FILE_EMPTY);}InvoiceOcrClientclient=getClient();FinanceOcrRecordDOrecord=newFinanceOcrRecordDO();record.setFileUrl(reqVO.getFileUrl());record.setBizType(reqVO.getBizType());record.setProvider(client.getProvider());record.setStatus(0);ocrRecordMapper.insert(record);InvoiceOcrResultresult=client.recognize(reqVO.getFileUrl());// 成功:status=1,原始 JSON 截断后入库,返回回填字段returnBeanUtils.toBean(result,FinanceOcrRecognizeRespVO.class);}

前端handleRecognized只setValues票种、代码、号码、日期、销方、三项金额。税号识别错了,录票人改完再保存。不要在 OCR 成功后立刻 occupy。

5.2 占用:行锁 + 他人占用判断

publicvoidoccupy(LonginvoiceId,IntegersourceType,LongsourceId,BigDecimalamount){FinanceInputInvoiceDOinvoice=inputInvoiceMapper.selectByIdForUpdate(invoiceId);if(invoice==null){throwexception(INPUT_INVOICE_NOT_EXISTS);}if(isOccupiedByOther(invoice,sourceType,sourceId)){throwexception(INPUT_INVOICE_OCCUPIED);}BigDecimaloccupyAmount=amount!=null?amount:nullToZero(invoice.getPriceTaxAmount());if(occupyAmount.compareTo(nullToZero(invoice.getPriceTaxAmount()))>0){throwexception(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 票池:分表查询,内存合并

publicList<FinanceInvoicePoolRespVO>getInvoicePoolList(FinanceInvoicePoolPageReqVOreqVO){List<FinanceInvoicePoolRespVO>result=newArrayList<>();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())));returnresult;}

进项行会再补占用视图:占用金额、占用单号。台账上看见单号,才能从票跳回报销,而不是只看见「已锁定」。


六、和预算强控差在哪

预算占用发票占用
对象编制明细行一张进项票
公式预算+调整+结转−占用−已发生占用金额 ≤ 价税合计
策略强控 / 预警 / 不控没有预警档,占不了就抛错
来源申请 / 报销 / 付款报销 / 应付
释放流水记负数清空指针,票夹回待报销

不要把发票占用金额累加进预算已发生。票是合规凭证,预算是盘子。一张票报销 500,预算占 500,发票占 500,对的是两本账,不是一个数字写两次。

开票申请是销项侧的审批单,流程 keyfinance-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)

  1. 打开/finance/invoice/input-invoice,点「录入发票」。
  2. 上传一张票面影像,点识别,看票号和价税是否回填。
  3. 保存后在列表点编辑(仅待报销且未占用),核对影像还在。
  4. 打开/finance/invoice/invoice-pool,切全票池,找刚录的进项和占用列。
  5. 打开一张财务报销详情,从票夹勾票,提交后回票池看占用单号。
  6. 若可造数:同一张票挂第二张报销,应看到已被占用。
  7. 驳回第一张报销,票夹应回到待报销,编辑按钮回来。

演示站公共数据只看、少改。编号前缀以页面为准,认的是「一张未占用进项 + 报销挂票后出现占用单号」,不是死背 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 支持一下!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询