mcpField
S
spree-shipping-fulfillment

spree-shipping-fulfillment

github.com/spree开发10119 次安装4 周前更新
代码仓库

关于 spree-shipping-fulfillment

面向 Spree 商店开发者的配送与履约参考技能,说明配送方式、库存地点、运费、发货状态和退货之间的关系。内容涵盖多仓库路由、定制计算器及外部履约接入。

主要功能

  1. 01讲解配送费率与适用条件
  2. 02说明 Shipment 状态流转
  3. 03介绍多库存地点订单路由
  4. 04记录库存变动与退货流程
  5. 05提供自定义计算器和分包指引
  6. 06说明外部履约的登记方式

适用场景

  1. 01排查结账时没有配送费率
  2. 02分析订单为何分配到特定仓库
  3. 03将 3PL 履约结果写回 Spree
  4. 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 状态”

按以下清单排查:

  1. 付款是否完成? 订单付款完成后,发货单才会变为 ready。order.payment_state == 'paid'。
  2. 库存是否已分配? shipment.inventory_units.all? { |iu| iu.on_hand? }——如果其中任何商品单位处于 backordered 状态,则表示正在等待补货。
  3. 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 中保留。

评论

支持 Markdown