先说结论
account_edi_ubl_cii 的难点,从来不只是“把 XML 读进来”。
从 account_move.py 的 _ubl_parse_attached_document()、_get_edi_decoder()、_get_specific_tax() 往下看,Odoo 其实在处理一条很讲究容错和边界的接收链:
- 先判断你拿到的是不是“包着别的 XML 的 XML”
- 再决定当前文件应该交给哪个 import model 处理
- 然后才尝试导入发票主体
- 最后在税项识别上做适度自动推断
所以更准确的说法是:
UBL/CII 导入是一个附件解包 + 格式识别 + 业务回填的组合链路,而不是单次 XML 解析。
为什么要专门处理 AttachedDocument
_ubl_parse_attached_document() 这段逻辑非常说明问题。
在 UBL 世界里,AttachedDocument 不是最终业务单据,而像一个包装壳。真正的原始文档可能放在:
Attachment/EmbeddedDocumentBinaryObject- 或
Attachment/ExternalReference/Description
也就是说,用户上传进来的文件看起来是 XML,但它不一定是“你现在就能导入的发票 XML”。
如果不先拆这一层包装,你可能会得到两个坏结果:
- 误判文件类型,找错 decoder
- 或把一个只是容器的文档当成业务单据硬导,最后产生含糊报错
这正是平台层和脚本式导入的区别:平台必须先判断“手上的东西究竟是什么”。
为什么会递归解包
源码里不只是解一次,它还支持 recurse=True 继续 _unwrap_attachments()。
这背后的现实很朴素:真实世界里的电子单据,并不总以“一个干净 XML 文件”出现。它可能是:
- 邮件附件中的 XML
- XML 里又嵌了另一个 XML
- 包装文档里带原始业务文档
如果系统只会单层展开,很多合法但结构复杂的文件就会在第一层被卡住。
Odoo 在这里选择的是“可以递归,但只在明确识别到可展开附件时继续”,既增强兼容性,又避免无限猜测。
_get_edi_decoder() 真正做的是“模型裁决”
很多人会把 decoder 理解成“某个函数”。
但 _get_edi_decoder() 展现出来的思路更高级:它先枚举 account.edi.xml.ubl_20 与 account.edi.xml.cii 的继承子模型,把一整族 importable model 纳入候选。
然后只要 file_data['import_file_type'] 属于这些模型,就返回:
- 优先级
priority: 20 - 对应模型的
_import_invoice_ubl_cii
这说明 decoder 选择不是简单 if/else,而是把文件类型路由到适当的业务模型。
这种设计有两个好处:
- 新本地化格式可以通过继承加入 import 体系
- 基础导入器不用知道所有国家和变体的细节
这就是 Odoo 平台常见的“模型族 + 路由”设计:可扩展、可覆盖、不把本地化硬编码在核心流程里。
为什么导入侧也要强调“宁愿阻断,也不乱猜”
自动导入最危险的地方,不是导不进来,而是半对半错地导进来。
_get_specific_tax() 很能体现官方态度。它会尝试调用 _predict_specific_tax() 去预测税项,但预测是在“已有上下文足够”的前提下做的。
一旦拿不到可靠结论,系统宁可保守一点,而不是把税种、金额归类、业务语义都自作主张填满。
这类边界非常重要,因为发票导入如果把税错配了,后续对账、报税、核销都会被污染;而如果系统明确告诉你“这里需要人工确认”,损失反而更小。
_need_ubl_cii_xml() 暗示了导入 / 导出的共同思路
虽然本文聚焦导入,但 _need_ubl_cii_xml() 也值得一提。它说明 Odoo 在处理 UBL/CII 时并不是“收”和“发”两套完全无关的思路,而是把这类 XML 视为一个统一能力层:
- 某些单据需要生成 UBL/CII
- 某些单据又需要识别并导入 UBL/CII
- partner / format / builder 决定具体怎么走
这使得 account_edi_ubl_cii 不只是某个国家接口模块,而更像平台里的“通用 XML 发票语义层”。
为什么导入链路适合放在 account.move 扩展上
把 decoder 和导入能力挂在 account.move 扩展上,而不是做一个孤立解析器,有几个明显好处:
- 文件一旦识别出来,能直接落到发票对象上
- 后续 partner、journal、tax、line vals 的回填有天然宿主
- 本地化模块可以继续 override move 级别的 builder / import file type 识别
这也是为什么后面像 Nemhandel 这类本地化,能继续基于 _get_import_file_type()、_get_ubl_cii_builder_from_xml_tree() 扩展自己的格式识别,而不需要把整套导入器重写一遍。
实战里最容易忽略的 5 个边界
边界 1:上传的是 XML,不代表它就是最终业务 XML
包装文档、嵌套附件、外部引用都会让“文件扩展名正确”毫无意义。
边界 2:decoder 选择是平台路由,不是硬编码 switch
本地化越多,越需要模型化路由,而不是在单个函数里堆大量条件判断。
边界 3:递归展开要谨慎
平台要能展开复杂附件,但不能无上限地猜测和拼接。
边界 4:税务回填要保守
自动预测是为了减少人工,不是为了代替确认责任。
边界 5:导入错误比导入失败更危险
宁可阻断,也不要把“表面成功、实则错配”的发票落库。
我会怎么向客户解释导入失败
如果客户说:“不就是 XML 吗,为什么还不能自动认?”
我会这样解释:
系统不是不会读 XML,而是在判断这个 XML 到底是不是业务原件、适合哪种解析器、以及导进账务后会不会把税和科目带歪。
当你把问题说到这个层级,对方通常就能理解:导入模块面对的是合规单据,不是文本文档。
结语
account_edi_ubl_cii 的导入侧最值得学习的地方,在于它没有把“自动化”误解成“越敢猜越好”。
它真正做的是:
- 先认清附件结构
- 再把文件路由给正确 decoder
- 再在业务回填上保持克制
这套节奏,才是电子单据导入在平台层应该有的样子。
DISCUSSION
评论区