先说结论
Odoo 的优惠券 / 忠诚度机制,不是“满足条件就多一行负数”。
它真正做的是:
先判断你有没有资格拿奖励,再判断这次能领什么 reward,然后把 reward 落成可追踪的销售行,并继续维护 coupon points、history 和取消时的回滚。
所以它的本质不是折扣显示,而是奖励状态机 + 销售行落地机制。
这篇文章主要参考了哪些源码
主要看的官方实现有:
/home/ubuntu/odoo-temp/addons/sale_loyalty/models/sale_order.py/home/ubuntu/odoo-temp/addons/sale_loyalty/models/sale_order_line.py/home/ubuntu/odoo-temp/addons/sale_loyalty/models/loyalty_program.py/home/ubuntu/odoo-temp/addons/sale_loyalty/wizard/sale_loyalty_reward_wizard.py/home/ubuntu/odoo-temp/addons/sale_loyalty/tests/
这里最关键的源码信号是:
sale.order.line上有is_reward_line、reward_id、coupon_id- 销售单确认时会
_update_programs_and_rewards() - 同时还会
_add_loyalty_history_lines() - 取消订单时又会回收 points、删除 reward line、清理 coupon
这说明 reward 不是“算完就消失的临时结果”,而是订单生命周期的一部分。
为什么 Odoo 要把奖励落成一条 sale.order.line
在 sale_loyalty/models/sale_order_line.py 里,is_reward_line 的计算非常直接:
- 只要
reward_id存在,这条 line 就是 reward line
这看上去很普通,但其实非常关键。
因为它意味着 Odoo 并没有把折扣或赠品只存在内存里,而是把奖励显式建模成订单行。
这样做的好处是:
- 前台能展示
- 报价 / 订单能打印
- 开票逻辑能识别
- 删除、取消、复制时能有明确边界
如果 reward 只是一个“订单总额上的隐藏修正值”,很多后续链路都会变得很模糊。
所以 Odoo 选择把奖励实体化。
reward line 为什么不等于“折扣行”
新手很容易把 reward line 和 discount line 画等号。
但从源码看,reward 至少可能对应:
- 折扣
- 赠品
- gift card / eWallet 语义
所以 reward line 更准确的理解应该是:
它是“促销奖励在销售订单上的承载体”,折扣只是其中一种表现。
这也解释了为什么有些 reward line 看起来是负金额,有些却是赠送商品数量。
本质上它们都不是普通销售行,而是 program reward 的落地形式。
系统为什么要反复 _update_programs_and_rewards()
在 sale_loyalty/models/sale_order.py 里,这个方法调用出现得非常频繁:
- 确认订单前
- 打开奖励向导前
- 某些流程变化后
这说明 Odoo 并不把 reward 视作“一次性算完”的结果,而是视作:
- 订单内容变化后需要重新判资格
- 已有 reward 可能需要移除或重算
- 某些 coupon 可能已经变成 ghost coupon,需要清理
也就是说,促销不是静态附属物,而是会随着订单变化重新编排。
这正是很多团队在项目里最容易低估的地方:
你改了订单,不只是改金额,也可能改了促销资格本身。
为什么确认订单时还要写 loyalty history
action_confirm() 里不只是确认订单。
它还会:
- 检查 reward 是否有效
_update_programs_and_rewards()_add_loyalty_history_lines()- 最后再发送相关 coupon mail
这说明 Odoo 认为 loyalty 的关键不是“此刻订单长什么样”,而是:
- 这次订单消耗了什么 points
- 发出了什么奖励
- 未来能不能追溯这次奖励从哪来
所以 loyalty history 的角色,类似一层促销审计轨迹。
没有它,你后面很难回答这些问题:
- 这张单到底用了哪张券
- 为什么会有这条赠品
- 这次积分是怎么被扣掉的
为什么取消订单时要做这么多清理动作
在 _action_cancel() 里,系统会做一串回滚:
- 删除相关 history
- 把积分变化扣回去
- 删除 reward line
- 清理不该留下的 coupon / coupon_point
这其实很说明设计思想。
Odoo 不把促销当成“你取消单了也无所谓”的外围信息,而是把它当作订单状态的一部分。
换句话说:
如果订单没成交,这次促销消耗、赠送和积分变更,也应该尽量跟着回滚。
这才是业务一致性。
为什么全局折扣计算这么复杂
在 _discountable_amount()、_discountable_order() 这些逻辑里,系统会细分:
- 哪些 line 可参与折扣
- 固定税是否应该被折
- gift card / eWallet 是否按整单总额算
- 税组怎么聚合
这说明 Odoo 处理的不是“总金额乘个折扣率”这么简单。
它真正要回答的是:
- 这条奖励适用于订单级还是行级
- 哪些税语义应该被折进去
- 哪些金额只是支付工具,不应和普通促销混为一谈
所以 loyalty 逻辑复杂,并不是实现过度,而是因为它试图保持会计和销售语义一致。
新手最容易误解的 5 件事
1. 以为优惠券只是自动加一条负数行
实际上背后还有 reward、coupon、points、history 这些状态。
2. 以为 reward line 就等于折扣行
reward line 也可能是赠品或支付型奖励的承载体。
3. 以为促销资格算一次就结束
订单变化后,资格和 reward 需要重算。
4. 以为取消订单只要删掉折扣展示
积分、history、coupon 使用状态也要一起回滚。
5. 以为 loyalty 只影响销售前台
其实它会继续影响开票、邮件、历史追踪和后续审计。
实战里该怎么理解这套机制
我会把它拆成四层:
第一层:资格判断
客户、规则、金额、产品范围,决定你有没有资格。
第二层:奖励选择
有资格之后,决定本次能领哪种 reward。
第三层:订单落地
把 reward 落成 sale.order.line,让后续链路都认得它。
第四层:历史与回滚
确认时写 history,取消时做回滚,保证积分和奖励不漂移。
如果你按这四层理解,就不会再把 loyalty 看成“页面上一点点折扣魔法”。
一句话记忆法
Odoo Loyalty 不是自动打折,而是在先判资格、再发奖励、再落订单行、最后维护历史与回滚。
DISCUSSION
评论区