很多团队在 POS 里卖套餐、礼盒、组装包时,脑子里默认的模型是:
前台卖了一个 kit 商品,后台就扣这个 kit 商品的库存。
但如果这个商品背后挂的是 phantom BOM,Odoo 的思路完全不是这样。
先说结论:在 POS + pos_mrp 场景里,前台确实卖的是 kit 成品语义,但库存与成本很多时候按组件展开来处理。 你在收银界面看到的是“一件商品”,而后台真实落库、算成本时,系统更在意的是“这件 kit 拆成了哪些零件”。
phantom BOM 在 POS 里的真正含义
pos_mrp 只对 bom_type='phantom' 的 BOM 做特殊处理。
这很关键,因为 phantom BOM 的设计本来就不是“先生产成半成品再卖”,而是:
- 销售时按成品语义展示;
- 库存时按组件语义消耗。
所以 POS 卖 kit 时,真正的业务含义通常是:
收银员卖的是成套商品,仓库/成本模块处理的是组件集合。
这也是为什么很多门店会困惑:“为什么 POS 里卖的是礼盒,库存减少的却是里面的零件?”——因为 phantom kit 就该这样。
_get_stock_moves_to_consider() 在改写什么
pos_mrp.models.pos_order_line 重写了 _get_stock_moves_to_consider()。
默认情况下,POS 订单行算成本时,会只看和该产品本身对应的 stock moves;但 phantom BOM 下,源码会:
- 先用
_bom_find(..., bom_type='phantom')找 kit BOM; - 调用
bom.explode(product, qty)展开组件; - 取出有效 bom_line;
- 把“应当纳入成本考虑的 move”改成组件 move,而不只是 kit 本身的 move。
这一步非常关键,因为它说明:POS 订单行虽然还是 kit 行,但 total_cost 的依据已经转向组件层。
为什么前台一行 kit,后台却可能没有同名 move line
stock_picking.py 里有句注释非常点题:某些 move 的 product_id 不在相关 order lines 里,这种情况就可能发生在 phantom-type BOM。
也就是说,POS 订单行与库存 move 并不总是一一同名对应。
前台可能只有:
- 礼盒 A × 1
而后台 move 可能是:
- 组件 X × 2
- 组件 Y × 1
- 组件 Z × 3
所以排查 kit 出库异常时,如果你一直盯着“为什么没看到 kit 成品自己的 stock move”,方向往往就已经偏了。
anglo-saxon / 实时估值下,成本为什么要“回卷”到 kit 行
pos_mrp.models.pos_order 重写了 _get_pos_anglo_saxon_price_unit()。
默认 POS 会根据该产品对应的已估值 move 去算成本;但 phantom BOM 下,源码会:
- 找到 kit 的 phantom BOM;
- 展开组件;
- 对每个组件递归取 anglo-saxon price unit;
- 按 BOM 用量与 UoM 换算,把组件成本加总;
- 最后回卷成 kit 这一行的 price unit。
这件事很有价值,因为它让前台销售分析仍然可以站在“卖了一套 kit”这个视角看毛利,但底层成本来源已经不再是 kit 自己的标准价,而是组件实际成本结构。
为什么 kit 毛利不能只盯标准价
如果你只看 kit 商品自己的 standard_price,很容易得到错误结论。特别是在:
- FIFO / AVCO;
- 实时估值;
- BOM 组件成本波动;
- 组件共用、嵌套 kit;
- 不同 UoM 换算。
这些场景下,真正影响 POS 毛利的往往是组件成本,而不是前台成品挂着的某个静态价格。
test_pos_mrp_flow.py 里也专门覆盖了:
- kit 总成本;
- 不同 UoM 下的成本换算;
- 共享组件;
- 嵌套 kit。
这恰恰说明源码作者非常清楚:kit 的成本语义一旦只停留在成品层,就很容易失真。
共享组件与嵌套 kit 为什么更容易踩坑
这两类场景最容易把人带偏:
共享组件
两个 kit 可能共用同一个组件。此时如果你只按“kit 自己多少钱”理解,很难解释为什么一边卖得多,另一边毛利也跟着波动。
嵌套 kit
一个 phantom kit 的组件里,还可能再包含另一个 kit。源码会继续 explode,并把更底层组件成本汇总回来。
所以嵌套 kit 的 POS 毛利,天然不是“简单成品价减售价”能看懂的。
变体属性为什么也会影响 kit 出库
测试里还有个很实战的例子:no_variant 属性值会跟着 POS 行传到 stock move,用来决定 phantom BOM 中哪些组件行生效。
这意味着:
- 前台虽然卖的是同一个 configurable product;
- 但不同属性组合,后台实际消耗的组件可以不同;
- 成本与库存结果也会跟着变。
所以如果门店说“同一个椅子商品,红色款和蓝色款为什么扣的料不一样”,不要觉得怪——对有条件 BOM 的 kit 来说,这本来就是设计目标。
最容易误解的三件事
误区一:POS 卖 kit 就一定扣 kit 自己的库存
不对。phantom BOM 下,更常见的是按组件出库。
误区二:kit 毛利只看成品标准价就行
不对。FIFO、AVCO、共享组件、嵌套 kit 都会让组件成本成为真正决定因素。
误区三:订单行和 stock move 必须一一同名对应
也不对。phantom kit 正是前台成品语义与后台组件语义分离的典型场景。
实战排错顺序
遇到“POS 卖 kit 后库存不对 / 毛利异常 / 会计估值和前台认知不一致”时,建议按这个顺序查:
- 先确认该商品是否挂了
phantom BOM; - 看 BOM 展开后的真实组件和数量;
- 看 picking / stock move 是否按组件而不是 kit 本身创建;
- 若是毛利问题,检查
_get_stock_moves_to_consider()选中的 move 是否正确; - 若是实时估值,继续检查
_get_pos_anglo_saxon_price_unit()的组件成本回卷; - 若涉及变体或 UoM,再查 BOM 条件行与单位换算是否改变了组件成本。
最后的结论
POS 卖 kit 最容易被误读,是因为前台语言和后台语言根本不是一回事:
- 前台说:我卖了一套成品;
- 库存说:我出了几种组件;
- 成本说:我要把这些组件成本重新卷回 kit 行;
- 会计说:实时估值还要保证分录语义成立。
所以别再把 phantom kit 理解成“普通成品多了一层 BOM”。
在 Odoo POS 里,它其实是一座把前台套餐语义翻译成后台组件语义的桥。
DISCUSSION
评论区