POS

Odoo 组合商品为什么要单独建模:product.combo 如何同时服务销售与 POS

基于 Odoo 官方 `product.combo`、`product.combo.item` 与 POS 扩展源码,讲清组合商品为什么不是“套餐描述文本”,而是一套可复用的数据模型,以及价格、免费数量、子项约束如何在销售与 POS 间共享。

销售
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 5 阅读

很多人第一次看到 Odoo 的组合商品(combo product),会把它想成一种“套餐说明”:

  • 主商品是一份套餐;
  • 下面列几个可选项;
  • 前台让用户勾一勾;
  • 最后凑成一张订单。

但如果你顺着 addons/product/models/product_combo.pyproduct_combo_item.py,再看 point_of_sale/models/product_combo.pyproduct_combo_item.py,会发现官方根本不是把它当“备注文本”来做。

它实际上是一套独立的产品建模层,而且这个建模层不是只给一个前端用,而是刻意设计成:

  • 核心产品模型先定义组合结构;
  • POS 在此基础上增补交互约束与数据加载;
  • 上层销售/POS 逻辑共享同一份组合事实。

这套设计很值得讲透,因为它解释了几个实战里经常被误解的问题:

  • 为什么 combo 不是直接把子商品写在模板描述里;
  • 为什么组合项要单独建 product.combo.item
  • 为什么官方要计算 base_price 而不是让每个组合自由漂移;
  • 为什么 POS 又额外加了 qty_freeqty_max
  • 为什么组合项里禁止再塞 combo 类型商品。

一、组合商品不是“前台配置项”,而是产品域模型

先看核心模型:

  • product.combo
  • product.combo.item

从命名就能看出,官方没有把它挂在某个特定业务模块下面,而是放在产品层。

这件事本身就说明:

Odoo 认为组合商品首先是产品建模问题,其次才是销售界面或 POS 界面问题。

这非常关键。

如果你把 combo 只当作“前端配置弹窗”,那你会很容易把逻辑拆得七零八落:

  • 一部分写在网站;
  • 一部分写在 POS;
  • 一部分写在销售订单行;
  • 最后各端对“同一个套餐”理解都不一样。

官方反过来做:先把组合结构做成独立模型,业务端只是消费这套模型。

二、为什么要拆成 combocombo 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_priceextra_price

product.combo.item 上核心字段是:

  • product_id
  • lst_price(related)
  • extra_price

这里官方没有把选项的真实商品价格复制一份独立值,而是 related 到商品本身。这意味着:

  • 选项商品的标准价变化时,组合能感知到;
  • 组合项不会悄悄和原商品价格脱节;
  • 组合自身只额外存“偏移量”而不是完整二次价格体系。

extra_price 的存在也很关键。

它代表的不是“这个商品卖多少钱”,而是:

  • 在被纳入该组合选择时,相对基础组合语义要再补多少钱。

这让组合建模更清楚:

  • 商品本身保留商品价格身份;
  • 组合项只表达“放进这个选择组后,相比默认基线多收多少”。

五、为什么组合项禁止再引用 combo 商品

product.combo.item 里,官方明确限制:

  • product_id domain 里 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 = True
  • product_idcheck_company=True

这说明官方明确不想让组合商品成为“跨公司偷渡点”。

这是实战里非常容易被忽视的坑:

  • 套餐模板在公司 A;
  • 选项商品来自公司 B;
  • 前台看起来都能选;
  • 直到报价、库存或 POS 出账时才炸。

官方选择在模型层尽早拦截,而不是把错误拖到订单阶段。

八、POS 扩展不是重做一套模型,而是在核心模型上加约束

再看 point_of_sale/models/product_combo.py

POS 没有新发明一个 pos.combo,而是直接:

_inherit = ['product.combo', 'pos.load.mixin']

然后只补了几个 POS 真正需要的东西:

  • qty_max
  • qty_free
  • _load_pos_data_domain()
  • _load_pos_data_fields()
  • 对最大数量/免费数量的约束。

这说明 POS 的设计态度很克制:

  • 组合结构仍然属于产品核心模型;
  • POS 只补“门店选择交互”的规则;
  • 不重新复制一套组合定义。

这是非常好的分层。

九、qty_freeqty_max 到底表达什么

这两个字段很容易被误读。

qty_max

表示这个组合组里最多能选多少项。

qty_free

表示这些可选项里,有多少个是包含在套餐基础价里的。

举例:

  • qty_max = 3
  • qty_free = 1

可能意味着:

  • 最多挑 3 个;
  • 其中 1 个免费;
  • 多选或高配项再按 extra_price 补差。

这两个字段一加入,POS 端就不再只是“单选菜单”,而能覆盖很多现实门店场景:

  • 套餐送一杯饮料,但可升级;
  • 小食可任选两份,其中一份免费;
  • 某些附加项最多点若干个。

所以官方没有把 POS combo 做成死板的“选一个结束”,而是支持受控的数量规则。

十、为什么 POS 只加载必要字段

POS 扩展里 _load_pos_data_fields() 只加载:

  • id
  • name
  • combo_item_ids
  • base_price
  • qty_free
  • qty_max

组合项也只加载:

  • id
  • combo_id
  • product_id
  • extra_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

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。