ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spree 6.0 配送档案(Delivery Profiles)深度解析:商品如何决定发货地、配送方式与运费报价

Spree 6.0 配送档案(Delivery Profiles)深度解析:商品如何决定发货地、配送方式与运费报价 Spree 6.0 配送档案Delivery Profiles深度解析商品如何决定发货地、配送方式与运费报价【免费下载链接】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 的核心重构之一——Spree::DeliveryProfile配送档案展开完整覆盖其数据模型、STI 种类、来源分组Origin Group、运行时“拆分-分配-报价”流程以及从 5.xShippingCategory原地重命名升级的迁移路径。读完本文你将掌握 Spree 6.0 中商品配送行为的完整决策链一个商品如何确定从哪些仓库发货、适用哪些目的地区域、提供哪些配送方式以及混合购物车如何按档案自动拆包报价。1. 背景ShippingCategory 的“升格”而非“替换”设计文档 docs/plans/6.0-delivery-profiles.md 明确了本方案的定位Spree::DeliveryProfile是店铺作用域的分组决定一组商品如何发货——哪些来源仓库可以履约、哪些目的地区域适用、提供哪些配送方式。商品直接引用档案nil时回退到店铺默认档案购物车按档案拆分包裹包裹的候选配送方式恰好是其档案下的方式再经来源与目的地过滤。一个关键的设计取向是这是 ShippingCategory 的升格promoted不是替换replaced。5.x 的spree_shipping_categories表与商品上的外键是原地重命名的因此历史数据靠 rename 而非复制完成迁移。源码中的模型注释印证了这一点delivery_profile.rb# Formerly Spree::ShippingCategory — the table is renamed in place, so 5.x # category assignments become profile assignments (see # docs/plans/6.0-delivery-profiles.md).设计文档还说明了为何在 6.0 窗口期推翻三个早期决策fulfillment types 不是模型、命名配送组推迟到 6.1、ProductType 上的fulfillment_types数组6.1 已没有重写窗口自定义字符串类型是二等公民——运费 Provider 声明其能处理的类型自定义类型无法使用任何承运商费率 Provider“来源轴”库存位置 ↔ 配送方式在配送方式上完全不存在ProductType 上的数组是产品种类模板教条中唯一的 live-by-reference 例外。2. 数据模型一个档案挂四棵树2.1 模型总览按设计文档的模型图Spree::DeliveryProfile表spree_delivery_profilesID 前缀fp_持有以下关联Spree::Store ─────────────────────────────── has many; exactly ONE default per store ▼ Spree::DeliveryProfile spree_delivery_profiles (STI, prefix fp_) │ store_id · name · type · default · position (renamed spree_shipping_categories) │ kinds: DeliveryProfiles::Shipping (默认种类, 实体商品) │ DeliveryProfiles::Digital (digital? true, 无需收货地址, │ 仅接受 digital-provider 方式) │ 扩展种类通过 registers_subclasses_via 注册 │ ├── Spree::Product delivery_profile_id原 shipping_category_id, 必填, │ 创建时自动指派: 种类模板, 否则店铺默认档案 ├── Spree::DeliveryOriginGroup (前缀 og_, 划分履约来源) ├── Spree::DeliveryZone 新增 delivery_profile_id delivery_origin_group_id └── Spree::DeliveryMethod delivery_profile_id (必填)、 delivery_origin_group_id、delivery_zone_id (可空)模型实现delivery_profile.rb与之一一对应class DeliveryProfile Spree.base_class include Spree::SingleStoreResource include Spree::HasListPosition has_prefix_id :fp belongs_to :store, class_name: Spree::Store, optional: true has_many :products, class_name: Spree::Product, dependent: :restrict_with_error, inverse_of: :delivery_profile has_many :delivery_origin_groups, - { order(:position) }, class_name: Spree::DeliveryOriginGroup, dependent: :destroy has_many :delivery_zones, class_name: Spree::DeliveryZone, dependent: :destroy has_many :delivery_methods, class_name: Spree::DeliveryMethod, dependent: :destroy after_create :create_default_origin_group validates :name, presence: true, uniqueness: { case_sensitive: false, scope: :store_id } registers_subclasses_via { Spree.delivery_profile_types } end几个值得注意的实现细节has_many :products使用dependent: :restrict_with_error——有商品引用的档案不可被级联删除与文档中非默认档案仅在无商品引用时可删除的决策一致can_be_deleted?L89-L91返回!default? products.none?默认档案因承担回退职责而不可删除。after_create :create_default_origin_group——每个档案创建时自动挂一个无名称、无成员的默认来源分组保证每个档案至少有一个分组这一不变量。ensure_one_defaultL133-L139模仿StockLocation#ensure_one_default将某档案设为默认会自动降级同店旧默认尝试撤销默认标志会被静默恢复为true——默认开关永远无法被移除只能被转移。registers_subclasses_via { Spree.delivery_profile_types }——STI 种类的注册入口Spree.delivery_profile_types只是配置访问器core.rb允许扩展包通过注册新的子类来扩展档案种类。2.2 STI 种类行为声明在类上不在字符串上两种内置种类都以类谓词声明行为DeliveryProfiles::Shipping实体商品默认种类见 shipping.rbclass Shipping Spree::DeliveryProfile def accepts_provider?(provider_class) !provider_class.digital? end endDeliveryProfiles::Digital数字商品见 digital.rbclass Digital Spree::DeliveryProfile def accepts_provider?(provider_class) provider_class.digital? end def digital? true end def requires_shipping_address? false end end基类的accepts_provider?默认放行一切delivery_profile.rb#L45-L47其注释点明了这个守卫的作用防止 Digital 档案悄悄混入实体配送方式从而“重新解释”其下商品。设计文档强调任何地方都没有字符串词表Spree.fulfillment_types注册表、ProductType 的数组、方式上的fulfillment_type/modality 列全部删除所有行为问题都路由到类——profile.digital?、profile.requires_shipping_address?、provider.requires_address?。基类的requires_shipping_address?L59-L61对物理种类按方式推导delivery_methods.any?(:requires_address?)——即一个纯自提档案无需收货地址。2.3 来源分组DeliveryOriginGroup同一档案内的“来源轴”Spree::DeliveryOriginGroup前缀og_将档案的履约来源分区每个区域和方式都属于某个分组因此同一组商品可以按不同仓库履行时给出不同费率表无需第二个档案。模型实现见 delivery_origin_group.rbclass DeliveryOriginGroup Spree.base_class has_prefix_id :og acts_as_list scope: :delivery_profile belongs_to :delivery_profile, class_name: Spree::DeliveryProfile has_many :delivery_origin_group_locations, ... # group ↔ StockLocation 关联表 has_many :stock_locations, through: :delivery_origin_group_locations has_many :delivery_zones, class_name: Spree::DeliveryZone, dependent: :destroy has_many :delivery_methods, class_name: Spree::DeliveryMethod, dependent: :destroy end其核心查询方法covers_location?L46-L57体现了文档中“自动创建的无名称默认分组、无成员 覆盖店铺全部仓库”的语义还包含一条针对多供应商场景的重要放宽def covers_location?(stock_location) return false if stock_location.nil? return true if member_stock_location_ids.empty? # 空成员 店铺所有仓库 # 收窄的分组表达的是“操作员”哪些仓库发这类货, # 与卖家把库存放在哪无关 return true if stock_location.seller_id.present? member_stock_location_ids.include?(stock_location.id) end从源码结构看最后两行意味着分组的仓库成员表只约束店铺运营方的仓库卖家seller仓库天然被覆盖——这与多供应商市场计划中的 Decision 13 呼应代码注释直接引用了 docs/plans/6.0-multi-vendor-marketplace.md。档案侧的covers_location?任一分组覆盖即可delivery_profile.rb#L98-L102与fulfillable_stock_locations各分组成员的并集任一空分组则放宽到全店仓库L109-L115则是分配阶段的统一入口。2.4 区域与配送方式的归属变化结构性迁移20260809150001_promote_shipping_categories_to_delivery_profiles.rb落实了模型图的字段变更add_reference :spree_delivery_zones, :delivery_profile, index: true add_reference :spree_delivery_zones, :delivery_origin_group, index: true add_reference :spree_delivery_methods, :delivery_profile, index: true add_reference :spree_delivery_methods, :delivery_origin_group, index: true add_reference :spree_delivery_methods, :delivery_zone, index: true add_reference :spree_product_types, :delivery_profile, index: true # 字符串词表彻底移除 remove_column :spree_delivery_methods, :fulfillment_type remove_column :spree_product_types, :fulfillment_types对应文档中两条硬性决策区域属于档案一个方式至多属于一个区域。spree_delivery_method_zones多对多关联表在回填后删除drop_table :spree_delivery_method_zones无区域的方式 无目的地限制。方式上没有 modality 列——行为来自类FulfillmentProvider子类回答requires_address?、digital?、pickup?Manual/Digital/Pickup/PickupPoint档案种类校验组合Digital 档案只接受 digital-provider 方式费率 Provider 声明requires_address?承运商只能为“要寄到某地”的方式报价而非类型列表。delivery_method.rb 中的pickup?、requires_address?、digital?等方法即为这一层的落点ships-from 收窄、providers、calculator、markup、services、rules 等既有行为保持不变。3. 运行时流程拆分 → 分配 → 报价 → 推导文档的 Runtime flow 四步在源码中都能找到对应实现。3.1 Split购物车按档案拆包Spree::Stock::Splitter::DeliveryProfile取代Splitter::FulfillmentType按每个条目解析出的档案分区包裹splitter/delivery_profile.rbclass DeliveryProfile Spree::Stock::Splitter::Base def split(packages) split_packages packages.flat_map { |package| split_by_profile(package) } return_next(split_packages) end private def split_by_profile(package) grouped package.contents.group_by { |item| profile_id_for(item) } grouped.values.map { |contents| build_package(contents) } end def profile_id_for(item) item_profiles || {} variant item.variant key [variant.product_id, variant[:delivery_profile_id]] return item_profiles[key] if item_profiles.key?(key) item_profiles[key] variant.resolved_delivery_profile.id end end从源码结构看分组键是[product_id, variant 自身的 delivery_profile_id 覆盖值]而不仅是商品变体可以覆盖商品级档案因此两个卖家共享同一商品时各自按自己的配送条件装包。注释还解释了为什么用key?而非||——店铺可能根本没有档案解析结果为nil时||会为每个条目重复查询。这与 6.0 变体级delivery_profile_id覆盖见 20260817000002_add_seller_and_delivery_profile_to_spree_variants.rb相配套。3.2 Allocate覆盖是硬过滤可达性是偏好Coordinator 只从档案覆盖的仓库其分组关联空 全部中为档案商品分配库存并在其中优先选择能够真正送达目的地的来源Stock::Prioritizer会把 Estimator 找到配送方式的包裹排在前面使商品优先从这些仓库出库。文档强调的不变量是覆盖coverage始终是硬过滤、可达性deliverability只是偏好——没有任何来源能服务的目的地仍必须产出包裹这样购物车才能报告“该商品送不到”而不是静默丢失商品。可达性问题在代码里只问一次Stock::Estimator#deliverable?estimator.rb 中的def deliverable?(package, audience DeliveryMethod::STOREFRONT)。文档在“对当前工作的约束”中将其列为红线——分配与费率刷新共享同一方法过滤器二者对“哪些来源能服务某目的地”的判断永不失配且绝不能绕过它直接对区域/来源分组重实现更不能以调用费率 Provider 来回答否则分配阶段每个候选仓库都要发一次承运商请求。3.3 Quote候选方式 档案方式 ∩ 来源覆盖 ∩ 区域匹配 ∩ 规则过滤对一个包裹档案, 来源候选是该档案下 ships-from 集合覆盖该来源、区域为 nil 或包含收货地址、并通过既有 rule/calculator/audience 过滤的方式随后费率 Provider 扇出多费率承运商保持不变。3.4 Derive, never declare twice只推导、不双重声明Product#digital?⇐ 其档案种类DeliveryProfiles::Digital可自提 ⇐ 档案中包含 pickup-provider 方式模型侧即offers_pickup?订单是否需要收货地址 ⇐ 任一商品档案回答requires_shipping_address?Digital 种类显式声明物理种类从方式的 provider 推导。这正是文档能力来自档案的方式集合而非声明在产品侧原则在运行时的一半体现。4. 关键决策Key Decisions逐条解析设计文档将以下决策标记为未经讨论不得偏离这里结合源码逐一说明delivery_profile_id在商品上必填2026-08-09。没有档案的商品无法履约可空回退会把OR IS NULL扩散到所有档案 join。创建时自动指派种类模板否则店铺默认档案沿袭 5.x 分类的存在自动指派行为。档案在商品上模板在 ProductType 上。delivery_profile_id位于spree_productsProductType 自身的delivery_profile_id仅在商品创建时盖章到商品上迁移中add_reference :spree_product_types, :delivery_profile即为该模板列。这恢复了被fulfillment_types数组破坏的创建时模板统一教条使商品的配送行为在商品本身上可检查。以重命名迁移而非复制2026-08-09。spree_shipping_categories→spree_delivery_profilesspree_products.shipping_category_id→delivery_profile_id。5.x 商品带着原指派进来5.x 的 Digital 分类成为 Digital 档案。重命名表达不了的部分由spree:migrate_delivery_profiles位于 5.6→6.0 清单处理店铺归属5.x 分类是全局的而商品自 5.5 起单属每个档案归到使用它商品的店铺共享时复制重映射——并大声记录日志不缩小店铺方式集合的分类折叠进店铺默认档案结构迁移会为每个店铺创建名为 General 的默认档案纯数字分类转为 Digital 种类档案spree_shipping_method_categoriesm:n坍缩为方式上的单一delivery_profile_id仅关联单一档案的方式直接迁入多档案方式留在默认档案并给出 LOUD 警告。m:n 表保留到 6.1 仅作为该任务的数据源。无 modality 列只用类2026-08-09取代临时性的 modality 改名。方式的fulfillment_type列被删除而非重命名Spree.fulfillment_types注册表引擎配置访问器整体删除——它只是未发布的 6.0-dev 表面。配送机制住在FulfillmentProvider类谓词digital?、pickup?、pickup_point?、实例requires_address?上费率 Provider 用requires_address?代替类型列表档案种类是 STI 类通过Spree.delivery_profile_types注册。线格式可以为展示/过滤派生种类字符串但任何地方都不存储、不校验它们。ProductType#fulfillment_types数组移除列校验改为模板档案引用。区域属于档案方式至多属于一个区域spree_delivery_method_zones回填后删除多区域取第一个并告警。每店铺一个默认档案仿StockLocation#ensure_one_default强制模型侧ensure_one_default即其实现不可删除由Store的after_create钩子及 seeds/upgrade 为既有店铺创建。非默认档案仅在无商品引用时可删模型级守卫遵循 RBAC axis-B 惯例。竞品名称不出现在代码或注释中——依据留在计划文档里。5. 迁移路径结构迁移、类删除与升级任务5.1 结构性迁移6.0Rails 8.120260809150001_promote_shipping_categories_to_delivery_profiles.rb 完成文档图上的全部重命名与新列rename_table :spree_shipping_categories, :spree_delivery_profiles rename_column :spree_products, :shipping_category_id, :delivery_profile_id # store_id/default/position 在升级任务归属前保持可空; null:false 收紧在 6.1 add_reference :spree_delivery_profiles, :store, index: true add_column :spree_delivery_profiles, :default, :boolean, null: false, default: false add_column :spree_delivery_profiles, :type, :string # STI add_column :spree_delivery_profiles, :position, :integer, default: 0 create_table :spree_delivery_origin_groups do |t| ... end create_table :spree_delivery_origin_group_locations do |t| ... end backfill_store_defaults drop_table :spree_delivery_method_zones迁移内还内置了一个匿名读取器技巧因为本迁移恰好给表加了 STItype列回填写种类字符串时必须self.inheritance_column nil否则 Rails 会尝试实例化真实子类而读取器不是其祖先。backfill_store_defaults会为本分支已有store_id 非空的店铺创建 General 默认档案与无名称默认分组并把该店铺的 zones、无档案商品、methods 归位——5.x 行store_id 为 nil刻意不碰留给升级任务判断。5.2 类与表面的删除Spree::ShippingCategory、Spree::ShippingMethodCategory类废弃关联Product#shipping_category*、Variant#shipping_category*、DeliveryMethod#shipping_categories*、工厂、权限目录主体与 typelizer 条目全部删除权限目录新增Spree::DeliveryProfile主体。5.3 升级任务spree:upgrade:migrate_delivery_profiles位于 5.6→6.0 清单可恢复m:n 读取使用匿名 AR 读取器执行上文决策 3 列出的判断性工作。任务实现见 delivery_profiles_migration.rake其任务描述为desc Assign 5.x shipping-category rows their store, kind and methods as delivery profiles task migrate_delivery_profiles: :environment do6.1 收尾删除spree_shipping_method_categories收紧档案store_id约束null: false。5.4 测试佐证模型行为与拆包器均有独立规格覆盖delivery_profile_spec.rb 与 splitter/delivery_profile_spec.rb分别验证默认档案约束、种类谓词与按档案拆包逻辑。6. 对后续工作的约束Constraints设计文档为后续开发划定了五条红线值得作为接入该模型的规范引用新配送功能必须通过档案链解析资格——绝不重新引入平行的产品侧能力数组。行为问题一律走类provider_class.digital?、profile.requires_shipping_address?——绝不在方式、商品或档案上重新引入存储的类型/modality 字符串。fulfillments表上的历史列fulfillment_type仅作为已记录数据保留。Store API 对档案只有只读推导商品序列化器可暴露 digital/pickupable档案管理仅限 Admin API。可达性只通过Stock::Estimator#deliverable?问一次——分配与费率刷新共享方法过滤器绝不对区域或来源分组直接重实现该问题也绝不以调用费率 Provider 回答那会让分配阶段对每个候选仓库产生一次承运商请求。凡构建包裹者必须设置Package#owner为正在分配的购物车或订单。未设置时#owner回退为沿 unit 的order_id查找而购物车阶段的 unit 不带 order_id——包裹会从其 id 恰好相同的某个订单那里取货币、店铺与目的地这正是多货币运费报价出错的根因。7. 开放问题与关联文档文档保留了两个开放问题ships-from 收窄是否需要升格为命名来源分组—— 已并入 6.02026-08-09Spree::DeliveryOriginGroup随本版本发布对配送方式的 per-method ships-from 收窄退役分组即来源轴DeliveryMethodStockLocation保留为自提专用自提柜/柜台场景。多货币/多市场交互档案是店铺作用域的市场感知档案推迟到 channels/markets 工作。本方案取代了以下文档的相关章节理解完整配送域设计时应一并阅读6.0-fulfillment-and-delivery.mdFulfillment types are a strict registered set、Named delivery groups 两节6.0-product-types.mdfulfillment_types 数组部分6.0-delivery-zones.mdstore-level zone ownership 部分前置依赖6.0-delivery-rate-provider.md动态承运商费率。决策记录另见 docs/plans/decisions.md 中 2026-08-09 条目动态承运商费率、配送档案。8. 小结Spree 6.0 的 Delivery Profile 把 5.x 时代扁平、全局、字符串驱动的ShippingCategory升格为店铺作用域、STI 种类声明行为、以来源分组为来源轴的完整配送配置单元。其工程价值体现在三点一是一次性消灭字符串词表把这货怎么送的全部问题收敛到类谓词上二是通过原地重命名让 5.x 数据零拷贝升级判断性迁移由可恢复的 rake 任务承担三是以档案链作为唯一的资格解析路径使拆包、分配、报价三个运行时阶段共享同一事实来源。对需要在 Spree 上搭建多仓库、多配送方式含自提、数字商品体系的开发者而言这套模型既是理解 6.0 履约域的入口也是扩展档案种类经由Spree.delivery_profile_types注册 STI 子类与费率 Provider 的锚点。【免费下载链接】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),仅供参考
返回列表