POS 组合餐

Odoo POS 组合餐为什么不是“套餐价拆几行”:combo 价格分摊、子行跟随与属性加价边界讲透

很多人以为 POS 套餐只是前台挑几个子品项,再把总价平均拆开;但 Odoo 真正在处理的是“免费份额与额外份额怎么分、最后一项怎样吃掉舍入尾差、主套餐数量变化时子行如何按比例跟随、属性加价加在哪一层”。本文结合 point_of_sale 前端源码,把 combo 的真实计算链讲透。

POS
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 4 阅读

很多人第一次看到 Odoo POS 的 combo / 套餐功能,会觉得它无非是:

  • 主商品是一个套餐;
  • 下面挂几条子商品;
  • 总价按平均数拆给每一行;
  • 结束。

真正看源码后会发现完全没这么粗糙。

/home/ubuntu/odoo-temp/addons/point_of_sale/static/src/app/models/utils/compute_combo_items.jspos_store.jspos_order_line.js 看,Odoo 真正在处理的是:

  • 套餐内 免费份额额外份额 怎么区分;
  • 子项价格不是平均拆,而是按 base_price 比例分配;
  • 舍入误差不能丢,最后一项要吃掉 remainingTotal
  • 父行数量或折扣变化时,子行要不要跟着按比例传播。

所以准确地说,Odoo POS combo 不是几条明细附属在一个套餐名下,而是一套父子行联动的价格分摊模型。

结论先说

如果你想真正看懂 Odoo POS combo,先记住四个点:

  1. combo 子项不是平均分价,而是按 combo.base_price 占比切分;
  2. 舍入尾差不会丢,最后一个子项会吸收 remainingTotal
  3. 额外选择不是和免费份额一视同仁,超出的部分会按另一套逻辑计价;
  4. 父套餐一改,子项要联动,数量与折扣都可能沿父子关系传递。

前端为什么先弹一个 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_pricecomboItem.extra_price 计价。

这意味着 Odoo 在业务上明确分了两类子项:

  • included items:属于套餐内本来就应被套餐价覆盖的部分;
  • extra items:顾客额外加的,不应继续躲在“套餐已含”里。

这层边界特别重要。否则你做自定义时很容易把:

  • 套餐内可选项;
  • 套餐外附加项;

都当成同一种行去处理,最后要么少收钱,要么把 included items 也重复收费。

属性加价加在哪一层

源码还会把 attribute_value_idsprice_extra 算进去,再叠加 comboItem.extra_price

这表示 combo 里的最终价格不是只有一层:

  1. 套餐主价按 base_price 分摊出来的基础份额;
  2. 子项本身可能带的 extra_price
  3. 具体属性配置带来的 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 的计价逻辑就是为了表达“超额选择需要另计”。

误解四:父套餐改数量,子项可以不动

不对。

那样一来父子结构立刻失真,订单再也解释不清。

实战排错顺序

如果你遇到“套餐价分摊不对、子项数量没跟上、额外加价算错、发票显示怪异”,建议按这个顺序查:

  1. combo configurator 收集的选择结果是否区分了 included 与 extra;
  2. base_price 配置是否合理,是否误以为它只是展示字段;
  3. originalTotal 是否按正确子项与数量计算;
  4. 舍入尾差是否被最后一项吸收;
  5. comboItem.extra_price 与属性 price_extra 是否重复或漏算;
  6. 父行改数量后,combo_line_ids 是否按比例更新;
  7. 折扣是否应从父行同步给子行;
  8. 发票侧是否理解成“父行 section + 子项明细”而非一条普通套餐商品。

最后的结论

Odoo POS combo 机制真正解决的不是“套餐怎么显示得更像套餐”,而是:

  • 套餐价如何被可解释地拆给子项;
  • 免费与超额选择怎样边界分明;
  • 父子结构在数量、折扣、发票层怎样保持一致。

所以这件事最值得记住的一句话是:

Odoo POS 的组合餐不是几条附属明细,而是一套围绕父子行联动、价格比例分摊与额外选择计价建立起来的结构化模型。

DISCUSSION

评论区

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