先说结论
Odoo 的 l10n_es_edi_sii 不是一个“把发票导出成 XML 然后提交”的轻量接口模块。
从 /home/ubuntu/odoo-temp/addons/l10n_es_edi_sii/models/account_edi_format.py 与 account_move.py 看,它实际解决的是三件事:
- 在提交前,把发票税务语义整理成税局能接受的结构;
- 提交后,把税局返回的 CSV 回执号与登记日期写回业务单据;
- 在重复提交、接受带警告、撤销或补偿场景下,维持本地与税局状态的一致性。
所以 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写回发票; - 如果逐行处理时某一行
Correcto或AceptadoConErrores,也会把 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 在这里的处理更成熟:
- 主结果成功,就按成功落账;
- 附加错误保留提示,不混淆主状态。
重复提交为什么有时也会被认定为成功?
源码里有两个很典型的兜底成功场景:
RegistroDuplicado且其状态表明此前已正确处理;- 取消场景下错误码
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
评论区