spree_core 5.5.3 → 5.6.0.rc1
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/helpers/spree/base_helper.rb +12 -2
- data/app/helpers/spree/currency_helper.rb +1 -4
- data/app/helpers/spree/locale_helper.rb +18 -13
- data/app/helpers/spree/localized_names_helper.rb +25 -0
- data/app/mailers/spree/admin_user_mailer.rb +71 -0
- data/app/mailers/spree/base_mailer.rb +73 -11
- data/app/mailers/spree/export_mailer.rb +7 -7
- data/app/mailers/spree/import_mailer.rb +19 -0
- data/app/mailers/spree/invitation_mailer.rb +16 -11
- data/app/mailers/spree/report_mailer.rb +6 -6
- data/app/mailers/spree/webhook_mailer.rb +7 -6
- data/app/models/concerns/spree/admin_user_methods.rb +44 -0
- data/app/models/concerns/spree/legacy_multi_store_support.rb +48 -0
- data/app/models/concerns/spree/presentation_translatable.rb +39 -0
- data/app/models/concerns/spree/product_scopes.rb +21 -3
- data/app/models/concerns/spree/single_store_resource.rb +5 -0
- data/app/models/concerns/spree/store_scoped_resource.rb +10 -0
- data/app/models/concerns/spree/stores/markets.rb +9 -1
- data/app/models/concerns/spree/stores/setup.rb +37 -23
- data/app/models/concerns/spree/translatable_resource.rb +95 -5
- data/app/models/concerns/spree/user_methods.rb +27 -2
- data/app/models/spree/ability.rb +5 -4
- data/app/models/spree/allowed_origin.rb +31 -0
- data/app/models/spree/category.rb +12 -2
- data/app/models/spree/channel/gating.rb +67 -0
- data/app/models/spree/channel.rb +1 -0
- data/app/models/spree/classification.rb +12 -0
- data/app/models/spree/country.rb +22 -1
- data/app/models/spree/credit_card.rb +29 -0
- data/app/models/spree/currency.rb +47 -0
- data/app/models/spree/current.rb +12 -1
- data/app/models/spree/customer_group.rb +1 -0
- data/app/models/spree/export.rb +19 -0
- data/app/models/spree/exports/coupon_codes.rb +1 -2
- data/app/models/spree/import.rb +84 -1
- data/app/models/spree/import_row.rb +7 -1
- data/app/models/spree/imports/product_translations.rb +6 -0
- data/app/models/spree/locale.rb +104 -0
- data/app/models/spree/option_type.rb +11 -13
- data/app/models/spree/option_value.rb +5 -17
- data/app/models/spree/order.rb +19 -2
- data/app/models/spree/payment_method.rb +6 -5
- data/app/models/spree/policy.rb +2 -1
- data/app/models/spree/product.rb +23 -2
- data/app/models/spree/promotion/rules/channel.rb +37 -0
- data/app/models/spree/promotion/rules/market.rb +37 -0
- data/app/models/spree/promotion.rb +5 -3
- data/app/models/spree/promotion_handler/promotion_duplicator.rb +1 -1
- data/app/models/spree/role_user.rb +4 -0
- data/app/models/spree/search_provider/meilisearch.rb +22 -9
- data/app/models/spree/setup_task.rb +12 -0
- data/app/models/spree/setup_tasks.rb +86 -0
- data/app/models/spree/shipment.rb +38 -1
- data/app/models/spree/shipment_handler.rb +1 -1
- data/app/models/spree/stock/availability_validator.rb +1 -1
- data/app/models/spree/stock/quantifier.rb +21 -1
- data/app/models/spree/stock_item.rb +2 -1
- data/app/models/spree/stock_location.rb +4 -1
- data/app/models/spree/store.rb +45 -9
- data/app/models/spree/store_payment_method.rb +5 -1
- data/app/models/spree/store_promotion.rb +4 -0
- data/app/models/spree/taxon.rb +112 -9
- data/app/models/spree/taxonomy.rb +1 -1
- data/app/models/spree/translations/batch.rb +104 -0
- data/app/models/spree/variant.rb +29 -2
- data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
- data/app/services/spree/carts/complete.rb +3 -0
- data/app/services/spree/fulfillments/create.rb +259 -0
- data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
- data/app/services/spree/locales.rb +52 -0
- data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
- data/app/services/spree/shipments/update.rb +1 -1
- data/app/services/spree/stock_reservations/reserve.rb +4 -1
- data/app/services/spree/taxons/add_products.rb +3 -0
- data/app/services/spree/taxons/remove_products.rb +3 -0
- data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
- data/app/subscribers/spree/import_email_subscriber.rb +26 -0
- data/app/views/layouts/spree/base_mailer.html.erb +14 -17
- data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
- data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
- data/app/views/spree/export_mailer/export_done.html.erb +6 -14
- data/app/views/spree/import_mailer/import_done.html.erb +12 -0
- data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
- data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
- data/app/views/spree/report_mailer/report_done.html.erb +6 -14
- data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
- data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
- data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
- data/app/views/spree/shared/_mailer_button.html.erb +10 -0
- data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
- data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
- data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
- data/config/initializers/spree_store_setup_tasks.rb +23 -0
- data/config/locales/en.yml +42 -992
- data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
- data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
- data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
- data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
- data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
- data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
- data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
- data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
- data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
- data/db/sample_data/promotions.rb +1 -1
- data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
- data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
- data/lib/spree/core/configuration.rb +1 -0
- data/lib/spree/core/controller_helpers/locale.rb +4 -1
- data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
- data/lib/spree/core/dependencies.rb +3 -0
- data/lib/spree/core/engine.rb +24 -0
- data/lib/spree/core/preferences/preferable.rb +6 -1
- data/lib/spree/core/previews/admin_user_preview.rb +22 -0
- data/lib/spree/core/previews/export_preview.rb +37 -0
- data/lib/spree/core/previews/invitation_preview.rb +47 -0
- data/lib/spree/core/previews/preview_data.rb +52 -0
- data/lib/spree/core/previews/report_preview.rb +40 -0
- data/lib/spree/core/previews/webhook_preview.rb +34 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +22 -1
- data/lib/spree/permitted_attributes.rb +5 -4
- data/lib/spree/testing_support/factories/export_factory.rb +4 -0
- data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
- data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
- data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
- data/lib/spree/translations.rb +113 -0
- data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
- data/lib/tasks/role_users.rake +21 -0
- data/lib/tasks/single_store_associations.rake +65 -0
- data/lib/tasks/taxons.rake +62 -1
- metadata +47 -5
- data/app/models/spree/product/legacy_multi_store_support.rb +0 -40
|
@@ -158,6 +158,7 @@ module Spree
|
|
|
158
158
|
|
|
159
159
|
Rails.logger.debug { "[Meilisearch] index=#{index_name} query=#{query.inspect} #{search_params.compact.inspect}" }
|
|
160
160
|
|
|
161
|
+
settings_refreshed = false
|
|
161
162
|
begin
|
|
162
163
|
if return_facets && grouped_options.any?
|
|
163
164
|
queries = [{ indexUid: index_name, q: query.to_s, **search_params }]
|
|
@@ -175,6 +176,16 @@ module Spree
|
|
|
175
176
|
facet_distribution = ms_result['facetDistribution'] || {}
|
|
176
177
|
end
|
|
177
178
|
rescue ::Meilisearch::ApiError => e
|
|
179
|
+
# Self-heal when a newly added filterable attribute (e.g. `preorder`)
|
|
180
|
+
# is referenced before the index settings were refreshed — otherwise
|
|
181
|
+
# every search on an upgraded store returns empty until a reindex or
|
|
182
|
+
# the next product write.
|
|
183
|
+
if e.code == 'invalid_search_filter' && !settings_refreshed
|
|
184
|
+
settings_refreshed = true
|
|
185
|
+
ensure_index_settings!
|
|
186
|
+
retry
|
|
187
|
+
end
|
|
188
|
+
|
|
178
189
|
Rails.logger.warn { "[Meilisearch] Search failed: #{e.message}. Run `rake spree:search:reindex` to initialize the index." }
|
|
179
190
|
Rails.error.report(e, handled: true, context: { index: index_name, query: query })
|
|
180
191
|
return nil
|
|
@@ -233,7 +244,7 @@ module Spree
|
|
|
233
244
|
end
|
|
234
245
|
|
|
235
246
|
def filterable_attributes
|
|
236
|
-
%w[product_id status in_stock store_ids channel_ids locale currency available_on discontinue_on price category_ids tags option_value_ids]
|
|
247
|
+
%w[product_id status in_stock preorder store_ids channel_ids locale currency available_on discontinue_on price category_ids tags option_value_ids]
|
|
237
248
|
end
|
|
238
249
|
|
|
239
250
|
def sortable_attributes
|
|
@@ -266,14 +277,16 @@ module Spree
|
|
|
266
277
|
conditions << "status = 'active'"
|
|
267
278
|
conditions << "locale = '#{locale.to_s.gsub(/[^a-zA-Z_-]/, '')}'"
|
|
268
279
|
conditions << "currency = '#{currency.to_s.gsub(/[^A-Z]/, '')}'"
|
|
269
|
-
# Exclude future-dated products — mirrors
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
#
|
|
275
|
-
#
|
|
276
|
-
|
|
280
|
+
# Exclude future-dated products — mirrors
|
|
281
|
+
# +Product.available(Time.current, include_preorderable: true)+. ISO 8601
|
|
282
|
+
# strings sort lexicographically in chronological order, so the string
|
|
283
|
+
# compare is sound; +NOT EXISTS+/+IS NULL+ keep the available_on clause
|
|
284
|
+
# backward-compatible with legacy docs. The trailing +preorder = true+
|
|
285
|
+
# keeps scheduled "coming soon" pre-orders searchable before their
|
|
286
|
+
# publish date. It filters on the new +preorder+ attribute, so a
|
|
287
|
+
# +rake spree:search:reindex+ (or the next product write, which refreshes
|
|
288
|
+
# the index settings) is required after deploy before it takes effect.
|
|
289
|
+
conditions << "(available_on NOT EXISTS OR available_on IS NULL OR available_on <= '#{now.iso8601}' OR preorder = true)"
|
|
277
290
|
conditions << "(discontinue_on = 0 OR discontinue_on > #{now.to_i})"
|
|
278
291
|
conditions
|
|
279
292
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# An evaluated onboarding task — the serializable result of running a
|
|
3
|
+
# {Spree::SetupTasks} registry entry against its subject (e.g. a store).
|
|
4
|
+
# See {Spree::Store#setup_tasks}.
|
|
5
|
+
class SetupTask
|
|
6
|
+
include ActiveModel::Model
|
|
7
|
+
include ActiveModel::Attributes
|
|
8
|
+
|
|
9
|
+
attribute :name, :string
|
|
10
|
+
attribute :done, :boolean
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# Registry of onboarding tasks for a given subject type. `Spree.store_setup_tasks`
|
|
3
|
+
# holds the Getting Started checklist evaluated against a store; future
|
|
4
|
+
# registries (e.g. vendor onboarding) instantiate the same class against
|
|
5
|
+
# their own subject. Defaults are registered in
|
|
6
|
+
# `config/initializers/spree_store_setup_tasks.rb`; extensions and host apps
|
|
7
|
+
# can add or remove tasks at boot:
|
|
8
|
+
#
|
|
9
|
+
# Rails.application.config.after_initialize do
|
|
10
|
+
# Spree.store_setup_tasks.add :connect_stripe,
|
|
11
|
+
# position: 25,
|
|
12
|
+
# done: ->(store) { store.payment_methods.active.exists?(type: 'Spree::PaymentMethod::Stripe') }
|
|
13
|
+
#
|
|
14
|
+
# Spree.store_setup_tasks.remove :setup_taxes_collection
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# The registry holds domain data only — each frontend maps task keys to its
|
|
18
|
+
# own presentation: the legacy admin renders the
|
|
19
|
+
# `spree/admin/dashboard/setup_tasks/_<key>` partial titled by the
|
|
20
|
+
# `admin.store_setup_tasks.<key>` translation (both resolved across engine
|
|
21
|
+
# paths, so extensions ship theirs by convention), and the React dashboard
|
|
22
|
+
# consumes tasks as {Spree::SetupTask} records via the Admin API.
|
|
23
|
+
class SetupTasks
|
|
24
|
+
class Definition
|
|
25
|
+
attr_reader :key, :position
|
|
26
|
+
|
|
27
|
+
def initialize(key, position:, done:, **options)
|
|
28
|
+
raise ArgumentError, "done: must respond to #call" unless done.respond_to?(:call)
|
|
29
|
+
|
|
30
|
+
@key = key.to_sym
|
|
31
|
+
@position = position
|
|
32
|
+
@done = done
|
|
33
|
+
@if = options[:if]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param subject [Object] the record the checklist belongs to (e.g. a store)
|
|
37
|
+
# @return [Boolean] whether the task is complete
|
|
38
|
+
def done?(subject)
|
|
39
|
+
!!@done.call(subject)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param subject [Object] the record the checklist belongs to
|
|
43
|
+
# @return [Boolean] whether the task applies to this subject at all
|
|
44
|
+
def available?(subject)
|
|
45
|
+
@if.nil? || !!@if.call(subject)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def initialize
|
|
50
|
+
@tasks = {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Registers a task (replacing any existing task with the same key).
|
|
54
|
+
#
|
|
55
|
+
# @param key [Symbol] identifies the task; frontends derive presentation from it
|
|
56
|
+
# @param position [Integer] sort order in the checklist
|
|
57
|
+
# @param done [#call] receives the subject, returns whether the task is complete
|
|
58
|
+
# @param options [Hash] optional :if (per-subject visibility callable)
|
|
59
|
+
# @return [Definition]
|
|
60
|
+
def add(key, position:, done:, **options)
|
|
61
|
+
key = key.to_sym
|
|
62
|
+
@tasks[key] = Definition.new(key, position: position, done: done, **options)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param key [Symbol]
|
|
66
|
+
# @return [Definition, nil] the removed task
|
|
67
|
+
def remove(key)
|
|
68
|
+
@tasks.delete(key.to_sym)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def find(key)
|
|
72
|
+
@tasks[key.to_sym]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Array<Definition>] all tasks sorted by position
|
|
76
|
+
def tasks
|
|
77
|
+
@tasks.values.sort_by(&:position)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param subject [Object] the record the checklist belongs to (e.g. a store)
|
|
81
|
+
# @return [Array<Definition>] the tasks applicable to the given subject, sorted by position
|
|
82
|
+
def for(subject)
|
|
83
|
+
tasks.select { |task| task.available?(subject) }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -130,6 +130,17 @@ module Spree
|
|
|
130
130
|
cost
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
# Strict decimal bridge for string input — raises ArgumentError on
|
|
134
|
+
# malformed values instead of letting Rails' cast silently truncate
|
|
135
|
+
# ("12 boxes" would otherwise become 12.0). Blank strings cast to nil,
|
|
136
|
+
# matching the default Rails behavior.
|
|
137
|
+
#
|
|
138
|
+
# @param value [String, Numeric, nil]
|
|
139
|
+
def cost=(value)
|
|
140
|
+
value = value.blank? ? nil : BigDecimal(value.strip) if value.is_a?(String)
|
|
141
|
+
super
|
|
142
|
+
end
|
|
143
|
+
|
|
133
144
|
def digital?
|
|
134
145
|
shipping_method&.digital? || false
|
|
135
146
|
end
|
|
@@ -347,6 +358,23 @@ module Spree
|
|
|
347
358
|
shipping_rates
|
|
348
359
|
end
|
|
349
360
|
|
|
361
|
+
# Public API v3 name for the selected rate (see docs/plans/6.0-fulfillment-and-delivery.md).
|
|
362
|
+
#
|
|
363
|
+
# @return [String, nil] the selected delivery rate's prefixed ID
|
|
364
|
+
def selected_delivery_rate_id
|
|
365
|
+
selected_shipping_rate&.prefixed_id
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Selects a delivery rate by its public prefixed ID (+dr_...+); raw IDs
|
|
369
|
+
# are accepted for internal callers. The model owns this naming bridge so
|
|
370
|
+
# API clients never deal with the legacy shipping-rate name.
|
|
371
|
+
#
|
|
372
|
+
# @param id [String, Integer] delivery rate prefixed or raw ID
|
|
373
|
+
def selected_delivery_rate_id=(id)
|
|
374
|
+
rate = Spree::PrefixedId.prefixed_id?(id) ? shipping_rates.find_by_prefix_id!(id) : shipping_rates.find(id)
|
|
375
|
+
self.selected_shipping_rate_id = rate.id
|
|
376
|
+
end
|
|
377
|
+
|
|
350
378
|
def selected_shipping_rate_id
|
|
351
379
|
selected_shipping_rate.try(:id)
|
|
352
380
|
end
|
|
@@ -422,8 +450,17 @@ module Spree
|
|
|
422
450
|
package
|
|
423
451
|
end
|
|
424
452
|
|
|
453
|
+
# External systems (3PLs, courier APIs) often hand over a complete
|
|
454
|
+
# tracking link rather than a bare tracking code — returned as-is
|
|
455
|
+
# instead of being templated into the delivery method's tracking URL.
|
|
456
|
+
#
|
|
457
|
+
# @return [String, nil]
|
|
425
458
|
def tracking_url
|
|
426
|
-
@tracking_url ||=
|
|
459
|
+
@tracking_url ||= if tracking&.start_with?('https://')
|
|
460
|
+
tracking
|
|
461
|
+
else
|
|
462
|
+
shipping_method&.build_tracking_url(tracking)
|
|
463
|
+
end
|
|
427
464
|
end
|
|
428
465
|
|
|
429
466
|
def update_amounts
|
|
@@ -7,7 +7,7 @@ module Spree
|
|
|
7
7
|
# Do we have a specialized shipping-method-specific handler? e.g:
|
|
8
8
|
# Given shipment.shipping_method = Spree::ShippingMethod::DigitalDownload
|
|
9
9
|
# do we have Spree::ShipmentHandler::DigitalDownload?
|
|
10
|
-
if sm_handler = "Spree::ShipmentHandler::#{shipment.shipping_method
|
|
10
|
+
if sm_handler = "Spree::ShipmentHandler::#{shipment.shipping_method&.name&.split('::')&.last}".safe_constantize
|
|
11
11
|
sm_handler.new(shipment)
|
|
12
12
|
else
|
|
13
13
|
new(shipment)
|
|
@@ -14,7 +14,7 @@ module Spree
|
|
|
14
14
|
display_name = variant.name.to_s
|
|
15
15
|
display_name += " (#{variant.options_text})" unless variant.options_text.blank?
|
|
16
16
|
|
|
17
|
-
if variant.available?
|
|
17
|
+
if variant.available? || variant.preorder?
|
|
18
18
|
line_item.errors.add(:quantity,
|
|
19
19
|
:selected_quantity_not_available,
|
|
20
20
|
message: Spree.t(:selected_quantity_not_available, item: display_name.inspect))
|
|
@@ -88,8 +88,28 @@ module Spree
|
|
|
88
88
|
@backorderable ||= stock_items.any?(&:backorderable)
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# Whether the requested quantity can be supplied. Beyond on-hand stock a
|
|
92
|
+
# variant may oversell two ways: +backorderable+ stock, or a pre-order
|
|
93
|
+
# (which also lifts the publish gate for a scheduled launch). Either way
|
|
94
|
+
# the variant's +backorder_limit+ caps how far below zero it may go; a nil
|
|
95
|
+
# (empty) limit means unlimited.
|
|
91
96
|
def can_supply?(required = 1)
|
|
92
|
-
variant.available?
|
|
97
|
+
return false unless variant.available? || variant.preorder?
|
|
98
|
+
return true unless variant.should_track_inventory?
|
|
99
|
+
|
|
100
|
+
oversellable = backorderable? || variant.preorder?
|
|
101
|
+
limit = variant.backorder_limit
|
|
102
|
+
return true if oversellable && limit.nil?
|
|
103
|
+
|
|
104
|
+
# On-hand stock, plus — for a capped oversell — the room to sell below
|
|
105
|
+
# zero down to +-backorder_limit+ (a single clamp of signed on-hand plus
|
|
106
|
+
# the limit).
|
|
107
|
+
supplyable = if oversellable
|
|
108
|
+
[available_stock - reserved_quantity + limit, 0].max
|
|
109
|
+
else
|
|
110
|
+
total_on_hand
|
|
111
|
+
end
|
|
112
|
+
supplyable >= required
|
|
93
113
|
end
|
|
94
114
|
|
|
95
115
|
def stock_items
|
|
@@ -107,7 +107,8 @@ module Spree
|
|
|
107
107
|
private
|
|
108
108
|
|
|
109
109
|
def verify_count_on_hand?
|
|
110
|
-
count_on_hand_changed? && !backorderable? &&
|
|
110
|
+
count_on_hand_changed? && !backorderable? && !variant.preorder? &&
|
|
111
|
+
(count_on_hand < count_on_hand_was) && (count_on_hand < 0)
|
|
111
112
|
end
|
|
112
113
|
|
|
113
114
|
# Process backorders based on amount of stock received
|
|
@@ -157,7 +157,10 @@ module Spree
|
|
|
157
157
|
else
|
|
158
158
|
on_hand = item.count_on_hand
|
|
159
159
|
on_hand = 0 if on_hand < 0
|
|
160
|
-
|
|
160
|
+
# A pre-order oversells like a backorder: the shortfall becomes
|
|
161
|
+
# backordered inventory units (the backorder_limit cap is enforced
|
|
162
|
+
# upstream by Stock::Quantifier#can_supply?).
|
|
163
|
+
backordered = item.backorderable? || variant.preorder? ? (quantity - on_hand) : 0
|
|
161
164
|
end
|
|
162
165
|
|
|
163
166
|
[on_hand, backordered]
|
data/app/models/spree/store.rb
CHANGED
|
@@ -28,7 +28,7 @@ module Spree
|
|
|
28
28
|
#
|
|
29
29
|
TRANSLATABLE_FIELDS = %i[name meta_description meta_keywords seo_title customer_support_email
|
|
30
30
|
address contact_phone].freeze
|
|
31
|
-
translates(*TRANSLATABLE_FIELDS, column_fallback:
|
|
31
|
+
translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback)
|
|
32
32
|
self::Translation.class_eval do
|
|
33
33
|
acts_as_paranoid
|
|
34
34
|
# deleted translation values still need to be accessible - remove deleted_at scope
|
|
@@ -46,7 +46,14 @@ module Spree
|
|
|
46
46
|
# email preferences
|
|
47
47
|
preference :send_consumer_transactional_emails, :boolean, default: true
|
|
48
48
|
# Checkout preferences
|
|
49
|
+
# Store-level fallback for the channel-owned `guest_checkout` preference
|
|
50
|
+
# (see Spree::Channel::Gating). Retained so existing accessors keep working.
|
|
49
51
|
preference :guest_checkout, :boolean, default: true
|
|
52
|
+
# Store-level fallback for the channel-owned `storefront_access` posture.
|
|
53
|
+
preference :storefront_access, :string, default: 'public'
|
|
54
|
+
# Canonical storefront origin used in customer-facing emails and links,
|
|
55
|
+
# e.g. "https://myshop.com" — see #storefront_url for the fallback chain.
|
|
56
|
+
preference :storefront_url, :string
|
|
50
57
|
preference :special_instructions_enabled, :boolean, default: false
|
|
51
58
|
preference :stock_reservation_ttl_minutes, :integer, default: 10
|
|
52
59
|
# Address preferences
|
|
@@ -74,8 +81,9 @@ module Spree
|
|
|
74
81
|
has_many :return_authorizations, through: :orders, class_name: 'Spree::ReturnAuthorization'
|
|
75
82
|
# has_many :reimbursements, through: :orders, class_name: 'Spree::Reimbursement' FIXME: we should fetch this via Customer Return
|
|
76
83
|
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
# :nullify (not :destroy) — clearing the collection must not cascade into
|
|
85
|
+
# Promotion#not_used? / payment records; orphaned rows are detached, not deleted.
|
|
86
|
+
has_many :payment_methods, class_name: 'Spree::PaymentMethod', dependent: :nullify
|
|
79
87
|
|
|
80
88
|
has_many :products, class_name: 'Spree::Product', dependent: :nullify
|
|
81
89
|
has_many :product_publications, through: :channels, source: :publications, class_name: 'Spree::ProductPublication'
|
|
@@ -91,11 +99,10 @@ module Spree
|
|
|
91
99
|
has_many :store_credit_events, through: :store_credits, class_name: 'Spree::StoreCreditEvent'
|
|
92
100
|
|
|
93
101
|
has_many :taxonomies, class_name: 'Spree::Taxonomy'
|
|
94
|
-
has_many :taxons,
|
|
95
|
-
has_many :categories,
|
|
102
|
+
has_many :taxons, class_name: 'Spree::Taxon'
|
|
103
|
+
has_many :categories, class_name: 'Spree::Category'
|
|
96
104
|
|
|
97
|
-
has_many :
|
|
98
|
-
has_many :promotions, through: :store_promotions, class_name: 'Spree::Promotion'
|
|
105
|
+
has_many :promotions, class_name: 'Spree::Promotion', dependent: :nullify
|
|
99
106
|
|
|
100
107
|
has_many :wishlists, class_name: 'Spree::Wishlist'
|
|
101
108
|
|
|
@@ -133,7 +140,10 @@ module Spree
|
|
|
133
140
|
validates :preferred_digital_asset_authorized_clicks, numericality: { only_integer: true, greater_than: 0 }
|
|
134
141
|
validates :preferred_digital_asset_authorized_days, numericality: { only_integer: true, greater_than: 0 }
|
|
135
142
|
validates :preferred_stock_reservation_ttl_minutes, numericality: { only_integer: true, greater_than: 0 }
|
|
143
|
+
validates :preferred_storefront_access, inclusion: { in: Spree::Channel::Gating::STOREFRONT_ACCESS }
|
|
144
|
+
validate :preferred_storefront_url_is_an_origin
|
|
136
145
|
validates :mail_from_address, email: { allow_blank: false }
|
|
146
|
+
validates :customer_support_email, email: { allow_blank: true }
|
|
137
147
|
# FIXME: we should remove this condition in v5
|
|
138
148
|
if !ENV['SPREE_DISABLE_DB_CONNECTION'] &&
|
|
139
149
|
connected? &&
|
|
@@ -152,6 +162,7 @@ module Spree
|
|
|
152
162
|
#
|
|
153
163
|
# Callbacks
|
|
154
164
|
before_validation :set_default_code, on: :create
|
|
165
|
+
before_validation :normalize_preferred_storefront_url
|
|
155
166
|
before_save :ensure_default_exists_and_is_unique
|
|
156
167
|
after_create :create_default_policies
|
|
157
168
|
|
|
@@ -276,11 +287,15 @@ module Spree
|
|
|
276
287
|
end
|
|
277
288
|
|
|
278
289
|
# Returns the storefront origin URL for use in customer-facing emails and links.
|
|
279
|
-
# Uses the
|
|
290
|
+
# Uses the `storefront_url` preference when set, then the oldest non-loopback
|
|
291
|
+
# allowed origin (the `http://localhost` origin seeded on install must never
|
|
292
|
+
# leak into customer emails), otherwise falls back to formatted_url.
|
|
280
293
|
#
|
|
281
294
|
# @return [String] e.g. "https://myshop.com"
|
|
282
295
|
def storefront_url
|
|
283
|
-
|
|
296
|
+
preferred_storefront_url.presence ||
|
|
297
|
+
allowed_origins.order(:created_at).reject(&:loopback?).first&.origin ||
|
|
298
|
+
formatted_url
|
|
284
299
|
end
|
|
285
300
|
|
|
286
301
|
# Returns true if the given URL's origin matches one of the store's allowed origins.
|
|
@@ -412,5 +427,26 @@ module Spree
|
|
|
412
427
|
def set_default_code
|
|
413
428
|
self.code = 'default' if code.blank?
|
|
414
429
|
end
|
|
430
|
+
|
|
431
|
+
# The storefront URL preference must always hold a canonical origin — it
|
|
432
|
+
# becomes the base for customer-email links and completes the storefront
|
|
433
|
+
# setup task, and the v3 Admin API writes it without any controller-side
|
|
434
|
+
# normalization. Parseable input is canonicalized here; garbage is left
|
|
435
|
+
# in place for the validation below to reject.
|
|
436
|
+
def normalize_preferred_storefront_url
|
|
437
|
+
raw = preferred_storefront_url
|
|
438
|
+
return if raw.blank?
|
|
439
|
+
|
|
440
|
+
normalized = Spree::AllowedOrigin.normalize_origin(raw)
|
|
441
|
+
self.preferred_storefront_url = normalized if normalized
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def preferred_storefront_url_is_an_origin
|
|
445
|
+
raw = preferred_storefront_url
|
|
446
|
+
return if raw.blank?
|
|
447
|
+
return if Spree::AllowedOrigin.normalize_origin(raw)
|
|
448
|
+
|
|
449
|
+
errors.add(:preferred_storefront_url, :invalid)
|
|
450
|
+
end
|
|
415
451
|
end
|
|
416
452
|
end
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
module Spree
|
|
2
|
+
# Legacy join between Store and PaymentMethod. Superseded by the single-store
|
|
3
|
+
# +PaymentMethod#store+ FK in 5.6; retained only so the +spree_multi_store+
|
|
4
|
+
# extension can restore +has_many :stores+ and so the backfill task can read
|
|
5
|
+
# historic attachments. Dropped in 6.0.
|
|
2
6
|
class StorePaymentMethod < Spree.base_class
|
|
3
7
|
self.table_name = 'spree_payment_methods_stores'
|
|
4
8
|
|
|
5
9
|
belongs_to :store, class_name: 'Spree::Store', touch: true
|
|
6
|
-
belongs_to :payment_method, class_name: 'Spree::PaymentMethod', touch: true
|
|
10
|
+
belongs_to :payment_method, class_name: 'Spree::PaymentMethod', touch: true
|
|
7
11
|
|
|
8
12
|
validates :store, :payment_method, presence: true
|
|
9
13
|
validates :store_id, uniqueness: { scope: :payment_method_id }
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
module Spree
|
|
2
|
+
# Legacy join between Store and Promotion. Superseded by the single-store
|
|
3
|
+
# +Promotion#store+ FK in 5.6; retained only so the +spree_multi_store+
|
|
4
|
+
# extension can restore +has_many :stores+ and so the backfill task can read
|
|
5
|
+
# historic attachments. Dropped in 6.0.
|
|
2
6
|
class StorePromotion < Spree.base_class
|
|
3
7
|
self.table_name = 'spree_promotions_stores'
|
|
4
8
|
|
data/app/models/spree/taxon.rb
CHANGED
|
@@ -4,6 +4,8 @@ require 'stringex'
|
|
|
4
4
|
|
|
5
5
|
module Spree
|
|
6
6
|
class Taxon < Spree.base_class
|
|
7
|
+
include Spree::SingleStoreResource
|
|
8
|
+
|
|
7
9
|
has_prefix_id :ctg
|
|
8
10
|
|
|
9
11
|
RULES_MATCH_POLICIES = %w[all any].freeze
|
|
@@ -37,7 +39,7 @@ module Spree
|
|
|
37
39
|
# Associations
|
|
38
40
|
#
|
|
39
41
|
belongs_to :taxonomy, class_name: 'Spree::Taxonomy', inverse_of: :taxons
|
|
40
|
-
|
|
42
|
+
belongs_to :store, class_name: 'Spree::Store', optional: true
|
|
41
43
|
has_many :classifications, -> { order(:position) }, dependent: :destroy_async, inverse_of: :taxon
|
|
42
44
|
has_many :products, through: :classifications
|
|
43
45
|
|
|
@@ -56,9 +58,16 @@ module Spree
|
|
|
56
58
|
#
|
|
57
59
|
# Validations
|
|
58
60
|
#
|
|
59
|
-
validates :name, presence: true
|
|
60
|
-
validates :taxonomy, presence: true
|
|
61
|
-
validates :
|
|
61
|
+
validates :name, presence: true
|
|
62
|
+
validates :taxonomy, presence: true, if: :requires_taxonomy?
|
|
63
|
+
validates :store, presence: true
|
|
64
|
+
# Taxonomy-backed taxons are unique within their taxonomy; taxonomy-less
|
|
65
|
+
# categories (store-owned) are unique within their store, so two stores can
|
|
66
|
+
# each have a top-level "Shoes".
|
|
67
|
+
validates :name, uniqueness: { scope: %i[parent_id taxonomy_id], case_sensitive: false }, if: :requires_taxonomy?
|
|
68
|
+
validates :permalink, uniqueness: { scope: %i[parent_id taxonomy_id], case_sensitive: false }, if: :requires_taxonomy?
|
|
69
|
+
validates :name, uniqueness: { scope: %i[parent_id store_id], case_sensitive: false }, unless: :requires_taxonomy?
|
|
70
|
+
validates :permalink, uniqueness: { scope: %i[parent_id store_id], case_sensitive: false }, unless: :requires_taxonomy?
|
|
62
71
|
validates :hide_from_nav, inclusion: { in: [true, false] }
|
|
63
72
|
validate :check_for_root, on: :create
|
|
64
73
|
validate :parent_belongs_to_same_taxonomy
|
|
@@ -81,13 +90,32 @@ module Spree
|
|
|
81
90
|
after_commit :regenerate_pretty_name_and_permalink, on: :update, if: :should_regenerate_pretty_name_and_permalink?
|
|
82
91
|
after_move :regenerate_pretty_name_and_permalink
|
|
83
92
|
after_move :regenerate_translations_pretty_name_and_permalink
|
|
93
|
+
# Moving a subtree shifts its products under a new ancestor chain (and away
|
|
94
|
+
# from the old one), so recompute both branches' inclusive products_count.
|
|
95
|
+
# Capture the old parent before the move; recompute both chains after.
|
|
96
|
+
before_move :capture_parent_before_move
|
|
97
|
+
after_move :recalculate_products_count_after_move
|
|
98
|
+
# Destroying a taxon drops its subtree's products from every ancestor's
|
|
99
|
+
# inclusive count. Recompute the ancestors here (while the doomed subtree's
|
|
100
|
+
# rows still exist) excluding that subtree, since the Classification destroy
|
|
101
|
+
# callbacks can't help: classifications are removed via destroy_async, so
|
|
102
|
+
# they outlive this callback and their taxon may already be gone.
|
|
103
|
+
before_destroy :recalculate_ancestors_before_destroy, prepend: true
|
|
84
104
|
|
|
85
105
|
#
|
|
86
106
|
# Scopes
|
|
87
107
|
#
|
|
88
|
-
|
|
89
|
-
|
|
108
|
+
# Prefer the direct store_id column; fall back to the taxonomy join for rows
|
|
109
|
+
# not yet backfilled (store_id IS NULL) so legacy behaviour is preserved.
|
|
110
|
+
scope :for_store, ->(store) { for_stores([store]) }
|
|
111
|
+
scope :for_stores, lambda { |stores|
|
|
112
|
+
store_ids = Array(stores).map(&:id)
|
|
113
|
+
taxonomy_ids = Spree::Taxonomy.where(store_id: store_ids).select(:id)
|
|
114
|
+
where(store_id: store_ids).or(where(store_id: nil, taxonomy_id: taxonomy_ids))
|
|
115
|
+
}
|
|
90
116
|
scope :for_taxonomy, lambda { |taxonomy_name|
|
|
117
|
+
Spree::Deprecation.warn('Spree::Taxon.for_taxonomy is deprecated and will be removed in Spree 6. Please use for_store instead.')
|
|
118
|
+
|
|
91
119
|
if Spree.use_translations?
|
|
92
120
|
joins(:taxonomy)
|
|
93
121
|
.join_translation_table(Taxonomy)
|
|
@@ -121,13 +149,14 @@ module Spree
|
|
|
121
149
|
#
|
|
122
150
|
self.whitelisted_ransackable_associations = %w[taxonomy parent]
|
|
123
151
|
self.whitelisted_ransackable_attributes = %w[name permalink automatic depth is_root children_count
|
|
124
|
-
classification_count hide_from_nav parent_id]
|
|
152
|
+
classification_count products_count pretty_name hide_from_nav parent_id]
|
|
125
153
|
|
|
126
154
|
#
|
|
127
155
|
# Translations
|
|
128
156
|
#
|
|
129
157
|
TRANSLATABLE_FIELDS = %i[name pretty_name description permalink].freeze
|
|
130
|
-
|
|
158
|
+
RICH_TEXT_TRANSLATABLE_FIELDS = %i[description].freeze
|
|
159
|
+
translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback)
|
|
131
160
|
|
|
132
161
|
#
|
|
133
162
|
# Action Text
|
|
@@ -154,6 +183,18 @@ module Spree
|
|
|
154
183
|
!automatic?
|
|
155
184
|
end
|
|
156
185
|
|
|
186
|
+
# The owning store. Prefers the direct +store_id+; falls back to the
|
|
187
|
+
# taxonomy's store for legacy rows not yet backfilled.
|
|
188
|
+
def store
|
|
189
|
+
super || taxonomy&.store
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Whether a taxonomy is mandatory. Legacy +Spree::Taxon+ requires one;
|
|
193
|
+
# +Spree::Category+ overrides this to false (it is owned via +store_id+).
|
|
194
|
+
def requires_taxonomy?
|
|
195
|
+
true
|
|
196
|
+
end
|
|
197
|
+
|
|
157
198
|
def manual_sort_order?
|
|
158
199
|
sort_order == 'manual'
|
|
159
200
|
end
|
|
@@ -169,6 +210,26 @@ module Spree
|
|
|
169
210
|
)
|
|
170
211
|
end
|
|
171
212
|
|
|
213
|
+
# Recomputes the stored, descendant-inclusive +products_count+ for the given
|
|
214
|
+
# taxons AND all of their ancestors — the nodes whose inclusive count can
|
|
215
|
+
# shift when a classification changes. Counts unique products classified
|
|
216
|
+
# under each node or its descendants (a product reachable through several
|
|
217
|
+
# nodes is counted once per ancestor). Call after any classification change.
|
|
218
|
+
#
|
|
219
|
+
# @param taxon_ids [Array<Integer>] taxons whose branch counts changed
|
|
220
|
+
def self.recalculate_products_count(taxon_ids)
|
|
221
|
+
changed = unscoped.where(id: Array(taxon_ids).compact.uniq)
|
|
222
|
+
|
|
223
|
+
# The affected nodes are each changed taxon plus its ancestors.
|
|
224
|
+
affected = changed.flat_map { |taxon| taxon.self_and_ancestors.to_a }.uniq(&:id)
|
|
225
|
+
|
|
226
|
+
affected.each do |taxon|
|
|
227
|
+
count = Spree::Classification.where(taxon_id: taxon.self_and_descendants.select(:id))
|
|
228
|
+
.distinct.count(:product_id)
|
|
229
|
+
taxon.update_column(:products_count, count) if taxon.products_count != count
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
172
233
|
def products_matching_rules(opts = {})
|
|
173
234
|
return Spree::Product.none if manual? || rules.empty?
|
|
174
235
|
|
|
@@ -379,6 +440,7 @@ module Spree
|
|
|
379
440
|
end
|
|
380
441
|
|
|
381
442
|
def sync_taxonomy_name
|
|
443
|
+
return unless taxonomy.present?
|
|
382
444
|
return unless saved_changes.key?(:name) && root?
|
|
383
445
|
return if taxonomy.name.to_s == name.to_s
|
|
384
446
|
|
|
@@ -389,7 +451,33 @@ module Spree
|
|
|
389
451
|
# Touches all ancestors at once to avoid recursive taxonomy touch, and reduce queries.
|
|
390
452
|
ancestors.update_all(updated_at: Time.current)
|
|
391
453
|
# Have taxonomy touch happen in #touch_ancestors_and_taxonomy rather than association option in order for imports to override.
|
|
392
|
-
taxonomy.
|
|
454
|
+
taxonomy.touch if taxonomy.present?
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def capture_parent_before_move
|
|
458
|
+
@parent_id_before_move = parent_id_in_database
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Recompute the inclusive products_count for the moved node's new ancestor
|
|
462
|
+
# chain and the previous parent's chain too (whose subtree just lost this
|
|
463
|
+
# node's products).
|
|
464
|
+
def recalculate_products_count_after_move
|
|
465
|
+
affected = [id, @parent_id_before_move].compact
|
|
466
|
+
@parent_id_before_move = nil
|
|
467
|
+
self.class.recalculate_products_count(affected)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Recomputes each ancestor's inclusive products_count as if this subtree were
|
|
471
|
+
# already gone (its classifications are destroyed asynchronously, so they're
|
|
472
|
+
# still present here). Excludes self_and_descendants from the count.
|
|
473
|
+
def recalculate_ancestors_before_destroy
|
|
474
|
+
doomed_ids = self_and_descendants.ids
|
|
475
|
+
ancestors.each do |ancestor|
|
|
476
|
+
remaining = Spree::Classification.
|
|
477
|
+
where(taxon_id: ancestor.self_and_descendants.where.not(id: doomed_ids).select(:id)).
|
|
478
|
+
distinct.count(:product_id)
|
|
479
|
+
ancestor.update_column(:products_count, remaining) if ancestor.products_count != remaining
|
|
480
|
+
end
|
|
393
481
|
end
|
|
394
482
|
|
|
395
483
|
def check_for_root
|
|
@@ -408,6 +496,21 @@ module Spree
|
|
|
408
496
|
self.taxonomy = parent.taxonomy if parent.present? && taxonomy.blank?
|
|
409
497
|
end
|
|
410
498
|
|
|
499
|
+
def set_store
|
|
500
|
+
Spree::Deprecation.warn('Spree::Taxon#set_store is deprecated and will be removed in Spree 6.0. ensure_store instead.')
|
|
501
|
+
ensure_store
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Every taxon is store-owned. Resolve the store from the taxonomy, then the
|
|
505
|
+
# parent, finally the current store — so the direct column is always
|
|
506
|
+
# populated for new records. Guards on the raw +store_id+ column rather than
|
|
507
|
+
# +#store+, whose reader masks an unset column with the taxonomy fallback.
|
|
508
|
+
def ensure_store
|
|
509
|
+
return if store_id.present?
|
|
510
|
+
|
|
511
|
+
self.store = taxonomy&.store || parent&.store || Spree::Store.current
|
|
512
|
+
end
|
|
513
|
+
|
|
411
514
|
def regenerate_translations_pretty_name_and_permalink
|
|
412
515
|
translations.each(&:regenerate_pretty_name_and_permalink)
|
|
413
516
|
end
|
|
@@ -8,7 +8,7 @@ module Spree
|
|
|
8
8
|
include Spree::SingleStoreResource
|
|
9
9
|
|
|
10
10
|
TRANSLATABLE_FIELDS = %i[name].freeze
|
|
11
|
-
translates(*TRANSLATABLE_FIELDS, column_fallback:
|
|
11
|
+
translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback)
|
|
12
12
|
|
|
13
13
|
acts_as_list
|
|
14
14
|
|