spree_core 5.4.0 → 5.4.2
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/jobs/spree/imports/process_group_job.rb +45 -0
- data/app/jobs/spree/imports/process_rows_job.rb +51 -5
- data/app/models/concerns/spree/payment_source_concern.rb +21 -0
- data/app/models/spree/address.rb +1 -1
- data/app/models/spree/gateway/bogus.rb +10 -6
- data/app/models/spree/import.rb +15 -0
- data/app/models/spree/import_row.rb +14 -2
- data/app/models/spree/imports/product_translations.rb +4 -0
- data/app/models/spree/imports/products.rb +5 -0
- data/app/models/spree/order.rb +0 -6
- data/app/models/spree/payment.rb +1 -0
- data/app/models/spree/payment_session.rb +5 -0
- data/app/models/spree/product.rb +5 -10
- data/app/models/spree/promotion.rb +2 -2
- data/app/models/spree/search_provider/base.rb +13 -1
- data/app/models/spree/search_provider/database.rb +44 -29
- data/app/models/spree/search_provider/filters_result.rb +5 -0
- data/app/models/spree/search_provider/meilisearch.rb +99 -82
- data/app/models/spree/search_provider/search_result.rb +1 -1
- data/app/models/spree/shipment.rb +5 -1
- data/app/models/spree/shipping_rate.rb +26 -1
- data/app/models/spree/taxon.rb +1 -2
- data/app/models/spree/variant.rb +6 -3
- data/app/services/spree/credit_cards/destroy.rb +10 -26
- data/app/services/spree/gift_cards/apply.rb +5 -1
- data/app/services/spree/imports/row_processors/base.rb +19 -2
- data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
- data/app/services/spree/imports/row_processors/product_variant.rb +2 -2
- data/app/services/spree/payments/handle_webhook.rb +6 -0
- data/app/services/spree/seeds/zones.rb +8 -2
- data/app/subscribers/spree/event_log_subscriber.rb +9 -7
- data/config/initializers/carmen.rb +23 -0
- data/config/locales/en.yml +0 -1
- data/db/migrate/20260424000001_add_unique_index_to_spree_payments_response_code.rb +51 -0
- data/db/migrate/20260424100000_add_processing_groups_to_spree_imports.rb +6 -0
- data/db/sample_data/markets.rb +34 -0
- data/db/sample_data/orders.rb +1 -1
- data/db/sample_data/shipping_methods.rb +25 -3
- data/lib/generators/spree/dummy/dummy_generator.rb +1 -1
- data/lib/spree/core/configuration.rb +3 -0
- data/lib/spree/core/engine.rb +3 -6
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/events.rb +6 -0
- data/lib/spree/permitted_attributes.rb +3 -3
- data/lib/spree/testing_support/factories/payment_factory.rb +1 -1
- data/lib/tasks/markets.rake +2 -5
- metadata +23 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 23692bda70e0c85ecff286d989063498e74adc2e2422ae21719da5c6b6e97c98
|
|
4
|
+
data.tar.gz: 132486aca06d2912f82a54593d57eb9c400768b1a45b5412af77b61f2c822ac5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0dc0c862bb8c7313e53ac13b80fe2ce6dd697bfad36ed0841562220b12c5253e473ac5f2623e95ac038da6d8903b3a329a47a4dee813f47a37e498bde6a66bc4
|
|
7
|
+
data.tar.gz: facf65e2c8067fbd2cbba0c04ff49daadf53901b137a2a80ada8ae95e646f0ba2ae3df1a7def6651fc81c9aaf52d8d2268e9c86e42c982f4074b3560c7595d91
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Imports
|
|
3
|
+
class ProcessGroupJob < Spree::BaseJob
|
|
4
|
+
queue_as Spree.queues.imports
|
|
5
|
+
|
|
6
|
+
def perform(import_id, row_ids)
|
|
7
|
+
import = Spree::Import.find(import_id)
|
|
8
|
+
Spree::Current.store = import.store
|
|
9
|
+
|
|
10
|
+
mappings = import.mappings.mapped.to_a
|
|
11
|
+
schema_fields = import.schema_fields
|
|
12
|
+
large = import.large_import?
|
|
13
|
+
rows = import.rows.where(id: row_ids).order(:row_number)
|
|
14
|
+
|
|
15
|
+
if large
|
|
16
|
+
Spree::Events.disable do
|
|
17
|
+
rows.each { |row| row.bulk_process!(mappings: mappings, schema_fields: schema_fields) }
|
|
18
|
+
end
|
|
19
|
+
else
|
|
20
|
+
rows.each do |row|
|
|
21
|
+
row.process!(mappings: mappings, schema_fields: schema_fields)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
check_import_completion(import, large)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def check_import_completion(import, large)
|
|
31
|
+
Spree::Import.where(id: import.id).update_all(
|
|
32
|
+
'completed_groups_count = COALESCE(completed_groups_count, 0) + 1'
|
|
33
|
+
)
|
|
34
|
+
import.reload
|
|
35
|
+
|
|
36
|
+
if import.completed_groups_count >= import.processing_groups_count
|
|
37
|
+
# Guard against concurrent workers both reaching this point
|
|
38
|
+
import.complete! if import.status == 'processing'
|
|
39
|
+
elsif large && (import.completed_groups_count % 10).zero?
|
|
40
|
+
import.publish_event('import.progress')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -3,16 +3,62 @@ module Spree
|
|
|
3
3
|
class ProcessRowsJob < Spree::BaseJob
|
|
4
4
|
queue_as Spree.queues.imports
|
|
5
5
|
|
|
6
|
+
BATCH_SIZE = 100
|
|
7
|
+
|
|
6
8
|
def perform(import_id)
|
|
7
9
|
import = Spree::Import.find(import_id)
|
|
10
|
+
dispatch_groups(import)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def dispatch_groups(import)
|
|
16
|
+
group_field = import.group_column
|
|
17
|
+
group_mapping = group_field && import.mappings.mapped.find_by(schema_field: group_field)
|
|
18
|
+
file_column = group_mapping&.file_column
|
|
8
19
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
20
|
+
if file_column
|
|
21
|
+
dispatch_grouped(import, file_column)
|
|
22
|
+
else
|
|
23
|
+
dispatch_batched(import)
|
|
12
24
|
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dispatch_grouped(import, file_column)
|
|
28
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
29
|
+
|
|
30
|
+
import.rows.pending_and_failed.order(:row_number).pluck(:id, :data).each do |id, data|
|
|
31
|
+
parsed = JSON.parse(data)
|
|
32
|
+
key = parsed[file_column].to_s.strip.downcase.presence || '__ungrouped__'
|
|
33
|
+
groups[key] << id
|
|
34
|
+
rescue JSON::ParserError
|
|
35
|
+
groups['__ungrouped__'] << id
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Set count before enqueuing so workers can't complete prematurely
|
|
39
|
+
import.update_columns(
|
|
40
|
+
processing_groups_count: groups.size,
|
|
41
|
+
completed_groups_count: 0,
|
|
42
|
+
updated_at: Time.current
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
groups.each_value { |row_ids| ProcessGroupJob.perform_later(import.id, row_ids) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def dispatch_batched(import)
|
|
49
|
+
# Count first, then enqueue — prevents premature completion
|
|
50
|
+
row_id_batches = import.rows.pending_and_failed.order(:row_number)
|
|
51
|
+
.pluck(:id)
|
|
52
|
+
.each_slice(BATCH_SIZE)
|
|
53
|
+
.to_a
|
|
54
|
+
|
|
55
|
+
import.update_columns(
|
|
56
|
+
processing_groups_count: row_id_batches.size,
|
|
57
|
+
completed_groups_count: 0,
|
|
58
|
+
updated_at: Time.current
|
|
59
|
+
)
|
|
13
60
|
|
|
14
|
-
|
|
15
|
-
import.complete!
|
|
61
|
+
row_id_batches.each { |row_ids| ProcessGroupJob.perform_later(import.id, row_ids) }
|
|
16
62
|
end
|
|
17
63
|
end
|
|
18
64
|
end
|
|
@@ -2,6 +2,10 @@ module Spree
|
|
|
2
2
|
module PaymentSourceConcern
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
|
+
included do
|
|
6
|
+
before_destroy :cleanup_payments_on_incomplete_orders
|
|
7
|
+
end
|
|
8
|
+
|
|
5
9
|
# Available actions for the payment source.
|
|
6
10
|
# @return [Array<String>]
|
|
7
11
|
def actions
|
|
@@ -35,5 +39,22 @@ module Spree
|
|
|
35
39
|
def has_payment_profile?
|
|
36
40
|
gateway_customer_profile_id.present? || gateway_payment_profile_id.present?
|
|
37
41
|
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Cleans up payments on incomplete orders before the source is destroyed.
|
|
46
|
+
# Invalidates checkout-state payments and voids non-checkout payments.
|
|
47
|
+
# Skips payments whose payment_method was already nullified (e.g. when
|
|
48
|
+
# the payment method itself is being destroyed).
|
|
49
|
+
def cleanup_payments_on_incomplete_orders
|
|
50
|
+
incomplete_payments = Spree::Payment.valid
|
|
51
|
+
.where(source: self)
|
|
52
|
+
.where.not(payment_method_id: nil)
|
|
53
|
+
.joins(:order)
|
|
54
|
+
.merge(Spree::Order.incomplete)
|
|
55
|
+
|
|
56
|
+
incomplete_payments.checkout.each(&:invalidate!)
|
|
57
|
+
incomplete_payments.where.not(state: :checkout).each(&:void!)
|
|
58
|
+
end
|
|
38
59
|
end
|
|
39
60
|
end
|
data/app/models/spree/address.rb
CHANGED
|
@@ -79,7 +79,7 @@ module Spree
|
|
|
79
79
|
end
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
delegate :name, :iso3, :iso, :iso_name, to: :country, prefix: true
|
|
82
|
+
delegate :name, :iso3, :iso, :iso_name, to: :country, prefix: true, allow_nil: true
|
|
83
83
|
delegate :abbr, to: :state, prefix: true, allow_nil: true
|
|
84
84
|
|
|
85
85
|
alias_attribute :postal_code, :zipcode
|
|
@@ -32,7 +32,7 @@ module Spree
|
|
|
32
32
|
def authorize(_money, credit_card, _options = {})
|
|
33
33
|
profile_id = credit_card.gateway_customer_profile_id
|
|
34
34
|
if VALID_CCS.include?(credit_card.number) || (profile_id&.starts_with?('BGS-'))
|
|
35
|
-
Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization:
|
|
35
|
+
Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization, avs_result: { code: 'D' })
|
|
36
36
|
else
|
|
37
37
|
Spree::PaymentResponse.new(false, 'Bogus Gateway: Forced failure', { message: 'Bogus Gateway: Forced failure' }, test: true)
|
|
38
38
|
end
|
|
@@ -41,18 +41,18 @@ module Spree
|
|
|
41
41
|
def purchase(_money, credit_card, _options = {})
|
|
42
42
|
profile_id = credit_card.gateway_customer_profile_id
|
|
43
43
|
if VALID_CCS.include?(credit_card.number) || (profile_id&.starts_with?('BGS-'))
|
|
44
|
-
Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization:
|
|
44
|
+
Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization, avs_result: { code: 'M' })
|
|
45
45
|
else
|
|
46
46
|
Spree::PaymentResponse.new(false, 'Bogus Gateway: Forced failure', message: 'Bogus Gateway: Forced failure', test: true)
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def credit(_money, _credit_card, _response_code, _options = {})
|
|
51
|
-
Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization:
|
|
51
|
+
Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def capture(_money, authorization, _gateway_options)
|
|
55
|
-
if authorization
|
|
55
|
+
if authorization&.start_with?('BGS-')
|
|
56
56
|
Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true)
|
|
57
57
|
else
|
|
58
58
|
Spree::PaymentResponse.new(false, 'Bogus Gateway: Forced failure', error: 'Bogus Gateway: Forced failure', test: true)
|
|
@@ -60,11 +60,11 @@ module Spree
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def void(_response_code, _credit_card, _options = {})
|
|
63
|
-
Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization:
|
|
63
|
+
Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: "void-#{generate_authorization}")
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def cancel(_response_code, _payment = nil)
|
|
67
|
-
Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization:
|
|
67
|
+
Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
def test?
|
|
@@ -150,6 +150,10 @@ module Spree
|
|
|
150
150
|
|
|
151
151
|
private
|
|
152
152
|
|
|
153
|
+
def generate_authorization
|
|
154
|
+
"BGS-#{SecureRandom.hex(6)}"
|
|
155
|
+
end
|
|
156
|
+
|
|
153
157
|
def generate_profile_id(success)
|
|
154
158
|
record = true
|
|
155
159
|
prefix = success ? 'BGS' : 'FAIL'
|
data/app/models/spree/import.rb
CHANGED
|
@@ -76,6 +76,21 @@ module Spree
|
|
|
76
76
|
#
|
|
77
77
|
preference :delimiter, :string, default: ','
|
|
78
78
|
|
|
79
|
+
# Returns true if the import has more rows than the large import threshold.
|
|
80
|
+
# Large imports skip per-row UI broadcasts and use bulk processing.
|
|
81
|
+
# @return [Boolean]
|
|
82
|
+
def large_import?
|
|
83
|
+
rows_count >= Spree::Config[:large_import_threshold]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns the schema field name used to group rows for parallel processing.
|
|
87
|
+
# Rows sharing the same value in this field are processed together in one job.
|
|
88
|
+
# Returns nil for imports where rows are independent (default — batched in chunks).
|
|
89
|
+
# @return [String, nil]
|
|
90
|
+
def group_column
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
79
94
|
# Returns true if the import is in mapping state
|
|
80
95
|
# @return [Boolean]
|
|
81
96
|
def mapping?
|
|
@@ -77,9 +77,9 @@ module Spree
|
|
|
77
77
|
data_json[mapping.file_column]
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
def process!
|
|
80
|
+
def process!(mappings: nil, schema_fields: nil)
|
|
81
81
|
start_processing!
|
|
82
|
-
self.item = import.row_processor_class.new(self).process!
|
|
82
|
+
self.item = import.row_processor_class.new(self, mappings: mappings, schema_fields: schema_fields).process!
|
|
83
83
|
complete!
|
|
84
84
|
rescue StandardError => e
|
|
85
85
|
Rails.error.report(e, handled: true, context: { import_row_id: id }, source: 'spree.core')
|
|
@@ -87,6 +87,18 @@ module Spree
|
|
|
87
87
|
fail!
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# Bulk processing mode for large imports.
|
|
91
|
+
# Uses update_columns to skip callbacks, validations, and event publishing.
|
|
92
|
+
def bulk_process!(mappings:, schema_fields:)
|
|
93
|
+
update_columns(status: 'processing', updated_at: Time.current)
|
|
94
|
+
processor = import.row_processor_class.new(self, mappings: mappings, schema_fields: schema_fields)
|
|
95
|
+
self.item = processor.process!
|
|
96
|
+
update_columns(status: 'completed', item_type: item.class.name, item_id: item.id, updated_at: Time.current)
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
Rails.error.report(e, handled: true, context: { import_row_id: id }, source: 'spree.core')
|
|
99
|
+
update_columns(status: 'failed', validation_errors: e.message, updated_at: Time.current)
|
|
100
|
+
end
|
|
101
|
+
|
|
90
102
|
def publish_import_row_completed_event
|
|
91
103
|
publish_event('import_row.completed')
|
|
92
104
|
end
|
data/app/models/spree/order.rb
CHANGED
|
@@ -102,7 +102,6 @@ module Spree
|
|
|
102
102
|
go_to_state :payment, if: ->(order) { order.payment? || order.payment_required? }
|
|
103
103
|
go_to_state :confirm, if: ->(order) { order.confirmation_required? }
|
|
104
104
|
go_to_state :complete
|
|
105
|
-
remove_transition from: :delivery, to: :confirm, unless: ->(order) { order.confirmation_required? }
|
|
106
105
|
end
|
|
107
106
|
|
|
108
107
|
self.whitelisted_ransackable_associations = %w[shipments user created_by approver canceler promotions bill_address ship_address line_items store]
|
|
@@ -1013,7 +1012,6 @@ module Spree
|
|
|
1013
1012
|
|
|
1014
1013
|
update_with_updater!
|
|
1015
1014
|
send_order_canceled_webhook
|
|
1016
|
-
publish_order_canceled_event
|
|
1017
1015
|
end
|
|
1018
1016
|
|
|
1019
1017
|
def after_resume
|
|
@@ -1094,10 +1092,6 @@ module Spree
|
|
|
1094
1092
|
publish_event('order.completed')
|
|
1095
1093
|
end
|
|
1096
1094
|
|
|
1097
|
-
def publish_order_canceled_event
|
|
1098
|
-
publish_event('order.canceled')
|
|
1099
|
-
end
|
|
1100
|
-
|
|
1101
1095
|
def publish_order_resumed_event
|
|
1102
1096
|
publish_event('order.resumed')
|
|
1103
1097
|
end
|
data/app/models/spree/payment.rb
CHANGED
|
@@ -42,6 +42,7 @@ module Spree
|
|
|
42
42
|
|
|
43
43
|
validates :payment_method, presence: true
|
|
44
44
|
validates :source, presence: true, if: :source_required?
|
|
45
|
+
validates :response_code, uniqueness: { scope: [:order_id, :payment_method_id] }, allow_nil: true
|
|
45
46
|
validate :payment_method_available_for_order, on: :create
|
|
46
47
|
|
|
47
48
|
before_validation :validate_source
|
data/app/models/spree/product.rb
CHANGED
|
@@ -249,16 +249,11 @@ module Spree
|
|
|
249
249
|
|
|
250
250
|
products_to_auto_match_ids = store.products.not_deleted.not_archived.where(id: product_ids).ids
|
|
251
251
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
auto_match_taxons_jobs = products_to_auto_match_ids.map do |product_id|
|
|
255
|
-
Spree::Products::AutoMatchTaxonsJob.new(product_id)
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
ActiveJob.perform_all_later(auto_match_taxons_jobs)
|
|
259
|
-
else
|
|
260
|
-
products_to_auto_match_ids.each { |product_id| Spree::Products::AutoMatchTaxonsJob.perform_later(product_id) }
|
|
252
|
+
auto_match_taxons_jobs = products_to_auto_match_ids.map do |product_id|
|
|
253
|
+
Spree::Products::AutoMatchTaxonsJob.new(product_id).tap { |job| job.scheduled_at = 30.seconds.from_now }
|
|
261
254
|
end
|
|
255
|
+
|
|
256
|
+
ActiveJob.perform_all_later(auto_match_taxons_jobs)
|
|
262
257
|
end
|
|
263
258
|
|
|
264
259
|
# Can't use short form block syntax due to https://github.com/Netflix/fast_jsonapi/issues/259
|
|
@@ -582,7 +577,7 @@ module Spree
|
|
|
582
577
|
store = stores.find_by(default: true) || stores.first
|
|
583
578
|
return if store.nil? || store.taxons.automatic.none?
|
|
584
579
|
|
|
585
|
-
Spree::Products::AutoMatchTaxonsJob.perform_later(id)
|
|
580
|
+
Spree::Products::AutoMatchTaxonsJob.set(wait: 30.seconds).perform_later(id)
|
|
586
581
|
end
|
|
587
582
|
|
|
588
583
|
def to_csv(store = nil)
|
|
@@ -232,7 +232,7 @@ module Spree
|
|
|
232
232
|
|
|
233
233
|
def adjusted_credits_count(promotable)
|
|
234
234
|
adjustments = promotable.is_a?(Order) ? promotable.all_adjustments : promotable.adjustments
|
|
235
|
-
credits_count - adjustments.promotion.where(source_id: actions.pluck(:id)).
|
|
235
|
+
credits_count - adjustments.eligible.promotion.where(source_id: actions.pluck(:id)).select(:order_id).distinct.count
|
|
236
236
|
end
|
|
237
237
|
|
|
238
238
|
def credits
|
|
@@ -240,7 +240,7 @@ module Spree
|
|
|
240
240
|
end
|
|
241
241
|
|
|
242
242
|
def credits_count
|
|
243
|
-
credits.count
|
|
243
|
+
credits.select(:order_id).distinct.count
|
|
244
244
|
end
|
|
245
245
|
|
|
246
246
|
def line_item_actionable?(order, line_item)
|
|
@@ -13,7 +13,7 @@ module Spree
|
|
|
13
13
|
@store = store
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
# Search
|
|
16
|
+
# Search and paginate products. Does NOT compute filter facets — use #filters for that.
|
|
17
17
|
#
|
|
18
18
|
# @param scope [ActiveRecord::Relation] base scope (store-scoped, visibility-filtered, authorized)
|
|
19
19
|
# @param query [String, nil] text search query
|
|
@@ -26,6 +26,18 @@ module Spree
|
|
|
26
26
|
raise NotImplementedError
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# Compute filter facets, sort options, and total count for the given scope.
|
|
30
|
+
# Called by the dedicated filters endpoint — kept separate from search_and_filter
|
|
31
|
+
# to avoid expensive facet queries on every product listing.
|
|
32
|
+
#
|
|
33
|
+
# @param scope [ActiveRecord::Relation] base scope
|
|
34
|
+
# @param query [String, nil] text search query
|
|
35
|
+
# @param filters [Hash] structured filters
|
|
36
|
+
# @return [FiltersResult]
|
|
37
|
+
def filters(scope:, query: nil, filters: {})
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
end
|
|
40
|
+
|
|
29
41
|
# Index a product — called after product save. No-op for database provider.
|
|
30
42
|
#
|
|
31
43
|
# @param product [Spree::Product] the product to index
|
|
@@ -8,35 +8,16 @@ module Spree
|
|
|
8
8
|
}.freeze
|
|
9
9
|
|
|
10
10
|
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
# 2. Extract internal params before passing to Ransack
|
|
15
|
-
category = filters.is_a?(Hash) ? filters.delete('_category') || filters.delete(:_category) : nil
|
|
16
|
-
|
|
17
|
-
# 2b. Extract option value IDs — handled by scope (OR within type, AND across)
|
|
18
|
-
option_value_ids = filters.is_a?(Hash) ? filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids) : nil
|
|
19
|
-
|
|
20
|
-
# 3. Structured filtering via Ransack
|
|
21
|
-
ransack_filters = sanitize_filters(filters)
|
|
22
|
-
if ransack_filters.present?
|
|
23
|
-
search = scope.ransack(ransack_filters)
|
|
24
|
-
scope = search.result(distinct: true)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Save scope before option filters for disjunctive facet counts
|
|
28
|
-
scope_before_options = scope
|
|
29
|
-
if option_value_ids.present?
|
|
30
|
-
scope = scope.with_option_value_ids(Array(option_value_ids))
|
|
31
|
-
end
|
|
11
|
+
filters = filters.is_a?(Hash) ? filters.dup : {}
|
|
12
|
+
option_value_ids = filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids)
|
|
32
13
|
|
|
33
|
-
|
|
34
|
-
|
|
14
|
+
scope = apply_search_and_filters(scope, query: query, filters: filters)
|
|
15
|
+
scope = scope.with_option_value_ids(Array(option_value_ids)) if option_value_ids.present?
|
|
35
16
|
|
|
36
|
-
#
|
|
17
|
+
# Total count (before sorting to avoid computed column conflicts with count)
|
|
37
18
|
total = scope.distinct.count
|
|
38
19
|
|
|
39
|
-
#
|
|
20
|
+
# Sorting + pagination
|
|
40
21
|
scope = apply_sort(scope, sort)
|
|
41
22
|
page = [page.to_i, 1].max
|
|
42
23
|
limit = limit.to_i.clamp(1, 100)
|
|
@@ -44,22 +25,56 @@ module Spree
|
|
|
44
25
|
|
|
45
26
|
SearchResult.new(
|
|
46
27
|
products: products,
|
|
28
|
+
total_count: total,
|
|
29
|
+
pagy: build_pagy(total, page, limit)
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def filters(scope:, query: nil, filters: {})
|
|
34
|
+
filters = filters.is_a?(Hash) ? filters.dup : {}
|
|
35
|
+
category = filters.delete('_category') || filters.delete(:_category)
|
|
36
|
+
option_value_ids = Array(filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids))
|
|
37
|
+
|
|
38
|
+
# Apply text search + ransack filters (without option values)
|
|
39
|
+
scope_before_options = apply_search_and_filters(scope, query: query, filters: filters)
|
|
40
|
+
|
|
41
|
+
# Apply option value filters for the final scope
|
|
42
|
+
scope_with_options = if option_value_ids.present?
|
|
43
|
+
scope_before_options.with_option_value_ids(option_value_ids)
|
|
44
|
+
else
|
|
45
|
+
scope_before_options
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
filter_facets = build_facets(scope_with_options, category: category, option_value_ids: option_value_ids, scope_before_options: scope_before_options)
|
|
49
|
+
|
|
50
|
+
FiltersResult.new(
|
|
47
51
|
filters: filter_facets[:filters],
|
|
48
52
|
sort_options: filter_facets[:sort_options],
|
|
49
53
|
default_sort: filter_facets[:default_sort],
|
|
50
|
-
total_count:
|
|
51
|
-
pagy: build_pagy(total, page, limit)
|
|
54
|
+
total_count: filter_facets[:total_count]
|
|
52
55
|
)
|
|
53
56
|
end
|
|
54
57
|
|
|
55
58
|
private
|
|
56
59
|
|
|
60
|
+
def apply_search_and_filters(scope, query: nil, filters: {})
|
|
61
|
+
scope = scope.search(query) if query.present?
|
|
62
|
+
|
|
63
|
+
ransack_filters = sanitize_filters(filters)
|
|
64
|
+
if ransack_filters.present?
|
|
65
|
+
search = scope.ransack(ransack_filters)
|
|
66
|
+
scope = search.result(distinct: true)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
scope
|
|
70
|
+
end
|
|
71
|
+
|
|
57
72
|
def build_pagy(count, page, limit)
|
|
58
73
|
Pagy::Offset.new(count: count, page: page, limit: limit)
|
|
59
74
|
end
|
|
60
75
|
|
|
61
76
|
def build_facets(scope, category: nil, option_value_ids: [], scope_before_options: nil)
|
|
62
|
-
return { filters: [], sort_options: available_sort_options, default_sort: 'manual' } unless defined?(Spree::Api::V3::FiltersAggregator)
|
|
77
|
+
return { filters: [], sort_options: available_sort_options, default_sort: 'manual', total_count: scope.distinct.count } unless defined?(Spree::Api::V3::FiltersAggregator)
|
|
63
78
|
|
|
64
79
|
Spree::Api::V3::FiltersAggregator.new(
|
|
65
80
|
scope: scope,
|
|
@@ -74,7 +89,7 @@ module Spree
|
|
|
74
89
|
return {} if filters.blank?
|
|
75
90
|
|
|
76
91
|
filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
|
|
77
|
-
filters.except('search', :search, '_category', :_category)
|
|
92
|
+
filters.except('search', :search, '_category', :_category, 'with_option_value_ids', :with_option_value_ids)
|
|
78
93
|
end
|
|
79
94
|
|
|
80
95
|
def apply_sort(scope, sort)
|