spree_core 5.4.0.beta8 → 5.4.0.beta10
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/app/finders/spree/products/find.rb +1 -1
- data/app/jobs/spree/search_provider/index_job.rb +22 -0
- data/app/jobs/spree/search_provider/remove_job.rb +19 -0
- data/app/models/concerns/spree/product_scopes.rb +95 -135
- data/app/models/concerns/spree/search_indexable.rb +83 -0
- data/app/models/concerns/spree/{multi_searchable.rb → searchable.rb} +10 -3
- data/app/models/concerns/spree/user_methods.rb +11 -8
- data/app/models/spree/address.rb +3 -15
- data/app/models/spree/credit_card.rb +1 -0
- data/app/models/spree/inventory_unit.rb +1 -1
- data/app/models/spree/line_item.rb +4 -0
- data/app/models/spree/metafield_definition.rb +7 -4
- data/app/models/spree/option_type.rb +3 -0
- data/app/models/spree/option_value.rb +3 -0
- data/app/models/spree/order.rb +27 -9
- data/app/models/spree/order_promotion.rb +1 -1
- data/app/models/spree/product.rb +9 -31
- data/app/models/spree/refresh_token.rb +60 -0
- data/app/models/spree/search_provider/base.rb +81 -0
- data/app/models/spree/search_provider/database.rb +95 -0
- data/app/models/spree/search_provider/meilisearch.rb +302 -0
- data/app/models/spree/search_provider/search_result.rb +5 -0
- data/app/models/spree/shipment.rb +1 -1
- data/app/models/spree/shipping_method.rb +1 -1
- data/app/models/spree/shipping_rate.rb +1 -1
- data/app/models/spree/taxon.rb +2 -7
- data/app/models/spree/variant.rb +6 -25
- data/app/models/spree/wishlist.rb +1 -0
- data/app/presenters/spree/search_provider/product_presenter.rb +131 -0
- data/app/services/spree/carts/update.rb +5 -4
- data/db/migrate/20260317000000_create_spree_refresh_tokens.rb +16 -0
- data/db/sample_data/payment_methods.rb +2 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +16 -3
- data/lib/tasks/search.rake +15 -0
- metadata +30 -6
- data/app/models/spree/cart_promotion.rb +0 -7
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module SearchProvider
|
|
3
|
+
class Meilisearch < Base
|
|
4
|
+
PREFIXED_ID_PATTERN = /\A[a-z]+_[A-Za-z0-9]+\z/
|
|
5
|
+
ALLOWED_STATUSES = %w[active draft archived paused].freeze
|
|
6
|
+
|
|
7
|
+
def self.indexing_required?
|
|
8
|
+
true
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(store)
|
|
12
|
+
super
|
|
13
|
+
require 'meilisearch'
|
|
14
|
+
rescue LoadError
|
|
15
|
+
raise LoadError, "Add `gem 'meilisearch'` to your Gemfile to use the Meilisearch search provider"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
|
|
19
|
+
page = [page.to_i, 1].max
|
|
20
|
+
limit = limit.to_i.clamp(1, 100)
|
|
21
|
+
|
|
22
|
+
search_params = {
|
|
23
|
+
filter: build_filters(filters),
|
|
24
|
+
facets: facet_attributes,
|
|
25
|
+
sort: build_sort(sort),
|
|
26
|
+
offset: (page - 1) * limit,
|
|
27
|
+
limit: limit
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
Rails.logger.debug { "[Meilisearch] index=#{index_name} query=#{query.inspect} #{search_params.compact.inspect}" }
|
|
31
|
+
|
|
32
|
+
ms_result = client.index(index_name).search(query.to_s, search_params)
|
|
33
|
+
|
|
34
|
+
Rails.logger.debug { "[Meilisearch] #{ms_result['estimatedTotalHits']} hits in #{ms_result['processingTimeMs']}ms" }
|
|
35
|
+
|
|
36
|
+
# Hits have composite prefixed_id (prod_abc_en_USD), extract product_id (prod_abc)
|
|
37
|
+
product_prefixed_ids = ms_result['hits'].map { |h| h['product_id'] }.uniq
|
|
38
|
+
raw_ids = product_prefixed_ids.filter_map { |pid| Spree::Product.decode_prefixed_id(pid) }
|
|
39
|
+
|
|
40
|
+
# Intersect with AR scope for security/visibility.
|
|
41
|
+
# Since we filter by store/status/currency/discontinue_on in Meilisearch,
|
|
42
|
+
# the AR scope is a safety net — it should not filter anything out.
|
|
43
|
+
products = if raw_ids.any?
|
|
44
|
+
scope.where(id: raw_ids).reorder(nil)
|
|
45
|
+
else
|
|
46
|
+
scope.none
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Build Pagy object from Meilisearch response (passive mode)
|
|
50
|
+
pagy = build_pagy(ms_result, page, limit)
|
|
51
|
+
|
|
52
|
+
SearchResult.new(
|
|
53
|
+
products: products,
|
|
54
|
+
filters: build_facet_response(ms_result['facetDistribution'] || {}),
|
|
55
|
+
sort_options: available_sort_options.map { |id| { id: id } },
|
|
56
|
+
default_sort: 'manual',
|
|
57
|
+
total_count: ms_result['estimatedTotalHits'] || 0,
|
|
58
|
+
pagy: pagy
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def index(product)
|
|
63
|
+
documents = ProductPresenter.new(product, store).call
|
|
64
|
+
client.index(index_name).add_documents(documents, 'prefixed_id')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def remove(product)
|
|
68
|
+
remove_by_id(product.prefixed_id)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def index_batch(documents)
|
|
72
|
+
client.index(index_name).add_documents(documents, 'prefixed_id')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Remove all documents for a product by its prefixed_id (e.g. 'prod_abc')
|
|
76
|
+
def remove_by_id(prefixed_id)
|
|
77
|
+
filter = "product_id = '#{sanitize_prefixed_id(prefixed_id)}'"
|
|
78
|
+
client.index(index_name).delete_documents(filter: filter)
|
|
79
|
+
rescue ::Meilisearch::ApiError => e
|
|
80
|
+
raise unless e.http_code == 404
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reindex(scope = nil)
|
|
84
|
+
scope ||= store.products
|
|
85
|
+
ensure_index_settings!
|
|
86
|
+
|
|
87
|
+
scope.reorder(id: :asc)
|
|
88
|
+
.preload(*ProductPresenter::REQUIRED_PRELOADS)
|
|
89
|
+
.find_in_batches(batch_size: 500) do |batch|
|
|
90
|
+
documents = batch.flat_map { |product| ProductPresenter.new(product, store).call }
|
|
91
|
+
index_batch(documents)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Configure index settings for filtering, sorting, and faceting.
|
|
96
|
+
# Called automatically by reindex, but can be called separately.
|
|
97
|
+
def ensure_index_settings!
|
|
98
|
+
index = client.index(index_name)
|
|
99
|
+
index.update_filterable_attributes(filterable_attributes)
|
|
100
|
+
index.update_sortable_attributes(sortable_attributes)
|
|
101
|
+
index.update_searchable_attributes(searchable_attributes)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def client
|
|
107
|
+
@client ||= ::Meilisearch::Client.new(
|
|
108
|
+
ENV.fetch('MEILISEARCH_URL', 'http://localhost:7700'),
|
|
109
|
+
ENV['MEILISEARCH_API_KEY']
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def index_name
|
|
114
|
+
"#{store.code}_products"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def searchable_attributes
|
|
118
|
+
%w[name description sku option_values category_names tags]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def filterable_attributes
|
|
122
|
+
%w[product_id status in_stock store_ids locale currency discontinue_on price category_ids tags option_value_ids]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def sortable_attributes
|
|
126
|
+
%w[name price created_at available_on units_sold_count]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def facet_attributes
|
|
130
|
+
filterable_attributes
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def available_sort_options
|
|
134
|
+
%w[price -price name -name -available_on available_on best_selling]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Convert Ransack-style filters to Meilisearch filter syntax.
|
|
138
|
+
# Always applies store scoping, active status, and currency availability
|
|
139
|
+
# so that Meilisearch results match what the AR scope would return.
|
|
140
|
+
# All values are sanitized to prevent filter injection.
|
|
141
|
+
def build_filters(filters)
|
|
142
|
+
conditions = []
|
|
143
|
+
|
|
144
|
+
# Always scope to current store, locale, currency, active, not discontinued.
|
|
145
|
+
# This mirrors the AR scope: store.products.active(currency) with locale
|
|
146
|
+
conditions << "store_ids = '#{store.id}'"
|
|
147
|
+
conditions << "status = 'active'"
|
|
148
|
+
conditions << "locale = '#{locale.to_s.gsub(/[^a-zA-Z_-]/, '')}'"
|
|
149
|
+
conditions << "currency = '#{currency.to_s.gsub(/[^A-Z]/, '')}'"
|
|
150
|
+
conditions << "(discontinue_on = 0 OR discontinue_on > #{Time.current.to_i})"
|
|
151
|
+
|
|
152
|
+
filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
|
|
153
|
+
return conditions if filters.blank?
|
|
154
|
+
|
|
155
|
+
filters.each do |key, value|
|
|
156
|
+
next if value.blank?
|
|
157
|
+
|
|
158
|
+
key = key.to_s
|
|
159
|
+
case key
|
|
160
|
+
when 'price_gte'
|
|
161
|
+
conditions << "price >= #{value.to_f}"
|
|
162
|
+
when 'price_lte'
|
|
163
|
+
conditions << "price <= #{value.to_f}"
|
|
164
|
+
when 'in_stock'
|
|
165
|
+
conditions << 'in_stock = true' if value.to_s != '0'
|
|
166
|
+
when 'out_of_stock'
|
|
167
|
+
conditions << 'in_stock = false' if value.to_s != '0'
|
|
168
|
+
when 'categories_id_eq'
|
|
169
|
+
conditions << "category_ids = '#{sanitize_prefixed_id(value)}'" if valid_prefixed_id?(value)
|
|
170
|
+
when 'with_option_value_ids'
|
|
171
|
+
Array(value).each do |ov|
|
|
172
|
+
conditions << "option_value_ids = '#{sanitize_prefixed_id(ov)}'" if valid_prefixed_id?(ov)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
conditions
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def build_sort(sort)
|
|
181
|
+
return nil if sort.blank?
|
|
182
|
+
|
|
183
|
+
case sort
|
|
184
|
+
when 'price'
|
|
185
|
+
['price:asc']
|
|
186
|
+
when '-price'
|
|
187
|
+
['price:desc']
|
|
188
|
+
when 'name'
|
|
189
|
+
['name:asc']
|
|
190
|
+
when '-name'
|
|
191
|
+
['name:desc']
|
|
192
|
+
when '-available_on'
|
|
193
|
+
['available_on:desc']
|
|
194
|
+
when 'available_on'
|
|
195
|
+
['available_on:asc']
|
|
196
|
+
when 'best_selling'
|
|
197
|
+
['units_sold_count:desc']
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Transform Meilisearch facetDistribution into our standard filter response format
|
|
202
|
+
def build_facet_response(facet_distribution)
|
|
203
|
+
filters = []
|
|
204
|
+
|
|
205
|
+
# Price range
|
|
206
|
+
if facet_distribution['price'].present?
|
|
207
|
+
amounts = facet_distribution['price'].keys.map(&:to_f)
|
|
208
|
+
filters << {
|
|
209
|
+
id: 'price',
|
|
210
|
+
type: 'price_range',
|
|
211
|
+
min: amounts.min,
|
|
212
|
+
max: amounts.max,
|
|
213
|
+
currency: currency
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Availability
|
|
218
|
+
if facet_distribution['in_stock'].present?
|
|
219
|
+
in_stock = facet_distribution['in_stock']['true'] || 0
|
|
220
|
+
out_of_stock = facet_distribution['in_stock']['false'] || 0
|
|
221
|
+
filters << {
|
|
222
|
+
id: 'availability',
|
|
223
|
+
type: 'availability',
|
|
224
|
+
options: [
|
|
225
|
+
{ id: 'in_stock', count: in_stock },
|
|
226
|
+
{ id: 'out_of_stock', count: out_of_stock }
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Option values — group by option type for faceted display
|
|
232
|
+
if facet_distribution['option_value_ids'].present?
|
|
233
|
+
prefixed_ids = facet_distribution['option_value_ids'].keys
|
|
234
|
+
raw_ids = prefixed_ids.filter_map { |pid| Spree::OptionValue.decode_prefixed_id(pid) }
|
|
235
|
+
option_values = Spree::OptionValue.where(id: raw_ids).includes(:option_type).preload_associations_lazily.index_by(&:prefixed_id)
|
|
236
|
+
|
|
237
|
+
# Group by option type
|
|
238
|
+
by_option_type = {}
|
|
239
|
+
facet_distribution['option_value_ids'].each do |ov_prefixed_id, count|
|
|
240
|
+
ov = option_values[ov_prefixed_id]
|
|
241
|
+
next unless ov
|
|
242
|
+
|
|
243
|
+
ot = ov.option_type
|
|
244
|
+
by_option_type[ot] ||= []
|
|
245
|
+
by_option_type[ot] << { id: ov.prefixed_id, name: ov.name, label: ov.label, position: ov.position, count: count }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
by_option_type.each do |option_type, values|
|
|
249
|
+
filters << {
|
|
250
|
+
id: option_type.prefixed_id,
|
|
251
|
+
type: 'option',
|
|
252
|
+
name: option_type.name,
|
|
253
|
+
label: option_type.label,
|
|
254
|
+
options: values.sort_by { |o| o[:position] }
|
|
255
|
+
}
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Categories
|
|
260
|
+
if facet_distribution['category_ids'].present?
|
|
261
|
+
prefixed_ids = facet_distribution['category_ids'].keys
|
|
262
|
+
raw_ids = prefixed_ids.filter_map { |pid| Spree::Taxon.decode_prefixed_id(pid) }
|
|
263
|
+
categories = Spree::Taxon.where(id: raw_ids).index_by(&:prefixed_id)
|
|
264
|
+
|
|
265
|
+
filters << {
|
|
266
|
+
id: 'categories',
|
|
267
|
+
type: 'category',
|
|
268
|
+
options: facet_distribution['category_ids'].filter_map do |prefixed_id, count|
|
|
269
|
+
cat = categories[prefixed_id]
|
|
270
|
+
next unless cat
|
|
271
|
+
|
|
272
|
+
{ id: cat.prefixed_id, name: cat.name, permalink: cat.permalink, count: count }
|
|
273
|
+
end
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
filters
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def build_pagy(ms_result, page, limit)
|
|
281
|
+
require 'pagy'
|
|
282
|
+
require 'pagy/toolbox/paginators/meilisearch'
|
|
283
|
+
|
|
284
|
+
fake_result = Struct.new(:raw_answer).new({
|
|
285
|
+
'totalHits' => ms_result['estimatedTotalHits'] || 0,
|
|
286
|
+
'hitsPerPage' => limit,
|
|
287
|
+
'page' => page
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
Pagy::MeilisearchPaginator.paginate(fake_result, {})
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def valid_prefixed_id?(value)
|
|
294
|
+
value.to_s.match?(PREFIXED_ID_PATTERN)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def sanitize_prefixed_id(value)
|
|
298
|
+
value.to_s.gsub(/[^a-zA-Z0-9_]/, '')
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
data/app/models/spree/taxon.rb
CHANGED
|
@@ -103,13 +103,8 @@ module Spree
|
|
|
103
103
|
#
|
|
104
104
|
# Search
|
|
105
105
|
#
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
pg_search_scope :search_by_name, against: :name, using: { tsearch: { any_word: true, prefix: true } }
|
|
109
|
-
else
|
|
110
|
-
def self.search_by_name(query)
|
|
111
|
-
i18n { name.lower.matches("%#{query.downcase}%") }
|
|
112
|
-
end
|
|
106
|
+
def self.search_by_name(query)
|
|
107
|
+
i18n { name.lower.matches("%#{query.downcase}%") }
|
|
113
108
|
end
|
|
114
109
|
|
|
115
110
|
scope :with_matching_name, lambda { |name_to_match|
|
data/app/models/spree/variant.rb
CHANGED
|
@@ -129,33 +129,14 @@ module Spree
|
|
|
129
129
|
|
|
130
130
|
scope :with_digital_assets, -> { joins(:digitals) }
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
scope :search, ->(query) {
|
|
133
|
+
next none if query.blank? || query.length < 3
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
pg_search_scope :search_by_sku_or_options,
|
|
138
|
-
against: :sku,
|
|
139
|
-
using: { tsearch: { prefix: true } },
|
|
140
|
-
associated_against: { option_values: %i[presentation] }
|
|
141
|
-
|
|
142
|
-
pg_search_scope :search_by_name_sku_or_options, against: :sku, associated_against: {
|
|
143
|
-
product: %i[name],
|
|
144
|
-
option_values: %i[presentation]
|
|
145
|
-
}, using: { tsearch: { prefix: true } }
|
|
146
|
-
|
|
147
|
-
scope :multi_search, lambda { |query|
|
|
148
|
-
return none if query.blank? || query.length < 3
|
|
149
|
-
|
|
150
|
-
search_by_name_sku_or_options(query)
|
|
151
|
-
}
|
|
152
|
-
else
|
|
153
|
-
scope :multi_search, lambda { |query|
|
|
154
|
-
return none if query.blank? || query.length < 3
|
|
135
|
+
product_name_or_sku_cont(query)
|
|
136
|
+
}
|
|
155
137
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
end
|
|
138
|
+
# Backward compatibility alias — remove in Spree 6.0
|
|
139
|
+
scope :multi_search, ->(*args) { search(*args) }
|
|
159
140
|
|
|
160
141
|
# FIXME: cost price should be represented with DisplayMoney class
|
|
161
142
|
LOCALIZED_NUMBERS = %w(cost_price weight depth width height)
|
|
@@ -16,6 +16,7 @@ module Spree
|
|
|
16
16
|
belongs_to :store, class_name: 'Spree::Store'
|
|
17
17
|
|
|
18
18
|
has_many :wished_items, class_name: 'Spree::WishedItem', dependent: :destroy
|
|
19
|
+
alias wishlist_items wished_items
|
|
19
20
|
has_many :variants, through: :wished_items, source: :variant, class_name: 'Spree::Variant'
|
|
20
21
|
has_many :products, -> { distinct }, through: :variants, source: :product, class_name: 'Spree::Product'
|
|
21
22
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module SearchProvider
|
|
3
|
+
class ProductPresenter
|
|
4
|
+
# Associations needed by this presenter — used by reindex and rake task for preloading
|
|
5
|
+
REQUIRED_PRELOADS = [
|
|
6
|
+
:taxons, :option_types, :primary_media, :store_products,
|
|
7
|
+
{ variants_including_master: [:prices, :option_values] }
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :product, :store
|
|
11
|
+
|
|
12
|
+
def initialize(product, store)
|
|
13
|
+
@product = product
|
|
14
|
+
@store = store
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns an array of documents — one per market × locale combination.
|
|
18
|
+
# Each document has flat name, description, price fields (no dynamic suffixes).
|
|
19
|
+
def call
|
|
20
|
+
documents = []
|
|
21
|
+
|
|
22
|
+
market_locale_pairs.each do |market, locale|
|
|
23
|
+
# Skip if product has no price in this currency
|
|
24
|
+
next unless lowest_price(market.currency)
|
|
25
|
+
|
|
26
|
+
Mobility.with_locale(locale) do
|
|
27
|
+
documents << build_document(locale, market.currency, default_locale)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Fallback for stores without markets (legacy/test)
|
|
32
|
+
if documents.empty?
|
|
33
|
+
fallback_currency = store.default_market&.currency || store.supported_currencies_list.first&.iso_code
|
|
34
|
+
if fallback_currency && lowest_price(fallback_currency)
|
|
35
|
+
documents << build_document(default_locale, fallback_currency, default_locale)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
documents
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def build_document(locale, currency, fallback_locale)
|
|
45
|
+
{
|
|
46
|
+
# Composite ID: product + locale + currency
|
|
47
|
+
prefixed_id: "#{product.prefixed_id}_#{locale}_#{currency}",
|
|
48
|
+
product_id: product.prefixed_id,
|
|
49
|
+
locale: locale.to_s,
|
|
50
|
+
currency: currency,
|
|
51
|
+
# Translated fields — with fallback to default locale
|
|
52
|
+
name: translated(product, :name, fallback_locale),
|
|
53
|
+
description: translated(product, :description, fallback_locale),
|
|
54
|
+
slug: translated(product, :slug, fallback_locale),
|
|
55
|
+
# Price in this currency
|
|
56
|
+
price: lowest_price(currency)&.to_f,
|
|
57
|
+
compare_at_price: compare_at_price(currency)&.to_f,
|
|
58
|
+
# Non-locale/currency fields
|
|
59
|
+
status: product.status,
|
|
60
|
+
sku: product.sku,
|
|
61
|
+
in_stock: product.in_stock?,
|
|
62
|
+
store_ids: cached_store_ids,
|
|
63
|
+
discontinue_on: product.discontinue_on&.to_i || 0,
|
|
64
|
+
category_ids: product.taxons.map(&:prefixed_id),
|
|
65
|
+
category_names: product.taxons.map { |t| translated(t, :name, fallback_locale) },
|
|
66
|
+
option_type_ids: product.option_types.map(&:prefixed_id),
|
|
67
|
+
option_type_names: product.option_types.map { |ot| translated(ot, :presentation, fallback_locale) },
|
|
68
|
+
option_value_ids: variant_option_value_ids,
|
|
69
|
+
option_values: variant_option_values_data.map { |ov| translated(ov, :presentation, fallback_locale) }.uniq,
|
|
70
|
+
tags: product.tag_list || [],
|
|
71
|
+
thumbnail_url: product.primary_media&.url(:large),
|
|
72
|
+
units_sold_count: cached_units_sold_count,
|
|
73
|
+
available_on: product.available_on&.iso8601,
|
|
74
|
+
created_at: product.created_at&.iso8601,
|
|
75
|
+
updated_at: product.updated_at&.iso8601
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns all market × locale pairs for this store
|
|
80
|
+
def market_locale_pairs
|
|
81
|
+
@market_locale_pairs ||= store.markets.flat_map do |market|
|
|
82
|
+
market.supported_locales_list.map { |locale| [market, locale] }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def default_locale
|
|
87
|
+
@default_locale ||= store.default_market&.default_locale || I18n.default_locale.to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Read a translated attribute with fallback to default locale.
|
|
91
|
+
def translated(record, attribute, fallback_locale)
|
|
92
|
+
value = record.send(attribute)
|
|
93
|
+
return value if value.present?
|
|
94
|
+
|
|
95
|
+
record.send(attribute, locale: fallback_locale.to_sym)
|
|
96
|
+
rescue ArgumentError
|
|
97
|
+
value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def lowest_price(currency)
|
|
101
|
+
@prices_cache ||= {}
|
|
102
|
+
@prices_cache[currency] = product.price_in(currency)&.amount unless @prices_cache.key?(currency)
|
|
103
|
+
@prices_cache[currency]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def compare_at_price(currency)
|
|
107
|
+
@compare_at_cache ||= {}
|
|
108
|
+
@compare_at_cache[currency] = product.compare_at_amount_in(currency) unless @compare_at_cache.key?(currency)
|
|
109
|
+
@compare_at_cache[currency]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Memoized — avoids N+1 when called per document
|
|
113
|
+
def cached_store_ids
|
|
114
|
+
@cached_store_ids ||= product.store_ids.map(&:to_s)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def cached_units_sold_count
|
|
118
|
+
@cached_units_sold_count ||= product.store_products.detect { |sp| sp.store_id == store.id }&.units_sold_count || 0
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def variant_option_value_ids
|
|
122
|
+
variant_option_values_data.map(&:prefixed_id).uniq
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Use variants_including_master (matches reindex preload) instead of variants.includes
|
|
126
|
+
def variant_option_values_data
|
|
127
|
+
@variant_option_values_data ||= product.variants_including_master.flat_map(&:option_values).uniq
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -9,8 +9,8 @@ module Spree
|
|
|
9
9
|
|
|
10
10
|
ApplicationRecord.transaction do
|
|
11
11
|
assign_cart_attributes
|
|
12
|
-
assign_address(:
|
|
13
|
-
assign_address(:
|
|
12
|
+
assign_address(:shipping_address)
|
|
13
|
+
assign_address(:billing_address)
|
|
14
14
|
|
|
15
15
|
cart.save!
|
|
16
16
|
|
|
@@ -34,10 +34,11 @@ module Spree
|
|
|
34
34
|
|
|
35
35
|
def assign_cart_attributes
|
|
36
36
|
cart.email = params[:email] if params[:email].present?
|
|
37
|
-
cart.
|
|
37
|
+
cart.customer_note = params[:customer_note] if params.key?(:customer_note)
|
|
38
38
|
cart.currency = params[:currency].upcase if params[:currency].present?
|
|
39
39
|
cart.locale = params[:locale] if params[:locale].present?
|
|
40
40
|
cart.metadata = cart.metadata.merge(params[:metadata].to_h) if params[:metadata].present?
|
|
41
|
+
cart.use_shipping = params[:use_shipping] if params.key?(:use_shipping)
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
def assign_address(address_type)
|
|
@@ -59,7 +60,7 @@ module Spree
|
|
|
59
60
|
# Only revert to address state when shipping address changes.
|
|
60
61
|
# Billing address updates (e.g. during payment) should not
|
|
61
62
|
# destroy shipments and reset the checkout flow.
|
|
62
|
-
revert_to_address_state if address_type == :
|
|
63
|
+
revert_to_address_state if address_type == :shipping_address && cart.has_checkout_step?('address')
|
|
63
64
|
cart.public_send(:"#{address_type}_attributes=", address_params)
|
|
64
65
|
end
|
|
65
66
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateSpreeRefreshTokens < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
create_table :spree_refresh_tokens do |t|
|
|
4
|
+
t.string :token, null: false
|
|
5
|
+
t.references :user, polymorphic: true, null: false
|
|
6
|
+
t.datetime :expires_at, null: false
|
|
7
|
+
t.string :ip_address
|
|
8
|
+
t.string :user_agent
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :spree_refresh_tokens, :token, unique: true
|
|
13
|
+
add_index :spree_refresh_tokens, :expires_at
|
|
14
|
+
add_index :spree_refresh_tokens, [:user_type, :user_id], name: 'idx_refresh_tokens_user'
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -4,6 +4,7 @@ cc_payment_method = Spree::Gateway::Bogus.where(
|
|
|
4
4
|
active: true
|
|
5
5
|
).first_or_initialize
|
|
6
6
|
|
|
7
|
+
cc_payment_method.display_on = 'back_end'
|
|
7
8
|
cc_payment_method.stores = Spree::Store.all
|
|
8
9
|
cc_payment_method.save!
|
|
9
10
|
|
|
@@ -13,5 +14,6 @@ check_payment_method = Spree::PaymentMethod::Check.where(
|
|
|
13
14
|
active: true
|
|
14
15
|
).first_or_initialize
|
|
15
16
|
|
|
17
|
+
check_payment_method.display_on = 'back_end'
|
|
16
18
|
check_payment_method.stores = Spree::Store.all
|
|
17
19
|
check_payment_method.save!
|
data/lib/spree/core/version.rb
CHANGED
data/lib/spree/core.rb
CHANGED
|
@@ -110,13 +110,14 @@ module Spree
|
|
|
110
110
|
gift_cards: :default,
|
|
111
111
|
webhooks: :default,
|
|
112
112
|
payment_webhooks: :default,
|
|
113
|
-
api_keys: :default
|
|
113
|
+
api_keys: :default,
|
|
114
|
+
search: :default
|
|
114
115
|
)
|
|
115
116
|
end
|
|
116
117
|
|
|
117
|
-
# @deprecated Spree.searcher_class is deprecated and will be removed in Spree 5.5.
|
|
118
|
+
# @deprecated Spree.searcher_class is deprecated and will be removed in Spree 5.5. Use Spree.search_provider instead.
|
|
118
119
|
def self.searcher_class=(value)
|
|
119
|
-
Spree::Deprecation.warn('Spree.searcher_class is deprecated and will be removed in Spree 5.5.
|
|
120
|
+
Spree::Deprecation.warn('Spree.searcher_class is deprecated and will be removed in Spree 5.5. Use Spree.search_provider instead.')
|
|
120
121
|
@@searcher_class = value
|
|
121
122
|
end
|
|
122
123
|
|
|
@@ -124,6 +125,18 @@ module Spree
|
|
|
124
125
|
@@searcher_class
|
|
125
126
|
end
|
|
126
127
|
|
|
128
|
+
# Search provider class name. Controls product search, filtering, and faceted navigation.
|
|
129
|
+
#
|
|
130
|
+
# Spree.search_provider = 'Spree::SearchProvider::Meilisearch'
|
|
131
|
+
#
|
|
132
|
+
def self.search_provider
|
|
133
|
+
@@search_provider ||= 'Spree::SearchProvider::Database'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def self.search_provider=(value)
|
|
137
|
+
@@search_provider = value.to_s
|
|
138
|
+
end
|
|
139
|
+
|
|
127
140
|
# Returns the events adapter class used for publishing and subscribing to events.
|
|
128
141
|
#
|
|
129
142
|
# @example Using a custom adapter
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
namespace :spree do
|
|
2
|
+
namespace :search do
|
|
3
|
+
desc 'Reindex all products in the search provider'
|
|
4
|
+
task reindex: :environment do
|
|
5
|
+
Spree::Store.all.find_each do |store|
|
|
6
|
+
provider = Spree.search_provider.constantize.new(store)
|
|
7
|
+
total = store.products.count
|
|
8
|
+
|
|
9
|
+
puts "Reindexing #{store.name} (#{total} products)..."
|
|
10
|
+
provider.reindex(store.products)
|
|
11
|
+
puts "Done."
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|