solidus_advanced_pricing 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.circleci/config.yml +53 -0
- data/.gem_release.yml +5 -0
- data/.github/stale.yml +1 -0
- data/.github/workflows/lint.yml +25 -0
- data/.github/workflows/test.yml +86 -0
- data/.github_changelog_generator +2 -0
- data/.gitignore +21 -0
- data/.rspec +2 -0
- data/.rubocop.yml +5 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +64 -0
- data/LICENSE +26 -0
- data/README.md +377 -0
- data/Rakefile +41 -0
- data/app/assets/javascripts/spree/backend/solidus_advanced_pricing.js +2 -0
- data/app/assets/javascripts/spree/frontend/solidus_advanced_pricing.js +2 -0
- data/app/assets/stylesheets/spree/backend/solidus_advanced_pricing.css +4 -0
- data/app/assets/stylesheets/spree/frontend/solidus_advanced_pricing.css +4 -0
- data/app/decorators/models/solidus_advanced_pricing/spree/app_configuration_decorator.rb +17 -0
- data/app/decorators/models/solidus_advanced_pricing/spree/permitted_attributes_decorator.rb +27 -0
- data/app/decorators/models/solidus_advanced_pricing/spree/price_decorator.rb +56 -0
- data/app/decorators/models/solidus_advanced_pricing/spree/role_decorator.rb +24 -0
- data/app/decorators/models/solidus_advanced_pricing/spree/variant_decorator.rb +52 -0
- data/app/models/solidus_advanced_pricing/price_selector.rb +73 -0
- data/app/models/solidus_advanced_pricing/price_type.rb +80 -0
- data/app/models/solidus_advanced_pricing/price_type_cache.rb +45 -0
- data/app/models/solidus_advanced_pricing/pricing_options.rb +107 -0
- data/app/overrides/spree/admin/prices/_form/add_advanced_fields.html.erb.deface +2 -0
- data/app/overrides/spree/admin/prices/_master_variant_table/add_advanced_columns.html.erb.deface +2 -0
- data/app/overrides/spree/admin/prices/_master_variant_table/add_advanced_headers.html.erb.deface +4 -0
- data/app/overrides/spree/admin/prices/_table/add_advanced_columns.html.erb.deface +2 -0
- data/app/overrides/spree/admin/prices/_table/add_advanced_headers.html.erb.deface +4 -0
- data/app/overrides/spree/admin/shared/_configuration_menu/add_price_types_link.html.erb.deface +4 -0
- data/bin/console +17 -0
- data/bin/rails +7 -0
- data/bin/rails-engine +13 -0
- data/bin/rails-sandbox +16 -0
- data/bin/rake +7 -0
- data/bin/sandbox +85 -0
- data/bin/setup +8 -0
- data/config/locales/en.yml +44 -0
- data/config/routes.rb +34 -0
- data/db/migrate/20260915000001_create_solidus_advanced_pricing_price_types.rb +16 -0
- data/db/migrate/20260915000002_seed_solidus_advanced_pricing_price_types.rb +34 -0
- data/db/migrate/20260915000003_add_advanced_pricing_to_spree_prices.rb +21 -0
- data/docs/superpowers/specs/2026-09-14-solidus-advanced-pricing-design.md +351 -0
- data/lib/components/admin/solidus_admin/price_types/index/component.rb +65 -0
- data/lib/controllers/admin/solidus_admin/price_types_controller.rb +26 -0
- data/lib/controllers/api/spree/api/prices_controller.rb +32 -0
- data/lib/controllers/backend/spree/admin/price_types_controller.rb +40 -0
- data/lib/generators/solidus_advanced_pricing/install/install_generator.rb +33 -0
- data/lib/generators/solidus_advanced_pricing/install/templates/initializer.rb +7 -0
- data/lib/solidus_advanced_pricing/configuration.rb +21 -0
- data/lib/solidus_advanced_pricing/engine.rb +41 -0
- data/lib/solidus_advanced_pricing/testing_support/factories/price_type_factory.rb +13 -0
- data/lib/solidus_advanced_pricing/testing_support/factories.rb +4 -0
- data/lib/solidus_advanced_pricing/version.rb +5 -0
- data/lib/solidus_advanced_pricing.rb +19 -0
- data/lib/views/api/spree/api/prices/_price.json.jbuilder +8 -0
- data/lib/views/api/spree/api/prices/index.json.jbuilder +3 -0
- data/lib/views/api/spree/api/prices/show.json.jbuilder +1 -0
- data/lib/views/backend/spree/admin/price_types/_form.html.erb +20 -0
- data/lib/views/backend/spree/admin/price_types/edit.html.erb +9 -0
- data/lib/views/backend/spree/admin/price_types/index.html.erb +31 -0
- data/lib/views/backend/spree/admin/price_types/new.html.erb +9 -0
- data/lib/views/backend/spree/admin/prices/_advanced_columns.html.erb +7 -0
- data/lib/views/backend/spree/admin/prices/_advanced_fields.html.erb +46 -0
- data/solidus_advanced_pricing.gemspec +39 -0
- metadata +228 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdvancedPricing
|
|
4
|
+
# Filter -> country bucket -> cheapest. Role is eligibility only, never specificity:
|
|
5
|
+
# a role-targeted price set above the untargeted price therefore never applies.
|
|
6
|
+
class PriceSelector < ::Spree::Variant::PriceSelector
|
|
7
|
+
def self.pricing_options_class
|
|
8
|
+
SolidusAdvancedPricing::PricingOptions
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def price_for_options(price_options)
|
|
12
|
+
candidates = eligible_prices(price_options)
|
|
13
|
+
return nil if candidates.empty?
|
|
14
|
+
|
|
15
|
+
cheapest(bucket_by_country(candidates, price_options.country_iso))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def eligible_prices(price_options)
|
|
21
|
+
wanted_type = price_options.desired_attributes[:price_type_id]
|
|
22
|
+
|
|
23
|
+
variant.prices.select do |price|
|
|
24
|
+
kept?(price) &&
|
|
25
|
+
price.currency == price_options.currency &&
|
|
26
|
+
matches_type?(price, wanted_type) &&
|
|
27
|
+
valid_at?(price, price_options.at) &&
|
|
28
|
+
visible_to?(price, price_options.customer_role_ids)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def kept?(price)
|
|
33
|
+
variant.discarded? || price.kept?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# nil = untyped base prices only (the admin's view); :any = every type competes
|
|
37
|
+
# (a customer's view); an id = that type only.
|
|
38
|
+
def matches_type?(price, wanted_type)
|
|
39
|
+
return true if wanted_type == :any
|
|
40
|
+
|
|
41
|
+
price.price_type_id == wanted_type
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def valid_at?(price, time)
|
|
45
|
+
return true if time.nil?
|
|
46
|
+
|
|
47
|
+
(price.valid_from.nil? || price.valid_from <= time) &&
|
|
48
|
+
(price.valid_to.nil? || price.valid_to > time)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def visible_to?(price, customer_role_ids)
|
|
52
|
+
price.role_id.nil? || customer_role_ids.include?(price.role_id)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Country is specificity, not competition: a country-specific price beats the
|
|
56
|
+
# any-country fallback even when the fallback is cheaper.
|
|
57
|
+
def bucket_by_country(candidates, country_iso)
|
|
58
|
+
specific = candidates.select { |price| price.country_iso == country_iso && !price.country_iso.nil? }
|
|
59
|
+
specific.presence || candidates.select { |price| price.country_iso.nil? }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def cheapest(candidates)
|
|
63
|
+
candidates.min_by do |price|
|
|
64
|
+
[
|
|
65
|
+
price.amount,
|
|
66
|
+
SolidusAdvancedPricing::PriceTypeCache.position_for(price.price_type_id),
|
|
67
|
+
-(price.updated_at || Time.zone.now).to_i,
|
|
68
|
+
-(price.id || Float::INFINITY)
|
|
69
|
+
]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdvancedPricing
|
|
4
|
+
# Discarding a type hides it from admin dropdowns but leaves historical
|
|
5
|
+
# prices resolvable through it; prices are never reassigned when a type is
|
|
6
|
+
# retired, so a retired type's code stays reserved rather than reusable.
|
|
7
|
+
# `::Spree::Base` etc. are qualified from the root: the
|
|
8
|
+
# `solidus_advanced_pricing/spree` decorator namespace shadows the bare
|
|
9
|
+
# `Spree` constant here otherwise (Zeitwerk const-shadowing gotcha).
|
|
10
|
+
class PriceType < ::Spree::Base
|
|
11
|
+
include ::Spree::SoftDeletable
|
|
12
|
+
|
|
13
|
+
self.table_name = "solidus_advanced_pricing_price_types"
|
|
14
|
+
|
|
15
|
+
# Needed for the solidus_admin index search box (name_or_code_cont); Ransack
|
|
16
|
+
# denies any attribute not explicitly allowed here.
|
|
17
|
+
self.allowed_ransackable_attributes = %w[name code]
|
|
18
|
+
|
|
19
|
+
has_many :prices,
|
|
20
|
+
class_name: "::Spree::Price",
|
|
21
|
+
foreign_key: :price_type_id,
|
|
22
|
+
inverse_of: :price_type
|
|
23
|
+
|
|
24
|
+
# Not `dependent: :restrict_with_error` — that check is default-scoped and
|
|
25
|
+
# misses discarded prices, which then trip the FK on DELETE.
|
|
26
|
+
before_destroy :prevent_destroying_referenced_type
|
|
27
|
+
|
|
28
|
+
before_validation :normalize_code
|
|
29
|
+
|
|
30
|
+
validates :name, presence: true
|
|
31
|
+
# Codes are normalized to lowercase so a plain unique index IS the rule on
|
|
32
|
+
# PostgreSQL, MySQL and SQLite alike. `case_sensitive: false` would have the
|
|
33
|
+
# validator and the index disagree on PG/SQLite, letting `insert_all` create
|
|
34
|
+
# two rows differing only in case.
|
|
35
|
+
validates :code, presence: true, uniqueness: {case_sensitive: true}
|
|
36
|
+
|
|
37
|
+
scope :ordered, -> { order(:position, :id) }
|
|
38
|
+
|
|
39
|
+
SEEDS = [
|
|
40
|
+
{code: "wholesale", name: "Wholesale", position: 1},
|
|
41
|
+
{code: "sale", name: "Sale", position: 2},
|
|
42
|
+
{code: "clearance", name: "Clearance", position: 3},
|
|
43
|
+
{code: "employee", name: "Employee", position: 4},
|
|
44
|
+
{code: "map", name: "MAP", position: 5},
|
|
45
|
+
{code: "promotional", name: "Promotional", position: 6}
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
# Idempotent. Used by the test suite and available to stores for re-seeding.
|
|
49
|
+
# The migration deliberately does NOT call this — a historical migration must
|
|
50
|
+
# not depend on current app code — so the two lists may drift, which is fine:
|
|
51
|
+
# the migration is history, this is the present.
|
|
52
|
+
def self.seed!
|
|
53
|
+
SEEDS.each do |attrs|
|
|
54
|
+
with_discarded.find_or_create_by!(code: attrs[:code]) do |price_type|
|
|
55
|
+
price_type.name = attrs[:name]
|
|
56
|
+
price_type.position = attrs[:position]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_s
|
|
62
|
+
name
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
after_commit { SolidusAdvancedPricing::PriceTypeCache.clear }
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def normalize_code
|
|
70
|
+
self.code = code&.strip&.downcase.presence
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def prevent_destroying_referenced_type
|
|
74
|
+
return unless prices.with_discarded.exists?
|
|
75
|
+
|
|
76
|
+
errors.add(:base, :referenced_by_prices)
|
|
77
|
+
throw :abort
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdvancedPricing
|
|
4
|
+
# Process-local memoization for values read on every pricing lookup.
|
|
5
|
+
# Cleared by PriceType's after_save/after_destroy/after_discard callbacks
|
|
6
|
+
# and, later, by Spree::Price.
|
|
7
|
+
module PriceTypeCache
|
|
8
|
+
class << self
|
|
9
|
+
def pricing_role_ids
|
|
10
|
+
return @pricing_role_ids if defined?(@pricing_role_ids)
|
|
11
|
+
|
|
12
|
+
@pricing_role_ids = ::Spree::Price.distinct.pluck(:role_id).compact
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# id => position, for the selector's tie-breaker. A handful of rows that
|
|
16
|
+
# change almost never, so one memoized map beats a query per candidate price.
|
|
17
|
+
def positions
|
|
18
|
+
return @positions if defined?(@positions)
|
|
19
|
+
|
|
20
|
+
@positions = PriceType.with_discarded.pluck(:id, :position).to_h
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def position_for(price_type_id)
|
|
24
|
+
positions.fetch(price_type_id, 0)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# code => id. Handful of rows that change almost never.
|
|
28
|
+
def ids_by_code
|
|
29
|
+
return @ids_by_code if defined?(@ids_by_code)
|
|
30
|
+
|
|
31
|
+
@ids_by_code = PriceType.with_discarded.pluck(:code, :id).to_h
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def id_for(code)
|
|
35
|
+
ids_by_code[code.to_s]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clear
|
|
39
|
+
remove_instance_variable(:@pricing_role_ids) if defined?(@pricing_role_ids)
|
|
40
|
+
remove_instance_variable(:@positions) if defined?(@positions)
|
|
41
|
+
remove_instance_variable(:@ids_by_code) if defined?(@ids_by_code)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdvancedPricing
|
|
4
|
+
# desired_attributes describes the price being sought and may hold only assignable
|
|
5
|
+
# Spree::Price columns, because core calls prices.build(default_price_attributes).
|
|
6
|
+
# at/customer_role_ids describe who is asking and live outside that hash.
|
|
7
|
+
class PricingOptions < ::Spree::Variant::PricingOptions
|
|
8
|
+
attr_reader :at, :customer_role_ids
|
|
9
|
+
|
|
10
|
+
def self.default_price_attributes
|
|
11
|
+
super.merge(
|
|
12
|
+
price_type_id: nil,
|
|
13
|
+
role_id: nil
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# price_type_id: :any means "no type filter, every type competes". nil, the
|
|
18
|
+
# default set by default_price_attributes above, means "untyped base prices
|
|
19
|
+
# only" and must not leak into a customer-facing lookup, or a sale price
|
|
20
|
+
# could never be selected.
|
|
21
|
+
def self.from_line_item(line_item)
|
|
22
|
+
options = super
|
|
23
|
+
new(
|
|
24
|
+
options.desired_attributes.merge(
|
|
25
|
+
price_type_id: :any,
|
|
26
|
+
customer_role_ids: pricing_relevant_role_ids(line_item.order&.user)
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# price_type_id: :any (see from_line_item above).
|
|
32
|
+
def self.from_context(context)
|
|
33
|
+
options = super
|
|
34
|
+
new(
|
|
35
|
+
options.desired_attributes.merge(
|
|
36
|
+
price_type_id: :any,
|
|
37
|
+
customer_role_ids: pricing_relevant_role_ids(context.try(:current_spree_user))
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.from_price(price)
|
|
43
|
+
new(
|
|
44
|
+
currency: price.currency,
|
|
45
|
+
country_iso: price.country_iso,
|
|
46
|
+
price_type_id: price.price_type_id,
|
|
47
|
+
role_id: price.role_id
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Narrowed to roles some price actually uses: selection is unaffected, but it
|
|
52
|
+
# collapses cache key cardinality (Task 12).
|
|
53
|
+
def self.pricing_relevant_role_ids(user)
|
|
54
|
+
return [] if user.nil?
|
|
55
|
+
|
|
56
|
+
user.spree_role_ids & SolidusAdvancedPricing::PriceTypeCache.pricing_role_ids
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize(desired_attributes = {})
|
|
60
|
+
attributes = desired_attributes.dup
|
|
61
|
+
@at = attributes.key?(:at) ? attributes.delete(:at) : Time.current
|
|
62
|
+
@customer_role_ids = Array(attributes.delete(:customer_role_ids))
|
|
63
|
+
super(attributes)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Core keys only on desired_attributes; customer context lives outside it, so
|
|
67
|
+
# without this a role-targeted price would be cached and served to a guest.
|
|
68
|
+
def cache_key
|
|
69
|
+
[super, roles_cache_component, time_cache_component].compact.join("/")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# nil is a real filter now (price_type_id IS NULL matches untyped base prices)
|
|
73
|
+
# and is kept; :any means no type filter at all, so it's stripped instead —
|
|
74
|
+
# it's a selector-only sentinel and not a column value `where` could use.
|
|
75
|
+
def search_arguments
|
|
76
|
+
arguments = desired_attributes.dup
|
|
77
|
+
arguments[:country_iso] = [desired_attributes[:country_iso], nil].flatten.uniq
|
|
78
|
+
arguments[:role_id] = [nil, *customer_role_ids].uniq
|
|
79
|
+
arguments.delete(:price_type_id) if desired_attributes[:price_type_id] == :any
|
|
80
|
+
arguments
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# at and customer_role_ids live outside desired_attributes, so they have to be
|
|
84
|
+
# carried explicitly or a derived lookup silently becomes an admin one.
|
|
85
|
+
def with(**overrides)
|
|
86
|
+
self.class.new(
|
|
87
|
+
desired_attributes.merge(at: at, customer_role_ids: customer_role_ids).merge(overrides)
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def roles_cache_component
|
|
94
|
+
return "r-none" if customer_role_ids.empty?
|
|
95
|
+
|
|
96
|
+
"r-#{customer_role_ids.sort.join("-")}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def time_cache_component
|
|
100
|
+
granularity = ::Spree::Config.advanced_pricing_cache_granularity.to_i
|
|
101
|
+
return nil if granularity <= 0
|
|
102
|
+
return nil if at.nil?
|
|
103
|
+
|
|
104
|
+
"t-#{at.to_i / granularity}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/bin/console
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require "bundler/setup"
|
|
6
|
+
require "solidus_advanced_pricing"
|
|
7
|
+
|
|
8
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
9
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
10
|
+
$LOAD_PATH.unshift(*Dir["#{__dir__}/../app/*"])
|
|
11
|
+
|
|
12
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
|
13
|
+
# require "pry"
|
|
14
|
+
# Pry.start
|
|
15
|
+
|
|
16
|
+
require "irb"
|
|
17
|
+
IRB.start(__FILE__)
|
data/bin/rails
ADDED
data/bin/rails-engine
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# This command will automatically be run when you run "rails" with Rails gems
|
|
3
|
+
# installed from the root of your application.
|
|
4
|
+
|
|
5
|
+
ENGINE_ROOT = File.expand_path('..', __dir__)
|
|
6
|
+
ENGINE_PATH = File.expand_path('../lib/solidus_advanced_pricing/engine', __dir__)
|
|
7
|
+
|
|
8
|
+
# Set up gems listed in the Gemfile.
|
|
9
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
|
|
10
|
+
require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
|
|
11
|
+
|
|
12
|
+
require 'rails/all'
|
|
13
|
+
require 'rails/engine/commands'
|
data/bin/rails-sandbox
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
app_root = "sandbox"
|
|
4
|
+
|
|
5
|
+
unless File.exist? "#{app_root}/bin/rails"
|
|
6
|
+
warn "Creating the sandbox app..."
|
|
7
|
+
Dir.chdir "#{__dir__}/.." do
|
|
8
|
+
system "#{__dir__}/sandbox" or begin
|
|
9
|
+
warn "Automatic creation of the sandbox app failed"
|
|
10
|
+
exit 1
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Dir.chdir app_root
|
|
16
|
+
exec "bin/rails", *ARGV
|
data/bin/rake
ADDED
data/bin/sandbox
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
set -e
|
|
4
|
+
test -z "${DEBUG+empty_string}" || set -x
|
|
5
|
+
|
|
6
|
+
test "$DB" = "sqlite" && export DB="sqlite3"
|
|
7
|
+
|
|
8
|
+
if [ -z "$PAYMENT_METHOD" ]
|
|
9
|
+
then
|
|
10
|
+
PAYMENT_METHOD="none"
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
if [ -z "$SOLIDUS_BRANCH" ]
|
|
14
|
+
then
|
|
15
|
+
echo "~~> Use 'export SOLIDUS_BRANCH=[main|v4.0|...]' to control the Solidus branch"
|
|
16
|
+
SOLIDUS_BRANCH="main"
|
|
17
|
+
fi
|
|
18
|
+
echo "~~> Using branch $SOLIDUS_BRANCH of solidus"
|
|
19
|
+
|
|
20
|
+
extension_name="solidus_advanced_pricing"
|
|
21
|
+
|
|
22
|
+
# Stay away from the bundler env of the containing extension.
|
|
23
|
+
function unbundled {
|
|
24
|
+
ruby -rbundler -e'
|
|
25
|
+
Bundler.with_unbundled_env {system *ARGV}' -- \
|
|
26
|
+
env BUNDLE_SUPPRESS_INSTALL_USING_MESSAGES=true $@
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
echo "~~~> Removing the old sandbox"
|
|
30
|
+
rm -rf ./sandbox
|
|
31
|
+
|
|
32
|
+
echo "~~~> Creating a pristine Rails app"
|
|
33
|
+
rails_version=`bundle exec ruby -e'require "rails"; puts Rails.version'`
|
|
34
|
+
rails _${rails_version}_ new sandbox \
|
|
35
|
+
--database="${DB:-sqlite3}" \
|
|
36
|
+
--skip-git \
|
|
37
|
+
--skip-keeps \
|
|
38
|
+
--skip-rc \
|
|
39
|
+
--skip-bootsnap \
|
|
40
|
+
--skip-test
|
|
41
|
+
|
|
42
|
+
if [ ! -d "sandbox" ]; then
|
|
43
|
+
echo 'sandbox rails application failed'
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
echo "~~~> Adding solidus (with i18n) to the Gemfile"
|
|
48
|
+
cd ./sandbox
|
|
49
|
+
cat <<RUBY >> Gemfile
|
|
50
|
+
gem 'solidus', github: 'solidusio/solidus', branch: '$SOLIDUS_BRANCH'
|
|
51
|
+
gem 'rails-i18n'
|
|
52
|
+
gem 'solidus_i18n'
|
|
53
|
+
gem 'solidus_auth_devise'
|
|
54
|
+
|
|
55
|
+
gem '$extension_name', path: '..'
|
|
56
|
+
|
|
57
|
+
group :test, :development do
|
|
58
|
+
platforms :mri do
|
|
59
|
+
gem 'pry-byebug'
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
RUBY
|
|
63
|
+
|
|
64
|
+
echo "Generating manifest file"
|
|
65
|
+
mkdir -p app/assets/config
|
|
66
|
+
cat <<MANIFESTJS > app/assets/config/manifest.js
|
|
67
|
+
//= link_tree ../images
|
|
68
|
+
//= link_directory ../javascripts .js
|
|
69
|
+
//= link_directory ../stylesheets .css
|
|
70
|
+
MANIFESTJS
|
|
71
|
+
|
|
72
|
+
unbundled bundle install --gemfile Gemfile
|
|
73
|
+
|
|
74
|
+
unbundled bundle exec rake db:drop db:create
|
|
75
|
+
|
|
76
|
+
unbundled bundle exec rails generate solidus:install \
|
|
77
|
+
--auto-accept \
|
|
78
|
+
$@
|
|
79
|
+
|
|
80
|
+
unbundled bundle exec rails generate solidus:auth:install --auto-run-migrations
|
|
81
|
+
unbundled bundle exec rails generate ${extension_name}:install --auto-run-migrations
|
|
82
|
+
|
|
83
|
+
echo
|
|
84
|
+
echo "🚀 Sandbox app successfully created for $extension_name!"
|
|
85
|
+
echo "🧪 This app is intended for test purposes."
|
data/bin/setup
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Sample localization file for English. Add more files in this directory for other locales.
|
|
2
|
+
# See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
|
|
3
|
+
|
|
4
|
+
en:
|
|
5
|
+
hello: Hello world
|
|
6
|
+
solidus_advanced_pricing:
|
|
7
|
+
price_types: "Price Types"
|
|
8
|
+
all_customers: "All customers"
|
|
9
|
+
role_hint: "Leave blank for all customers. A price type name alone grants no access control — an employee-only price needs this field set."
|
|
10
|
+
admin_notes_hint: "Internal only. Never shown to customers."
|
|
11
|
+
validity: "Validity"
|
|
12
|
+
solidus_admin:
|
|
13
|
+
menu_item:
|
|
14
|
+
price_types: "Price Types"
|
|
15
|
+
price_types:
|
|
16
|
+
title: "Price Types"
|
|
17
|
+
destroy:
|
|
18
|
+
success: "Price types were successfully removed."
|
|
19
|
+
activerecord:
|
|
20
|
+
models:
|
|
21
|
+
solidus_advanced_pricing/price_type:
|
|
22
|
+
one: "Price Type"
|
|
23
|
+
other: "Price Types"
|
|
24
|
+
attributes:
|
|
25
|
+
spree/price:
|
|
26
|
+
price_type_id: "Price Type"
|
|
27
|
+
role_id: "Visible to role"
|
|
28
|
+
valid_from: "Valid from"
|
|
29
|
+
valid_to: "Valid to"
|
|
30
|
+
admin_notes: "Admin notes"
|
|
31
|
+
errors:
|
|
32
|
+
models:
|
|
33
|
+
solidus_advanced_pricing/price_type:
|
|
34
|
+
attributes:
|
|
35
|
+
base:
|
|
36
|
+
referenced_by_prices: "This price type is still used by one or more prices and cannot be deleted. Retire it instead."
|
|
37
|
+
spree/price:
|
|
38
|
+
attributes:
|
|
39
|
+
valid_to:
|
|
40
|
+
must_be_after_valid_from: "must be after the valid from date"
|
|
41
|
+
spree/role:
|
|
42
|
+
attributes:
|
|
43
|
+
base:
|
|
44
|
+
referenced_by_prices: "This role is used to target one or more prices and cannot be deleted."
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Spree::Core::Engine.routes.draw do
|
|
4
|
+
namespace :api, defaults: {format: "json"} do
|
|
5
|
+
resources :variants, only: [] do
|
|
6
|
+
resources :prices, only: [:index, :show]
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
namespace :admin do
|
|
11
|
+
resources :price_types, except: [:show]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# SolidusAdmin::ResourcesController, which our controller inherits from, only exists
|
|
16
|
+
# from Solidus 4.5 -- solidus_admin itself ships from 4.3. Version check rather than
|
|
17
|
+
# a constant check: routes are drawn before solidus_admin's autoloads are ready.
|
|
18
|
+
if defined?(SolidusAdmin::Engine) && Spree.solidus_gem_version >= Gem::Version.new("4.5")
|
|
19
|
+
SolidusAdmin::Engine.routes.draw do
|
|
20
|
+
# admin_resources is added via `extend` on the Mapper instance solidus_admin's
|
|
21
|
+
# own routes.rb draws with -- a separate `draw` call like this one gets its
|
|
22
|
+
# own Mapper, so it isn't inherited and has to be required/extended again.
|
|
23
|
+
require "solidus_admin/admin_resources"
|
|
24
|
+
extend SolidusAdmin::AdminResources
|
|
25
|
+
|
|
26
|
+
# :index and the batch :destroy only -- SolidusAdmin::Engine is mounted
|
|
27
|
+
# before Spree::Core::Engine (see spec/dummy's routes.rb), so a wider set
|
|
28
|
+
# here (e.g. :new, :edit) would shadow the legacy backend's own working
|
|
29
|
+
# forms at the same /admin/price_types/... paths with a 404, since this
|
|
30
|
+
# gem intentionally doesn't implement solidus_admin new/edit components
|
|
31
|
+
# for price types.
|
|
32
|
+
admin_resources :price_types, only: [:index, :destroy]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateSolidusAdvancedPricingPriceTypes < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :solidus_advanced_pricing_price_types do |t|
|
|
6
|
+
t.string :name, null: false
|
|
7
|
+
t.string :code, null: false
|
|
8
|
+
t.integer :position, null: false, default: 0
|
|
9
|
+
t.datetime :deleted_at
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :solidus_advanced_pricing_price_types, :code, unique: true
|
|
14
|
+
add_index :solidus_advanced_pricing_price_types, :deleted_at
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class SeedSolidusAdvancedPricingPriceTypes < ActiveRecord::Migration[7.0]
|
|
4
|
+
SEEDS = [
|
|
5
|
+
{code: "wholesale", name: "Wholesale", position: 1},
|
|
6
|
+
{code: "sale", name: "Sale", position: 2},
|
|
7
|
+
{code: "clearance", name: "Clearance", position: 3},
|
|
8
|
+
{code: "employee", name: "Employee", position: 4},
|
|
9
|
+
{code: "map", name: "MAP", position: 5},
|
|
10
|
+
{code: "promotional", name: "Promotional", position: 6}
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def up
|
|
14
|
+
now = Time.current
|
|
15
|
+
|
|
16
|
+
SEEDS.each do |attrs|
|
|
17
|
+
next if price_types.where(code: attrs[:code]).exists?
|
|
18
|
+
|
|
19
|
+
price_types.insert_all([attrs.merge(created_at: now, updated_at: now)])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def down
|
|
24
|
+
price_types.where(code: SEEDS.map { |attrs| attrs[:code] }).delete_all
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def price_types
|
|
30
|
+
@price_types ||= Class.new(ActiveRecord::Base) do
|
|
31
|
+
self.table_name = "solidus_advanced_pricing_price_types"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddAdvancedPricingToSpreePrices < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
add_column :spree_prices, :price_type_id, :bigint
|
|
6
|
+
# :integer, not :bigint — spree_roles.id is int and MySQL rejects mismatched FK precision.
|
|
7
|
+
add_column :spree_prices, :role_id, :integer
|
|
8
|
+
add_column :spree_prices, :valid_from, :datetime
|
|
9
|
+
add_column :spree_prices, :valid_to, :datetime
|
|
10
|
+
add_column :spree_prices, :admin_notes, :text
|
|
11
|
+
|
|
12
|
+
add_index :spree_prices, :price_type_id
|
|
13
|
+
add_index :spree_prices, :role_id
|
|
14
|
+
add_index :spree_prices,
|
|
15
|
+
[:variant_id, :currency, :country_iso],
|
|
16
|
+
name: "index_spree_prices_on_advanced_pricing_lookup"
|
|
17
|
+
|
|
18
|
+
add_foreign_key :spree_prices, :solidus_advanced_pricing_price_types, column: :price_type_id, on_delete: :restrict
|
|
19
|
+
add_foreign_key :spree_prices, :spree_roles, column: :role_id, on_delete: :restrict
|
|
20
|
+
end
|
|
21
|
+
end
|