很多人第一次看到 Odoo 的组合商品(combo product),会把它想成一种“套餐说明”:
- 主商品是一份套餐;
- 下面列几个可选项;
- 前台让用户勾一勾;
- 最后凑成一张订单。
但如果你顺着 addons/product/models/product_combo.py、product_combo_item.py,再看 point_of_sale/models/product_combo.py 和 product_combo_item.py,会发现官方根本不是把它当“备注文本”来做。
它实际上是一套独立的产品建模层,而且这个建模层不是只给一个前端用,而是刻意设计成:
- 核心产品模型先定义组合结构;
- POS 在此基础上增补交互约束与数据加载;
- 上层销售/POS 逻辑共享同一份组合事实。
这套设计很值得讲透,因为它解释了几个实战里经常被误解的问题:
- 为什么 combo 不是直接把子商品写在模板描述里;
- 为什么组合项要单独建
product.combo.item; - 为什么官方要计算
base_price而不是让每个组合自由漂移; - 为什么 POS 又额外加了
qty_free、qty_max; - 为什么组合项里禁止再塞 combo 类型商品。
一、组合商品不是“前台配置项”,而是产品域模型
先看核心模型:
product.comboproduct.combo.item
从命名就能看出,官方没有把它挂在某个特定业务模块下面,而是放在产品层。
这件事本身就说明:
Odoo 认为组合商品首先是产品建模问题,其次才是销售界面或 POS 界面问题。
这非常关键。
如果你把 combo 只当作“前端配置弹窗”,那你会很容易把逻辑拆得七零八落:
- 一部分写在网站;
- 一部分写在 POS;
- 一部分写在销售订单行;
- 最后各端对“同一个套餐”理解都不一样。
官方反过来做:先把组合结构做成独立模型,业务端只是消费这套模型。
二、为什么要拆成 combo 和 combo item 两层
product.combo 代表的是一个“选择组”或“组合槽位”;product.combo.item 代表的是这个槽位里的具体可选商品。
比如一个套餐里可能有:
- 饮料一组选项;
- 配菜一组选项;
- 主食一组选项。
每一组本身不是商品,而是一个“可选集合”。这正是 product.combo 的角色。
而组里的可选项——可乐、雪碧、果汁——才是 product.combo.item。
如果不做这层拆分,你会立刻遇到两个问题:
1)无法表达“同一套餐里有多个独立选择组”
把所有选项直接堆在模板上,只能得到一团列表,表达不了“饮料和配菜是两组不同规则”。
2)无法分别施加组级约束和项级约束
组级约束比如:
- 最多选几个;
- 免费名额几个;
- 组的顺序。
项级约束比如:
- 对应哪一个商品;
- 额外加价多少;
- 商品不能是 combo 类型。
所以官方拆两层,不是为了“规范”,而是因为业务语义天然就是两层。
三、base_price 为什么取最小值,而不是平均值或零
product.combo 里有一个很有意思的计算字段:
base_price
它的帮助文本已经把设计意图写得很直白:
取这个组合里所有可选商品的最小价格,作为后续在多组合产品里做 price prorate 的基准。
很多人第一次看到这里会奇怪:
- 为什么不是平均值?
- 为什么不是最高价?
- 为什么不是直接不算?
答案在注释里已经给出:
这样无论用户在该组合里选哪一个产品,整个 combo product 的定价都能保持稳定,不会因为选项不同导致套餐基础价语义飘掉。
说白了,官方想解决的不是“怎么把每个选项卖出去”,而是:
- 主套餐应该有一个稳定的基础价格语义;
- 超出的差异,再通过
extra_price这类机制表达; - 不要让套餐总价因为内部选项组合而完全失控。
这其实很像一套“最低基准 + 差价补正”的建模方法。
四、为什么 product.combo.item 同时保留 lst_price 和 extra_price
product.combo.item 上核心字段是:
product_idlst_price(related)extra_price
这里官方没有把选项的真实商品价格复制一份独立值,而是 related 到商品本身。这意味着:
- 选项商品的标准价变化时,组合能感知到;
- 组合项不会悄悄和原商品价格脱节;
- 组合自身只额外存“偏移量”而不是完整二次价格体系。
extra_price 的存在也很关键。
它代表的不是“这个商品卖多少钱”,而是:
- 在被纳入该组合选择时,相对基础组合语义要再补多少钱。
这让组合建模更清楚:
- 商品本身保留商品价格身份;
- 组合项只表达“放进这个选择组后,相比默认基线多收多少”。
五、为什么组合项禁止再引用 combo 商品
在 product.combo.item 里,官方明确限制:
product_iddomain 里type != 'combo'- 约束
_check_product_id_no_combo()进一步兜底
这背后的动机非常现实:
如果组合项还能继续引用 combo 商品,就会出现:
- 组合套组合;
- 规则递归展开;
- 前台选择器层层嵌套;
- 价格与数量规则难以收敛;
- 甚至循环引用风险。
官方直接从模型层封死这个可能性,本质上是在说:
组合是选择容器,不应该无限递归成树状套餐引擎。
这让功能边界很清晰,也更适合大多数零售和餐饮业务场景。
六、为什么还要做“不能为空”和“不能重复商品”约束
product.combo 上还有两个很朴素但很重要的约束:
- 一个组合选择组至少要有 1 个商品;
- 同一组里不能出现重复商品。
这类约束看起来像基础校验,但它们真正保证的是:
1)组合组本身有业务意义
如果一个组没有任何候选商品,那它只是一个空壳结构,会把前端选择器和价格计算都搞复杂。
2)同一商品不能在一组选项里重复出现
否则 UI 上会看到多个“同名选项”,而底层又很难解释它们究竟是两个不同业务选项,还是脏数据。
官方宁可在模型层直接报错,也不把这种歧义留给前端去猜。
七、多公司检查为什么做得这么早
product.combo 会在 _check_company_id() 里检查:
- 关联模板的公司一致性;
- 组合项里商品的公司一致性。
product.combo.item 还启用了:
_check_company_auto = Trueproduct_id上check_company=True
这说明官方明确不想让组合商品成为“跨公司偷渡点”。
这是实战里非常容易被忽视的坑:
- 套餐模板在公司 A;
- 选项商品来自公司 B;
- 前台看起来都能选;
- 直到报价、库存或 POS 出账时才炸。
官方选择在模型层尽早拦截,而不是把错误拖到订单阶段。
八、POS 扩展不是重做一套模型,而是在核心模型上加约束
再看 point_of_sale/models/product_combo.py。
POS 没有新发明一个 pos.combo,而是直接:
_inherit = ['product.combo', 'pos.load.mixin']
然后只补了几个 POS 真正需要的东西:
qty_maxqty_free_load_pos_data_domain()_load_pos_data_fields()- 对最大数量/免费数量的约束。
这说明 POS 的设计态度很克制:
- 组合结构仍然属于产品核心模型;
- POS 只补“门店选择交互”的规则;
- 不重新复制一套组合定义。
这是非常好的分层。
九、qty_free 和 qty_max 到底表达什么
这两个字段很容易被误读。
qty_max
表示这个组合组里最多能选多少项。
qty_free
表示这些可选项里,有多少个是包含在套餐基础价里的。
举例:
qty_max = 3qty_free = 1
可能意味着:
- 最多挑 3 个;
- 其中 1 个免费;
- 多选或高配项再按
extra_price补差。
这两个字段一加入,POS 端就不再只是“单选菜单”,而能覆盖很多现实门店场景:
- 套餐送一杯饮料,但可升级;
- 小食可任选两份,其中一份免费;
- 某些附加项最多点若干个。
所以官方没有把 POS combo 做成死板的“选一个结束”,而是支持受控的数量规则。
十、为什么 POS 只加载必要字段
POS 扩展里 _load_pos_data_fields() 只加载:
idnamecombo_item_idsbase_priceqty_freeqty_max
组合项也只加载:
idcombo_idproduct_idextra_price
这说明官方知道 POS 需要的是:
- 足够驱动本地选择和价格计算的数据;
- 不是整张后台模型的全部字段。
也就是说,核心模型负责正确,POS 加载层负责轻量。
这能减少门店端的数据负担,同时保持规则来源统一。
十一、给实施与开发的落地建议
1)别把组合规则散落在多个前端
如果网站、Sales、POS 都要支持组合,优先围绕 product.combo 这层做统一建模,不要每端各存一份 JSON 规则。
2)把价格理解成“基线 + 差价”
不要试图让每个选项都重新决定整套套餐价格。先保住主套餐基础价语义,再通过 extra_price 表达升级差异。
3)不要轻易放开 combo 套 combo
短期看像功能增强,长期看大概率把 UI、价格、库存和测试复杂度一起拉爆。
4)多公司环境一定在模型层卡住
组合商品经常把多个商品绑在一起,更容易跨公司串错。不要只靠前端 domain。
结语
Odoo 的组合商品设计,真正厉害的地方不在“能弹个选项框”,而在于它把套餐业务拆成了一套可复用、可约束、可跨业务端共享的模型:
product.combo表达选择组;product.combo.item表达组选项;base_price保住基础价格语义;extra_price表达升级差价;- POS 再补
qty_free/qty_max这类交互规则。
所以如果你在项目里也要做套餐、搭配、附加项,最值得学的不是某个前端弹窗,而是这个底层思路:
先把组合商品建模清楚,再让销售、网站、POS 去复用它。
DISCUSSION
评论区