solidus_advanced_pricing 0.1.0 → 0.3.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 +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +116 -1
- data/README.md +243 -16
- data/app/decorators/models/solidus_advanced_pricing/spree/price_decorator.rb +33 -1
- data/app/decorators/models/solidus_advanced_pricing/spree/role_decorator.rb +3 -1
- data/app/models/solidus_advanced_pricing/price_batch.rb +264 -0
- data/app/models/solidus_advanced_pricing/price_selector.rb +3 -1
- data/app/models/solidus_advanced_pricing/price_type.rb +2 -0
- data/app/models/solidus_advanced_pricing/price_type_cache.rb +19 -1
- data/config/locales/en.yml +11 -2
- data/config/routes.rb +8 -1
- data/db/migrate/20260923000001_add_role_to_solidus_advanced_pricing_price_types.rb +10 -0
- data/lib/components/admin/solidus_admin/price_types/index/component.rb +4 -0
- data/lib/controllers/admin/solidus_admin/price_types_controller.rb +1 -1
- data/lib/controllers/api/spree/api/price_batches_controller.rb +47 -0
- data/lib/controllers/api/spree/api/price_types_controller.rb +17 -0
- data/lib/controllers/api/spree/api/prices_controller.rb +79 -1
- data/lib/controllers/backend/spree/admin/price_types_controller.rb +1 -1
- data/lib/solidus_advanced_pricing/configuration.rb +14 -3
- data/lib/solidus_advanced_pricing/version.rb +1 -1
- data/lib/views/api/spree/api/price_batches/create.json.jbuilder +6 -0
- data/lib/views/api/spree/api/price_types/_price_type.json.jbuilder +1 -0
- data/lib/views/api/spree/api/price_types/index.json.jbuilder +3 -0
- data/lib/views/backend/spree/admin/price_types/_form.html.erb +11 -0
- data/lib/views/backend/spree/admin/price_types/index.html.erb +2 -0
- data/lib/views/backend/spree/admin/prices/_advanced_fields.html.erb +5 -4
- data/solidus_advanced_pricing.gemspec +3 -3
- metadata +12 -6
- data/docs/superpowers/specs/2026-09-14-solidus-advanced-pricing-design.md +0 -351
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusAdvancedPricing
|
|
4
|
+
# Applies many price rows in one call, keyed on the dimensions a price
|
|
5
|
+
# legitimately varies on, so re-sending the same payload is a no-op rather
|
|
6
|
+
# than a pile of duplicates.
|
|
7
|
+
#
|
|
8
|
+
# Deliberately generic: it validates and writes prices and reports what it
|
|
9
|
+
# did. Store policy -- how large a swing is allowed, what gets reindexed,
|
|
10
|
+
# what gets published on an event bus -- belongs to the host app, which can
|
|
11
|
+
# hang it off `SolidusAdvancedPricing.config.batch_guard` or act on the
|
|
12
|
+
# returned results.
|
|
13
|
+
class PriceBatch
|
|
14
|
+
MODES = %w[upsert replace].freeze
|
|
15
|
+
|
|
16
|
+
# Two rows agreeing on all of these are the same price. `valid_to` is
|
|
17
|
+
# deliberately absent: extending or shortening a window is an edit to an
|
|
18
|
+
# existing price, not a different one.
|
|
19
|
+
NATURAL_KEY = %i[variant_id currency country_iso price_type_id role_id valid_from].freeze
|
|
20
|
+
|
|
21
|
+
# Everything a natural-key row may change. Anything in the key is, by
|
|
22
|
+
# definition, how the row was found, so changing it there is incoherent.
|
|
23
|
+
UPDATABLE = %i[amount valid_to admin_notes].freeze
|
|
24
|
+
|
|
25
|
+
# An `id` names the row outright, so nothing is off-limits except which
|
|
26
|
+
# variant it hangs off. `price_type_id` is listed but the model refuses to
|
|
27
|
+
# change it on a persisted price, which surfaces as an ordinary row error.
|
|
28
|
+
UPDATABLE_BY_ID = %i[amount currency country_iso price_type_id role_id valid_from valid_to admin_notes].freeze
|
|
29
|
+
|
|
30
|
+
class TooManyRows < StandardError; end
|
|
31
|
+
|
|
32
|
+
class InvalidMode < StandardError; end
|
|
33
|
+
|
|
34
|
+
Result = Struct.new(:index, :status, :price_id, :variant_id, :errors, keyword_init: true) do
|
|
35
|
+
def to_h
|
|
36
|
+
{index: index, status: status, price_id: price_id, variant_id: variant_id, errors: errors}.compact
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :results, :mode
|
|
41
|
+
|
|
42
|
+
def initialize(rows:, mode: "upsert", dry_run: false, row_limit: nil)
|
|
43
|
+
@rows = Array(rows)
|
|
44
|
+
@mode = mode.to_s
|
|
45
|
+
@dry_run = ActiveModel::Type::Boolean.new.cast(dry_run) || false
|
|
46
|
+
@row_limit = row_limit || SolidusAdvancedPricing.config.batch_row_limit
|
|
47
|
+
@results = []
|
|
48
|
+
@touched_ids = Hash.new { |hash, key| hash[key] = [] }
|
|
49
|
+
|
|
50
|
+
raise InvalidMode, "mode must be one of #{MODES.join(", ")}" unless MODES.include?(@mode)
|
|
51
|
+
raise TooManyRows, "batch is limited to #{@row_limit} rows, got #{@rows.size}" if @rows.size > @row_limit
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def dry_run? = @dry_run
|
|
55
|
+
|
|
56
|
+
def replace? = @mode == "replace"
|
|
57
|
+
|
|
58
|
+
def call
|
|
59
|
+
# A dry run does the real work against the database and throws it away, so
|
|
60
|
+
# what it reports is what the write would actually do -- validations, type
|
|
61
|
+
# casting and constraints included -- rather than a guess at it.
|
|
62
|
+
ActiveRecord::Base.transaction do
|
|
63
|
+
seen = {}
|
|
64
|
+
|
|
65
|
+
@rows.each_with_index do |row, index|
|
|
66
|
+
apply_row(row.to_h.symbolize_keys, index, seen)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
apply_replacements if replace?
|
|
70
|
+
|
|
71
|
+
# The host app's veto, with every row already written and nothing
|
|
72
|
+
# committed. Raising from here aborts the whole batch -- which is the
|
|
73
|
+
# only place a "this moves too many prices too far" rule can see the
|
|
74
|
+
# finished picture and still stop it.
|
|
75
|
+
SolidusAdvancedPricing.config.batch_guard&.call(self)
|
|
76
|
+
|
|
77
|
+
raise ActiveRecord::Rollback if dry_run?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def summary
|
|
84
|
+
results.group_by(&:status).transform_values(&:size)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Resolution happens outside the savepoint -- it touches no state, and a
|
|
90
|
+
# `return` out of a transaction block is a trap worth not setting.
|
|
91
|
+
# Two ways to name a row, and `id` wins when it is there: it says exactly
|
|
92
|
+
# which price to change, with no key to assemble and no precision to lose.
|
|
93
|
+
#
|
|
94
|
+
# The natural key is what a payload authored somewhere that does not know
|
|
95
|
+
# Solidus ids -- a supplier feed, a merchandiser's spreadsheet, a backfill
|
|
96
|
+
# from another system -- has instead. It is also what makes such a payload
|
|
97
|
+
# re-runnable: without it, a nightly feed has no ids on its rows and would
|
|
98
|
+
# create a fresh duplicate of every price on every pass.
|
|
99
|
+
def apply_row(row, index, seen)
|
|
100
|
+
located = row[:id].present? ? locate_by_id(row) : locate_by_key(row)
|
|
101
|
+
return record(index, :error, errors: [located]) if located.is_a?(String)
|
|
102
|
+
|
|
103
|
+
price, variant_id, price_type_id, dedup, attributes = located
|
|
104
|
+
|
|
105
|
+
if seen.key?(dedup)
|
|
106
|
+
return record(index, :error, variant_id: variant_id,
|
|
107
|
+
errors: ["duplicate of row #{seen[dedup]}: #{duplicate_reason(dedup)}"])
|
|
108
|
+
end
|
|
109
|
+
seen[dedup] = index
|
|
110
|
+
|
|
111
|
+
persist(index, price, variant_id, price_type_id, attributes)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def duplicate_reason(dedup)
|
|
115
|
+
if dedup.first == :id
|
|
116
|
+
"same price id"
|
|
117
|
+
else
|
|
118
|
+
"same variant, currency, country, price type, role and valid_from"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def locate_by_id(row)
|
|
123
|
+
price = ::Spree::Price.kept.find_by(id: row[:id])
|
|
124
|
+
return "no price with id #{row[:id]}" if price.nil?
|
|
125
|
+
|
|
126
|
+
variant = resolve_variant(row) unless row[:variant_id].blank? && row[:sku].blank?
|
|
127
|
+
return "variant: #{variant}" if variant.is_a?(String)
|
|
128
|
+
if variant && variant.id != price.variant_id
|
|
129
|
+
return "price #{price.id} belongs to variant #{price.variant_id}, not #{variant.id}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
price_type_id = resolve_price_type_id(row)
|
|
133
|
+
return "price_type: #{price_type_id}" if price_type_id.is_a?(String)
|
|
134
|
+
|
|
135
|
+
attributes = row.slice(*UPDATABLE_BY_ID)
|
|
136
|
+
attributes[:price_type_id] = price_type_id if row.key?(:price_type_code)
|
|
137
|
+
|
|
138
|
+
[price, price.variant_id, price.price_type_id, [:id, price.id], attributes]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def locate_by_key(row)
|
|
142
|
+
variant = resolve_variant(row)
|
|
143
|
+
return "variant: #{variant}" if variant.is_a?(String)
|
|
144
|
+
|
|
145
|
+
price_type_id = resolve_price_type_id(row)
|
|
146
|
+
return "price_type: #{price_type_id}" if price_type_id.is_a?(String)
|
|
147
|
+
|
|
148
|
+
key = natural_key(row, variant, price_type_id)
|
|
149
|
+
price = find_existing(key) || ::Spree::Price.new(key)
|
|
150
|
+
|
|
151
|
+
[price, variant.id, price_type_id, [:key, key], row.slice(*UPDATABLE)]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Each row gets its own savepoint: one bad row must not poison the batch,
|
|
155
|
+
# and on PostgreSQL a raised constraint error would otherwise abort every
|
|
156
|
+
# statement that follows it in the transaction.
|
|
157
|
+
def persist(index, price, variant_id, price_type_id, attributes)
|
|
158
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
159
|
+
existing = price.persisted?
|
|
160
|
+
price.assign_attributes(attributes)
|
|
161
|
+
|
|
162
|
+
if existing && !price.changed?
|
|
163
|
+
touch(variant_id, price_type_id, price.id)
|
|
164
|
+
record(index, :unchanged, price_id: price.id, variant_id: variant_id)
|
|
165
|
+
elsif price.save
|
|
166
|
+
touch(variant_id, price.price_type_id, price.id)
|
|
167
|
+
record(index, existing ? :updated : :created, price_id: reportable_id(price), variant_id: variant_id)
|
|
168
|
+
else
|
|
169
|
+
# Recorded before the rollback on purpose: @results is a Ruby array,
|
|
170
|
+
# so the savepoint unwinding the row does not unwind the report of it.
|
|
171
|
+
record(index, :error, variant_id: variant_id, errors: price.errors.full_messages)
|
|
172
|
+
raise ActiveRecord::Rollback
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def touch(variant_id, price_type_id, price_id)
|
|
178
|
+
@touched_ids[[variant_id, price_type_id]] << price_id
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Only prices of a type the payload actually spoke about, on a variant the
|
|
182
|
+
# payload actually named, are in scope. A `replace` that named one sale
|
|
183
|
+
# price must not reach the variant's base price or its wholesale tier.
|
|
184
|
+
def apply_replacements
|
|
185
|
+
@touched_ids.each do |(variant_id, price_type_id), kept_ids|
|
|
186
|
+
stale = ::Spree::Price.kept.where(variant_id: variant_id, price_type_id: price_type_id).where.not(id: kept_ids)
|
|
187
|
+
|
|
188
|
+
stale.each do |price|
|
|
189
|
+
price.discard
|
|
190
|
+
@results << Result.new(index: nil, status: :deleted, price_id: price.id, variant_id: variant_id)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def resolve_variant(row)
|
|
196
|
+
if row[:variant_id].present? && row[:sku].present?
|
|
197
|
+
return "give variant_id or sku, not both"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
if row[:variant_id].present?
|
|
201
|
+
::Spree::Variant.find_by(id: row[:variant_id]) || "no variant with id #{row[:variant_id]}"
|
|
202
|
+
elsif row[:sku].present?
|
|
203
|
+
::Spree::Variant.find_by(sku: row[:sku]) || "no variant with sku #{row[:sku].inspect}"
|
|
204
|
+
else
|
|
205
|
+
"variant_id or sku is required"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Mirrors the single-price endpoint: a code is portable between databases
|
|
210
|
+
# where an id is not. Returns a String to signal failure, since nil is the
|
|
211
|
+
# legitimate value for the untyped base price.
|
|
212
|
+
def resolve_price_type_id(row)
|
|
213
|
+
has_code = row.key?(:price_type_code)
|
|
214
|
+
return "give price_type_id or price_type_code, not both" if has_code && row[:price_type_id].present?
|
|
215
|
+
return row[:price_type_id].presence && row[:price_type_id].to_i unless has_code
|
|
216
|
+
|
|
217
|
+
code = row[:price_type_code]
|
|
218
|
+
return nil if code.blank?
|
|
219
|
+
|
|
220
|
+
SolidusAdvancedPricing.resolve_price_type_id(code)
|
|
221
|
+
rescue ArgumentError
|
|
222
|
+
"no price type with code #{code.inspect}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def natural_key(row, variant, price_type_id)
|
|
226
|
+
valid_from = ::Spree::Price.type_for_attribute(:valid_from).cast(row[:valid_from].presence)
|
|
227
|
+
|
|
228
|
+
{
|
|
229
|
+
variant_id: variant.id,
|
|
230
|
+
currency: row[:currency].presence || ::Spree::Config.default_pricing_options.currency,
|
|
231
|
+
country_iso: row[:country_iso].presence,
|
|
232
|
+
price_type_id: price_type_id,
|
|
233
|
+
role_id: row[:role_id].presence && row[:role_id].to_i,
|
|
234
|
+
# Truncated to the second so the key survives a round trip: a client that
|
|
235
|
+
# read a price back and re-sent its `valid_from` may have dropped the
|
|
236
|
+
# sub-second part, and treating that as a different price would duplicate
|
|
237
|
+
# the row -- and in `replace` mode, discard the original.
|
|
238
|
+
valid_from: valid_from&.change(usec: 0)
|
|
239
|
+
}
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Matching is a one-second window rather than an equality, so a price that
|
|
243
|
+
# was stored with sub-second precision by some other writer is still found
|
|
244
|
+
# by a truncated key.
|
|
245
|
+
def find_existing(key)
|
|
246
|
+
scope = ::Spree::Price.kept.where(key.except(:valid_from))
|
|
247
|
+
from = key[:valid_from]
|
|
248
|
+
|
|
249
|
+
return scope.find_by(valid_from: nil) if from.nil?
|
|
250
|
+
|
|
251
|
+
scope.where(valid_from: from...(from + 1.second)).first
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# A dry run rolls back, so any id it allocated is about to stop existing.
|
|
255
|
+
# Reporting it would invite a client to use it.
|
|
256
|
+
def reportable_id(price)
|
|
257
|
+
dry_run? ? nil : price.id
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def record(index, status, price_id: nil, variant_id: nil, errors: nil)
|
|
261
|
+
@results << Result.new(index: index, status: status, price_id: price_id, variant_id: variant_id, errors: errors)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -49,7 +49,9 @@ module SolidusAdvancedPricing
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def visible_to?(price, customer_role_ids)
|
|
52
|
-
|
|
52
|
+
effective = price.effective_role_id
|
|
53
|
+
|
|
54
|
+
effective.nil? || customer_role_ids.include?(effective)
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
# Country is specificity, not competition: a country-specific price beats the
|
|
@@ -21,6 +21,8 @@ module SolidusAdvancedPricing
|
|
|
21
21
|
foreign_key: :price_type_id,
|
|
22
22
|
inverse_of: :price_type
|
|
23
23
|
|
|
24
|
+
belongs_to :role, class_name: "::Spree::Role", optional: true
|
|
25
|
+
|
|
24
26
|
# Not `dependent: :restrict_with_error` — that check is default-scoped and
|
|
25
27
|
# misses discarded prices, which then trip the FK on DELETE.
|
|
26
28
|
before_destroy :prevent_destroying_referenced_type
|
|
@@ -6,10 +6,15 @@ module SolidusAdvancedPricing
|
|
|
6
6
|
# and, later, by Spree::Price.
|
|
7
7
|
module PriceTypeCache
|
|
8
8
|
class << self
|
|
9
|
+
# Union with type-level defaults: a role that appears only on a price type
|
|
10
|
+
# (never directly on a price) must still narrow in, or a customer holding
|
|
11
|
+
# only that role would be filtered out before selection ever runs.
|
|
9
12
|
def pricing_role_ids
|
|
10
13
|
return @pricing_role_ids if defined?(@pricing_role_ids)
|
|
11
14
|
|
|
12
|
-
@pricing_role_ids =
|
|
15
|
+
@pricing_role_ids = (
|
|
16
|
+
::Spree::Price.distinct.pluck(:role_id) + PriceType.with_discarded.distinct.pluck(:role_id)
|
|
17
|
+
).compact.uniq
|
|
13
18
|
end
|
|
14
19
|
|
|
15
20
|
# id => position, for the selector's tie-breaker. A handful of rows that
|
|
@@ -35,10 +40,23 @@ module SolidusAdvancedPricing
|
|
|
35
40
|
ids_by_code[code.to_s]
|
|
36
41
|
end
|
|
37
42
|
|
|
43
|
+
# id => role_id, for the effective-role fallback. with_discarded matters —
|
|
44
|
+
# a retired type's default role must still apply to historical prices.
|
|
45
|
+
def role_ids
|
|
46
|
+
return @role_ids if defined?(@role_ids)
|
|
47
|
+
|
|
48
|
+
@role_ids = PriceType.with_discarded.pluck(:id, :role_id).to_h
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def role_id_for(price_type_id)
|
|
52
|
+
role_ids[price_type_id]
|
|
53
|
+
end
|
|
54
|
+
|
|
38
55
|
def clear
|
|
39
56
|
remove_instance_variable(:@pricing_role_ids) if defined?(@pricing_role_ids)
|
|
40
57
|
remove_instance_variable(:@positions) if defined?(@positions)
|
|
41
58
|
remove_instance_variable(:@ids_by_code) if defined?(@ids_by_code)
|
|
59
|
+
remove_instance_variable(:@role_ids) if defined?(@role_ids)
|
|
42
60
|
end
|
|
43
61
|
end
|
|
44
62
|
end
|
data/config/locales/en.yml
CHANGED
|
@@ -6,7 +6,10 @@ en:
|
|
|
6
6
|
solidus_advanced_pricing:
|
|
7
7
|
price_types: "Price Types"
|
|
8
8
|
all_customers: "All customers"
|
|
9
|
-
|
|
9
|
+
base_price: "Base price (no type)"
|
|
10
|
+
price_type_hint: "Leave as Base price for the standard price. A type is a label for the admin and reporting — it grants no eligibility on its own."
|
|
11
|
+
role_hint: "Leave blank to inherit the price type's default role, if it has one; otherwise visible to all customers. A price type name alone grants no access control — an employee-only price needs this field, or the type, set."
|
|
12
|
+
price_type_role_hint: "Prices of this type default to this role unless a price sets its own. Leaving a price's own role blank means it inherits this default — it does not make that price public."
|
|
10
13
|
admin_notes_hint: "Internal only. Never shown to customers."
|
|
11
14
|
validity: "Validity"
|
|
12
15
|
solidus_admin:
|
|
@@ -22,6 +25,8 @@ en:
|
|
|
22
25
|
one: "Price Type"
|
|
23
26
|
other: "Price Types"
|
|
24
27
|
attributes:
|
|
28
|
+
solidus_advanced_pricing/price_type:
|
|
29
|
+
role_id: "Default role"
|
|
25
30
|
spree/price:
|
|
26
31
|
price_type_id: "Price Type"
|
|
27
32
|
role_id: "Visible to role"
|
|
@@ -36,9 +41,13 @@ en:
|
|
|
36
41
|
referenced_by_prices: "This price type is still used by one or more prices and cannot be deleted. Retire it instead."
|
|
37
42
|
spree/price:
|
|
38
43
|
attributes:
|
|
44
|
+
price_type_id:
|
|
45
|
+
cannot_be_changed: "cannot be changed once the price exists. Discard this price and create a new one with the right type."
|
|
46
|
+
ambiguous_price_type: "cannot be given as both price_type_id and price_type_code. Send one or the other."
|
|
47
|
+
unknown_code: "no price type with code %{code}"
|
|
39
48
|
valid_to:
|
|
40
49
|
must_be_after_valid_from: "must be after the valid from date"
|
|
41
50
|
spree/role:
|
|
42
51
|
attributes:
|
|
43
52
|
base:
|
|
44
|
-
referenced_by_prices: "This role is used to target one or more prices and cannot be deleted."
|
|
53
|
+
referenced_by_prices: "This role is used to target one or more prices or price types and cannot be deleted."
|
data/config/routes.rb
CHANGED
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
Spree::Core::Engine.routes.draw do
|
|
4
4
|
namespace :api, defaults: {format: "json"} do
|
|
5
5
|
resources :variants, only: [] do
|
|
6
|
-
resources :prices, only: [:index, :show]
|
|
6
|
+
resources :prices, only: [:index, :show, :create, :update, :destroy]
|
|
7
7
|
end
|
|
8
|
+
|
|
9
|
+
# Not nested under a variant: a batch spans variants, which is the point of it.
|
|
10
|
+
post "prices/batch", to: "price_batches#create", as: :price_batch
|
|
11
|
+
|
|
12
|
+
# Read-only: a client building a price payload needs the codes and ids, but
|
|
13
|
+
# creating a pricing dimension is an admin act, not an API one.
|
|
14
|
+
resources :price_types, only: [:index]
|
|
8
15
|
end
|
|
9
16
|
|
|
10
17
|
namespace :admin do
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddRoleToSolidusAdvancedPricingPriceTypes < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
# :integer, not :bigint — spree_roles.id is int and MySQL rejects mismatched FK precision.
|
|
6
|
+
add_column :solidus_advanced_pricing_price_types, :role_id, :integer
|
|
7
|
+
add_index :solidus_advanced_pricing_price_types, :role_id
|
|
8
|
+
add_foreign_key :solidus_advanced_pricing_price_types, :spree_roles, column: :role_id, on_delete: :restrict
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -7,7 +7,7 @@ module SolidusAdmin
|
|
|
7
7
|
def resource_class = SolidusAdvancedPricing::PriceType
|
|
8
8
|
|
|
9
9
|
def permitted_resource_params
|
|
10
|
-
params.require(:price_type).permit(:name, :code, :position)
|
|
10
|
+
params.require(:price_type).permit(:name, :code, :position, :role_id)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
# ResourcesController derives these from resource_class.model_name, which for
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Api
|
|
5
|
+
class PriceBatchesController < Spree::Api::BaseController
|
|
6
|
+
rescue_from SolidusAdvancedPricing::PriceBatch::TooManyRows, with: :batch_too_large
|
|
7
|
+
rescue_from SolidusAdvancedPricing::PriceBatch::InvalidMode, with: :bad_mode
|
|
8
|
+
|
|
9
|
+
# Always 200 with a per-row report, never a partial 4xx: a caller that
|
|
10
|
+
# sent 500 rows and got a bare 422 has no way to know which of them
|
|
11
|
+
# landed. The 4xx cases here are the ones where no row was even looked at.
|
|
12
|
+
def create
|
|
13
|
+
authorize! :create, Spree::Price
|
|
14
|
+
|
|
15
|
+
@batch = SolidusAdvancedPricing::PriceBatch.new(
|
|
16
|
+
rows: batch_rows,
|
|
17
|
+
mode: params.fetch(:mode, "upsert"),
|
|
18
|
+
dry_run: params[:dry_run]
|
|
19
|
+
).call
|
|
20
|
+
|
|
21
|
+
render :create, status: :ok
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def batch_rows
|
|
27
|
+
params.require(:prices).map do |row|
|
|
28
|
+
row.permit(
|
|
29
|
+
*Spree::PermittedAttributes.price_attributes,
|
|
30
|
+
:id,
|
|
31
|
+
:variant_id,
|
|
32
|
+
:sku,
|
|
33
|
+
:price_type_code
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def batch_too_large(exception)
|
|
39
|
+
render json: {error: exception.message}, status: :unprocessable_entity
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def bad_mode(exception)
|
|
43
|
+
render json: {error: exception.message}, status: :unprocessable_entity
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Api
|
|
5
|
+
class PriceTypesController < Spree::Api::BaseController
|
|
6
|
+
# Retired types are excluded: this endpoint exists so a client can build a
|
|
7
|
+
# valid price payload, and a discarded type is no longer a valid choice.
|
|
8
|
+
# Historical prices still serialize their own type's code via the prices
|
|
9
|
+
# endpoint, so nothing is hidden from a reader.
|
|
10
|
+
def index
|
|
11
|
+
authorize! :index, SolidusAdvancedPricing::PriceType
|
|
12
|
+
@price_types = SolidusAdvancedPricing::PriceType.accessible_by(current_ability, :index).ordered
|
|
13
|
+
respond_with(@price_types)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -15,18 +15,96 @@ module Spree
|
|
|
15
15
|
respond_with(@price)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
def create
|
|
19
|
+
authorize! :create, Spree::Price
|
|
20
|
+
|
|
21
|
+
@price = variant.prices.new(currency: Spree::Config.default_pricing_options.currency)
|
|
22
|
+
assign_price_attributes(@price)
|
|
23
|
+
|
|
24
|
+
if @price.errors.empty? && @price.save
|
|
25
|
+
render :show, status: :created
|
|
26
|
+
else
|
|
27
|
+
invalid_resource!(@price)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def update
|
|
32
|
+
# `find` then `authorize!`, rather than an accessible_by scope, so a
|
|
33
|
+
# caller without rights gets 401 like every other action here instead of
|
|
34
|
+
# a 404 that reads as "no such price".
|
|
35
|
+
@price = live_prices.find(params[:id])
|
|
36
|
+
authorize! :update, @price
|
|
37
|
+
|
|
38
|
+
assign_price_attributes(@price)
|
|
39
|
+
|
|
40
|
+
if @price.errors.empty? && @price.save
|
|
41
|
+
render :show
|
|
42
|
+
else
|
|
43
|
+
invalid_resource!(@price)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Soft delete: `discard`, not `destroy`, so the row stays available to
|
|
48
|
+
# anything reporting on what a variant used to cost.
|
|
49
|
+
def destroy
|
|
50
|
+
@price = live_prices.find(params[:id])
|
|
51
|
+
authorize! :destroy, @price
|
|
52
|
+
|
|
53
|
+
@price.discard
|
|
54
|
+
render plain: nil, status: :no_content
|
|
55
|
+
end
|
|
56
|
+
|
|
18
57
|
private
|
|
19
58
|
|
|
20
59
|
# Lists what exists for this variant; never routed through
|
|
21
60
|
# current_pricing_options, which would filter by the requesting admin's
|
|
22
61
|
# own roles via current_spree_user.
|
|
62
|
+
#
|
|
63
|
+
# `Spree::Variant#prices` is declared `-> { with_discarded }` in core, so
|
|
64
|
+
# the default here has to put `kept` back -- otherwise every listing
|
|
65
|
+
# includes prices someone deleted. `?show_deleted=true` opts back in, the
|
|
66
|
+
# same switch core uses on products.
|
|
23
67
|
def scope
|
|
24
|
-
variant.prices.accessible_by(current_ability, :index)
|
|
68
|
+
prices = variant.prices.accessible_by(current_ability, :index)
|
|
69
|
+
params[:show_deleted] ? prices : prices.kept
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Writes never reach a discarded price: undeleting is not an API operation,
|
|
73
|
+
# and silently editing a deleted row is worse than a 404.
|
|
74
|
+
def live_prices
|
|
75
|
+
variant.prices.kept
|
|
25
76
|
end
|
|
26
77
|
|
|
27
78
|
def variant
|
|
28
79
|
@variant ||= Spree::Variant.find(params[:variant_id])
|
|
29
80
|
end
|
|
81
|
+
|
|
82
|
+
# `price_type_code` is accepted as an alternative to `price_type_id` because
|
|
83
|
+
# ids are per-database and codes are not -- a payload written against staging
|
|
84
|
+
# has to work unchanged against production. An explicit null means the untyped
|
|
85
|
+
# base price, so a present-but-empty key is meaningful and not the same as an
|
|
86
|
+
# absent one.
|
|
87
|
+
def assign_price_attributes(price)
|
|
88
|
+
attributes = params.require(:price).permit(*Spree::PermittedAttributes.price_attributes, :price_type_code)
|
|
89
|
+
|
|
90
|
+
if attributes.key?("price_type_code")
|
|
91
|
+
code = attributes.delete("price_type_code")
|
|
92
|
+
|
|
93
|
+
if attributes.key?("price_type_id")
|
|
94
|
+
price.errors.add(:price_type_id, :ambiguous_price_type)
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
begin
|
|
99
|
+
attributes["price_type_id"] = code.presence && SolidusAdvancedPricing.resolve_price_type_id(code)
|
|
100
|
+
rescue ArgumentError
|
|
101
|
+
price.errors.add(:price_type_id, :unknown_code, code: code)
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
price.assign_attributes(attributes)
|
|
107
|
+
end
|
|
30
108
|
end
|
|
31
109
|
end
|
|
32
110
|
end
|
|
@@ -14,7 +14,7 @@ module Spree
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def permitted_resource_params
|
|
17
|
-
params.require(:price_type).permit(:name, :code, :position)
|
|
17
|
+
params.require(:price_type).permit(:name, :code, :position, :role_id)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
# ResourceController derives these from model_class.model_name, which for a
|
|
@@ -2,9 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidusAdvancedPricing
|
|
4
4
|
class Configuration
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
5
|
+
# Largest payload PriceBatch will accept in one call. A synchronous request
|
|
6
|
+
# has to stay inside the web timeout; anything bigger belongs in a job.
|
|
7
|
+
attr_writer :batch_row_limit
|
|
8
|
+
|
|
9
|
+
# Optional callable invoked by PriceBatch once every row is written and
|
|
10
|
+
# before the transaction commits, receiving the batch. Raise from it to
|
|
11
|
+
# abort. This is the seam for store policy -- "refuse a batch that moves
|
|
12
|
+
# more than N% of prices by more than X%" -- which does not belong in a
|
|
13
|
+
# general-purpose extension but does need somewhere to stand.
|
|
14
|
+
attr_accessor :batch_guard
|
|
15
|
+
|
|
16
|
+
def batch_row_limit
|
|
17
|
+
@batch_row_limit ||= 500
|
|
18
|
+
end
|
|
8
19
|
end
|
|
9
20
|
|
|
10
21
|
class << self
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
json.call(price_type, :id, :code, :name, :position, :role_id)
|
|
@@ -17,4 +17,15 @@
|
|
|
17
17
|
<%= f.number_field :position, class: 'fullwidth' %>
|
|
18
18
|
<% end %>
|
|
19
19
|
</div>
|
|
20
|
+
<div class="col-6">
|
|
21
|
+
<%= f.field_container :role do %>
|
|
22
|
+
<%= f.label :role_id, Spree::Role.model_name.human %>
|
|
23
|
+
<%= f.collection_select :role_id,
|
|
24
|
+
Spree::Role.order(:name),
|
|
25
|
+
:id, :name,
|
|
26
|
+
{ include_blank: t('solidus_advanced_pricing.all_customers') },
|
|
27
|
+
{ class: 'custom-select fullwidth' } %>
|
|
28
|
+
<span class="field-hint"><%= t('solidus_advanced_pricing.price_type_role_hint') %></span>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
20
31
|
</div>
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
<th><%= SolidusAdvancedPricing::PriceType.human_attribute_name(:name) %></th>
|
|
13
13
|
<th><%= SolidusAdvancedPricing::PriceType.human_attribute_name(:code) %></th>
|
|
14
14
|
<th><%= SolidusAdvancedPricing::PriceType.human_attribute_name(:position) %></th>
|
|
15
|
+
<th><%= SolidusAdvancedPricing::PriceType.human_attribute_name(:role_id) %></th>
|
|
15
16
|
<th class="actions"></th>
|
|
16
17
|
</tr>
|
|
17
18
|
</thead>
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
<td><%= price_type.name %></td>
|
|
22
23
|
<td><%= price_type.code %></td>
|
|
23
24
|
<td><%= price_type.position %></td>
|
|
25
|
+
<td><%= price_type.role&.name || t('solidus_advanced_pricing.all_customers') %></td>
|
|
24
26
|
<td class="actions">
|
|
25
27
|
<%= link_to_edit price_type, no_text: true %>
|
|
26
28
|
<%= link_to_delete price_type, no_text: true %>
|