mcpField
S
spree-pricing

spree-pricing

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

关于 spree-pricing

面向开发者的 Spree 6 定价技能,涵盖按货币设置基础价格、价格清单、规则和定价上下文,说明店面价格的解析方式及 API 管理方法。

主要功能

  1. 01按货币设置基础价格
  2. 02条件式价格清单与规则
  3. 03基于上下文解析价格
  4. 04批量定价与数量阶梯
  5. 05追踪基础价格历史

适用场景

  1. 01按货币设置不同价格
  2. 02安排限时价格清单
  3. 03应用按数量计算的价格
  4. 04排查顾客看到的价格来源

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/spree/agent-skills --skill spree-pricing

安装后在 Claude Code 中输入/spree-pricing确认已加载。

详细介绍

Spree 定价

在 Spree 6 中,变体的价格始终是金额加币种,不存在隐含的“默认”价格。在基础价格之上,还有价格列表(有条件的覆盖项),系统会根据定价上下文针对每个请求解析价格。

Variant ── Price (price_) × n
             ├── base prices:  price_list_id = nil, one per currency  (what everyone pays by default)
             └── list prices:  price_list_id = pl_…, per currency, optional min_quantity (quantity breaks)
PriceList (pl_) ── PriceRule × n (STI: MarketRule, ChannelRule, VolumeRule, …)
            └── optionally owned by one Catalog (cat_) → audience = the catalog's assignments
Price ── PriceHistory × n   (base-price changes, for EU Omnibus)

含税或未税显示以及 VAT 重新列示由 spree-taxes 处理。

基础价格:price_in / amount_in / set_price

variant.set_price('USD', 39.99)            # upsert the USD base price (saves immediately if the variant is persisted)
variant.set_price('USD', 39.99, 49.99)     # 3rd arg = compare_at_amount; omitting it CLEARS compare-at
variant.set_price('EUR', nil)              # blank amount deletes that currency's base price

variant.price_in('USD')                    # => Spree::Price (unsaved blank Price if none — check .persisted? / .amount)
variant.amount_in('USD')                   # => BigDecimal or nil
variant.compare_at_amount_in('USD')
variant.price_in('USD').display_amount     # "$39.99"

product.price_in('USD')                    # delegates to product.default_variant
variant.prices.base_prices.exists?(currency: 'EUR')
  • variant.price、price=、default_price、display_price、currency 均不存在——请传入你所指的币种。
  • price_in 仅返回基础价格。要获取购物者实际支付的价格(应用价格列表后),请使用 variant.price_for(...)(见下文)。
  • 不提供外汇换算:商店销售的每种币种都需要单独设置价格。若商品没有请求币种对应的基础价格,则不会显示在 Store API 列表中。
  • Spree::Price#amount= 会解析本地化数字('1,599.99' 可正常使用)。
  • 嵌套写入:variant.prices = [{ currency:, amount:, compare_at_amount: }] 会更新或创建所列币种的价格,并移除未列出币种的基础价格([] 会清除全部价格)。product.prices = [...] 会转发到默认变体。
通过 API
await admin.prices.create({ variant_id: 'variant_xxx', currency: 'USD', amount: '15.99' })

// Upsert many (matches on variant + currency + price_list_id + min_quantity)
await admin.prices.bulkUpsert({
  prices: [
    { variant_id: 'variant_xxx', currency: 'USD', amount: '15.99', compare_at_amount: '19.99' },
    { variant_id: 'variant_xxx', currency: 'EUR', amount: '14.99' },
  ],
})
await admin.prices.bulkDestroy({ ids: ['price_xxx'] })
spree api post /prices/bulk_upsert -d '{"prices":[{"variant_id":"variant_xxx","currency":"USD","amount":"15.99"}]}'

/api/v3/admin/prices 支持基础价格和列表覆盖项(price_list_id: 'pl_…')。Store API 响应会根据请求的币种、市场和客户,返回已解析的 price / original_price。

价格列表

属性说明
statusdraft(默认)、active、scheduled、inactive——通过工作流转换(Spree.price_list_activate_workflow / price_list_deactivate_workflow;API PATCH /price_lists/:id/activate)。激活具有未来 starts_at 的列表时,其状态会设为 scheduled。
starts_at / ends_at时间范围(商店时区)。计划中的列表会在此时间范围内自动生效——黑色星期五促销就是这样安排的。
match_policy对其规则采用 all / any 匹配策略。
position独立列表之间的优先级——数值越低优先级越高;第一个为变体定价的列表生效。
price_adjustment_percentage + price_adjustment_tiers仅限目录拥有的列表:按基础价格 × (1 + pct/100) 推导价格,也可按数量分档。列表中的显式价格行仍优先。
const pl = await admin.priceLists.create({
  name: 'EU Black Friday',
  starts_at: '2026-11-27T00:00:00Z',
  ends_at: '2026-11-30T23:59:00Z',
  match_policy: 'all',
  rules: [{ type: 'market_rule', preferences: { market_ids: ['mkt_eu'] } }],
  prices: [{ variant_id: 'variant_xxx', currency: 'EUR', amount: '19.99' }],
})
await admin.priceLists.activate(pl.id)   // lists are born draft

GET /api/v3/admin/price_lists/price_rule_types 会列出已注册的规则类型及其偏好项架构。商品成员关系(每个变体 × 币种对应的占位行)通过 admin.priceLists.products 设置。

价格规则——适用地点 / 适用渠道 / 适用金额
类型(api_type)类匹配条件
market_ruleSpree::PriceRules::MarketRule上下文市场位于 market_ids 中(地域规则——没有区域规则)
channel_ruleSpree::PriceRules::ChannelRule上下文渠道位于 channel_ids 中(为空表示任意渠道)
volume_ruleSpree::PriceRules::VolumeRulemin_quantity ≤ 行项目数量 ≤ max_quantity(可为空)——上下文相关
user_rule、customer_group_ruleUserRule、CustomerGroupRule已弃用但保留兼容性(superseded? = true):现有规则仍会匹配,但选择器不再提供这些规则

价格由谁获得并非规则决定,而是由目录决定:将价格列表关联到分配给客户组或公司的 Spree::Catalog(渠道通过 default_catalog_id 指定其默认目录)。参见 spree-b2b。

自定义规则:继承 Spree::PriceRule,添加 preference,实现 applicable?(context),并在 config/initializers/spree.rb 中注册:Rails.application.config.after_initialize { Spree.pricing.rules << MyApp::PriceRules::LoyaltyTierRule }。如果规则查询的是购买情况(类似数量)而非购买者,请将 self.contextual? 覆写为 true,这样它仍会应用于目录拥有的列表。

价格如何解析

variant.price_for(context_or_hash) → Spree::Pricing::PriceResolution → 商店的定价提供方(默认是 Spree::PricingProvider::Internal):

  1. 目录自有价格列表,优先最近的节点——买家所属的公司节点,然后是其祖先节点;否则使用其客户群组的目录;否则使用渠道的默认目录。在同一节点内,选择价格最低的一项(按购买数量计算)。自有价格列表会跳过受众规则,但仍会检查上下文规则(例如 VolumeRule)、状态和日期。
  2. 独立价格列表,按 position 顺序匹配规则——第一个能为该变体定价的列表胜出。
  3. 基础价格(price_list_id: nil)。
ctx = Spree::Pricing::Context.new(
  variant: variant, currency: 'EUR',
  market: market, channel: channel, user: customer, company: company, quantity: 24
)  # store/market/channel/country default from Spree::Current; date defaults to now
variant.price_for(ctx)                                   # => Spree::Price or nil
variant.price_for(currency: 'EUR', user: customer, quantity: 24)   # hash form
Spree::Pricing::Context.from_order(variant, cart)        # uses the cart's own market/channel/customer

Store API 会根据 X-Spree-Currency / X-Spree-Country、API 密钥所属的渠道以及经过身份验证的客户构建此上下文——无需手动操作。进程内不会缓存价格。只有访客响应可以公开缓存(Vary: Accept, x-spree-currency, x-spree-locale, x-spree-channel);已验证身份的响应会使用 private, no-store。Vary 中不包含国家/地区,但它会决定市场——请将其添加到 CDN/应用缓存键中(参见 spree-i18n)。

外部定价(ERP、合同定价): 配置定价提供方(Spree::PricingProvider::Base#price_for(context)、handles?、cache_ttl)——它会在目录读取路径上运行,因此请声明 cache_ttl,并在 handles? 中拒绝匿名上下文。参见 spree-providers。

阶梯定价和数量区间

有两种方式,请按需求选择:

  • 单个列表上的数量区间——价格行的 min_quantity > 1(需要设置 price_list_id)。系统会按行数量收取 min_quantity 不大于该数量的最高一档价格。某变体在一个列表上有阶梯价格时,仅按阶梯定价。
    await admin.prices.bulkUpsert({ prices: [
      { variant_id: 'variant_xxx', currency: 'USD', price_list_id: 'pl_trade', amount: '10.00' },
      { variant_id: 'variant_xxx', currency: 'USD', price_list_id: 'pl_trade', min_quantity: 24, amount: '8.50' },
    ]})
    
  • 每个档位一个列表,并设置 volume_rule(min_quantity/max_quantity)——适用于每个档位的商品组合或其他规则不同的情况。档位之间不要重叠。

没有 quantity 的上下文(商品列表、导出)会按最低档定价。

欧盟 Omnibus:价格历史和 prior_price

每次基础价格金额变更都会写入一条 Spree::PriceHistory 记录(不追踪价格列表中的价格)。price.prior_price 会返回过去 30 天内记录的最低金额(包含刚设置的金额)。

  • 每个商店的偏好设置:preferred_track_price_history(默认值为 true)、preferred_price_history_retention_days(默认值为 30)。全局设置 Spree::Config[:track_price_history] 已弃用——请在商店上设置:
    await admin.store.update({ preferred_track_price_history: false })   // non-EU store
    
  • Store API:在商品/变体上使用 ?expand=prior_price → { amount, currency, display_amount, recorded_at }。
  • Rake:spree rake spree:price_history:seed(一次性基线,仅处理尚无历史记录的价格)、spree rake spree:price_history:prune(安排定期执行,例如通过 Solid Queue 周期性任务)。

批量更新

update_all 会跳过回调 → 不会记录价格历史,也不会重新匹配集合。请优先使用 Admin API bulk_upsert,或逐项迭代:

Spree::Price.base_prices.where(currency: 'USD').where.not(amount: nil).find_in_batches(batch_size: 500) do |batch|
  Spree::Price.transaction { batch.each { |p| p.update!(amount: (p.amount * 1.1).round(2)) } }
end

“客户看到的价格不对”

  1. 货币——Spree::Current.currency / 购物车的货币是什么?该变体是否有该货币的基础价格?
  2. 哪个列表胜出? variant.price_for(currency:, user:, quantity:)&.price_list——然后检查该买家的目录(公司节点优先于客户群组,客户群组优先于渠道默认目录)和按 position 排序的独立列表。Spree::Current.price_lists 会显示当前请求的独立候选列表。
  3. 状态 / 有效期——草稿列表不会生效;已安排的列表仅在 starts_at..ends_at 期间生效;已停用目录的自有列表会进入休眠状态。
  4. 数量——只有上下文包含数量时,数量规则和阶梯价格才会生效(购物车行包含数量,商品列表不包含)。
  5. 购物车与商品详情页——行项目会保留其定价时的价格;购物车会通过 Spree::Carts::Recalculate,在重要变更(商品、地址/市场)时重新定价。已下单的订单会冻结金额。
  6. 税费显示——含税价与未税价由市场/税务设置决定(spree-taxes)。

注意事项

  • 不带第三个参数调用 set_price(currency, amount) 会清除 compare_at_amount。
  • price_in 返回的是未保存的空白 Spree::Price,而不是 nil——请检查 .amount 或 .persisted?。
  • 读取-修改-写入陷阱:?expand=variants.prices 会返回所有价格行——基础价格、价格列表价格和数量阶梯价格,每行都包含 price_list_id(null = 基础价格)。将 prices 写回变体或商品时,每一项都会被视为基础价格(并会删除未提供的货币价格),因此将展开后的数组原样提交会用列表价格或阶梯价格覆盖商店价格,而金额为 null 的成员占位项则会删除某种货币的价格。发送前请按 price_list_id === null 过滤;列表价格请通过 /api/v3/admin/prices 写入。(控制面板的商品表单会执行此过滤;你自己的客户端也必须如此。)
  • 没有 price_list_id 时,min_quantity > 1 无效;百分比调整只允许用于目录拥有的列表。
  • 移除目录的定价(price_list: null)或删除目录,会软删除其拥有的列表——它绝不会“释放”出来以匹配所有人。
  • Store API 商品筛选器中的价格范围(products.filters → price_range)仅根据基础价格计算——绝不包含价格列表行(例如公司的合同价格、草稿列表或定时列表)——如果渠道对调用者隐藏价格,则完全不返回此字段。不要指望它反映 B2B 买家的列表价格。
  • 价格列表写入(Spree::PriceLists::Update,包括目录内联列表)会静默丢弃来自其他商店的变体。
  • 结账时的折扣和优惠券代码请使用促销功能(spree-promotions);如果降价必须显示在商品页面上,请使用价格列表。

延伸阅读

  • node_modules/@spree/docs/dist/developer/core-concepts/pricing.md
  • node_modules/@spree/docs/dist/developer/core-concepts/catalogs.md
  • node_modules/@spree/docs/dist/user/how-to/volume-pricing.md, .../schedule-sale-pricing.md, .../wholesale-pricing.md
  • 源码:spree_core 中的 Spree::Price、Spree::PriceList、Spree::PriceRule、Spree::Pricing::Context、Spree::PricingProvider::Internal
  • 相关技能:spree-catalog、spree-b2b、spree-taxes、spree-providers、spree-promotions

评论

支持 Markdown