关于 spree-promotions
spree-promotions 是面向 Spree 开发者的促销配置与扩展指南,涵盖规则、折扣动作、计算器及优惠码。它详述购物车重算中的竞争、持久化与结账行为,并说明如何添加自定义扩展。
主要功能
- 01配置自动促销与优惠码促销
- 02定义促销规则、动作与计算器
- 03处理折扣竞争、分摊与持久化
- 04管理批量优惠码及结账应用
- 05注册自定义规则、动作和计算器
适用场景
- 01为购物车设置门槛或商品促销
- 02创建单次使用的批量优惠码
- 03开发自定义折扣规则或计算器
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-promotions安装后在 Claude Code 中输入/spree-promotions确认已加载。
详细介绍
Spree 促销
促销(promo_)是一场活动:规则决定是否适用,动作决定如何生效。添加到购物车/订单中的内容是折扣(Spree::Discount,disc_)——一条金额记录,即使促销被编辑或删除,也会保留。
Promotion (belongs_to :store; kind coupon_code|automatic; starts_at/expires_at; usage_limit; match_policy all|any)
├── PromotionRule (prorule_, STI) — eligible?(cart_or_order)
├── PromotionAction (pact_, STI) — discount_scope + compute_amount, or a side effect in perform
│ └── Calculator (calc_) — how much (FlatRate, FlatPercentItemTotal, PercentOnLineItem, …)
└── CouponCode (coupon_) × n — multi-code promotions; state unused|used
Discount — kind 'promotion' | 'manual'; attached to exactly one LineItem or Fulfillment; amount ≤ 0
促销如何生效
每次购物车发生变更时,都会运行 Spree::Carts::RecalculateTotals,它会运行 Spree::Adjusters::Promotion:
- 候选项 — 与购物车关联的促销、购物车已保存的
coupon_code促销,以及正在激活的自动促销。系统会逐一检查:有效时间范围、使用次数限制,然后根据match_policy检查规则。 - 竞争,只保留胜出者 — 每个竞争组中,金额最负的促销胜出(平局时选择动作最新的促销):
:line_item动作按每个商品项竞争(仅限通过规则actionable?检查的商品项),:fulfillment动作按每个配送项竞争,:order动作在整个订单范围内竞争;胜出者的折扣会按比例分摊到商品项(使用最大余数法)——不会创建关联到订单的折扣记录。
- 持久化 — 写入胜出者的记录,并限制折扣金额,确保商品项/配送项的金额不会低于零;删除过时的促销记录。落败者不会被存储,只会在下次重新参与竞争。随后会根据折后金额估算税费。
- 下单 —
Spree::Order创建后,其折扣记录会被冻结;使用次数会计入对应促销。
6.0 中不支持叠加——同一商品项上的两个促销不会合并。
创建促销
// "20% off orders over $100 with code SUMMER20"
await admin.promotions.create({
name: 'Summer Sale',
kind: 'coupon_code',
code: 'SUMMER20',
starts_at: '2027-06-01T00:00:00Z',
expires_at: '2027-09-01T00:00:00Z',
rules: [{ type: 'item_total', preferences: { amount_min: 100 } }],
actions: [{ type: 'create_adjustment',
calculator: { type: 'flat_percent_item_total', preferences: { flat_percent: 20 } } }],
})
await admin.promotions.rules.create('promo_xxx', { type: 'first_order' })
await admin.promotions.actions.create('promo_xxx', { type: 'free_shipping' })
spree api get /promotion_rules/types # registered rule types + preference schemas
spree api get /promotion_actions/types
spree api get "/promotion_actions/calculators?type=create_adjustment"
kind:automatic(无需代码,规则匹配时自动适用)或coupon_code。设置code会将自动促销切换为coupon_code;代码会以小写形式存储和匹配。- 多代码(批量)促销:
multi_codes: true, number_of_codes: 1000, code_prefix: 'SUMMER'会生成Spree::CouponCode记录(大批量代码会通过后台任务生成);每个代码只能使用一次(unused→used),usage_limit不适用。只有使用该代码下单时,代码才会被视为已使用——将其应用到购物车仅会暂时占用它(coupon_code.holder)。使用GET /promotions/:id/coupon_codes列出这些代码。 - API 中的
type值采用api_type简写(item_total、create_item_adjustments、flat_rate)——切勿使用 Ruby 类名。 - 促销仅属于单个商店(
current_store.promotions)。在请求上下文之外,请设置Spree::Current.store。
内置规则
currency、country、channel、market、item_total、product(any/all/none)、category(按子项计数;Spree::Promotion::Rules::Taxon 是已弃用的别名)——它们的 product_ids= / category_ids= 会通过促销所属的商店解析带前缀或原始 ID,因此,属于其他商店的记录会引发 RecordNotFound(并导致验证失败);自定义规则若要关联记录,可复用 ids_within_store(ids, promotion.store.products) — option_value、user、customer_group、first_order、user_logged_in、one_use_per_user。
内置操作
操作(api_type) | discount_scope | 默认计算器 | 说明 |
|---|---|---|---|
create_adjustment — 订单折扣 | :order | FlatPercentItemTotal | 还支持 FlatRate、FlexiRate、TieredPercent、TieredFlatRate。按比例分摊到各个商品行。 |
create_item_adjustments — 商品折扣 | :line_item | PercentOnLineItem | 还支持 FlatRate、FlexiRate。仅适用于符合条件的商品。 |
free_shipping | :fulfillment | — | 该行会以零金额保留;order.has_free_shipping? 用于检查该行是否存在。 |
create_line_items — 免费赠品 | :line_item | — | 将购物车补足到赠品数量(会检查库存),并且添加一条商品行级折扣,覆盖赠送的数量(仅限这些数量——同一款式的额外数量仍需付费)。不要与用于“支付赠品费用”的订单折扣搭配使用,否则会重复打折。规则决定是否适用,而不是决定折扣支付哪些商品行;赠品本身不能满足规则。赠送的数量不计入促销的计算基数:包括 ItemTotal 门槛(也包括该促销自身的门槛,因此付费商品低于门槛时,赠品会被收回),以及订单级百分比折扣的计算基数(Purchase::Totals#amount 会减去 gift_amount)。购物者付费购买的同款商品可像任何商品一样计入其他促销。购物者已持有该款商品时,会将其免费,而不是再添加一件重复商品。促销不再适用时,购物车重新计算会撤销折扣并移除赠品数量(仅适用于实际加入购物车的促销,因此永远不会删除购物者自行购买的该款商品)。 |
计算器带有货币属性。 FlatRate(以及其他基于金额的计算器)在 preferred_currency 与购物车货币不匹配时会返回 0 — 每种货币应设置一个促销/计算器,否则某些客户会遇到促销悄然失效的情况。
结账时使用优惠码
const cart = await client.carts.discountCodes.apply(cart.id, 'SUMMER20', { spreeToken: cart.token })
await client.carts.discountCodes.remove(cart.id, 'SUMMER20', { spreeToken: cart.token })
优惠码会存储在 cart.coupon_code 中(并复制到 order.coupon_code)。如果购物车暂时不符合条件(例如未达到最低金额),优惠码仍会保留在购物车中,并显示 coupon_code_not_eligible 警告;当购物车在重新计算时首次符合条件后,优惠码便会生效。礼品卡使用自己的端点(carts.giftCards)— 请参阅 spree-payments。
批量(一次性)优惠码会在保存到购物车时立即锁定,即使购物车尚未符合条件;只有持有该优惠码的购物车会获得折扣(不持有该码的购买会因 Spree::CouponCode.held_by 而无法通过 Promotion#eligible? 检查)。优惠码被替换或移除后会释放。对于弃置的购物车,如果另一个未结购物车持有优惠码,下一个提交该码的人会接管它(旧购物车会失去折扣),而最先下单的购物车会保留该码。处于结账完成流程中的购物车(completion_claimed?)、已完成的购物车和草稿订单都不会释放所持有的优惠码 — 新提交者会收到 coupon_code_used,优惠码已被已下单订单用掉(或正在应用)时也是如此。失去优惠码的购物车(cart.coupon_code_unavailable?)会在下一次读取或更改购物车的 Store API 请求中移除该码,并显示 coupon_code_unavailable 警告。carts.complete 会拒绝此类购物车并返回 coupon_code_unavailable,以免收取高于购物者看到的金额。服务器端的处理程序为 Spree.coupon_handler(Spree::PromotionHandler::Coupon)。
手动折扣
员工可以为已下单的订单添加不关联促销的折扣(kind: 'manual'):
await admin.orders.discounts.create('or_xxx', { label: 'Goodwill', value: '10', value_type: 'percent', line_item_id: 'li_xxx' })
await admin.orders.discounts.create('or_xxx', { label: 'Price match', value: '15.00', value_type: 'flat' })
只有手动折扣行可以编辑或删除 — 促销折扣行会返回 422。
自定义规则
# app/models/spree/promotion/rules/minimum_quantity.rb
module Spree
class Promotion
module Rules
class MinimumQuantity < Spree::PromotionRule
preference :quantity, :integer, default: 5
# MUST accept both — promotions are evaluated on the Cart through checkout
def applicable?(promotable)
promotable.is_a?(Spree::Cart) || promotable.is_a?(Spree::Order)
end
def eligible?(promotable, _options = {})
return true if promotable.line_items.sum(&:quantity) >= preferred_quantity
eligibility_errors.add(:base, "Add at least #{preferred_quantity} items")
false
end
# optional: restrict which line items item-level actions discount (default true)
# def actionable?(line_item) = …
end
end
end
end
- 对可促销对象不
applicable?的规则会被跳过,而不是判定失败——仅针对Spree::Order的规则会在购物车上被忽略,因此促销可以在不满足你的条件的情况下应用(如果这是唯一一条规则,则会应用于所有人)。 - 通过
promotable.customer读取买家,通过item_total读取总额(如果消费门槛不应计入免费赠品,则减去promotable.gift_amount(promotion: promotion),与ItemTotal的处理方式一致),通过line_items读取商品行——这是 Cart 和 Order 共享的Spree::Purchase::*接口。 - 偏好设置(
:string、:integer、:decimal、:boolean、:array)会生成相应的后台表单。基于关联的配置(例如brand_ids)还需要在子类中设置self.additional_permitted_attributes = [brand_ids: []],以便 Admin API 允许该属性。
自定义动作
折扣动作声明折扣范围和折扣金额;调整器负责处理竞争、限额、写入和清理:
# app/models/spree/promotion/actions/tiered_discount.rb
module Spree
class Promotion
module Actions
class TieredDiscount < Spree::PromotionAction
preference :currency, :string, default: 'USD'
def discount_scope = :order # :line_item | :fulfillment | :order
def perform(options = {}) # connects the promotion; returns true if it yields a candidate
apply_via_adjuster(options)
end
# Called with the adjustable matching discount_scope (cart/order, line item, or fulfillment).
# Return a NEGATIVE amount, or 0 for "no discount".
def compute_amount(order)
return 0 unless order.currency == preferred_currency
discount = if order.item_total >= 100 then 25 elsif order.item_total >= 50 then 10 else 0 end
-[discount, order.item_total].min
end
end
end
end
end
非折扣动作(忠诚度积分、通知)将 discount_scope 留空,并在 perform(options) 中执行操作(options[:order]、options[:promotion]),应用成功时返回 true;也可以选择实现 revert(options)。请确保副作用幂等——重复激活时可能会多次调用 perform。
自定义计算器
class MyApp::Calculator::PerItemCap < Spree::Calculator
preference :amount, :decimal, default: 0
preference :currency, :string, default: -> { Spree::Store.default&.default_currency || 'USD' }
def self.description = 'Flat amount per unit, capped at line amount'
def compute(line_item) # item actions pass a line item; order actions pass the cart/order
return 0 unless line_item.currency.casecmp?(preferred_currency)
[preferred_amount * line_item.quantity, line_item.amount].min
end
end
计算器返回正数;动作会将其取负。
注册(集中在一处)
Core 会在自己的 after_initialize 中重新赋值这些注册表——在 initializer 顶层追加,或在 to_prepare 中追加,都会被清除。请始终:
# config/initializers/spree.rb
Rails.application.config.after_initialize do
Spree.promotions.rules << Spree::Promotion::Rules::MinimumQuantity
Spree.promotions.actions << Spree::Promotion::Actions::TieredDiscount
Spree.calculators.promotion_actions_create_item_adjustments << MyApp::Calculator::PerItemCap
# order-level calculators go in Spree.calculators.promotion_actions_create_adjustments
end
然后添加语言环境键——后台的促销编辑器会根据 /types 端点构建选择器和偏好设置表单,因此无需编写 UI 代码:
# config/locales/en.yml
en:
spree:
promotion_rule_types:
minimum_quantity:
name: Minimum quantity
description: Cart must contain at least N items
promotion_action_types:
tiered_discount:
name: Tiered discount
description: $10 off over $50, $25 off over $100
键是类的 api_type(去掉命名空间前缀并转换为下划线形式)。重命名类后,如需保持 API 名称稳定,可重写 def self.api_type = 'min_qty'。
自定义调整器(非促销类费用/折扣)
忠诚度定价、礼品包装费、支付附加费都没有规则或代码——它们属于调整器:继承 Spree::Adjusters::Base,实现幂等的 update,在 order(结账期间的购物车)上写入或移除自己的记录,并在 after_initialize 中通过 Spree.adjusters << MyAdjuster 注册。不要写入 kind: 'promotion' 的记录(促销调整器会删除它未写入的记录);Spree::Discount 仅允许 promotion/manual 类型,且必须关联到商品行或配送记录。费用和总额见 spree-order-totals。
测试
RSpec.describe Spree::Promotion::Rules::MinimumQuantity do
let(:rule) { described_class.new(preferred_quantity: 3) }
let(:cart) { create(:cart_with_line_items, line_items_count: 1) }
it 'is applicable to carts' do
expect(rule.applicable?(cart)).to be(true)
end
it 'needs 3 units' do
expect(rule.eligible?(cart)).to be(false)
cart.line_items.first.update!(quantity: 3)
expect(rule.eligible?(cart.reload)).to be(true)
end
end
Factory::promotion、:promotion_with_item_adjustment、:promotion_with_order_adjustment、:promotion_with_item_total_rule、:free_shipping_promotion、:cart_with_line_items。请针对购物车测试资格,而不只是订单。
“促销未生效”
- 是否已启用?检查
starts_at/expires_at;检查promotion.usage_limit_exceeded?(cart);对于优惠券促销,确认cart.coupon_code是否匹配(代码会转换为小写)。 - 规则:检查
promotion.eligible?(cart),然后检查promotion.eligibility_errors。自定义规则的applicable?是否针对Spree::Cart? - 货币:金额计算器在遇到其他货币时会返回 0。
- 是否输掉了竞争?其他促销在同一商品行/配送记录/订单组上提供了更大优惠——每组只有一个促销会生效。
- 商品动作:规则的
actionable?是否排除了这些商品行(例如category/product规则与这些商品不匹配)。 - 订单是否已下单?已下单的订单不会重新运行促销。
- 后台缺少自定义类型:未在
after_initialize中注册,或服务器未重启。
延伸阅读
node_modules/@spree/docs/dist/developer/core-concepts/promotions.md、.../discounts.md、.../calculators.mdnode_modules/@spree/docs/dist/developer/how-to/custom-promotion.md- 源码:
spree_core中的Spree::Adjusters::Promotion、Spree::PromotionRule、Spree::PromotionAction、Spree::Promotion::Rules::*、Spree::Promotion::Actions::* - 相关技能:
spree-order-totals、spree-checkout、spree-pricing(价格表与促销的区别)、spree-testing