关于 spree-fulfillment
spree-fulfillment 是面向 Spree 开发者的履约技能,涵盖履约记录、配送工作流、费率、服务商、追踪和库存路由,帮助扩展订单配送流程。
主要功能
- 01履约模型与状态说明
- 02发货、拆分、取消和配送工作流
- 03配送费率与服务商扩展点
- 04承运商标签和追踪集成
- 05库存地点与商品路由
适用场景
- 01实现部分发货或手动履约
- 02接入承运商或 3PL 服务商
- 03处理追踪更新和配送 Webhook
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-fulfillment安装后在 Claude Code 中输入/spree-fulfillment确认已加载。
附带文件references/provider-contracts.md
详细介绍
Spree 履约
履约单(Spree::Fulfillment,前缀为 ful_,编号格式为 F…)是指从一个库存地点,通过一种配送方式发送给客户的一批商品——可以是包裹、下载内容,也可以是等待客户在取货柜台领取的订单。购物车/订单会为每个(库存地点 × 配送档案 × 分拣器输出)创建一个履约单;客户为每个履约单选择一个配送费率。
从 5.x(Shipment、ShippingMethod、StockItem、发货状态机)迁移而来?常量别名会保留到 6.1,但请参阅 spree-upgrade-5-to-6——不要再针对旧名称编写新代码。
模型关系图
Cart / Order
└── Fulfillment (ful_) status: unfulfilled → fulfilled → delivered | canceled
├── stock_location (sloc_) where it ships from
├── FulfillmentItem (fi_) × n line_item + variant + quantity; status on_hand/backordered/shipped/returned
├── DeliveryRate (dr_) × n the options offered; one has selected: true
│ └── DeliveryMethod (dm_) what the customer picks ("Standard", "Express", "Collect in store")
├── Delivery (dlv_) × n each consignment/parcel: tracking_number, carrier, status
├── ShippingLabel (lbl_) × n postage bought (purchased) or recorded (uploaded)
└── TaxLine / Discount / Fee delivery money rows (see spree-order-totals)
Product ──> DeliveryProfile (fp_) how a product ships: DeliveryProfiles::Shipping | ::Digital
├── DeliveryOriginGroup (og_) × n a named set of stock locations ("EU warehouse")
├── DeliveryZone (dz_) × n where: DeliveryZoneMember (dzm_) country | state | postal_code
└── DeliveryMethod × n belongs_to profile + origin group, optional zone
├── calculator how much (when priced internally)
├── rate_provider where the price comes from (default Internal)
├── fulfillment_provider how it's dispatched (Manual | Digital | Pickup | PickupPoint | carrier gem)
└── DeliveryMethodRule (dmrule_) × n eligibility, AND-ed
PackageType (pkgtype_) box/envelope/carton/pallet/container — tare + dimensions for quotes
需要牢记的事项:
- 每个履约单都只有一个所有者——结账期间是
cart_id,下单后是order_id。读取fulfillment.owner,切勿假设它是.order。 - 配送类型由配送方式的履约提供方类决定,而非字符串列:
delivery_method.digital?、.pickup?、.pickup_point?、.requires_address?。API 会在履约单上公开fulfillment_type(shipping | digital | pickup | pickup_point)。 - 配送档案决定产品可使用哪些配送方式。 数字配送档案仅接受数字配送提供方;配送档案接受其他所有类型。来源分组会限制可为产品供货的地点——协调器绝不会从产品配送档案未涵盖的地点为其配货。
cart.delivery_step_required?的逻辑是line_items.any? && !digital?——仅含数字商品的购物车会跳过配送步骤。shipping_address_required?会查询已选配送方式(若尚未选择,则查询配送档案)——只提供自取方式的配送档案不需要地址。- 配送区域仅用于配送。 税务区域是独立的(spree-taxes)。
状态
Spree::Fulfillment 使用 has_status :unfulfilled, :fulfilled, :delivered, :canceled——状态存储在字符串 status 列中,没有状态机。状态描述商家对包裹采取的操作,只会向前推进:
| 状态 | 含义 |
|---|---|
unfulfilled | 仍由你保管 |
fulfilled | 已交接(交给承运商、放在取货柜台,或已提供下载);设置 fulfilled_at |
delivered | 客户已收到;设置 delivered_at(依据承运商的扫描记录,而非 Webhook 到达时间) |
canceled | 不会发送。最终状态——不能恢复 |
付款/缺货订单的就绪情况不是一种状态。Spree::Fulfillments::Fulfill 会在发货时检查,并返回原因(order_not_paid、backordered_units、insufficient_stock_on_hand、order_draft)。员工可以传入 force: true,在发票未付款或存在缺货订单时发货(但绝不能针对草稿订单)。can_fulfill? / can_cancel? 的判断采用否定式(“尚未发出”),因此自定义的交接前状态仍可履约。
承运商的实际配送状态记录在每个 Spree::Delivery 上(Spree::Delivery::STATUSES:pending pre_transit in_transit out_for_delivery available_for_pickup delivered return_to_sender failure unknown),绝不会使履约单状态倒退。所有托运单送达后,履约单才会变为 delivered。履约单上的 tracking / tracking_url 汇总第一条配送记录的信息。
订单会将这些状态汇总为 order.fulfillment_status(仅由 Spree::Orders::UpdateStatuses 写入):unfulfilled、partial、fulfilled、delivered、backorder、canceled。不要自行写入该字段。
状态转换就是工作流
永远不要调用 update!(status: 'fulfilled')。通过 DI 键调用工作流,以便应用可以替换它:
| 操作 | 工作流(Spree.<key>) | 钩子 |
|---|---|---|
| 发货(全部或部分) | fulfillment_fulfill_workflow → Spree::Fulfillments::Fulfill | validate、after_fulfill |
| 确认收货 | fulfillment_mark_delivered_workflow → Fulfillments::MarkDelivered | validate、after_mark_delivered |
| 取消 | fulfillment_cancel_workflow → Fulfillments::Cancel | validate、after_cancel |
| 对已下单的订单进行手动履约/3PL 履约 | fulfillment_create_workflow → Fulfillments::Create | validate、get_provider_data、after_create |
| 编辑(跟踪信息、费率、位置) | fulfillment_update_workflow → Fulfillments::Update | |
| 提前购买运单标签 | fulfillment_purchase_label_workflow → Fulfillments::PurchaseLabel | |
| 承运商跟踪信息更新 | delivery_update_tracking_workflow → Deliveries::UpdateTracking | validate、after_update_tracking |
result = Spree.fulfillment_fulfill_workflow.call(
fulfillment: fulfillment,
items: [{ line_item: line_item, quantity: 1 }], # omit to ship everything
tracking: '1Z999AA10123456784', # becomes the primary Spree::Delivery
notify_customer: true,
force: false
)
result.value # the fulfillment that actually shipped — for a partial ship, the NEW split-off one
Fulfill 按以下顺序执行:validate 钩子 → 守卫条件 → 拆出请求的商品数量(通过 Fulfillments::Create,成本为 0)→ 记录跟踪信息 → 如果服务商的 generates_labels? 返回 true 且没有有效标签,则购买运单标签(失败会被报告,但包裹仍会发货)→ provider.create_fulfillment → 对分配的商品数量取消库存预留(shipped 库存变动)→ 将商品标记为 shipped、将履约标记为 fulfilled,并发布 fulfillment.fulfilled → 捕获在发货时扣款的支付方式对应的付款 → 汇总订单状态。
手动履约(将 3PL 或快递公司的履约信息同步回 Spree):使用 POST /api/v3/admin/orders/:order_id/fulfillments,并提供 stock_location_id、items: [{ item_id: 'li_…', quantity }],以及可选的 tracking、delivery_method_id、cost、status、metadata。商品数量会从当前履约中移出(位置不同时会重新入库/取消库存预留);空的来源履约会被销毁,其成本会转移过来。注意:唯一接受的 status 值是旧版的 'shipped'(会将其登记为已履约);其他任何值都会报错 invalid_status。
拆分但不发货:PATCH …/fulfillments/:id/split 接受 variant_id、quantity 和可选的 stock_location_id(将商品数量移到新的履约中,位置可以不同),并返回订单上的所有履约。
门控与自定义阶段
使用 validate 钩子门控交接——处理程序会接收工作流并调用 reject!:
# config/initializers/spree.rb
Spree.hooks.register('fulfillments.fulfill.validate') do |flow|
work_order = MyApp::WorkOrder.find_by(fulfillment_id: flow.fulfillment.id)
flow.reject!('Still in production') unless work_order&.completed?
end
钩子键的格式为 <workflow key>.<hook>,其中 workflow key 是去掉 Spree::、转换为下划线并以点分隔的类名(Spree::Fulfillments::Fulfill → fulfillments.fulfill)。预加载时,未知键会导致启动失败。
仅当员工会按该状态筛选、报告会按该状态分组,或需要为该状态触发 webhook 时,才添加实际状态:
# config/initializers/spree.rb — to_prepare so it survives dev reloads
Rails.application.config.to_prepare do
Spree::Fulfillment.add_status('in_production', after: 'unfulfilled')
end
这样会生成 in_production?、.in_production 和一个有效值。进入该状态由你自己的 Spree::Workflow 负责(参见 spree-workflows);核心履约/取消流程仍可在该状态下正常工作。状态只能增加,绝不能移除核心状态。
费率:计算器与费率提供方
每个 DeliveryMethod 都有一个 rate_provider(类名字符串,需通过 Spree.delivery_rate_providers 验证)和一个计算器:
| 需求 | 使用 |
|---|---|
| 根据包裹中的内容进行计算(固定费率、按件计费、按商品总额百分比计费、price sack、flexi、数字商品免费) | 计算器——内置计算器位于 Spree::Calculator::Shipping::* |
| 价格来自 Spree 之外(承运商 API、聚合器、合同费率表服务) | 费率提供方——Spree::DeliveryRateProvider::Base 子类 |
| “审核后报价”(托盘、集装箱) | Spree::DeliveryRateProvider::Freight——返回一条携带货运摘要的 unpriced 费率 |
| “仅在……时提供”(订单金额、重量、体积、企业买家、渠道、排除的商品) | 在该方法上使用 DeliveryMethodRule,而非计算器阈值(计算器的 minimum_*/maximum_* 偏好设置已弃用) |
默认提供方是 Spree::DeliveryRateProvider::Internal(通过计算器定价)。基于金额的计算器带有 currency 偏好设置——货币与订单不匹配的计算器不会产生任何结果(不进行货币转换)。每种销售货币都应创建一个方法或计算器。出于同样的原因,Spree::Stock::Estimator 会丢弃使用其他货币的费率提供方估算结果。
自定义计算器:
class MyApp::Calculator::DimensionalWeight < Spree::ShippingCalculator
preference :rate_per_kg, :decimal, default: 0
preference :currency, :string, default: -> { Spree::Store.default&.default_currency }
def self.description = 'Dimensional weight'
def supports_currency?(currency) = currency.casecmp?(preferred_currency)
def compute_package(package)
package.weight * preferred_rate_per_kg # nil hides the method
end
end
# config/initializers/spree.rb — core ASSIGNS this list in after_initialize, so append there
Rails.application.config.after_initialize do
Spree.calculators.shipping_methods << MyApp::Calculator::DimensionalWeight
end
自定义承运商费率提供方(完整契约、服务目录和测试见 references/provider-contracts.md):
module SpreeAcme
class DeliveryRateProvider < Spree::DeliveryRateProvider::Base
def self.integration_class = 'SpreeAcme::Integration' # hidden/refused until connected
def self.requires_address? = true # it quotes real shipments
def estimates(package)
integration.client.rates(from: package.stock_location, to: package.order.ship_address, weight: package.weight).map do |q|
Spree::DeliveryRateProvider::Estimate.new(
cost: q.amount, currency: q.currency, carrier: q.carrier,
service_level: q.service, estimated_delivery_date: q.eta, metadata: { quote_id: q.id }
)
end
rescue SpreeAcme::Error => e
Rails.error.report(e, source: 'acme.rating')
[] # never raise into checkout — an empty array hides the method
end
end
end
Rails.application.config.after_initialize do
Spree.integrations << 'SpreeAcme::Integration'
Spree.delivery_rate_providers << SpreeAcme::DeliveryRateProvider
end
cost 为税前金额;Spree 会在之后应用税费。参考实现是 spree_easypost gem(SpreeEasyPost::DeliveryRateProvider + SpreeEasyPost::FulfillmentProvider + SpreeEasyPost::Integration)。
履约提供方(发货、标签、3PL/WMS)
delivery_method.fulfillment_provider(默认值为 Spree::FulfillmentProvider::Manual;还包括 Digital、Pickup、PickupPoint)负责出库操作。WMS/3PL/承运商集成应继承 Spree::FulfillmentProvider::Base,并实现 create_fulfillment(fulfillment)(可选返回 { tracking_number:, tracking_url: })和 cancel_fulfillment(fulfillment);生成标签的承运商还应重写 self.generates_labels?、purchase_label(owner)(owner 为 Fulfillment 或 Return)、refund_label(label),并可选重写 tracking_url(delivery)、documents(owner)、can_fulfill?(fulfillment)。在 after_initialize 中注册:Spree.fulfillment_providers << MyProvider(core 也会为该列表赋值)。
承运商追踪 Webhook:Spree 提供 POST /api/v3/webhooks/fulfillments/:integration_id(带前缀的集成 ID,无 API key)。系统仅通过 ID 查找集成,并在该集成自己的商店中处理请求(Spree::Current.store = integration.store),因此每个集成使用一个 URL 即可服务所有商店。你的 Spree::Integration 子类需实现 parse_webhook_event(raw_post, headers):验证签名(抛出 Spree::Integration::WebhookSignatureError → 401;未配置密钥时也如此),并返回 { tracking_code:, tracking_status:, estimated_delivery_at:, delivered_at:, details: },或返回 nil 以忽略该事件(→ 200)。控制器会根据追踪号码在该集成的商店中查找 Spree::Delivery,并运行 Deliveries::UpdateTracking;delivered 状态会确认货物已在履约中送达。将未知的承运商状态映射为 'unknown'。
订单路由与库存拆分器
有两个层面决定商品如何转化为履约:
- 哪些库存位置 — 订单路由。默认策略
Spree::OrderRouting::Strategy::Rules会遍历渠道的Spree::OrderRoutingRule记录(PreferredLocation、MinimizeSplits、DefaultLocation;排名越低越优先,nil表示弃权)。自定义规则:继承Spree::OrderRoutingRule,实现rank(order, locations),为每个库存位置返回一个LocationRanking;通过Spree.order_routing.rules << MyRule注册,并为每个渠道创建一条记录。自定义策略:继承Spree::OrderRouting::Strategy::Base,实现for_allocation(返回Spree::Stock::Package),以及for_sale(fulfillment:)、for_release、for_cancellation;通过Spree.order_routing.strategies << MyStrategy注册,并使用store.preferred_order_routing_strategy/channel.preferred_order_routing_strategy进行选择(未注册的值会回退到 Rules)。 - 如何将某个库存位置的分配拆分成多个包裹 — 拆分器。默认链为
Spree.stock_splitters = [Splitter::DeliveryProfile, Splitter::Backordered];Splitter::Weight(类级别的threshold,默认值为 150)需显式启用。自定义拆分器:继承Spree::Stock::Splitter::Base,实现split(packages),并始终以return_next(packages)结束。
了解路由实际应用的位置。 店面结账通过 Cart#rebuild_fulfillments! 构建履约,该方法会直接调用 Spree::Stock::Coordinator——它会遵循配送配置文件的来源分组、渠道服务的库存位置以及拆分器链,但不会查询路由策略或路由规则。Carts::Complete 会将购物车的履约复制到订单。路由策略会在 Order#rebuild_fulfillments! 中运行——用于管理后台创建/更新草稿订单(Spree::Orders::BuildFulfillments,订单下单后则不执行任何操作),以及已下单订单的 OrderInventory 变更。编辑已下单订单的商品不会重建其履约——Spree::OrderInventory 会就地调整履约,因此下单时的分配仍会保留在可供取消/发货操作的记录中。策略的 for_sale / for_release / for_cancellation 属于约定接口,但核心代码目前不会调用它们(Rules 将其实现为空操作)——请改在履约工作流钩子或 fulfillment.* 订阅者中执行分配后的副作用。索赔/换货替换也会使用 Coordinator。要更改店面购物车的发货仓库,请使用配送配置文件/来源分组、渠道库存位置,或为 Coordinator 添加装饰器——仅使用路由规则无法实现。
# Splitters are assigned by core in after_initialize — append there, not in to_prepare
Rails.application.config.after_initialize do
Spree.stock_splitters << MyApp::Stock::Splitter::Refrigerated
end
自提、数字商品与货运
- 自提:使用
FulfillmentProvider::Pickup的配送方式服务于启用了自提的库存位置(GET /api/v3/store/delivery_methods/:id/pickup_locations);PickupPoint会请求自提点提供方(…/pickup_points?latitude=&longitude=)。自提订单通过标记为已送达来结单。 - 数字商品:为商品指定商店的数字配送配置文件。
Digital履约提供方会在下单时自动完成履约,并授予Spree::DigitalLink(事件digital_link.downloaded)。如需交付上传文件以外的内容,请继承Spree::DigitalAssetProvider::Base(实现deliver(digital_link, expires_in:)),并在Spree.digital_asset_providers中注册。 - 货运:使用受
volume_rule/company_rule限制的Freight费率提供方配送方式;返回的费率为unpriced: true,显示“审核后报价”,排序位于已定价费率之后,结账仍会完成。
API 接口
- 存储:
PATCH /api/v3/store/carts/:cart_id/fulfillments/:id,并传入selected_delivery_rate_id;费率位于cart.fulfillments[].delivery_rates。 - 更改商品后仍会保留所选项。每次重新计算都会重建提案(
rebuild_fulfillments!),并沿用每个库存地点所选的费率(相同承运商服务;否则相同方式;否则使用估算器的默认值),再按新商品内容重新计价。只有更改目的地(Address::DESTINATION_FIELDS:地址行、城市、州、邮政编码、国家/地区)才会重置为默认费率;更改电话或姓名则会保留原选择。重建时不会重新计算已下单订单的配送价格。 - 管理端 — 权限键:fulfillments 端点使用
read_/write_fulfillments(嵌套的标签/配送位于*_orders下),方式/规则使用*_delivery_methods,包装类型使用*_package_types,区域和配置文件使用*_settings。端点:…/orders/:order_id/fulfillments(index/show/create/update)及成员操作fulfill(items: [{ item_id, quantity }]、tracking、tracking_carrier、notify_customer、force)、mark_delivered、cancel、split;嵌套的labels(create/destroy、download、refund)和deliveries(CRUD、mark_delivered)。设置:delivery_methods(以及calculators、rate_providers、fulfillment_providers、嵌套的rules)、delivery_method_rules/types、delivery_zones、delivery_profiles(以及kinds、嵌套的origin_groups)、package_types、tracking_carriers。 - 管理端 SDK:
adminClient.orders.fulfillments.{list,fulfill,markDelivered,cancel,split}、.labels.*、.deliveries.*。
事件
fulfillment.created/updated/deleted、fulfillment.fulfilled(元数据 notify_customer)、fulfillment.delivered、fulfillment.canceled;delivery.created/updated/deleted;shipping_label.purchased/.refunded(以及生命周期事件);订单级别的 order.fulfilled、order.delivered。没有 fulfillment.resumed。使用订阅器进行 WMS 同步和邮件发送(spree-events-webhooks)。
故障排除
结账时没有配送费率(购物车显示 delivery_unavailable 警告,履行记录被删除):
- 商品的配送配置文件没有适用于该地址/来源地点的配送方式(配送区域或来源组不匹配)。
DeliveryMethodRule排除了该包裹(商品总额、重量、体积、公司、渠道)。- 计算器的货币与购物车货币不一致,或计算器返回了
nil。 - 费率提供方集成未连接、发生错误(检查错误报告),或仅返回了外币估价。
- 控制台:
Spree::Stock::Coordinator.new(cart).packages.map { |p| [p.stock_location.name, p.delivery_rates.map(&:name)] };也可以使用Spree::Stock::Estimator.new(cart).delivery_rates(package, Spree::DeliveryMethod::BACKOFFICE),以包含仅限管理端使用的方式。
履行操作返回 422:查看错误信息 — 未付款(使用 force 或先捕获付款)、有缺货商品、该地点的 count_on_hand 不足(更正库存,或使用 force)、订单处于草稿状态,或你自己的 validate 钩子。
发货仓库错误:店面购物车会忽略路由规则(见上文)——检查商品的配送配置文件来源组、渠道的库存地点,以及每个地点是否存在 StockLevel。
跟踪 Webhook 无效:按设计,端点会对未知跟踪代码和 nil 事件返回 200;401 表示签名验证失败;集成必须在当前商店中处于启用状态。
测试
工厂(spree_dev_tools / spree/testing_support)::fulfillment、:fulfillment_item、:delivery_method、:free_delivery_method、:digital_delivery_method、:pickup_delivery_method、:delivery_zone、:delivery_zone_with_country、:delivery_profile、:digital_delivery_profile、:delivery_rate、:delivery、:stock_package、:cart_ready_for_delivery、:completed_order_with_totals。对承运商客户端进行存根处理——每次结账都会运行费率报价。在请求之外构建费率的规格测试中,设置 Spree::Current.store。
延伸阅读
node_modules/@spree/docs/dist/developer/core-concepts/fulfillments.md、delivery-setup.md、freight.md、calculators.mdnode_modules/@spree/docs/dist/developer/how-to/custom-delivery-rate-provider.md、custom-order-routing.md、custom-stock-splitter.md、sell-digital-products.md、custom-digital-asset-provider.md- 深入了解配送服务提供方:
node_modules/@spree/docs/dist/developer/providers/fulfillment.md;EasyPost 设置:node_modules/@spree/docs/dist/integrations/shipping/easypost.md - 更深入的契约:
references/provider-contracts.md - 相关技能:spree-inventory(库存水平、预留、变动)、spree-returns、spree-workflows(钩子、自定义工作流)、spree-providers、spree-checkout、spree-order-totals。