很多团队第一次听到“POS 可以卖订阅产品”时,脑子里的模型都很朴素:
- 门店前台把 recurring product 加进购物车;
- 顾客现场付款;
- 打印小票;
- 结束。
这理解只对了收银表面,不对订阅主链。
在企业版 pos_sale_subscription 里,官方真正解决的不是“POS 能不能卖订阅”,而是 POS 成交以后,订阅对象该如何承认这笔成交已经发生。
因为对订阅来说,最怕的不是少一个前台按钮,而是下面两件事对不上:
- 数量口径没跟上:POS 明明已经卖出一周期,但订阅行
qty_invoiced还像没开过; - 时间口径没跟上:POS 明明已经完成本期收费,但
next_invoice_date没推进,下一次自动开票节奏还是停在旧日期。
所以更准确地说,pos_sale_subscription 补的是一座桥:
让 POS 这条“门店即时成交链”,能安全进入企业版订阅的“周期计费链”。
一、这个模块为什么很小,却是企业版里非常关键的一块
pos_sale_subscription/__manifest__.py 很短,只声明了两件依赖:
pos_salesale_subscription
这已经把它的定位说得很清楚了:
- 它不是重新做一套订阅;
- 也不是重新做一套 POS;
- 它是把 POS 接销售单 与 企业版订阅续费逻辑 接起来。
也正因为这样,它的源码量虽然不大,但职责非常集中:
- 在
sale.order.line层补订阅数量口径; - 在
pos.order层补付款完成后的账期推进。
这种模块往往最容易被低估。因为它看起来“只改了几行”,但其实守住的是跨模块语义一致性。
二、第一座桥:POS 卖出去的数量,为什么要算进订阅行的 qty_invoiced
核心逻辑在:
enterprise/pos_sale_subscription/models/sale_order_line.py
它扩展了 sale.order.line._get_subscription_qty_invoiced()。
先看它的思路,不看代码细节也能抓住重点:
- 先调用父类逻辑,拿到原本订阅体系认定的已开票数量;
- 再遍历每条
sale.order.line; - 把这条销售行关联到的
pos_order_line_ids数量累计进去; - 累计前还会通过
_convert_qty(..., 'p2s')做 POS 到 Sales UoM 的数量转换。
这背后其实在回答一个非常现实的问题:
如果本期订阅不是通过传统发票流程收的,而是通过 POS 现场成交收的,那订阅系统要不要承认“这期已经兑现过了”?
官方答案是:要,而且必须并到 qty_invoiced。
否则会立刻出现三类问题。
1. 订阅看起来会像“这一期还没收过”
很多订阅逻辑后面都会依赖 qty_invoiced、剩余可开票量、周期履约状态去判断当前期次是否已经处理。
如果 POS 成交不计入这里,系统就会出现一种很危险的假象:
- 门店已经收过钱;
- 订阅对象却像还没完成本期收费;
- 后续流程可能继续按“还没收”去推下一步。
这会让 POS 和订阅各说各话。
2. 数量单位可能根本不是同一种语言
源码里特意用了 _convert_qty(sale_line, pos_line.qty, 'p2s'),这点很值得讲。
因为 POS 和销售行虽然都在卖“同一个产品”,但口径不一定天然一致:
- POS 前台数量单位可能偏零售表达;
- 销售 / 订阅行数量单位则是销售对象自己的计量语义。
所以这里不是暴力 sum(qty),而是先转换再累计。
这说明官方并不把 POS 数量当“随便记个数字”,而是把它当作 要进入订阅计费口径的正式业务数量。
3. sudo() 也暴露了它的真实意图
源码里对 sale_line.sudo().pos_order_line_ids 做汇总,也很说明问题。
这不是随手写法,而是在强调:
- 这里关心的是业务事实是否存在;
- 不是当前订阅计算调用上下文恰好有没有权限看到所有 POS 行。
换句话说,订阅已开票数量在这里追求的是 事实一致性,不是前台权限视角下的局部可见性。
三、第二座桥:为什么一定要等 POS 订单 paid 以后,才推进 next_invoice_date
另一段关键逻辑在:
enterprise/pos_sale_subscription/models/pos_order.py
它重写的是 pos.order.action_pos_order_paid():
- 先执行父类付款完成逻辑;
- 再取当前 POS 订单行里关联的
sale_order_origin_id; - 过滤出
is_subscription的销售订单; - 对这些订阅单执行
_update_next_invoice_date()。
这一步比很多人想的更讲究。
因为它并不是在:
- 建 POS 单时推进;
- 关联销售单时推进;
- 点击付款按钮时就推进;
而是要等到 action_pos_order_paid() 真正成立以后 才推进。
这表示官方明确把账期推进绑定在一个条件上:
不是“前台准备收款”,而是“这张 POS 订单已经成为已付款事实”。
这非常合理。因为订阅的下一开票日,本质上是周期责任推进,不该由草稿动作触发。
如果太早推进,会出很麻烦的偏差:
- 收银员只是建了草稿单;
- 或者顾客中途放弃支付;
- 或者 POS 订单后来没真正完成;
- 订阅下一期日期却已经往后滚了。
那订阅节奏就会被门店前台的半成品操作污染。
所以这里的设计非常克制:
只有付款事实成立,订阅周期才往前走。
四、为什么这里更新的是 sale_order_origin_id,不是随便找一个订阅对象
源码从 POS 行上取的是 sale_order_origin_id,这也很关键。
这表示官方的业务语义不是:
- POS 里卖了一个 recurring product,系统就自己猜该挂哪张订阅;
而是:
- 这笔 POS 行必须明确来自某张销售 / 订阅订单的承接关系。
这和已有的 pos_sale 设计完全一致:POS 收尾并不是凭空造一个全新的商业对象,而是接手已有销售责任的一部分。
到了订阅场景,这层关系更不能丢。因为订阅最重要的不是“卖了某个 recurring product”,而是:
- 卖的是哪一张订阅;
- 推进的是哪一个周期;
- 回写的是哪条销售行的计费状态。
一旦丢掉来源单,只按产品匹配,后面就会出现非常糟糕的歧义:
- 同一个客户可能有多张订阅;
- 同一个 recurring product 可能在不同订阅里都存在;
- 门店收的到底是哪一期、哪一单,就会说不清。
所以 sale_order_origin_id 不是顺手带个关联,它是这条桥能成立的前提。
五、官方测试其实已经把这条链讲得很直白了
tests/test_pos_sale_subscription.py 里给了一个很小但很完整的场景:
- 先创建一个 monthly plan;
- 再创建一个
recurring_invoice=True且available_in_pos=True的产品; - 创建一张
sale.order,它本身就是订阅订单: -plan_id为月度计划; -start_date = 2021-01-01; -next_invoice_date = 2021-01-01; - 打开 POS session;
- 构造一张 POS 订单:
- 订单行挂了
sale_order_line_id; - 同时挂了sale_order_origin_id; -to_invoice = True; - 金额 250,数量 1; - 调用
sync_from_ui([pos_order]); - 最后断言两件事:
-
qty_invoiced == 1-next_invoice_date == 2021-02-01
这两个断言特别重要。
因为它说明官方真正想保住的,不只是“POS 能收这笔钱”,而是:
- 数量口径 要承认这次周期已经兑现;
- 时间口径 要承认下一周期应该从下个月开始。
如果只满足其中一个,都不算真的打通。
六、这不是“POS 发票化”这么简单,而是订阅节奏被 POS 正式承认
很多人看到测试里的 to_invoice = True,会自然把重点放到“POS 也能开发票”。
这当然没错,但不够深。
真正深的一层是:POS 在这里不是普通零售收银,而是在替订阅周期做一次正式结算。
所以它影响的不是单张票据,而是订阅生命周期里的两个核心问题:
- 这一期算不算已经计费 / 已兑现;
- 下一期从什么时候开始。
也就是说,pos_sale_subscription 的价值并不在“门店多了订阅商品可卖”,而在:
门店收银动作可以被企业版订阅引擎当成一笔真正成立的周期业务。
这才是企业版和“前台卖个 recurring product”之间的差距。
七、几个最容易误解的点
误解一:POS 卖订阅,就是新建一张独立 POS 单,和原订阅没关系
不对。
源码明确依赖 sale_order_origin_id 和 sale_order_line_id。这说明它不是孤立零售交易,而是 对既有订阅销售链的承接。
误解二:只要 POS 订单创建了,就该推进下次开票日
不对。
官方把推进动作放在 action_pos_order_paid(),说明只有付款事实成立,周期才应往前滚。
误解三:qty_invoiced 只该来自传统发票,不该算 POS
不对。
在订阅语义里,关键是“本期是否已经通过受控业务流程兑现”。企业版这里明确承认:POS 成交也能构成这类兑现。
误解四:数量累计就是简单相加
也不对。
源码特意做了 _convert_qty(..., 'p2s'),说明 POS 数量进入订阅口径前,要先翻译成销售侧计量语言。
八、实施排错时,最该先看哪几件事
如果你遇到“POS 明明卖了订阅,但下次开票日没动 / 已开票数量不对”,建议按这个顺序查:
- 先看产品本身:是否
recurring_invoice=True,并且允许在 POS 使用; - 再看来源单:POS 行是否真的挂了
sale_order_origin_id与sale_order_line_id; - 再看 POS 状态:订单是否真的走到了
action_pos_order_paid(); - 再看数量口径:POS 与销售行是否存在 UoM 转换差异;
- 最后看订阅计划:
plan_id、next_invoice_date、账期单位是否本来就配置异常。
这个顺序很重要。
因为很多人一上来就怀疑“订阅自动开票坏了”,但问题常常更早:POS 根本没有把这笔成交安全挂回那张订阅。
九、这套设计最值钱的地方,是它让“门店成交”不会破坏“周期收费秩序”
POS 的世界追求的是:
- 快速成交;
- 现场付款;
- 收银员少做判断。
订阅的世界追求的却是:
- 周期边界要准;
- 已计费数量要准;
- 下一次开票节奏要准。
这两个世界天然不一样。
而 pos_sale_subscription 最值得肯定的地方,就是它没有把 POS 收银粗暴地塞进订阅,而是精确补了两条桥:
- 数量桥:POS 成交能进入订阅
qty_invoiced; - 时间桥:POS paid 事实能推进
next_invoice_date。
所以对这模块最准确的总结不是“POS 也能卖订阅了”,而应该是:
Odoo 企业版允许门店完成一笔订阅周期结算,但前提是这笔收银既能回写订阅数量事实,也能推进订阅时间事实。
理解到这一步,你就不会再把它看成一个“给 POS 多加个订阅商品”的小功能,而会把它看成:
企业版在 POS 与周期计费之间补上的最小、但非常关键的一致性桥梁。
DISCUSSION
评论区