先说结论
Odoo 的罗马尼亚 l10n_ro_edi 真正难的地方,不是“把 CIUS-RO XML 传给 SPV”这一步,而是:上传之后,系统往往还处在一个信息不完整、需要异步追认的中间态。
从 /home/ubuntu/odoo-temp/addons/l10n_ro_edi/models/account_move.py 看,这套实现的关键不是单次请求,而是下面这套状态恢复思路:
- 先发送,再等 SPV 异步验证。
- 发送时最好拿到
key_loading作为索引;但拿不到也不一定代表失败。 - 后续要通过 fetch / synchronize 补拿状态、签名和拒绝原因。
- 原先的“sent”文档并不是最终结果,后面会被 validated / refused 文档替换。
所以罗马尼亚 e-Factura 的本质不是“同步上传接口”,而是一个带追踪、补偿和恢复逻辑的异步监管通道。
为什么发送成功后,发票也还没真正“结束”
_l10n_ro_edi_send_invoice() 这段逻辑非常有代表性。
发送前,Odoo 先做预检查:
- move 必须已 posted;
- 公司必须有 access token;
- 必须找到 CIUS-RO XML;
- 这张发票之前不能已经有 e-Factura document。
这说明“能不能发”本身就是一层业务门槛。
但更重要的是:即使发送请求成功,发票也只是进入 invoice_sent,不是最终完成。
因为 SPV 还要继续处理、验证,再决定是接收还是拒绝。
很多实施项目的问题就出在这里:把“已提交”当成“已合规落地”。
key_loading 为什么这么重要
发送返回里,如果 SPV 给了 key_loading,Odoo 会把它存到:
l10n_ro_edi_index
同时 chatter 里提示:现在 SPV 正在用这个 index 验证。
这个 index 的意义,不只是“有个编号好看”,而是它成为后续追状态的主要抓手。
没有 index 会怎样?
源码里专门处理了这种情况:
- 如果发送成功但没拿到
key_loading; - Odoo 把状态记成
invoice_not_indexed; - 并提醒后续需要 synchronize 去恢复索引和状态。
这说明在罗马尼亚 e-Factura 里,“上传成功但暂时没拿到索引”是被标准实现预期到的正常异常。
它不是流程外的边缘 bug,而是必须被恢复机制兜住的情况。
为什么发送文档后面还会被删掉再重建
_l10n_ro_edi_fetch_invoice_sent_documents() 和后面的同步处理逻辑里,有一个第一次看会觉得很奇怪的动作:
- 原先
invoice_sent的 document 会被删除; - 然后创建一条新的
invoice_validated或invoice_refuseddocument。
这背后其实是一种“阶段性凭证替换”思想。
为什么不直接把 sent 文档原地改状态?
因为发送态和最终态携带的信息不同:
sent只是证明“我交上去了”;validated/refused还带回签名、证书 key、下载 key,甚至拒绝原因。
换句话说,最终文档不只是“状态更改后的同一对象”,而是带着更多监管回执内容的新阶段记录。
这能让每个阶段的证据更清晰。
fetch 和 synchronize 的差别,很多团队其实没分清
fetch:针对已发送且有 index 的发票继续追状态
_l10n_ro_edi_fetch_invoice_sent_documents() 主要处理:
- 现在状态还是
invoice_sent; - 已经有
l10n_ro_edi_index; - 去 SPV 问“处理完了吗”;
- 处理完了再下载签名与结果。
这更像“按票追单”。
synchronize:更大范围地和 SPV 消息箱对账
_l10n_ro_edi_fetch_invoices() 做的事更广:
- 处理已发送成功的 accepted messages;
- 处理已发送但被 refused 的 messages;
- 处理接收到的供应商 bill messages;
- 还顺带补救那些没 index 的发票。
这更像“系统级同步收件箱”。
所以如果你把 synchronize 只理解成“再点一次刷新”,就低估了它。它其实承担了状态恢复与消息回补的核心职责。
为什么缺失 index 的发票还能靠名字补回来
_l10n_ro_edi_process_invoice_accepted_messages() 里处理了一个很现实的场景:
- 发票其实发成功了;
- 但当时没收到 index;
- 后续同步时,SPV 消息里带回了发票名与 index;
- Odoo 会按 name 去匹配数据库里的
invoice_not_indexed发票,再补写 index。
这一步非常实用,因为真实世界网络不会永远完美。
但源码也很诚实地写了边界:如果重编号或重复发送导致同名碰撞,可能出现一个名字对应多条可能签名的棘手情况,需要人工判定。
这说明恢复逻辑虽然强,但不是无限神奇。发票编号治理如果混乱,恢复成本会急剧上升。
为什么超过保留时间后,会直接做“推定拒绝”
对于 invoice_not_indexed 且长期没能恢复的单据,源码定义了:
HOLDING_DAYS = 3
超过这个时间后,Odoo 会:
- 删除旧 sent 文档;
- 新建一条
invoice_refused文档; - 在消息里说明:很可能被 SPV 拒绝了,但因为没拿到 index,无法恢复真正原因;
- 建议 duplicate 发票后重新发送。
这看起来有点“保守”,但非常现实。
因为监管接口里最危险的状态不是明确失败,而是长时间悬而未决却无法继续操作。Odoo 选择在无法恢复时给出一个明确的业务落点,让人可以继续处理,而不是永久卡死。
采购侧为什么也被纳入 synchronize
_l10n_ro_edi_process_bill_messages() 说明这套实现不只是发销项,还能从 SPV 同步进项 bill。
它会:
- 根据 SPV index 或卖方 VAT + 金额 + 日期去找相似 bill;
- 如果找不到,就创建新的 vendor bill;
- 保存签名附件;
- 把 XML 挂到
account.move,再走导入扩展。
这说明 Odoo 对罗马尼亚 e-Factura 的理解不是“单向上传”,而是双向监管通道:
- 销项发票要发出去;
- 进项发票也可能从平台同步回来。
这套链路里最容易踩的坑
1. 把 invoice_sent 当成最终成功
它只是“已提交,待处理”。
2. 看到没 index 就判断发送失败
其实很多时候只是暂时未回 index,需要后续 synchronize 恢复。
3. 发票编号管理混乱
名字碰撞会让“按 name 补回 index”的恢复逻辑变得非常尴尬。
4. 不看 chatter 只看字段
很多关键恢复信息、拒绝原因、操作建议都写在消息里。
5. 忽略采购侧同步
最后会把系统理解成只会发、不知道还能收。
最后一句
罗马尼亚 e-Factura 在 Odoo 里最值得看懂的,不是 XML 结构,而是这条异步恢复链:
- 先发送;
- 可能拿到 index,也可能暂时拿不到;
- 后续通过 fetch / synchronize 继续追状态;
- sent 文档会被带签名的 validated / refused 文档替换;
- 缺失 index 还能尝试按名字恢复;
- 超时太久则做推定拒绝并建议重发;
- 采购 bill 还可以从 SPV 反向同步进来。
这说明它不是“一次 HTTP 上传”,而是一套围绕不确定响应设计的监管状态机。
DISCUSSION
评论区