news 2026/9/15 3:33:03

Spree 第三方价格与库存集成:ERP/PIM/DAM 身份映射与 Provider 扩展架构

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spree 第三方价格与库存集成:ERP/PIM/DAM 身份映射与 Provider 扩展架构

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/corespree/api中的实际实现代码,系统讲解 Spree 6.0 的外键映射(ExternalReference)、价格/库存 Provider 契约、同步构建块(批量库存 upsert、外部 URL 媒体)以及失败策略,帮助读者掌握如何在 Spree 中接入第三方系统、并在“展示数据本地同步、决策数据实时外呼”的严格边界下落地 ERP/PIM/DAM 集成。

总体设计:两条数据边界,一条混合规则

方案的核心是混合式集成,并遵守一条严格规则:Spree 是“消费者所见内容”的系统记录源(system of record),外部系统是“事实真相”的系统记录源。所有渲染在列表页、商品详情页、搜索、筛选和购物车展示中的数据,都来自 Spree 的本地副本,由入站同步保持新鲜;只有到决策时刻——行项目定价、加入购物车、结算——才会通过两个窄接口向第三方发起实时调用,默认实现直接包装现有代码,未连接任何系统的店铺行为完全不变。

设计文档明确指出当前缺失的三件事,也正是本方案要补齐的:

  1. 身份映射缺失:一条同时被 PIM 与 ERP 以不同 key 认识的记录,无处存储两种身份,每个连接器都自造 SKU 匹配逻辑;
  2. 价格与库存答案不可插拔:workflow hooks 只能否决,不能提供价格或库存水平,因为价格解析与Spree::Stock::Quantifier被硬编码在Variant#price_forVariant#in_stock?中;
  3. 入站批量面不完整:价格有 bulk upsert,库存没有;导入管线只覆盖商品、商品翻译和客户。

关键决策一览(设计文档原文要求“不讨论不得偏离”)

#决策要点
1展示数据走同步,决策数据走实时,唯一例外是定价(见决策 5)
2身份映射是模型而非列:Spree::ExternalReference
3两个 Provider 契约,Internal默认实现,注册表机制同 tax/delivery-rate
4Stock::Quantifier保留算术职责,Provider 只是行的来源
5定价解析器位于读路径,Provider 必须缓存支持并自行划定范围
6库存读路径保持本地,仅加购/预留/完成咨询 Provider
7Provider 调用必须是显式external_step,不隐藏在模型方法中
8行项目记录价格来源(price_source列)
9失败策略显式且按店铺配置:fallback/strict
10DAM 媒体同步引用而非字节(external_media_url
11ERP 侧软预留(reserve in the ERP)暂不实现
12Provider 选择是 Store 偏好字符串,校验于注册表
13失败策略是一对 Store 偏好
14单一多态Spree::ExternalReference表,而非每资源一表
15Provider 调用移入新的 Tier 1 服务:PriceItemsCheckAvailability
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 upsertPOST /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::TaxIdentifierPaymentSession#external_idprovider 拥有的身份而非同步 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::PriceInternal返回持久化的行;外部 provider 返回未保存的、readonly!的实例。设计文档强调:“Active Record 形状本身就是契约”——下游所有读取方(price_including_vat_fordiscounted?、序列化器)无需知道数字来自哪里即可继续工作;readonly!把“意外调用 save”从静默损坏变成报错。
  • handles?(context)→ 该 provider 是否愿意为这个 context 应答。返回 false 时内部解析器代为应答——这是保持匿名目录浏览不接触合同定价系统的逃生舱。
  • cache_ttl→ 外部答案的缓存时长,nil 表示不缓存(仅对读本地数据库的 provider 正确)。

类级方法(在class << self中,继承自IntegrationBackedProvider):integration_class(如'SpreeSap::Integration')、available_for_store?(store)(形状同DeliveryRateProvider::Base)、keyname.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 才清除标记;
  • .applyprice_source来自答案而非 store 设置:provider 拒绝该 context 或回退时,价格实际来自目录,行必须如实记录(源码:line_item.assign_attributes(price: amount, price_list_id: price.price_list_id, price_source: price.price_source))。

AddItemUpsertItems在写入行之前从external_step调用它;Complete#recalculate_in_lockstrict策略下重新定价,陈旧缓存的合同价无法完成结算。地址变更路径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!的实例(携带远程系统数量、backorderableallocated_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/UpsertItemsexternal_step :check_availability,写入行之前——今天加购本无事务外库存检查,故不替代任何东西);
  • StockReservations::Reserve(预留针对 provider 行扣减);
  • Complete#validate_cart(逐行sufficient_stock?需求从Checkout::DefaultRequirements移入external_step :confirm_availabilityRequirements保留所有非库存检查)。
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_handProductScopes.in_stock全部继续读持久化的库存行。只有加购、预留、完成咨询 provider——列表页绝不为每个商品磁贴发一次远程调用。

两个已修复的 bug(设计文档披露)

工作过程暴露并修复了两个 bug:

  1. 定价探针重复计数:通过cart.line_items.new构建的定价占位行会被行项目查找器找到,第二次加购把数量翻倍——现在探针是分离的PriceItems.probe使用Spree::LineItem.new并仅把 cart 设为关联 target,不进关联);
  2. 预留扣减键控错误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:providerstore_idreason——PII 安全),使用 Internal 答案,并向购物车的warnings追加警告码(pricing_provider_unavailable/inventory_provider_unavailable);
  • strictfailure(...)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_idstock_location_id,以及count_on_handadjustment(两者都缺则 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_urlexternal_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 对账列表。

七、迁移路径与实施约束

设计文档将落地分五步:

  1. 外部引用(core + API + SDK + dashboard 卡):模型、所列资源的 concern、嵌套端点、写路径上的按外部 id upsert、Company#external_id替换。不使用它的任何人零行为变化。
  2. 同步构建块:库存 bulk upsert、外部 URL 媒体。
  3. Internal的 Provider 契约:注册表、基类、Store 偏好、Variant#price_for调度、Quantifierstock_levels:注入。验收:core + API 全套测试不修改通过。
  4. 调用点重构(决策 7):Carts::PriceItems+Carts::CheckAvailabilityAddItem/UpsertItems/Recalculate/Complete中的external_stepline_items.price_source、失败策略 + 通知、LineItem薄壳。Carts::Complete电池不修改通过。
  5. 参考连接器(有客户驱动则 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),仅供参考

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

COMSOL 6.3 多物理场仿真避坑指南:安装、求解器与耦合实战

1. 为什么“一步不踩坑”在 COMSOL 6.3 里不是口号&#xff0c;而是刚需刚打开 COMSOL Multiphysics 6.3 安装包时&#xff0c;你可能只看到一个蓝色图标和“Multiphysics”几个英文字母。但真正点开第一个模型、拖进一个“固体力学”接口、再试图添加“热传导”耦合时&#xf…

作者头像 李华
网站建设 2026/9/15 3:32:30

GD32H759+RT-Thread工控开发环境搭建实战

1. 项目概述&#xff1a;为什么选 GD32H759 RT-Thread 做工控入门&#xff1f;GD32H759 是兆易创新在 2023 年底正式量产的高性能工业级 MCU&#xff0c;它不是简单地把 Cortex-M7 频率拉高&#xff0c;而是围绕真实工控场景做了系统性重构。我去年在某智能电表产线做边缘协议…

作者头像 李华
网站建设 2026/9/15 3:32:13

YOLO数据集标签格式转换与仓库工人检测训练实战指南

简介&#xff1a;面向YOLO系列算法训练与验证的仓库工人目标检测数据集&#xff0c;共包含555张带标签图像&#xff0c;适配yolov5、v7、v8、v9、v10、yolo11等主流版本&#xff0c;适用于仓储物流场景中的人员定位、安全帽佩戴检测、行为分析等任务&#xff0c;对目标检测入门…

作者头像 李华
网站建设 2026/9/15 3:31:24

MCU、MPU、SoC选型本质:确定性、灵活性与异构协同的工程权衡

1. 这不是选型指南&#xff0c;而是一份踩过二十多个项目坑后写下的“芯片认知地图”你手头正要启动一个新项目&#xff0c;可能是智能电表、工业网关、边缘AI盒子&#xff0c;也可能是车载T-Box或医疗手持终端。硬件选型会卡在第一个十字路口&#xff1a;到底该用MCU、MPU还是…

作者头像 李华
网站建设 2026/9/15 3:31:20

MCU、MPU还是SoC?嵌入式选型的多约束决策实战指南

1. 为什么今天还在为“选MCU还是SoC”反复纠结&#xff1f;——一个嵌入式老兵的十年踩坑实录我第一次在项目里卡在选型上&#xff0c;是2014年做一款智能电表。当时手头有三份方案&#xff1a;用STM32F4&#xff08;MCU&#xff09;、用i.MX6ULL&#xff08;MPU&#xff09;、…

作者头像 李华
网站建设 2026/9/15 3:30:56

AES加密算法详解:原理、实现与优化实践

1. AES加密算法概述AES&#xff08;Advanced Encryption Standard&#xff09;作为当今最广泛使用的对称加密算法&#xff0c;其设计理念源于比利时密码学家Joan Daemen和Vincent Rijmen提出的Rijndael算法。2001年经美国国家标准与技术研究院&#xff08;NIST&#xff09;遴选…

作者头像 李华