很多人第一次接触 Odoo POS 的 combo,会把它理解成一个很简单的东西:
- 主商品是“套餐”;
- 下面挂几个可选品;
- 收银员随便点几个;
- 系统再把结果记下来。
这个理解只够解释“能选什么”,解释不了“为什么有时会弹窗、有时不会”“为什么同样是套餐,有的只能单选、有的能多选”“为什么小票上既像一行套餐,又能看见明细子项”。
也就是说,combo 真正难的地方不是有没有子项,而是:
前台怎么让人正确选,后台怎么把它拆成可追踪的订单行。
先说结论
结合 ComboConfiguratorPopup、pos_store.js、pos.order.line 和 report_sale_details.py,可以先抓住五个结论:
- 不是每个 combo 组都会弹复杂选择界面。
qty_max与qty_free分别控制“最多选多少”和“多少份算套餐内含”。- 单选型 combo 不是靠人工记忆避免多选,而是前端会主动清空旧选择。
- 套餐真正落单时,会被拆成父行与子行,而不是只保留一个“套餐壳”。
- 小票和销售明细会把所选子项重新汇总展示出来,所以前台展示和后台结构不是一回事。
为什么有的 combo 要弹窗,有的几乎像自动带出
ComboConfiguratorPopup 里有个非常关键的判断:shouldShowCombo(combo)。
它的逻辑大意是:如果这个 combo 组满足以下任一条件,就值得给用户展示更明显的选择界面:
- 组内有多个可选项;
qty_max > 1;- 即使只有一个商品,但这个商品本身还是 configurable。
反过来,如果:
- 只有一个候选项,
qty_max == 1,- 而且商品本身也不需要再配置,
那么 Odoo 甚至会自动把它视为默认选择。
这体现了一个很实用的前台设计:
POS 不应该为了“技术上是 combo”就让收银员每次都多点一轮无意义弹窗。
也就是说,Odoo 在 combo 展示上追求的不是“结构完整”,而是“收银速度和配置复杂度相匹配”。
qty_max 控制的是选择上限,不是套餐总数量
很多门店在配置套餐时,最容易把 qty_max 误解成“这个套餐总共能卖多少份”。
不是。
在弹窗选择逻辑里,它控制的是:某个 combo 组里最多能选多少个项目。
所以如果一个组设置:
qty_max = 1
前端就会把它当成单选组。
而在 resetSingleQtyMaxCombo(combo) 中,Odoo 甚至会在切换选择时主动把该组已有选择清空,确保只有一个结果留下来。
这说明 Odoo 并不依赖收银员“自己记得不要多选”。 它在前端层就把单选边界做死了。
这类设计非常重要,因为 POS 不是后台表单。 前台必须更偏“防误触”,不能把复杂配置理解留给高频操作人员自己消化。
qty_free 决定哪些子项算套餐内含,哪些算额外加价
在 getSelectedComboItems() 里,Odoo 会把选中的内容拆成两组:
itemsIncludeditemsExtra
拆分依据就是 qty_free。
含义非常直白:
- 在免费份额内的,属于套餐内含;
- 超出免费份额的,就进入额外项目,需要另外计价。
这跟很多线下餐饮门店的直觉完全一致:
- 套餐送一杯饮料;
- 第二杯不是不能点,而是要补差价;
- 套餐内允许选两份小食,但第三份开始算加购。
也正因为这样,combo 不是“选完再平均摊价”这么简单。 它要先把业务含义拆出来:
- 哪些是 included;
- 哪些是 extra;
- 哪些 extra 还可能带属性配置。
套餐真正落单时,不会只剩一行主商品
在 pos_store.js 的 handleComboProduct() 中,Odoo 最终会把 combo 结果写进:
values.combo_line_ids = comboPrices.map(...)
也就是说,当套餐加入订单时,系统不是只保留一个模糊的“套餐商品”。 它还会为所选内容创建真正的子行,带着:
product_idcombo_item_idprice_unitqtyattribute_value_idscustom_attribute_value_ids
对应到后端模型,就是 pos.order.line 里的:
combo_parent_idcombo_line_idscombo_item_id
所以 Odoo 的 combo 结构更像:
- 父行负责表达“这是一个套餐”;
- 子行负责表达“你在这个套餐里具体选了什么”。
这正是套餐可追踪、可打印、可统计的前提。
为什么小票和报表既看到套餐,又看到内容
很多人会困惑:
- 订单行里已经拆成父子结构了;
- 那为什么销售明细里又像把所选内容拼回主套餐后面?
因为 report_sale_details.py 会在检测到 combo_line_ids 后,把子品项名称拼成:
' (' + ", ".join(line.combo_line_ids.product_id.mapped('name')) + ')'
也就是类似:
- 双人套餐(薯条、可乐、鸡翅)
这一步很有意思。
它说明 Odoo 在展示层采用的不是“完全暴露底层结构”,而是:
- 对数据库和逻辑层,保留父子行;
- 对人类阅读层,把选择结果重新拼成好读的标签。
这也是为什么套餐在报表里往往看起来比底层数据更自然。
展示层与结构层为什么必须分开
如果前台展示和后台结构完全一样,门店会遇到两个问题:
1)展示太碎
收银员和店长只想知道:
- 这卖的是哪个套餐;
- 顾客选了哪些内容。
他们不想看一串技术味很重的父子行关系。
2)结构太粗
如果只保留“一行套餐”,后面很多事都难做:
- 小票没法体现所选明细;
- 某些准备环节没法识别子项;
- 统计和售后也更难解释。
所以 Odoo 的做法其实很成熟:
面向机器时拆细,面向人时再合适度重组。
为什么套餐“展示”不是纯前端问题
很多人会把 combo 的展示视为 UI 问题,但其实它直接影响后续业务链。
因为一旦你在弹窗里做出的选择被拆成真实子行,这些子行后面会继续参与:
- 小票展示;
- 销售明细;
- 某些准备单或厨房分类判断;
- 售后解释“顾客当时到底选了什么”。
换句话说,combo 的展示不是漂亮不漂亮的问题,而是能不能把顾客的选择,稳定地传进后续链路。
常见误解
误解 1:所有 combo 都应该弹窗
不对。
如果组合足够简单,Odoo 会尽量减少无效交互。
误解 2:qty_max 就是套餐卖几份
不对。
它首先控制的是某个 combo 组内可选项目上限。
误解 3:套餐只要前台显示清楚就够了
不对。
如果落单结构不清楚,后面的小票、报表和准备流程都会混乱。
误解 4:小票看到的是底层真实结构
也不完全对。
展示层通常会把子项重新拼成更适合阅读的文本。
实战排查顺序
当套餐展示或拆分不符合预期时,建议按这个顺序查:
- 该 combo 组是否真的需要弹窗;
qty_max与qty_free的业务含义有没有配反;- 单选组是否因 reset 逻辑自动清掉了旧项;
- 额外项目是否进入了 extra 逻辑;
- 订单里是否真的生成了
combo_line_ids; - 报表和小票是结构层问题,还是只是展示拼接问题。
最后的理解方式
如果只记一句话,我建议记:
Odoo POS 的 combo 不是“让顾客选配菜”,而是“把一次选择过程拆成既方便收银、又方便后续追踪的父子订单结构”。
前台弹窗负责降低误操作, 父子订单行负责保留真实选择, 小票与报表再把这些结构重新翻译成人能看懂的结果。
这三层一起,才是 Odoo POS 套餐真正成熟的地方。
DISCUSSION
评论区