先说结论
Odoo 的 VAT 逻辑,难点不是“这串字符像不像税号”,而是系统如何在国家语义、号码格式、欧盟规则和外部服务不稳定之间,给出一个可落库、可解释、可运营的结果。
从 /home/ubuntu/odoo-temp/addons/base_vat/models/res_partner.py 可以看出,官方在处理 VAT 时至少同时考虑了这些问题:
- 用户填的是本国号,还是带前缀的跨国号。
- EU / EL / XI 这类前缀语义要不要特殊映射。
- 落库前是否需要格式归一。
- 本地格式校验通过后,要不要再去 VIES 联机确认。
- 如果数据来自外部平台,是否允许先存后治理。
所以最短结论是:
Odoo 的 VAT 校验不是一个正则表达式,而是一条“归一化 + 规则判断 + 在线验证 + 错误降级”的管道。
_run_vat_checks() 的第一任务不是判错,而是先理解你填的是什么
_run_vat_checks() 一上来并没有急着说“合法 / 不合法”,而是先拆分:
vat_prefixvat_number
也就是把输入分成:
- 前两个字符是不是国家前缀;
- 后面是不是号码主体。
这一步很关键,因为很多现实场景里,用户并不会老老实实按单一格式输入:
- 有人填
BE0477472701; - 有人只填
0477472701; - 有人会把空格、点号、国家码混着写;
- 有些欧盟号码还会有
EU、EL、XI这样的特殊语义。
换句话说,系统首先要解决的不是“校验”,而是“理解”。
为什么 Odoo 会区分 EU、EL、XI
源码里有一层 EU_EXTRA_VAT_CODES / EU_EXTRA_VAT_CODES_INV 映射,还单独处理:
EUGR/ELXI
这说明 Odoo 并不把 VAT 国家前缀简单等同于 res.country.code。
这是现实法规逼出来的。
例如:
- 希腊的 VAT 展示里经常见到
EL,但国家代码是GR; - 北爱相关场景会出现
XI; - 某些非欧盟本地公司在特定欧盟交易语境下还会出现
EU前缀。
所以如果系统只按 ISO 国家码死判断,就会把很多业务上正确、法规上可解释的号码误判掉。
Odoo 在这里做的不是“把规则搞复杂”,而是承认税号前缀本身就是业务语义,不只是国家缩写。
_format_vat_number() 的意义,是把“可读输入”变成“可比较存储”
虽然具体格式化实现散落在底层 country 模块和 stdnum 里,但 _run_vat_checks() 会在 code_to_check 确定后调用 _format_vat_number()。
这一步的价值非常大,因为 ERP 最怕这种情况:
- 同一客户被录成三个 VAT 版本;
- 一个带空格,一个不带;
- 一个有前缀,一个没前缀;
- 搜索、去重、税务校验结果互相打架。
所以归一化的真实作用不是“美化格式”,而是让系统后续可以稳定地:
- 比较;
- 查重;
- 调接口;
- 输出报表;
- 给用户一致错误提示。
这也是为什么 VAT 这种字段经常不能只当普通 Char 看待。
为什么 Odoo 要防 BEBE047... 这种双前缀
源码里有一段很实用的保护:
double_prefix = prefixed_country and vat_to_return.startswith(prefixed_country + prefixed_country)
意思很简单:
- 如果前缀已经推断出来了;
- 结果又像是被重复拼接了一次;
- 那就别轻易当作合法号码收下。
这种问题在线上非常常见,尤其是:
- 用户导入 CSV 时自己手工补过前缀;
- 第三方系统已经发来带国家码的数据;
- 中间脚本又根据国家再拼了一次前缀。
于是就会出现肉眼很难第一时间发现的 BEBE...、DEDE...。
Odoo 在这里的防守非常务实:不怕你填得花,只怕系统帮你错得更整齐。
no_vat_validation 为什么不是偷懒开关,而是系统边界开关
_run_vat_checks() 里有一句极其重要的注释:
no_vat_validation允许在不做校验的情况下存 VAT;- 这是给“来自外部平台、你无法控制 VAT 质量”的推数场景准备的。
这很值得单独讲。
很多团队第一次看到这个 context key,会本能地担心:
- 这是不是给脏数据开后门?
其实更准确的理解是:
这不是“放弃治理”,而是承认系统边界。
当你的 Odoo 只是下游接收方时,某些 VAT 号也许必须先存下来,原因可能是:
- 它对应已成交订单;
- 它来自法定外部平台;
- 你需要原样留存,再走人工清洗;
- 你不能因为一个税号格式问题就整单拒收。
所以 no_vat_validation 代表的是一种成熟的集成思路:
- 业务接入先保通;
- 数据治理再分层进行。
这比“一刀切,错一个号整个接口 400”往往更适合真实企业环境。
VIES 校验为什么不是主校验,而是附加可信度层
_compute_vies_valid() 的写法也很能说明问题。
它先看:
- 是否有公司启用了
vat_check_vies; - partner 是否有 VAT;
- 子联系人是否与 parent 用同一个 VAT;
- 然后才调
check_vies(partner.vat, timeout=10)。
这说明在 Odoo 眼里,VIES 不是“税号能否存在”的唯一依据,而是:
- 对欧盟跨境 VAT 的额外在线确认。
为什么这样设计是合理的?
因为 VIES 有两个天然限制:
- 它是外部服务,可能超时、波动或故障;
- 它回答的是“当前 VIES 视角下是否有效”,而不是取代本地格式和业务上下文。
所以 Odoo 的顺序是对的:
- 先做本地规则判断;
- 再做远程可信度补充;
- 远程失败时,记录、提示、降级,而不是把整个 partner 永远锁死。
VIES 失败时为什么是 message_post,不是直接炸掉
在 _compute_vies_valid() 中,如果发生:
OSErrorInvalidComponentzeep.exceptions.Fault
Odoo 会:
- 给原记录
message_post一条说明; - 写 warning 日志;
- 并把
vies_valid置为False。
这个设计非常现实。
因为在线校验失败,可能有三种原因:
- 号码真有问题;
- VIES 服务挂了;
- VIES 服务回了系统无法解释的错误。
如果一律抛成致命错误,业务人员会被卡死;如果一律静默,又没人知道为什么结果变成 false。
所以 Odoo 取了中间路线:
- 尽量不中断当前业务;
- 但把失败痕迹留在记录沟通流里。
这对运营团队其实很友好,因为他们可以追溯“当时为什么没通过 VIES”。
为什么 parent / child 共用 VAT 时会复用结果
还有一个很容易忽略的小优化:
- 如果子联系人和 parent 用同一个 VAT;
- 就直接复用 parent 的
vies_valid。
这说明 Odoo 不希望在联系人树里对同一税号重复打外部服务。
这背后的好处很直接:
- 减少不必要的 VIES 请求;
- 降低超时风险;
- 保证同一集团 / 总公司号码在多个联系人上结果一致。
它本质上体现的是:
税号验证针对的是“税号实体”,不是“表单行数”。
一句话理解 Odoo VAT 的真正价值
如果非要把 base_vat 的设计压成一句话,我会这样说:
Odoo 不是在判断一串字符对不对,而是在尽量把现实世界里不整齐的税号输入,整理成一个既能落业务、又能对法规负责的结构化结果。
所以做 VAT 集成时,最该记住的不是某个正则,而是四层边界:
- 输入理解;
- 格式归一;
- 本地规则校验;
- 在线 VIES 可信度补充。
真正成熟的税务系统,往往不是“永不出错”,而是:
即使数据不完美,也知道该在什么地方严格、什么地方降级、什么地方留下解释。
DISCUSSION
评论区