mcpField
S
spree-workflows

spree-workflows

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

spree-workflows について

spree-workflows は、Spree のサービスとワークフローの構成、拡張フック、結果処理を解説する開発者向けスキルです。コア処理を直接変更せず、適切な箇所に業務ロジックを追加できます。

主な機能

  1. 01サービスとワークフローの役割
  2. 02検証、コンテキスト、ライフサイクルフック
  3. 03フック登録と処理の拒否
  4. 04ワークフロー拡張ポイントの一覧
  5. 05イベントと処理置換の指針

活用シーン

  1. 01条件に応じたチェックアウトの拒否
  2. 02税やプロモーション計算へのコンテキスト追加
  3. 03ライフサイクルフックで関連レコードを作成
  4. 04フック、イベント、ワークフローの選択

インストール

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

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

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

同梱ファイルreferences/hooks.md

詳細

Spree のワークフローとフック

コマンドでは Spree CLI(spree …)を使用します。CLI のない従来の Rails アプリでは、アプリのルートから bin/rails … / bundle exec rake … を使用してください。以下のパスは、Rails アプリが server/ にあることを前提としています。

Spree のビジネスロジックは、同じ方法で呼び出され、同じ Spree::ServiceModule::Result を返す、2 種類のプレーンな Ruby クラスにあります。

階層配置場所用途
サービスapp/services/単純な作成・更新・削除など、順次実行する単一の call 本体。Spree の大部分を占めます。
ワークフロー(Spree::Workflow)app/workflows/拡張ポイント、外部呼び出し(ゲートウェイ、配送業者)、または補償処理(後続のステップが失敗した場合の取り消し)が必要なフローです。カートへの追加・完了、注文のキャンセル、支払いのキャプチャ、フルフィルメント、返品、商品ステータスの変更、インポートなどが該当します。
result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 2)
result.success?   # never raise on expected failures — check this
result.value      # the line item
result.error.to_s # message on failure; result.error.value is ActiveModel::Errors for rejections

Spree にステートマシンはありません。レコードには通常の status 文字列(Spree::HasStatus)があり、各遷移はそれぞれ独自のフックを持つワークフローです。

カスタムロジックの配置場所 — まず判断する

やりたいこと使用するもの
操作を中止する(購入上限、B2B 購入資格、不正利用保留、返品ポリシー)validate フック
計算に使うデータを提供する(免税、プロモーションの適用資格、配送業者データ)コンテキストフック(set_*_context、get_provider_data、extend_payload)
変更と関連する行をアトミックに書き込むライフサイクルフック(after_*。一部はトランザクション内で実行されます)
メール送信、Webhook の呼び出し、検索インデックスの更新、ERP の同期イベントサブスクライバー — spree-events-webhooks
チェックアウト要件を追加する(顧客が X を提供する必要がある)Spree::Checkout::Registry.add_requirement — spree-checkout
操作全体を置き換えるSpree.dependencies — spree-dependencies(最後の手段です。置き換えた実装は自分で保守します)
新しい複数ステップの操作を追加する独自の Spree::Workflow(下記)

最小限の方法を選んでください。フックはアップグレード後も維持されますが、クラスを置き換えた場合はリリースごとに再同期する必要があります。

モデルにビジネス処理を追加するのは避けてください(書き込みを行う新しいパブリックモデルメソッド、after_save による副作用、遷移コールバックなど)。また、finalize!、Order#cancel、Payment#capture! などをデコレートしたりパッチしたりするのも避けてください。これらはワークフローに処理を委譲する非推奨のシェル(Order#finalize! → Spree.order_complete_workflow)であり、実際のフロー(Spree::Carts::Complete、Spree::Orders::Cancel、Spree::Payments::Capture)からオーバーライドが呼び出されることはありません。代わりに、ワークフローにフックを使用してください。

フックの登録

config/initializers/spree.rb で:

Spree.hooks.register('carts.add_item.validate', 'MyStore::CheckPurchaseLimit')
Spree.hooks.register('carts.upsert_items.validate', 'MyStore::CheckPurchaseLimit')

# A block works for one-liners (but can't be deduplicated or unregistered by name)
Spree.hooks.register('orders.cancel.after_cancel') do |workflow|
  MyStore::Analytics.track(:order_canceled, workflow.order.number)
end
  • キーは '<workflow key>.<hook>' です。ワークフローキーはクラス名から導出されます:Spree::Carts::AddItem → carts.add_item。
  • ハンドラーはクラス名の文字列として保存され、フックの実行時に定数化されます。これにより、起動時やリロード時にも安全です。同じクラスを複数回登録しても何も起こりません。
  • タイミング: Spree.hooks はプロセス全体で使われるレジストリーで、コアによってリセットされることはないため、イニシャライザーでのトップレベル登録が適切です(コアが再割り当てするため after_initialize で追加する必要がある payment_methods などのレジストリーとは異なります)。config.to_prepare には登録しないでください。開発時のリロードごとに再実行され、ブロックハンドラーが重複します。Rails.application.config.after_initialize が必要なのは、コアが登録するハンドラーを登録解除する場合(下記)のみです。
  • ハンドラーには #call(workflow) を持つ任意のクラスを指定できます。受け取るのはワークフローインスタンスです。すべての #perform キーワードはパブリックリーダー(workflow.cart、workflow.variant、workflow.quantity)として利用でき、さらにワークフローが attr_reader で公開する値(workflow.line_item、workflow.order、workflow.fulfillment など)も利用できます。各フックでどのリーダーに値が設定されるかは、ワークフローのソースまたは references/hooks.md を確認してください。たとえば、carts.add_item.validate の時点では line_item は nil です。
  • eager_load が有効な場合(本番環境、および通常は CI)には、起動時にキーが検証されます。誤記があると、有効なフックの一覧を含む Spree::Hooks::UnknownHookError が発生します。開発環境(遅延読み込み)では、無効なキーがあってもフックは実行されませんが、エラーも発生しません。確認するには、Rails.application.eager_load! の後にコンソールで Spree.hooks.validate! を実行してください。
  • Spree.hooks.unregister(key, 'Handler') は指定したハンドラーを1つ登録解除し、unregister(key) はそのキーに登録されたすべてのハンドラーを登録解除します。
module MyStore
  class CheckPurchaseLimit
    def call(workflow)
      return if (workflow.quantity || 1).to_i <= 10   # carts.add_item quantity defaults to nil (=1)

      workflow.errors.add(:quantity, :purchase_limit_exceeded,
                          message: 'You can order at most 10 of this item.')
      workflow.reject!
    end
  end
end

workflow.errors は ActiveModel::Errors です。reject!(または reject!('message')。これは :base に対するエラーの省略記法です)を呼び出すと、フローを中断し、補償処理を巻き戻し、開いているトランザクションをロールバックして、失敗結果を返します。API はモデルのバリデーション失敗とまったく同じ形式でこれを返します。つまり、フィールドごとにキー付けされた details を含む 422 validation_error となるため、SDK クライアントで特別な処理を行う必要はありません。クライアントがコードに基づいて分岐できるよう、message: とともにシンボリック型(:purchase_limit_exceeded など)を使うことを推奨します。

carts.add_item は数量に加算しますが、carts.upsert_items は数量を設定します(数量の編集、削除、一括ペイロード)。カートのルールは両方のキーに設定します。リーダー(cart、variant、quantity)は一致するため、1つのハンドラークラスをそれぞれに登録できます。ストアフロントでは、upsert_items が拒否されてもバッチ全体は失敗せず、そのアイテムはスキップされ、カートの warnings に報告されます。管理画面での注文編集では、編集全体が失敗します。

3種類のフック

種類名前実行タイミング戻り値複数のハンドラー
検証validate(および orders.cancel.before_cancel)書き込みが行われる前。拒否してもコストはかかりません無視されます。reject! で拒否します登録順に実行されます。最初の reject! でフローが停止し、後続の検証ハンドラーは実行されません
コンテキストset_promotion_context、set_tax_line_context、get_provider_data、extend_payload計算の前Hash を返します。各ハンドラーの値が(ディープに)マージされ、計算に渡されますすべて実行されます。キーが衝突すると、最後に登録された値が優先され、その内容が Rails.error で報告されます
ライフサイクルbefore_* / after_*(after_item_added、before_finalize、after_cancel、before_capture など)処理の前後、または処理後。複数のフックはフローのトランザクション内で実行されることがあります無視されますすべて登録順に実行されます
# Context hook: contribute tax-exemption evidence to totals recalculation
module MyStore
  class TaxExemption
    def call(workflow)
      cert = MyStore::ExemptionLookup.for(workflow.cart.customer)
      cert ? { exemption_certificate: cert.number } : {}
    end
  end
end
Spree.hooks.register('carts.recalculate_totals.set_tax_line_context', 'MyStore::TaxExemption')

マージされたハッシュは context: として税プロバイダーに渡されます。プロモーションの場合は workflow.promotion_context として公開されます(carts.recalculate.set_promotion_context)。

設計時に考慮すべき複数ハンドラーのルール
  • フックを単独で使えるとは考えないでください。 アプリ、拡張機能、Spree 自身がハンドラーを登録します(コアは Spree::Returns::EligibilityValidator を returns.create.validate と exchanges.create.validate に登録します)。
  • ハンドラー間の実行順序に依存しないでください。 順序は登録順(gem のイニシャライザーの読み込み順)です。2 つの処理を順番に実行する必要がある場合は、1 つのハンドラーにまとめてください。
  • ハンドラーは分離されていません。 例外はワークフローの外に伝播し、後続のハンドラーをスキップして、開いているトランザクションをロールバックします。after_item_added で raise すると、アイテムは追加されません。任意の処理では内部で例外を捕捉するか、イベントサブスクライバーに移してください。
  • 拒否するのは validate からのみにしてください。 carts.complete.before_finalize はカードへの課金後に実行されるため、そこで拒否すると、課金は成立したまま注文がロールバックされます。
  • トランザクション内のライフサイクルフックでは、時間のかかる処理を行わないでください(after_item_added、after_cancel、ステータスワークフローの after_*)。ネットワーク呼び出し中は往復通信の間、DB 接続が占有されるため、サブスクライバーを使用してください。
Spree 自身が登録したフックを置き換える

コアの返品期間バリデーターは、config/initializers/* より前に実行されるエンジンのイニシャライザーによって登録されるため、イニシャライザー内で通常の unregister を実行すると削除できます。

# config/initializers/spree.rb
Spree.hooks.unregister('returns.create.validate', 'Spree::Returns::EligibilityValidator')
Spree.hooks.unregister('exchanges.create.validate', 'Spree::Returns::EligibilityValidator')
Spree.hooks.register('returns.create.validate', 'MyStore::ReturnPolicy')
Spree.hooks.register('exchanges.create.validate', 'MyStore::ReturnPolicy')

最も有用なフックポイント

Key種類タイミング
carts.add_item.validate検証明細項目が作成される前(トランザクションの外)
carts.add_item.after_item_addedライフサイクル保存と再計算の後、トランザクション内(workflow.line_item、line_item_created)
carts.upsert_items.validate / .after_items_upserted検証 / ライフサイクル各項目の適用前 / 一括処理の後
carts.merge.validate / .after_merge検証 / ライフサイクルゲストカートから顧客カートへのマージ
carts.recalculate.set_promotion_contextコンテキストプロモーションの評価前
carts.recalculate.after_recalculateライフサイクル価格再計算の後
carts.recalculate_totals.set_tax_line_contextコンテキスト税額の見積もり前
carts.complete.validate検証チェックアウト要件の確認後、注文が作成される前 — チェックアウトをブロックする場所(与信限度額、承認の必須化)
carts.complete.before_finalize / .after_finalizeライフサイクル決済後、注文確定の前後(workflow.order、order_group)
orders.cancel.before_cancel / .after_cancel検証 / ライフサイクルキャンセルを拒否する / キャンセルに合わせて書き込む(トランザクション内)
payments.capture.validate検証不正利用の保留 — 決済ゲートウェイの呼び出し前
payments.process.validate検証オーソリまたは購入処理が決済ゲートウェイに送られる前
refunds.create.validate検証返金レコードの作成前(承認基準額)
fulfillments.create.get_provider_dataコンテキスト配送業者のアカウント / サービスレベル / 通関データ
fulfillments.fulfill.validate / .after_fulfill検証 / ライフサイクル発送済みとして記録する前 / 後
returns.create.validate検証返品の適格性(コアの返品可能期間のルールを置き換える)
customers.create.validate検証登録ポリシー(ボットのスクリーニング、B2B 承認)
customers.anonymize.validate検証GDPR に基づく消去を拒否する(係争中、法的保留)
products.activate.validate検証商品を公開する前に画像 / 価格 / カテゴリを必須にする
products.create/update.validate検証Admin API、CSV インポート、シードのすべてで実行

管理画面での下書き注文の編集では、独自のキーを持ち、同じフックを使う対になるワークフローを使用します:orders.add_item、orders.upsert_items、orders.recalculate、orders.recalculate_totals。ストアフロントのカートにはカートのキー、管理画面での編集には注文のキー、または両方に登録してください。対になるワークフローは order: を cart: として渡すため、どちらのキーでもハンドラーは workflow.cart を読み取ります。

完全なカタログ(すべてのワークフロー、フック、依存関係キー、リーダー): references/hooks.md。

実行時に確認する(rake タスクはありません — コンソールを使用してください):

Spree.hooks.workflows                     # { 'carts.add_item' => 'Spree::Carts::AddItem', ... }
Spree.hooks.carts                         # { 'carts.add_item' => [:validate, :after_item_added], ... }
Spree::Carts::Complete.declared_hooks     # [:validate, :before_finalize, :after_finalize]
Spree.hooks.keys                          # keys with registered handlers
Spree.hooks.handler_count('carts.complete.validate')
Spree.hooks.validate!                     # true, or raises UnknownHookError

独自のワークフローを作成する

外部呼び出し、補償処理、または拡張ポイントを含む、新しい複数ステップの操作に限り、独自のワークフローを作成します。単純な CRUD はサービス(またはモデルの保存処理)のままにします。

# server/app/workflows/my_store/subscriptions/renew.rb
module MyStore
  module Subscriptions
    class Renew < Spree::Workflow
      hooks :validate, :after_renew      # declare every run_hooks name
      attr_reader :order                 # derived state handlers may read

      # The signature IS the contract. Bare `super` turns each keyword into a reader.
      def perform(subscription:, renewed_at: nil)
        super

        step :ensure_renewable
        run_hooks :validate

        ApplicationRecord.transaction do
          step :build_order, on_flow_failure: :discard_order
          step :extend_period
        end

        external_step :charge_customer   # refuses to run inside a transaction this workflow opened

        run_hooks :after_renew
        subscription.publish_event('subscription.renewed')
        success(order)
      end

      private

      def ensure_renewable
        failure(subscription, :not_active) unless subscription.active?   # raises — aborts the flow
      end

      def build_order
        @order = MyStore::Subscriptions::BuildOrder.call(subscription: subscription).value
      end

      def extend_period
        subscription.update!(renews_at: (renewed_at || Time.current) + 1.month)
      end

      def charge_customer
        Spree.payment_capture_workflow.call(payment: order.payments.last)   # a failure Result fails this step
      end

      def discard_order   # undo — armed when the transaction commits, run in reverse on later failure
        order&.destroy
      end
    end
  end
end
用語意味
step :nameプライベートメソッドを実行します。返された失敗 Result によってフローは失敗します。
step :name, with: -> { Spree.some_workflow }差し替え可能なコラボレーターに処理を委譲します。そのキーワード引数は、このワークフローのリーダーから抽出されます
on_flow_failure: :undoステップの補償処理です。後続のステップが失敗した場合、逆順に実行されます
external_step :nameワークフローが開始したトランザクション内で Spree::Workflow::ContractError を発生させる、ネットワーク/ゲートウェイ I/O です
run_hooks :name宣言済みのフックをディスパッチし、マージされた Hash(コンテキストフック)を返します
failure(value, error)どこからでも処理を中断します(内部で例外を発生させるため、開いているトランザクションはロールバックされます)
workflow.reject!errors を伴う、ハンドラー向けの failure 相当のメソッドです
halt!(value)正常な早期終了です。ワークフローが開始したトランザクション内では使用できません
hooks :a, :b / workflow_key 'x.y'拡張ポイントを宣言する/派生キーを上書きします

ルール:ネットワーク呼び出しを DB トランザクション内で共有してはいけません(external_step がこれを強制します)。failure/halt! は Exception のサブクラスを発生させるため、#perform 内の通常の rescue では捕捉されません。また、rescue Exception は使用しないでください。#perform 内では、パラメーター名はリーダーをシャドーイングするローカル変数です。派生状態には別の名前を付けてください(入力は variant、出力は line_item)。

ステータス(ステートマシンの代わりに)

class MyStore::Subscription < Spree.base_class
  include Spree::HasStatus
  has_status :trialing, :active, :paused, :canceled, default: :trialing
end
# => inclusion validation, #paused?, .paused scope, .with_status(:active, :trialing)

Spree::GiftCard.add_status(:on_hold, after: :active)   # extend a core model's statuses

遷移グラフ、イベント、コールバックはありません。レコードのステータスを変更する処理はワークフローです(subscription.pause! ではなく、MyStore::Subscriptions::Pause.call(...))。カスタムステータスへレコードを移行するには、独自のワークフローが必要です。コアワークフローがチェックするのはコアステータスであり、add_status はステータスの追加のみを行います。Spree の遷移も、ワークフロー経由で呼び出してください:Spree.product_archive_workflow.call(product: product)。

オブザーバビリティ

すべての実行、ステップ、フックのディスパッチで ActiveSupport::Notifications が通知されます。

イベントペイロード
perform.spree_workflowworkflow、outcome(success/failure/error)
step.spree_workflowworkflow、step、external、outcome
hooks.spree_workflowworkflow、hook、handler_count
ActiveSupport::Notifications.subscribe('step.spree_workflow') do |*, payload|
  Rails.logger.info("#{payload[:workflow]}##{payload[:step]} #{payload[:outcome]}")
end

spree_opentelemetry gem と OTEL_EXPORTER_OTLP_ENDPOINT を使用すると、これらは自動的にスパンになります。ワークフローの実行ごとに 1 つ、ステップごとに 1 つ(external_step は client スパン)、ハンドラーがあるフックのディスパッチごとに 1 つです。

テスト

ハンドラーは通常のクラスです。実際のワークフローインスタンスを使って単体テストし、ワークフロー経由で統合テストします。

RSpec.describe MyStore::CheckPurchaseLimit do
  let(:cart)    { create(:cart) }
  let(:variant) { create(:variant) }
  let(:handler) { 'MyStore::CheckPurchaseLimit' }

  before { Spree.hooks.register('carts.add_item.validate', handler) }
  after  { Spree.hooks.unregister('carts.add_item.validate', handler) }

  it 'rejects more than 10' do
    result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 11)

    expect(result).to be_failure
    expect(result.error.value.details[:quantity]).to include(a_hash_including(error: :purchase_limit_exceeded))
    expect(cart.line_items.count).to eq(0)
  end
end
  • ハンドラーが config/initializers/spree.rb に登録されている場合、スペックですでに有効になっています。before/after は省略してください。
  • アプリのスペックで Spree.hooks.clear! を実行しないでください。プロセスの残りの期間、コア登録(返品期間のバリデーター)が完全に消去されます。登録したものだけを正確に登録解除してください。ブロックの場合は proc を保持します:h = ->(w) { ... }; Spree.hooks.register(key, &h) … Spree.hooks.unregister(key, h)。
  • CI に起動時のチェックを追加します:Rails.application.eager_load!; Spree.hooks.validate!。

注意点

  • ワークフロークラスを差し替えると、フックキーが変わります。 MyApp::Carts::AddItem < Spree::Carts::AddItem は my_app.carts.add_item.* をディスパッチするため、carts.add_item.* に登録されたすべてのハンドラー(自分のもの、拡張機能のもの、コアのもの)がひっそりと実行されなくなります。サブクラス化が必要な場合は、そのクラスで workflow_key 'carts.add_item' を宣言してください。spree-dependencies を参照してください。
  • orders.complete.* に登録すると、起動時(イーガーロード時)に UnknownHookError が発生します。Spree::Orders::Complete はフックを宣言していません。チェックアウトは carts.complete を通ります。
  • carts.recalculate_totals にあるのは set_tax_line_context だけです。プロモーションのコンテキストは carts.recalculate にあります。
  • ジョブ、rakeタスク、コンソールから実行するワークフローにはストアのコンテキストが必要です。 先に Spree::Current.store = cart.store(または order.store)を設定してください。Spree::Store.default は nil の場合があります。複数のストアのレコードを処理するジョブでは、それぞれを Spree::Current.with_store(record.store) { … } で囲みます(または Spree::BaseJob のサブクラスで each_store_of(relation) を使用します)。これにより、そのジョブに対して開発/テスト用の Spree::StoreScopeGuard も有効になり、自分が作成したハンドラー内のスコープ指定のないクエリも、APIリクエストの場合と同様に検出されます。
  • 検証フックが参照するのは結果ではなく引数です。workflow.line_item、workflow.order、workflow.fulfillment は、それらを設定するステップが実行されるまで nil です。

詳細情報

  • node_modules/@spree/docs/dist/developer/customization/workflows.md (https://spreecommerce.org/docs/developer/customization/workflows)
  • node_modules/@spree/docs/dist/developer/customization/validations.md — フックとモデルのバリデーション、バリデーター登録の違い
  • node_modules/@spree/docs/dist/developer/providers/observability.md — OpenTelemetry のセットアップ
  • ソース:spree_core の lib/spree/workflow.rb、lib/spree/hooks.rb、app/workflows/spree/**、app/models/concerns/spree/has_status.rb
  • 関連スキル:spree-dependencies(クラスの置き換え)、spree-events-webhooks(事後の副作用)、spree-checkout(要件レジストリ)、spree-customization(ルーティング)

コメント