框架深潜

Odoo 西班牙 Veri*Factu 为什么真正难点不在“发 XML”,而在链式指纹、批次节流与二维码边界

很多人把 Veri*Factu 理解成“开票后生成一份 XML 发给税局”。但 Odoo 标准实现里,真正复杂的是每条记录都要带上前序记录指纹形成链、已成功生成的文档不能删除、发送还要遵守批次等待时间,甚至超时后还要靠 duplicate 信息做恢复判断,二维码也依赖最终记录标识而不是随手拼接。

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

先说结论

Odoo 的 l10n_es_edi_verifactu 绝不是“出一份 XML,传给 AEAT,然后等回执”这么线性的流程。

/home/ubuntu/odoo-temp/addons/l10n_es_edi_verifactu/models/verifactu_document.py 看,标准实现真正围绕的是四个关键词:

  1. chain:每条记录不是独立存在,而要串成生成顺序链。
  2. fingerprint:每条记录都要根据关键字段与前序 huella 计算新指纹。
  3. batching:不是想发就发,发送要受批次大小与等待时间约束。
  4. immutability:成功生成并入链的文档,原则上不能再删。

所以 VeriFactu 的重点不是“发票 XML 长什么样”,而是你如何证明这些记录按规则持续生成、持续串接、持续可追溯。*

Veri*Factu 文档为什么不是附件,而是一条“链上的记录”

l10n_es_edi_verifactu.document 这个模型非常值得细看。

它不只是存一份 JSON / XML,而是显式保存:

  • chain_index
  • document_type
  • json_attachment_id
  • state
  • response_csv
  • errors

这里最关键的是 chain_index

源码写得很直白:文档形成一条按生成顺序排列的链。而且一旦文档成功生成、进入链条,它就不能被删除。

为什么不能删?

因为后续文档的 fingerprint 里会引用前序文档的关键标识和 Huella。你删掉前一条,后面的链路语义就被破坏了。

这和很多普通 EDI 的思维完全不同。

普通对接里,常见想法是:

  • 发错了就删掉重来;
  • XML 重新生成即可;
  • 本地记录只是缓存。

而 Veri*Factu 在 Odoo 里的实现是:

本地文档本身就是链式合规模型的一部分,不是可随意重建的临时文件。

指纹为什么必须带上前序记录

_update_render_vals_with_chaining_info()_fingerprint() 是理解这套机制的核心。

如果是第一条记录,Odoo 会写:

'PrimerRegistro': 'S'

否则就写入:

  • 前一条的发票标识;
  • 前一条的 Huella

然后当前文档的 fingerprint,会由这些值拼接后再做 SHA-256。

这意味着什么?

意味着每条记录都不是“自己给自己做 hash”,而是在说:

  • 我是谁;
  • 我对应哪张发票;
  • 我的金额 / 类型是什么;
  • 我生成于什么时候;
  • 我承认我前面那一条是谁,它的指纹是什么。

于是整条链就具备了篡改敏感性:

  • 改掉前面的记录,后面的 huella 语义会失配;
  • 跳过某些记录重建,也会破坏链序。

这就是 Veri*Factu 在平台层真正要保住的“连续性”。

为什么 Odoo 要先生成 JSON,再用 zeep 验 XML

_create_for_record() 里的步骤非常谨慎:

  1. 先拼出 document_dict
  2. 再调用 zeep 的 create_message 只做 XML 生成验证;
  3. 通过后才分配 chain_index
  4. 再把 JSON 存为 attachment。

这套顺序背后有个很关键的判断:

不能先随便把文档链进去,再发现 XML 其实不合法

因为一旦链进去了,文档就不该被随便删改;如果之后才发现 XML 过不了 XSD / WSDL 校验,你就会陷入一个很糟糕的状态:

  • 本地链条已经前进;
  • 但实际上这条记录并不具备可发送性;
  • 后续文档又可能已经引用了它。

所以 Odoo 先尽量在本地把结构问题挡掉,再正式入链。这是非常典型的“合规前置校验”思路。

为什么链式序号获取会主动锁住 sequence

_create_for_record() 里,next_by_id() 如果遇到并发问题,会报 OperationalError,源码注释明确说明:

  • 多个事务不能同时为同一公司生成链上文档;
  • sequence 实际上承担了“链顺序锁”的作用;
  • 这样才能避免不同事务抢着把文档插进同一条链。

也就是说,VeriFactu 在 Odoo 里不是“天然高并发生成”优先,而是链顺序正确性优先*。

这很符合监管语义,因为税务链条最怕的不是慢一点,而是顺序错乱、前后断裂。

发送为什么不是实时逐条发,而是批次 + 等待时间

trigger_next_batch() 里定义了两重节流逻辑:

  1. 大批量时按 BATCH_LIMIT = 1000 分批。
  2. 小于 1000 条时,还要受 next_batch_time 约束。

源码注释直接写了:AEAT 要求两次发送之间通常要等待约 60 秒。

这就说明 Veri*Factu 不是一个普通 webhook 式 API:

  • 不是每生成一条就马上想发就发;
  • 发送节奏本身也是协议的一部分;
  • Odoo 必须在“链生成”和“批次投递”之间做排队调度。

所以如果你线上看到“发票已生成但还没立刻发出”,这不一定是故障,可能恰恰是系统在守合规节流。

超时为什么不一定等于失败

发送批次时,源码专门处理了 [Read-Timeout] 场景,而且会去看响应里的 duplicate 信息来做补救判断。

这个细节特别值得重视。

为什么 timeout 后不能草率重发?

因为超时只代表:

  • Odoo 没及时收到完整响应;
  • 不代表 AEAT 一定没收到或没处理。

如果你把 timeout 直接理解成“没发出去”,然后无脑重发,就可能造成重复记录判断、链路混乱,甚至把本来成功的记录又推入更复杂的恢复状态。

Odoo 的做法更成熟:

  • 优先解析 duplicate 信息;
  • 如果 duplicate 表明原记录已被接受,就把它视作成功恢复;
  • 对取消文档也做了对应映射。

这说明 VeriFactu 对接里最重要的不是“请求发出去那一刻”,而是如何在不确定网络条件下保持记录唯一性和状态可恢复性。*

二维码为什么依赖记录标识,而不是页面层临时拼接

_get_qr_code_img_url() 并不是任意拿几项字段就直接前端生成二维码,它先要拿到 document 的 record identifier。

并且只有 submission 类型的记录,才具备生成二维码所需的完整值,例如:

  • IDEmisorFactura
  • NumSerieFactura
  • FechaExpedicionFactura
  • ImporteTotal

这说明二维码不是一个装饰层功能,而是发送记录语义的外显结果

如果记录本身还没建立好、关键标识不完整,二维码也不该先出现。

这套实现最容易误解的 5 个点

1. 误以为本地文档只是缓存

其实链上 JSON 文档本身就是合规证据的一部分。

2. 误以为生成成功就等于已发送

生成、入链、排队、批次发送、响应处理,是多阶段流程。

3. 误以为 rejected 记录可以像普通失败任务那样直接删掉

链式世界里,记录即使被税局拒绝,也可能仍属于链的一环。

4. 误以为 timeout 可以简单重试

实际上要先判断对端是否已处理、是否返回 duplicate 语义。

5. 误以为二维码只是打印模板逻辑

它其实依赖最终记录标识和发送语义,不是随便拼参数。

最后一句

Veri*Factu 在 Odoo 里最值得看懂的地方,不是 XML 字段清单,而是这条更深的设计逻辑:

  • 文档要先合法生成;
  • 生成后进入不可随意删除的链;
  • 每条记录通过前序 huella 与当前关键字段计算指纹;
  • 发送要受批次和时间节流;
  • timeout 与 duplicate 还要能做恢复判断;
  • 二维码则是这套记录标识最终对外展示的一部分。

所以它不是“发票电子化插件”,而是一套以链式可追溯性为中心的监管记录系统

DISCUSSION

评论区

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