先抓主线
在很多团队的直觉里,“包邮”最简单的做法就是:
- 找到 delivery line
- 把价格改成 0
- 结束
但标准 Odoo 没这么做。
sale_loyalty_delivery 的核心思路是:
- 保留原始 delivery line
- 再生成一条
reward_type == 'shipping'的负向奖励行 - 这条奖励行的金额通常等于运费,必要时受
discount_max_amount限制 - 同时把 delivery line 从积分门槛和积分累计逻辑里排除
- 并限制同一时刻只能存在一个 shipping reward
所以标准方案不是“改掉运费”,而是:
保留运费事实,再用奖励语义去抵消它。
这套设计对税、审计、促销门槛、积分累计都更稳。
这篇文章主要参考哪些源码
核心参考文件是:
/home/ubuntu/odoo-temp/addons/sale_loyalty_delivery/models/sale_order.py
最关键的方法包括:
_compute_amount_total_without_delivery()_get_no_effect_on_threshold_lines()_get_not_rewarded_order_lines()_get_reward_values_free_shipping()_get_reward_line_values()_get_claimable_rewards()
文件不大,但每个 override 都非常说明标准 Odoo 对“包邮”边界的态度。
第一层:为什么标准不直接把运费改零
因为 delivery line 本身是一个业务事实:
- 选了哪个承运逻辑
- 原始运费是多少
- 运费适用什么税
- 订单小计里原本发生了什么
如果你直接把运费改成 0,这些语义会被抹平。
标准方案保留 delivery line,再新增负向 reward line,意味着你在订单上能同时看到:
- 原本运费是多少
- 因为什么促销/积分被抵掉了多少
这在业务解释上明显更强,也更接近“补贴”而不是“历史上从来没产生过运费”。
第二层:包邮奖励行到底怎么构造
_get_reward_values_free_shipping() 是这篇文章的核心。
标准逻辑会:
- 找当前订单第一条 delivery line
- 取 delivery 产品税并按公司过滤
- 再通过
fiscal_position_id.map_tax()做税映射 - 计算
max_discount - 生成一条新的 reward line,其
price_unit为运费的负数,且不超过上限
这条 reward line 里通常会写入:
name = Free Shipping - xxxreward_idcoupon_idpoints_costproduct_id = reward.discount_line_product_idprice_unit = -min(max_discount, delivery_line.price_unit or 0)product_uom_qty = 1is_reward_line = Truetax_ids为映射后的税
这说明标准 Odoo 对包邮的理解非常明确:
- 不是改原运费行
- 而是新增一条“负向运费补贴行”
第三层:为什么包邮奖励也要做税映射
这正是很多自定义最容易做错的地方。
标准逻辑不会偷懒沿用“零税”或“随便复制一下税”,而是:
- 先拿 delivery 产品税
- 再按公司过滤
- 再过 fiscal position
原因很简单:
包邮奖励抵消的是运费这个商业事实,所以税语义也应该沿着运费上下文走。
如果你直接做一条无税负行,或者用错税,最后订单税额、开票口径、跨国销售税位都可能变形。
所以 reward line 不是只关心金额,还关心“你在抵消什么税语义下的运费”。
第四层:为什么 delivery line 会被排除出门槛和积分累计
标准还有两个配套 override:
1)_get_no_effect_on_threshold_lines()
它会把:
- delivery line
reward_type == 'shipping'的奖励行
都加入“不影响门槛”的集合。
2)_get_not_rewarded_order_lines()
它会把 delivery line 从可累计奖励积分的订单行里排除。
这两步特别重要。
因为如果不排除,你会得到一堆很怪的促销结果:
- 运费也被算进“满 100 包邮”的门槛
- 包邮本身又反过来产生积分
- 奖励叠奖励,逻辑递归失真
标准 Odoo 在这里的态度很明确:
- delivery 是可被补贴的对象
- 但它不应该成为进一步薅奖励的基础
第五层:为什么同一时间只允许一个 shipping reward
_get_claimable_rewards() 会做一道额外过滤:
- 如果订单上已经有
reward_type == 'shipping'的奖励行 - 那么新的 claimable rewards 里,shipping 奖励会被过滤掉
源码注释也很直白:
Allow only one reward of type shipping at the same time
这背后的业务原因很简单:
- 运费本身通常只有一条主对象
- 允许多个 shipping reward 同时叠加,很容易出现负运费、重复抵扣或规则冲突
所以标准不是“能领几个券就领几个”,而是把 shipping reward 当成一个互斥型优惠位。
第六层:为什么 _compute_amount_total_without_delivery() 还要特殊排除钱包/礼卡
这个方法表面看和包邮有点远,其实也在维护边界。
它在父类基础上,进一步扣掉:
coupon.program_type in ['ewallet', 'gift_card']
对应订单行的金额。
这说明标准在处理“总额里哪些属于 delivery、哪些属于奖励抵扣、哪些属于钱包/礼卡抵扣”时,是逐层拆口径的。
也就是说,包邮从来不是单点 hack,而是嵌在整套 loyalty / delivery / reward 的金额口径体系里。
新手最容易误解的 5 件事
1. 以为包邮就是把 delivery price 改成 0
标准不是这么做的,而是保留运费事实,再新增负向奖励行。
2. 以为包邮奖励不需要税
不是。标准会沿运费税语义做公司过滤和 fiscal position 映射。
3. 以为运费也应该参与满减门槛
标准明确把 delivery line 排除出 threshold 计算。
4. 以为包邮奖励还能继续帮你累计积分
标准也排除了 delivery line 的积分累计资格。
5. 以为多个包邮券可以同时叠加
标准只允许一个 shipping reward 同时生效。
实战调试顺序
如果你排查“为什么包邮后税额不对”,建议看:
- delivery line 的产品税是什么
- reward line 用的是不是映射后的税
- fiscal position 有没有变更税
- 自定义代码有没有直接写
tax_ids = False
如果你排查“为什么包邮门槛怪怪的”,重点看:
- delivery line 是否被纳入 threshold 计算
- reward line 是否被误算进门槛
- 是否有钱包/礼卡类 coupon 同时生效
- 是否已存在一个 shipping reward 导致后续奖励被过滤
一句话记忆法
Odoo 的包邮不是“把运费清零”,而是保留 delivery line 这条运费事实,再用一条带正确税语义的 shipping reward 负行去抵消它,同时把运费排除出门槛和积分累计。
DISCUSSION
评论区