mcpField
S
spree-promotions

spree-promotions

github.com/spree开发10221 次安装4 周前更新
代码仓库

关于 spree-promotions

spree-promotions 是面向 Spree 开发者的促销配置与扩展指南,涵盖规则、折扣动作、计算器及优惠码。它详述购物车重算中的竞争、持久化与结账行为,并说明如何添加自定义扩展。

主要功能

  1. 01配置自动促销与优惠码促销
  2. 02定义促销规则、动作与计算器
  3. 03处理折扣竞争、分摊与持久化
  4. 04管理批量优惠码及结账应用
  5. 05注册自定义规则、动作和计算器

适用场景

  1. 01为购物车设置门槛或商品促销
  2. 02创建单次使用的批量优惠码
  3. 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:

  1. 候选项 — 与购物车关联的促销、购物车已保存的 coupon_code 促销,以及正在激活的自动促销。系统会逐一检查:有效时间范围、使用次数限制,然后根据 match_policy 检查规则。
  2. 竞争,只保留胜出者 — 每个竞争组中,金额最负的促销胜出(平局时选择动作最新的促销):
    • :line_item 动作按每个商品项竞争(仅限通过规则 actionable? 检查的商品项),
    • :fulfillment 动作按每个配送项竞争,
    • :order 动作在整个订单范围内竞争;胜出者的折扣会按比例分摊到商品项(使用最大余数法)——不会创建关联到订单的折扣记录。
  3. 持久化 — 写入胜出者的记录,并限制折扣金额,确保商品项/配送项的金额不会低于零;删除过时的促销记录。落败者不会被存储,只会在下次重新参与竞争。随后会根据折后金额估算税费。
  4. 下单 — 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 — 订单折扣:orderFlatPercentItemTotal还支持 FlatRate、FlexiRate、TieredPercent、TieredFlatRate。按比例分摊到各个商品行。
create_item_adjustments — 商品折扣:line_itemPercentOnLineItem还支持 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。请针对购物车测试资格,而不只是订单。

“促销未生效”

  1. 是否已启用?检查 starts_at/expires_at;检查 promotion.usage_limit_exceeded?(cart);对于优惠券促销,确认 cart.coupon_code 是否匹配(代码会转换为小写)。
  2. 规则:检查 promotion.eligible?(cart),然后检查 promotion.eligibility_errors。自定义规则的 applicable? 是否针对 Spree::Cart?
  3. 货币:金额计算器在遇到其他货币时会返回 0。
  4. 是否输掉了竞争?其他促销在同一商品行/配送记录/订单组上提供了更大优惠——每组只有一个促销会生效。
  5. 商品动作:规则的 actionable? 是否排除了这些商品行(例如 category/product 规则与这些商品不匹配)。
  6. 订单是否已下单?已下单的订单不会重新运行促销。
  7. 后台缺少自定义类型:未在 after_initialize 中注册,或服务器未重启。

延伸阅读

  • node_modules/@spree/docs/dist/developer/core-concepts/promotions.md、.../discounts.md、.../calculators.md
  • node_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

评论

支持 Markdown