先说结论
很多人调 Odoo 电商结账时,最容易产生的错觉是:
- 页面上填一个地址;
- 提交后把字段写回客户;
- 完成。
实际完全不是这样。
website_sale 里的地址流程,本质上是在回答几个连续问题:
- 当前购物车是不是匿名 cart;
- 用户是在新建地址,还是编辑已有地址;
- 这是账单地址还是收货地址;
- 当前地址能不能直接复用现有子联系人;
- 是否要把收货地址同时当账单地址;
- 这个地址该归到哪个网站、公司、销售员语境里。
所以你看到的“地址自己变了”“怎么又生成了一个子联系人”“为什么账单地址跟着一起改了”,大多不是 bug,而是 Odoo 在执行一套很明确的地址决策逻辑。
一、/shop/address 不是单纯表单页,而是 checkout 状态机的一环
在 website_sale/controllers/main.py 里,/shop/address 会先拿当前 request.cart,然后先做购物车校验,再决定本次地址表单到底是在干什么。
核心不是“渲染模板”,而是先判定:
- 当前要编辑的是哪个 partner;
- 这个 partner 的地址类型是
billing还是delivery; - 是否需要把
use_delivery_as_billing打开。
这意味着地址页不是孤立存在的,它依附于当前 cart 上的:
partner_idpartner_invoice_idpartner_shipping_id
所以你在地址页看到的内容,本质上是订单当前地址状态的一个编辑视图。
二、为什么匿名购物车和登录用户的地址行为完全不一样
_prepare_address_update() 里有个非常关键的分支:
- 匿名 cart:允许从空 partner 语义出发创建地址;
- 非匿名 cart:只能在订单已有主联系人、账单地址、收货地址及其合法子联系人范围内编辑。
这背后的目的很明确:
对匿名用户
系统必须允许他从零开始填一份地址,所以 partner 可以视作“待创建”。
对已登录用户
系统不能让他随便通过 URL 改到别人的联系人记录,所以会检查:
- 指定的 partner 是否已经关联到当前订单;
- 当前客户是否有权限编辑这条地址。
这就是为什么 Odoo 的地址页虽然看起来很普通,但其实已经带着访问边界。
三、地址提交后为什么不一定新建联系人
真正提交走的是 /shop/address/submit。
很多人会以为:
- 填一个地址;
- 就 create 一个
res.partner。
但 Odoo 实际会先区分:
- 是新地址还是旧地址;
- 是匿名还是已登录;
- 是账单还是收货;
- 是否只卖服务型产品;
- 是否勾选“收货地址同账单地址”。
而且在 express checkout 和普通 checkout 里,都能看到一个核心思想:
尽量复用已有子联系人,只有找不到匹配地址时才新建。
源码里的 _find_child_partner() 会遍历商业主体下面的子联系人,只要地址字段一致,就直接复用那条联系人,而不是继续制造重复地址。
这点很关键,因为很多企业客户会反复下单。
如果每次网站订单都机械新建一个地址联系人,后台客户档案会很快爆炸。
四、为什么 Odoo 要把国家/州先转成对象,再做地址比较
在 express checkout 相关逻辑里,Odoo 会先调用 _include_country_and_state_in_address(),把前端传来的 ISO 国家/州编码转成 country_id 和 state_id。
看起来像小事,其实非常重要。
因为地址去重和比较必须基于统一结构。
否则会出现这种问题:
- 前端传
US; - 后台存的是美国记录 id;
- 两边直接比较永远不相等。
所以 Odoo 先做标准化,再用 _are_same_addresses() 和 _find_child_partner() 比较。
这说明它关心的不只是“页面有没有填值”,而是:
- 地址能不能被稳定识别;
- 已有联系人能不能被复用;
- 后续税、配送、客户档案能不能建立在同一份地址事实上。
五、账单地址、收货地址、主联系人,三者到底怎么联动
地址提交成功后,Odoo 不会只写 partner 本身,而是更新订单上的 partner 字段集合。
在 shop_address_submit() 里可以看到:
- 如果是主地址更新,强制重算
partner_id相关字段; - 如果是账单地址,更新
partner_invoice_id; - 如果是收货地址,更新
partner_shipping_id; - 如果勾选
use_delivery_as_billing,则收货地址也会同步给账单地址。
这意味着网站结账地址不是一张表单,而是一个订单地址指针重绑定过程。
所以有时你觉得“我只是改了收货地址,为什么账单也跟着变”,原因通常不是它乱来,而是:
- 本次流程确实要求把配送地址兼作账单地址;
- 或当前订单仍处于共用同一 partner 的模式。
六、为什么只卖服务时,地址跳转逻辑又不一样
源码里还有一个很容易忽略的细节:
- 如果订单
only_services,成功提交地址后更倾向直接回/shop/checkout?try_skip_step=true。
这反映出 Odoo 对电商结账的理解不是死板的“三步走”,而是:
- 按订单是否真的需要配送来裁剪流程。
也就是说,地址流程虽然存在,但其存在目的不是“网站都得有地址页”,而是为了满足订单履约。
如果没有可配送商品,配送相关步骤就应该尽量跳过。
这也是 Odoo checkout 体验比较顺的一个原因:
- 它的流程是条件驱动的,不是模板驱动的。
七、为什么新建地址时会带上网站、公司和销售员语境
_complete_address_values() 做了几件很业务化的事:
- 语言不在网站可用语言里就移除;
- 公司用当前网站所属公司;
user_id绑定到网站销售员;- 某些场景还会写入
website_id。
这说明网站地址不是孤立主数据,而是带着网站上下文创建的客户数据。
这会影响什么?
- 后续销售归属;
- 多网站客户识别;
- 语言与邮件模板;
- 地址后续是否被别的网站或别的公司复用。
所以如果你做多网站或多公司电商,地址逻辑不能只盯住 res.partner 表面字段,还要看它是在哪个网站语境里创建的。
八、做结账地址定制时,最容易踩的坑
1)前端加字段,却没接入地址标准化
结果页面看着提交成功,后台却无法正确复用联系人,或者配送税费计算异常。
2)强行每次都新建联系人
短期感觉“最安全”,长期一定把客户档案搞脏。
3)把账单和收货逻辑混在一起改
最后常见结果是:
- 订单 partner 指针错乱;
- 税务地址和履约地址对不上;
- 支付页、配送页来回跳。
4)忽略匿名购物车与登录用户的边界差异
这会直接带来权限问题,或者让已登录客户改到不该改的联系人记录。
最后一句
Odoo 电商里的地址流程,真正处理的不是“填表单”,而是:
购物车身份识别、地址标准化、子联系人复用、账单/收货指针更新,以及网站上下文归属。
你一旦把它看成这条主链路,就能明白为什么很多所谓“地址异常”,其实只是系统在认真维护订单和客户数据的一致性。
DISCUSSION
评论区