Spree 第三方价格与库存集成:ERP/PIM/DAM 身份映射与 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
Spree 面向 B2B、Marketplace 与 Enterprise 场景的电商平台,当商家同时使用 ERP 管理库存、PIM 管理商品信息与基础价格、DAM 管理图片时,需要一套明确的混合集成方案。本文基于docs/plans/6.0-third-party-pricing-inventory.md设计文档,结合spree/core与spree/api中的实际实现代码,系统讲解 Spree 6.0 的外键映射(ExternalReference)、价格/库存 Provider 契约、同步构建块(批量库存 upsert、外部 URL 媒体)以及失败策略,帮助读者掌握如何在 Spree 中接入第三方系统、并在“展示数据本地同步、决策数据实时外呼”的严格边界下落地 ERP/PIM/DAM 集成。
总体设计:两条数据边界,一条混合规则
方案的核心是混合式集成,并遵守一条严格规则:Spree 是“消费者所见内容”的系统记录源(system of record),外部系统是“事实真相”的系统记录源。所有渲染在列表页、商品详情页、搜索、筛选和购物车展示中的数据,都来自 Spree 的本地副本,由入站同步保持新鲜;只有到决策时刻——行项目定价、加入购物车、结算——才会通过两个窄接口向第三方发起实时调用,默认实现直接包装现有代码,未连接任何系统的店铺行为完全不变。
设计文档明确指出当前缺失的三件事,也正是本方案要补齐的:
- 身份映射缺失:一条同时被 PIM 与 ERP 以不同 key 认识的记录,无处存储两种身份,每个连接器都自造 SKU 匹配逻辑;
- 价格与库存答案不可插拔:workflow hooks 只能否决,不能提供价格或库存水平,因为价格解析与
Spree::Stock::Quantifier被硬编码在Variant#price_for与Variant#in_stock?中; - 入站批量面不完整:价格有 bulk upsert,库存没有;导入管线只覆盖商品、商品翻译和客户。
关键决策一览(设计文档原文要求“不讨论不得偏离”)
| # | 决策要点 |
|---|---|
| 1 | 展示数据走同步,决策数据走实时,唯一例外是定价(见决策 5) |
| 2 | 身份映射是模型而非列:Spree::ExternalReference |
| 3 | 两个 Provider 契约,Internal默认实现,注册表机制同 tax/delivery-rate |
| 4 | Stock::Quantifier保留算术职责,Provider 只是行的来源 |
| 5 | 定价解析器位于读路径,Provider 必须缓存支持并自行划定范围 |
| 6 | 库存读路径保持本地,仅加购/预留/完成咨询 Provider |
| 7 | Provider 调用必须是显式external_step,不隐藏在模型方法中 |
| 8 | 行项目记录价格来源(price_source列) |
| 9 | 失败策略显式且按店铺配置:fallback/strict |
| 10 | DAM 媒体同步引用而非字节(external_media_url) |
| 11 | ERP 侧软预留(reserve in the ERP)暂不实现 |
| 12 | Provider 选择是 Store 偏好字符串,校验于注册表 |
| 13 | 失败策略是一对 Store 偏好 |
| 14 | 单一多态Spree::ExternalReference表,而非每资源一表 |
| 15 | Provider 调用移入新的 Tier 1 服务:PriceItems与CheckAvailability |
| 16 | 不新增全局配置,凭据放在Spree::Integration |
一、身份映射:Spree::ExternalReference
为什么是“模型”而不是“列”
设计决策 2 明确:身份映射必须是模型。一条记录可能同时被 PIM(如商品 key)和 ERP(如 SKU)以不同 key 识别,若为每个系统加一列,连接器数量增长时列会失控。Spree::ExternalReference一行对应一个(system, record)对,一个商品可同时携带 PIM key 与 ERP key,一个订单可携带 ERP 订单号(出站推送后写回)。
实际实现见 spree/core/app/models/spree/external_reference.rb:
class Spree::ExternalReference < Spree.base_class has_prefix_id :extref include Spree::SingleStoreResource # belongs_to :store include Spree::Metadata # connector bookkeeping: version, etag, synced_at belongs_to :store, class_name: 'Spree::Store', inverse_of: :external_references belongs_to :resource, polymorphic: true normalizes :system, with: ->(value) { normalize_system(value) } normalizes :external_id, with: ->(value) { value.to_s.strip } validates :system, :external_id, presence: true validates :system, format: { with: /\A[a-z0-9_]+\z/, message: :invalid_system_key }, allow_blank: true # Both directions, each backed by a unique index: one reference per system # per record, and one record per external id. validates :resource_id, uniqueness: { scope: [:store_id, :system, :resource_type, *spree_base_uniqueness_scope] }, allow_blank: true validates :external_id, uniqueness: { scope: [:store_id, :system, :resource_type, *spree_base_uniqueness_scope] }, allow_blank: true self.whitelisted_ransackable_attributes = %w[system external_id resource_type] scope :for_system, ->(system) { where(system: normalize_system(system)) } end几个关键设计点:
system是普通字符串 key,按约定使用连接器的Spree::Integration#api_type(若存在),但绝不是外键——因为客户可能从没有活跃连接器的系统导入数据(如旧 PIM 的夜间 CSV),这些数据也需要身份记录位置。字符串被normalize_system归一化为小写去空格,且格式约束为/\A[a-z0-9_]+\z/。- 多态
resource是有意为之:这是与 metafields 同族的横切性簿记(metafields 的resource今天就是多态的),而非购物车/订单 owner 那样的领域关系——“具体外键、绝不多态”规则只针对后者。 - 双方向唯一性:
(store_id, system, resource_type, resource_id)保证每个系统每记录一条引用;(store_id, system, resource_type, external_id)保证一个外部 id 恰好映射一条记录——这正是按外部 id 安全 upsert 的前提。
迁移与索引
迁移文件 spree/core/db/migrate/20260818000001_create_spree_external_references.rb 定义了表结构与三个索引:
class CreateSpreeExternalReferences < ActiveRecord::Migration[8.1] def change create_table :spree_external_references do |t| t.references :store, null: false, index: false t.string :system, null: false t.references :resource, polymorphic: true, null: false, index: false t.string :external_id, null: false if t.respond_to?(:jsonb) t.jsonb :metadata else t.json :metadata end t.timestamps end # One reference per system per record. add_index :spree_external_references, [:store_id, :system, :resource_type, :resource_id], unique: true, name: 'idx_external_references_on_resource' # An external id maps to exactly one record — what makes upsert-by-external-id safe. add_index :spree_external_references, [:store_id, :system, :resource_type, :external_id], unique: true, name: 'idx_external_references_on_external_id' # Reverse lookup for the resource-side association (resource → its references). add_index :spree_external_references, [:resource_type, :resource_id], name: 'idx_external_references_on_resource_lookup' end end注意metadata字段:支持 jsonb 的数据库用 jsonb,否则用 json,用于存放连接器簿记(版本、etag、同步时间)。
Concern:Spree::HasExternalReferences
该 concern 被显式 include 在 Product、Variant、StockLocation、StockItem(实现为 StockLevel)、Company、CompanyLocation、Order、Category、Media 上;不在Spree::Base上——每个资源按需 opt-in。Customer 有意缺席(偏离设计决策 3):客户是全局性的而非店铺作用域,对全局记录做店铺作用域引用没有无歧义的所有者——B2B 买家的身份放在其 Company 上。
实现见 spree/core/app/models/concerns/spree/has_external_references.rb:
module Spree module HasExternalReferences extend ActiveSupport::Concern included do has_many :external_references, class_name: 'Spree::ExternalReference', as: :resource, dependent: :destroy # Scope-friendly so controllers can chain it onto an already store-scoped # relation: `current_store.products.with_external_id('erp', 'MAT-100')`. scope :with_external_id, lambda { |system, external_id| joins(:external_references).where( spree_external_references: { system: Spree::ExternalReference.normalize_system(system), external_id: external_id.to_s.strip } ) } end def external_id_for(system) key = Spree::ExternalReference.normalize_system(system) if external_references.loaded? external_references.detect { |reference| reference.system == key }&.external_id else external_references.for_system(key).pick(:external_id) end end def set_external_id(system, external_id, metadata: nil) key = Spree::ExternalReference.normalize_system(system) reference = external_references.for_system(key).first if external_id.to_s.strip.blank? reference&.destroy external_references.reset return nil end reference ||= external_references.build(system: key) reference.external_id = external_id reference.metadata = metadata if metadata.present? reference.store ||= external_reference_store # A connector re-sending what it read is the steady state; an unchanged # reference must not pay the two uniqueness SELECTs a save costs. reference.save! if reference.new_record? || reference.changed? external_references.reset reference end def assign_external_references(references) Spree::ExternalReference.normalize_references(references).each do |reference| set_external_id(reference[:system], reference[:external_id], metadata: reference[:metadata]) end end end end对外三个方法:
external_id_for(system)→ 返回该记录在指定系统中的外部 id(String 或 nil),支持预加载后的内存查找与未加载时的for_system查询;set_external_id(system, external_id, metadata: nil)→ upsert:空值删除引用,未变化不触发多余的唯一性 SELECT,metadata可选;assign_external_references(references)→ 批量写入,只触碰 payload 提及的系统,不影响其他系统的引用;接受序列化器渲染的{ system => external_id }映射,或[{ system:, external_id: }]列表两种形状(形状解析统一走Spree::ExternalReference.normalize_references)。
with_external_idscope 设计为可链接到已店铺作用域的 relation 上(如current_store.products.with_external_id('erp', 'MAT-100')),店铺作用域是调用方职责。
Admin API v3 的形状
两种形状,均通过current_store作用域:
external_references在上述资源的 admin 序列化器上以扁平的{ system => external_id }映射渲染,写入时接受映射形式或[{ system, external_id }]列表——客户端可以原样回发它读到的内容。- 连接器写路径上的按外部 id upsert:
POST /admin/products、/variants、/stock_items、/stock_locations、/companies、/media接受 payload 中的external_references。若 create 命中了 Spree 已知的 key,则写入该记录而非在唯一索引上报错,且使用 create action 自己的 permitted params(绝不派发到update,因为多个控制器的 permitted keys 不同)。 - 成员路径接受
external:<system>:<id>替代 prefixed id(偏离决策 4)——不需要为每个资源增加无:id的路由。index 过滤使用external_references_external_id_eq+external_references_system_eq(通过 Ransack 关联 allowlist)。 - 订单:
PATCH /admin/orders/:id接受external_references,使出站推送(carts.complete.after_finalize处理器)能写回 ERP 订单号。
设计文档特别说明:Company#external_id(未发布,feature/v-3526)被移除以让位给该模型,无需 bridge;Spree::TaxIdentifier、PaymentSession#external_id是provider 拥有的身份而非同步 key,保持原样。
二、定价 Provider 契约:Spree::PricingProvider
契约接口
实际实现见 spree/core/app/models/spree/pricing_provider/base.rb:
module Spree module PricingProvider class Base include Spree::IntegrationBackedProvider # @param context [Spree::Pricing::Context] # @return [Spree::Price, nil] nil when this provider has no price def price_for(_context) raise NotImplementedError, "#{self.class} must implement #price_for" end # Whether this provider wants to answer for this context at all. Return # false and the internal resolver answers instead — the escape hatch that # keeps anonymous catalog browsing off a contract-pricing system. def handles?(_context) true end # How long an answer may be cached, keyed by the context. nil means no # caching, which is only correct for a provider reading the local # database. def cache_ttl nil end end end end三个核心方法:
price_for(context)→ 返回一个Spree::Price。Internal返回持久化的行;外部 provider 返回未保存的、readonly!的实例。设计文档强调:“Active Record 形状本身就是契约”——下游所有读取方(price_including_vat_for、discounted?、序列化器)无需知道数字来自哪里即可继续工作;readonly!把“意外调用 save”从静默损坏变成报错。handles?(context)→ 该 provider 是否愿意为这个 context 应答。返回 false 时内部解析器代为应答——这是保持匿名目录浏览不接触合同定价系统的逃生舱。cache_ttl→ 外部答案的缓存时长,nil 表示不缓存(仅对读本地数据库的 provider 正确)。
类级方法(在class << self中,继承自IntegrationBackedProvider):integration_class(如'SpreeSap::Integration')、available_for_store?(store)(形状同DeliveryRateProvider::Base)、key(name.demodulize.underscore,注册表 key,同时作为price_source存储在行项目上)。
Internal 默认实现:价格列表走查
spree/core/app/models/spree/pricing_provider/internal.rb 中,Internal#price_for委托给独立的Resolution对象:
class Internal < Base def price_for(context) Resolution.new(context).resolve end class Resolution # ... def resolve find_price_from_lists || find_base_price end def find_price_from_lists (catalog_price_lists + applicable_price_lists).each do |price_list| price = find_price_for_list(price_list) return price if price end nil end end end解析逻辑为:目录(catalog)关联的价格列表优先(catalog 即受众,先最近节点),然后是按自身规则匹配的价格列表,最后回退到 variant 的基础价格(无price_list_id)。Resolution是独立对象是因为它按 context 记忆化(applicable_price_lists),而 provider 实例是无状态复用的——把状态放在 provider 里会让缓存的 provider 把为甲购物者解析的列表服务于乙购物者。
设计文档中的调用链变更在此处体现:旧的Spree::Pricing::Resolver变成委托给它的废弃壳(6.1 移除),其价格列表走查逻辑移入PricingProvider::Internal。
调度层:Spree::Pricing::PriceResolution
Variant#price_for(context)的最终实现(spree/core/app/models/spree/variant.rb 第 837-847 行)委托给Spree::Pricing::PriceResolution.call(context)。该调度类(spree/core/lib/spree/core/pricing/price_resolution.rb)站在 Provider 与 Spree 其余部分之间,在目录读路径上运行(Store API 序列化器给每个列表的每行定价),因此在此完成三件事:
handles?分流:provider 拒绝该 context 时根本不会被询问,匿名浏览即使连接了合同定价系统也留在本地目录;- 缓存:为声明
cache_ttl的 provider 按 context 键缓存答案。关键细节——缓存的是价格的 attributes 而非 marshalled AR 对象,因为序列化后的 Active Record 实例携带它被 dump 时的 schema,迁移后取回会损坏;重建时通过readonly!保护; - 失败处理:按 store 的失败策略——
strict重新抛出为ProviderUnavailable(调用方决定),fallback静默使用 Spree 自身价格。
缓存键(provider_cache_key)显式携带 store id、provider key、variant id、currency、country code、market id、channel id、user id、quantity——设计文档强调Context#cache_key已携带这些维度,但缓存键故意不用Context#cache_key(它含精确到秒的日期,会让每条目唯一、TTL 失去意义),陈旧度由 provider 的cache_ttl界定。
选型与注册表
注册表Spree.pricing_providers由 engine 预置Internal,gem 可追加,访问器形状同Spree.tax_providers。选型是Store 偏好字符串pricing_provider(默认'internal'),变更时对照注册表校验。对应实现见 spree/core/app/models/spree/store.rb 第 110-116 行:
# Where prices and stock levels come from. 'internal' is Spree's own # catalog and stock records; a connector gem registers others. preference :pricing_provider, :string, default: 'internal' preference :inventory_provider, :string, default: 'internal' # What happens when an external source cannot be reached. preference :pricing_provider_failure_policy, :string, default: Spree::ProviderFailurePolicy::DEFAULT_PRICING_POLICY preference :inventory_provider_failure_policy, :string, default: Spree::ProviderFailurePolicy::DEFAULT_INVENTORY_POLICY实例解析逻辑见 spree/core/app/models/concerns/spree/store_data_sources.rb:pricing_provider_instance/inventory_provider_instance刻意不记忆化——store 对象可能活得比偏好变更久(job 持有一个 store 数小时),缓存的实例会一直按旧设置应答。未注册的 key 解析为 Internal 而非抛错:provider gem 被卸载不能拖垮结算,商家会在 admin 看到设置回退。
决策时刻的调用点:Spree::Carts::PriceItems
设计决策 15 将 provider 调用移入新的 Tier 1 服务。PriceItems(spree/core/app/services/spree/carts/price_items.rb)被刻意拆成两次调用:
#call只询问——可能触达外部系统,因此作为external_step在事务外运行;.apply再在调用方的事务内写入解析结果。
源码注释点明了原因:“跨网络调用持有数据库事务,就是把慢 ERP 变成一桌子卡死的锁”。两个关键细节:
money_frozen?订单(已完成)的既有行永不重新定价——已售价格是该笔销售的既成事实,不是可以再问的问题;但向已下单订单追加的行仍需定价(admin 修订不能按目录价给合同客户开票);manual_price?标记的行跳过——协商价格不是问题,只有显式 revert 才清除标记;.apply中price_source来自答案而非 store 设置:provider 拒绝该 context 或回退时,价格实际来自目录,行必须如实记录(源码:line_item.assign_attributes(price: amount, price_list_id: price.price_list_id, price_source: price.price_source))。
AddItem与UpsertItems在写入行之前从external_step调用它;Complete#recalculate_in_lock在strict策略下重新定价,陈旧缓存的合同价无法完成结算。地址变更路径Cart#recalculate_for_address_change!直接批量走PriceItems——每流恰好一个重新定价点(偏离决策 6,Recalculate未新增reprice_itemsstep)。
三、库存 Provider 契约:Spree::InventoryProvider
契约接口
实现见 spree/core/app/models/spree/inventory_provider/base.rb:
module Spree module InventoryProvider class Base include Spree::IntegrationBackedProvider # @param variant [Spree::Variant] # @param stock_location [Spree::StockLocation, nil] narrows to one location # @return [Enumerable<Spree::StockLevel>] def stock_levels_for(_variant, stock_location: nil) raise NotImplementedError, "#{self.class} must implement #stock_levels_for" end end end end返回Spree::StockLevel行:Internal返回持久化行,外部 provider 返回未保存、readonly!的实例(携带远程系统数量、backorderable、allocated_count = 0除非 provider 知道得更多)。Spree::Stock::Quantifier对交给它的任何行做算术——backorder 限额、preorder、本地结算预留的行为在两种来源下完全一致,因为外部系统不知道我们的购物车,那些预留必须留在本地。
Internal实现(spree/core/app/models/spree/inventory_provider/internal.rb)返回关联本身,保留Quantifier的预加载快速路径——预加载的 variant 不能退回逐库存行查询。
调用点:Spree::Carts::CheckAvailability
库存 provider 仅在三个决策点被咨询,全部经由新的 Tier 1 服务CheckAvailability(spree/core/app/services/spree/carts/check_availability.rb),从external_step调用:
AddItem/UpsertItems(external_step :check_availability,写入行之前——今天加购本无事务外库存检查,故不替代任何东西);StockReservations::Reserve(预留针对 provider 行扣减);Complete#validate_cart(逐行sufficient_stock?需求从Checkout::DefaultRequirements移入external_step :confirm_availability,Requirements保留所有非库存检查)。
def supplyable?(variant, quantity) stock_levels = @internal ? nil : @provider.stock_levels_for(variant) Spree::Stock::Quantifier.new(variant, excluded_order: @cart, stock_levels: stock_levels). can_supply?(quantity) end关键机制:Stock::Quantifier.new(variant, stock_location, excluded_order:, stock_levels: nil)—— 传入stock_levels:时算术在 provider 的行上运行,否则用variant.stock_levels;预留扣减按行的stock_location_id键控,因此本地持有仍然生效。association_loaded?/ 预加载分支在 Internal 路径继续工作。
读路径保持不变(设计决策 6):Variant#in_stock?、#purchasable?、#backorderable?、total_on_hand、ProductScopes.in_stock全部继续读持久化的库存行。只有加购、预留、完成咨询 provider——列表页绝不为每个商品磁贴发一次远程调用。
两个已修复的 bug(设计文档披露)
工作过程暴露并修复了两个 bug:
- 定价探针重复计数:通过
cart.line_items.new构建的定价占位行会被行项目查找器找到,第二次加购把数量翻倍——现在探针是分离的(PriceItems.probe使用Spree::LineItem.new并仅把 cart 设为关联 target,不进关联); - 预留扣减键控错误:
Quantifier的预留减法匹配stock_item_id,而外部 provider 的未保存行该值为 nil——每个本地结算预留都会算成 0 导致超卖,现在改为按(variant, location)解析。
四、失败策略与遥测
设计决策 9 与 13:失败策略显式且按店铺配置,是一对 Store 偏好:
pricing_provider_failure_policy(默认strict)inventory_provider_failure_policy(默认fallback)
取值Spree::ProviderFailurePolicy::VALUES = %w[fallback strict]:
fallback:step 记日志、发射provider.fallback.spree事件(payload:provider、store_id、reason——PII 安全),使用 Internal 答案,并向购物车的warnings追加警告码(pricing_provider_unavailable/inventory_provider_unavailable);strict:failure(...)且code: 'provider_unavailable',顾客被告知重试。
默认策略的取向:库存默认 fallback(超卖可恢复,阻断结算不可恢复),实时定价默认 strict(静默收错价比重试更糟)。Provider 异常与超时永不泄漏出external_step。超时不是全局配置(Spree::Config[:provider_timeout_seconds]未新增)——超时是连接器Integration的偏好,由 gem 提供默认值。
在PriceResolution中,handle_failure还处理了一个微妙场景:strict 策略下目录读路径(无 order)返回 nil(序列化器已把 nil 渲染为“无价格”),只有携带 order 的结算上下文才抛出ProviderUnavailable——strict 拒绝的是“按未确认的价格收费”,不是把整个目录拉垮。
五、同步构建块
库存批量 upsert
POST /admin/stock_items/bulk_upsert(实际控制器为Spree::Api::V3::Admin::StockLevelsController#bulk_upsert,见 spree/api/app/controllers/spree/api/v3/admin/stock_levels_controller.rb),按(variant, stock_location)的 prefixed id 键控,镜像prices#bulk_upsert(行校验、跨 store 404)。源码注释明确了 2026-08-25 的调整:每行外部 key 查找被移除——端点只 upsert 库存水平,连接器先通过成员路径解析自己的 key。写入走类型化库存移动类型(6.0-typed-stock-movements.md),历史记录保持完整。
请求形状:每行必须包含variant_id与stock_location_id,以及count_on_hand或adjustment(两者都缺则 422)。id 批解析——解码 prefixed id 是纯 Ruby,千行 feed 只花两次 SELECT(变体集与位置集各一),而非每行两次。
无 CSV 导入类型(2026-08-25 权衡后放弃):商家不会从 ERP 手工导出价格或库存再当电子表格导回——这些系统通过 API 触达(库存走 bulk 端点推送,实时答案走 pricing/inventory provider),按外部系统 id 键控的 CSV 面没有调用方。
外部 URL 媒体(DAM)
设计决策 10:Spree::Media增加外部 URL 图像模式(media_type: 'external_image'+ 独立external_media_url列),DAM 托管的 CDN 图片是一行记录而非 ActiveStorage 副本。变体(renditions)仍是 DAM 的业务。列按media而非 image 命名:自托管视频文件也应入此列,由media_type说明。external_video_url保持独立(2026-08-19 决议):它持有 YouTube/Vimeo页面链接,Spree 解析为 embed 并对照 provider allowlist 校验,与“取字节的地址”是不同种类的值;合并会让一列校验依赖兄弟属性,且破坏已发布的 API/SDK/dashboard 面。
实现中的命名偏离(设计文档“Deviation 1”):external_url→external_media_url。因为Spree::Asset#external_url已存在且含义相反——导入图片来自哪里,由Images::SaveFromUrlJob用于避免重复下载——占用该名字会破坏图片导入。
暴露方式:Media#hosted_still_url(外部图像地址或外部视频的 provider 缩略图——一个不属于我们 resize 的 still),序列化器、Google feed 和 storefront helper 无需按media_type分支即可应答。admin 媒体选择器的 URL 输入与 products-import 的image_urls引用模式仍待完成。
出站事件
出站面已存在:Product/Variant/StockItem/Price/StockMovement 的生命周期事件 +product.out_of_stock/back_in_stock,以及用于订单推送的carts.complete.after_finalize。文档将获得一份“sync from an ERP/PIM”指南,展示外部引用 upsert、库存 bulk 端点与订单号写回。
六、Admin 表面与设置
- 连接器发布一个
Spree::Integration子类,出现在/settings/integrations(group 词汇表新增erp/pim/dam)。 - 2026-08-19 已交付:Store settings 新增“Pricing and inventory sources”卡片:两个 provider 选择器 + 各自的失败策略,数据来自
GET /api/v3/admin/store/data_sources(镜像 delivery-method rate-provider 发现端点)。整卡仅在注册了非 internal provider 时显示。integration 未连接的 provider 以available: false列出并禁用而非省略——商家能看到它为什么不可选。两个 provider 契约与外部引用的开发者指南待写;设置卡已链接到它们未来的位置。 - Admin 资源页显示“External references”卡片(system → id,首版只读)——2026-08-19 的补充:外键的受众是调试同步的人,因此引用通过开发者 JSON 抽屉读取(现已启用在每个记录详情页),外加公司表上默认关闭的列,用于对照 ERP 对账列表。
七、迁移路径与实施约束
设计文档将落地分五步:
- 外部引用(core + API + SDK + dashboard 卡):模型、所列资源的 concern、嵌套端点、写路径上的按外部 id upsert、
Company#external_id替换。不使用它的任何人零行为变化。 - 同步构建块:库存 bulk upsert、外部 URL 媒体。
- 带
Internal的 Provider 契约:注册表、基类、Store 偏好、Variant#price_for调度、Quantifier的stock_levels:注入。验收:core + API 全套测试不修改通过。 - 调用点重构(决策 7):
Carts::PriceItems+Carts::CheckAvailability、AddItem/UpsertItems/Recalculate/Complete中的external_step、line_items.price_source、失败策略 + 通知、LineItem薄壳。Carts::Complete电池不修改通过。 - 参考连接器(有客户驱动则 6.0,否则 6.1):
spree/providers/下一个实现两个 provider + Integration 的 ERP 连接器 gem、VCR 支撑的 spec、以及“sync from an ERP/PIM”指南。
当前工作的硬性约束:
- 任何模型不得新增
external_id列——身份映射一律走Spree::ExternalReference,需要它的模型 includeSpree::HasExternalReferences; - 绝不从
Variant#in_stock?/purchasable?/backorderable?、ProductScopes或任何序列化器调用外部系统——库存读路径本地化(决策 6); - 新定价/库存逻辑进
Spree::PricingProvider::Internal::Resolution/Stock::Quantifier(Internal 路径)或 provider 契约——绝不新增序列化器可达的第二个解析器; - 任何重新定价行项目的代码调用
Spree::Carts::PriceItems(而非LineItem#recalculate_price);不得新增recalculate_price/update_price调用点; - workflow 中的出站调用是
external_step(既有规则);provider 适配器在契约之后,凭据通过Spree::Integration解析; - 连接器与导入绝不直接写
spree_stock_items.count_on_hand——走库存 bulk upsert / 类型化移动。
八、既有实现状态与前瞻
截至 2026-08-18,Phases 1–4 已交付(core 8,390 例、API 3,121 例测试全绿,workspace typecheck 干净,OpenAPI + SDK 类型已重新生成);2026-08-19 增加设置 UI。遗留:Phase 5(参考连接器 gem)与开发者指南。
两项刻意延后而非悬而未决的问题:
- ERP 侧软预留(决策 11)——只在真正需要它的连接器出现后才解封;以
InventoryProvider::Base#reserves?+reserve/release对落地,镜像 delivery-rate 的book/release形状。 - 按市场选 provider(决策 12)——由跨法人实体运行单店铺、连接不同 ERP 的客户解封;在 Store 偏好旁边加
Market#pricing_provider,绝不替代它。
参考资料
- 设计文档:docs/plans/6.0-third-party-pricing-inventory.md
- 关联设计:docs/plans/6.0-integrations-admin.md、docs/plans/6.0-service-workflows.md、docs/plans/6.0-opentelemetry.md、docs/plans/6.0-stock-reservations.md、docs/plans/6.0-tax-provider.md、docs/plans/6.0-delivery-rate-provider.md、docs/plans/6.0-store-scoped-configuration.md
- 核心实现:external_reference.rb、has_external_references.rb、pricing_provider/base.rb、pricing_provider/internal.rb、inventory_provider/base.rb、store_data_sources.rb
- 调度与服务:price_resolution.rb、price_items.rb、check_availability.rb
- 迁移与 API:create_spree_external_references.rb、stock_levels_controller.rb、prices_controller.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),仅供参考