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.
- checksums.yaml +4 -4
- data/app/jobs/spree/images/save_from_url_job.rb +24 -5
- data/app/models/concerns/spree/account_deactivation.rb +54 -0
- data/app/models/concerns/spree/calculator/currency_amounts.rb +19 -6
- data/app/models/concerns/spree/derived_number.rb +1 -1
- data/app/models/concerns/spree/has_external_references.rb +24 -16
- data/app/models/concerns/spree/prefixed_id.rb +15 -5
- data/app/models/concerns/spree/publishable.rb +47 -1
- data/app/models/concerns/spree/purchase/addresses.rb +19 -4
- data/app/models/concerns/spree/purchase/digital_items.rb +17 -3
- data/app/models/concerns/spree/purchase/freight.rb +2 -1
- data/app/models/concerns/spree/purchase/payment_processing.rb +7 -1
- data/app/models/concerns/spree/purchase/quantity_rules.rb +2 -0
- data/app/models/concerns/spree/stores/channels.rb +3 -1
- data/app/models/concerns/spree/translatable_resource.rb +6 -1
- data/app/models/spree/admin_user.rb +1 -0
- data/app/models/spree/base.rb +33 -1
- data/app/models/spree/calculator/shipping/digital_delivery.rb +3 -1
- data/app/models/spree/calculator/shipping/flat_rate.rb +3 -1
- data/app/models/spree/calculator/shipping/per_item.rb +3 -1
- data/app/models/spree/cart.rb +4 -1
- data/app/models/spree/category.rb +76 -5
- data/app/models/spree/channel.rb +79 -40
- data/app/models/spree/company.rb +45 -2
- data/app/models/spree/customer.rb +1 -1
- data/app/models/spree/customer_group.rb +59 -41
- data/app/models/spree/delivery_method.rb +18 -8
- data/app/models/spree/delivery_profile.rb +18 -0
- data/app/models/spree/digital_asset.rb +14 -0
- data/app/models/spree/event.rb +20 -5
- data/app/models/spree/fulfillment.rb +8 -1
- data/app/models/spree/fulfillment_changer.rb +3 -1
- data/app/models/spree/fulfillment_item.rb +11 -0
- data/app/models/spree/import.rb +20 -2
- data/app/models/spree/order_routing/strategy/rules.rb +4 -1
- data/app/models/spree/price_list.rb +15 -0
- data/app/models/spree/product/channels.rb +16 -8
- data/app/models/spree/product.rb +7 -2
- data/app/models/spree/return.rb +8 -1
- data/app/models/spree/search_provider/base.rb +8 -3
- data/app/models/spree/search_provider/database.rb +2 -10
- data/app/models/spree/seller.rb +35 -5
- data/app/models/spree/seller_balance.rb +28 -0
- data/app/models/spree/seller_requirements/delivery_method.rb +20 -2
- data/app/models/spree/stock/coordinator.rb +7 -4
- data/app/models/spree/stock/estimator.rb +6 -0
- data/app/models/spree/stock/inventory_unit_builder.rb +14 -2
- data/app/models/spree/stock/packer.rb +39 -5
- data/app/models/spree/stock/quantifier.rb +16 -0
- data/app/models/spree/stock_location.rb +15 -8
- data/app/models/spree/store.rb +31 -2
- data/app/models/spree/store_credit.rb +4 -4
- data/app/models/spree/variant.rb +23 -6
- data/app/models/spree/webhook_endpoint.rb +45 -11
- data/app/services/spree/carts/price_items.rb +3 -0
- data/app/services/spree/carts/split_by_seller.rb +3 -1
- data/app/services/spree/catalogs/set_product_terms.rb +8 -3
- data/app/services/spree/collections/add_products.rb +1 -1
- data/app/services/spree/collections/regenerate_products.rb +1 -3
- data/app/services/spree/collections/remove_products.rb +1 -1
- data/app/services/spree/newsletter/subscribe.rb +9 -1
- data/app/services/spree/order_groups/allocate_payments.rb +7 -5
- data/app/services/spree/orders/update.rb +19 -0
- data/app/services/spree/product_memberships.rb +16 -6
- data/app/services/spree/products/duplicator.rb +12 -4
- data/app/services/spree/search_provider/custom_field_schema.rb +10 -2
- data/app/services/spree/stock_levels/correct.rb +11 -4
- data/app/services/spree/stock_reservations/reserve.rb +1 -1
- data/app/services/spree/stores/provision_defaults.rb +11 -7
- data/app/services/spree/tags/bulk_add.rb +16 -6
- data/app/workflows/spree/carts/complete.rb +25 -9
- data/app/workflows/spree/carts/upsert_items.rb +31 -4
- data/app/workflows/spree/customers/anonymize.rb +2 -2
- data/app/workflows/spree/digital_assets/create.rb +56 -0
- data/app/workflows/spree/digital_assets/update.rb +44 -0
- data/app/workflows/spree/digital_assets/writing.rb +108 -0
- data/app/workflows/spree/imports/start_mapping.rb +3 -1
- data/app/workflows/spree/orders/complete.rb +12 -5
- data/app/workflows/spree/payments/capture.rb +12 -0
- data/app/workflows/spree/products/nested_attributes.rb +12 -28
- data/app/workflows/spree/products/propose.rb +9 -4
- data/app/workflows/spree/stock_levels/bulk_upsert.rb +28 -13
- data/app/workflows/spree/variants/nested_attributes.rb +35 -10
- data/config/locales/en.yml +10 -0
- data/db/migrate/20261022000001_store_credit_actor_polymorphic.rb +13 -0
- data/db/migrate/20261023100001_add_deactivated_at_to_spree_admin_users.rb +7 -0
- data/lib/generators/spree/dummy/templates/rails/test.rb +87 -0
- data/lib/spree/checkout/default_requirements.rb +12 -2
- data/lib/spree/core/dependencies.rb +5 -0
- data/lib/spree/core/permissions/default_catalog.rb +7 -2
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/reporting/default_vocabulary.rb +1 -1
- data/lib/spree/testing_support/factories/product_factory.rb +10 -4
- data/lib/spree/testing_support/factories/store_factory.rb +4 -1
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +10 -0
- data/lib/tasks/backfill_tagging_tenants.rake +35 -0
- data/lib/tasks/seller_sample_data.rake +114 -112
- 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
|
|
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
|
|
68
|
-
product_publications.find { |
|
|
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
|
-
|
|
128
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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.
|
|
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)
|
data/app/models/spree/product.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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?
|
data/app/models/spree/return.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
75
|
+
search = scope.ransack(ransack_filters, auth_object: audience)
|
|
84
76
|
scope = search.result(distinct: true)
|
|
85
77
|
end
|
|
86
78
|
|
data/app/models/spree/seller.rb
CHANGED
|
@@ -310,10 +310,7 @@ module Spree
|
|
|
310
310
|
#
|
|
311
311
|
# @return [Array<Spree::SellerBalance>]
|
|
312
312
|
def balances
|
|
313
|
-
|
|
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
|
|
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
|
|
14
|
+
return true if ships_own_methods?(seller)
|
|
15
15
|
|
|
16
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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)
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
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
|
data/app/models/spree/store.rb
CHANGED
|
@@ -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
|
-
:
|
|
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::
|
|
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
|