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
@@ -59,8 +59,11 @@ module Spree
59
59
  # rank order so the Prioritizer's first-package-wins-on-hand logic
60
60
  # honors the routing decision.
61
61
  def build_packages(locations)
62
+ stock_levels = Spree::Stock::Packer.stock_levels_by_location(locations, requested_variant_ids)
63
+
62
64
  locations.flat_map do |location|
63
- Spree::Stock::Packer.new(location, inventory_units, Spree.stock_splitters, owner: order).packages
65
+ Spree::Stock::Packer.new(location, inventory_units, Spree.stock_splitters, owner: order,
66
+ stock_levels: stock_levels.fetch(location.id, {})).packages
64
67
  end
65
68
  end
66
69
 
@@ -38,6 +38,21 @@ module Spree
38
38
  # flip or a catalog attach/detach changes which lists match generically.
39
39
  after_commit -> { Spree::Current.price_lists = nil }
40
40
 
41
+ # Distinct products per list for a page of lists, in one grouped query.
42
+ # @param price_list_ids [Array<Integer>]
43
+ # @return [Hash{Integer => Integer}] keyed by price list id; lists with no products are absent
44
+ def self.products_count_by_list(price_list_ids)
45
+ where(id: price_list_ids).joins(:variants).group(arel_table[:id]).
46
+ distinct.count(Spree::Variant.arel_table[:product_id])
47
+ end
48
+
49
+ # Prices per list for a page of lists, in one grouped query.
50
+ # @param price_list_ids [Array<Integer>]
51
+ # @return [Hash{Integer => Integer}] keyed by price list id; lists with no prices are absent
52
+ def self.prices_count_by_list(price_list_ids)
53
+ Spree::Price.where(price_list_id: price_list_ids).group(:price_list_id).count
54
+ end
55
+
41
56
  # Flat-payload writer for `rules`. See
42
57
  # {Spree::TypedAssociations#assign_typed_association}.
43
58
  def rules=(rows)
@@ -64,8 +64,8 @@ module Spree
64
64
  def publication_for(channel)
65
65
  return nil unless channel
66
66
 
67
- if product_publications.loaded?
68
- product_publications.find { |p| p.channel_id == channel.id }
67
+ if read_association_in_memory?(:product_publications)
68
+ product_publications.find { |publication| publication.channel_id == channel.id }
69
69
  else
70
70
  product_publications.find_by(channel_id: channel.id)
71
71
  end
@@ -122,10 +122,16 @@ module Spree
122
122
  end
123
123
 
124
124
  def apply_product_publications(publications_params)
125
+ rows = publications_params.map { |publication_data| publication_data.to_h.with_indifferent_access }
125
126
  publication_ids_in_payload = []
126
127
 
127
- publications_params.each do |publication_data|
128
- publication_data = publication_data.to_h.with_indifferent_access
128
+ # The product's publications and every channel the payload names are
129
+ # read once, rather than a lookup per row.
130
+ named_channel_ids = rows.filter_map { |row| decode_publication_channel_id(row[:channel_id]) if row[:id].blank? }
131
+ channels_by_id = named_channel_ids.empty? ? {} : publishable_channels.where(id: named_channel_ids).index_by { |channel| channel.id.to_s }
132
+ product_publications.load
133
+
134
+ rows.each do |publication_data|
129
135
  publication_id = publication_data.delete(:id)
130
136
  channel_id = decode_publication_channel_id(publication_data[:channel_id])
131
137
 
@@ -133,7 +139,7 @@ module Spree
133
139
  decoded_id = Spree::PrefixedId.prefixed_id?(publication_id) ?
134
140
  Spree::PrefixedId.decode_prefixed_id(publication_id) :
135
141
  publication_id
136
- publication = product_publications.find_by(id: decoded_id)
142
+ publication = product_publications.detect { |existing| existing.id.to_s == decoded_id.to_s }
137
143
  next unless publication
138
144
 
139
145
  # Channel is immutable; ignore any rebind attempt.
@@ -142,19 +148,21 @@ module Spree
142
148
  elsif channel_id.present?
143
149
  # Only publish to channels owned by the product's store; ignore
144
150
  # ids referencing another store's channel.
145
- channel = publishable_channels.find_by(id: channel_id)
151
+ channel = channels_by_id[channel_id.to_s]
146
152
  next unless channel
147
153
 
148
154
  # Upsert by channel_id so repeat submissions are idempotent
149
155
  # against the unique (product_id, channel_id) index.
150
- publication = product_publications.find_or_initialize_by(channel_id: channel.id)
156
+ publication = product_publications.detect { |existing| existing.channel_id == channel.id } ||
157
+ product_publications.build(channel: channel)
151
158
  publication.assign_attributes(publication_data.slice(:published_at, :unpublished_at))
152
159
  publication.save!
153
160
  publication_ids_in_payload << publication.id
154
161
  end
155
162
  end
156
163
 
157
- product_publications.where.not(id: publication_ids_in_payload).destroy_all
164
+ removed = product_publications.reject { |publication| publication_ids_in_payload.include?(publication.id) }
165
+ product_publications.destroy(*removed) if removed.any?
158
166
  end
159
167
 
160
168
  def decode_publication_channel_id(value)
@@ -277,7 +277,9 @@ module Spree
277
277
  # the search. Definitions are store-owned, so the caller says which store
278
278
  # is searching; `Spree::Current.store` is the request's answer and a
279
279
  # background caller (an export, say) passes its own.
280
- scope :search, ->(query, store = Spree::Current.store) {
280
+ # @param audience [Symbol, nil] `:store` keeps a shopper's search off the
281
+ # custom fields hidden from the storefront; `nil` is the back office
282
+ scope :search, ->(query, store = Spree::Current.store, audience: nil) {
281
283
  next none if query.blank?
282
284
 
283
285
  product_ids = Spree::Variant.search_by_product_name_or_sku(query).pluck(:product_id)
@@ -285,6 +287,7 @@ module Spree
285
287
  searchable_definitions = store.custom_field_definitions.
286
288
  for_resource_type('Spree::Product').
287
289
  searchable
290
+ searchable_definitions = searchable_definitions.storefront_visible if audience == :store
288
291
 
289
292
  # Only pay for the custom_field scan when some definition opts into search;
290
293
  # the leading-wildcard LIKE over spree_custom_fields can't use an index.
@@ -494,7 +497,9 @@ module Spree
494
497
  def purchasable?
495
498
  return @purchasable unless @purchasable.nil?
496
499
 
497
- @purchasable = sellable_variants.any?(&:purchasable?)
500
+ variants_to_check = sellable_variants
501
+ Spree::Stock::Quantifier.preload(variants_to_check)
502
+ @purchasable = variants_to_check.any?(&:purchasable?)
498
503
  end
499
504
 
500
505
  def in_stock?
@@ -165,7 +165,7 @@ module Spree
165
165
  # more than one step (part to store credit, part to the card), and store
166
166
  # credit is its own ledger rather than a Spree::Refund row.
167
167
  def refunded_total
168
- refunds.sum(:amount) + store_credits.sum(:amount)
168
+ ledger_total(:refunds) + ledger_total(:store_credits)
169
169
  end
170
170
 
171
171
  def refundable_total
@@ -187,5 +187,12 @@ module Spree
187
187
  def taxed_lines
188
188
  return_line_items
189
189
  end
190
+
191
+ private
192
+
193
+ def ledger_total(association_name)
194
+ rows = public_send(association_name)
195
+ read_association_in_memory?(association_name) ? rows.sum(0.to_d, &:amount) : rows.sum(:amount)
196
+ end
190
197
  end
191
198
  end
@@ -5,7 +5,7 @@ module Spree
5
5
  # attributes. `price` also sorts descending as `-price`.
6
6
  SORT_FIELDS = %w[price best_selling manual].freeze
7
7
 
8
- attr_reader :store
8
+ attr_reader :store, :audience
9
9
 
10
10
  # Whether this provider requires background indexing jobs.
11
11
  # Override in subclasses. Database provider returns false.
@@ -13,13 +13,18 @@ module Spree
13
13
  false
14
14
  end
15
15
 
16
- def initialize(store)
16
+ # @param store [Spree::Store]
17
+ # @param audience [Symbol, nil] who searches: `:store` (shoppers) limits
18
+ # filters and sorts to what the storefront may see; `nil` is the back
19
+ # office
20
+ def initialize(store, audience: :store)
17
21
  @store = store
22
+ @audience = audience
18
23
  end
19
24
 
20
25
  # @return [CustomFieldSchema]
21
26
  def custom_field_schema
22
- @custom_field_schema ||= CustomFieldSchema.new(store)
27
+ @custom_field_schema ||= CustomFieldSchema.new(store, audience: audience)
23
28
  end
24
29
 
25
30
  # Deprecated 6.0 name, removed in 6.1.
@@ -7,14 +7,6 @@ module Spree
7
7
  'best_selling' => :by_best_selling
8
8
  }.freeze
9
9
 
10
- # @param store [Spree::Store]
11
- # @param audience [Symbol, nil] who filters: `:store` (shoppers) limits
12
- # filters to what the storefront may query; `nil` is the back office
13
- def initialize(store, audience: :store)
14
- super(store)
15
- @audience = audience
16
- end
17
-
18
10
  def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
19
11
  filters = filters.is_a?(Hash) ? filters.dup : {}
20
12
  option_value_ids = filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids)
@@ -73,14 +65,14 @@ module Spree
73
65
  private
74
66
 
75
67
  def apply_search_and_filters(scope, query: nil, filters: {})
76
- scope = scope.search(query, store) if query.present?
68
+ scope = scope.search(query, store, audience: audience) if query.present?
77
69
 
78
70
  filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
79
71
  scope, filters = scope.with_custom_field_filters(filters, schema: custom_field_schema)
80
72
 
81
73
  ransack_filters = sanitize_filters(filters)
82
74
  if ransack_filters.present?
83
- search = scope.ransack(ransack_filters, auth_object: @audience)
75
+ search = scope.ransack(ransack_filters, auth_object: audience)
84
76
  scope = search.result(distinct: true)
85
77
  end
86
78
 
@@ -310,10 +310,7 @@ module Spree
310
310
  #
311
311
  # @return [Array<Spree::SellerBalance>]
312
312
  def balances
313
- pairs = seller_transfers.where.not(status: 'failed').
314
- distinct.pluck(:currency, :settled_currency)
315
-
316
- pairs.sort.map { |currency, settlement| Spree::SellerBalance.for(self, currency, settlement) }
313
+ Spree::SellerBalance.all_for(self)
317
314
  end
318
315
 
319
316
  # The seller's account with whichever provider pays them — a Stripe Connect
@@ -399,6 +396,10 @@ module Spree
399
396
  # returns; falls back to any active one, since a seller with none marked
400
397
  # still has to receive what customers send back.
401
398
  def returns_location
399
+ if stock_locations.loaded? || lazy_preload_context
400
+ return @returns_location ||= returns_location_from_loaded
401
+ end
402
+
402
403
  @returns_location ||= stock_locations.active.returns_enabled.order_default.first ||
403
404
  stock_locations.active.order_default.first
404
405
  end
@@ -417,9 +418,24 @@ module Spree
417
418
  location.address
418
419
  end
419
420
 
421
+ # A team is small, so on a list its members are loaded for every seller
422
+ # at once rather than counted one seller at a time.
423
+ #
424
+ # @return [Integer] how many people are on this seller's team
425
+ def users_count
426
+ users.loaded? || lazy_preload_context ? users.to_a.size : users.count
427
+ end
428
+
420
429
  # @return [Integer] how many products this seller lists
421
430
  def products_count
422
- @products_count ||= products.count
431
+ return @products_count if @products_count
432
+
433
+ siblings = lazy_preload_context&.records&.grep(self.class)
434
+ return @products_count = products.count if siblings.blank?
435
+
436
+ counts = Spree::Product.where(seller_id: siblings.map(&:id)).group(:seller_id).count
437
+ siblings.each { |seller| seller.preloaded_products_count = counts.fetch(seller.id, 0) }
438
+ @products_count
423
439
  end
424
440
 
425
441
  # Where this seller stands against the marketplace's checklist
@@ -465,8 +481,22 @@ module Spree
465
481
  onboarding_requirements.none?(&:blocking?)
466
482
  end
467
483
 
484
+ protected
485
+
486
+ def preloaded_products_count=(count)
487
+ @products_count = count
488
+ end
489
+
468
490
  private
469
491
 
492
+ # The same choice as the query in {#returns_location}, made over the
493
+ # preloaded locations.
494
+ def returns_location_from_loaded
495
+ active_locations = stock_locations.select(&:active?).sort_by { |location| [location.default? ? 0 : 1, location.name.to_s] }
496
+
497
+ active_locations.find(&:returns_enabled?) || active_locations.first
498
+ end
499
+
470
500
 
471
501
  def normalize_slug
472
502
  self.slug = (slug.presence || name).to_s.parameterize.presence
@@ -57,6 +57,34 @@ module Spree
57
57
  )
58
58
  end
59
59
 
60
+ # Every position the seller holds, read with one grouped query per figure
61
+ # rather than four per position. See {Spree::Seller#balances}.
62
+ #
63
+ # @param seller [Spree::Seller]
64
+ # @return [Array<Spree::SellerBalance>]
65
+ def self.all_for(seller)
66
+ transfers = seller.seller_transfers.where.not(status: 'failed')
67
+ amounts = transfers.group(:currency, :settled_currency, :status).sum(:amount)
68
+ payable = transfers.completed.group(:currency, :settled_currency).sum(:settled_amount)
69
+ paid = transfers.completed.joins(:payout).merge(Spree::SellerPayout.completed).
70
+ group(:currency, :settled_currency).sum(:settled_amount)
71
+
72
+ pairs = amounts.keys.map { |currency, settlement_currency, _status| [currency, settlement_currency] }.uniq.sort
73
+ pairs.map do |pair|
74
+ amount_in = ->(*statuses) { statuses.sum(BigDecimal('0')) { |status| amounts.fetch([*pair, status], 0) } }
75
+
76
+ new(
77
+ seller: seller,
78
+ currency: pair.first,
79
+ settlement_currency: pair.last,
80
+ earned: amount_in.call('completed'),
81
+ pending: amount_in.call('pending', 'processing', 'unresolved'),
82
+ payable: payable.fetch(pair, 0),
83
+ paid: paid.fetch(pair, 0)
84
+ )
85
+ end
86
+ end
87
+
60
88
  # What the marketplace still owes, in the currency it can actually be sent
61
89
  # in.
62
90
  #
@@ -11,9 +11,27 @@ module Spree
11
11
  # has shared at least one of theirs.
12
12
  class DeliveryMethod < Spree::SellerRequirement
13
13
  def met_by_seller?(seller)
14
- return true if seller.delivery_methods.storefront_visible.exists?
14
+ return true if ships_own_methods?(seller)
15
15
 
16
- store.delivery_methods.shared_with_sellers.storefront_visible.exists?
16
+ operator_shares_methods?
17
+ end
18
+
19
+ private
20
+
21
+ def ships_own_methods?(seller)
22
+ if seller.read_association_in_memory?(:delivery_methods)
23
+ seller.delivery_methods.any?(&:storefront_visible?)
24
+ else
25
+ seller.delivery_methods.storefront_visible.exists?
26
+ end
27
+ end
28
+
29
+ # The same answer for every seller of the store, so a list of them
30
+ # asks once.
31
+ def operator_shares_methods?
32
+ return @operator_shares_methods if defined?(@operator_shares_methods)
33
+
34
+ @operator_shares_methods = store.delivery_methods.shared_with_sellers.storefront_visible.exists?
17
35
  end
18
36
  end
19
37
  end
@@ -28,11 +28,14 @@ module Spree
28
28
  end
29
29
 
30
30
  def build_packages(packages = [])
31
- stock_locations_with_requested_variants.each do |stock_location|
31
+ stock_locations = stock_locations_with_requested_variants.to_a
32
+ stock_levels = Packer.stock_levels_by_location(stock_locations, requested_variant_ids)
33
+
34
+ stock_locations.each do |stock_location|
32
35
  units = allocatable_units_for(stock_location)
33
36
  next if units.empty?
34
37
 
35
- packer = build_packer(stock_location, units)
38
+ packer = build_packer(stock_location, units, stock_levels: stock_levels.fetch(stock_location.id, {}))
36
39
  packages += packer.packages
37
40
  end
38
41
 
@@ -114,8 +117,8 @@ module Spree
114
117
  @estimator ||= Estimator.new(order)
115
118
  end
116
119
 
117
- def build_packer(stock_location, inventory_units)
118
- Packer.new(stock_location, inventory_units, splitters(stock_location), owner: order)
120
+ def build_packer(stock_location, inventory_units, stock_levels: nil)
121
+ Packer.new(stock_location, inventory_units, splitters(stock_location), owner: order, stock_levels: stock_levels)
119
122
  end
120
123
 
121
124
  def splitters(_stock_location)
@@ -162,6 +162,12 @@ module Spree
162
162
  methods = package.eligible_delivery_methods
163
163
  # Storeless orders exist only in specs; real orders always carry one.
164
164
  methods = methods.merge(order.store.delivery_methods) if order.store
165
+ # Read for every method below and when its rates are built. Through the
166
+ # preloader because an overridden package may hand back an Array.
167
+ methods = methods.to_a
168
+ ActiveRecord::Associations::Preloader.new(
169
+ records: methods, associations: %i[delivery_method_rules calculator services delivery_zone]
170
+ ).call
165
171
 
166
172
  # On a marketplace, a package's seller is its stock location's, and it
167
173
  # is quoted by that seller's own methods plus whatever the operator
@@ -11,16 +11,28 @@ module Spree
11
11
  # rather than pointing at whatever order happens to share the cart's
12
12
  # id — a stranger's order whose currency and ship address would then
13
13
  # stand in for the customer's.
14
- @order.line_items.map do |line_item|
14
+ line_items = @order.line_items.to_a
15
+ # Allocation asks each variant how it ships, so the variants and their
16
+ # delivery profiles are read once for the whole cart.
17
+ ActiveRecord::Associations::Preloader.new(
18
+ records: line_items, associations: { variant: [:delivery_profile, { product: :delivery_profile }] }
19
+ ).call
20
+
21
+ line_items.map do |line_item|
15
22
  # They go through multiple splits, avoid loading the
16
23
  # association to order until needed.
17
- Spree::FulfillmentItem.new(
24
+ unit = Spree::FulfillmentItem.new(
18
25
  pending: true,
19
26
  line_item_id: line_item.id,
20
27
  variant_id: line_item.variant_id,
21
28
  quantity: line_item.quantity,
22
29
  order_id: @order.is_a?(Spree::Order) ? @order.id : nil
23
30
  )
31
+ # Set as targets rather than assigned, so the proposed unit is not
32
+ # added to the line item's own fulfillment_items.
33
+ unit.association(:line_item).target = line_item
34
+ unit.association(:variant).target = line_item.variant
35
+ unit
24
36
  end
25
37
  end
26
38
  end
@@ -9,9 +9,25 @@ module Spree
9
9
  # belong to no order yet — so anything reading the buyer through the
10
10
  # package (the packaging tare, the channel and company eligibility
11
11
  # rules) would otherwise see nothing.
12
- def initialize(stock_location, inventory_units, splitters = [Splitter::Base], owner: nil)
12
+ # Every candidate location's levels for the given variants in one query,
13
+ # lowest id first as StockLocation#stock_level reads, for packing several
14
+ # locations without a read per location.
15
+ #
16
+ # @param stock_locations [Array<Spree::StockLocation>]
17
+ # @param variant_ids [Array]
18
+ # @return [Hash{Integer => Hash{Integer => Spree::StockLevel}}] by location, then variant
19
+ def self.stock_levels_by_location(stock_locations, variant_ids)
20
+ Spree::StockLevel.where(stock_location_id: stock_locations.map(&:id), variant_id: variant_ids).
21
+ order(:id).group_by(&:stock_location_id).
22
+ transform_values { |levels| levels.group_by(&:variant_id).transform_values(&:first) }
23
+ end
24
+
25
+ # @param stock_levels [Hash{Integer => Spree::StockLevel}, nil] this
26
+ # location's levels by variant id, when the caller read them already
27
+ def initialize(stock_location, inventory_units, splitters = [Splitter::Base], owner: nil, stock_levels: nil)
13
28
  @stock_location = stock_location
14
29
  @inventory_units = inventory_units
30
+ @stock_levels = stock_levels
15
31
  @splitters = splitters
16
32
  @owner = owner
17
33
  end
@@ -28,13 +44,21 @@ module Spree
28
44
  package = build_package
29
45
 
30
46
  # Group by variant_id as grouping by variant fires cached query.
31
- inventory_units.index_by(&:variant_id).each do |variant_id, inventory_unit|
32
- variant = Spree::Variant.find(variant_id)
47
+ units_by_variant_id = inventory_units.index_by(&:variant_id)
48
+ # The package's variants and their levels here, each in one query
49
+ # rather than one per variant. Lowest id first, as StockLocation#stock_level reads.
50
+ variants = variants_for(units_by_variant_id)
51
+ stock_levels = @stock_levels || stock_location.stock_levels.where(variant_id: units_by_variant_id.keys).order(:id).
52
+ group_by(&:variant_id).transform_values(&:first)
53
+
54
+ units_by_variant_id.each do |variant_id, inventory_unit|
55
+ variant = variants[variant_id] || Spree::Variant.find(variant_id)
33
56
  unit = inventory_unit.dup # Can be used by others, do not use directly
34
57
  if variant.should_track_inventory?
35
- next unless stock_location.stocks? variant
58
+ stock_level = stock_levels[variant_id]
59
+ next if stock_level.nil?
36
60
 
37
- on_hand, backordered = stock_location.fill_status(variant, unit.quantity)
61
+ on_hand, backordered = stock_location.fill_status(variant, unit.quantity, stock_level: stock_level)
38
62
  package.add(FulfillmentItem.split(unit, backordered), :backordered) if backordered.positive?
39
63
  package.add(FulfillmentItem.split(unit, on_hand), :on_hand) if on_hand.positive?
40
64
  else
@@ -57,6 +81,16 @@ module Spree
57
81
 
58
82
  private
59
83
 
84
+ # The units' own variants when they carry them, so a cart's variants are
85
+ # read once however many locations are packed.
86
+ def variants_for(units_by_variant_id)
87
+ variants = units_by_variant_id.each_with_object({}) do |(variant_id, unit), loaded|
88
+ loaded[variant_id] = unit.variant if unit.association(:variant).loaded?
89
+ end
90
+ missing_ids = units_by_variant_id.keys - variants.keys
91
+ missing_ids.empty? ? variants : variants.merge(Spree::Variant.where(id: missing_ids).index_by(&:id))
92
+ end
93
+
60
94
  def build_splitter
61
95
  splitter = nil
62
96
  splitters.reverse_each do |klass|
@@ -3,6 +3,22 @@ module Spree
3
3
  class Quantifier
4
4
  attr_reader :variant, :stock_location, :excluded_order, :provided_stock_levels
5
5
 
6
+ # What a quantifier reads, preloaded for many variants at once, so
7
+ # quantifying each of them reads memory instead of querying per variant.
8
+ # Variants whose levels are loaded already are left as they are.
9
+ #
10
+ # @param variants [Enumerable<Spree::Variant>]
11
+ # @return [void]
12
+ def self.preload(variants)
13
+ unloaded = variants.compact.reject { |variant| variant.association(:stock_levels).loaded? }
14
+ return if unloaded.empty?
15
+
16
+ ActiveRecord::Associations::Preloader.new(
17
+ records: unloaded,
18
+ associations: { stock_levels: [:stock_location, :active_stock_reservations] }
19
+ ).call
20
+ end
21
+
6
22
  # @param excluded_order [Spree::Cart, Spree::Order, nil] when given,
7
23
  # reservations belonging to this cart/order are not counted against
8
24
  # availability. Used when checking a cart's own line items so the
@@ -182,8 +182,10 @@ module Spree
182
182
  #
183
183
  # @param fulfillment [Spree::Fulfillment]
184
184
  # @return [Spree::StockMovement]
185
- def allocate(variant, quantity, fulfillment)
186
- move(variant, quantity, kind: 'allocated', cause: fulfillment)
185
+ # @param stock_level [Spree::StockLevel, nil] the variant's level here, when
186
+ # the caller loaded it already
187
+ def allocate(variant, quantity, fulfillment, stock_level: nil)
188
+ move(variant, quantity, kind: 'allocated', cause: fulfillment, stock_level: stock_level)
187
189
  end
188
190
 
189
191
  # Withdraws a promise — a canceled, relocated or shrunk fulfillment.
@@ -199,21 +201,26 @@ module Spree
199
201
  # a negative quantity writes stock off.
200
202
  #
201
203
  # @param reason [String] audit text, required
204
+ # @param stock_level [Spree::StockLevel, nil] the variant's level here, when
205
+ # the caller holds it already (locked, say), so the movement updates that
206
+ # instance rather than one looked up again
202
207
  # @return [Spree::StockMovement]
203
- def adjust(variant, quantity, reason:)
204
- move(variant, quantity, kind: 'adjusted', reason: reason)
208
+ def adjust(variant, quantity, reason:, stock_level: nil)
209
+ move(variant, quantity, kind: 'adjusted', reason: reason, stock_level: stock_level)
205
210
  end
206
211
 
207
- def move(variant, quantity, kind:, cause: nil, reason: nil, force: false, unit_cost: nil)
208
- stock_level = stock_level_or_create(variant)
212
+ def move(variant, quantity, kind:, cause: nil, reason: nil, force: false, unit_cost: nil, stock_level: nil)
213
+ stock_level ||= stock_level_or_create(variant)
209
214
  attributes = { quantity: quantity, kind: kind, reason: reason, unit_cost: unit_cost,
210
215
  **cause_attributes(cause) }
211
216
 
212
217
  stock_level.stock_movements.create!(attributes) { |movement| movement.force = force }
213
218
  end
214
219
 
215
- def fill_status(variant, quantity)
216
- if item = stock_level_or_create(variant)
220
+ # @param stock_level [Spree::StockLevel, nil] the variant's level here, when
221
+ # the caller loaded it already
222
+ def fill_status(variant, quantity, stock_level: nil)
223
+ if item = stock_level || stock_level_or_create(variant)
217
224
  if item.available_count >= quantity
218
225
  on_hand = quantity
219
226
  backordered = 0
@@ -78,6 +78,22 @@ module Spree
78
78
  # same reason as auto_approve_sellers: what the marketplace lists is what
79
79
  # it vouches for.
80
80
  preference :auto_approve_seller_products, :boolean, default: false
81
+ # Let sellers attach downloadable files to their own products. Off for the
82
+ # same reason again: a file a buyer downloads under the marketplace's name
83
+ # is something the marketplace vouches for, and that should be a choice.
84
+ preference :seller_digital_assets_enabled, :boolean, default: false
85
+ # What a seller's file may be, checked against the file's own signature
86
+ # rather than its name. An entry names one type or, with `*` as the
87
+ # subtype, a whole family. The operator's own uploads are not restricted.
88
+ preference :seller_digital_asset_content_types, :array, of: :string,
89
+ default: Spree::DigitalAsset::DEFAULT_SELLER_CONTENT_TYPES
90
+ # A typo here would refuse every seller upload with a message about the
91
+ # file, so the list is checked for the type/subtype shape when written.
92
+ validate :seller_digital_asset_content_types_are_media_types
93
+ # A file added to or replaced on a seller's product already on sale sends
94
+ # the product back to review, so an approved listing cannot change what it
95
+ # delivers without anyone looking. Moot when seller products auto-approve.
96
+ preference :review_seller_digital_asset_changes, :boolean, default: true
81
97
  # Who pays this store's sellers. Blank means core's record-only provider,
82
98
  # so a marketplace that has connected nothing still keeps a correct ledger
83
99
  # and settles by hand.
@@ -157,6 +173,8 @@ module Spree
157
173
  # B2B store collects it from everyone, a mixed store only offers it.
158
174
  preference :address_requires_company, :boolean, default: false
159
175
  preference :address_requires_phone, :boolean, default: false
176
+ MEDIA_TYPE_FORMAT = %r{\A[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+*-]*\z}i
177
+
160
178
  # digital assets preferences
161
179
  preference :limit_digital_download_count, :boolean, default: true
162
180
  preference :limit_digital_download_days, :boolean, default: true
@@ -186,7 +204,8 @@ module Spree
186
204
  :pricing_provider, :inventory_provider, :pricing_provider_failure_policy,
187
205
  :inventory_provider_failure_policy, :payout_provider, :default_payouts_schedule_interval,
188
206
  :default_minimum_payout_amount, :auto_approve_sellers, :auto_approve_seller_products,
189
- :send_seller_transactional_emails, :default_commission_tax_rate, :document_number_format,
207
+ :seller_digital_assets_enabled, :seller_digital_asset_content_types,
208
+ :review_seller_digital_asset_changes, :send_seller_transactional_emails, :default_commission_tax_rate, :document_number_format,
190
209
  :order_number_prefix, :order_number_suffix, :order_number_sequence_start,
191
210
  :limit_digital_download_count, :digital_asset_authorized_clicks,
192
211
  :limit_digital_download_days, :digital_asset_authorized_days,
@@ -729,7 +748,9 @@ module Spree
729
748
  # Products without a profile fall back to this one, so it must exist
730
749
  # from the store's first moment.
731
750
  def create_default_delivery_profile
732
- Spree::DeliveryProfiles::Shipping.create!(store: self, name: 'General', default: true)
751
+ Spree::Events.disable do
752
+ Spree::DeliveryProfiles::Shipping.create!(store: self, name: 'General', default: true)
753
+ end
733
754
  end
734
755
 
735
756
  def create_default_policies
@@ -831,5 +852,13 @@ module Spree
831
852
 
832
853
  errors.add(:preferred_order_number_sequence_start, :locked_after_first_number)
833
854
  end
855
+
856
+ def seller_digital_asset_content_types_are_media_types
857
+ bad = Array(preferred_seller_digital_asset_content_types).reject { |type| type.to_s.match?(MEDIA_TYPE_FORMAT) }
858
+ return if bad.empty?
859
+
860
+ errors.add(:preferred_seller_digital_asset_content_types, :invalid,
861
+ message: I18n.t('spree.digital_assets_content_type_list_invalid', types: bad.join(', ')))
862
+ end
834
863
  end
835
864
  end