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
@@ -3,6 +3,7 @@ module Spree
3
3
  has_prefix_id :credit
4
4
 
5
5
  include Spree::SingleStoreResource
6
+ include Spree::ActedBy
6
7
  include Spree::HasCustomFields
7
8
  include Spree::Metadata
8
9
  include Spree::CanonicalDecimals
@@ -25,7 +26,7 @@ module Spree
25
26
  # to attach it to.
26
27
  belongs_to :customer, class_name: "::#{Spree.customer_class}", optional: true
27
28
  include Spree::DeprecatedCustomerAlias
28
- belongs_to :created_by, class_name: Spree.admin_user_class.to_s, foreign_key: 'created_by_id', optional: true
29
+ acted_by :created_by
29
30
  belongs_to :originator, polymorphic: true, optional: true
30
31
  belongs_to :refunded_order, class_name: 'Spree::Order', optional: true,
31
32
  inverse_of: :store_credit_refunds
@@ -40,8 +41,6 @@ module Spree
40
41
  validate :amount_used_less_than_or_equal_to_amount
41
42
  validate :amount_authorized_less_than_or_equal_to_amount
42
43
 
43
- delegate :email, to: :created_by, prefix: true, allow_nil: true
44
-
45
44
  # Redemption order: the oldest credit is spent first.
46
45
  scope :oldest_first, -> { order(:created_at, :id) }
47
46
  # @deprecated Removed in Spree 6.1 — store credit types are gone; use {.oldest_first}.
@@ -110,7 +109,7 @@ module Spree
110
109
 
111
110
  self.whitelisted_ransackable_attributes = %w[customer_id created_by_id amount currency memo]
112
111
  search_by :memo
113
- self.whitelisted_ransackable_associations = %w[customer created_by]
112
+ self.whitelisted_ransackable_associations = %w[customer]
114
113
  self.whitelisted_ransackable_scopes = %w[outstanding from_gift_card]
115
114
  self.ransackable_scope_types = %w[outstanding from_gift_card].index_with('boolean')
116
115
  # Staff notes and who issued the credit are back-office data.
@@ -283,6 +282,7 @@ module Spree
283
282
  amount: amount,
284
283
  customer_id: customer_id,
285
284
  created_by_id: created_by_id,
285
+ created_by_type: created_by_type,
286
286
  currency: currency,
287
287
  memo: credit_allocation_memo,
288
288
  store: store
@@ -393,7 +393,11 @@ module Spree
393
393
  @tax_category ||= if self[:tax_category_id].nil?
394
394
  product.tax_category
395
395
  else
396
- Spree::TaxCategory.find_by(id: self[:tax_category_id]) || product.tax_category
396
+ # The association, so a page of variants loads its
397
+ # categories together; one deleted since it was
398
+ # assigned no longer counts.
399
+ own = super
400
+ own.nil? || own.destroyed? || own.try(:deleted?) ? product.tax_category : own
397
401
  end
398
402
  end
399
403
 
@@ -466,7 +470,7 @@ module Spree
466
470
  # Returns the options text of the variant.
467
471
  # @return [String] the options text of the variant
468
472
  def options_text
469
- @options_text ||= if option_values.loaded?
473
+ @options_text ||= if read_association_in_memory?(:option_values)
470
474
  option_values.sort_by do |ov|
471
475
  ov.option_type.position
472
476
  end.map { |ov| "#{ov.option_type.label}: #{ov.label}" }.to_sentence(words_connector: ', ', two_words_connector: ', ')
@@ -663,7 +667,7 @@ module Spree
663
667
  def price_in(currency)
664
668
  currency = currency&.upcase
665
669
 
666
- price = if read_association_in_memory?(:prices) && prices.any?
670
+ price = if read_association_in_memory?(:prices)
667
671
  prices.detect { |p| p.currency == currency && p.price_list_id.nil? }
668
672
  else
669
673
  prices.base_prices.find_by(currency: currency)
@@ -864,7 +868,12 @@ module Spree
864
868
  # @return [void]
865
869
  def set_stock(count_on_hand, backorderable = nil, stock_location = nil)
866
870
  stock_location ||= default_stock_location
867
- stock_level = stock_levels.find_or_initialize_by(stock_location: stock_location)
871
+ stock_level = if stock_levels.loaded?
872
+ stock_levels.detect { |level| level.stock_location_id == stock_location.id } ||
873
+ stock_levels.build(stock_location: stock_location)
874
+ else
875
+ stock_levels.find_or_initialize_by(stock_location: stock_location)
876
+ end
868
877
  # `unless nil?`, not `if present?` — a caller passing false means it.
869
878
  stock_level.backorderable = backorderable unless backorderable.nil?
870
879
 
@@ -880,7 +889,10 @@ module Spree
880
889
  delta = count_on_hand.to_i - stock_level.count_on_hand.to_i
881
890
  return if delta.zero?
882
891
 
883
- stock_location.adjust(self, delta, reason: Spree::StockMovement.default_adjustment_reason)
892
+ # Through the level already in hand rather than StockLocation#adjust,
893
+ # which would look the same row up again.
894
+ stock_level.stock_movements.create!(quantity: delta, kind: 'adjusted',
895
+ reason: Spree::StockMovement.default_adjustment_reason)
884
896
  end
885
897
 
886
898
  def default_stock_location
@@ -967,6 +979,11 @@ module Spree
967
979
  # Variants carry no store of their own; the product owns it.
968
980
  # @return [Spree::Store, nil]
969
981
  def preference_store
982
+ # The request's store instance when it is this one, so a page of
983
+ # variants reads its settings from one record rather than a copy each.
984
+ current_store = Spree::Current.store
985
+ return current_store if current_store && product&.store_id == current_store.id
986
+
970
987
  product&.store
971
988
  end
972
989
 
@@ -1188,7 +1205,7 @@ module Spree
1188
1205
  end
1189
1206
 
1190
1207
  def set_cost_currency
1191
- self.cost_currency = Spree::Store.default.default_currency if cost_currency.blank?
1208
+ self.cost_currency = (product&.store || Spree::Store.default).default_currency if cost_currency.blank?
1192
1209
  end
1193
1210
 
1194
1211
  # Only the product's own store's locations — a new variant must not grow
@@ -20,6 +20,10 @@ module Spree
20
20
  validates :url, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]), message: :invalid_url }
21
21
  validates :active, inclusion: { in: [true, false] }
22
22
  validate :url_must_not_resolve_to_private_ip, if: -> { !Rails.env.development? && url.present? && url_changed? }
23
+ validate :subscriptions_must_be_event_patterns, if: :will_save_change_to_subscriptions?
24
+
25
+ attribute :subscriptions, default: -> { [] }
26
+ normalizes :subscriptions, apply_to_nil: true, with: ->(value) { Array(value).map { |entry| entry.to_s.strip }.reject(&:empty?).uniq }
23
27
 
24
28
  before_create :generate_secret_key
25
29
  after_create { @reveal_secret_in_response = true }
@@ -83,6 +87,23 @@ module Spree
83
87
  # Number of consecutive failed deliveries before auto-disabling
84
88
  AUTO_DISABLE_THRESHOLD = 15
85
89
 
90
+ MAX_SUBSCRIPTIONS = 200
91
+ MAX_SUBSCRIPTION_LENGTH = 100
92
+ SUBSCRIPTION_SEGMENT = /\A[a-zA-Z0-9_-]+\z/
93
+
94
+ # Whether +value+ is a shape `subscriptions` accepts: `*`, an event name,
95
+ # or a name with `*` in place of whole segments (`order.*`, `*.created`).
96
+ # Anything else — regexp syntax above all — is refused before it is
97
+ # stored, so the matcher only ever sees names and segment wildcards.
98
+ #
99
+ # @param value [Object]
100
+ # @return [Boolean]
101
+ def self.event_pattern?(value)
102
+ return false unless value.is_a?(String) && value.length.between?(1, MAX_SUBSCRIPTION_LENGTH)
103
+
104
+ value.split('.', -1).all? { |segment| segment == '*' || segment.match?(SUBSCRIPTION_SEGMENT) }
105
+ end
106
+
86
107
  # Check if this endpoint is subscribed to a specific event.
87
108
  #
88
109
  # Events whose payload carries a live credential (a password reset token)
@@ -93,16 +114,9 @@ module Spree
93
114
  # @return [Boolean]
94
115
  def subscribed_to?(event_name)
95
116
  return subscriptions.to_a.include?(event_name) if Spree::Events.catalog.credential?(event_name)
96
- return true if subscriptions.blank? || subscriptions.include?('*')
97
-
98
- subscriptions.any? do |subscription|
99
- if subscription.include?('*')
100
- pattern = Regexp.escape(subscription).gsub('\*', '.*')
101
- event_name.match?(Regexp.new("^#{pattern}$"))
102
- else
103
- subscription == event_name
104
- end
105
- end
117
+ return true if subscriptions.blank?
118
+
119
+ subscriptions.any? { |subscription| Spree::Event.matches?(event_name, subscription) }
106
120
  end
107
121
 
108
122
  # The credential-carrying events this endpoint names.
@@ -191,6 +205,16 @@ module Spree
191
205
  self.secret_key ||= SecureRandom.hex(32)
192
206
  end
193
207
 
208
+ def subscriptions_must_be_event_patterns
209
+ entries = subscriptions.to_a
210
+ errors.add(:subscriptions, :too_many, count: MAX_SUBSCRIPTIONS) if entries.size > MAX_SUBSCRIPTIONS
211
+
212
+ rejected = entries.reject { |entry| self.class.event_pattern?(entry) }
213
+ return if rejected.empty?
214
+
215
+ errors.add(:subscriptions, :invalid_pattern, entries: rejected.first(5).map { |entry| entry.truncate(40) }.join(', '))
216
+ end
217
+
194
218
  def clear_disabled_state_when_reactivated
195
219
  return unless will_save_change_to_active? && active
196
220
 
@@ -203,12 +227,22 @@ module Spree
203
227
 
204
228
  uri = URI.parse(url)
205
229
  blacklist = SsrfFilter::IPV4_BLACKLIST + SsrfFilter::IPV6_BLACKLIST
206
- addresses = Resolv.getaddresses(uri.host)
230
+ addresses = host_addresses(uri.host)
207
231
  if addresses.any? { |addr| blacklist.any? { |range| range.include?(IPAddr.new(addr)) } }
208
232
  errors.add(:url, :internal_address_not_allowed)
209
233
  end
210
234
  rescue URI::InvalidURIError, Resolv::ResolvError, IPAddr::InvalidAddressError, ArgumentError
211
235
  # URI format validation handles invalid URLs; DNS failures are not SSRF
212
236
  end
237
+
238
+ # A literal address first — including the forms a resolver answers
239
+ # nothing for, such as a bracketed IPv6 address, `127.1`, or a decimal or
240
+ # hex IPv4 — then DNS for a hostname.
241
+ def host_addresses(host)
242
+ literal = host.to_s.delete_prefix('[').delete_suffix(']')
243
+ Socket.getaddrinfo(literal, nil, nil, Socket::SOCK_STREAM, nil, Socket::AI_NUMERICHOST).map { |entry| entry[3] }.uniq
244
+ rescue SocketError
245
+ Resolv.getaddresses(host)
246
+ end
213
247
  end
214
248
  end
@@ -31,6 +31,9 @@ module Spree
31
31
  items = items.reject(&:manual_price?)
32
32
  return success([]) if items.empty?
33
33
 
34
+ # One read of every line's prices rather than one per variant.
35
+ ActiveRecord::Associations::Preloader.new(records: items.filter_map(&:variant).uniq, associations: :prices).call
36
+
34
37
  resolutions = items.filter_map do |line_item|
35
38
  price = resolve_price(line_item, cart)
36
39
  next if price.blank?
@@ -270,8 +270,10 @@ module Spree
270
270
  # describe what they held before the split, and the new one holds nothing
271
271
  # at all.
272
272
  def restate_freight_summaries(*fulfillments)
273
+ selected_rates = Spree::DeliveryRate.where(fulfillment_id: fulfillments.map(&:id), selected: true).order(:cost).to_a
274
+
273
275
  fulfillments.each do |fulfillment|
274
- rate = fulfillment.reload.selected_delivery_rate
276
+ rate = selected_rates.find { |selected_rate| selected_rate.fulfillment_id == fulfillment.id }
275
277
  next if rate.nil?
276
278
 
277
279
  metadata = rate.metadata
@@ -50,7 +50,8 @@ module Spree
50
50
  def apply(catalog, product, values)
51
51
  minimum = normalize(values[:minimum_order_quantity])
52
52
  multiple = normalize(values[:order_multiple])
53
- variant_ids = product.variants.ids
53
+ variants = product.variants.to_a
54
+ variant_ids = variants.map(&:id)
54
55
 
55
56
  # Both blank is a deletion: the product falls back to the catalog's
56
57
  # own default, which is what an empty pair means on the row.
@@ -59,8 +60,12 @@ module Spree
59
60
  return nil
60
61
  end
61
62
 
62
- variant_ids.each do |variant_id|
63
- rule = catalog.quantity_rules.find_or_initialize_by(variant_id: variant_id)
63
+ existing_rules = catalog.quantity_rules.where(variant_id: variant_ids).index_by(&:variant_id)
64
+
65
+ variants.each do |variant|
66
+ rule = existing_rules[variant.id] || catalog.quantity_rules.build
67
+ # The loaded variant, so the store check reads its product in memory.
68
+ rule.variant = variant
64
69
  rule.assign_attributes(minimum_order_quantity: minimum, order_multiple: multiple)
65
70
  return rule unless rule.save
66
71
  end
@@ -22,7 +22,7 @@ module Spree
22
22
  def group_class = Spree::Collection
23
23
 
24
24
  def refresh_group_counters(collection_ids)
25
- collection_ids.each { |id| Spree::Collection.reset_counters(id, :product_collections) }
25
+ Spree::Collection.reset_counters_in_bulk(collection_ids, :product_collections)
26
26
  end
27
27
  end
28
28
  end
@@ -44,9 +44,7 @@ module Spree
44
44
 
45
45
  # counter caches — bulk insert/delete bypass the ProductCollection counter_cache callbacks
46
46
  Spree::Collection.reset_counters(collection.id, :product_collections)
47
- all_product_ids = (previous_products_ids + product_ids_to_insert).uniq
48
- existing_product_ids = Spree::Product.where(id: all_product_ids).pluck(:id)
49
- existing_product_ids.each { |id| Spree::Product.reset_counters(id, :product_collections) }
47
+ Spree::Product.reset_counters_in_bulk(previous_products_ids + product_ids_to_insert, :product_collections)
50
48
 
51
49
  success(collection)
52
50
  end
@@ -22,7 +22,7 @@ module Spree
22
22
  def group_class = Spree::Collection
23
23
 
24
24
  def refresh_group_counters(collection_ids)
25
- collection_ids.each { |id| Spree::Collection.reset_counters(id, :product_collections) }
25
+ Spree::Collection.reset_counters_in_bulk(collection_ids, :product_collections)
26
26
  end
27
27
  end
28
28
  end
@@ -54,8 +54,16 @@ module Spree
54
54
  @existed_subscription ||= Spree::NewsletterSubscriber.verified.find_by(email: email, store: current_store)
55
55
  end
56
56
 
57
+ # The account the subscription belongs to: the signed-in customer only
58
+ # when the address is theirs. Linking it to whoever is signed in would
59
+ # hand them another person's subscription — and with it the right to
60
+ # cancel it.
57
61
  def known_user
58
- @known_user ||= current_user || Spree.customer_class.find_by(email: email)
62
+ @known_user ||= if current_user&.email.to_s.casecmp?(email.to_s)
63
+ current_user
64
+ else
65
+ Spree.customer_class.find_by(email: email)
66
+ end
59
67
  end
60
68
  end
61
69
  end
@@ -82,14 +82,16 @@ module Spree
82
82
  )
83
83
  captured = payment.completed?
84
84
 
85
+ allocated_order_ids = Spree::PaymentSplit.where(payment_id: payment.id, order_id: orders.map(&:id)).pluck(:order_id).to_set
86
+
85
87
  orders.each_with_index do |order, index|
86
- split = Spree::PaymentSplit.find_or_initialize_by(payment_id: payment.id, order_id: order.id)
87
- next if split.persisted?
88
+ next if allocated_order_ids.include?(order.id)
88
89
 
89
90
  share = Spree::Money::Rounding.from_minor_units(shares[index], currency)
90
- # So re-summing its payment total on save reuses this instance rather
91
- # than loading a second copy of the row per share.
92
- split.order = order
91
+ # The payment and order are handed over rather than looked up, so
92
+ # validating and re-summing each share reuses these instances
93
+ # instead of loading a copy per share.
94
+ split = Spree::PaymentSplit.new(payment: payment, order: order)
93
95
  split.currency = payment.currency
94
96
  split.authorized_amount = share
95
97
  split.captured_amount = captured ? share : 0
@@ -14,6 +14,7 @@ module Spree
14
14
 
15
15
  items_param = @params.delete(:items)
16
16
  address_params = extract_address_params
17
+ scope_store_references
17
18
 
18
19
  # requires_new: the rescue below sits outside the block, so joining a
19
20
  # caller's open transaction (the API's order lock) would let it commit
@@ -63,6 +64,24 @@ module Spree
63
64
  end.compact_blank
64
65
  end
65
66
 
67
+ # Named by id in the payload and read through the order's own store, as
68
+ # Orders::Create does: the model only checks that the record exists, so
69
+ # another store's market, channel or warehouse would otherwise be
70
+ # accepted.
71
+ STORE_REFERENCES = {
72
+ market_id: :markets,
73
+ channel_id: :channels,
74
+ preferred_stock_location_id: :stock_locations
75
+ }.freeze
76
+
77
+ def scope_store_references
78
+ STORE_REFERENCES.each do |key, association|
79
+ next if @params[key].blank?
80
+
81
+ @params[key] = @order.store.public_send(association).find_by_param!(@params[key]).id
82
+ end
83
+ end
84
+
66
85
  def assign_addresses(address_params)
67
86
  address_params.each do |association, attributes|
68
87
  @order.public_send(:"#{association}_attributes=", attributes)
@@ -7,9 +7,15 @@ module Spree
7
7
  private
8
8
 
9
9
  def add_memberships(groups, products)
10
- rows = groups.pluck(:id).flat_map do |group_id|
11
- position = membership_class.where(group_key => group_id).count
12
- products.pluck(:id).map do |product_id|
10
+ group_ids = groups.pluck(:id)
11
+ product_ids = products.pluck(:id)
12
+ # Every group's current size in one grouped count, rather than a probe
13
+ # per group for the position to append at.
14
+ sizes = membership_class.where(group_key => group_ids).group(group_key).count
15
+
16
+ rows = group_ids.flat_map do |group_id|
17
+ position = sizes.fetch(group_id, 0)
18
+ product_ids.map do |product_id|
13
19
  {
14
20
  group_key => group_id, product_id: product_id, position: (position += 1),
15
21
  created_at: Time.current, updated_at: Time.current
@@ -18,7 +24,7 @@ module Spree
18
24
  end
19
25
  membership_class.insert_all(rows)
20
26
 
21
- refresh_after_membership_change(groups.pluck(:id), products)
27
+ refresh_after_membership_change(group_ids, products)
22
28
  end
23
29
 
24
30
  # Removes the products, then re-packs the remaining positions from 1.
@@ -28,8 +34,12 @@ module Spree
28
34
  ApplicationRecord.transaction do
29
35
  membership_class.where(group_key => group_ids, product_id: products.pluck(:id)).delete_all
30
36
 
37
+ # What survived the delete, for every group in one read.
38
+ remaining = membership_class.where(group_key => group_ids).order(:position).pluck(group_key, :product_id).
39
+ group_by(&:first).transform_values { |pairs| pairs.map(&:last) }
40
+
31
41
  rows = group_ids.flat_map do |group_id|
32
- membership_class.where(group_key => group_id).order(:position).pluck(:product_id).map.with_index(1) do |product_id, position|
42
+ remaining.fetch(group_id, []).map.with_index(1) do |product_id, position|
33
43
  {
34
44
  group_key => group_id, product_id: product_id, position: position,
35
45
  created_at: Time.current, updated_at: Time.current
@@ -46,7 +56,7 @@ module Spree
46
56
  # search index are refreshed here.
47
57
  def refresh_after_membership_change(group_ids, products)
48
58
  product_ids = products.pluck(:id)
49
- product_ids.each { |id| Spree::Product.reset_counters(id, membership_class.model_name.element.pluralize.to_sym) }
59
+ Spree::Product.reset_counters_in_bulk(product_ids, membership_class.model_name.element.pluralize.to_sym)
50
60
  refresh_group_counters(group_ids)
51
61
 
52
62
  Spree::Product.where(id: product_ids).touch_all
@@ -42,7 +42,7 @@ module Spree
42
42
 
43
43
  def duplicate_variant(variant, include_images)
44
44
  new_variant = variant.dup
45
- new_variant.sku = sku_generator(new_variant.sku)
45
+ new_variant.sku = sku_generator(variant)
46
46
  new_variant.deleted_at = nil
47
47
  new_variant.option_values = variant.option_values.map { |option_value| option_value }
48
48
  new_variant.prices = duplicate_prices(variant.prices)
@@ -82,10 +82,18 @@ module Spree
82
82
  new_image
83
83
  end
84
84
 
85
- def sku_generator(sku)
86
- return '' if sku.blank?
85
+ def sku_generator(variant)
86
+ return '' if variant.sku.blank?
87
87
 
88
- "COPY OF #{Variant.unscoped.where('sku like ?', "%#{sku}").order(:created_at).last.sku}"
88
+ store_product_ids = Product.with_deleted.where(store_id: variant.store_id).select(:id)
89
+ # Matched the way the SKU uniqueness check compares, without regard
90
+ # to case, and within the same seller scope it reads, so the name taken
91
+ # from the latest match is one this seller may use.
92
+ sku_pattern = Variant.arel_table[:sku].matches("%#{Variant.sanitize_sql_like(variant.sku)}", '\\', false)
93
+ latest_match = Variant.with_deleted.for_seller(variant.resolved_seller_id).
94
+ where(product_id: store_product_ids).where(sku_pattern).order(:created_at, :id).last
95
+
96
+ "COPY OF #{(latest_match || variant).sku}"
89
97
  end
90
98
 
91
99
  def duplicate_error_message(new_product)
@@ -15,13 +15,16 @@ module Spree
15
15
  ORDERED_FILTER_PREDICATES = (TEXT_FILTER_PREDICATES | NUMBER_FILTER_PREDICATES).
16
16
  sort_by { |predicate| -predicate.length }.freeze
17
17
 
18
- attr_reader :store
18
+ attr_reader :store, :audience
19
19
 
20
20
  # @param store [Spree::Store] the store whose custom-field schema this
21
21
  # describes — definitions are store-owned, so two stores have their own
22
22
  # filter vocabularies.
23
- def initialize(store)
23
+ # @param audience [Symbol, nil] who filters: `:store` (shoppers) sees only
24
+ # storefront-visible definitions; `nil` is the back office
25
+ def initialize(store, audience: nil)
24
26
  @store = store
27
+ @audience = audience
25
28
  end
26
29
 
27
30
  # @return [Hash{String => Spree::CustomFieldDefinition}]
@@ -134,8 +137,13 @@ module Spree
134
137
 
135
138
  private
136
139
 
140
+ # A definition hidden from the storefront stays out of its filter and
141
+ # sort vocabulary too: the serializer withholds the value, and a sort or
142
+ # a range filter on it would hand that value back one comparison at a
143
+ # time.
137
144
  def product_definitions
138
145
  scope = store.custom_field_definitions.for_resource_type('Spree::Product')
146
+ scope = scope.storefront_visible if audience == :store
139
147
  scope.where(searchable: true).or(scope.where(sortable: true))
140
148
  end
141
149
 
@@ -32,6 +32,11 @@ module Spree
32
32
  return failure(stock_level, I18n.t('spree.stock_level.errors.count_on_hand_not_an_integer')) if count_on_hand.present? && target.nil?
33
33
  return failure(stock_level, I18n.t('spree.stock_level.errors.adjustment_not_an_integer')) if adjustment.present? && delta.nil?
34
34
 
35
+ # Read before locking, which reloads the level and with it drops the
36
+ # variant and location a bulk caller loaded for every row at once.
37
+ stock_location = stock_level.stock_location
38
+ variant = stock_level.variant
39
+
35
40
  # Locked around the read: the delta is worked out from the count this
36
41
  # caller first saw, so two admins correcting the same level at once
37
42
  # would otherwise each measure against a shelf the other had already
@@ -42,12 +47,14 @@ module Spree
42
47
 
43
48
  # Through the location's own mover rather than building a movement
44
49
  # by hand: it types the row as an adjustment and records the reason,
45
- # so a correction reads like every other change in the history.
46
- stock_level.stock_location.adjust(stock_level.variant, move,
47
- reason: Spree::StockMovement.adjustment_reason_text(reason))
50
+ # so a correction reads like every other change in the history. The
51
+ # movement writes through this locked instance, which leaves it
52
+ # current without another reload.
53
+ stock_location.adjust(variant, move, reason: Spree::StockMovement.adjustment_reason_text(reason),
54
+ stock_level: stock_level)
48
55
  end
49
56
 
50
- success(stock_level.reload)
57
+ success(stock_level)
51
58
  end
52
59
 
53
60
  private
@@ -69,7 +69,7 @@ module Spree
69
69
  private
70
70
 
71
71
  def build_targets(cart)
72
- cart.line_items.includes(variant: { stock_levels: :stock_location }).filter_map do |line_item|
72
+ cart.line_items.includes(variant: [:product, { stock_levels: :stock_location }]).filter_map do |line_item|
73
73
  variant = line_item.variant
74
74
  next unless variant&.should_track_inventory?
75
75
 
@@ -31,13 +31,17 @@ module Spree
31
31
  @currency = resolve_currency(currency, country)
32
32
  @locale = resolve_locale(locale, country)
33
33
 
34
- ApplicationRecord.transaction do
35
- update_default_market
36
- provision_stock_location
37
- provision_delivery_zones
38
- provision_package_type
39
- provision_pickup
40
- restate_seeded_calculators
34
+ # Defaults are bookkeeping, not merchant actions — webhooks and
35
+ # subscribers should not hear about each zone and method created here.
36
+ Spree::Events.disable do
37
+ ApplicationRecord.transaction do
38
+ update_default_market
39
+ provision_stock_location
40
+ provision_delivery_zones
41
+ provision_package_type
42
+ provision_pickup
43
+ restate_seeded_calculators
44
+ end
41
45
  end
42
46
 
43
47
  store.reload
@@ -10,8 +10,15 @@ module Spree
10
10
  # @param context [String] default: 'tags'
11
11
  # @return [Spree::ServiceModule::Base::Result]
12
12
  def call(tag_names: [], records: [], context: 'tags')
13
- tags = tag_names.map do |tag_name|
14
- ActsAsTaggableOn::Tag.find_or_create_by(name: tag_name.strip)
13
+ names = tag_names.map(&:strip).reject(&:blank?).uniq
14
+ return if names.empty?
15
+
16
+ # One read for the names that already exist, then a create only for
17
+ # the ones that do not. `find_or_create_by` per name is a query per
18
+ # tag, which a bulk tagging pays on every name in the payload.
19
+ existing = ActsAsTaggableOn::Tag.where(name: names).index_by(&:name)
20
+ tags = names.map do |name|
21
+ existing[name] || ActsAsTaggableOn::Tag.create_or_find_by(name: name)
15
22
  end
16
23
 
17
24
  record_class = records.first.class
@@ -27,17 +34,20 @@ module Spree
27
34
 
28
35
  private
29
36
 
37
+ # Mirrors the gem's own tagging save, which stores the taggable's tenant
38
+ # (its `store_id` for store-owned records) as a string on each tagging.
30
39
  def taggings_attributes(tags, records, context:, record_class:)
31
- records.pluck(:id).map do |record_id|
40
+ records.flat_map do |record|
32
41
  tags.map do |tag|
33
42
  {
34
- taggable_id: record_id,
43
+ taggable_id: record.id,
35
44
  taggable_type: record_class.to_s,
36
45
  context: context,
37
- tag_id: tag.id
46
+ tag_id: tag.id,
47
+ tenant: record.taggable_tenant&.to_s
38
48
  }
39
49
  end
40
- end.flatten.compact
50
+ end
41
51
  end
42
52
  end
43
53
  end