先说结论
l10n_dk_nemhandel 真正复杂的地方,不是“再多支持一个丹麦电子单据格式”。
从 res_partner.py、account_edi_proxy_user.py、account_move.py 一路看下来,Nemhandel 接入至少包含四个层次:
- 对端 partner 是否真的是网络里的有效参与方
- 我方公司是否已经通过 proxy 完成 receiver 注册
- Webhook、消息状态轮询、ack 是否维持住收票通道
- 收到的 XML 能否被正确识别并安全导入成账单
所以它不是“丹麦版 Peppol UI”,而是一条完整的本地化接入链路。
真正难点发生在“文档还没开始发”之前,以及“文档已经收到但还没安全落账”之间。
Partner 校验为什么是第一关
res_partner.py 里,button_nemhandel_check_partner_endpoint() 最终会走 _get_nemhandel_verification_state():
- 如果 identifier type / value 不完整,直接
not_verified - 若不是
oioubl_21,也不算有效 Nemhandel 目标 - 然后拼
edi_identification - 通过
_nemhandel_lookup_participant()去查网络参与方信息 - 最后用
_check_nemhandel_participant_exists()验证 identifier 和 SMP 域名是否匹配
这条链很重要,因为它说明 Nemhandel 的“可发送”不是配置一个邮箱地址那么简单,而是要验证:
- 这个对端标识真的存在
- 它的服务元数据来自正确的 Nemhandel 域
- 当前 partner 的电子发票格式也对得上
也就是说,partner 能不能被设为 Nemhandel 发送目标,本身就是合规判断,不是普通主数据录入。
_nemhandel_lookup_participant() 的代理查询说明了什么
它不是直接在本地做所有解析,而是通过 Odoo 的 proxy URL 去查 lookup 接口,并带上对应 zone。
这说明官方在设计上已经承认:
- Nemhandel 参与方发现不是纯本地逻辑
- 不同 mode(test / prod / demo)对应不同网络环境
- 平台要把外部发现链路封装到代理能力里
这和很多人想象中的“读取一组 endpoint scheme 映射表”完全不同。
真正的复杂度在于,参与方是否在线、在哪个 zone、服务元数据是不是合法,都是动态网络事实,而不是静态配置。
为什么 receiver 注册有这么多前置条件
_register_proxy_user() 和 _nemhandel_register_as_receiver() 一起看,能看到非常典型的平台边界:
- 先为公司生成 RSA 私钥
- 以公司标识拼出
edi_identification - 通过
/api/nemhandel/1/connect建立 proxy user - receiver 注册前要求公司状态在
in_verification - 若用 CVR(0184)注册,还要校验公司 VAT 一致
- 还要检查是否已在 alternative service 上注册,避免重复占位
这说明 Nemhandel onboarding 真正要解决的不是“点按钮开通”,而是:
- 谁代表公司接入网络
- 这个公司身份和税号是否一致
- 网络上是否已存在另一份冲突注册
- 我们是否已经具备收票所需的身份材料
这就是典型的本地化合规层问题:业务上像配置,实际上是注册治理。
Webhook reset 和 cron 为什么不可少
很多团队做电子单据接入时,只盯着“发送调用成功”。但 _nemhandel_reset_webhook()、_nemhandel_get_new_documents()、_nemhandel_get_message_status() 一起看,会发现官方真正关心的是通道持续可用性。
它做了三件事:
- 定期重设 webhook,带签名 token 保持回调可信
- 定时拉取新文档,必要时按批次继续 retrigger
- 查询 message status,并对成功 / processing / error 做不同处理
这说明 Nemhandel 不是“发请求 -> 收结果”的单同步 API,而是一条需要长期保活、确认、补拉、ack 的异步通道。
如果你只做一次 connect 而不维护 webhook 和轮询任务,系统表面上“已接入”,实际上收票能力会慢慢失真。
_nemhandel_get_new_documents() 为什么先收附件、再 ack
这个方法的链路很值得学习:
- 先按
receiver_identifier拉尚未确认的消息 - 再逐条
get_document - 解密
document与enc_key - 创建
ir.attachment - 调
_nemhandel_import_invoice()落成account.move - 成功后再
ack
这条顺序非常关键。
因为如果先 ack 再导入,任何导入失败都会造成“网络已确认收取,但本地并未成功入账”的丢单风险。Odoo 选择的是:只有当本地落单成功后,才去确认已接收。
这是一条非常成熟的消息处理边界。
收票为什么最终又回到 account.move 识别链
_nemhandel_import_invoice() 创建了应付发票 account.move,随后 move._extend_with_attachments(...) 把 XML 真正交给账单导入链处理。
再结合 account_move.py 的 _get_import_file_type():
- 若
CustomizationID == 'OIOUBL-2.1' - 就识别为
account.edi.xml.oioubl_21
这说明 Nemhandel 并没有自己再造一套完全独立的收票解析器,而是把本地化接入层和通用 UBL/CII 导入能力接起来。
这是一种很好的平台分层:
- Nemhandel 负责网络接入、参与方验证、代理、消息状态
- OIOUBL builder / importer 负责文档语义解析
- account.move 负责最终落账
分层清楚,系统才容易维护。
实战最容易踩的 6 个坑
坑 1:把 Nemhandel 当成“多一个发送渠道”
实际上 partner 校验、receiver 注册和通道保活都在发送前。
坑 2:只验证 partner 标识,不验证服务元数据域
源码明确会对 identifier 和 SMP 域做双重匹配。
坑 3:公司主体资料没对齐就直接注册
CVR/VAT 不一致、状态不在 in_verification,都会导致注册链条有问题。
坑 4:Webhook 配完一次就不管了
真实系统里,回调端点和 token 需要持续维护。
坑 5:先 ack 再导入
这是消息系统里最危险的顺序之一,极易造成不可追的漏单。
坑 6:把网络接入层和文档解析层混写
接入、解密、状态管理和 XML 业务解析,本来就该分层。
结语
l10n_dk_nemhandel 最值得学习的地方,不是它支持了丹麦格式,而是它把参与方发现、公司注册、Webhook 保活、消息确认、账单导入拆成了一条有边界的接入流水线。
这也是很多本地化电子单据项目最终成败的关键:
- 不是 XML 能不能生成
- 而是整条网络与落账链路能不能长期、稳定、可追溯地运转
Nemhandel 在 Odoo 里的价值,正是在这里。
DISCUSSION
评论区