spree_core 6.0.0.rc4 → 6.0.0.rc6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +114 -36
- 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/concerns/spree/webhook_payload_redaction.rb +1 -0
- data/app/models/spree/ability.rb +8 -0
- data/app/models/spree/address.rb +6 -0
- 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 +7 -7
- data/app/models/spree/category.rb +76 -5
- data/app/models/spree/channel.rb +79 -40
- data/app/models/spree/company.rb +46 -3
- data/app/models/spree/customer.rb +1 -1
- data/app/models/spree/customer_group.rb +59 -41
- data/app/models/spree/data_request.rb +34 -1
- 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.rb +1 -1
- 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/sales_context.rb +162 -0
- 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/tax_line.rb +5 -4
- data/app/models/spree/variant.rb +23 -6
- data/app/models/spree/webhook_endpoint.rb +45 -11
- data/app/services/spree/carts/destroy.rb +7 -2
- 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/carts/update.rb +1 -4
- 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/data_requests/confirm.rb +37 -0
- data/app/services/spree/data_requests/create.rb +61 -13
- 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 +40 -19
- data/app/workflows/spree/data_requests/fulfill.rb +20 -6
- 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/orders/create.rb +41 -17
- 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 +14 -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/engine.rb +2 -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/data_request_factory.rb +4 -0
- 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 +15 -6
|
@@ -20,9 +20,12 @@ module Spree
|
|
|
20
20
|
|
|
21
21
|
publishes_lifecycle_events
|
|
22
22
|
publishes_event :completed
|
|
23
|
+
publishes_event :confirmation_requested,
|
|
24
|
+
serializer: 'Spree::Api::V3::DataRequestConfirmationRequestedEventSerializer',
|
|
25
|
+
credential: 'write_customers'
|
|
23
26
|
|
|
24
27
|
include Spree::HasStatus
|
|
25
|
-
has_status :pending, :processing, :completed, :failed, default: :pending
|
|
28
|
+
has_status :awaiting_confirmation, :pending, :processing, :completed, :failed, :lapsed, default: :pending
|
|
26
29
|
|
|
27
30
|
ACCESS = 'access'.freeze
|
|
28
31
|
ERASURE = 'erasure'.freeze
|
|
@@ -33,11 +36,23 @@ module Spree
|
|
|
33
36
|
# about them is not left lying around.
|
|
34
37
|
DEFAULT_EXPIRY = 7.days
|
|
35
38
|
|
|
39
|
+
# How long a request a customer raised waits for confirmation from its
|
|
40
|
+
# email address before it lapses.
|
|
41
|
+
CONFIRMATION_WINDOW = 24.hours
|
|
42
|
+
|
|
36
43
|
# Addresses the finished export. Plaintext like the digital-link token:
|
|
37
44
|
# the URL is the credential, it is only ever sent to the address that
|
|
38
45
|
# asked, and it stops working when the request expires.
|
|
39
46
|
has_secure_token :download_token
|
|
40
47
|
|
|
48
|
+
# Proves that whoever confirms reads mail at the address the request
|
|
49
|
+
# names. Bound to
|
|
50
|
+
# the download token, so an erasure, which clears it, voids every link
|
|
51
|
+
# already sent.
|
|
52
|
+
generates_token_for :confirmation do
|
|
53
|
+
download_token
|
|
54
|
+
end
|
|
55
|
+
|
|
41
56
|
belongs_to :customer, class_name: Spree.customer_class.to_s
|
|
42
57
|
# Null when the subject asked for it themselves; set when staff acted on a
|
|
43
58
|
# request that arrived by email.
|
|
@@ -52,6 +67,8 @@ module Spree
|
|
|
52
67
|
scope :access, -> { where(kind: ACCESS) }
|
|
53
68
|
scope :erasure, -> { where(kind: ERASURE) }
|
|
54
69
|
scope :in_progress, -> { with_status(:pending, :processing) }
|
|
70
|
+
scope :confirmable, -> { awaiting_confirmation.where(requested_at: CONFIRMATION_WINDOW.ago..) }
|
|
71
|
+
scope :past_confirmation_window, -> { awaiting_confirmation.where(requested_at: ...CONFIRMATION_WINDOW.ago) }
|
|
55
72
|
scope :recent_first, -> { order(requested_at: :desc) }
|
|
56
73
|
|
|
57
74
|
self.whitelisted_ransackable_attributes = %w[number kind status email requested_at completed_at]
|
|
@@ -89,6 +106,22 @@ module Spree
|
|
|
89
106
|
expires_at.present? && expires_at.past?
|
|
90
107
|
end
|
|
91
108
|
|
|
109
|
+
# Whether the request is still waiting for confirmation, within the window
|
|
110
|
+
# to receive it, and still names the account's address.
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def confirmable?
|
|
113
|
+
awaiting_confirmation? && requested_at > CONFIRMATION_WINDOW.ago && addressed_to_customer?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Whether the request names the address the account has now. The work
|
|
117
|
+
# matches guest rows by the account's address when it runs, so a request
|
|
118
|
+
# for an address the account has since left would reach rows it never
|
|
119
|
+
# named.
|
|
120
|
+
# @return [Boolean]
|
|
121
|
+
def addressed_to_customer?
|
|
122
|
+
customer.present? && email.to_s.casecmp?(customer.email.to_s)
|
|
123
|
+
end
|
|
124
|
+
|
|
92
125
|
private
|
|
93
126
|
|
|
94
127
|
def set_defaults
|
|
@@ -451,10 +451,13 @@ module Spree
|
|
|
451
451
|
calculator.description + ': ' +
|
|
452
452
|
|
|
453
453
|
if calculator.is_a?(Spree::Calculator::Shipping::FlatRate)
|
|
454
|
-
|
|
454
|
+
amount = calculator.amount_for(calculator.preferred_currency)
|
|
455
|
+
if amount.nil?
|
|
456
|
+
''
|
|
457
|
+
elsif amount.zero?
|
|
455
458
|
I18n.t('spree.free')
|
|
456
459
|
else
|
|
457
|
-
Spree::Money.new(
|
|
460
|
+
Spree::Money.new(amount, { currency: calculator.preferred_currency }).to_s
|
|
458
461
|
end
|
|
459
462
|
elsif calculator.is_a?(Spree::Calculator::Shipping::FlexiRate)
|
|
460
463
|
Spree::Money.new(calculator.preferred_first_item, { currency: calculator.preferred_currency }).to_s
|
|
@@ -497,14 +500,21 @@ module Spree
|
|
|
497
500
|
reconcile_services(pending)
|
|
498
501
|
end
|
|
499
502
|
|
|
503
|
+
# Matched against the method's rows loaded once, rather than a lookup per
|
|
504
|
+
# row. A row created here joins that list, so a later row naming the
|
|
505
|
+
# same (carrier, service) updates it as a lookup would.
|
|
500
506
|
def reconcile_services(rows)
|
|
507
|
+
existing = services.reload.to_a
|
|
501
508
|
kept_ids = rows.filter_map do |row|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
record
|
|
509
|
+
if row[:id].present?
|
|
510
|
+
id = Spree::PrefixedId.prefixed_id?(row[:id]) ? Spree::PrefixedId.decode_prefixed_id(row[:id]) : row[:id]
|
|
511
|
+
record = existing.find { |service| service.id.to_s == id.to_s }
|
|
512
|
+
end
|
|
513
|
+
record ||= existing.find { |service| service.carrier == row[:carrier] && service.service == row[:service] }
|
|
514
|
+
unless record
|
|
515
|
+
record = services.build(carrier: row[:carrier], service: row[:service])
|
|
516
|
+
existing << record
|
|
517
|
+
end
|
|
508
518
|
record.assign_attributes(row.slice(:carrier, :service, :label, :markup_flat, :markup_percent, :position).to_h)
|
|
509
519
|
record.save!
|
|
510
520
|
record.id
|
|
@@ -123,6 +123,24 @@ module Spree
|
|
|
123
123
|
delivery_origin_groups.first
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
+
# How many products use this profile. Counted for every profile of a
|
|
127
|
+
# lazily preloaded list in one grouped query, since loading the products
|
|
128
|
+
# themselves to count them would fetch the whole catalog.
|
|
129
|
+
#
|
|
130
|
+
# @return [Integer]
|
|
131
|
+
def products_count
|
|
132
|
+
return @products_count if defined?(@products_count)
|
|
133
|
+
|
|
134
|
+
profiles = lazy_preload_context&.records.presence || [self]
|
|
135
|
+
counts = Spree::Product.where(delivery_profile_id: profiles.map(&:id)).group(:delivery_profile_id).count
|
|
136
|
+
profiles.each { |profile| profile.products_count = counts.fetch(profile.id, 0) }
|
|
137
|
+
@products_count
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
protected
|
|
141
|
+
|
|
142
|
+
attr_writer :products_count
|
|
143
|
+
|
|
126
144
|
private
|
|
127
145
|
|
|
128
146
|
def create_default_origin_group
|
|
@@ -25,6 +25,20 @@ module Spree
|
|
|
25
25
|
# and provider-backed assets (which carry no file) are exempt.
|
|
26
26
|
DEFAULT_PROVIDER = 'Spree::DigitalAssetProvider::File'.freeze
|
|
27
27
|
|
|
28
|
+
# What a seller may upload unless the marketplace says otherwise: the
|
|
29
|
+
# formats digital goods usually come in. Executables, scripts and
|
|
30
|
+
# unrecognised binaries are left out on purpose, as is SVG, which can
|
|
31
|
+
# carry scripts. A marketplace selling a niche format adds it in settings.
|
|
32
|
+
# The text formats have no signature, so they are taken at the type the
|
|
33
|
+
# upload declared.
|
|
34
|
+
DEFAULT_SELLER_CONTENT_TYPES = %w[
|
|
35
|
+
application/pdf application/epub+zip application/zip application/x-7z-compressed application/gzip
|
|
36
|
+
application/json text/plain text/csv
|
|
37
|
+
audio/mpeg audio/mp4 audio/aac audio/flac audio/ogg audio/wav audio/x-wav audio/midi
|
|
38
|
+
video/mp4 video/webm video/quicktime
|
|
39
|
+
image/jpeg image/png image/gif image/webp image/tiff
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
28
42
|
# The API names a provider by its shorthand (`file`); the column keeps the
|
|
29
43
|
# class name.
|
|
30
44
|
normalizes :provider_type, with: ->(value) { Spree::ApiTyped.class_name_for(Spree.digital_asset_providers, value) }
|
data/app/models/spree/event.rb
CHANGED
|
@@ -86,14 +86,29 @@ module Spree
|
|
|
86
86
|
# @return [Boolean]
|
|
87
87
|
def self.matches?(event_name, pattern)
|
|
88
88
|
return true if pattern == '*'
|
|
89
|
+
return event_name == pattern unless pattern.include?('*')
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
wildcard_match?(event_name, pattern)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# `*` stands for any run of characters, as in a shell glob. The comparison
|
|
95
|
+
# is plain string searching so a pattern taken from a merchant never
|
|
96
|
+
# reaches the regexp engine, which has had memory bugs in pattern parsing.
|
|
97
|
+
def self.wildcard_match?(event_name, pattern)
|
|
98
|
+
head, *middle, tail = pattern.split('*', -1)
|
|
99
|
+
return false unless event_name.start_with?(head) && event_name.end_with?(tail)
|
|
100
|
+
return false if event_name.length < head.length + tail.length
|
|
101
|
+
|
|
102
|
+
position = head.length
|
|
103
|
+
limit = event_name.length - tail.length
|
|
104
|
+
middle.all? do |segment|
|
|
105
|
+
found = event_name.index(segment, position)
|
|
106
|
+
next false if found.nil? || found + segment.length > limit
|
|
107
|
+
|
|
108
|
+
position = found + segment.length
|
|
95
109
|
end
|
|
96
110
|
end
|
|
111
|
+
private_class_method :wildcard_match?
|
|
97
112
|
|
|
98
113
|
alias_method :to_h, :attributes
|
|
99
114
|
|
|
@@ -653,7 +653,14 @@ module Spree
|
|
|
653
653
|
#
|
|
654
654
|
# @return [Spree::DeliveryMethod]
|
|
655
655
|
def delivery_method
|
|
656
|
-
|
|
656
|
+
selected = selected_delivery_rate
|
|
657
|
+
# The same row read through the rate list, when that list is to hand,
|
|
658
|
+
# so its method loads with every other rate's instead of on its own.
|
|
659
|
+
if selected && read_association_in_memory?(:delivery_rates)
|
|
660
|
+
selected = delivery_rates.detect { |rate| rate.id == selected.id } || selected
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
selected&.delivery_method || delivery_rates.first&.delivery_method
|
|
657
664
|
end
|
|
658
665
|
|
|
659
666
|
# The fulfillment provider strategy handling this fulfillment's
|
|
@@ -138,7 +138,9 @@ module Spree
|
|
|
138
138
|
end
|
|
139
139
|
|
|
140
140
|
def reload_shipment_inventory_units
|
|
141
|
-
[current_shipment, desired_shipment]
|
|
141
|
+
shipments = [current_shipment, desired_shipment]
|
|
142
|
+
shipments.each { |shipment| shipment.association(:fulfillment_items).reset }
|
|
143
|
+
ActiveRecord::Associations::Preloader.new(records: shipments, associations: :fulfillment_items).call
|
|
142
144
|
end
|
|
143
145
|
|
|
144
146
|
def current_quantity
|
|
@@ -107,6 +107,17 @@ module Spree
|
|
|
107
107
|
split
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# `dup` copies the foreign keys but not the loaded records, so every copy
|
|
111
|
+
# made while packing and splitting would look its line item and variant
|
|
112
|
+
# up again.
|
|
113
|
+
def initialize_dup(other)
|
|
114
|
+
super
|
|
115
|
+
%i[line_item variant].each do |name|
|
|
116
|
+
association = other.association(name)
|
|
117
|
+
self.association(name).target = association.target if association.loaded?
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
110
121
|
# This will fail if extract >= available_quantity
|
|
111
122
|
def split_inventory!(extract_quantity)
|
|
112
123
|
split = self.class.split(self, extract_quantity)
|
data/app/models/spree/import.rb
CHANGED
|
@@ -215,10 +215,22 @@ module Spree
|
|
|
215
215
|
end
|
|
216
216
|
|
|
217
217
|
# Per-status row counts for API status payloads, memoized so one request
|
|
218
|
-
# issues a single grouped COUNT instead of one per status
|
|
218
|
+
# issues a single grouped COUNT instead of one per status — and, on a
|
|
219
|
+
# lazily preloaded list, one for every import of the list.
|
|
219
220
|
# @return [Hash{String => Integer}]
|
|
220
221
|
def rows_status_counts
|
|
221
|
-
@rows_status_counts
|
|
222
|
+
return @rows_status_counts if @rows_status_counts
|
|
223
|
+
|
|
224
|
+
siblings = lazy_preload_context&.records&.grep(Spree::Import)
|
|
225
|
+
return @rows_status_counts = rows.group(:status).count if siblings.blank?
|
|
226
|
+
|
|
227
|
+
counts = Spree::ImportRow.where(import_id: siblings.map(&:id)).group(:import_id, :status).count
|
|
228
|
+
siblings.each do |import|
|
|
229
|
+
import.preloaded_rows_status_counts = counts.each_with_object({}) do |((import_id, status), count), by_status|
|
|
230
|
+
by_status[status] = count if import_id == import.id
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
@rows_status_counts
|
|
222
234
|
end
|
|
223
235
|
|
|
224
236
|
# Public API name for the +results_url+ preference (read/write symmetry).
|
|
@@ -474,6 +486,12 @@ module Spree
|
|
|
474
486
|
end
|
|
475
487
|
end
|
|
476
488
|
|
|
489
|
+
protected
|
|
490
|
+
|
|
491
|
+
def preloaded_rows_status_counts=(counts)
|
|
492
|
+
@rows_status_counts = counts
|
|
493
|
+
end
|
|
494
|
+
|
|
477
495
|
private
|
|
478
496
|
|
|
479
497
|
# Mirrors the machine's bang events: an illegal move raised.
|
data/app/models/spree/order.rb
CHANGED
|
@@ -152,7 +152,7 @@ module Spree
|
|
|
152
152
|
self.whitelisted_ransackable_attributes = %w[
|
|
153
153
|
completed_at email number status payment_status payment_state fulfillment_status shipment_state delivery_total
|
|
154
154
|
total item_total total_quantity considered_risky channel_id currency coupon_code customer_id seller_id
|
|
155
|
-
order_group_id po_number
|
|
155
|
+
order_group_id po_number market_id locale
|
|
156
156
|
]
|
|
157
157
|
self.whitelisted_ransackable_scopes = %w[complete incomplete refunded partially_refunded search]
|
|
158
158
|
self.ransackable_scope_types = %w[complete incomplete refunded partially_refunded].index_with('boolean')
|
|
@@ -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
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# Who buys, on which channel, in which market: what a buyer can be sold, and
|
|
3
|
+
# at what price, depends on all of these together. A market fixes the
|
|
4
|
+
# currency and the languages a sale may use.
|
|
5
|
+
class SalesContext
|
|
6
|
+
include ActiveModel::Model
|
|
7
|
+
include ActiveModel::Attributes
|
|
8
|
+
|
|
9
|
+
attribute :store
|
|
10
|
+
attribute :market
|
|
11
|
+
attribute :channel
|
|
12
|
+
attribute :customer
|
|
13
|
+
attribute :company
|
|
14
|
+
attribute :country
|
|
15
|
+
attribute :currency, :string
|
|
16
|
+
attribute :locale, :string
|
|
17
|
+
# A placed sale keeps the company it was stamped with, even none, so only
|
|
18
|
+
# a sale still being put together looks up the customer's company.
|
|
19
|
+
attribute :resolve_company, :boolean, default: true
|
|
20
|
+
|
|
21
|
+
validate :currency_sold_in_a_market
|
|
22
|
+
validate :currency_matches_market
|
|
23
|
+
validate :locale_supported_by_market
|
|
24
|
+
|
|
25
|
+
# @param order [Spree::Order]
|
|
26
|
+
# @return [Spree::SalesContext]
|
|
27
|
+
def self.from_order(order)
|
|
28
|
+
new(
|
|
29
|
+
store: order.store, market: order.market, channel: order.channel, customer: order.customer,
|
|
30
|
+
company: order.resolved_company, resolve_company: false, country: order.tax_country,
|
|
31
|
+
currency: order.currency, locale: order.locale
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The market chosen for the sale, else one selling the requested currency,
|
|
36
|
+
# else the store's default.
|
|
37
|
+
#
|
|
38
|
+
# @return [Spree::Market, nil]
|
|
39
|
+
def market
|
|
40
|
+
chosen = super
|
|
41
|
+
return chosen if chosen
|
|
42
|
+
return @resolved_market if defined?(@resolved_market)
|
|
43
|
+
|
|
44
|
+
@resolved_market = resolve_market
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def channel
|
|
48
|
+
super || store&.default_channel
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [String] the requested currency, else the market's, else the store's
|
|
52
|
+
def currency
|
|
53
|
+
requested_currency || market&.currency || store&.default_currency
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [String, nil] the requested language, else the market's default
|
|
57
|
+
def locale
|
|
58
|
+
super.presence || market&.default_locale
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The company chosen for the sale, else the one the customer stands in
|
|
62
|
+
# alone — the same answer pricing and catalog visibility reach.
|
|
63
|
+
#
|
|
64
|
+
# @return [Spree::Company, nil]
|
|
65
|
+
def company
|
|
66
|
+
chosen = super
|
|
67
|
+
return chosen if chosen || !resolve_company
|
|
68
|
+
return @standing_company if defined?(@standing_company)
|
|
69
|
+
|
|
70
|
+
@standing_company =
|
|
71
|
+
if customer.is_a?(Spree.customer_class) && store
|
|
72
|
+
Spree::Company.sole_standing_for(store: store, customer: customer)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Variants the buyer may order on the channel: published there and inside
|
|
77
|
+
# their catalogs.
|
|
78
|
+
#
|
|
79
|
+
# @return [ActiveRecord::Relation<Spree::Variant>]
|
|
80
|
+
def orderable_variants
|
|
81
|
+
@orderable_variants ||= Spree::Variant.for_products(
|
|
82
|
+
Spree.products_for_context_service.call(
|
|
83
|
+
store: store, channel: channel, customer: customer, company: company,
|
|
84
|
+
base: store.products.not_discontinued_on(channel)
|
|
85
|
+
).value
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Orderable variants the buyer can be quoted in the sale's currency: a base
|
|
90
|
+
# price, or a price from a list that applies to them, so a product sold to
|
|
91
|
+
# a buyer only under their agreement is offered to that buyer alone.
|
|
92
|
+
#
|
|
93
|
+
# @return [ActiveRecord::Relation<Spree::Variant>]
|
|
94
|
+
def sellable_variants
|
|
95
|
+
prices = Spree::Price.where(variant_id: orderable_variants.select(:id), currency: currency)
|
|
96
|
+
.where.not(amount: nil)
|
|
97
|
+
.where(price_list_id: [nil, *applicable_price_list_ids])
|
|
98
|
+
|
|
99
|
+
orderable_variants.where(id: prices.select(:variant_id))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [Spree::Pricing::Context] the context prices are resolved in
|
|
103
|
+
def pricing_context
|
|
104
|
+
@pricing_context ||= Spree::Pricing::Context.new(
|
|
105
|
+
currency: currency, store: store, country: country, market: market,
|
|
106
|
+
channel: channel, user: customer, company: company
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def requested_currency
|
|
113
|
+
attributes['currency'].presence&.upcase
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def resolve_market
|
|
117
|
+
return unless store
|
|
118
|
+
|
|
119
|
+
candidates = [(Spree::Current.market if Spree::Current.store == store), store.default_market].compact
|
|
120
|
+
return candidates.first unless requested_currency
|
|
121
|
+
|
|
122
|
+
candidates.find { |candidate| candidate.currency == requested_currency } ||
|
|
123
|
+
store.markets.find_by(currency: requested_currency)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Catalog price lists the buyer's catalogs carry, and standalone lists
|
|
127
|
+
# whose rules match the sale, judged the way the internal pricing provider
|
|
128
|
+
# judges them.
|
|
129
|
+
def applicable_price_list_ids
|
|
130
|
+
@applicable_price_list_ids ||= begin
|
|
131
|
+
catalogs = Spree::Catalog.groups_for_buyer(store: store, customer: customer, company: company, channel: channel)
|
|
132
|
+
catalog_lists = catalogs.flatten.filter_map(&:price_list).select do |list|
|
|
133
|
+
list.currently_active? && list.contextual_rules_applicable?(pricing_context)
|
|
134
|
+
end
|
|
135
|
+
standalone_lists = Spree::PriceList.for_context(pricing_context).select { |list| list.applicable?(pricing_context) }
|
|
136
|
+
|
|
137
|
+
(catalog_lists + standalone_lists).map(&:id).uniq
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def currency_sold_in_a_market
|
|
142
|
+
return if requested_currency.nil? || market || store.nil? || !store.markets.exists?
|
|
143
|
+
|
|
144
|
+
errors.add(:currency, :currency_not_supported_by_store, message: I18n.t('spree.currency_not_supported_by_store'))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def currency_matches_market
|
|
148
|
+
return if market.nil? || currency == market.currency
|
|
149
|
+
|
|
150
|
+
errors.add(
|
|
151
|
+
:currency, :currency_does_not_match_market,
|
|
152
|
+
message: I18n.t('spree.currency_does_not_match_market', currency: market.currency)
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def locale_supported_by_market
|
|
157
|
+
return if market.nil? || locale.blank? || market.supported_locales_list.include?(locale)
|
|
158
|
+
|
|
159
|
+
errors.add(:locale, :locale_not_supported_by_market, message: I18n.t('spree.locale_not_supported_by_market'))
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
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.
|