订阅项目

Odoo 企业版订阅项目为什么不是“挂个项目字段”就完了:递归任务、续约承接与 Profitability 的 Subscriptions 区块讲透

很多人以为订阅单挂上项目后,无非就是项目里能看到一条销售记录。但从企业版 project_sale_subscription 源码看,订阅会同时改写任务生成、递归结束方式、upsell/renew 的项目承接,以及项目 Profitability 里收入归集的口径。真正难点不是“有没有关联项目”,而是“项目协作链”和“经营收入链”要不要跟订阅生命周期一起走。

企业 销售
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

很多团队第一次看到 Odoo 企业版里的订阅项目联动,会下意识把它理解成一件很轻的事:

  • 订阅单上多了一个 project_id
  • 项目里多了一个“订阅”入口
  • 差不多就是个关联跳转

project_sale_subscription 的源码并不是这么设计的。

它真正解决的是两条完全不同、但必须同时成立的链路:

  1. 交付协作链:订阅型服务要不要生成任务,任务要不要跟着订阅周期递归,订阅关闭后任务递归要不要停。
  2. 经营归集链:项目 Profitability 里,订阅收入该怎么显示,哪些金额算待开票,哪些金额算已开票,又如何避免和普通销售 / 发票收入重复统计。

所以这套设计的关键问题不是:

“项目能不能看到订阅单?”

而是:

“订阅这种持续履约对象,如何同时影响项目执行节奏和项目经营视图?”


一、订阅产品为什么能生成项目任务,但又不是所有订阅都该生成

project_sale_subscription/models/sale_order.py 里,sale.order 扩展了 _can_generate_service()

  • 普通销售单可以生成服务
  • 新建订阅可以生成服务
  • renewal 状态的订阅也可以生成服务
  • 但并不是所有订阅生命周期阶段都允许继续生成

这背后有个很现实的边界:

项目任务是“当前交付承诺”的落点,不是订阅整个历史生命周期的无限副产品。

如果一个订阅已经进入不该继续派生交付对象的状态,系统就不应该继续顺手生任务。

所以这里不是简单地看“是不是订阅”,而是看:

  • 这个订单当前还算不算有效交付阶段
  • 这个阶段生成任务会不会污染后续协作对象

这就是为什么官方用 _can_generate_service() 先卡一道门。


二、为什么订阅任务的重点不是“生成 task”,而是“生成 recurrence”

真正有意思的逻辑在 project_sale_subscription/models/sale_order_line.py_timesheet_create_task()

普通服务产品生成任务,重点通常是:

  • 任务建到哪个项目
  • 负责人、标题、销售行怎么关联

但订阅型服务不一样。

如果产品本身 recurring_invoice = True,并且项目 allow_recurring_tasks = True,Odoo 会在任务创建后额外生成 project.task.recurrence,并把任务标记为 recurring_task = True

这意味着官方的理解不是:

订阅卖出去一次,就生成一个任务卡片。

而是:

订阅卖出去的是一段持续履约节奏,任务只是这个节奏在项目协作层的落点。

也就是说,任务递归不是 UI 小功能,而是订阅周期在项目里的协作投影。


三、没有任务模板时,为什么 recurrence 要“模仿订阅计划”

源码里最值得看的地方,是它在没有现成任务 recurrence 模板时,不会放弃,而是自己拼一套 recurrence 参数:

  • start_dateorder.next_invoice_date
  • repeat_interval 取订阅 plan 的 billing_period_value
  • repeat_unit 取订阅 plan 的 billing_period_unit
  • 如果订阅有 end_date,就把 recurrence 设成 until
  • 没有 end_date,就设成 forever

这套设计很说明问题:

项目任务的递归节奏,不是独立配置的,它默认应该和订阅账期节奏对齐。

为什么要这么做?因为企业版订阅场景里,很多服务交付本来就和账期强绑定:

  • 每月一次巡检
  • 每季度一次交付审查
  • 每个账单周期一次客户回访
  • 每个续费周期一次 SLA 复盘

如果销售侧按月收费,项目侧却按随机频率复制任务,协作节奏和收入节奏就会脱钩。

所以当没有任务模板时,官方宁可“用订阅 plan 去驱动 task recurrence”,也不让任务变成一张静态卡片。


四、为什么有限期订阅结束时,任务递归也必须跟着收口

sale_order.py 里还有一段很关键:

  • _set_deferred_end_date_from_template() 会把订阅的 end_date 同步到任务 recurrence
  • set_close() 会先 _unlink_tasks_recurrence()
  • _action_cancel() 也会先 _unlink_tasks_recurrence()

这说明官方非常在意一个边界:

订阅结束,不只是“以后不再开票”,还意味着项目里的递归履约机制也该停。

很多团队做订阅定制时,容易只停财务链,不停协作链,结果就会出现:

  • 合同已经关闭
  • 客户已经不续费
  • 项目里每月服务任务还在自动长

这种问题最烦的地方在于,它不是显式报错,而是静悄悄制造假工作量。

官方这里的处理很干脆:只要订阅关闭或取消,就主动解除任务 recurrence。

也就是说,企业版把“订阅是否活着”视为任务递归是否还能继续存在的上游真相。


五、为什么 upsell 和 renewal 都承接项目,但不会产生同样的任务后果

_prepare_upsell_renew_order_values() 会把原订阅的 project_id 传给 upsell / renewal 订单。

这一步非常重要,因为它回答的是:

  • 新单是不是还属于同一交付上下文
  • 项目经营视图要不要连续
  • 客户协作是不是在同一个项目里延续

项目承接不等于任务一定重建

test_subscription_task.py 可以看出两个很关键的事实:

  1. upsell 确认后,不会给原订阅额外再造一批任务
  2. renewal 确认后,会为新的续约订阅生成新的任务

这背后的业务语义非常合理:

  • upsell 更像是在当前订阅关系里加量、加项、加范围,项目上下文延续,但不该无脑复制旧任务体系。
  • renewal 则更像进入了新的订阅周期,哪怕项目还延续,也应该允许新的周期性交付对象重新建立。

所以别把“都继承 project_id”理解成“任务行为也一样”。

真正被继承的是项目经营和交付上下文;真正被区分的是当前动作代表扩容,还是代表新周期重启


六、为什么项目 Profitability 要单独开一个 Subscriptions 区块

再看 project_project.py,你会发现企业版没有把订阅收入粗暴塞进已有销售收入里,而是明确扩展出一个新的 revenue section:subscriptions

官方做了几件事:

  • _get_profitability_labels() 新增 Subscriptions
  • _get_profitability_sequence_per_invoice_type() 给它单独排序
  • _get_profitability_items() 专门汇总订阅收入
  • action_profitability_items() 可以直接点进订阅对象

这说明在项目视角下,官方认为订阅收入不是普通销售收入的一个别名,而是一种独立经营口径

为什么必须独立?因为订阅收入和一次性销售收入在三个方面天然不同:

  1. 时间结构不同:订阅有周期性,不是一次确认就结束。
  2. 待开票逻辑不同:订阅可以持续存在 future value,不只是当前单次金额。
  3. 归集风险不同:如果直接混进普通 sale / invoice section,很容易重复或误判。

所以项目 Profitability 里单独开 Subscriptions,不是为了好看,而是为了让经营解释成立。


七、to_invoice 为什么不是简单等于 monthly amount

_get_profitability_items() 里最值得注意的,是它对 to_invoice 的计算非常克制,不是“把所有订阅月费加起来”这么简单。

它大致分两类:

第一类:没有订阅模板

如果没有 sale_order_template_id,系统更保守,基本按:

  • 当前 recurring_monthly
  • 结合币种汇率
  • 作为下一步可开票口径

这更像在说:

没有明确期限模板时,我先按当前持续月费估一个待开票值。

第二类:有有限期订阅模板

如果有模板,且模板不是 unlimited,系统会读取模板的 duration_value,把:

  • recurring_monthly
  • × 订阅模板持续期

折成一个更完整的待开票值。

这说明在项目经营视图里,Odoo 不是只看“下个月能开多少”,而是在某些明确有限期场景下,愿意把合同期内尚可实现的订阅价值一起体现在项目收入预期里。

这和普通 sale order 完全不是一个思路。


八、为什么订阅已开票收入要从 analytic / move line 反向取证

源码另一边会用 account.analytic.line,并通过 move_line_id.subscription_id 去识别哪些已入账数据属于订阅。

这个设计很关键。

因为项目 Profitability 要的是:

  • 经营上归属于该项目
  • 会计上已经落地
  • 且确实来自订阅链路

如果只看发票抬头、客户、项目字段,口径都不够稳。

官方等于是在说:

订阅“已开票”不是靠 UI 关联猜出来,而是靠会计分录 / 分析行回溯确认。

这样做的好处是,项目面板里的 invoiced 更接近真实落账结果,而不是“看起来像订阅收入”的展示值。


九、为什么还要显式把订阅排除出普通 sale / invoice 统计

如果你继续看同一个文件,会发现官方还专门覆盖了多个 domain:

  • _get_profitability_sale_order_items_domain()
  • _get_items_from_invoices_domain()
  • _get_sale_items_domain()

这些扩展本质都在做同一件事:

把订阅收入从普通销售 / 发票口径里排除,防止一笔业务被看两次。

这是这篇文章里最容易被低估的设计点。

因为很多自定义都能做到“项目里显示订阅金额”,但做不到“显示了以后还不重算”。

一旦没有这层排重,你会遇到:

  • Subscriptions 看起来有收入
  • 普通 Sales / Invoices 区块里又来一遍
  • 项目总收入被高估
  • 团队以为项目利润很好,其实只是口径重复

官方之所以值得学,不在于它加了一个 section,而在于它同时修好了入口排重


十、这套企业版设计真正回答了什么问题

把整条链路串起来,你会发现 project_sale_subscription 回答的是一个很企业级的问题:

当销售对象从一次性订单变成持续订阅时,项目既要跟得上交付节奏,也要看得懂收入节奏。

所以它并不是“项目上加个订阅按钮”这么简单,而是同时定义了:

  • 哪些订阅还能合法生成服务任务
  • 订阅周期如何投影成项目任务 recurrence
  • 订阅关闭时协作递归如何及时收口
  • upsell 和 renewal 如何承接同一项目上下文但产生不同任务后果
  • 项目 Profitability 如何单列 Subscriptions 并避免与普通收入重复

这也是为什么它虽然代码量不算巨大,却非常值得读。

因为它展示了一种很成熟的企业版思路:

持续收费的业务,不应该只改销售单据;它必须同步改写交付协作模型和经营归集模型。


最后一句话记住

Odoo 企业版订阅项目联动的本质,不是给订阅挂上 project_id,而是让订阅生命周期同时驱动“任务怎么递归”和“收入怎么进项目 Profitability”。

DISCUSSION

评论区

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