先说结论
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
- 通常必须带
CnlRsn和CnlRem - 然后调用政府取消接口
- 成功后再写本地状态,并保留请求 / 返回附件
甚至源码对错误码 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 / 无法取消”,建议按这个顺序查:
- 先确认这张票是否真的符合 E-Invoice 资格
- 检查 JSON 关键字段:GSTIN、供应类型、POS、金额、ItemList
- 如果报重复 IRN,先走文档详情回查,不要急着改票重开
- 看是否存在负数行,且是否被正确折算成折扣
- 确认是否有并发处理导致锁冲突或状态覆盖
- 最后再查 IAP token、额度、网络与生产 / 测试环境配置
一句话记忆
Odoo 印度 E-Invoice 的核心不是“把发票发出去”,而是用 JSON 重组、负行改写、IRN 幂等恢复、正式取消和 IAP 出口,把 ERP 单据变成可监管、可重试的电子事实。
DISCUSSION
评论区