先说结论
Odoo 的 l10n_es_edi_verifactu 绝不是“出一份 XML,传给 AEAT,然后等回执”这么线性的流程。
从 /home/ubuntu/odoo-temp/addons/l10n_es_edi_verifactu/models/verifactu_document.py 看,标准实现真正围绕的是四个关键词:
- chain:每条记录不是独立存在,而要串成生成顺序链。
- fingerprint:每条记录都要根据关键字段与前序 huella 计算新指纹。
- batching:不是想发就发,发送要受批次大小与等待时间约束。
- immutability:成功生成并入链的文档,原则上不能再删。
所以 VeriFactu 的重点不是“发票 XML 长什么样”,而是你如何证明这些记录按规则持续生成、持续串接、持续可追溯。*
Veri*Factu 文档为什么不是附件,而是一条“链上的记录”
l10n_es_edi_verifactu.document 这个模型非常值得细看。
它不只是存一份 JSON / XML,而是显式保存:
chain_indexdocument_typejson_attachment_idstateresponse_csverrors
这里最关键的是 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() 里的步骤非常谨慎:
- 先拼出
document_dict; - 再调用 zeep 的
create_message只做 XML 生成验证; - 通过后才分配
chain_index; - 再把 JSON 存为 attachment。
这套顺序背后有个很关键的判断:
不能先随便把文档链进去,再发现 XML 其实不合法
因为一旦链进去了,文档就不该被随便删改;如果之后才发现 XML 过不了 XSD / WSDL 校验,你就会陷入一个很糟糕的状态:
- 本地链条已经前进;
- 但实际上这条记录并不具备可发送性;
- 后续文档又可能已经引用了它。
所以 Odoo 先尽量在本地把结构问题挡掉,再正式入链。这是非常典型的“合规前置校验”思路。
为什么链式序号获取会主动锁住 sequence
在 _create_for_record() 里,next_by_id() 如果遇到并发问题,会报 OperationalError,源码注释明确说明:
- 多个事务不能同时为同一公司生成链上文档;
- sequence 实际上承担了“链顺序锁”的作用;
- 这样才能避免不同事务抢着把文档插进同一条链。
也就是说,VeriFactu 在 Odoo 里不是“天然高并发生成”优先,而是链顺序正确性优先*。
这很符合监管语义,因为税务链条最怕的不是慢一点,而是顺序错乱、前后断裂。
发送为什么不是实时逐条发,而是批次 + 等待时间
trigger_next_batch() 里定义了两重节流逻辑:
- 大批量时按
BATCH_LIMIT = 1000分批。 - 小于 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 类型的记录,才具备生成二维码所需的完整值,例如:
IDEmisorFacturaNumSerieFacturaFechaExpedicionFacturaImporteTotal
这说明二维码不是一个装饰层功能,而是发送记录语义的外显结果。
如果记录本身还没建立好、关键标识不完整,二维码也不该先出现。
这套实现最容易误解的 5 个点
1. 误以为本地文档只是缓存
其实链上 JSON 文档本身就是合规证据的一部分。
2. 误以为生成成功就等于已发送
生成、入链、排队、批次发送、响应处理,是多阶段流程。
3. 误以为 rejected 记录可以像普通失败任务那样直接删掉
链式世界里,记录即使被税局拒绝,也可能仍属于链的一环。
4. 误以为 timeout 可以简单重试
实际上要先判断对端是否已处理、是否返回 duplicate 语义。
5. 误以为二维码只是打印模板逻辑
它其实依赖最终记录标识和发送语义,不是随便拼参数。
最后一句
Veri*Factu 在 Odoo 里最值得看懂的地方,不是 XML 字段清单,而是这条更深的设计逻辑:
- 文档要先合法生成;
- 生成后进入不可随意删除的链;
- 每条记录通过前序 huella 与当前关键字段计算指纹;
- 发送要受批次和时间节流;
- timeout 与 duplicate 还要能做恢复判断;
- 二维码则是这套记录标识最终对外展示的一部分。
所以它不是“发票电子化插件”,而是一套以链式可追溯性为中心的监管记录系统。
DISCUSSION
评论区