先说结论
在 Odoo 里,沙特 ZATCA 电子发票不是“导出一份 UBL XML,丢给税局接口”这么简单。
从 /home/ubuntu/odoo-temp/addons/l10n_sa_edi/models/account_edi_format.py 和 account_move.py 可以看出,官方实际处理的是一条很强约束的合规链:
- 先判断这张票属于 Standard 还是 Simplified
- 再决定走 Clearance 还是 Reporting API
- 提交前要准备 UUID、哈希、数字签名、证书、二维码
- 提交中还要维护链式序号,避免重复上报
- 生产环境下,已进入合规链的发票不能随意 reset / 删除
一句话概括:
Odoo 不是在“上传 XML”,而是在维护一条受监管的电子票据链。
为什么标准票和简化票要分流
_l10n_sa_assert_clearance_status() 很直接地把两类流程分开:
- Standard Invoice 走
clearance - Simplified Invoice 走
reporting
而 account_move.py 里的 _l10n_sa_is_simplified() 又把 B2C 场景视作简化票。
这意味着在 Odoo 眼里,沙特电子发票不是“一种税局接口”,而是两条监管路径:
- 一条要拿到 CLEARED
- 一条只要求 REPORTED
如果你把两者混成一个“提交成功”状态,后续所有排错和业务解释都会乱掉。
为什么要先生成 UUID、哈希和签名,而不是最后再补
_l10n_sa_generate_unsigned_data() 会先做几件事:
- 生成
l10n_sa_uuid - 先把 XML 模板生成出来
- 对 XML 做哈希
- 再基于哈希生成
l10n_sa_invoice_signature
这个顺序很重要。
因为沙特链路里,签名不是“给最终文件盖个章”这么简单,它会反过来影响:
- XML 的签名块内容
- 二维码数据
- 后续提交内容的一致性
源码注释甚至明确说了:
- 签名必须保存下来
- 因为签名每次重算都可能不同
- 而签名和 QR code 期待看到的是同一份、完全一致的签名
所以这里的重点不是“如何签”,而是 如何保证签名前后链路引用的是同一个事实版本。
为什么链式序号 l10n_sa_chain_index 这么关键
_l10n_sa_post_zatca_edi() 里最值得注意的一点,是它在真正提交前会检查链头是否健康,并在需要时给发票分配新的 l10n_sa_chain_index。
源码注释说得很重:
- 如果一次提交超时,系统无法确认税局是否已收到
- 这时如果把同一张票重复提交,ZATCA 可能会直接联系纳税人要求解释
这说明链式序号不是一个漂亮的技术字段,而是合规世界里的“别把同一事实报两次”控制器。
Odoo 的策略大致是:
- 如果提交异常但不确定状态,保留链索引,不轻易重算
- 如果明确被拒绝,再把链索引清空,允许重新生成并重新提交
这个边界拿捏得很关键。它不是为了代码优雅,而是为了避免监管层面的重复申报风险。
为什么 QR code 不是打印样式问题
很多实施团队把二维码看成“发票上要显示一个码”。
但从 _l10n_sa_get_qr_code() 可以看到,它拼进去的内容非常重:
- 卖方名称
- 卖方 VAT
- 时间戳
- 总额与税额
- 发票哈希
- 数字签名
- 证书公钥
- 对 B2C 场景还追加证书签名字节
这说明 ZATCA 的 QR 不是视觉元素,而是 发票关键事实的压缩证明。
所以二维码出错,往往不是“页面渲染 bug”,而是:
- 签名不一致
- XML 内容变了
- 时间或金额字段有偏差
- 证书链路没对上
这就是为什么 Odoo 要把 QR 生成放在 EDI 主链里,而不是交给报表层随便拼。
为什么生产环境下不能随便改回草稿
button_draft()、_compute_show_reset_to_draft_button() 和 _prevent_zatca_rejected_invoice_deletion() 都在做一件事:
- 限制已进入合规链的发票被修改、回退、删除
尤其在生产环境,一旦发票已和有效 EDI 文档绑定,Odoo 会明确阻止 reset to draft。
这会让不少业务用户觉得“系统太死”。
但站在监管视角,这恰恰是正确的。
因为电子发票一旦进入法定链路,就不再只是 ERP 内部草稿,它已经是:
- 有外部监管含义的正式事实
- 不能像普通业务单据那样任意推倒重来
Odoo 在这里做的不是限制用户,而是在维护“ERP 事实”和“合规事实”之间的一致性。
为什么配置校验这么啰嗦
_check_move_configuration() 会检查很多内容:
- journal 是否已 onboard
- 公司 VAT 是否满足沙特规则
- 私钥是否存在
- 卖方 / 买方是否缺失必要字段
- 发票日期是否晚于当地今天
- Credit / Debit Note 是否带了理由和引用
这类检查看起来繁琐,但正是因为 ZATCA 的失败成本高,系统必须在“生成 XML 之前”尽量把会失败的问题拦住。
否则你会得到最差体验:
- 用户点了提交
- 接口报错一大串业务规则
- 大家再回头猜到底是地址、税号、证书还是引用关系不对
Odoo 的做法是把很多监管规则尽量前置成本地校验。
新手最容易误解的 5 件事
1. 误以为 Standard 和 Simplified 只是模板不同
其实它们直接决定走 clearance 还是 reporting。
2. 误以为签名是最后一步附加物
源码里签名会反过来影响 QR 和提交内容一致性。
3. 误以为链式序号只是内部编号
它本质上是在避免重复申报和链断裂。
4. 误以为二维码只是报表展示需求
它承载的是受监管的票据摘要。
5. 误以为提交成功后还能像普通发票一样随意撤回修改
生产模式下,很多回退路径会被明确关闭。
实战排错顺序
如果你碰到“沙特发票提交失败 / 二维码异常 / 不能回草稿”,建议按这个顺序查:
- 先确认发票属于 Standard 还是 Simplified
- 检查 journal 是否已经完成 onboard,证书与私钥是否齐全
- 看
l10n_sa_chain_index、链头发票和前一张票是否异常 - 确认是被拒绝、警告接受,还是提交超时未确定
- 再查买卖双方必填字段、税号、日期、原单引用等本地校验项
- 最后才去看报表上的 QR 呈现,因为很多 QR 问题根子其实在签名和 XML
一句话记忆
Odoo 沙特 ZATCA 方案的核心不是“生成 XML”,而是用清关 / 报送分流、签名、链式索引和不可回退边界,把发票变成一条真正受监管的电子凭证链。
DISCUSSION
评论区