spree_core 6.0.0.rc4 → 6.0.0.rc6

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 (113) 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 +114 -36
  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/concerns/spree/webhook_payload_redaction.rb +1 -0
  17. data/app/models/spree/ability.rb +8 -0
  18. data/app/models/spree/address.rb +6 -0
  19. data/app/models/spree/admin_user.rb +1 -0
  20. data/app/models/spree/base.rb +33 -1
  21. data/app/models/spree/calculator/shipping/digital_delivery.rb +3 -1
  22. data/app/models/spree/calculator/shipping/flat_rate.rb +3 -1
  23. data/app/models/spree/calculator/shipping/per_item.rb +3 -1
  24. data/app/models/spree/cart.rb +7 -7
  25. data/app/models/spree/category.rb +76 -5
  26. data/app/models/spree/channel.rb +79 -40
  27. data/app/models/spree/company.rb +46 -3
  28. data/app/models/spree/customer.rb +1 -1
  29. data/app/models/spree/customer_group.rb +59 -41
  30. data/app/models/spree/data_request.rb +34 -1
  31. data/app/models/spree/delivery_method.rb +18 -8
  32. data/app/models/spree/delivery_profile.rb +18 -0
  33. data/app/models/spree/digital_asset.rb +14 -0
  34. data/app/models/spree/event.rb +20 -5
  35. data/app/models/spree/fulfillment.rb +8 -1
  36. data/app/models/spree/fulfillment_changer.rb +3 -1
  37. data/app/models/spree/fulfillment_item.rb +11 -0
  38. data/app/models/spree/import.rb +20 -2
  39. data/app/models/spree/order.rb +1 -1
  40. data/app/models/spree/order_routing/strategy/rules.rb +4 -1
  41. data/app/models/spree/price_list.rb +15 -0
  42. data/app/models/spree/product/channels.rb +16 -8
  43. data/app/models/spree/product.rb +7 -2
  44. data/app/models/spree/return.rb +8 -1
  45. data/app/models/spree/sales_context.rb +162 -0
  46. data/app/models/spree/search_provider/base.rb +8 -3
  47. data/app/models/spree/search_provider/database.rb +2 -10
  48. data/app/models/spree/seller.rb +35 -5
  49. data/app/models/spree/seller_balance.rb +28 -0
  50. data/app/models/spree/seller_requirements/delivery_method.rb +20 -2
  51. data/app/models/spree/stock/coordinator.rb +7 -4
  52. data/app/models/spree/stock/estimator.rb +6 -0
  53. data/app/models/spree/stock/inventory_unit_builder.rb +14 -2
  54. data/app/models/spree/stock/packer.rb +39 -5
  55. data/app/models/spree/stock/quantifier.rb +16 -0
  56. data/app/models/spree/stock_location.rb +15 -8
  57. data/app/models/spree/store.rb +31 -2
  58. data/app/models/spree/store_credit.rb +4 -4
  59. data/app/models/spree/tax_line.rb +5 -4
  60. data/app/models/spree/variant.rb +23 -6
  61. data/app/models/spree/webhook_endpoint.rb +45 -11
  62. data/app/services/spree/carts/destroy.rb +7 -2
  63. data/app/services/spree/carts/price_items.rb +3 -0
  64. data/app/services/spree/carts/split_by_seller.rb +3 -1
  65. data/app/services/spree/carts/update.rb +1 -4
  66. data/app/services/spree/catalogs/set_product_terms.rb +8 -3
  67. data/app/services/spree/collections/add_products.rb +1 -1
  68. data/app/services/spree/collections/regenerate_products.rb +1 -3
  69. data/app/services/spree/collections/remove_products.rb +1 -1
  70. data/app/services/spree/data_requests/confirm.rb +37 -0
  71. data/app/services/spree/data_requests/create.rb +61 -13
  72. data/app/services/spree/newsletter/subscribe.rb +9 -1
  73. data/app/services/spree/order_groups/allocate_payments.rb +7 -5
  74. data/app/services/spree/orders/update.rb +19 -0
  75. data/app/services/spree/product_memberships.rb +16 -6
  76. data/app/services/spree/products/duplicator.rb +12 -4
  77. data/app/services/spree/search_provider/custom_field_schema.rb +10 -2
  78. data/app/services/spree/stock_levels/correct.rb +11 -4
  79. data/app/services/spree/stock_reservations/reserve.rb +1 -1
  80. data/app/services/spree/stores/provision_defaults.rb +11 -7
  81. data/app/services/spree/tags/bulk_add.rb +16 -6
  82. data/app/workflows/spree/carts/complete.rb +25 -9
  83. data/app/workflows/spree/carts/upsert_items.rb +31 -4
  84. data/app/workflows/spree/customers/anonymize.rb +40 -19
  85. data/app/workflows/spree/data_requests/fulfill.rb +20 -6
  86. data/app/workflows/spree/digital_assets/create.rb +56 -0
  87. data/app/workflows/spree/digital_assets/update.rb +44 -0
  88. data/app/workflows/spree/digital_assets/writing.rb +108 -0
  89. data/app/workflows/spree/imports/start_mapping.rb +3 -1
  90. data/app/workflows/spree/orders/complete.rb +12 -5
  91. data/app/workflows/spree/orders/create.rb +41 -17
  92. data/app/workflows/spree/payments/capture.rb +12 -0
  93. data/app/workflows/spree/products/nested_attributes.rb +12 -28
  94. data/app/workflows/spree/products/propose.rb +9 -4
  95. data/app/workflows/spree/stock_levels/bulk_upsert.rb +28 -13
  96. data/app/workflows/spree/variants/nested_attributes.rb +35 -10
  97. data/config/locales/en.yml +14 -0
  98. data/db/migrate/20261022000001_store_credit_actor_polymorphic.rb +13 -0
  99. data/db/migrate/20261023100001_add_deactivated_at_to_spree_admin_users.rb +7 -0
  100. data/lib/generators/spree/dummy/templates/rails/test.rb +87 -0
  101. data/lib/spree/checkout/default_requirements.rb +12 -2
  102. data/lib/spree/core/dependencies.rb +5 -0
  103. data/lib/spree/core/engine.rb +2 -0
  104. data/lib/spree/core/permissions/default_catalog.rb +7 -2
  105. data/lib/spree/core/version.rb +1 -1
  106. data/lib/spree/reporting/default_vocabulary.rb +1 -1
  107. data/lib/spree/testing_support/factories/data_request_factory.rb +4 -0
  108. data/lib/spree/testing_support/factories/product_factory.rb +10 -4
  109. data/lib/spree/testing_support/factories/store_factory.rb +4 -1
  110. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +10 -0
  111. data/lib/tasks/backfill_tagging_tenants.rake +35 -0
  112. data/lib/tasks/seller_sample_data.rake +114 -112
  113. metadata +15 -6
@@ -167,6 +167,9 @@ module Spree
167
167
 
168
168
  # Reference data every staff member can read regardless of keys — address
169
169
  # forms need countries/states, and the dashboard shell reads the store.
170
+ # The back office also reads the store's markets and channels: its
171
+ # currencies and languages come from the markets, and list filters offer
172
+ # both.
170
173
  #
171
174
  # The store grant is unrestricted only for the store's own back office.
172
175
  # Another panel reads the one store it operates under and no other, so a
@@ -180,6 +183,11 @@ module Spree
180
183
  else
181
184
  can :read, Spree::Store, id: @store.id
182
185
  end
186
+
187
+ return unless store_resource?
188
+
189
+ can :read, Spree::Market, store_id: @resource.id
190
+ can :read, Spree::Channel, store_id: @resource.id
183
191
  end
184
192
 
185
193
  end
@@ -343,6 +343,12 @@ module Spree
343
343
  def snapshot
344
344
  dup.tap do |copy|
345
345
  copy.owner = nil
346
+
347
+ if business_owned?
348
+ copy.company = company.presence || owner&.name
349
+ copy.owner_type = owner_type
350
+ end
351
+
346
352
  copy.label = nil
347
353
  copy.deleted_at = nil
348
354
  end
@@ -8,6 +8,7 @@ module Spree
8
8
 
9
9
  include Spree::AdminUserMethods
10
10
  include Spree::AccountLockout
11
+ include Spree::AccountDeactivation
11
12
  include Spree::PasswordPolicy
12
13
 
13
14
  validates :email, presence: true, uniqueness: { case_sensitive: false }
@@ -46,13 +46,21 @@ class Spree::Base < ApplicationRecord
46
46
  select("#{klass.table_name}.*").select(*(fields || klass::TRANSLATABLE_FIELDS))
47
47
  }
48
48
 
49
+ # The store's own rows of this model, through the association named after
50
+ # it. A model the store has no association for answers nothing: a tenant
51
+ # boundary that quietly widened to every store's rows is how a cross-store
52
+ # read ships unnoticed. Global reference data (countries, roles) and
53
+ # records reached through a parent are fetched as such, never through here.
54
+ #
55
+ # @param store [Spree::Store]
56
+ # @return [ActiveRecord::Relation]
49
57
  def self.for_store(store)
50
58
  plural_model_name = model_name.plural.gsub(/spree_/, '').to_sym
51
59
 
52
60
  if store.respond_to?(plural_model_name)
53
61
  store.send(plural_model_name)
54
62
  else
55
- self
63
+ none
56
64
  end
57
65
  end
58
66
 
@@ -81,6 +89,30 @@ class Spree::Base < ApplicationRecord
81
89
  association(name).loaded? || lazy_preload_context.present?
82
90
  end
83
91
 
92
+ # Recomputes a counter cache for many records at once. Rails'
93
+ # +reset_counters+ spends two queries per record, which a bulk write pays
94
+ # for every row it touched; this is one grouped count plus one UPDATE per
95
+ # distinct value.
96
+ #
97
+ # @param ids [Array] the records whose counter is recomputed
98
+ # @param association [Symbol] the has_many whose rows are counted
99
+ # @return [void]
100
+ def self.reset_counters_in_bulk(ids, association)
101
+ ids = Array(ids).compact.uniq
102
+ return if ids.empty?
103
+
104
+ reflection = reflect_on_association(association)
105
+ counter = reflection.klass.reflect_on_all_associations(:belongs_to).find do |belongs_to|
106
+ belongs_to.foreign_key.to_s == reflection.foreign_key.to_s && belongs_to.options[:counter_cache]
107
+ end.counter_cache_column
108
+
109
+ counts = reflection.klass.where(reflection.foreign_key => ids).group(reflection.foreign_key).count
110
+
111
+ ids.group_by { |id| counts.fetch(id, 0) }.each do |count, grouped_ids|
112
+ unscoped.where(id: grouped_ids).where.not(counter => count).update_all(counter => count)
113
+ end
114
+ end
115
+
84
116
  # @see Spree.mysql? — the single definition; this reads it so a model can
85
117
  # branch without reaching for the connection itself.
86
118
  def mysql_adapter?
@@ -3,7 +3,9 @@ module Spree
3
3
  class DigitalDelivery < ShippingCalculator
4
4
  include Spree::Calculator::CurrencyAmounts
5
5
 
6
- preference :amount, :money, default: 0
6
+ # Blank once its figure has moved into `amounts`, so the method quotes
7
+ # nothing outside the hash.
8
+ preference :amount, :money, default: 0, nullable: true
7
9
  preference :currency, :string, format: :currency, default: -> { Spree::Store.default.default_currency }
8
10
 
9
11
  def self.description
@@ -3,7 +3,9 @@ module Spree
3
3
  class FlatRate < ShippingCalculator
4
4
  include Spree::Calculator::CurrencyAmounts
5
5
 
6
- preference :amount, :money, default: 0
6
+ # Blank once its figure has moved into `amounts`, so the method quotes
7
+ # nothing outside the hash.
8
+ preference :amount, :money, default: 0, nullable: true
7
9
  # @deprecated Together with the single `amount`, superseded by the
8
10
  # per-currency `amounts` hash; kept as the fallback for its own
9
11
  # currency so upgraded stores quote unchanged.
@@ -3,7 +3,9 @@ module Spree
3
3
  class PerItem < ShippingCalculator
4
4
  include Spree::Calculator::CurrencyAmounts
5
5
 
6
- preference :amount, :money, default: 0
6
+ # Blank once its figure has moved into `amounts`, so the method quotes
7
+ # nothing outside the hash.
8
+ preference :amount, :money, default: 0, nullable: true
7
9
  preference :currency, :string, format: :currency, default: -> { Spree::Store.default.default_currency }
8
10
 
9
11
  def self.description
@@ -203,10 +203,13 @@ module Spree
203
203
  # delivery_unavailable warning per affected line item (parity with the
204
204
  # order-side ensure_available_delivery_rates).
205
205
  def prune_undeliverable_fulfillments!
206
- undeliverable = fulfillments.reload.select { |fulfillment| fulfillment.delivery_rates.empty? }
206
+ candidates = fulfillments.reload.to_a
207
+ ActiveRecord::Associations::Preloader.new(records: candidates, associations: :delivery_rates).call
208
+ undeliverable = candidates.select { |fulfillment| fulfillment.delivery_rates.empty? }
207
209
  return if undeliverable.empty?
208
210
 
209
211
  affected_line_items = undeliverable.flat_map(&:line_items).uniq
212
+ ActiveRecord::Associations::Preloader.new(records: affected_line_items, associations: { variant: :product }).call
210
213
  undeliverable.each(&:destroy)
211
214
  self.warnings |= affected_line_items.map do |line_item|
212
215
  {
@@ -284,12 +287,9 @@ module Spree
284
287
  #
285
288
  # @return [ActiveRecord::Relation<Spree::Variant>]
286
289
  def orderable_variants
287
- products = Spree.products_for_context_service.call(
288
- store: store, channel: channel, customer: customer, company: resolved_company,
289
- base: store.products.not_discontinued_on(channel)
290
- ).value
291
-
292
- Spree::Variant.for_products(products)
290
+ Spree::SalesContext.new(
291
+ store: store, channel: channel, customer: customer, company: resolved_company, resolve_company: false
292
+ ).orderable_variants
293
293
  end
294
294
 
295
295
  # Removes out-of-stock/discontinued items and populates warnings
@@ -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
@@ -245,7 +282,13 @@ module Spree
245
282
  #
246
283
  # @return [ActiveRecord::Relation]
247
284
  def address_book
248
- Spree::Address.where(owner_type: 'Spree::Company', owner_id: self_and_ancestors.map(&:id))
285
+ Spree::Address.not_deleted.where(owner_type: 'Spree::Company', owner_id: self_and_ancestors.map(&:id))
286
+ end
287
+
288
+ protected
289
+
290
+ def preloaded_members_count=(count)
291
+ @members_count = count
249
292
  end
250
293
 
251
294
  private
@@ -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