Разработка кастомного расширения Spree Commerce
Spree расширяется через Rails Engine — это отдельный gem с собственными моделями, контроллерами, вьюхами и миграциями. Механизм тот же, что и у самого Spree: Rails Engine + Decorator pattern для изменения поведения существующих классов. Можно упаковать расширение в gem для переиспользования или держать код непосредственно в основном приложении.
Два подхода к расширению
Inline (в приложении): Декораторы в app/models/spree/, переопределение вьюх через deface, новые контроллеры. Подходит для специфичных доработок.
Rails Engine (gem): Полноценный gem с Engine, который монтируется в основное приложение. Подходит для переиспользуемой функциональности — например, плагин для конкретного платёжного провайдера.
Структура расширения как Engine
bundle gem spree_b2b_pricing --no-ext
spree_b2b_pricing/
├── app/
│ ├── models/
│ │ └── spree/
│ │ ├── b2b_price_list.rb
│ │ └── order_decorator.rb
│ ├── controllers/
│ │ └── spree/
│ │ └── api/v2/
│ │ └── storefront/
│ │ └── price_lists_controller.rb
│ └── views/
│ └── spree/
│ └── admin/
├── config/
│ ├── routes.rb
│ └── initializers/
│ └── spree_b2b_pricing.rb
├── db/
│ └── migrate/
│ └── 20240101000000_create_spree_b2b_price_lists.rb
├── lib/
│ ├── spree_b2b_pricing.rb
│ └── spree_b2b_pricing/
│ ├── engine.rb
│ └── version.rb
└── spree_b2b_pricing.gemspec
Engine
# lib/spree_b2b_pricing/engine.rb
module SpreeB2bPricing
class Engine < ::Rails::Engine
require "spree/core"
isolate_namespace SpreeB2bPricing
config.autoload_paths += %W[#{config.root}/lib]
initializer "spree_b2b_pricing.register_hooks" do
Spree::Config.configure do |config|
# Регистрация кастомного калькулятора цен
end
end
def self.activate
Dir.glob(File.join(File.dirname(__FILE__), "../../app/**/*_decorator.rb")) do |c|
Rails.configuration.cache_classes ? require(c) : load(c)
end
end
config.to_prepare(&method(:activate))
end
end
Decorator: расширение модели Spree::Order
# app/models/spree/order_decorator.rb
module Spree
module OrderDecorator
def self.prepended(base)
base.belongs_to :b2b_price_list,
class_name: "Spree::B2bPriceList",
optional: true
end
# Переопределяем метод пересчёта цен
def update_line_item_prices!
return super unless b2b_price_list
line_items.each do |line_item|
b2b_price = b2b_price_list.price_for(
line_item.variant,
currency
)
if b2b_price
line_item.price = b2b_price
line_item.save!
end
end
super
end
def applicable_promotions
return super unless user&.b2b_customer?
# B2B клиенты не получают публичные промокоды
super.where(b2b_only: true)
end
end
end
Spree::Order.prepend(Spree::OrderDecorator)
Переопределение вьюх через Deface
# app/overrides/spree/admin/products/_form_override.rb
Deface::Override.new(
virtual_path: "spree/admin/products/_form",
name: "add_b2b_wholesale_price",
insert_after: "[data-hook='product_form_right']",
text: <<~HTML
<div data-hook="b2b_wholesale_price">
<%= f.field_container :wholesale_price do %>
<%= f.label :wholesale_price, "Оптовая цена (руб.)" %>
<%= f.text_field :wholesale_price, class: "form-control" %>
<% end %>
</div>
HTML
)
Deface не модифицирует файлы Spree — он применяет патчи в рантайме, что делает обновления Spree безопасными.
Расширение Admin API
# app/controllers/spree/admin/b2b_price_lists_controller.rb
module Spree
module Admin
class B2bPriceListsController < Spree::Admin::ResourceController
before_action :load_resource
def create
@b2b_price_list = Spree::B2bPriceList.new(b2b_price_list_params)
if @b2b_price_list.save
flash[:success] = "Прайс-лист создан"
redirect_to admin_b2b_price_lists_path
else
render :new
end
end
private
def b2b_price_list_params
params.require(:b2b_price_list).permit(:name, :discount_percent, :active)
end
end
end
end
Расширение Storefront API v2
# app/controllers/spree/api/v2/storefront/b2b_controller.rb
module Spree
module Api
module V2
module Storefront
class B2bController < ::Spree::Api::V2::BaseController
before_action :require_spree_current_user
def price_list
user = spree_current_user
price_list = Spree::B2bPriceList.for_user(user)
render_serialized_payload { serialize_resource(price_list) }
end
end
end
end
end
end
# config/routes.rb
Spree::Core::Engine.routes.draw do
namespace :api do
namespace :v2 do
namespace :storefront do
resource :b2b, only: [:show] do
get :price_list
end
end
end
end
namespace :admin do
resources :b2b_price_lists
end
end
Миграция
# db/migrate/20240101000000_create_spree_b2b_price_lists.rb
class CreateSpreeB2bPriceLists < ActiveRecord::Migration[7.1]
def change
create_table :spree_b2b_price_lists do |t|
t.string :name, null: false
t.decimal :discount_percent, precision: 5, scale: 2
t.boolean :active, default: true, null: false
t.timestamps
end
add_column :spree_orders, :b2b_price_list_id, :bigint
add_foreign_key :spree_orders, :spree_b2b_price_lists,
column: :b2b_price_list_id
add_index :spree_orders, :b2b_price_list_id
end
end
Тестирование расширения
# spec/models/spree/order_decorator_spec.rb
RSpec.describe Spree::Order do
describe "#update_line_item_prices!" do
let(:price_list) { create(:b2b_price_list, discount_percent: 10) }
let(:order) { create(:order_with_line_items, b2b_price_list: price_list) }
it "applies b2b pricing to line items" do
original_price = order.line_items.first.price
order.update_line_item_prices!
expect(order.line_items.first.reload.price)
.to be_within(0.01).of(original_price * 0.9)
end
end
end
Типичные задачи и сроки
| Расширение | Срок |
|---|---|
| Кастомный калькулятор доставки | 1–2 дня |
| B2B ценообразование | 3–5 дней |
| Программа лояльности | 4–6 дней |
| Кастомный платёжный gateway | 2–4 дня |
| Расширение Admin UI (новые разделы) | 2–3 дня |







