框架深潜

Odoo 西班牙 SII 不是“发个 XML”那么简单:税额拆分、CSV 回执与撤销对账链路讲透

很多团队把西班牙 SII 理解成过账后调用一次税局接口。真正麻烦的,是发票行税种组合是否合规、同批次为什么按 move_type 与 CSV 分组、税局回执 CSV 如何沉淀到单据上,以及撤销时为什么既要看状态也要看历史回执。

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

先说结论

Odoo 的 l10n_es_edi_sii 不是一个“把发票导出成 XML 然后提交”的轻量接口模块。

/home/ubuntu/odoo-temp/addons/l10n_es_edi_sii/models/account_edi_format.pyaccount_move.py 看,它实际解决的是三件事:

  1. 在提交前,把发票税务语义整理成税局能接受的结构;
  2. 提交后,把税局返回的 CSV 回执号与登记日期写回业务单据;
  3. 在重复提交、接受带警告、撤销或补偿场景下,维持本地与税局状态的一致性。

所以 SII 真正难的地方不是“能不能发出去”,而是发出去之后,Odoo 如何持续知道自己与税局到底处于什么关系

这不是纯文件式 EDI,而是明确依赖 Web Service

_needs_web_services() 里直接返回:

  • self.code == 'es_sii'

同时 account_move.py 里在处理 EDI 文档时,也会明确判断是否需要 web service,并且对 to_cancel 做特殊分支。

这说明 SII 在 Odoo 的定位不是“生成一个电子单据附件”,而是必须跟税局在线对话的业务流程

这种设计影响很大:

  • 你的发票状态不只取决于本地是否过账;
  • 证书、网络、税局端点、回执解析都会影响业务闭环;
  • 本地取消动作也不能只靠内部状态翻转。

提交前真正麻烦的是税种组合,不是接口地址

很多项目在接电子税务系统时,最先盯着:

  • WSDL 地址
  • 证书
  • 测试环境
  • 超时时间

这些当然重要,但源码里 _check_move_configuration() 告诉你,更棘手的是发票内容本身是否可被解释

例如它会逐行检查:

  • retention tax 不能超过一个;
  • recargo 不能超过一个;
  • sujeto / no_sujeto / no_sujeto_loc 等主税语义不能冲突;
  • 外国客户场景下,税 scope 必须正确配置;
  • 供应商账单必须有 vendor reference;
  • 公司 VAT 不能为空。

也就是说,SII 并不是“任意 Odoo 发票都能提交,失败了再说”。

它要求在进入税局前,发票已经被整理成一套税法上可判读的表达

这也是很多实施项目容易误判的地方:

  • 以为本地会计能过账,就代表电子申报也能过;
  • 实际上,SII 对税务语义的结构要求更严格。

Registration date 为什么在提交时自动补?

_l10n_es_edi_call_web_service_sign_common() 里有个非常关键的动作:

  • 对还没有 l10n_es_registration_date 的发票,先写入今天日期。

很多人看到这里会问:

  • 既然税局回了结果,为什么不是等响应回来再写?

因为在 SII 语境里,登记日期不是纯展示字段,它参与了提交语义。

源码前面构建发票信息时,如果 invoice.l10n_es_registration_date 已有值,还会把它写进 FechaRegContable

这说明 registration date 在这里既是:

  • 本地登记语义的一部分;
  • 也是后续重发、补发、撤销时的重要上下文。

所以 Odoo 选择在发送前先把它补齐,而不是把它完全当税局回执后的衍生信息。

CSV 回执号为什么这么关键?

税局响应里,Odoo 会拿到 CSV,然后:

  • 如果整批 EstadoEnvio == 'Correcto',把 l10n_es_edi_csv 写回发票;
  • 如果逐行处理时某一行 CorrectoAceptadoConErrores,也会把 CSV 写回对应发票;
  • 甚至在某些“重复提交但此前已正确送达”的场景里,它也把这件事当成功处理。

这意味着 l10n_es_edi_csv 不只是一个回执展示号,而是税局已知晓这张发票的关键锚点

_get_move_applicability() 也能看出来:

  • post_batching 是按 (invoice.move_type, invoice.l10n_es_edi_csv) 分组的。

为什么要把 CSV 作为 batching 维度之一?

因为 CSV 实际上把“这张单据是否已经进入过某次税局交互语境”表达了出来。它不是单纯结果字段,而是本地后续动作的分流依据。

“AceptadoConErrores” 为什么依然算成功?

源码对 EstadoRegistro in ('Correcto', 'AceptadoConErrores') 都视作成功,只是在后者额外发 chatter 消息提醒。

这很值得学习。

很多开发者喜欢把“有错误”一律当失败,但合规系统里常常存在:

  • 业务主动作已被接受;
  • 同时附带一些警示或次级问题。

如果把这类结果全按失败处理:

  • 用户会误以为需要重发;
  • 反而可能制造重复报送;
  • 本地状态和税局状态会被你自己弄乱。

Odoo 在这里的处理更成熟:

  • 主结果成功,就按成功落账;
  • 附加错误保留提示,不混淆主状态。

重复提交为什么有时也会被认定为成功?

源码里有两个很典型的兜底成功场景:

  1. RegistroDuplicado 且其状态表明此前已正确处理;
  2. 取消场景下错误码 3001

这背后表达的是一个很现实的合规观点:

对账系统最重要的不是“这次调用返回 200 还是 500”,而是“税局当前认为这张票处于什么状态”。

如果税局已经确认过这张票,本地哪怕之前因为网络、超时、事务中断没及时把结果记下来,也不该再把它当失败。

源码甚至会给用户留言:

  • 这张发票此前已经正确发送过,但系统当时没有处理响应,请检查是不是配置错误。

这是一种非常诚实的状态恢复策略。

撤销为什么不是把 CSV 清空就结束?

很多系统把“撤销电子报送”想成:

  • 调个 cancel 接口;
  • 本地字段清掉。

Odoo 的实现明显更谨慎。

它会:

  • 调用同一套 _l10n_es_edi_call_web_service_sign_common(..., cancel=True)
  • 仍然按响应逐行处理;
  • 只有在确认撤销语义成立后,才在最后把 l10n_es_edi_csv 清空。

这意味着 CSV 不是“为了好看先写上,撤销时随便删掉”的临时值,而是在报送有效期间承载着税局引用关系

只有当撤销流程也被正确接受,本地才撤掉这层锚点。

为什么发送成功后还要创建 JSON 附件?

_l10n_es_edi_sii_send() 在成功时会新建一个 jsondump.json 附件,把 info_list 存下来。

这个设计我很喜欢,因为它让你保留了:

  • 当时实际发送给税局的结构化内容;
  • 后续审计、排错、复盘时的可追溯依据;
  • 与发票记录绑定的本地证据链。

很多项目只保存“税局回了成功”,却不留“我们当时到底发了什么”。出了问题只能猜。

Odoo 这里是把发送载荷作为审计材料的一部分留下来,这对合规系统非常重要。

证书与税局机构为什么前置校验?

_l10n_es_edi_sii_send() 会先检查:

  • 公司是否配置了 SII 证书;
  • 是否选择了税局机构;
  • 根据不同机构选择不同 web service 地址;
  • 测试环境是否要切到 test_url。

这说明 Odoo 并不假设“西班牙 SII 只有一个统一端点”。

它承认不同 tax agency 的现实差异,也承认测试与正式环境切换不是一个通用布尔值就能糊弄过去的。

实施时最容易出问题的 5 个点

1. 证书有了,但税务语义没整理好

接口能连通,不代表发票内容合规。

2. 忽略 AceptadoConErrores

把它全当失败,往往会诱发重复发送。

3. 不重视 CSV 字段

一旦你把 CSV 当成无关展示字段,本地对账与撤销逻辑就会变脆弱。

4. 取消动作只看本地按钮,不看税局回执

这会制造“本地取消了,税局没取消”的错位。

5. 不保留发送载荷审计线索

出了争议时,很难还原当时到底提交了什么数据。

最后一句

SII 模块最值得学习的一点,是它把“电子报送成功”理解成一条完整链路:

  • 先校验税务语义;
  • 再调用 web service;
  • 再把 CSV 和登记日期沉淀回单据;
  • 遇到重复、警告、撤销时继续做状态对齐;
  • 最后保留发送载荷作为审计证据。

这说明合规模块的成熟度,从来不在“能否调通接口”,而在“接口调通之后,能否持续说清楚这张单据现在到底是什么状态”。

DISCUSSION

评论区

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