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
|
@@ -422,10 +422,21 @@ module Spree
|
|
|
422
422
|
# provider a question whose answer is discarded — and for an external
|
|
423
423
|
# engine that is a billable remote call per line item, inside the
|
|
424
424
|
# completion lock.
|
|
425
|
+
#
|
|
426
|
+
# Each copy is handed the cart line's variant, loaded once for every
|
|
427
|
+
# line, so validating the copies reads its product, stock and tax
|
|
428
|
+
# category for all of them at once rather than per line.
|
|
425
429
|
def copy_line_items!(cart, order)
|
|
426
|
-
cart.line_items.reload.
|
|
430
|
+
cart_line_items = cart.line_items.reload.to_a
|
|
431
|
+
ActiveRecord::Associations::Preloader.new(
|
|
432
|
+
records: cart_line_items,
|
|
433
|
+
associations: { variant: [{ product: %i[store product_publications tax_category] }, { stock_levels: %i[stock_location active_stock_reservations] }] }
|
|
434
|
+
).call
|
|
435
|
+
|
|
436
|
+
cart_line_items.index_with do |cart_line_item|
|
|
427
437
|
attributes = cart_line_item.attributes.except('id', 'cart_id', 'created_at', 'updated_at')
|
|
428
438
|
line_item = order.line_items.new(attributes.merge('order_id' => order.id))
|
|
439
|
+
line_item.variant = cart_line_item.variant if cart_line_item.variant
|
|
429
440
|
line_item.skip_tax_estimation = true
|
|
430
441
|
line_item.save!
|
|
431
442
|
line_item
|
|
@@ -434,9 +445,12 @@ module Spree
|
|
|
434
445
|
|
|
435
446
|
# @return [Hash{Integer => Integer}] cart fulfillment id → order fulfillment id
|
|
436
447
|
def copy_fulfillments!(cart, order, line_item_map)
|
|
437
|
-
|
|
448
|
+
line_items_by_cart_line_item_id = line_item_map.transform_keys(&:id)
|
|
449
|
+
|
|
450
|
+
cart_fulfillments = cart.fulfillments.reload.to_a
|
|
451
|
+
ActiveRecord::Associations::Preloader.new(records: cart_fulfillments, associations: %i[selected_delivery_rate fulfillment_items]).call
|
|
438
452
|
|
|
439
|
-
|
|
453
|
+
cart_fulfillments.each_with_object({}) do |cart_fulfillment, map|
|
|
440
454
|
attributes = cart_fulfillment.attributes.except('id', 'cart_id', 'number', 'tracking', 'created_at', 'updated_at')
|
|
441
455
|
fulfillment = order.fulfillments.create!(attributes.merge('order_id' => order.id, 'address_id' => order.ship_address_id))
|
|
442
456
|
|
|
@@ -447,13 +461,15 @@ module Spree
|
|
|
447
461
|
|
|
448
462
|
cart_fulfillment.fulfillment_items.each do |item|
|
|
449
463
|
item_attributes = item.attributes.except('id', 'created_at', 'updated_at')
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
'order_id' => order.id,
|
|
454
|
-
'line_item_id' => line_item_id_map.fetch(item.line_item_id)
|
|
455
|
-
)
|
|
464
|
+
line_item = line_items_by_cart_line_item_id.fetch(item.line_item_id)
|
|
465
|
+
fulfillment_item = fulfillment.fulfillment_items.new(
|
|
466
|
+
item_attributes.merge('fulfillment_id' => fulfillment.id, 'order_id' => order.id)
|
|
456
467
|
)
|
|
468
|
+
# Objects rather than ids, so validating the copy does not look
|
|
469
|
+
# each of them up again.
|
|
470
|
+
fulfillment_item.line_item = line_item
|
|
471
|
+
fulfillment_item.variant = line_item.variant if line_item.variant_id == item.variant_id
|
|
472
|
+
fulfillment_item.save!
|
|
457
473
|
end
|
|
458
474
|
|
|
459
475
|
map[cart_fulfillment.id] = fulfillment.id
|
|
@@ -90,9 +90,10 @@ module Spree
|
|
|
90
90
|
# applied.
|
|
91
91
|
def resolve_items
|
|
92
92
|
store = cart.store || Spree::Current.store
|
|
93
|
+
requested_items = Array(items).map { |item_params| item_params.to_h.deep_symbolize_keys }
|
|
94
|
+
prefetch_added_variants(store, requested_items)
|
|
93
95
|
|
|
94
|
-
@resolved_items =
|
|
95
|
-
item_params = item_params.to_h.deep_symbolize_keys
|
|
96
|
+
@resolved_items = requested_items.filter_map do |item_params|
|
|
96
97
|
quantity = (item_params[:quantity] || 1).to_i
|
|
97
98
|
variant = resolve_variant(store, item_params[:variant_id], removing: quantity <= 0)
|
|
98
99
|
next if variant.nil?
|
|
@@ -357,18 +358,44 @@ module Spree
|
|
|
357
358
|
cart.line_items.detect { |line_item| line_item.variant_id.to_s == variant_id.to_s }&.variant ||
|
|
358
359
|
Spree::Variant.with_deleted.find_by_param(variant_id)
|
|
359
360
|
else
|
|
360
|
-
variant =
|
|
361
|
+
variant = @added_variants[variant_param_id(variant_id)]
|
|
361
362
|
# +orderable_variants+ narrows what a request may add. A line already
|
|
362
363
|
# in the cart is a quantity edit and resolves as before — checkout
|
|
363
364
|
# judges it — so one stale line cannot fail a whole cart's update.
|
|
364
365
|
if variant && orderable_variants && cart.line_items.none? { |line_item| line_item.variant_id == variant.id }
|
|
365
|
-
variant =
|
|
366
|
+
variant = nil unless @orderable_added_variant_ids.include?(variant.id)
|
|
366
367
|
end
|
|
367
368
|
|
|
368
369
|
variant || raise(ActiveRecord::RecordNotFound.new("Variant '#{variant_id}' not found in this store", 'Spree::Variant', 'id', variant_id))
|
|
369
370
|
end
|
|
370
371
|
end
|
|
371
372
|
|
|
373
|
+
# Every variant the batch adds, looked up through the store in one
|
|
374
|
+
# query rather than one per item, and which of them the buyer may order.
|
|
375
|
+
def prefetch_added_variants(store, requested_items)
|
|
376
|
+
ids = requested_items.filter_map do |item_params|
|
|
377
|
+
variant_param_id(item_params[:variant_id]) if (item_params[:quantity] || 1).to_i.positive?
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
@added_variants = ids.empty? ? {} : store.variants.where(id: ids.uniq).preload_associations_lazily.index_by(&:id)
|
|
381
|
+
Spree::Stock::Quantifier.preload(@added_variants.values)
|
|
382
|
+
@orderable_added_variant_ids = if orderable_variants && @added_variants.any?
|
|
383
|
+
orderable_variants.where(id: @added_variants.keys).pluck(:id).to_set
|
|
384
|
+
else
|
|
385
|
+
Set.new
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# The id a variant param names: a prefixed id, or a bare integer id
|
|
390
|
+
# kept for backwards compatibility.
|
|
391
|
+
#
|
|
392
|
+
# @return [Integer, nil]
|
|
393
|
+
def variant_param_id(param)
|
|
394
|
+
return nil if param.blank?
|
|
395
|
+
|
|
396
|
+
Spree::Variant.decode_prefixed_id(param) || (param.to_s.to_i if param.to_s.match?(/\A\d+\z/))
|
|
397
|
+
end
|
|
398
|
+
|
|
372
399
|
def warn(index, item, code, message)
|
|
373
400
|
warnings << Spree::Carts::ItemWarning.new(item_index: index, variant: item.variant, code: code, message: message)
|
|
374
401
|
end
|
|
@@ -318,7 +318,7 @@ module Spree
|
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
owner_ids.each do |owner_type, ids|
|
|
321
|
-
Spree::ShippingLabel.where(owner_type: owner_type, owner_id: ids).find_each do |label|
|
|
321
|
+
Spree::ShippingLabel.where(owner_type: owner_type, owner_id: ids).with_attached_file.find_each do |label|
|
|
322
322
|
purge_after_commit(label.file)
|
|
323
323
|
end
|
|
324
324
|
end
|
|
@@ -408,7 +408,7 @@ module Spree
|
|
|
408
408
|
# complete copy of everything being erased, sitting behind a link that
|
|
409
409
|
# stays live for days. Rewriting the row's email would leave the
|
|
410
410
|
# payload itself untouched and still fetchable.
|
|
411
|
-
requests.each do |data_request|
|
|
411
|
+
requests.with_attached_export_file.each do |data_request|
|
|
412
412
|
purge_after_commit(data_request.export_file)
|
|
413
413
|
end
|
|
414
414
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module DigitalAssets
|
|
3
|
+
# Adds a downloadable file (or a provider-backed deliverable) to a product.
|
|
4
|
+
#
|
|
5
|
+
# The single gate for new assets: the operator's and the seller's nested
|
|
6
|
+
# endpoints and the `digital_assets` list on a product write all run
|
|
7
|
+
# through it, so a :validate handler sees every file a catalog gains.
|
|
8
|
+
# A host that scans uploads registers on `digital_assets.create.validate`
|
|
9
|
+
# for a quick check, or on `after_create` to queue a slower one; the bytes
|
|
10
|
+
# are already in storage when either fires.
|
|
11
|
+
#
|
|
12
|
+
# Passing `seller:` applies the marketplace's rules for a seller's file —
|
|
13
|
+
# whether sellers may upload at all, which content types, and whether an
|
|
14
|
+
# approved listing goes back to review. The operator's own writes pass none.
|
|
15
|
+
class Create < Spree::Workflow
|
|
16
|
+
include Spree::DigitalAssets::Writing
|
|
17
|
+
|
|
18
|
+
hooks :validate, :after_create
|
|
19
|
+
|
|
20
|
+
# The unsaved record a :validate handler reads.
|
|
21
|
+
attr_reader :digital_asset
|
|
22
|
+
|
|
23
|
+
# @param product [Spree::Product]
|
|
24
|
+
# @param attributes [Hash] `signed_id`, `variant_id`, the download limits
|
|
25
|
+
# and, for the operator, the provider fields
|
|
26
|
+
# @param seller [Spree::Seller, nil] the seller this file is written for
|
|
27
|
+
# @param submitted_by [Spree.admin_user_class, nil] who made the change,
|
|
28
|
+
# named on the review it may reopen
|
|
29
|
+
# @return [Spree::ServiceModule::Result] value is the digital asset
|
|
30
|
+
def perform(product:, attributes: {}, seller: nil, submitted_by: nil)
|
|
31
|
+
super
|
|
32
|
+
|
|
33
|
+
step :build_digital_asset
|
|
34
|
+
step :resolve_upload
|
|
35
|
+
step :enforce_seller_rules
|
|
36
|
+
run_hooks :validate
|
|
37
|
+
|
|
38
|
+
ApplicationRecord.transaction do
|
|
39
|
+
step :attach_upload
|
|
40
|
+
step :save_digital_asset
|
|
41
|
+
step :reopen_review
|
|
42
|
+
run_hooks :after_create
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
success(digital_asset)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def build_digital_asset
|
|
51
|
+
@digital_asset = Spree::DigitalAsset.new
|
|
52
|
+
assign_attributes
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module DigitalAssets
|
|
3
|
+
# Changes a digital asset: its download limits, the variant it hangs off,
|
|
4
|
+
# or the file itself. Replacing the file keeps every already-issued link
|
|
5
|
+
# working, since links resolve through the asset rather than the blob.
|
|
6
|
+
#
|
|
7
|
+
# Handlers reading `digital_asset.changes` in :validate see the pending
|
|
8
|
+
# edit; `blob` is the replacement file, when the payload carries one.
|
|
9
|
+
class Update < Spree::Workflow
|
|
10
|
+
include Spree::DigitalAssets::Writing
|
|
11
|
+
|
|
12
|
+
hooks :validate, :after_update
|
|
13
|
+
|
|
14
|
+
# @param digital_asset [Spree::DigitalAsset]
|
|
15
|
+
# @param attributes [Hash] see Spree::DigitalAssets::Create
|
|
16
|
+
# @param seller [Spree::Seller, nil] the seller this change is written for
|
|
17
|
+
# @param submitted_by [Spree.admin_user_class, nil] who made the change
|
|
18
|
+
# @return [Spree::ServiceModule::Result] value is the digital asset
|
|
19
|
+
def perform(digital_asset:, attributes: {}, seller: nil, submitted_by: nil)
|
|
20
|
+
super
|
|
21
|
+
|
|
22
|
+
step :assign_attributes
|
|
23
|
+
step :resolve_upload
|
|
24
|
+
step :enforce_seller_rules
|
|
25
|
+
run_hooks :validate
|
|
26
|
+
|
|
27
|
+
ApplicationRecord.transaction do
|
|
28
|
+
step :attach_upload
|
|
29
|
+
step :save_digital_asset
|
|
30
|
+
step :reopen_review
|
|
31
|
+
run_hooks :after_update
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
success(digital_asset)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def product
|
|
40
|
+
digital_asset.product
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module DigitalAssets
|
|
3
|
+
# The steps a file write shares between Create and Update: the upload the
|
|
4
|
+
# payload names, the marketplace's rules for a seller's file, and sending
|
|
5
|
+
# an approved listing back to review once what it delivers has changed.
|
|
6
|
+
module Writing
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
# Why a product reopened review: recorded on the submission row so the
|
|
10
|
+
# operator's queue says what changed.
|
|
11
|
+
REVIEW_REASON = 'digital_asset_changed'.freeze
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
# The uploaded file the payload names, resolved before it is attached.
|
|
15
|
+
attr_reader :blob
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def assign_attributes
|
|
21
|
+
attrs = attributes.to_h.with_indifferent_access
|
|
22
|
+
@signed_id = attrs.delete(:signed_id)
|
|
23
|
+
variant_id = attrs.delete(:variant_id)
|
|
24
|
+
|
|
25
|
+
digital_asset.assign_attributes(attrs)
|
|
26
|
+
# Through the product's own variants, so an asset reached through one
|
|
27
|
+
# product can never point at another product's variant.
|
|
28
|
+
digital_asset.variant = product.variants.find_by_param!(variant_id) if variant_id.present?
|
|
29
|
+
digital_asset.variant ||= product.default_variant
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def resolve_upload
|
|
33
|
+
return if @signed_id.blank?
|
|
34
|
+
|
|
35
|
+
@blob = ActiveStorage::Blob.find_signed(@signed_id)
|
|
36
|
+
return refuse(:digital_assets_upload_not_found) if blob.nil?
|
|
37
|
+
|
|
38
|
+
# Attaching never moves a blob between storage services, so a file
|
|
39
|
+
# uploaded to the public bucket would stay publicly readable while
|
|
40
|
+
# looking attached.
|
|
41
|
+
unless blob.service_name.to_s == Spree.private_storage_service_name.to_s
|
|
42
|
+
return refuse(:digital_assets_attachment_must_be_private)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A provider-backed asset delivers through its provider, so a file on
|
|
46
|
+
# it would show in the panel but never reach a buyer. An unregistered
|
|
47
|
+
# provider is left to the model's validation rather than asked.
|
|
48
|
+
return unless Spree.digital_asset_providers.include?(digital_asset.provider_class)
|
|
49
|
+
|
|
50
|
+
refuse(:digital_assets_provider_takes_no_file) unless digital_asset.provider_class.requires_attachment?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# What the marketplace lets a seller attach. The operator's own writes
|
|
54
|
+
# are not subject to these: the operator is who the rules protect.
|
|
55
|
+
def enforce_seller_rules
|
|
56
|
+
return if seller.nil?
|
|
57
|
+
|
|
58
|
+
refuse(:digital_assets_sellers_disabled) unless store.preferred_seller_digital_assets_enabled
|
|
59
|
+
return if blob.nil?
|
|
60
|
+
|
|
61
|
+
# Identifying reads the file's own signature where it has one, so a
|
|
62
|
+
# renamed executable cannot pass as a PDF; a format with no signature
|
|
63
|
+
# (plain text) keeps the type the client declared.
|
|
64
|
+
blob.identify_without_saving
|
|
65
|
+
return if content_type_accepted?(blob.content_type)
|
|
66
|
+
|
|
67
|
+
refuse(:digital_assets_content_type_not_accepted, content_type: blob.content_type)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# An entry names one type (`audio/mpeg`) or a whole family (`audio/*`).
|
|
71
|
+
def content_type_accepted?(content_type)
|
|
72
|
+
family = "#{content_type.to_s.split('/').first}/*"
|
|
73
|
+
store.preferred_seller_digital_asset_content_types.any? { |accepted| accepted.casecmp?(content_type.to_s) || accepted == family }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def attach_upload
|
|
77
|
+
digital_asset.attachment.attach(blob) if blob
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def save_digital_asset
|
|
81
|
+
failure(digital_asset) unless digital_asset.save
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A seller changing what an approved listing delivers reopens its review.
|
|
85
|
+
# The operator's own change does not: they are the reviewer.
|
|
86
|
+
def reopen_review
|
|
87
|
+
return if seller.nil? || blob.nil? || !product.active?
|
|
88
|
+
return unless store.preferred_review_seller_digital_asset_changes
|
|
89
|
+
return if store.preferred_auto_approve_seller_products
|
|
90
|
+
|
|
91
|
+
result = Spree.product_propose_workflow.call(product: product, submitted_by: submitted_by, reason: REVIEW_REASON)
|
|
92
|
+
return if result.success?
|
|
93
|
+
|
|
94
|
+
digital_asset.errors.add(:base, result.error.to_s)
|
|
95
|
+
failure(digital_asset)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def store
|
|
99
|
+
product.store
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def refuse(key, **options)
|
|
103
|
+
digital_asset.errors.add(:base, I18n.t("spree.#{key}", **options))
|
|
104
|
+
failure(digital_asset)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -33,8 +33,10 @@ module Spree
|
|
|
33
33
|
# TODO: seed from the previous import of the same type, so a merchant
|
|
34
34
|
# doesn't map the same columns twice.
|
|
35
35
|
def build_mappings
|
|
36
|
+
existing = import.mappings.index_by(&:schema_field)
|
|
37
|
+
|
|
36
38
|
import.schema_fields.each do |schema_field|
|
|
37
|
-
mapping = import.mappings.
|
|
39
|
+
mapping = existing[schema_field[:name]] || import.mappings.build(schema_field: schema_field[:name])
|
|
38
40
|
mapping.try_to_auto_assign_file_column(import.csv_headers)
|
|
39
41
|
mapping.save!
|
|
40
42
|
end
|
|
@@ -140,11 +140,16 @@ module Spree
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
def allocate_fulfillment_stock(fulfillment)
|
|
143
|
-
fulfillment.manifest.
|
|
144
|
-
|
|
145
|
-
next unless item.quantity.positive?
|
|
143
|
+
items = fulfillment.manifest.select { |item| item.variant.track_inventory? && item.quantity.positive? }
|
|
144
|
+
return if items.empty?
|
|
146
145
|
|
|
147
|
-
|
|
146
|
+
stock_location = fulfillment.stock_location
|
|
147
|
+
# Lowest id first, as StockLocation#stock_level reads.
|
|
148
|
+
stock_levels = stock_location.stock_levels.where(variant_id: items.map { |item| item.variant.id }).order(:id).
|
|
149
|
+
group_by(&:variant_id).transform_values(&:first)
|
|
150
|
+
|
|
151
|
+
items.each do |item|
|
|
152
|
+
stock_location.allocate(item.variant, item.quantity, fulfillment, stock_level: stock_levels[item.variant.id])
|
|
148
153
|
end
|
|
149
154
|
end
|
|
150
155
|
|
|
@@ -240,7 +245,9 @@ module Spree
|
|
|
240
245
|
# wildcard subscribers dedupe on the metadata marker.
|
|
241
246
|
def publish_order_placed
|
|
242
247
|
metadata = { notify_customer: order.notify_customer }
|
|
243
|
-
payload
|
|
248
|
+
# The payload renders every line and parcel, read back through a lazily
|
|
249
|
+
# preloading query so each association loads once rather than per row.
|
|
250
|
+
payload = Spree::Order.where(id: order.id).preload_associations_lazily.take!.event_payload
|
|
244
251
|
order.publish_event('order.placed', payload, metadata)
|
|
245
252
|
order.publish_event('order.completed', payload, metadata.merge(deprecated_alias_of: 'order.placed'))
|
|
246
253
|
end
|
|
@@ -23,6 +23,7 @@ module Spree
|
|
|
23
23
|
@amount = capture_amount(amount)
|
|
24
24
|
|
|
25
25
|
step :ensure_capturable
|
|
26
|
+
step :ensure_within_payment_amount
|
|
26
27
|
# Veto point — fraud holds, manual review. Before any money moves.
|
|
27
28
|
run_hooks :validate
|
|
28
29
|
run_hooks :before_capture
|
|
@@ -69,6 +70,17 @@ module Spree
|
|
|
69
70
|
failure(payment, :payment_not_capturable)
|
|
70
71
|
end
|
|
71
72
|
|
|
73
|
+
# A capture takes some or all of what was authorized, never more: a
|
|
74
|
+
# gateway may let a larger figure through, and the books would then
|
|
75
|
+
# carry a capture the authorization never covered. Nor nothing, or less
|
|
76
|
+
# than nothing — every caller gets the same rule, not only the API.
|
|
77
|
+
def ensure_within_payment_amount
|
|
78
|
+
failure(payment, :capture_amount_not_positive) unless @amount.positive?
|
|
79
|
+
return if @amount <= payment.amount
|
|
80
|
+
|
|
81
|
+
failure(payment, :capture_exceeds_payment_amount)
|
|
82
|
+
end
|
|
83
|
+
|
|
72
84
|
def claim
|
|
73
85
|
halt!(payment) unless payment.started_processing!
|
|
74
86
|
rescue Spree::Core::GatewayError => error
|
|
@@ -195,6 +195,10 @@ module Spree
|
|
|
195
195
|
# accumulate copies. Omitting an entry leaves it alone; removal is the
|
|
196
196
|
# nested digital_assets DELETE endpoint's job.
|
|
197
197
|
#
|
|
198
|
+
# Delegates to the digital asset workflows, so a file written as part of
|
|
199
|
+
# a product payload passes the same gate — and the same :validate
|
|
200
|
+
# handlers — as one written through the digital assets endpoint.
|
|
201
|
+
#
|
|
198
202
|
# @param product [Spree::Product]
|
|
199
203
|
# @param digital_assets_params [Array<Hash>, nil]
|
|
200
204
|
# @return [void]
|
|
@@ -203,38 +207,18 @@ module Spree
|
|
|
203
207
|
|
|
204
208
|
digital_assets_params.each do |raw|
|
|
205
209
|
attrs = (raw.respond_to?(:to_h) ? raw.to_h : raw).with_indifferent_access
|
|
206
|
-
|
|
207
210
|
asset_id = attrs.delete(:id)
|
|
208
|
-
signed_id = attrs.delete(:signed_id)
|
|
209
|
-
variant_id = attrs.delete(:variant_id)
|
|
210
|
-
|
|
211
|
-
# An id patches an existing asset; without one this is a new asset on
|
|
212
|
-
# the named variant (or the default). A patch for an asset that is
|
|
213
|
-
# gone is skipped rather than recreated.
|
|
214
|
-
if asset_id.present?
|
|
215
|
-
asset = product.digital_assets.find_by_param(asset_id) || next
|
|
216
|
-
else
|
|
217
|
-
target = variant_id.present? ? product.variants.find_by_param!(variant_id) : product.default_variant
|
|
218
|
-
next unless target
|
|
219
211
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
# uploaded to the public bucket would stay publicly readable. Refuse
|
|
228
|
-
# it rather than attaching it wrongly.
|
|
229
|
-
blob = ActiveStorage::Blob.find_signed(signed_id)
|
|
230
|
-
unless blob && blob.service_name.to_s == Spree.private_storage_service_name.to_s
|
|
231
|
-
asset.errors.add(:attachment, :digital_assets_attachment_must_be_private, message: I18n.t('spree.digital_assets_attachment_must_be_private'))
|
|
232
|
-
raise ActiveRecord::RecordInvalid.new(asset)
|
|
212
|
+
result =
|
|
213
|
+
if asset_id.present?
|
|
214
|
+
# A patch for an asset that is gone is skipped rather than recreated.
|
|
215
|
+
asset = product.digital_assets.find_by_param(asset_id) || next
|
|
216
|
+
Spree.digital_asset_update_workflow.call(digital_asset: asset, attributes: attrs)
|
|
217
|
+
else
|
|
218
|
+
Spree.digital_asset_create_workflow.call(product: product, attributes: attrs)
|
|
233
219
|
end
|
|
234
220
|
|
|
235
|
-
|
|
236
|
-
end
|
|
237
|
-
asset.save!
|
|
221
|
+
raise ActiveRecord::RecordInvalid, result.value if result.failure?
|
|
238
222
|
end
|
|
239
223
|
end
|
|
240
224
|
end
|
|
@@ -13,16 +13,20 @@ module Spree
|
|
|
13
13
|
|
|
14
14
|
# @param product [Spree::Product]
|
|
15
15
|
# @param submitted_by [Spree.admin_user_class, nil] who asked
|
|
16
|
+
# @param reason [String, nil] why a listing already on sale goes back to
|
|
17
|
+
# review — what changed on it. Recorded on the submission row. Without
|
|
18
|
+
# one, only a draft or a rejected product can be submitted.
|
|
16
19
|
# @return [Spree::ServiceModule::Result] value is the product
|
|
17
|
-
def perform(product:, submitted_by: nil)
|
|
20
|
+
def perform(product:, submitted_by: nil, reason: nil)
|
|
18
21
|
super
|
|
19
22
|
|
|
20
23
|
# Only a listing that is being worked on can be submitted. Guarding
|
|
21
24
|
# `active?` alone let an archived product through — and with
|
|
22
25
|
# auto-approval on, a withdrawn listing went straight back on sale
|
|
23
26
|
# without anybody reviewing it. Re-submitting one already in review
|
|
24
|
-
# is equally meaningless.
|
|
25
|
-
|
|
27
|
+
# is equally meaningless. An active listing reopens review only when
|
|
28
|
+
# the caller says what changed, which no seller-facing action does.
|
|
29
|
+
unless product.draft? || product.rejected? || (reason.present? && product.active?)
|
|
26
30
|
reject!(:cannot_propose, message: I18n.t('activerecord.errors.models.spree/product.attributes.base.cannot_propose'))
|
|
27
31
|
end
|
|
28
32
|
|
|
@@ -51,7 +55,8 @@ module Spree
|
|
|
51
55
|
# one: the trail is how many times this was asked for, and rewriting
|
|
52
56
|
# the last answer would erase that.
|
|
53
57
|
def open_submission
|
|
54
|
-
product.submissions.create!(status: 'pending', submitted_by: submitted_by
|
|
58
|
+
product.submissions.create!(status: 'pending', submitted_by: submitted_by,
|
|
59
|
+
metadata: reason.present? ? { 'reason' => reason } : {})
|
|
55
60
|
end
|
|
56
61
|
|
|
57
62
|
def auto_approve?
|
|
@@ -35,9 +35,10 @@ module Spree
|
|
|
35
35
|
# means the later line, not the sum of both.
|
|
36
36
|
deduped = keyed.reverse.uniq { |row| [row[:variant_id].to_s, row[:stock_location_id].to_s] }.reverse
|
|
37
37
|
|
|
38
|
+
existing = existing_stock_levels(deduped)
|
|
38
39
|
count = 0
|
|
39
40
|
Spree::StockLevel.transaction do
|
|
40
|
-
deduped.each { |row| count += 1 if apply_row(row) }
|
|
41
|
+
deduped.each { |row| count += 1 if apply_row(row, existing) }
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
success(stock_level_count: count)
|
|
@@ -52,27 +53,41 @@ module Spree
|
|
|
52
53
|
# happens to name — including another merchant's. One caller decoding
|
|
53
54
|
# ids correctly does not make the workflow safe for the next one.
|
|
54
55
|
def resolve(rows)
|
|
55
|
-
variants =
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
@variants = scoped_records(store_variants, Spree::Variant, rows.map { |row| row[:variant_id] })
|
|
57
|
+
@stock_locations = scoped_records(store_stock_locations, Spree::StockLocation,
|
|
58
|
+
rows.map { |row| row[:stock_location_id] })
|
|
59
|
+
@variants_by_id = @variants.values.index_by(&:id)
|
|
60
|
+
@stock_locations_by_id = @stock_locations.values.index_by(&:id)
|
|
58
61
|
|
|
59
62
|
rows.map do |row|
|
|
60
63
|
row.merge(
|
|
61
|
-
variant_id: variants[row[:variant_id]],
|
|
62
|
-
stock_location_id:
|
|
64
|
+
variant_id: @variants[row[:variant_id]]&.id,
|
|
65
|
+
stock_location_id: @stock_locations[row[:stock_location_id]]&.id
|
|
63
66
|
).compact
|
|
64
67
|
end
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
# Accepts a prefixed id or a raw one: the Admin API speaks prefixed,
|
|
68
71
|
# while the importer and seeds hold records they already loaded.
|
|
69
|
-
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash{Object => ActiveRecord::Base}] each value as given => this store's record
|
|
74
|
+
def scoped_records(scope, model, values)
|
|
70
75
|
decoded = values.compact.uniq.index_with do |value|
|
|
71
76
|
model.decode_own_prefixed_id(value) || value
|
|
72
77
|
end
|
|
73
|
-
found = scope.where(id: decoded.values.compact).
|
|
78
|
+
found = scope.where(id: decoded.values.compact).index_by { |record| record.id.to_s }
|
|
79
|
+
|
|
80
|
+
decoded.transform_values { |id| found[id.to_s] if id }.compact
|
|
81
|
+
end
|
|
74
82
|
|
|
75
|
-
|
|
83
|
+
# Every shelf the feed names, in one query rather than one per row.
|
|
84
|
+
# Lowest id wins, as an unordered lookup per pair would return.
|
|
85
|
+
def existing_stock_levels(rows)
|
|
86
|
+
Spree::StockLevel.
|
|
87
|
+
where(variant_id: rows.map { |row| row[:variant_id] }.uniq,
|
|
88
|
+
stock_location_id: rows.map { |row| row[:stock_location_id] }.uniq).
|
|
89
|
+
order(id: :desc).
|
|
90
|
+
index_by { |stock_level| [stock_level.variant_id.to_s, stock_level.stock_location_id.to_s] }
|
|
76
91
|
end
|
|
77
92
|
|
|
78
93
|
def store_variants
|
|
@@ -83,10 +98,10 @@ module Spree
|
|
|
83
98
|
@store.stock_locations
|
|
84
99
|
end
|
|
85
100
|
|
|
86
|
-
def apply_row(row)
|
|
87
|
-
stock_level = Spree::StockLevel.
|
|
88
|
-
|
|
89
|
-
)
|
|
101
|
+
def apply_row(row, existing)
|
|
102
|
+
stock_level = existing[[row[:variant_id].to_s, row[:stock_location_id].to_s]] || Spree::StockLevel.new
|
|
103
|
+
stock_level.variant = @variants_by_id.fetch(row[:variant_id])
|
|
104
|
+
stock_level.stock_location = @stock_locations_by_id.fetch(row[:stock_location_id])
|
|
90
105
|
stock_level.backorderable = row[:backorderable] if row.key?(:backorderable)
|
|
91
106
|
stock_level.save! if stock_level.new_record? || stock_level.changed?
|
|
92
107
|
|
|
@@ -48,6 +48,8 @@ module Spree
|
|
|
48
48
|
return if prices_params.nil?
|
|
49
49
|
|
|
50
50
|
currencies = []
|
|
51
|
+
# Loaded once so each currency's write finds its row in memory.
|
|
52
|
+
variant.prices.load if variant.persisted?
|
|
51
53
|
|
|
52
54
|
Array(prices_params).each do |raw|
|
|
53
55
|
price = raw.to_h.with_indifferent_access
|
|
@@ -59,7 +61,8 @@ module Spree
|
|
|
59
61
|
|
|
60
62
|
return unless variant.persisted?
|
|
61
63
|
|
|
62
|
-
variant.prices.
|
|
64
|
+
stale = variant.prices.select { |price| price.persisted? && price.price_list_id.nil? && currencies.exclude?(price.currency) }
|
|
65
|
+
variant.prices.destroy(*stale) if stale.any?
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
# @param variant [Spree::Variant]
|
|
@@ -68,22 +71,44 @@ module Spree
|
|
|
68
71
|
def apply_stock_levels(variant, stock_levels_params)
|
|
69
72
|
return if stock_levels_params.blank?
|
|
70
73
|
|
|
71
|
-
Array(stock_levels_params).
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
rows = Array(stock_levels_params).map { |raw| raw.to_h.with_indifferent_access }
|
|
75
|
+
rows.reject! { |row| row[:stock_location_id].blank? }
|
|
76
|
+
return if rows.empty?
|
|
74
77
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
locations = stock_locations_by_param(variant, rows.map { |row| row[:stock_location_id] })
|
|
79
|
+
# Loaded once so each row's write finds its level in memory.
|
|
80
|
+
variant.stock_levels.load if variant.persisted?
|
|
81
|
+
|
|
82
|
+
rows.each do |row|
|
|
83
|
+
location = locations[row[:stock_location_id].to_s]
|
|
79
84
|
next if location.nil?
|
|
80
85
|
|
|
81
86
|
variant.set_stock(row[:count_on_hand], row[:backorderable], location)
|
|
82
87
|
end
|
|
88
|
+
|
|
89
|
+
# A count change locks and reloads its level, so the locations the
|
|
90
|
+
# response's stock figures read are loaded for every level afterwards.
|
|
91
|
+
ActiveRecord::Associations::Preloader.new(records: variant.stock_levels.to_a, associations: :stock_location).call
|
|
83
92
|
end
|
|
84
93
|
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
# Resolves every stock location the payload names in one read. Scoped
|
|
95
|
+
# through the product's own store: Spree::StockLocation lookup is
|
|
96
|
+
# global, so an id belonging to another store would otherwise put this
|
|
97
|
+
# variant's stock in that store's warehouse. A store keeps few
|
|
98
|
+
# locations, so loading them beats a lookup per row.
|
|
99
|
+
#
|
|
100
|
+
# @return [Hash{String => Spree::StockLocation}] keyed by both the
|
|
101
|
+
# prefixed id and the raw id, which is what the payload may carry
|
|
102
|
+
def stock_locations_by_param(variant, params)
|
|
103
|
+
scope = variant.product&.store&.stock_locations
|
|
104
|
+
return {} if scope.nil? || params.empty?
|
|
105
|
+
|
|
106
|
+
wanted = params.map(&:to_s).uniq
|
|
107
|
+
scope.each_with_object({}) do |location, memo|
|
|
108
|
+
[location.prefixed_id.to_s, location.id.to_s].each do |key|
|
|
109
|
+
memo[key] = location if wanted.include?(key)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
87
112
|
end
|
|
88
113
|
end
|
|
89
114
|
end
|