很多团队第一次看到 Odoo 企业版里的订阅项目联动,会下意识把它理解成一件很轻的事:
- 订阅单上多了一个
project_id - 项目里多了一个“订阅”入口
- 差不多就是个关联跳转
但 project_sale_subscription 的源码并不是这么设计的。
它真正解决的是两条完全不同、但必须同时成立的链路:
- 交付协作链:订阅型服务要不要生成任务,任务要不要跟着订阅周期递归,订阅关闭后任务递归要不要停。
- 经营归集链:项目 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_date取order.next_invoice_daterepeat_interval取订阅 plan 的billing_period_valuerepeat_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同步到任务 recurrenceset_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 可以看出两个很关键的事实:
- upsell 确认后,不会给原订阅额外再造一批任务。
- 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()可以直接点进订阅对象
这说明在项目视角下,官方认为订阅收入不是普通销售收入的一个别名,而是一种独立经营口径。
为什么必须独立?因为订阅收入和一次性销售收入在三个方面天然不同:
- 时间结构不同:订阅有周期性,不是一次确认就结束。
- 待开票逻辑不同:订阅可以持续存在 future value,不只是当前单次金额。
- 归集风险不同:如果直接混进普通 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
评论区