ZATCA

Odoo 沙特 ZATCA E-Invoicing 为什么不只是“生成一份 XML 上传”而已:清关 / 报送、链式序号与二维码边界讲透

沙特电子发票最容易被低估的,不是 XML 模板,而是清关与报送分流、证书签名、链式序号、拒绝后重提规则、QR 载荷与生产环境不可回退边界。本文结合 l10n_sa_edi 源码讲透。

框架
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

先说结论

在 Odoo 里,沙特 ZATCA 电子发票不是“导出一份 UBL XML,丢给税局接口”这么简单。

/home/ubuntu/odoo-temp/addons/l10n_sa_edi/models/account_edi_format.pyaccount_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. 误以为提交成功后还能像普通发票一样随意撤回修改

生产模式下,很多回退路径会被明确关闭。


实战排错顺序

如果你碰到“沙特发票提交失败 / 二维码异常 / 不能回草稿”,建议按这个顺序查:

  1. 先确认发票属于 Standard 还是 Simplified
  2. 检查 journal 是否已经完成 onboard,证书与私钥是否齐全
  3. l10n_sa_chain_index、链头发票和前一张票是否异常
  4. 确认是被拒绝、警告接受,还是提交超时未确定
  5. 再查买卖双方必填字段、税号、日期、原单引用等本地校验项
  6. 最后才去看报表上的 QR 呈现,因为很多 QR 问题根子其实在签名和 XML

一句话记忆

Odoo 沙特 ZATCA 方案的核心不是“生成 XML”,而是用清关 / 报送分流、签名、链式索引和不可回退边界,把发票变成一条真正受监管的电子凭证链。

DISCUSSION

评论区

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