news 2026/9/14 13:53:23

Spree 6.0 频道作用域配送:用 Channel → Stock Location 白名单约束履约原点

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spree 6.0 频道作用域配送:用 Channel → Stock Location 白名单约束履约原点

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 当初要消灭的重复。

核心设计决策(不可随意偏离)

计划文档明确了五条决策,理解它们是读懂源码的前提:

  1. Channel 关联 Stock Location,既不关联 Profile 也不关联 Origin Group。Profile 是对商品的分组,groups 对原点做划分,而 channel 约束的是"哪些原点服务其流量"。若 channel→profile,目录按渠道分叉;若 channel→group,group 承担双重职责并复制一个 channel 本来就需要(库存侧)的关系。Origin 是唯一真正承载该职责的轴,且 Coordinator 与 Estimator 本来就在这个轴上过滤,所以强制只是"取交集"。
  2. 空允许清单 = 全部地点。沿用 Spree 既有的"无行即无限制"约定(见docs/plans/6.0-channel-markets.md、origin group 成员、method service 行)。现有店铺升级后行为零变化。
  3. Origin 作用域是原点侧过滤,绝不是方法侧规则。曾考虑过"用规则决定哪些仓库服务某渠道",被否决:方法可见但其原点无法服务该渠道时,会报出与 allocation 相矛盾的运费。Stock Location 允许清单从结构上保证报价与分配一致。
  4. 费率可见性是方法侧规则(2026-08-10 补充决策)。"一个渠道从它已经能到达的原点被提供哪些配送选项和价格"是另一个问题,由Spree::DeliveryMethodRules::ChannelRule(与 ItemTotal/Weight/ExcludedProducts 并列的 STI 规则)在 Estimator 的方法过滤中执行。它让"批发付 €5、零售付 €10,同仓同货"无需复制产品或 profile 即可表达:不同价格是一个携带该规则的第二个方法,与 zones 的既有玩法一致。两套机制回答不同问题且可组合:origins 决定渠道"能否"被服务,rules 决定"提供什么"。
  5. 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::Updatepreferred_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 end

eligible候选集已经收敛过 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 的购物车看到哪些配送选项与自提柜台。

对并行开发的约束

计划文档对后续代码立下两条规矩,值得作为该子系统的设计守则:

  1. 新代码为购物车解析履约原点时,必须走 profile/group 覆盖路径covers_location?/fulfillable_stock_locations),让 channel 交集只落在一处——配送代码中绝不允许直接枚举store.stock_locations
  2. 不要DeliveryProfileDeliveryOriginGroupDeliveryMethod加 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 13:51:05

Qt5.14.2 aarch64静态交叉编译完整手册与踩坑实录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 13:48:19

Pytorch车牌识别实战:CNN+BiLSTM+CTC端到端方案解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 13:42:53

CMSIS-4遗产代码库的静态工程评测:迁移前如何摸清底细

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 13:42:00

SpringBoot家装预算系统开发实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 13:41:16

SSM+JSP母婴商城毕业设计:数据库脚本与部署全攻略

简介&#xff1a;一套基于SSMJSP的母婴用品商城项目源码&#xff0c;面向Java毕业设计、课程设计及期末大作业需求。项目包含完整前后端代码、数据库脚本与部署工具&#xff0c;基于IDEA开发&#xff0c;数据库采用MySQL 5.7&#xff0c;可部署于Tomcat 7/8&#xff0c;代码附注…

作者头像 李华