关于 spree-shipping-fulfillment
面向 Spree 商店开发者的配送与履约参考技能,说明配送方式、库存地点、运费、发货状态和退货之间的关系。内容涵盖多仓库路由、定制计算器及外部履约接入。
主要功能
- 01讲解配送费率与适用条件
- 02说明 Shipment 状态流转
- 03介绍多库存地点订单路由
- 04记录库存变动与退货流程
- 05提供自定义计算器和分包指引
- 06说明外部履约的登记方式
适用场景
- 01排查结账时没有配送费率
- 02分析订单为何分配到特定仓库
- 03将 3PL 履约结果写回 Spree
- 04扩展运费计算或包裹拆分逻辑
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-shipping-fulfillment安装后在 Claude Code 中输入/spree-shipping-fulfillment确认已加载。
详细介绍
Spree 配送
配送系统需要回答两个问题:订单要发往哪里(配送方式),以及 从哪里发货(库存地点)。两者共同生成一个或多个 Shipment——每个 Shipment 都代表一个包裹,通过特定的配送方式从特定的 StockLocation 发出。
配送关系图
Order
└── Shipment (one per fulfillment package — may be many per order)
├── StockLocation — where it ships from
├── ShippingRate × n — offered rates from configured methods
│ └── ShippingMethod — UPS Ground, USPS Priority, etc.
└── InventoryUnit × n — per LineItem, each carrying a quantity (may split, e.g. partial backorder)
ShippingMethod
├── ShippingCategory × n — which categories of items this method handles
├── Calculator — how much (per-order, per-item, weight-based)
└── Zone × n — where this method applies
Variant
└── ShippingCategory — heavy items, fragile items, digital, etc.
每个订单项的变体都有一个 ShippingCategory。配送方式能否用于某个 Shipment,取决于该变体的类别是否属于此方式允许的类别,以及目的地是否位于此方式的 Zone 内。
Shipment 状态机
pending ──ready──→ ready ──ship──→ shipped
│ ←─pend── │ ↑
│ │ │
└────cancel───→ canceled ──ship────┘
│
└──resume──→ ready (or pending if not yet ready)
(cancel 可从 pending 和 ready 状态执行;resume 会将已取消的 Shipment 恢复为 ready——如果订单尚未就绪,则恢复为 pending;已取消的 Shipment 也可以直接通过 ship 发货。)
| 状态 | 含义 |
|---|---|
pending | 已创建,但尚未准备好发货(等待付款、分配等) |
ready | 库存已分配,仓库可以开始拣货 |
shipped | 已由承运商取件;已设置跟踪号码 |
canceled | 订单已取消;Shipment 不会发出 |
状态转换:ready、pend(退回 pending)、ship、cancel、resume。ship 转换会触发 shipment.shipped 事件(订阅者会收到通知),并根据订单的其他 Shipment 将 order.shipment_state 更新为 shipped 或 partial。cancel 和 resume 转换分别会触发 shipment.canceled 和 shipment.resumed 事件。
如何计算配送费率
当订单进入结账的 delivery 状态(填写地址之后):
For each Shipment in order:
package = shipment.to_package
Spree::Stock::Estimator.new(order).shipping_rates(package)
↓
For each ShippingMethod where:
- method.shipping_categories.include?(variant.shipping_category)
- method.zones.include?(zone_for(order.ship_address))
↓
Run method.calculator.compute(package)
↓
ShippingRate(shipping_method: method, cost: amount, selected: best)
每个 Shipment 默认选择最便宜的费率,即 selected: true。客户可以在结账界面中选择其他费率。
内置计算器
| 计算器 | 计算内容 |
|---|---|
Spree::Calculator::Shipping::FlatRate | 固定费率;可选最小/最大重量和商品总额范围(超出范围时返回 nil → 配送方式不可用) |
Spree::Calculator::Shipping::FlatPercentItemTotal | 订单商品总额的百分比 |
Spree::Calculator::Shipping::PerItem | 费率 × 商品数量 |
Spree::Calculator::Shipping::FlexiRate | 按商品数量分档 |
Spree::Calculator::Shipping::PriceSack | 按订单总额分档(例如:低于 $50 收费 $10,高于 $50 免费) |
Spree::Calculator::Shipping::DigitalDelivery | 可配置金额(默认 0);仅当包裹中的所有商品均为数字商品时可用 |
自定义配送计算器需继承 Spree::ShippingCalculator,并实现 compute_package(package)(可选实现 available?(package))。package 是一个 Spree::Stock::Package,包含内容物、总重量和商品总额。
StockLocation
库存通过 Spree::StockItem 按 Variant 和 StockLocation 跟踪。每个商店至少有一个 StockLocation;拥有多个仓库的商店则有多个。
warehouse = Spree::StockLocation.create!(
name: 'East Coast Warehouse',
address1: '...',
city: '...',
state_id: Spree::State.find_by(name: 'New York').id,
country_id: Spree::Country.find_by(iso: 'US').id,
propagate_all_variants: true, # auto-create StockItem for every Variant
active: true,
backorderable_default: false,
default: false # only one StockLocation can be the default
)
variant.stock_items.where(stock_location: warehouse).first.count_on_hand
variant.total_on_hand # summed across all locations
StockMovement
库存变更会记录为 Spree::StockMovement 条目,作为审计日志:
stock_item = warehouse.stock_item_or_create(variant)
stock_item.stock_movements.create!(
quantity: 10, # positive = received, negative = sold/lost
originator: purchase_order # polymorphic — what caused the movement
)
# or the higher-level helpers (these wrap the same StockMovement creation):
warehouse.restock(variant, 10, purchase_order)
warehouse.unstock(variant, 2, shipment)
不要直接更新 count_on_hand;请创建 StockMovement,并由模型重新计算库存数量。
发货如何在 StockLocations 之间拆分
将订单拆分为多个发货单时,Spree 会根据库存单位可从哪个位置发出进行分组:
Order has 3 items: [A from East, B from East, C from West]
↓
Order Routing (`order.order_routing_strategy.for_allocation`, default `Spree::OrderRouting::Strategy::Rules`) ranks eligible StockLocations via the channel's routing rules, packs each location (`Spree::Stock::Packer` + the `Spree.stock_splitters` chain), and `Spree::Stock::Prioritizer` assigns each inventory unit to the first ranked package with on-hand stock
↓
Creates 2 Shipments:
- Shipment 1: items A + B from East Warehouse
- Shipment 2: item C from West Warehouse
从 Spree 5.5 开始,决定由哪些库存位置履行订单的是订单路由,而不是拆分器。每个渠道都有一个按顺序排列的 Spree::OrderRoutingRule 行列表(STI 子类:Spree::OrderRouting::Rules::PreferredLocation、MinimizeSplits、DefaultLocation——按该优先级顺序预置),用于对候选库存位置进行排序。路由策略可通过 store.preferred_order_routing_strategy 配置(默认值为 Spree::OrderRouting::Strategy::Rules),也可按渠道覆盖;Spree::OrderRouting::Strategy::Legacy 可恢复 5.5 之前的 Spree::Stock::Coordinator 行为,并将在 6.0 中移除。然后,拆分器会将每个选定位置的分配拆分为该位置内的多个包裹。
每个发货单都会单独计算配送费率(发货来源不同,费率也不同)。客户需支付每个发货单所选的费率。
对于位置偏好逻辑(基于距离、优先选择最近的仓库、尽量减少拆分),请编写自定义订单路由规则或策略,参见 node_modules/@spree/docs/dist/developer/how-to/custom-order-routing.md。若要将一个位置的分配拆分为更多包裹(冷藏、危险品、礼品包装),请编写自定义拆分器,参见 node_modules/@spree/docs/dist/developer/how-to/custom-stock-splitter.md。
手动履约(3PL / 外部履约)
若要将外部管理的履约情况同步回 Spree(例如,3PL 或快递 API 发出了已完成订单中的部分商品),请使用 Spree::Fulfillments::Create(5.5+),而不是手动创建发货单。它会绕过订单路由,将请求中尚未发出的商品单位从当前发货单移至新的发货单,并处理库存记账(位置不同时,在来源位置补货 / 在目标位置扣减库存):
result = Spree.fulfillment_create_service.call(
order: order, # must be completed, not canceled
stock_location: three_pl_location,
items: [{ line_item: li, quantity: 1 }], # nil = everything not yet shipped
tracking: '1Z…',
delivery_method: shipping_method, # defaults to the drained source shipment's
cost: '12.50', # explicit 3PL price; defaults to carrying over the source cost
status: 'shipped' # register as already shipped ('shipped' is the only accepted value; omit for pending)
)
result.valid? # true if the fulfillment was created successfully
请注意,显式指定的 cost 仅在 status: 'shipped' 时保留——待处理的履约会在下次重新计算时由费率引擎重新定价。
退货与逆向物流
客户想要退回一件商品:
ReturnAuthorization (admin-created, lists which InventoryUnits)
↓
Customer ships item back
↓
CustomerReturn (admin-received, links InventoryUnits to receipt)
↓
Reimbursement (calculates the refund amount from the return items' amounts — admins can adjust per-item amounts before reimbursing)
↓
Refund (to original payment) OR StoreCredit
管理员会创建一个 ReturnAuthorization,列出客户要退回的库存单位。商品退回后,管理员会记录一条 CustomerReturn,标记已收到这些商品单位。Reimbursement 会根据退货商品的金额计算退款金额,并生成 Refund(退回至原支付方式)或 StoreCredit。
自定义配送
自定义计算器
# app/models/spree/calculator/shipping/weight_based.rb
module Spree
class Calculator::Shipping::WeightBased < Spree::ShippingCalculator
preference :rate_per_kg, :decimal, default: 5.00
def compute_package(package)
package.weight * preferred_rate_per_kg
end
end
end
# Register so it shows in the admin shipping method UI (config/initializers/spree.rb)
Rails.application.config.after_initialize do
Spree.calculators.shipping_methods << Spree::Calculator::Shipping::WeightBased
end
接入发货单事件
对于外部仓库集成,请订阅 shipment.shipped:
class ShipmentShippedSubscriber < Spree::Subscriber
subscribes_to 'shipment.shipped'
def call(event)
shipment = Spree::Shipment.find_by_prefix_id(event.payload['id'])
return unless shipment
ExternalWarehouseAPI.notify_dispatched(
tracking: shipment.tracking,
shipping_method: shipment.shipping_method.name,
order_number: shipment.order.number
)
end
end
有关事件系统,请参阅 spree-events-webhooks 技能。
自定义配送费率排序
默认情况下会选择最低费率。若要优先考虑承运商的可靠性,请装饰 Spree::Stock::Estimator:
module Spree::Stock::EstimatorDecorator
# which rate is pre-selected
def choose_default_shipping_rate(rates)
preferred = rates.find { |r| r.shipping_method.name == 'UPS Ground' }
(preferred || rates.min_by(&:cost))&.selected = true
end
# display order
def sort_shipping_rates(rates)
rates.sort_by { |r| [r.shipping_method.name == 'UPS Ground' ? 0 : 1, r.cost] }
end
Spree::Stock::Estimator.prepend self
end
注意:sort_shipping_rates 只影响显示顺序;choose_default_shipping_rate(先运行,默认选择最低费率)决定哪个费率会被标记为 selected: true。
常见配送问题
“发货单卡在 pending 状态”
按以下清单排查:
- 付款是否完成? 订单付款完成后,发货单才会变为
ready。order.payment_state == 'paid'。 - 库存是否已分配?
shipment.inventory_units.all? { |iu| iu.on_hand? }——如果其中任何商品单位处于backordered状态,则表示正在等待补货。 determine_state是否返回pending? 这是明确的阻塞原因;通过shipment.determine_state(shipment.order)检查发货单认为订单处于什么状态。
“结账时未显示运费”
- 没有
ShippingMethod覆盖该地址所属的 Zone。 为该国家/地区添加一种配送方式,或将该国家/地区添加到现有配送方式的 Zone 中。 - 没有
ShippingMethod覆盖该变体的ShippingCategory。 确保每个变体都有类别,并且每种配送方式都允许该类别。 - 所有配送方式的计算器都错误地返回 nil/零。 在控制台中,对
order.shipments.map(&:to_package)中的每个package调用Spree::Stock::Estimator.new(order).shipping_rates(package),检查运费。
“订单从错误的仓库发货”
订单路由决定发货地点:默认的 Spree::OrderRouting::Strategy::Rules 会遍历渠道的路由规则(preferred_location → minimize_splits → default_location 基准规则)。调整渠道的 Spree::OrderRoutingRule 记录;如果希望优先选择最近的仓库,则编写自定义路由规则(实现 #rank(order, locations))或策略,参见 node_modules/@spree/docs/dist/developer/how-to/custom-order-routing.md。
“购物车发生变化时运费没有更新”
首次计算后,每个 Shipment 的运费都会缓存。购物车发生变化(添加/移除行项目)时,Shipment 会被销毁并重新创建,因此下次调用 Spree::Stock::Estimator 时会重新计算运费。如果你在 Turbo Frame 中显示运费,请确保在购物车更新时重新渲染。
延伸阅读
- 核心概念:
node_modules/@spree/docs/dist/developer/core-concepts/shipments.md、inventory.md - 自定义库存拆分器:
node_modules/@spree/docs/dist/developer/how-to/custom-stock-splitter.md - 自定义订单路由:
node_modules/@spree/docs/dist/developer/how-to/custom-order-routing.md - 库存服务:
Spree::Stock::Estimator、Spree::Stock::Packer、Spree::Stock::Prioritizer、Spree::Stock::Splitter::Base(以及ShippingCategory/Backordered/Digital/Weight子类)。默认情况下,分配由Spree::OrderRouting::Strategy::Rules处理;Spree::Stock::Coordinator已弃用(计划在 6.0 中移除),仅在可选的 Legacy 路由策略、Spree::Exchange和Spree::Cart::EstimateShippingRates中保留。