先说结论
在 Odoo 里,EDI 从来不只是“发票过账时顺手生成一份 XML”。
从 /home/ubuntu/odoo-temp/addons/account_edi/models/account_edi_document.py、account_move.py 和 account_edi_format.py 可以看出,官方真正设计的是:
- 每张发票可对应多份 EDI 文档
- 每份文档都有独立状态
- 错误不只是“有 / 没有”,还分阻塞级别
- 有些格式同步完成,有些要走异步 web service
- 一旦已发送,后续编辑、撤销、作废都受外部监管链路影响
所以一句话概括:
Odoo 的 EDI 不是一个附件生成功能,而是一套受监管约束的文档状态机。
为什么 account.edi.document 要单独成模
一个 account.move 可以挂多条 edi_document_ids,而且 (edi_format_id, move_id) 强制唯一。
这说明 Odoo 并不把 EDI 当成“发票上的一个字段”,而是把它建模成独立对象。
这么做的原因很现实:
- 一张发票可能对应多个 EDI 格式
- 每个格式的发送、取消、附件、错误状态都不同
- 有些国家 / 平台需要单独 web service 生命周期
如果只在发票上塞几个字段,系统很快就会失去表达能力。
独立成模之后,平台才有办法分别描述:
- 哪个格式已经 sent
- 哪个还在 to_send
- 哪个取消中 to_cancel
- 哪个已 cancelled
这就是平台层与“简单导出文件”之间的根本区别。
为什么 blocking_level 比单纯 error 更重要
很多系统出错时只会告诉你:
- 成功
- 失败
但 account.edi.document 还额外区分:
infowarningerror
这意味着 Odoo 不是把所有问题都当成同一种严重性。
这背后是在回答什么问题
它在回答:
- 这个问题是否会阻断当前 EDI 流程
- 还是只是提醒你存在异常,但允许继续
这比“有错误就全卡死”成熟很多。
因为真实世界的电子发票链路常常会遇到:
- 可接受但需要提醒的偏差
- 暂时不影响发送的警告
- 以及必须立即阻断的硬错误
把这些都压成一个红字,只会让运维和财务团队疲于判断优先级。
_get_move_applicability() 为什么是整个框架的核心抽象
account_edi_format.py 里明确说了,格式实现会返回一个 applicability 字典,里面可能包含:
postcancelpost_batchingcancel_batchingedi_content
这个抽象非常漂亮,因为它把“某种 EDI 格式如何对某张 move 生效”压成了统一契约。
也就是说,核心框架不用知道:
- 这是哪个国家
- 这是哪家税局
- 这是同步接口还是异步接口
它只需要知道:
- 这张单据是否适用该格式
- 若适用,该怎么发、怎么取消、能否批处理、内容怎么导出
这就是为什么 account_edi 能承载那么多本地化与监管模块,而核心代码仍保持相对克制。
发票过账时到底发生了什么
在 account.move._post() 里,Odoo 会:
- 遍历 journal 上启用的 EDI 格式
- 对每个格式判断 applicability
- 做配置校验
_check_move_configuration() - 为适用格式创建或重置
account.edi.document - 将状态置为
to_send - 立即处理不需要 web service 的文档
- 对需要 web service 的文档触发 cron
这条链路说明两件事:
第一,EDI 是过账后的正式动作,不是预览动作
因为单据一旦过账,就进入了更正式的会计 / 合规阶段,EDI 才具有业务意义。
第二,Odoo 明确承认“有些 EDI 不可能同步完成”
这就是为什么框架里原生区分:
- 可立即处理
- 必须异步排队
如果团队排错时总问“为什么按钮点了没有立刻 sent”,要先接受这件事:
- 对某些监管链路来说,异步才是常态。
为什么已发送后的回草稿会被卡住
button_draft() 在 account_edi 中有非常明确的保护:
- 如果相关 EDI 已发送,且该格式支持取消流程
- 就不能直接回草稿
- 必须先走 “Request EDI Cancellation”
这一步特别像现实监管世界的逻辑:
- 单据不是你本地想改就改
- 你已经把事实发给外部系统了
- 要改,就得先和外部世界对齐取消或作废
所以很多财务用户会抱怨:
- 为什么这张发票昨天还能改,今天突然不能?
答案通常不是 Odoo 在找麻烦,而是:
一旦外部监管链路已经承认这份电子文档,本地编辑自由度就必须收紧。
为什么取消流程不是直接 button_cancel() 完事
在 EDI 场景下,button_cancel() 会:
- 对未 sent 的文档直接标记
cancelled - 对已 sent 的文档置成
to_cancel - 先处理同步可取消的部分
- 再触发异步流程继续取消
这说明“取消”在 Odoo 里其实有两层含义:
- 本地想取消
- 外部监管系统也认可取消
只有第二件事完成,很多链路才算真正闭环。
这也是为什么源码里还有:
edi_show_cancel_buttonedi_show_abandon_cancel_buttonedi_show_force_cancel_button
因为取消不是一个单步动作,而是一个可申请、可撤回、可强制推进的状态过程。
_process_documents_web_services() 为什么强调锁和 commit
异步 web service 处理里,Odoo 会:
lock_for_update()- 尝试锁定 move 和潜在附件
- 多 job 情况下在 job 间 commit
- 若已被别的事务锁住,则跳过或报“正在被另一进程发送”
这说明官方非常清楚 EDI 处理的现实问题:
- cron 可能并发
- 人工点击和后台任务可能同时来
- 附件与状态更新可能互相踩踏
所以 EDI 的麻烦常常不只在“税局接口回了什么”,也在“平台如何避免双发、重试和并发冲突”。
新手最容易误解的 5 件事
1. 误以为 EDI 就是一份导出附件
真正核心是独立文档生命周期,不是附件本身。
2. 误以为有错误就一定不能继续
还要看 blocking_level,不是所有问题都同级。
3. 误以为发票过账后 EDI 应该立刻 sent
很多格式天然就要异步 web service。
4. 误以为已发送电子发票还能像普通凭证一样随意回草稿
一旦外部世界已收到,边界就完全不同。
5. 误以为取消只是本地状态切换
对 EDI 来说,取消通常还要等待监管链路确认。
实战排错顺序
如果你遇到“EDI 一直发不出去”或“为什么不能改回草稿”这类问题,建议按这个顺序查:
- 先看具体是哪条
account.edi.document卡住,而不是只看 move 总状态 - 确认
blocking_level是 info、warning 还是 error - 检查
_check_move_configuration()是否在发票过账前就埋下配置错误 - 区分这是同步格式还是需要 web service 的异步格式
- 若是取消链路,再看当前是
sent、to_cancel还是已cancelled,不要把本地想取消和外部已取消混为一谈
一句话记忆
Odoo EDI 的本质不是“发 XML”,而是把电子文档、错误严重级别、同步 / 异步处理和外部监管取消流程统一进一套可追踪状态机。
DISCUSSION
评论区