企业 POS 订阅

Odoo 企业版 POS 卖订阅为什么不是“收完钱就结束”:qty_invoiced 回写与 next_invoice_date 推进讲透

很多人以为把订阅产品放进 POS,无非就是门店也能卖一笔 recurring product。但从企业版 pos_sale_subscription 源码看,官方真正补的是两条缺一不可的桥:一条把 POS 成交数量并回 subscription 的 qty_invoiced 口径,另一条在订单真正 paid 后推进 sale.order 的 next_invoice_date。少了任意一条,订阅既会“卖了像没卖”,也会“账期像没走”。

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

很多团队第一次听到“POS 可以卖订阅产品”时,脑子里的模型都很朴素:

  • 门店前台把 recurring product 加进购物车;
  • 顾客现场付款;
  • 打印小票;
  • 结束。

这理解只对了收银表面,不对订阅主链。

在企业版 pos_sale_subscription 里,官方真正解决的不是“POS 能不能卖订阅”,而是 POS 成交以后,订阅对象该如何承认这笔成交已经发生

因为对订阅来说,最怕的不是少一个前台按钮,而是下面两件事对不上:

  1. 数量口径没跟上:POS 明明已经卖出一周期,但订阅行 qty_invoiced 还像没开过;
  2. 时间口径没跟上:POS 明明已经完成本期收费,但 next_invoice_date 没推进,下一次自动开票节奏还是停在旧日期。

所以更准确地说,pos_sale_subscription 补的是一座桥:

让 POS 这条“门店即时成交链”,能安全进入企业版订阅的“周期计费链”。


一、这个模块为什么很小,却是企业版里非常关键的一块

pos_sale_subscription/__manifest__.py 很短,只声明了两件依赖:

  • pos_sale
  • sale_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()

先看它的思路,不看代码细节也能抓住重点:

  1. 先调用父类逻辑,拿到原本订阅体系认定的已开票数量;
  2. 再遍历每条 sale.order.line
  3. 把这条销售行关联到的 pos_order_line_ids 数量累计进去;
  4. 累计前还会通过 _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 里给了一个很小但很完整的场景:

  1. 先创建一个 monthly plan;
  2. 再创建一个 recurring_invoice=Trueavailable_in_pos=True 的产品;
  3. 创建一张 sale.order,它本身就是订阅订单: - plan_id 为月度计划; - start_date = 2021-01-01; - next_invoice_date = 2021-01-01
  4. 打开 POS session;
  5. 构造一张 POS 订单: - 订单行挂了 sale_order_line_id; - 同时挂了 sale_order_origin_id; - to_invoice = True; - 金额 250,数量 1;
  6. 调用 sync_from_ui([pos_order])
  7. 最后断言两件事: - 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_idsale_order_line_id。这说明它不是孤立零售交易,而是 对既有订阅销售链的承接

误解二:只要 POS 订单创建了,就该推进下次开票日

不对。

官方把推进动作放在 action_pos_order_paid(),说明只有付款事实成立,周期才应往前滚。

误解三:qty_invoiced 只该来自传统发票,不该算 POS

不对。

在订阅语义里,关键是“本期是否已经通过受控业务流程兑现”。企业版这里明确承认:POS 成交也能构成这类兑现。

误解四:数量累计就是简单相加

也不对。

源码特意做了 _convert_qty(..., 'p2s'),说明 POS 数量进入订阅口径前,要先翻译成销售侧计量语言。


八、实施排错时,最该先看哪几件事

如果你遇到“POS 明明卖了订阅,但下次开票日没动 / 已开票数量不对”,建议按这个顺序查:

  1. 先看产品本身:是否 recurring_invoice=True,并且允许在 POS 使用;
  2. 再看来源单:POS 行是否真的挂了 sale_order_origin_idsale_order_line_id
  3. 再看 POS 状态:订单是否真的走到了 action_pos_order_paid()
  4. 再看数量口径:POS 与销售行是否存在 UoM 转换差异;
  5. 最后看订阅计划plan_idnext_invoice_date、账期单位是否本来就配置异常。

这个顺序很重要。

因为很多人一上来就怀疑“订阅自动开票坏了”,但问题常常更早:POS 根本没有把这笔成交安全挂回那张订阅。


九、这套设计最值钱的地方,是它让“门店成交”不会破坏“周期收费秩序”

POS 的世界追求的是:

  • 快速成交;
  • 现场付款;
  • 收银员少做判断。

订阅的世界追求的却是:

  • 周期边界要准;
  • 已计费数量要准;
  • 下一次开票节奏要准。

这两个世界天然不一样。

pos_sale_subscription 最值得肯定的地方,就是它没有把 POS 收银粗暴地塞进订阅,而是精确补了两条桥:

  • 数量桥:POS 成交能进入订阅 qty_invoiced
  • 时间桥:POS paid 事实能推进 next_invoice_date

所以对这模块最准确的总结不是“POS 也能卖订阅了”,而应该是:

Odoo 企业版允许门店完成一笔订阅周期结算,但前提是这笔收银既能回写订阅数量事实,也能推进订阅时间事实。

理解到这一步,你就不会再把它看成一个“给 POS 多加个订阅商品”的小功能,而会把它看成:

企业版在 POS 与周期计费之间补上的最小、但非常关键的一致性桥梁。

DISCUSSION

评论区

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