spree_core 6.0.0.rc5 → 6.0.0.rc7

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/images/save_from_url_job.rb +24 -5
  3. data/app/models/concerns/spree/account_deactivation.rb +54 -0
  4. data/app/models/concerns/spree/calculator/currency_amounts.rb +19 -6
  5. data/app/models/concerns/spree/derived_number.rb +1 -1
  6. data/app/models/concerns/spree/has_external_references.rb +24 -16
  7. data/app/models/concerns/spree/prefixed_id.rb +15 -5
  8. data/app/models/concerns/spree/publishable.rb +47 -1
  9. data/app/models/concerns/spree/purchase/addresses.rb +19 -4
  10. data/app/models/concerns/spree/purchase/digital_items.rb +17 -3
  11. data/app/models/concerns/spree/purchase/freight.rb +2 -1
  12. data/app/models/concerns/spree/purchase/payment_processing.rb +7 -1
  13. data/app/models/concerns/spree/purchase/quantity_rules.rb +2 -0
  14. data/app/models/concerns/spree/stores/channels.rb +3 -1
  15. data/app/models/concerns/spree/translatable_resource.rb +6 -1
  16. data/app/models/spree/admin_user.rb +1 -0
  17. data/app/models/spree/base.rb +33 -1
  18. data/app/models/spree/calculator/shipping/digital_delivery.rb +3 -1
  19. data/app/models/spree/calculator/shipping/flat_rate.rb +3 -1
  20. data/app/models/spree/calculator/shipping/per_item.rb +3 -1
  21. data/app/models/spree/cart.rb +4 -1
  22. data/app/models/spree/category.rb +76 -5
  23. data/app/models/spree/channel.rb +79 -40
  24. data/app/models/spree/company.rb +45 -2
  25. data/app/models/spree/customer.rb +1 -1
  26. data/app/models/spree/customer_group.rb +59 -41
  27. data/app/models/spree/delivery_method.rb +18 -8
  28. data/app/models/spree/delivery_profile.rb +18 -0
  29. data/app/models/spree/digital_asset.rb +14 -0
  30. data/app/models/spree/event.rb +20 -5
  31. data/app/models/spree/fulfillment.rb +8 -1
  32. data/app/models/spree/fulfillment_changer.rb +3 -1
  33. data/app/models/spree/fulfillment_item.rb +11 -0
  34. data/app/models/spree/import.rb +20 -2
  35. data/app/models/spree/order_routing/strategy/rules.rb +4 -1
  36. data/app/models/spree/price_list.rb +15 -0
  37. data/app/models/spree/product/channels.rb +16 -8
  38. data/app/models/spree/product.rb +7 -2
  39. data/app/models/spree/return.rb +8 -1
  40. data/app/models/spree/search_provider/base.rb +8 -3
  41. data/app/models/spree/search_provider/database.rb +2 -10
  42. data/app/models/spree/seller.rb +35 -5
  43. data/app/models/spree/seller_balance.rb +28 -0
  44. data/app/models/spree/seller_requirements/delivery_method.rb +20 -2
  45. data/app/models/spree/stock/coordinator.rb +7 -4
  46. data/app/models/spree/stock/estimator.rb +6 -0
  47. data/app/models/spree/stock/inventory_unit_builder.rb +14 -2
  48. data/app/models/spree/stock/packer.rb +39 -5
  49. data/app/models/spree/stock/quantifier.rb +16 -0
  50. data/app/models/spree/stock_location.rb +15 -8
  51. data/app/models/spree/store.rb +31 -2
  52. data/app/models/spree/store_credit.rb +4 -4
  53. data/app/models/spree/variant.rb +23 -6
  54. data/app/models/spree/webhook_endpoint.rb +45 -11
  55. data/app/services/spree/carts/price_items.rb +3 -0
  56. data/app/services/spree/carts/split_by_seller.rb +3 -1
  57. data/app/services/spree/catalogs/set_product_terms.rb +8 -3
  58. data/app/services/spree/collections/add_products.rb +1 -1
  59. data/app/services/spree/collections/regenerate_products.rb +1 -3
  60. data/app/services/spree/collections/remove_products.rb +1 -1
  61. data/app/services/spree/newsletter/subscribe.rb +9 -1
  62. data/app/services/spree/order_groups/allocate_payments.rb +7 -5
  63. data/app/services/spree/orders/update.rb +19 -0
  64. data/app/services/spree/product_memberships.rb +16 -6
  65. data/app/services/spree/products/duplicator.rb +12 -4
  66. data/app/services/spree/search_provider/custom_field_schema.rb +10 -2
  67. data/app/services/spree/stock_levels/correct.rb +11 -4
  68. data/app/services/spree/stock_reservations/reserve.rb +1 -1
  69. data/app/services/spree/stores/provision_defaults.rb +11 -7
  70. data/app/services/spree/tags/bulk_add.rb +16 -6
  71. data/app/workflows/spree/carts/complete.rb +25 -9
  72. data/app/workflows/spree/carts/upsert_items.rb +31 -4
  73. data/app/workflows/spree/customers/anonymize.rb +2 -2
  74. data/app/workflows/spree/digital_assets/create.rb +56 -0
  75. data/app/workflows/spree/digital_assets/update.rb +44 -0
  76. data/app/workflows/spree/digital_assets/writing.rb +108 -0
  77. data/app/workflows/spree/imports/start_mapping.rb +3 -1
  78. data/app/workflows/spree/orders/complete.rb +12 -5
  79. data/app/workflows/spree/payments/capture.rb +12 -0
  80. data/app/workflows/spree/products/nested_attributes.rb +12 -28
  81. data/app/workflows/spree/products/propose.rb +9 -4
  82. data/app/workflows/spree/stock_levels/bulk_upsert.rb +28 -13
  83. data/app/workflows/spree/variants/nested_attributes.rb +35 -10
  84. data/config/locales/en.yml +10 -0
  85. data/db/migrate/20261022000001_store_credit_actor_polymorphic.rb +13 -0
  86. data/db/migrate/20261023100001_add_deactivated_at_to_spree_admin_users.rb +7 -0
  87. data/lib/generators/spree/dummy/templates/rails/test.rb +87 -0
  88. data/lib/spree/checkout/default_requirements.rb +12 -2
  89. data/lib/spree/core/dependencies.rb +5 -0
  90. data/lib/spree/core/permissions/default_catalog.rb +7 -2
  91. data/lib/spree/core/version.rb +1 -1
  92. data/lib/spree/reporting/default_vocabulary.rb +1 -1
  93. data/lib/spree/testing_support/factories/product_factory.rb +10 -4
  94. data/lib/spree/testing_support/factories/store_factory.rb +4 -1
  95. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +10 -0
  96. data/lib/tasks/backfill_tagging_tenants.rake +35 -0
  97. data/lib/tasks/seller_sample_data.rake +114 -112
  98. metadata +13 -6
@@ -81,7 +81,13 @@ module Spree
81
81
  validates :meta_description
82
82
  validates :meta_title
83
83
  end
84
- validates :image, :square_image, content_type: Rails.application.config.active_storage.web_image_content_types
84
+ # Only a file assigned in this write is checked: a stored one passed when
85
+ # it was attached, and reading it back would cost a query per slot on
86
+ # every save — including each category a product write validates.
87
+ validates :image, content_type: Rails.application.config.active_storage.web_image_content_types,
88
+ if: -> { attachment_changes.key?('image') }
89
+ validates :square_image, content_type: Rails.application.config.active_storage.web_image_content_types,
90
+ if: -> { attachment_changes.key?('square_image') }
85
91
 
86
92
  #
87
93
  # Callbacks
@@ -183,16 +189,81 @@ module Spree
183
189
  def self.recalculate_products_count(category_ids)
184
190
  changed = unscoped.where(id: Array(category_ids).compact.uniq)
185
191
 
186
- # The affected nodes are each changed category plus its ancestors.
187
- affected = changed.flat_map { |category| category.self_and_ancestors.to_a }.uniq(&:id)
192
+ # The affected nodes are each changed category plus its ancestors,
193
+ # collected in one read: an ancestor chain per category is a query per
194
+ # node, which a bulk catalog move pays for every category it touched.
195
+ # A nested set makes an ancestor a node whose `lft`/`rgt` span the
196
+ # child's, so one range query answers for all of them.
197
+ changed = changed.to_a
198
+ return if changed.empty?
199
+
200
+ ancestor_conditions = changed.map do |category|
201
+ arel_table[:store_id].eq(category.store_id).
202
+ and(arel_table[:lft].lteq(category.lft)).
203
+ and(arel_table[:rgt].gteq(category.rgt))
204
+ end.reduce(:or)
205
+
206
+ affected = unscoped.where(ancestor_conditions).to_a
207
+
208
+ # One grouped query for the whole set rather than a count per node:
209
+ # this runs on every catalog move, and a deep tree would otherwise
210
+ # spend a query on each affected ancestor.
211
+ counts = distinct_product_counts_for(affected)
188
212
 
189
213
  affected.each do |category|
190
- count = Spree::ProductCategory.where(category_id: category.self_and_descendants.select(:id))
191
- .distinct.count(:product_id)
214
+ count = counts.fetch(category.id, 0)
192
215
  category.update_column(:products_count, count) if category.products_count != count
193
216
  end
194
217
  end
195
218
 
219
+ # Distinct product counts per category, each counting its whole subtree.
220
+ # A nested set stores the subtree as an `lft`/`rgt` range, so the tie
221
+ # between a node and its descendants is a range join rather than a
222
+ # separate descendant query for every node.
223
+ #
224
+ # @param categories [Array<Spree::Category>]
225
+ # @return [Hash{Integer => Integer}] category id => distinct product count
226
+ def self.distinct_product_counts_for(categories)
227
+ return {} if categories.empty?
228
+
229
+ nodes = arel_table.alias('counted_nodes')
230
+ descendants = arel_table
231
+ product_categories = Spree::ProductCategory.arel_table
232
+
233
+ relation = unscoped.
234
+ from(nodes).
235
+ joins(
236
+ Arel::Nodes::InnerJoin.new(
237
+ descendants,
238
+ Arel::Nodes::On.new(
239
+ descendants[:store_id].eq(nodes[:store_id]).
240
+ and(descendants[:lft].gteq(nodes[:lft])).
241
+ and(descendants[:rgt].lteq(nodes[:rgt]))
242
+ )
243
+ )
244
+ ).
245
+ joins(
246
+ Arel::Nodes::InnerJoin.new(
247
+ product_categories,
248
+ Arel::Nodes::On.new(product_categories[:category_id].eq(descendants[:id]))
249
+ )
250
+ ).
251
+ where(nodes[:id].in(categories.map(&:id))).
252
+ group(nodes[:id])
253
+
254
+ relation.distinct.count(product_categories[:product_id])
255
+ end
256
+
257
+ # The breadcrumb trail, with the images a serializer renders alongside
258
+ # each crumb already loaded. `ancestors` comes from the nested set and
259
+ # preloads nothing, so drawing a trail would otherwise cost two
260
+ # attachment lookups per level.
261
+ #
262
+ # @return [ActiveRecord::Relation<Spree::Category>]
263
+ def ancestors
264
+ super.with_attached_image.with_attached_square_image
265
+ end
266
+
196
267
  def slug
197
268
  permalink
198
269
  end
@@ -62,6 +62,47 @@ module Spree
62
62
  self.whitelisted_ransackable_attributes = %w[name code active default store_id]
63
63
  search_by :name
64
64
 
65
+ # Publishes the given products on every channel in the current scope
66
+ # (`store.channels.where(id: ids).add_products(product_ids)`), touching
67
+ # and reindexing each product once rather than once per channel.
68
+ # @param product_ids [Array<Integer>, Integer]
69
+ # @return [Integer] the number of ProductPublications created or updated
70
+ def self.add_products(product_ids)
71
+ product_ids = Array(product_ids).map(&:to_s).uniq
72
+ channels = all.to_a
73
+ return 0 if product_ids.empty? || channels.empty?
74
+
75
+ count = channels.sum { |channel| channel.send(:upsert_publications, product_ids) }
76
+ refresh_products(product_ids)
77
+ channels.each(&:touch)
78
+ count
79
+ end
80
+
81
+ # Unpublishes the given products from every channel in the current scope.
82
+ # @param product_ids [Array<Integer>, Integer]
83
+ # @return [Integer] the number of ProductPublications destroyed
84
+ def self.remove_products(product_ids)
85
+ product_ids = Array(product_ids).map(&:to_s).uniq
86
+ channels = all.to_a
87
+ return 0 if product_ids.empty? || channels.empty?
88
+
89
+ removed = Spree::ProductPublication.includes(:product).where(channel_id: channels.map(&:id), product_id: product_ids).destroy_all
90
+ refresh_products(product_ids)
91
+ emptied_channel_ids = removed.map(&:channel_id).uniq
92
+ channels.select { |channel| emptied_channel_ids.include?(channel.id) }.each(&:touch)
93
+ removed.size
94
+ end
95
+
96
+ # @param product_ids [Array<String>]
97
+ # @return [void]
98
+ def self.refresh_products(product_ids)
99
+ products = Spree::Product.where(id: product_ids)
100
+ products.touch_all
101
+ # `to_a` once: `touch_all` does not load the relation, so iterating it
102
+ # afterwards would re-run the query.
103
+ products.to_a.each(&:enqueue_search_index)
104
+ end
105
+
65
106
  # Publishes the given products on this channel by creating/upserting ProductPublications.
66
107
  # Optionally sets the publication window; if not given, the products will be published immediately
67
108
  # with no end date.
@@ -73,42 +114,10 @@ module Spree
73
114
  product_ids = Array(product_ids).map(&:to_s).uniq
74
115
  return 0 if product_ids.empty?
75
116
 
76
- now = Time.current
77
- # Only include window columns in the upsert payload when the caller
78
- # explicitly passed a value. Leaving them out keeps existing
79
- # publication schedules intact on re-publish — otherwise +on_duplicate:
80
- # :update+ + +update_only+ would rewrite scheduled +published_at+ /
81
- # +unpublished_at+ to NULL whenever the bulk action re-runs without
82
- # dates.
83
- base = { channel_id: id, created_at: now, updated_at: now }
84
- base[:published_at] = published_at unless published_at.nil?
85
- base[:unpublished_at] = unpublished_at unless unpublished_at.nil?
86
-
87
- records_to_upsert = product_ids.map { |product_id| base.merge(product_id: product_id) }
88
-
89
- # Only update the window columns the caller passed. When neither was
90
- # passed, treat re-publish as a no-op (+on_duplicate: :skip+ → MySQL
91
- # +INSERT IGNORE+, PG/SQLite +ON CONFLICT DO NOTHING+).
92
- update_columns = []
93
- update_columns << :published_at unless published_at.nil?
94
- update_columns << :unpublished_at unless unpublished_at.nil?
95
- opts = if update_columns.empty?
96
- { on_duplicate: :skip }
97
- else
98
- { record_timestamps: false, update_only: update_columns, on_duplicate: :update }
99
- end
100
- # MySQL infers the conflict target from the table's unique constraints
101
- # and rejects an explicit +unique_by+; PostgreSQL/SQLite require it.
102
- opts[:unique_by] = %i[product_id channel_id] unless mysql_adapter?
103
-
104
- Spree::ProductPublication.upsert_all(records_to_upsert, **opts)
105
-
106
- products = Spree::Product.where(id: product_ids)
107
- products.touch_all
108
- products.each(&:enqueue_search_index)
117
+ count = upsert_publications(product_ids, published_at: published_at, unpublished_at: unpublished_at)
118
+ self.class.refresh_products(product_ids)
109
119
  touch
110
-
111
- records_to_upsert.size
120
+ count
112
121
  end
113
122
 
114
123
  # Unpublishes the given products from this channel.
@@ -118,12 +127,9 @@ module Spree
118
127
  product_ids = Array(product_ids).map(&:to_s).uniq
119
128
  return 0 if product_ids.empty?
120
129
 
121
- count = publications.where(product_id: product_ids).destroy_all.size
122
-
123
- products = Spree::Product.where(id: product_ids)
124
- products.touch_all
125
- products.each(&:enqueue_search_index)
130
+ count = publications.includes(:product).where(product_id: product_ids).destroy_all.size
126
131
 
132
+ self.class.refresh_products(product_ids)
127
133
  touch if count.positive?
128
134
  count
129
135
  end
@@ -161,6 +167,39 @@ module Spree
161
167
 
162
168
  private
163
169
 
170
+ def upsert_publications(product_ids, published_at: nil, unpublished_at: nil)
171
+ now = Time.current
172
+ # Only include window columns in the upsert payload when the caller
173
+ # explicitly passed a value. Leaving them out keeps existing
174
+ # publication schedules intact on re-publish — otherwise +on_duplicate:
175
+ # :update+ + +update_only+ would rewrite scheduled +published_at+ /
176
+ # +unpublished_at+ to NULL whenever the bulk action re-runs without
177
+ # dates.
178
+ base = { channel_id: id, created_at: now, updated_at: now }
179
+ base[:published_at] = published_at unless published_at.nil?
180
+ base[:unpublished_at] = unpublished_at unless unpublished_at.nil?
181
+
182
+ records_to_upsert = product_ids.map { |product_id| base.merge(product_id: product_id) }
183
+
184
+ # Only update the window columns the caller passed. When neither was
185
+ # passed, treat re-publish as a no-op (+on_duplicate: :skip+ → MySQL
186
+ # +INSERT IGNORE+, PG/SQLite +ON CONFLICT DO NOTHING+).
187
+ update_columns = []
188
+ update_columns << :published_at unless published_at.nil?
189
+ update_columns << :unpublished_at unless unpublished_at.nil?
190
+ opts = if update_columns.empty?
191
+ { on_duplicate: :skip }
192
+ else
193
+ { record_timestamps: false, update_only: update_columns, on_duplicate: :update }
194
+ end
195
+ # MySQL infers the conflict target from the table's unique constraints
196
+ # and rejects an explicit +unique_by+; PostgreSQL/SQLite require it.
197
+ opts[:unique_by] = %i[product_id channel_id] unless mysql_adapter?
198
+
199
+ Spree::ProductPublication.upsert_all(records_to_upsert, **opts)
200
+ records_to_upsert.size
201
+ end
202
+
164
203
  def ensure_not_default
165
204
  return unless default?
166
205
  return if destroyed_by_association.present?
@@ -177,10 +177,47 @@ module Spree
177
177
  self_and_descendants.where.not(id: id)
178
178
  end
179
179
 
180
+ # Distinct members across each node's whole subtree, for many nodes in
181
+ # one query.
182
+ #
183
+ # @param company_ids [Array] the subtree roots
184
+ # @return [Hash{Integer => Integer}] member count by root id; roots with none are absent
185
+ def self.members_counts_for(company_ids)
186
+ return {} if company_ids.empty?
187
+
188
+ membership_table = Spree::CompanyMembership.table_name
189
+ subtree_sql = sanitize_sql_array([<<~SQL.squish, company_ids])
190
+ WITH RECURSIVE subtree(root_id, id) AS (
191
+ SELECT id, id FROM #{table_name} WHERE id IN (?)
192
+ UNION
193
+ SELECT subtree.root_id, child.id FROM #{table_name} child
194
+ INNER JOIN subtree ON child.parent_id = subtree.id
195
+ )
196
+ SELECT root_id, id FROM subtree
197
+ SQL
198
+
199
+ Spree::CompanyMembership.
200
+ joins("INNER JOIN (#{subtree_sql}) company_subtree ON company_subtree.id = #{membership_table}.company_id").
201
+ group('company_subtree.root_id').
202
+ distinct.count(:customer_id)
203
+ end
204
+
205
+ # On a lazily preloaded list, the first call counts every node of the
206
+ # list at once.
207
+ #
180
208
  # @return [Integer]
181
209
  def members_count
182
- Spree::CompanyMembership.where(company_id: self_and_descendants.select(:id)).
183
- distinct.count(:customer_id)
210
+ return @members_count if defined?(@members_count)
211
+
212
+ siblings = lazy_preload_context&.records&.grep(self.class)
213
+ if siblings.blank?
214
+ return Spree::CompanyMembership.where(company_id: self_and_descendants.select(:id)).
215
+ distinct.count(:customer_id)
216
+ end
217
+
218
+ counts = self.class.members_counts_for(siblings.map(&:id))
219
+ siblings.each { |company| company.preloaded_members_count = counts.fetch(company.id, 0) }
220
+ @members_count
184
221
  end
185
222
 
186
223
  # The single tax anchor: the nearest self-or-ancestor +company+ node. Tax
@@ -248,6 +285,12 @@ module Spree
248
285
  Spree::Address.not_deleted.where(owner_type: 'Spree::Company', owner_id: self_and_ancestors.map(&:id))
249
286
  end
250
287
 
288
+ protected
289
+
290
+ def preloaded_members_count=(count)
291
+ @members_count = count
292
+ end
293
+
251
294
  private
252
295
 
253
296
  def ensure_can_be_deleted
@@ -16,7 +16,7 @@ module Spree
16
16
  include Spree::AccountLockout
17
17
  include Spree::PasswordPolicy
18
18
 
19
- validates :email, presence: true, uniqueness: { case_sensitive: false }
19
+ validates :email, presence: true, uniqueness: { case_sensitive: false, scope: spree_base_uniqueness_scope }
20
20
 
21
21
  attribute :accepts_email_marketing, :boolean, default: false
22
22
 
@@ -21,7 +21,10 @@ module Spree
21
21
  # own association (rather than `alias_method`) is what lets `customer_ids=`
22
22
  # exist and what makes the `PrefixedId` auto-decoder in `assign_attributes`
23
23
  # recognise the key — that lookup keys off `reflect_on_association(:customers)`.
24
- has_many :customers, through: :customer_group_users, source: :customer, source_type: Spree.customer_class.to_s
24
+ # Joining a group changes nothing on the customer, so saving the group
25
+ # does not re-validate every member it lists.
26
+ has_many :customers, through: :customer_group_users, source: :customer, source_type: Spree.customer_class.to_s,
27
+ validate: false
25
28
 
26
29
  #
27
30
  # Validations
@@ -41,64 +44,79 @@ module Spree
41
44
  # @param user_ids [Array] array of user IDs to add
42
45
  # @return [Integer] number of customers added
43
46
  def add_customers(user_ids)
44
- return 0 if user_ids.blank?
47
+ self.class.add_customers_to([self], user_ids)
48
+ end
45
49
 
46
- user_ids = Array(user_ids).map(&:to_s).uniq
47
- return 0 if user_ids.empty?
50
+ # Bulk remove customers from the group
51
+ # @param user_ids [Array] array of user IDs to remove
52
+ # @return [Integer] number of customers removed
53
+ def remove_customers(user_ids)
54
+ self.class.remove_customers_from([self], user_ids)
55
+ end
48
56
 
49
- # Get existing user IDs to avoid duplicates
50
- existing_user_ids = customer_group_users.where(customer_id: user_ids).pluck(:customer_id).map(&:to_s).to_set
57
+ # Adds customers to every given group, skipping memberships that already
58
+ # exist, with one lookup for all groups.
59
+ # @param groups [Array<Spree::CustomerGroup>]
60
+ # @param customer_ids [Array] customer IDs to add
61
+ # @return [Integer] number of memberships created
62
+ def self.add_customers_to(groups, customer_ids)
63
+ customer_ids = Array(customer_ids).compact_blank.map(&:to_s).uniq
64
+ groups = Array(groups)
65
+ return 0 if customer_ids.empty? || groups.empty?
66
+
67
+ existing_pairs = Spree::CustomerGroupUser.
68
+ where(customer_group_id: groups.map(&:id), customer_id: customer_ids).
69
+ pluck(:customer_group_id, :customer_id).
70
+ to_set { |group_id, customer_id| [group_id, customer_id.to_s] }
51
71
 
52
72
  now = Time.current
53
- user_type = Spree.customer_class.to_s
54
-
55
- records_to_insert = user_ids.filter_map do |user_id|
56
- next if existing_user_ids.include?(user_id)
57
-
58
- {
59
- customer_group_id: id,
60
- user_id: user_id,
61
- user_type: user_type,
62
- created_at: now,
63
- updated_at: now
64
- }
73
+ customer_type = Spree.customer_class.to_s
74
+ records_to_insert = groups.flat_map do |group|
75
+ customer_ids.filter_map do |customer_id|
76
+ next if existing_pairs.include?([group.id, customer_id])
77
+
78
+ {
79
+ customer_group_id: group.id,
80
+ customer_id: customer_id,
81
+ customer_type: customer_type,
82
+ created_at: now,
83
+ updated_at: now
84
+ }
85
+ end
65
86
  end
66
-
67
87
  return 0 if records_to_insert.empty?
68
88
 
69
89
  Spree::CustomerGroupUser.upsert_all(records_to_insert, on_duplicate: :skip)
70
- added_user_ids = records_to_insert.map { |r| r[:user_id] }
71
- touch_users(added_user_ids)
72
- touch
90
+ touch_customers(records_to_insert.map { |record| record[:customer_id] }.uniq)
91
+ changed_group_ids = records_to_insert.map { |record| record[:customer_group_id] }.to_set
92
+ groups.select { |group| changed_group_ids.include?(group.id) }.each(&:touch)
73
93
 
74
94
  records_to_insert.size
75
95
  end
76
96
 
77
- # Bulk remove customers from the group
78
- # @param user_ids [Array] array of user IDs to remove
79
- # @return [Integer] number of customers removed
80
- def remove_customers(user_ids)
81
- return 0 if user_ids.blank?
97
+ # Removes customers from every given group.
98
+ # @param groups [Array<Spree::CustomerGroup>]
99
+ # @param customer_ids [Array] customer IDs to remove
100
+ # @return [Integer] number of memberships removed
101
+ def self.remove_customers_from(groups, customer_ids)
102
+ customer_ids = Array(customer_ids).compact_blank.map(&:to_s).uniq
103
+ groups = Array(groups)
104
+ return 0 if customer_ids.empty? || groups.empty?
82
105
 
83
- user_ids = Array(user_ids).map(&:to_s).uniq
84
- return 0 if user_ids.empty?
106
+ memberships = Spree::CustomerGroupUser.where(customer_group_id: groups.map(&:id), customer_id: customer_ids)
107
+ changed_group_ids = memberships.distinct.pluck(:customer_group_id).to_set
108
+ return 0 if changed_group_ids.empty?
85
109
 
86
- deleted_count = customer_group_users.where(customer_id: user_ids).delete_all
87
-
88
- if deleted_count > 0
89
- touch_users(user_ids)
90
- touch
91
- end
110
+ deleted_count = memberships.delete_all
111
+ touch_customers(customer_ids)
112
+ groups.select { |group| changed_group_ids.include?(group.id) }.each(&:touch)
92
113
 
93
114
  deleted_count
94
115
  end
95
116
 
96
- private
97
-
98
- def touch_users(user_ids)
99
- return if user_ids.blank?
100
-
101
- Spree.customer_class.where(id: user_ids).touch_all
117
+ def self.touch_customers(customer_ids)
118
+ Spree.customer_class.where(id: customer_ids).touch_all
102
119
  end
120
+ private_class_method :touch_customers
103
121
  end
104
122
  end
@@ -451,10 +451,13 @@ module Spree
451
451
  calculator.description + ': ' +
452
452
 
453
453
  if calculator.is_a?(Spree::Calculator::Shipping::FlatRate)
454
- if calculator.preferred_amount == 0
454
+ amount = calculator.amount_for(calculator.preferred_currency)
455
+ if amount.nil?
456
+ ''
457
+ elsif amount.zero?
455
458
  I18n.t('spree.free')
456
459
  else
457
- Spree::Money.new(calculator.preferred_amount, { currency: calculator.preferred_currency }).to_s
460
+ Spree::Money.new(amount, { currency: calculator.preferred_currency }).to_s
458
461
  end
459
462
  elsif calculator.is_a?(Spree::Calculator::Shipping::FlexiRate)
460
463
  Spree::Money.new(calculator.preferred_first_item, { currency: calculator.preferred_currency }).to_s
@@ -497,14 +500,21 @@ module Spree
497
500
  reconcile_services(pending)
498
501
  end
499
502
 
503
+ # Matched against the method's rows loaded once, rather than a lookup per
504
+ # row. A row created here joins that list, so a later row naming the
505
+ # same (carrier, service) updates it as a lookup would.
500
506
  def reconcile_services(rows)
507
+ existing = services.reload.to_a
501
508
  kept_ids = rows.filter_map do |row|
502
- record =
503
- if row[:id].present?
504
- id = Spree::PrefixedId.prefixed_id?(row[:id]) ? Spree::PrefixedId.decode_prefixed_id(row[:id]) : row[:id]
505
- services.find_by(id: id)
506
- end
507
- record ||= services.find_or_initialize_by(carrier: row[:carrier], service: row[:service])
509
+ if row[:id].present?
510
+ id = Spree::PrefixedId.prefixed_id?(row[:id]) ? Spree::PrefixedId.decode_prefixed_id(row[:id]) : row[:id]
511
+ record = existing.find { |service| service.id.to_s == id.to_s }
512
+ end
513
+ record ||= existing.find { |service| service.carrier == row[:carrier] && service.service == row[:service] }
514
+ unless record
515
+ record = services.build(carrier: row[:carrier], service: row[:service])
516
+ existing << record
517
+ end
508
518
  record.assign_attributes(row.slice(:carrier, :service, :label, :markup_flat, :markup_percent, :position).to_h)
509
519
  record.save!
510
520
  record.id
@@ -123,6 +123,24 @@ module Spree
123
123
  delivery_origin_groups.first
124
124
  end
125
125
 
126
+ # How many products use this profile. Counted for every profile of a
127
+ # lazily preloaded list in one grouped query, since loading the products
128
+ # themselves to count them would fetch the whole catalog.
129
+ #
130
+ # @return [Integer]
131
+ def products_count
132
+ return @products_count if defined?(@products_count)
133
+
134
+ profiles = lazy_preload_context&.records.presence || [self]
135
+ counts = Spree::Product.where(delivery_profile_id: profiles.map(&:id)).group(:delivery_profile_id).count
136
+ profiles.each { |profile| profile.products_count = counts.fetch(profile.id, 0) }
137
+ @products_count
138
+ end
139
+
140
+ protected
141
+
142
+ attr_writer :products_count
143
+
126
144
  private
127
145
 
128
146
  def create_default_origin_group
@@ -25,6 +25,20 @@ module Spree
25
25
  # and provider-backed assets (which carry no file) are exempt.
26
26
  DEFAULT_PROVIDER = 'Spree::DigitalAssetProvider::File'.freeze
27
27
 
28
+ # What a seller may upload unless the marketplace says otherwise: the
29
+ # formats digital goods usually come in. Executables, scripts and
30
+ # unrecognised binaries are left out on purpose, as is SVG, which can
31
+ # carry scripts. A marketplace selling a niche format adds it in settings.
32
+ # The text formats have no signature, so they are taken at the type the
33
+ # upload declared.
34
+ DEFAULT_SELLER_CONTENT_TYPES = %w[
35
+ application/pdf application/epub+zip application/zip application/x-7z-compressed application/gzip
36
+ application/json text/plain text/csv
37
+ audio/mpeg audio/mp4 audio/aac audio/flac audio/ogg audio/wav audio/x-wav audio/midi
38
+ video/mp4 video/webm video/quicktime
39
+ image/jpeg image/png image/gif image/webp image/tiff
40
+ ].freeze
41
+
28
42
  # The API names a provider by its shorthand (`file`); the column keeps the
29
43
  # class name.
30
44
  normalizes :provider_type, with: ->(value) { Spree::ApiTyped.class_name_for(Spree.digital_asset_providers, value) }
@@ -86,14 +86,29 @@ module Spree
86
86
  # @return [Boolean]
87
87
  def self.matches?(event_name, pattern)
88
88
  return true if pattern == '*'
89
+ return event_name == pattern unless pattern.include?('*')
89
90
 
90
- if pattern.include?('*')
91
- regex = Regexp.new("^#{Regexp.escape(pattern).gsub('\*', '.*')}$")
92
- event_name.match?(regex)
93
- else
94
- event_name == pattern
91
+ wildcard_match?(event_name, pattern)
92
+ end
93
+
94
+ # `*` stands for any run of characters, as in a shell glob. The comparison
95
+ # is plain string searching so a pattern taken from a merchant never
96
+ # reaches the regexp engine, which has had memory bugs in pattern parsing.
97
+ def self.wildcard_match?(event_name, pattern)
98
+ head, *middle, tail = pattern.split('*', -1)
99
+ return false unless event_name.start_with?(head) && event_name.end_with?(tail)
100
+ return false if event_name.length < head.length + tail.length
101
+
102
+ position = head.length
103
+ limit = event_name.length - tail.length
104
+ middle.all? do |segment|
105
+ found = event_name.index(segment, position)
106
+ next false if found.nil? || found + segment.length > limit
107
+
108
+ position = found + segment.length
95
109
  end
96
110
  end
111
+ private_class_method :wildcard_match?
97
112
 
98
113
  alias_method :to_h, :attributes
99
114
 
@@ -653,7 +653,14 @@ module Spree
653
653
  #
654
654
  # @return [Spree::DeliveryMethod]
655
655
  def delivery_method
656
- selected_delivery_rate&.delivery_method || delivery_rates.first&.delivery_method
656
+ selected = selected_delivery_rate
657
+ # The same row read through the rate list, when that list is to hand,
658
+ # so its method loads with every other rate's instead of on its own.
659
+ if selected && read_association_in_memory?(:delivery_rates)
660
+ selected = delivery_rates.detect { |rate| rate.id == selected.id } || selected
661
+ end
662
+
663
+ selected&.delivery_method || delivery_rates.first&.delivery_method
657
664
  end
658
665
 
659
666
  # The fulfillment provider strategy handling this fulfillment's
@@ -138,7 +138,9 @@ module Spree
138
138
  end
139
139
 
140
140
  def reload_shipment_inventory_units
141
- [current_shipment, desired_shipment].each { |shipment| shipment.fulfillment_items.reload }
141
+ shipments = [current_shipment, desired_shipment]
142
+ shipments.each { |shipment| shipment.association(:fulfillment_items).reset }
143
+ ActiveRecord::Associations::Preloader.new(records: shipments, associations: :fulfillment_items).call
142
144
  end
143
145
 
144
146
  def current_quantity
@@ -107,6 +107,17 @@ module Spree
107
107
  split
108
108
  end
109
109
 
110
+ # `dup` copies the foreign keys but not the loaded records, so every copy
111
+ # made while packing and splitting would look its line item and variant
112
+ # up again.
113
+ def initialize_dup(other)
114
+ super
115
+ %i[line_item variant].each do |name|
116
+ association = other.association(name)
117
+ self.association(name).target = association.target if association.loaded?
118
+ end
119
+ end
120
+
110
121
  # This will fail if extract >= available_quantity
111
122
  def split_inventory!(extract_quantity)
112
123
  split = self.class.split(self, extract_quantity)
@@ -215,10 +215,22 @@ module Spree
215
215
  end
216
216
 
217
217
  # Per-status row counts for API status payloads, memoized so one request
218
- # issues a single grouped COUNT instead of one per status.
218
+ # issues a single grouped COUNT instead of one per status — and, on a
219
+ # lazily preloaded list, one for every import of the list.
219
220
  # @return [Hash{String => Integer}]
220
221
  def rows_status_counts
221
- @rows_status_counts ||= rows.group(:status).count
222
+ return @rows_status_counts if @rows_status_counts
223
+
224
+ siblings = lazy_preload_context&.records&.grep(Spree::Import)
225
+ return @rows_status_counts = rows.group(:status).count if siblings.blank?
226
+
227
+ counts = Spree::ImportRow.where(import_id: siblings.map(&:id)).group(:import_id, :status).count
228
+ siblings.each do |import|
229
+ import.preloaded_rows_status_counts = counts.each_with_object({}) do |((import_id, status), count), by_status|
230
+ by_status[status] = count if import_id == import.id
231
+ end
232
+ end
233
+ @rows_status_counts
222
234
  end
223
235
 
224
236
  # Public API name for the +results_url+ preference (read/write symmetry).
@@ -474,6 +486,12 @@ module Spree
474
486
  end
475
487
  end
476
488
 
489
+ protected
490
+
491
+ def preloaded_rows_status_counts=(counts)
492
+ @rows_status_counts = counts
493
+ end
494
+
477
495
  private
478
496
 
479
497
  # Mirrors the machine's bang events: an illegal move raised.