mcpField
S
spree-shipping-fulfillment

spree-shipping-fulfillment

github.com/spree開発10119 回インストール4 週間前に更新
リポジトリ

spree-shipping-fulfillment について

Spree 開発者向けに、配送方法、在庫拠点、送料、Shipment の状態、返品の関係を解説するリファレンススキルです。複数拠点のルーティング、独自計算機、外部フルフィルメントの記録も扱います。

主な機能

  1. 01送料と適用条件の解説
  2. 02Shipment の状態遷移の説明
  3. 03複数拠点の注文ルーティング
  4. 04在庫移動と返品の説明
  5. 05独自計算機と荷物分割の手引き
  6. 06外部フルフィルメント記録の説明

活用シーン

  1. 01チェックアウトで送料が表示されない原因調査
  2. 02注文の出荷倉庫が決まる理由の確認
  3. 033PL のフルフィルメント結果を Spree に記録
  4. 04送料計算や荷物分割の拡張

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/spree/agent-skills --skill spree-shipping-fulfillment

インストール後、Claude Code で/spree-shipping-fulfillmentを入力して読み込まれたか確認します。

詳細

Spree の配送

配送システムでは、次の 2 つの問いに答えます。この注文はどこへ配送するのか(配送方法)と、どこから配送するのか(在庫ロケーション)です。これらを組み合わせて 1 つ以上の Shipment が作成されます。各 Shipment は、特定の StockLocation から特定の ShippingMethod を利用して発送される荷物を表します。

配送の構成

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 が設定されています。ShippingMethod をその配送に利用できるかどうかは、バリアントのカテゴリーがそのメソッドで許可されたカテゴリーに含まれていることと、配送先がそのメソッドの 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 で直接発送できます。)

State意味
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 に設定されます。お客様はチェックアウト画面で別の料金を選択できます。

組み込みの計算機

Calculator計算内容
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 ごとに追跡されます。ストアには少なくとも 1 つの 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 を作成し、モデルに在庫数を再計算させてください。

出荷が在庫ロケーション間でどのように分割されるか

注文が出荷に分割されると、Spree は出荷元に応じて InventoryUnit をグループ化します。

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 以降、注文をどの在庫ロケーションから出荷するかは、スプリッターではなく Order Routing によって決まります。各チャネルには、候補となる在庫ロケーションの優先順位を決める Spree::OrderRoutingRule レコード(STI サブクラス:Spree::OrderRouting::Rules::PreferredLocation、MinimizeSplits、DefaultLocation。この優先順位でシードされます)の順序付きリストがあります。ルーティング戦略は store.preferred_order_routing_strategy で設定でき(デフォルトは Spree::OrderRouting::Strategy::Rules)、チャネルごとに上書きできます。Spree::OrderRouting::Strategy::Legacy を使うと、Spree 5.5 より前の Spree::Stock::Coordinator の動作に戻りますが、6.0 で削除されます。その後、スプリッターが選択された各ロケーションの割り当てを、そのロケーション内で複数のパッケージに分割します。

各 Shipment で個別に ShippingRate を計算します(出荷元が異なると料金も異なるためです)。顧客は各出荷で選択された料金を支払います。

距離ベース、最寄りの倉庫を優先、分割の最小化といったロケーション優先ロジックを実装するには、カスタムの注文ルーティングルールまたは戦略を作成してください。詳細は node_modules/@spree/docs/dist/developer/how-to/custom-order-routing.md を参照してください。1つのロケーションへの割り当てを複数のパッケージ(冷蔵品、危険物、ギフト包装など)に分割するには、カスタムスプリッターを作成してください。詳細は node_modules/@spree/docs/dist/developer/how-to/custom-stock-splitter.md を参照してください。

手動フルフィルメント(3PL/外部フルフィルメント)

外部で管理されるフルフィルメント(3PL または宅配業者の API による、処理済み注文の一部の出荷)を Spree に反映するには、出荷を手作業で作成するのではなく、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

管理者は、顧客が返品する InventoryUnit を列挙した 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 は表示順にのみ影響します。どの料金に selected: true が設定されるかは、先に実行される choose_default_shipping_rate(デフォルトでは最も安い料金を選択)が決定します。

よくある配送の問題

「Shipment が pending のままになる」

次の項目を確認してください。

  1. 支払いが完了していない? 注文の支払いが完了するまで、Shipment は ready に移行しません。order.payment_state == 'paid'。
  2. 在庫が割り当てられていない? shipment.inventory_units.all? { |iu| iu.on_hand? } を確認してください。backordered のものがある場合は、在庫待ちです。
  3. determine_state が pending を返している? これが明示的なブロッカーです。shipment.determine_state(shipment.order) を使って、Shipment が注文の状態をどう認識しているか確認してください。
「チェックアウト時に配送料が表示されない」
  • 住所の Zone を対象とする ShippingMethod がありません。 国に対応するメソッドを追加するか、既存のメソッドの Zone にその国を追加してください。
  • バリアントの ShippingCategory を対象とする ShippingMethod がありません。 各バリアントにカテゴリが設定されており、各メソッドでそのカテゴリが許可されていることを確認してください。
  • すべてのメソッドの計算器が誤って nil または 0 を返しています。 コンソールで、order.shipments.map(&:to_package) 内の各 package に対して Spree::Stock::Estimator.new(order).shipping_rates(package) を呼び出し、配送料を確認してください。
「注文の配送元が誤った倉庫になっている」

注文の配送先ロケーションは Order Routing が決定します。デフォルトの 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 でのみ引き続き使用されます。

コメント