很多团队第一次做 Odoo POS 会员玩法时,脑子里默认的模型是:
- 前台顾客用了 100 积分;
- 系统把积分字段减掉;
- 如果送了一张券,就新建一张卡。
这个想法太直线了。
从 /home/ubuntu/odoo-temp/addons/pos_loyalty/models/pos_order.py 看,Odoo 真正在保护的是一整条 卡券生命周期:先校验能不能用,再落订单,再建卡/绑卡/记历史,最后把结果回传给前台缓存。也就是说,POS loyalty 不是“改个余额”,而是“先防错、再记账、再回写状态”的闭环。
结论先说
如果你要理解 Odoo POS 里的积分、礼品卡、电子钱包,先记住三句话:
- 前台不能自说自话决定优惠一定成立,后端会再验一次;
- 新卡和老卡不是同一类对象,Odoo 会先做映射、去重、合并;
- 历史记录不是附属品,
loyalty.history才是后续审计、补偿和排错的关键证据。
第一层边界:为什么结账前还要再验一次 coupon
validate_coupon_programs() 做的第一件事,不是发券,而是验券。
它会先把前台带来的 point_changes 转成以后端卡片 ID 为准的数据,再检查:
- 这些 coupon 是否真的存在;
- 是否仍属于 active program;
- 当前卡里积分是否足够抵扣;
- 前台准备新生成的 code 在数据库里是否已经存在。
这一步非常像收银台前的“最后闸机”。
因为 POS 前端的缓存可能已经旧了:
- 顾客刚在别的门店用过同一张卡;
- 这张券对应的 program 已失效;
- 前台预生成的 code 已被别的终端抢先卖出;
- 本地缓存还以为有积分,但真实余额已经不够。
所以 Odoo 的态度很明确:
优惠是否成立,最终以后端校验为准,不以前端展示为准。
这也是为什么很多“前台明明能选券,提交时却提示失效”的案例,并不是 bug,而是后端在拦住过期事实。
第二层边界:为什么要先处理“已存在卡”再决定是否建新卡
confirm_coupon_programs() 真正落库前,先调用了三步预处理:
_check_existing_loyalty_cards()_remove_duplicate_coupon_data()_process_existing_gift_cards()
这三步特别能体现 Odoo 的业务观。
1)老客户已经有卡,就不要再乱长一张
_check_existing_loyalty_cards() 会检查:如果某个 partner_id 在同一 loyalty / ewallet program 下已经有卡,就把本次前台传来的临时 key 改指向已有卡。
这意味着 Odoo 优先维护的是:
- 一个客户在同一计划下尽量持续使用同一张卡;
- 同类权益尽量累计,不轻易碎成多张匿名卡。
这对实施很重要。否则门店越做越久,最后不是积分体系,而是“一个客户名下十几张半死不活的卡”。
2)同一订单重复回放,不应该重复发历史
_remove_duplicate_coupon_data() 会查 loyalty.history 是否已经存在同一订单的历史线。
这一步看似不起眼,实战里却非常关键。因为 POS 本来就存在:
- 网络抖动后重试;
- 离线回传重复点击;
- 同步恢复时订单多次重放。
如果没有这层去重,一张单可能:
- 重复加积分;
- 重复发券;
- 重复生成礼品卡 PDF;
- 把客户余额冲得越来越离谱。
所以这里的核心思想是:
POS loyalty 必须具备“重复提交幂等防线”,不能把每次重放都当成新业务。
3)礼品卡不是永远新建,很多时候是“补绑定”或“补归属”
_process_existing_gift_cards() 处理 gift card 的方式也很有代表性。
如果系统能通过 code 或 coupon_id 找到已有 gift card,它并不会急着新建,而是优先做这些补全:
- 卡还没绑客户,但这次订单有客户,就把
partner_id补上; - 卡还没挂 source order,就把
source_pos_order_id补上; - 如果本次实际消费/充值与卡内 points 不一致,就更新 points 并写历史。
这说明在 Odoo 看来,礼品卡不是一次性打印凭证,而是一个会持续演化的权益载体。
第三层边界:为什么前台负 ID 能变成后端真实卡 ID
在 confirm_coupon_programs() 里,前台尚未存在的 coupon 常常先用负 ID 占位。
后端落库后,会建立 coupon_new_id_map,把:
- 前台临时 ID
- 后端新建真实 ID
对应起来,再回传 coupon_updates 给前台。
这解决的是 POS 场景里一个很现实的问题:
前台在一张订单里,可能先“假想”出若干将要创建的卡券,但只有订单真的落库后,它们才配拥有数据库里的正式身份。
如果你忽略这层映射,前端会很容易出现:
- 订单行绑了不存在的 coupon;
- 小票打印拿不到真实 code;
- 后续继续消费时还指着旧临时 ID。
reward line 为什么还要回填 coupon_id
源码里会把订单行按 reward_identifier_code 归组,再把对应的 coupon_id 回填到真正的 reward line 上。
这一步的意义不是“字段填完整”这么简单,而是把三层对象串起来:
- 顾客用了哪个奖励;
- 这条奖励来源于哪张卡;
- 这张卡属于哪个 program。
没有这层链路,你后面就很难回答:
- 这次优惠到底消耗了谁的积分;
- 赠券是哪张订单发出去的;
- 某张 gift card 为什么余额会变。
loyalty history 为什么是整个机制的审计中轴
add_loyalty_history_lines() 会把每张卡在这张单上的 spent / won 分别记成 history line。
它不是可有可无的日志,而是后续最关键的解释层。因为真实门店里最常见的问题不是“系统会不会发券”,而是:
- 为什么这个客户积分少了;
- 为什么这张卡多了 50;
- 为什么礼品卡突然绑定到某个客户;
- 为什么同一订单打印了券但看起来没加积分。
这些问题最后都要回到历史线去追。
你可以把它理解成:
余额是结果,history 才是证据。
最容易误解的四件事
误解一:POS loyalty 就是改 points
不对。
points 只是余额表象。真正完整的流程包括校验、建卡、映射、绑订单、绑订单行、记历史、回写前端缓存。
误解二:新券一定新建卡
不对。
对 loyalty / ewallet,Odoo 会优先复用同客户同计划已有卡;对 gift card,也会先找已有卡并补归属。
误解三:同步重试无所谓,最多重复发一次
这恰恰很危险。
POS 场景最怕的就是“看起来只是重试,实际上把权益重复发放”。_remove_duplicate_coupon_data() 就是在挡这种事。
误解四:打印出来就说明后端都落对了
不一定。
正确判断应该看:
loyalty.card是否正确创建/更新;coupon_updates是否回传前台;loyalty.history是否形成证据链。
实战排错顺序
遇到“积分扣错、礼品卡重复发、优惠券失效、客户卡余额不对”时,建议按这个顺序查:
- 订单提交前,前台使用的 coupon/code 是否还是最新状态;
validate_coupon_programs()返回的是哪类失败:无效券、积分不足,还是 code 冲突;- 同一个 partner 在对应 program 下是否已有卡;
- 是否存在订单重复同步,导致同单多次尝试发券;
loyalty.history是否已经存在同一订单的历史,触发了去重;- gift card 是应新建,还是其实命中了已有卡;
- reward line 有没有正确回填
coupon_id; - 前端是否拿到了新的
coupon_updates并刷新本地缓存。
最后的结论
Odoo POS 的 loyalty 机制,本质上解决的不是“积分数字怎么变”,而是:
- 顾客权益能不能被安全使用;
- 新旧卡怎样在一次结账里正确衔接;
- 重试、离线、重复提交时怎样避免把权益体系冲坏。
所以这套设计最值得记住的一句话是:
在 Odoo POS 里,积分、礼品卡和优惠券都不是前台临时 UI 状态,而是有校验、有身份、有历史证据的长期对象。
DISCUSSION
评论区