很多人第一次看到 Odoo POS 的 combo / 套餐功能,会觉得它无非是:
- 主商品是一个套餐;
- 下面挂几条子商品;
- 总价按平均数拆给每一行;
- 结束。
真正看源码后会发现完全没这么粗糙。
从 /home/ubuntu/odoo-temp/addons/point_of_sale/static/src/app/models/utils/compute_combo_items.js、pos_store.js 和 pos_order_line.js 看,Odoo 真正在处理的是:
- 套餐内 免费份额 与 额外份额 怎么区分;
- 子项价格不是平均拆,而是按
base_price比例分配; - 舍入误差不能丢,最后一项要吃掉
remainingTotal; - 父行数量或折扣变化时,子行要不要跟着按比例传播。
所以准确地说,Odoo POS combo 不是几条明细附属在一个套餐名下,而是一套父子行联动的价格分摊模型。
结论先说
如果你想真正看懂 Odoo POS combo,先记住四个点:
- combo 子项不是平均分价,而是按
combo.base_price占比切分; - 舍入尾差不会丢,最后一个子项会吸收
remainingTotal; - 额外选择不是和免费份额一视同仁,超出的部分会按另一套逻辑计价;
- 父套餐一改,子项要联动,数量与折扣都可能沿父子关系传递。
前端为什么先弹一个 combo configurator
在 pos_store.js 里,点 combo 商品不会立刻加单,而是先走 ComboConfiguratorPopup。
这个弹窗不是为了好看,而是为了把两种完全不同的决策先收齐:
- 每个 combo 组里免费可选多少;
- 顾客是否额外多选了超出免费份额的子项;
- 某些子项是否还带属性配置与额外价格。
也就是说,Odoo 不把 combo 看成“一个固定 BOM”,而是把它看成:
一个主套餐价格 + 若干受规则约束的可配置子选择。
这跟传统“套餐就是死的物料清单”差别很大。
为什么 price split 不是平均,而是按 base_price 比例切
compute_combo_items() 的核心算法很值得看。
它先取父商品价格 parentLstPrice,然后对选中的子项配置算一个 originalTotal:
const originalPrice = conf.combo_item_id.combo_id.base_price * conf.qty;
随后每个子项的价格不是“总价 / 子项数”,而是:
(combo.base_price * parentLstPrice) / originalTotal
这说明 Odoo 想表达的不是“套餐里每项都一样值钱”,而是:
套餐价应按照各 combo 组自身定义的基础价值比例来拆分。
这很合理。现实里一个套餐的主菜、饮料、加购甜品,本来就不应机械均分。
为什么最后一个子项要吃掉 remainingTotal
源码里还有一个很务实的细节:每次分完一个子项价格,就从 remainingTotal 扣掉;到了最后一个子项,再把剩余尾差加回去。
这是典型的舍入保护动作。
因为货币精度下,如果每一项都单独 round:
- 分摊后的小项价格之和,可能不等于父套餐价格;
- 小票看起来总额对,明细加总却差一分;
- 下游税额和统计也会跟着歪。
所以 Odoo 的做法不是追求“每项都绝对精确”,而是优先保证:
拆分后的全部子项合计,必须回到父套餐真实价格。
免费份额与 extra lines 为什么不是一回事
compute_combo_items() 还单独处理 childLineExtra。
前半段处理的是套餐规则内正常选择;后半段则处理超出免费份额的 extra child lines,并按 combo 的 base_price 和 comboItem.extra_price 计价。
这意味着 Odoo 在业务上明确分了两类子项:
- included items:属于套餐内本来就应被套餐价覆盖的部分;
- extra items:顾客额外加的,不应继续躲在“套餐已含”里。
这层边界特别重要。否则你做自定义时很容易把:
- 套餐内可选项;
- 套餐外附加项;
都当成同一种行去处理,最后要么少收钱,要么把 included items 也重复收费。
属性加价加在哪一层
源码还会把 attribute_value_ids 的 price_extra 算进去,再叠加 comboItem.extra_price。
这表示 combo 里的最终价格不是只有一层:
- 套餐主价按 base_price 分摊出来的基础份额;
- 子项本身可能带的
extra_price; - 具体属性配置带来的
price_extra。
所以“组合餐加价”这件事,Odoo 不是只在父商品改总价,而是允许把部分增量落在子项配置层。
这对实际菜单设计非常有用,比如:
- 套餐里饮料默认免费,但换大杯加价;
- 同一主食可换不同口味,某些口味额外收费;
- 免费选 2 样,第三样开始每样都加价。
父行变了,为什么子行要跟着变
pos_order_line.js 里,父行 setQuantity() 时会遍历 combo_line_ids,按原比例调整子行数量:
comboLine.setQuantity((comboLine.qty / this.uiState.oldQty || 1) * quantity, true)
同时 setDiscountFromUI() 也会把折扣传播给 combo 子行。
这说明 Odoo 的 combo 不是“父行只是显示标题,子行才是真卖品”,也不是“子行只是备注”。
它更像一个树:
- 父行承载套餐语义;
- 子行承载细项结构;
- 两者的数量、折扣、展示要保持联动。
不然你把父套餐从 1 改成 2,子项还停在旧数量,单据立刻就会自相矛盾。
为什么发票里 combo 父行会变成 section
后端 models/pos_order.py 的 _get_invoice_lines_values() 对 combo 父商品还有专门处理:如果产品类型是 combo,发票层不把它当普通产品行,而会生成 display_type = 'line_section' 的分节标题。
这意味着在发票表达上,Odoo 倾向于:
- 用父行保留“这是一个套餐”的阅读语义;
- 用实际子项行承载真正可计价、可税算的内容。
这和前端父子树结构是呼应的:父行负责组合语义,子行负责明细事实。
最容易误解的四件事
误解一:套餐价拆分就是平均分
不对。
Odoo 明确按 base_price 比例拆,不是按行数平均。
误解二:舍入尾差无所谓,差一分钱问题不大
问题不小。
POS 明细、税额、发票、营业统计都要求可回加解释,所以尾差必须有人吃掉。
误解三:extra item 只是 included item 数量多一点
不对。
extra item 的计价逻辑就是为了表达“超额选择需要另计”。
误解四:父套餐改数量,子项可以不动
不对。
那样一来父子结构立刻失真,订单再也解释不清。
实战排错顺序
如果你遇到“套餐价分摊不对、子项数量没跟上、额外加价算错、发票显示怪异”,建议按这个顺序查:
- combo configurator 收集的选择结果是否区分了 included 与 extra;
base_price配置是否合理,是否误以为它只是展示字段;originalTotal是否按正确子项与数量计算;- 舍入尾差是否被最后一项吸收;
comboItem.extra_price与属性price_extra是否重复或漏算;- 父行改数量后,
combo_line_ids是否按比例更新; - 折扣是否应从父行同步给子行;
- 发票侧是否理解成“父行 section + 子项明细”而非一条普通套餐商品。
最后的结论
Odoo POS combo 机制真正解决的不是“套餐怎么显示得更像套餐”,而是:
- 套餐价如何被可解释地拆给子项;
- 免费与超额选择怎样边界分明;
- 父子结构在数量、折扣、发票层怎样保持一致。
所以这件事最值得记住的一句话是:
Odoo POS 的组合餐不是几条附属明细,而是一套围绕父子行联动、价格比例分摊与额外选择计价建立起来的结构化模型。
DISCUSSION
评论区