企业版 OCR

Odoo 企业版银行对账单 OCR 为什么不是“上传即导入”:post-commit 发送、webhook 回调与覆盖保护讲透

很多人把企业版银行对账单 OCR 理解成“上传 PDF 后系统直接生成流水”。但从 `account_bank_statement_extract` 与 `iap_extract` 源码看,官方真正设计的是一条异步链:先建 statement、事务提交后再扣 OCR credits、由 webhook 回调触发结果拉取,并用 extractable state 防止覆盖已经人工处理的流水。

企业 会计
进阶 开发者 3 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

很多人第一次看到 Odoo 企业版的 Bank Statement OCR,直觉都会把它理解成一条很短的链:

  • 上传 PDF 或图片;
  • OCR 服务识别;
  • 系统生成 statement lines。

这个描述不能说错,但太粗了。

如果你去看 /home/ubuntu/odoo-temp/enterprise/account_bank_statement_extract,再连同 /home/ubuntu/odoo-temp/enterprise/iap_extract/models/extract_mixin.py 一起看,会发现官方真正想解决的不是“怎么把 PDF 读成几行流水”,而是下面四个更现实的问题:

  1. OCR credits 什么时候扣,才不会因为事务回滚白白浪费?
  2. 识别结果什么时候回到 Odoo,靠轮询还是回调?
  3. 如果财务已经开始人工处理 statement,OCR 结果还能不能强行覆盖?
  4. 识别出来的金额为什么最后又表现成借方/贷方列,而底层仍是 signed amount?

所以企业版银行对账单 OCR 的重点不是“识别”,而是“异步导入控制”。

它是一条围绕事务安全、外部服务回调、人工处理边界和会计录入体验搭起来的链路。


一、入口不是“解析文件”,而是先判断附件该走哪条导入路由

先看 models/account_journal.py

_import_bank_statement() 并没有简单地把所有附件都丢给 OCR,而是先做 _check_attachments_ocrizable()

  • PDF 可以走 OCR;
  • image/pngimage/jpeg 可以走 OCR;
  • 其他格式仍回到普通导入逻辑;
  • 如果同一批附件里混了 PDF/图片和其他格式,直接报错,不允许混搭。

这段设计很值得注意。

它说明官方不是把 OCR 当成“银行对账单导入的默认实现”,而是把它当成 与 CAMT / OFX / CSV 等结构化导入并存的另一条路由

也就是说,Odoo 先问的不是:

这份文件能不能识别?

而是:

这批附件是不是属于 OCR 这条导入通道?

为什么不允许混合上传?

因为两类导入的失败模式完全不同:

  • 结构化文件导入,更像语法解析和字段映射;
  • OCR 导入,更像异步外部服务识别;
  • 如果一批附件混着来,用户很难判断哪一份是同步导入成功、哪一份在等外部服务回调。

所以官方干脆把导入批次切干净,避免状态语义混乱。


二、Odoo 会先创建 account.bank.statement,而不是等 OCR 成功后再建对象

还是在 _import_bank_statement() 里,PDF / PNG / JPEG 这条路径做的第一件事是:

  • 先按附件创建 account.bank.statement
  • 给 statement 绑上 attachment_ids
  • 再把 message_main_attachment_id 指到当前附件;
  • 然后调用 statements._send_batch_for_digitization()

这很关键。

很多人会以为“识别结果回来以后才生成 bank statement”,但官方恰恰反过来:

先有 statement 这个业务对象,再让 OCR 去给它补数据。

这样做有三个好处:

1)外部识别结果有明确落点

OCR 服务返回时,不需要临时猜该落在哪个界面对象上,因为 statement 早就存在了。

2)上传动作和业务对象建立了稳定关联

附件、chatter 主附件、extract 状态、document uuid,都可以挂在 statement 自身上。

3)后续允许人工接管

即使 OCR 还没回来,财务已经能看到那张 statement 记录;后面如果需要人工补录、重传或放弃 OCR,也有明确对象可以操作。

这就是典型的 Odoo 风格:

先把业务对象立住,再逐步填充外部结果。


三、为什么真正发送到 OCR 服务器要放到 post-commit

这一层是整条链最容易被忽略、但其实最专业的部分。

extract_mixin.py 里的 _send_batch_for_digitization() 注释写得非常直白:

  • 文档发送在 post-commit 做;
  • 这样每份文档都可以单独提交;
  • 如果在当前事务里直接发送,一旦本地事务后来回滚,
  • Odoo 这边状态会被回滚,但 OCR 服务器那边已经接单,IAP credits 就丢了

换句话说,官方要防的不是“识别慢”,而是:

本地事务失败,外部计费却已经发生。

这就是为什么 _send_batch_for_digitization() 不是直接循环 _upload_to_extract(),而是注册一个 @self.env.cr.postcommit.add 回调,再开新 cursor 逐条发送、逐条 cr.commit()

这背后的设计判断是什么?

银行对账单 OCR 不是纯本地功能,而是:

  • 本地 ORM 事务
  • 外部 IAP 服务
  • 有成本的 credits 消耗

三者交叉的场景。

在这种场景里,“事务一致性” 不只是数据库一致性,还包括和外部付费服务的一致性

所以 post-commit 在这里不是性能优化,而是成本保护。

对二开的提醒

如果你以后自己接第三方 OCR、电子回单识别、票据识别,最不该偷懒的一件事就是:

  • 不要在主事务里直接调用外部扣费接口;
  • 更不要在对象还没稳定落库前就把文件发出去。

否则你迟早会遇到:

  • 用户看到导入失败;
  • 但第三方平台已经计费;
  • 系统还找不到对应 document token;
  • 财务只能重复上传,成本和状态一起乱掉。

四、message_main_attachment_id 不只是附件字段,它是 OCR 流程的真正触发点

account_bank_statement.py 里,模块重写了 _message_set_main_attachment_id()

  • 先走父类逻辑;
  • 再调用 _autosend_for_digitization()

_autosend_for_digitization() 又受公司设置 extract_bank_statement_digitalization_mode 控制:

  • no_send:不自动送 OCR;
  • auto_send:只要当前 statement 能显示 send button,就自动发。

这说明官方把“上传附件”与“送 OCR”之间的关系设计成:

不是任何附件变化都触发 OCR,而是“主附件建立之后,在允许自动送的模式下,触发 OCR”。

这个边界很重要。

因为 chatter 里可能有很多附件:

  • 说明截图
  • 沟通邮件
  • 重新导出的文件
  • 与当前导入无关的补充材料

只有 message_main_attachment_id 才表达:

这份附件是当前这张业务记录最重要、最该被系统消费的文档。

把 OCR 触发点挂在这里,比简单监听 attachment_ids 更稳,也更符合 Odoo 自己的文档对象模型。


五、结果回来时,Odoo 先看状态,再通过 webhook 把等待中的 statement 拉到下一步

controllers/main.py 只有很短一段代码,但信息量非常大。

路由是:

/account_bank_statement_extract/request_done/<extract_document_uuid>

OCR 服务处理完成后,会调这个 webhook。控制器不会直接接收完整识别内容,而是:

  • 通过 extract_document_uuid 找 statement;
  • 限定 extract_state 必须是 waiting_extractionextract_not_ready
  • 限定 is_in_extractable_state = True
  • 对命中的 statement 调 _check_ocr_status()

这说明 webhook 的职责不是“推送结果包”,而是:

通知 Odoo:这个 token 对应的文档处理好了,你现在可以主动去取结果。

所以真正的结果获取仍走 _contact_iap_extract('get_result', ...)

为什么不直接在 webhook 里塞完整结果?

因为这样能把职责拆得更清楚:

  • webhook 只负责唤醒;
  • Odoo 自己负责拉取结果并写库;
  • 结果处理逻辑集中在 _check_ocr_status() / _fill_document_with_results(),不会散落在 controller。

这是一种很稳的外部集成写法。


六、is_in_extractable_state 才是真正的“覆盖保护开关”

这是本模块最值得单独写一篇的地方。

account_bank_statement.py 里:

@api.depends('line_ids')
def _compute_is_in_extractable_state(self):
    self.is_in_extractable_state = not self.line_ids

逻辑看上去很简单:只要 statement 已经有 line_ids,就不再处于 extractable 状态。

但它的业务含义非常重。

这等于在明确宣布:

OCR 只允许填充“空白 statement”,不允许覆盖已经拥有流水行的 statement。

这条规则同时影响多个地方:

  • send button 是否显示;
  • webhook 回调能不能继续处理;
  • check_all_status() 是否还会轮到它;
  • 用户能不能再次把这张 statement 送去 OCR。

为什么官方宁可保守,也不自动 merge?

因为银行流水不是发票表头,错误覆盖的成本非常高。

一旦财务已经:

  • 手工补了几条 statement line;
  • 调整了金额;
  • 开始做 reconciliation;

这时 OCR 结果再回来,如果强行 merge 或重建行项,会把后续会计动作一起污染。

所以官方选择了一个非常保守但很靠谱的规则:

“有行就别碰。”

这比“尽量聪明地 merge”更像真正的财务系统思路。


七、OCR 成功后,填的不是借贷列,而是 amount + payment_ref + date

_fill_document_with_results() 里真正写入 line 的核心字段只有几个:

  • amount
  • date
  • journal_id
  • payment_ref(来自 OCR 的 description

也就是说,OCR 识别结果落库时,官方并没有直接生成一对“借方/贷方列”,而是先用 signed amount 表达交易方向:

  • 收款是正数;
  • 付款是负数。

这和基础会计对象设计保持一致。

随后,account_bank_statement_line.py 又补了两个计算字段:

  • debit
  • credit

其规则是:

  • amount < 0 时,debit = -amount
  • amount > 0 时,credit = amount

并且 inverse onchange 会把用户在借贷列上的修改重新折回 amount = credit - debit

这说明什么?

说明官方把这件事分成了两层:

  1. 存储层:仍坚持一套 signed amount 语义;
  2. 录入层 / 展示层:允许财务按借方、贷方习惯查看和微调。

这是一种很典型的“底层模型保持简洁,界面适配会计习惯”的做法。

所以你在企业版 OCR statement 里看到多出来的 debit / credit 列,不要以为底层对象变了; 它只是给财务更顺手的编辑界面。


八、OCR 返回的不只是结构化值,还会保存 words / numbers / dates 供后续纠错

extract.mixin.with.words 还做了一件很容易被忽略的事:

  • extract_attachment_id
  • extracted_words
  • extracted_numbers
  • extracted_dates

并提供 get_boxes() 去把 OCR 识别框坐标整理出来。

这说明企业版设计根本不是“识别完就丢”,而是预留了一个 人机协作校正层

换句话说,官方默认接受一件事:

OCR 能帮你大幅减少录入,但它不是最终真相,财务仍可能需要按原文档校正。

所以系统会保留:

  • 原附件是谁;
  • 哪些词被识别出来;
  • 哪些数字、日期对应哪些框。

这和“导入工具只给最后结果、不保留识别上下文”的思路很不一样。

它更接近:

把 OCR 当成可审阅、可校正的辅助层,而不是不可质疑的黑盒。


九、_fill_document_with_results() 最关键的副作用,是自动给 statement 留下一条系统说明

识别成功后,模块会 message_post() 一条 OdooBot 消息:

Statement and transactions have been updated using Artificial Intelligence.

很多人会觉得这只是装饰。

其实不是。

这条消息的价值在于,它给 bank statement 留下了一个非常清晰的审计信号:

  • 这批行不是纯手工录入;
  • 它们是在某次 OCR 结果返回后被系统更新的;
  • 后续如果金额或描述出现争议,至少能先追到“这是 AI 导入结果”。

在财务场景里,这种 来源可追踪 非常重要。


十、最容易误解的 4 个点

误区 1:上传附件就等于已经完成 OCR 导入

不是。

上传只是让 statement 获得主附件;真正送 OCR 可能是:

  • 自动发送;
  • 手动发送;
  • post-commit 后才真正发出。

误区 2:OCR 回来时,系统会智能合并人工改动

不是。

模块默认逻辑非常保守:只处理还没有 line_ids 的 statement。

误区 3:企业版多出来的借方/贷方列说明底层不再用 signed amount

也不是。

底层仍是 amount,借贷列只是 UI / onchange 层的会计展示适配。

误区 4:webhook 一调用,结果就直接写进数据库

也不准确。

webhook 更像“完成通知”,真正写库仍由 Odoo 自己走 get_result_fill_document_with_results() 这条内部流程。


十一、实施和二开时,最该学的不是 OCR 本身,而是这四条边界

如果你是实施顾问或二开开发,我觉得这个模块最值得抄作业的地方有四个:

  1. 导入前先做路由分流:结构化文件和 OCR 文件不要混跑。
  2. 外部扣费调用放 post-commit:避免本地回滚与外部计费失配。
  3. 结果回写前加覆盖保护:只处理空白对象,不贸然 merge 人工数据。
  4. 保留识别上下文:不要只存最终字段,必要时把原 OCR 词框也留下。

这四条看起来都不像“识别算法”,但它们才是真正决定系统能不能上线稳定跑的部分。


结语

account_bank_statement_extract 最有意思的地方,不在于它能把 PDF 识别成几条流水,而在于它把 OCR 当成了一条 受事务、外部计费、回调时机和人工修正共同约束的会计导入流程

所以企业版银行对账单 OCR 的真正价值,不是“省录入”,而是让“异步识别”这件事在财务场景里仍然可控、可追踪、可回退。

理解了这一点,你以后再看银行回单识别、票据识别、电子单据抽取,就不会只盯着“识别率高不高”,而会先问:

  • 事务失败时怎么处理?
  • credits / 配额怎么保护?
  • 人工已经介入后谁优先?
  • 回调结果凭什么覆盖本地数据?

这些,才是企业版源码真正教你的东西。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。