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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/imports/process_group_job.rb +45 -0
  3. data/app/jobs/spree/imports/process_rows_job.rb +51 -5
  4. data/app/models/concerns/spree/payment_source_concern.rb +21 -0
  5. data/app/models/spree/address.rb +1 -1
  6. data/app/models/spree/gateway/bogus.rb +10 -6
  7. data/app/models/spree/import.rb +15 -0
  8. data/app/models/spree/import_row.rb +14 -2
  9. data/app/models/spree/imports/product_translations.rb +4 -0
  10. data/app/models/spree/imports/products.rb +5 -0
  11. data/app/models/spree/order.rb +0 -6
  12. data/app/models/spree/payment.rb +1 -0
  13. data/app/models/spree/payment_session.rb +5 -0
  14. data/app/models/spree/product.rb +5 -10
  15. data/app/models/spree/promotion.rb +2 -2
  16. data/app/models/spree/search_provider/base.rb +13 -1
  17. data/app/models/spree/search_provider/database.rb +44 -29
  18. data/app/models/spree/search_provider/filters_result.rb +5 -0
  19. data/app/models/spree/search_provider/meilisearch.rb +99 -82
  20. data/app/models/spree/search_provider/search_result.rb +1 -1
  21. data/app/models/spree/shipment.rb +5 -1
  22. data/app/models/spree/shipping_rate.rb +26 -1
  23. data/app/models/spree/taxon.rb +1 -2
  24. data/app/models/spree/variant.rb +6 -3
  25. data/app/services/spree/credit_cards/destroy.rb +10 -26
  26. data/app/services/spree/gift_cards/apply.rb +5 -1
  27. data/app/services/spree/imports/row_processors/base.rb +19 -2
  28. data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
  29. data/app/services/spree/imports/row_processors/product_variant.rb +2 -2
  30. data/app/services/spree/payments/handle_webhook.rb +6 -0
  31. data/app/services/spree/seeds/zones.rb +8 -2
  32. data/app/subscribers/spree/event_log_subscriber.rb +9 -7
  33. data/config/initializers/carmen.rb +23 -0
  34. data/config/locales/en.yml +0 -1
  35. data/db/migrate/20260424000001_add_unique_index_to_spree_payments_response_code.rb +51 -0
  36. data/db/migrate/20260424100000_add_processing_groups_to_spree_imports.rb +6 -0
  37. data/db/sample_data/markets.rb +34 -0
  38. data/db/sample_data/orders.rb +1 -1
  39. data/db/sample_data/shipping_methods.rb +25 -3
  40. data/lib/generators/spree/dummy/dummy_generator.rb +1 -1
  41. data/lib/spree/core/configuration.rb +3 -0
  42. data/lib/spree/core/engine.rb +3 -6
  43. data/lib/spree/core/version.rb +1 -1
  44. data/lib/spree/events.rb +6 -0
  45. data/lib/spree/permitted_attributes.rb +3 -3
  46. data/lib/spree/testing_support/factories/payment_factory.rb +1 -1
  47. data/lib/tasks/markets.rake +2 -5
  48. metadata +23 -12
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 063ec32e04e9cca4d9987ddc55b8bf72266d6f9aaf204f2e7da96ad57365495d
4
- data.tar.gz: 9b13ad923ebcad513038a2bbe63d880c9b285e73e18e93a3f73983bec23cc6a1
3
+ metadata.gz: 23692bda70e0c85ecff286d989063498e74adc2e2422ae21719da5c6b6e97c98
4
+ data.tar.gz: 132486aca06d2912f82a54593d57eb9c400768b1a45b5412af77b61f2c822ac5
5
5
  SHA512:
6
- metadata.gz: a4cbe7e4ea5cab60434be37aed988f0c1b5d2276a43f94b31bf4150dbd933e8ddb64f0a4ddffb316c9e5d10449b6de0563c2ccaa4baf31bd1fb7b511a2b55ab8
7
- data.tar.gz: 52d17f39e82f7db6155af88252181cfe0fc60362cc29a46233f9748c09b6cd6b0a4cd03b6e9f544d12c1e7c2f895bd75b91c8d6142794b326e30f16d9738e08f
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
- # process all rows in sequential order
10
- import.rows.pending_and_failed.find_each(batch_size: 100) do |row|
11
- row.process!
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
- # mark as complete
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
@@ -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: '12345', avs_result: { code: 'D' })
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: '12345', avs_result: { code: 'M' })
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: '12345')
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 == '12345'
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: 'void-12345')
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: '12345')
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'
@@ -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
@@ -5,6 +5,10 @@ module Spree
5
5
  Spree::Imports::RowProcessors::ProductTranslation
6
6
  end
7
7
 
8
+ def group_column
9
+ 'slug'
10
+ end
11
+
8
12
  def model_class
9
13
  Spree::Product
10
14
  end
@@ -4,6 +4,11 @@ module Spree
4
4
  def row_processor_class
5
5
  Spree::Imports::RowProcessors::ProductVariant
6
6
  end
7
+
8
+ # Group by slug: product row + its variant rows must be processed together
9
+ def group_column
10
+ 'slug'
11
+ end
7
12
  end
8
13
  end
9
14
  end
@@ -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
@@ -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
@@ -92,6 +92,11 @@ module Spree
92
92
  p.amount = amount
93
93
  p.skip_source_requirement = true
94
94
  end
95
+ rescue ActiveRecord::RecordNotUnique
96
+ order.payments.find_by!(
97
+ payment_method: payment_method,
98
+ response_code: external_id
99
+ )
95
100
  end
96
101
 
97
102
  private
@@ -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
- # for ActiveJob 7.1+
253
- if ActiveJob.respond_to?(:perform_all_later)
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)).size
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, filter, and return facets in one call.
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
- # 1. Text search
12
- scope = scope.search(query) if query.present?
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
- # 4. Facets (before sorting to avoid computed column conflicts with count)
34
- filter_facets = build_facets(scope, category: category, option_value_ids: Array(option_value_ids), scope_before_options: scope_before_options)
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
- # 5. Total count (before sorting to avoid computed column conflicts with count)
17
+ # Total count (before sorting to avoid computed column conflicts with count)
37
18
  total = scope.distinct.count
38
19
 
39
- # 6. Sorting + pagination
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: total,
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)
@@ -0,0 +1,5 @@
1
+ module Spree
2
+ module SearchProvider
3
+ FiltersResult = Struct.new(:filters, :sort_options, :default_sort, :total_count, keyword_init: true)
4
+ end
5
+ end