很多门店会把 POS 里的 loyalty、reward、coupon 想成一个极简流程:
- 顾客出示卡券或优惠码,
- 收银员扫进去,
- 系统扣点数或减金额,
- 如果触发新奖励,再生成一张新券。
这个流程没错,但太薄了。
Odoo 真正关心的问题比“能不能抵扣”多得多:
- 这张券是不是还在有效期;
- 这个 program 有没有到启用日期;
- 当前 pricelist 能不能用这张券;
- 余额够不够领取某个 reward;
- 本单产生的新券是要当场打印,还是只更新卡余额;
- 什么信息可以给小票,什么信息不该直接暴露。
所以更准确的理解是:
Odoo POS 的 loyalty 不是简单抵扣,而是一套“先校验可领取性,再决定如何核销和如何发放”的时机控制。
先说结论
结合 pos_loyalty/models/pos_config.py 与 pos_loyalty/models/pos_order.py,可以先记住五个关键点:
- 顾客输入 coupon code 后,系统先判断这张券是否“现在可用”。
- “可用”不只看余额,还看 program 是否启用、是否过期、是否匹配当前 pricelist。
- 本单获得的新奖励,不一定都应该把 code 直接打印给顾客。
- 礼品卡、电子钱包、未来生效券在输出策略上并不相同。
- 核销成功不是终点,Odoo 还要把更新后的 points、program usage 和新券信息回传给 POS 缓存。
顾客输入 code 时,系统检查的不是“有没有这张券”而已
pos_config.py 里的 use_coupon_code() 很能代表 Odoo 的设计思路。
它先按 code 查 loyalty.card,再继续判断:
- 卡券所属 program 是否仍然 active;
- coupon 本身有没有
expiration_date; - program 有没有整体结束日期
date_to; - program 是否还没开始
date_from; - 使用次数是否达到上限;
- 当前 program 有没有任何 reward 还能被领取;
- 当前价目表是否在 program 允许范围内。
这说明 POS 里的“扫券”根本不是被动读码。
真正发生的是:
收银台在问后台:这张券在此时此地、对这个顾客、在这个价目表下,到底还有没有资格参与这次结账。
所以门店现场常见的几种抱怨:
- “明明是这张券,为什么扫不进去?”
- “昨天还能用,今天怎么失效?”
- “积分还在,为什么说没有 reward 可领?”
都不一定是 bug,很可能只是 Odoo 在执行这些时机校验。
“有点数”不等于“现在能领到奖励”
源码里一个很容易被忽略的判断是:
not any(r.required_points <= coupon.points for r in program.reward_ids)
它的业务含义非常直接:
- 券上有 points,
- 不等于当前就一定有 reward 可领。
因为 reward 是 program 规则层的对象,不是 points 本身。
换句话说,Odoo 保护的是两层边界:
- 余额层:券上到底还有多少 points;
- 规则层:当前 program 里有没有满足条件的 reward。
这对实施顾问和门店主管都很重要。
很多时候“顾客还有积分却兑不了”并不是系统坏了,而是 reward 规则已经改了、停了,或者当前条件不满足。
pricelist 也是核销边界的一部分
use_coupon_code() 还会明确检查:
elif program.pricelist_ids and pricelist_id not in program.pricelist_ids.ids:
这意味着 loyalty 在 POS 里并不是一个脱离商品价格体系独立运行的外挂。
它会受到当前价目表的边界影响。
这在现实门店里非常常见:
- 某些会员券只允许零售价门店使用;
- 员工价、批发价、特殊活动价目表下,某些券不允许叠加;
- 同一顾客在不同店、不同终端、不同价格体系里,能用的奖励不完全一样。
所以当门店说“这张券在 A 店能用,B 店不能用”,先不要急着怀疑码错了。很可能是 program 和 pricelist 的边界在起作用。
本单奖励什么时候算“发出去”
confirm_coupon_programs() 处理的是订单已经创建之后的事情。
这一步里,Odoo 才真正:
- 创建需要新发的 card;
- 把已有 card 的 points 更新掉;
- 把 reward line 回写到对应 coupon;
- 生成
coupon_updates、program_updates、new_coupon_info。
这意味着一个很关键的时机边界:
前台看见“可领”,不代表奖励已经正式发出;真正发出发生在订单落库之后。
这也是为什么一些门店会感觉:
- 收银员前面已经选了 reward,
- 但最终订单失败后,奖励并没有真的发出去。
因为 Odoo 不会在“只是选中、还没确认”的阶段就让业务对象真正落地。
为什么有些新券要打印,有些不该打印
confirm_coupon_programs() 返回 new_coupon_info 时,有一段特别关键的过滤:
- 只考虑
applies_on == 'future'的场景; - gift card 和 ewallet program 不把 code 放进这里打印。
这非常能体现 Odoo 的业务边界感。
它不是“只要发了新奖励,就把所有 code 全打印出来”。
而是会区分:
1)未来使用型奖励
这种奖励通常是:
- 本单满足条件后获得;
- 下一单再用;
- 小票上应该清楚告诉顾客获得了什么、何时到期、code 是什么。
所以它进入 new_coupon_info 很合理。
2)礼品卡 / 电子钱包
源码明确写着:
- 对
gift_card、ewallet,不要在这里把 coupon code 打印到小票。
这不是一个小细节,而是很强的业务判断。
因为礼品卡和电子钱包往往更像:
- 持久账户,
- 存值对象,
- 后续还会反复使用的卡资产。
它们的 code 暴露策略,和“送你一张下次使用的促销券”不是一回事。
换句话说:
Odoo 不仅在管理优惠金额,也在管理哪些奖励信息适合在收银台公开。
核销完成后,为什么还要回传 updates
很多人会觉得:
- 后台都已经确认了,
- 前台直接继续卖不就行了?
但 confirm_coupon_programs() 还会把这些结果回给 POS:
coupon_updatesprogram_updatesnew_coupon_infocoupon_report
原因很简单:POS 不是单纯渲染页面,它需要本地继续工作。
如果前台拿不到这些更新,很容易出现:
- 顾客刚用过的券,前台还显示旧 points;
- 新发的 reward 小票没法正确展示;
- 某个 program 的 usage 计数没更新,下一笔判断又错;
- 收银员以为奖励已发,但本地缓存还停在旧状态。
所以 loyalty 在 POS 场景下真正难的不是“算一下减多少”,而是确认后如何让本地缓存和后台事实重新对齐。
常见误解
误解 1:只要 code 能查到,就一定能用
不对。
还要看激活状态、有效期、使用上限、价目表和 reward 条件。
误解 2:有积分就一定能领 reward
不对。
积分只是余额,reward 领取还受 program 规则控制。
误解 3:新奖励生成后应该全部打印出来
不对。
未来使用型奖励、礼品卡、电子钱包在输出策略上就不同。
误解 4:前台一选 reward,就已经发券成功
不对。
真正的创建、更新和回传发生在订单确认之后。
实战排查顺序
如果你遇到 loyalty 领取或核销异常,建议按这个顺序查:
- 这张 card / coupon 是否真的存在且 program 仍 active;
- coupon 或 program 是否过期、未开始或超出使用次数;
- 当前价目表是否被 program 允许;
- 当前 points 是否足以覆盖任何一个 reward;
- 该奖励是本单即用还是 future 奖励;
- 确认后是否返回了
coupon_updates与new_coupon_info; - 收银台的问题到底是后端没确认,还是前端缓存没更新。
最后的理解方式
如果只记一句话,我会建议记这句:
Odoo POS 的 loyalty 核心不是“扫码抵扣”,而是“判断奖励何时可领、何时可用、何时该发、何时该显示”。
这套时机控制,决定了:
- 顾客此刻能不能核销;
- 收银员能不能安全确认;
- 小票上该不该暴露新 code;
- 前后台的积分状态能不能保持一致。
这才是 POS loyalty 真正难也真正值钱的地方。
DISCUSSION
评论区