India E-Invoice

Odoo 印度 E-Invoice 为什么不只是“发票过账后发个 JSON”而已:IRN 幂等、负行折算、取消链路与 IAP 边界讲透

印度电子发票最复杂的地方不是字段多,而是 JSON 组装规则、重复 IRN 的幂等处理、负行改写、取消原因、IAP 连接与锁控制。本文结合 l10n_in_edi 源码讲透这条链路。

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

先说结论

Odoo 的印度电子发票不是“过账后把 invoice 转成 JSON 发出去”这么简单。

/home/ubuntu/odoo-temp/addons/l10n_in_edi/models/account_move.py 和取消向导可以看出,官方真正处理的是一条很讲究幂等与约束的链路:

  • 只有满足资格的销售票据,过账后才进入 to_send
  • 发送前要把抬头、明细、税、出口信息、折扣、舍入等重组为政府 JSON
  • 遇到重复 IRN 不是直接报废,而是尝试按单据信息回查
  • 负数行有时不能原样发送,要折算进同税率同 HSN 的正行折扣
  • 取消又是另一条带原因与备注的正式流程
  • 整条链路还通过 IAP 出口,并带并发锁防止重复处理

一句话概括:

Odoo 印度 E-Invoice 的难点不是“能不能发 JSON”,而是“如何把 ERP 单据收敛成政府接受、且可重试的事实”。


为什么过账后不是立刻就算“已上报”

_post() 只是把符合条件的票据标成 l10n_in_edi_status = 'to_send'

这说明在 Odoo 里:

  • 会计过账成立
  • 不等于政府侧电子发票已经成立

这是两层事实。

第一层是 ERP 自己认可这张发票; 第二层才是外部电子发票平台接受并返回 IRN。

如果把两者混为一谈,后面处理失败、重试、取消都会乱掉。


为什么 JSON 组装比很多人想得更复杂

_l10n_in_edi_generate_invoice_json() 做的不是“字段平铺导出”,而是大量业务重构:

  • TranDtls 要判断 B2B、出口、SEZ、有税 / 无税等供应类型
  • DocDtls 要区分普通发票、贷项通知、借项通知
  • SellerDtls / BuyerDtls 要按 GSTIN、地址、POS、海外规则重组
  • ValDtls 要重算基础金额、税额、舍入、全局折扣、总额
  • 海外业务还要额外拼 ExpDtls

这说明政府接口要的不是“ERP 的原始内部结构”,而是一份已经过合规模型翻译的文档。

所以很多对接失败,并不是接口不通,而是:

  • 你的 ERP 事实和政府 JSON 事实不是同一套语义

Odoo 这层适配,就是在做语义翻译。


为什么负数行不能总是原样上报

_l10n_in_edi_generate_invoice_json_managing_negative_lines() 很值得研究。

它会把负数行从 ItemList 中拿出来,尽量折算到 同 HSN 编码、同税率 的正数行上,改成折扣。

为什么?

因为政府侧并不总接受 ERP 世界里那种“正商品 + 单独负折扣行”的表达方式。

所以 Odoo 采取的不是“强行按原样输出”,而是:

  • 识别哪些负行其实代表商业折扣
  • 把它们吸收到更容易被接受的行级折扣结构里

这就是典型的平台翻译层:

不是忠实复刻 ERP 行,而是尽量生成监管接口可接受的等价表达。


为什么重复 IRN 不一定是失败,而是幂等场景

发送逻辑 _l10n_in_edi_send_invoice() 里,对错误码 2150 的处理特别关键。

它不是简单报错,而是进一步调用 getirnbydocdetails 回查:

  • 这张单据会不会其实已经在政府端生成过 IRN
  • 只是上一次本地流程因为超时或中断,没拿到结果

如果能回查到结果,Odoo 还会尝试解码 SignedInvoice,对比:

  • 买方 GSTIN
  • 发票总额是否在容忍范围内

如果匹配,系统会把这当作“重复但可接受”的幂等恢复,而不是彻底失败。

这很重要,因为真实网络世界里最麻烦的一种情况就是:

  • 远端做成功了
  • 本地没收到成功响应

Odoo 在这里做的就是把“重复错误”转译为“幂等回收机会”。


为什么要显式加锁

_l10n_in_lock_invoice() 会在发送或取消前 lock_for_update()

这一步很平台,但很实用。

因为电子发票最怕并发:

  • 定时任务在发
  • 用户手动点一次也在发
  • 或一个人在取消时另一个人又触发重试

没有锁,最容易出现的就是:

  • 重复上报
  • 状态互相覆盖
  • 附件与状态不一致

所以这里的数据库锁,不是性能细节,而是“同一张票同一时刻只能有一个官方动作在路上”的制度保障。


为什么取消不是改个状态,而是一条正式流程

button_request_cancel()_l10n_in_edi_cancel_invoice() 说明:

  • 已 sent 的票,不是想取消就直接本地改成 cancelled
  • 通常必须带 CnlRsnCnlRem
  • 然后调用政府取消接口
  • 成功后再写本地状态,并保留请求 / 返回附件

甚至源码对错误码 9999 还做了“可能远端早就取消过”的兜底提示。

这说明在 Odoo 眼里,取消不是本地 UI 动作,而是 对外部监管事实的逆向申报

所以取消流程天然应该:

  • 有理由
  • 有备注
  • 有附件
  • 有审计留痕

为什么 IAP 边界也会影响 E-Invoice

_l10n_in_edi_connect_to_server() 不是直接裸连政府接口,而是通过 IAP 通道出去。

这带来几个现实边界:

  • token 可能失效,需要重新认证
  • 服务端可能暂时不可达
  • 没 credit 时会返回 no-credit
  • 相同 GST 凭据在别的实例更新 token,也会影响当前实例

所以实施里经常遇到一种误判:

  • 以为是电子发票规则错了
  • 实际上只是 IAP 凭据 / 额度 / 网络层出了问题

这就是为什么合规接口问题,不能只盯业务 JSON,还得同时看平台出口层。


新手最容易误解的 5 件事

1. 误以为过账成功就等于 IRN 成功

实际还要走外部发送与回执链路。

2. 误以为负数行可以原样发给政府

Odoo 很多时候会把它们改写成折扣。

3. 误以为重复 IRN 就只能人工处理

源码其实先尝试按单据详情回查,走幂等恢复。

4. 误以为取消只是本地把状态改掉

真正取消要带原因、备注并调用官方接口。

5. 误以为错误都来自税务规则

有时真正的问题在 IAP token、额度或连通性。


实战排错顺序

如果你碰到“印度电子发票发送失败 / 重复 IRN / 无法取消”,建议按这个顺序查:

  1. 先确认这张票是否真的符合 E-Invoice 资格
  2. 检查 JSON 关键字段:GSTIN、供应类型、POS、金额、ItemList
  3. 如果报重复 IRN,先走文档详情回查,不要急着改票重开
  4. 看是否存在负数行,且是否被正确折算成折扣
  5. 确认是否有并发处理导致锁冲突或状态覆盖
  6. 最后再查 IAP token、额度、网络与生产 / 测试环境配置

一句话记忆

Odoo 印度 E-Invoice 的核心不是“把发票发出去”,而是用 JSON 重组、负行改写、IRN 幂等恢复、正式取消和 IAP 出口,把 ERP 单据变成可监管、可重试的电子事实。

DISCUSSION

评论区

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