很多人第一次看到 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 读成几行流水”,而是下面四个更现实的问题:
- OCR credits 什么时候扣,才不会因为事务回滚白白浪费?
- 识别结果什么时候回到 Odoo,靠轮询还是回调?
- 如果财务已经开始人工处理 statement,OCR 结果还能不能强行覆盖?
- 识别出来的金额为什么最后又表现成借方/贷方列,而底层仍是 signed amount?
所以企业版银行对账单 OCR 的重点不是“识别”,而是“异步导入控制”。
它是一条围绕事务安全、外部服务回调、人工处理边界和会计录入体验搭起来的链路。
一、入口不是“解析文件”,而是先判断附件该走哪条导入路由
先看 models/account_journal.py。
_import_bank_statement() 并没有简单地把所有附件都丢给 OCR,而是先做 _check_attachments_ocrizable():
- PDF 可以走 OCR;
image/png、image/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_extraction或extract_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 的核心字段只有几个:
amountdatejournal_idpayment_ref(来自 OCR 的description)
也就是说,OCR 识别结果落库时,官方并没有直接生成一对“借方/贷方列”,而是先用 signed amount 表达交易方向:
- 收款是正数;
- 付款是负数。
这和基础会计对象设计保持一致。
随后,account_bank_statement_line.py 又补了两个计算字段:
debitcredit
其规则是:
amount < 0时,debit = -amountamount > 0时,credit = amount
并且 inverse onchange 会把用户在借贷列上的修改重新折回 amount = credit - debit。
这说明什么?
说明官方把这件事分成了两层:
- 存储层:仍坚持一套 signed amount 语义;
- 录入层 / 展示层:允许财务按借方、贷方习惯查看和微调。
这是一种很典型的“底层模型保持简洁,界面适配会计习惯”的做法。
所以你在企业版 OCR statement 里看到多出来的 debit / credit 列,不要以为底层对象变了; 它只是给财务更顺手的编辑界面。
八、OCR 返回的不只是结构化值,还会保存 words / numbers / dates 供后续纠错
extract.mixin.with.words 还做了一件很容易被忽略的事:
extract_attachment_idextracted_wordsextracted_numbersextracted_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 本身,而是这四条边界
如果你是实施顾问或二开开发,我觉得这个模块最值得抄作业的地方有四个:
- 导入前先做路由分流:结构化文件和 OCR 文件不要混跑。
- 外部扣费调用放 post-commit:避免本地回滚与外部计费失配。
- 结果回写前加覆盖保护:只处理空白对象,不贸然 merge 人工数据。
- 保留识别上下文:不要只存最终字段,必要时把原 OCR 词框也留下。
这四条看起来都不像“识别算法”,但它们才是真正决定系统能不能上线稳定跑的部分。
结语
account_bank_statement_extract 最有意思的地方,不在于它能把 PDF 识别成几条流水,而在于它把 OCR 当成了一条 受事务、外部计费、回调时机和人工修正共同约束的会计导入流程。
所以企业版银行对账单 OCR 的真正价值,不是“省录入”,而是让“异步识别”这件事在财务场景里仍然可控、可追踪、可回退。
理解了这一点,你以后再看银行回单识别、票据识别、电子单据抽取,就不会只盯着“识别率高不高”,而会先问:
- 事务失败时怎么处理?
- credits / 配额怎么保护?
- 人工已经介入后谁优先?
- 回调结果凭什么覆盖本地数据?
这些,才是企业版源码真正教你的东西。
DISCUSSION
评论区