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→groupgroup 承担双重职责并复制一个 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 # 内存 memochannel 的成员 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 channels 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?是短路守卫该地点不服务则直接跳过整组 unitprofile.covers_location?继续负责 profile 侧覆盖交集逻辑只存在于这一处。order_channel做了 memoorder_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.rbdef 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_locationdef 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 markets6.0-channel-markets.md 的市场轴 批发仓本文的原点轴 批发价ChannelRule 轴。三轴彼此正交组合即可表达无需复制产品或 profile。API 与 Dashboard 形态Admin APIchannel serializer 与 params 上暴露stock_location_idsreplace-set 语义空 全部——与 origin groups 完全同构Dashboardchannel 表单上的 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.mdorigin groups——本文组合的轴6.0-channel-markets.md兄弟允许清单markets 轴6.0-order-routing.mdChannel 模型、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),仅供参考
