Spree 6.0 频道作用域配送:用 Channel → Stock Location 白名单约束履约原点
【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree
本文聚焦 Spree 6.0 已落地的 Channel-Scoped Delivery 设计(对应docs/plans/6.0-channel-delivery.md,实现于 PR #14404):它让一个 Channel(如批发、POS)只被店铺内部分 Stock Location(仓库/门店)服务,从而天然约束其购物车能看到的配送选项与自提柜台。读完后,你将掌握该功能的完整数据模型、四个强制执行点(allocation / quoting / pickup discovery / pickup selection)的源码实现,以及它如何与 delivery profiles、origin groups 和订单路由组合而不产生重复建模。
背景:Channel 与配送模型正交带来的问题
在 6.0 的配送模型中,DeliveryProfile回答"这些商品怎么发货",origin groups 把原点(stock locations)分组,但 Channel 与这套模型完全正交:
- 一个批发 Channel 的购物车会从产品 profile 的每一个origin group 获取运费报价;
- 没有任何机制能表达"B2B 渠道只从批发仓发货"或"POS 渠道只从本店自提"。
该计划的解法是一条可选的允许清单(allowlist)连接表spree_channel_stock_locations(空 = 所有地点,即当前行为),强制点选在配送模型已经按 origin 轴过滤的位置上:allocation 与报价只需把 origin group 的有效地点与 channel 的服务集合做交集,而不是引入新机制。这也是行业通行形态——Medusa 将 sales channels 关联到stock locations而非 shipping profiles,让渠道适配的配送选项通过原点传递性地涌现。若不引入这条链路,唯一替代方案是每个 channel 建一套独立 profile 并把产品重复发布其上——正是 origin groups 当初要消灭的重复。
核心设计决策(不可随意偏离)
计划文档明确了五条决策,理解它们是读懂源码的前提:
- Channel 关联 Stock Location,既不关联 Profile 也不关联 Origin Group。Profile 是对商品的分组,groups 对原点做划分,而 channel 约束的是"哪些原点服务其流量"。若 channel→profile,目录按渠道分叉;若 channel→group,group 承担双重职责并复制一个 channel 本来就需要(库存侧)的关系。Origin 是唯一真正承载该职责的轴,且 Coordinator 与 Estimator 本来就在这个轴上过滤,所以强制只是"取交集"。
- 空允许清单 = 全部地点。沿用 Spree 既有的"无行即无限制"约定(见
docs/plans/6.0-channel-markets.md、origin group 成员、method service 行)。现有店铺升级后行为零变化。 - Origin 作用域是原点侧过滤,绝不是方法侧规则。曾考虑过"用规则决定哪些仓库服务某渠道",被否决:方法可见但其原点无法服务该渠道时,会报出与 allocation 相矛盾的运费。Stock Location 允许清单从结构上保证报价与分配一致。
- 费率可见性是方法侧规则(2026-08-10 补充决策)。"一个渠道从它已经能到达的原点被提供哪些配送选项和价格"是另一个问题,由
Spree::DeliveryMethodRules::ChannelRule(与 ItemTotal/Weight/ExcludedProducts 并列的 STI 规则)在 Estimator 的方法过滤中执行。它让"批发付 €5、零售付 €10,同仓同货"无需复制产品或 profile 即可表达:不同价格是一个携带该规则的第二个方法,与 zones 的既有玩法一致。两套机制回答不同问题且可组合:origins 决定渠道"能否"被服务,rules 决定"提供什么"。 - Pickup 走同一套规则:渠道的购物车只能看到该渠道服务地点中的自提柜台,无特判——
available_pickup_locations与其他一切一样取交集。
此外,订单路由保持不变:preferred_stock_location_id位于 Order 上(客户自提选择/路由结果),其解析路径已经按pickup_enabled作用域化——channel 交集落在同一解析逻辑里。允许清单约束候选集,路由策略在其内部排序。
数据模型与迁移
计划中的模型定义:
spree_channel_stock_locations (join; unique [channel_id, stock_location_id]) Channel has_many :channel_stock_locations, :stock_locations (through) Channel#serves_location?(stock_location) # 空允许清单 → true Channel#served_stock_locations # 空 → store.stock_locations实际迁移见 20260809170001_create_spree_channel_stock_locations.rb,注释直接引用了本计划文件:
class CreateSpreeChannelStockLocations < ActiveRecord::Migration[8.1] # Optional Channel → StockLocation allowlist # (docs/plans/6.0-channel-delivery.md): no rows means the channel is served # by every store location, so existing stores upgrade with zero behavior # change. def change create_table :spree_channel_stock_locations do |t| t.references :channel, null: false t.references :stock_location, null: false t.timestamps end add_index :spree_channel_stock_locations, [:channel_id, :stock_location_id], unique: true, name: 'idx_channel_stock_locations_uniqueness' end end迁移为纯增量:只有一条create_table,无回填(空 = 全部,行为保持),无弃用。
模型侧实现在 channel.rb:
# Optional fulfillment-origin allowlist: no rows means every store # location serves this channel (docs/plans/6.0-channel-delivery.md). has_many :channel_stock_locations, class_name: 'Spree::ChannelStockLocation', dependent: :destroy, inverse_of: :channel has_many :stock_locations, through: :channel_stock_locations, class_name: 'Spree::StockLocation'三个核心谓词位于 channel.rb:
# 空允许清单 → 全部服务 def serves_location?(stock_location) return false if stock_location.nil? return true if served_stock_location_ids.empty? served_stock_location_ids.include?(stock_location.id) end # 内存 memo:channel 的成员 ID 列表(空 = 未受限) def served_stock_location_ids @served_stock_location_ids ||= channel_stock_locations.map(&:stock_location_id) end # 允许履约该渠道流量的地点;空 → store.stock_locations def served_stock_locations return store.stock_locations if served_stock_location_ids.empty? store.stock_locations.where(id: served_stock_location_ids) end注意serves_location?对nil地点直接返回false,而"空清单 = 全部"的语义在served_stock_location_ids.empty?分支实现——两者语义分离,避免 nil 检查与空清单判定相互纠缠。
四个强制点(同一个谓词)
计划文档声明"all one predicate",源码逐一印证:
1. Allocation(库存分配)
Stock::Coordinator#allocatable_units_for 中,每件商品只能从"其 delivery profile 覆盖、且(若订单携带 channel)该 channel 服务"的地点分配:
# An item may only be allocated from locations its delivery profile # covers — a profile narrowed to the cold-storage warehouse never packs # from anywhere else — intersected with the channel's served set when # the order carries one (docs/plans/6.0-channel-delivery.md). def allocatable_units_for(stock_location) return [] unless channel_serves?(stock_location) inventory_units.select do |unit| profile = profile_for(unit) profile.nil? || profile.covers_location?(stock_location) end end # A nil channel means unrestricted — key-bound storefront traffic # always has one, but admin-created and legacy carts may not. def channel_serves?(stock_location) return true if order_channel.nil? order_channel.serves_location?(stock_location) end这里channel_serves?是短路守卫(该地点不服务则直接跳过整组 unit),profile.covers_location?继续负责 profile 侧覆盖,交集逻辑只存在于这一处。order_channel做了 memo(@order_channel ||= order.try(:channel)),保证一次分配只读一次 channel 成员关系,而非每个候选地点查一次。nilchannel 一律视为无限制,对应计划文档中"admin-created 和 legacy carts 可能没有 channel"的审计结论。
2. Quoting(报价)
计划文档明确报价不需要额外检查,作为不变式记录并由 spec 覆盖:一个来自"非服务地点"的包裹根本不可能存在(已在 allocation 阶段被过滤)。Estimator#filter_delivery_methods 的方法过滤链中,origin 侧的服务性检查由delivery_method.serves_location?(package.stock_location)承担,其委托链见 delivery_method.rb:
def serves_location?(stock_location) provider.serves_location?(self, stock_location) end即履约 provider(如 pickup provider)决定"该方法能否从这个原产地发货"。channel 交集在 allocation 落地后,这条过滤链天然一致——这正是决策 3"从结构上保证报价与分配一致"的含义。
3 & 4. Pickup discovery / selection
- 店铺
pickup_locations端点与Carts::Update的preferred_stock_location_id解析都与购物车的 channel 取交集; - 客户提交的
preferred_stock_location_id需通过pickup_enabled并且该 channel 的服务集合,不满足时与非自提地点一样返回 404。
对应实现在 Carts::Update#assign_preferred_stock_location:
def assign_preferred_stock_location value = params[:preferred_stock_location_id] # ... cart.preferred_stock_location_id = eligible.find_by_param!(value).id endeligible候选集已经收敛过 channel 服务集合,所以非法柜台走find_by_param!的 404 路径,与"该地点根本不支持自提"的行为完全一致——无特判。
与 ChannelRule 的组合:origin 与 price 双轴
理解两套机制的分工是本文关键结论:
| 问题 | 机制 | 位置 |
|---|---|---|
| 该渠道能否由某仓库服务 | spree_channel_stock_locations允许清单 | origin 侧(allocation + pickup) |
| 该渠道被提供哪些方法与价格 | Spree::DeliveryMethodRules::ChannelRule | 方法侧(Estimator 方法过滤) |
典型组合:"wholesale channel → EU markets(6.0-channel-markets.md 的市场轴)+ 批发仓(本文的原点轴)+ 批发价(ChannelRule 轴)"。三轴彼此正交,组合即可表达,无需复制产品或 profile。
API 与 Dashboard 形态
- Admin API:channel serializer 与 params 上暴露
stock_location_ids(replace-set 语义,空 = 全部)——与 origin groups 完全同构; - Dashboard:channel 表单上的 "Fulfillment locations" 卡片(
all/selected单选 + 勾选列表),即 origin groups 与 profiles 已上线的同一模式; - Store API:无新增表面——该约束体现为绑定 channel 的购物车看到哪些配送选项与自提柜台。
对并行开发的约束
计划文档对后续代码立下两条规矩,值得作为该子系统的设计守则:
- 新代码为购物车解析履约原点时,必须走 profile/group 覆盖路径(
covers_location?/fulfillable_stock_locations),让 channel 交集只落在一处——配送代码中绝不允许直接枚举store.stock_locations; - 不要给
DeliveryProfile、DeliveryOriginGroup、DeliveryMethod加 channel 感知——channel 约束从外部组合进来,模型保持 channel-free。
这与"约束从外部组合、核心模型保持纯净"的整体风格一致:profiles 回答"怎么发货"、groups 划分原点、channel 约束原点子集、rules 修饰方法可见性与定价,各司其职。
遗留问题
计划文档保留了两个开放问题,阅读实现时需留意:
PR 内实现 vs 后续跟进—— 2026-08-09 已定:实现于 PR #14404 内,模式与 origin groups 相同;Order#channel_id在所有 storefront 路径的报价时刻是否可靠存在(key-bound channel vs 显式 header)——nilchannel 意味着无限制,属于安全方向,实现阶段已对主要路径审计。从 Coordinator 源码注释("key-bound storefront traffic always has one, but admin-created and legacy carts may not")看,该假设已被写入实现。
参考
- 6.0-delivery-profiles.md:origin groups——本文组合的轴
- 6.0-channel-markets.md:兄弟允许清单(markets 轴)
- 6.0-order-routing.md:Channel 模型、
preferred_stock_location_id - 迁移:20260809170001_create_spree_channel_stock_locations.rb
- 模型:channel.rb
- 分配强制:stock/coordinator.rb
- 报价过滤:stock/estimator.rb、delivery_method.rb
- 自提选择:carts/update.rb
【免费下载链接】spreeOpen Source eCommerce Platform for B2B, Marketplace, and Enterprise. REST API, TypeScript SDK, and production-ready Next.js storefront. Self-host it. Own your stack. No vendor lock-in. Zero platform fees.项目地址: https://gitcode.com/GitHub_Trending/sp/spree
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考