spree_core 5.5.2 → 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/jobs/spree/imports/process_group_job.rb +20 -9
- data/app/jobs/spree/imports/process_rows_job.rb +12 -4
- 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 +93 -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/imports/row_processors/base.rb +9 -0
- data/app/services/spree/imports/row_processors/product_variant.rb +37 -19
- 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 +6 -5
- 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
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
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Translations
|
|
3
|
+
# Atomically upserts translations across MANY records of (possibly)
|
|
4
|
+
# different translatable resource types in one operation — the domain
|
|
5
|
+
# behind +POST /api/v3/admin/translations/batch+.
|
|
6
|
+
#
|
|
7
|
+
# The input is a flat list of independent registry writes, each naming its
|
|
8
|
+
# own +resource_type+ + +resource_id+ (no nested parent-owns-children
|
|
9
|
+
# payload), so there's no per-model branching. Records are resolved within
|
|
10
|
+
# the current store, then upserted in a single transaction: any failure
|
|
11
|
+
# rolls back the whole batch and surfaces the offending entry's index via a
|
|
12
|
+
# typed {EntryError}.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# Spree::Translations::Batch.new(entries).process! do |record|
|
|
16
|
+
# authorize!(:update, record)
|
|
17
|
+
# end
|
|
18
|
+
class Batch
|
|
19
|
+
include ActiveModel::Model
|
|
20
|
+
|
|
21
|
+
# Raised when the batch payload is empty (or not a list of entries).
|
|
22
|
+
class EmptyError < StandardError; end
|
|
23
|
+
|
|
24
|
+
# Raised when an entry can't be processed — unknown/non-translatable
|
|
25
|
+
# resource type, a record missing in the current store, or an invalid
|
|
26
|
+
# save. Carries the entry +index+ so the caller can map the error back to
|
|
27
|
+
# the offending row.
|
|
28
|
+
class EntryError < StandardError
|
|
29
|
+
attr_reader :index
|
|
30
|
+
|
|
31
|
+
def initialize(message, index)
|
|
32
|
+
@index = index
|
|
33
|
+
super(message)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<Spree.base_class>] records written by the last successful {#process!}
|
|
38
|
+
attr_reader :records
|
|
39
|
+
|
|
40
|
+
# @param entries [Array<Hash>] [{ resource_type:, resource_id:, values: { locale => { field => value } } }]
|
|
41
|
+
def initialize(entries)
|
|
42
|
+
@entries = Array(entries)
|
|
43
|
+
@records = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Distinct +write_<resource>+ scopes a caller needs to authorize this
|
|
47
|
+
# batch — one per resource type present. Lets the API layer gate an
|
|
48
|
+
# API-key request without re-deriving scope names.
|
|
49
|
+
# @return [Array<String>]
|
|
50
|
+
def required_scopes
|
|
51
|
+
@entries.map { |entry| "write_#{entry[:resource_type].to_s.pluralize}" }.uniq
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Upserts every entry in one transaction. Yields each resolved record
|
|
55
|
+
# before it is written so the caller can authorize it; a raised error in
|
|
56
|
+
# the block (or any entry failure) rolls back the whole batch.
|
|
57
|
+
#
|
|
58
|
+
# @yieldparam record [Spree.base_class] the resolved record, pre-write
|
|
59
|
+
# @raise [EmptyError] when there are no entries
|
|
60
|
+
# @raise [EntryError] when an entry can't be resolved or saved
|
|
61
|
+
# @return [Array<Spree.base_class>] the written records
|
|
62
|
+
def process!
|
|
63
|
+
raise EmptyError if @entries.empty?
|
|
64
|
+
|
|
65
|
+
@records = []
|
|
66
|
+
ActiveRecord::Base.transaction do
|
|
67
|
+
@entries.each_with_index do |entry, index|
|
|
68
|
+
record = resolve_record!(entry, index)
|
|
69
|
+
yield record if block_given?
|
|
70
|
+
upsert!(record, entry[:values], index)
|
|
71
|
+
@records << record
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
@records
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def upsert!(record, values, index)
|
|
80
|
+
record.upsert_translations(values)
|
|
81
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
82
|
+
raise EntryError.new(e.record.errors.full_messages.join(', '), index)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def resolve_record!(entry, index)
|
|
86
|
+
klass = resource_class(entry[:resource_type])
|
|
87
|
+
raise EntryError.new("Unknown translatable resource type: #{entry[:resource_type]}", index) if klass.nil?
|
|
88
|
+
|
|
89
|
+
relation = klass.respond_to?(:for_store) ? klass.for_store(Spree::Store.current) : klass
|
|
90
|
+
relation.find_by_prefix_id!(entry[:resource_id])
|
|
91
|
+
rescue ActiveRecord::RecordNotFound
|
|
92
|
+
raise EntryError.new("Resource not found: #{entry[:resource_id]}", index)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Memoized per instance so a batch of N entries doesn't rebuild the
|
|
96
|
+
# registry map N times. A Batch is request-scoped, so dev-mode class
|
|
97
|
+
# reloads are still picked up on the next request.
|
|
98
|
+
def resource_class(token)
|
|
99
|
+
@resource_class_map ||= Hash.new { |h, t| h[t] = Spree::Translations.resource_class(t) }
|
|
100
|
+
@resource_class_map[token]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/app/models/spree/variant.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Spree
|
|
|
13
13
|
|
|
14
14
|
publishes_lifecycle_events
|
|
15
15
|
|
|
16
|
-
MEMOIZED_METHODS = %w(
|
|
16
|
+
MEMOIZED_METHODS = %w(in_stock on_sale backorderable tax_category options_text compare_at_price)
|
|
17
17
|
|
|
18
18
|
DIMENSION_UNITS = %w[mm cm in ft]
|
|
19
19
|
WEIGHT_UNITS = %w[g kg lb oz]
|
|
@@ -88,6 +88,8 @@ module Spree
|
|
|
88
88
|
validates :dimensions_unit, inclusion: { in: DIMENSION_UNITS }, allow_blank: true
|
|
89
89
|
validates :weight_unit, inclusion: { in: WEIGHT_UNITS }, allow_blank: true
|
|
90
90
|
|
|
91
|
+
validates :backorder_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
|
|
92
|
+
|
|
91
93
|
after_create :create_stock_items
|
|
92
94
|
after_create :set_master_out_of_stock, unless: :is_master?
|
|
93
95
|
after_commit :clear_line_items_cache, on: :update
|
|
@@ -238,6 +240,19 @@ module Spree
|
|
|
238
240
|
!discontinued? && product.available?
|
|
239
241
|
end
|
|
240
242
|
|
|
243
|
+
# Returns true if the variant is sold as a pre-order: the product is active
|
|
244
|
+
# and not deleted, the variant is flagged preorderable and not discontinued,
|
|
245
|
+
# and the "ships by" date is open-ended or still in the future.
|
|
246
|
+
# Pre-order relaxes only the publish-date embargo, not the rest of the
|
|
247
|
+
# availability rules. Like backorder, a pre-order can make the variant
|
|
248
|
+
# purchasable beyond on-hand stock; the oversell cap is +backorder_limit+
|
|
249
|
+
# (empty ⇒ unlimited), and it adds the ship-by promise.
|
|
250
|
+
# @return [Boolean] true if the variant is a pre-order
|
|
251
|
+
def preorder?
|
|
252
|
+
!discontinued? && preorderable? && product.active? && !product.deleted? &&
|
|
253
|
+
(preorder_ships_at.nil? || preorder_ships_at > Time.current)
|
|
254
|
+
end
|
|
255
|
+
|
|
241
256
|
# Returns true if the variant is in stock or backorderable.
|
|
242
257
|
# @return [Boolean] true if the variant is in stock or backorderable
|
|
243
258
|
def in_stock_or_backorderable?
|
|
@@ -647,7 +662,19 @@ module Spree
|
|
|
647
662
|
alias is_backorderable? backorderable?
|
|
648
663
|
|
|
649
664
|
def purchasable?
|
|
650
|
-
|
|
665
|
+
in_stock? || oversellable_now?
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Whether the variant can currently be bought by overselling — via
|
|
669
|
+
# backorder or pre-order. Unlimited when +backorder_limit+ is nil (empty =
|
|
670
|
+
# no cap); with a limit, purchasable only while the oversell allowance
|
|
671
|
+
# remains.
|
|
672
|
+
#
|
|
673
|
+
# @return [Boolean]
|
|
674
|
+
def oversellable_now?
|
|
675
|
+
return false unless backorderable? || preorder?
|
|
676
|
+
|
|
677
|
+
backorder_limit.nil? || can_supply?(1)
|
|
651
678
|
end
|
|
652
679
|
|
|
653
680
|
# Shortcut method to determine if inventory tracking is enabled for this variant
|
|
@@ -58,6 +58,9 @@ module Spree
|
|
|
58
58
|
status: product.status,
|
|
59
59
|
sku: product.sku,
|
|
60
60
|
in_stock: product.in_stock?,
|
|
61
|
+
# True when the product has an active pre-order variant, so a
|
|
62
|
+
# scheduled (future-published) launch still surfaces in search.
|
|
63
|
+
preorder: product.preorder?,
|
|
61
64
|
store_ids: Array(product.store_id).map(&:to_s),
|
|
62
65
|
channel_ids: channel_ids_for_store,
|
|
63
66
|
discontinue_on: product.discontinue_on&.to_i || 0,
|
|
@@ -10,6 +10,9 @@ module Spree
|
|
|
10
10
|
def call(cart:)
|
|
11
11
|
return success(cart) if cart.completed?
|
|
12
12
|
return failure(cart, 'Order is canceled') if cart.canceled?
|
|
13
|
+
# Enforced here (not only in the controller) so every completion path —
|
|
14
|
+
# API, payment-session webhook — honors the channel's guest-checkout gate.
|
|
15
|
+
return failure(cart, Spree.t(:guest_checkout_not_allowed)) if cart.guest_checkout_disallowed?
|
|
13
16
|
|
|
14
17
|
cart.with_lock do
|
|
15
18
|
process_payments!(cart) if cart.payment_required?
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Fulfillments
|
|
3
|
+
# Manually creates a fulfillment (Spree::Shipment) on a completed order,
|
|
4
|
+
# bypassing order routing. Moves the requested quantities of each line
|
|
5
|
+
# item's not-yet-shipped inventory units out of their current shipments
|
|
6
|
+
# into the new fulfillment, mirroring externally-managed fulfillment
|
|
7
|
+
# (3PL, courier API, drop-shipping) back into Spree.
|
|
8
|
+
#
|
|
9
|
+
# Stock bookkeeping follows the split/transfer semantics: when the source
|
|
10
|
+
# and target stock locations differ, moved quantities are restocked at the
|
|
11
|
+
# source and unstocked at the target. Source shipments left empty are
|
|
12
|
+
# destroyed and their cost and selected delivery method carry over to the
|
|
13
|
+
# new fulfillment, unless the caller provides its own +cost+ /
|
|
14
|
+
# +delivery_method+.
|
|
15
|
+
#
|
|
16
|
+
# The resulting cost is frozen only for fulfillments registered with
|
|
17
|
+
# status: 'shipped' (rate refresh skips shipped shipments). Pending/ready
|
|
18
|
+
# fulfillments participate in the standard rate machinery — the order
|
|
19
|
+
# updater re-prices them from the delivery method calculators on the next
|
|
20
|
+
# recalculation, exactly like shipments created via split/transfer.
|
|
21
|
+
class Create
|
|
22
|
+
prepend Spree::ServiceModule::Base
|
|
23
|
+
|
|
24
|
+
# @param order [Spree::Order] completed order to fulfill
|
|
25
|
+
# @param stock_location [Spree::StockLocation] location the fulfillment ships from
|
|
26
|
+
# @param items [Array<Hash>, nil] `[{ line_item: Spree::LineItem, quantity: Integer }]`;
|
|
27
|
+
# nil fulfills every not-yet-shipped unit on the order
|
|
28
|
+
# @param tracking [String, nil] carrier tracking number
|
|
29
|
+
# @param delivery_method [Spree::ShippingMethod, nil] carrier; stored as the selected rate.
|
|
30
|
+
# Defaults to the delivery method of the drained source fulfillment(s)
|
|
31
|
+
# @param cost [String, Numeric, nil] explicit shipping cost (e.g. the 3PL's price).
|
|
32
|
+
# Defaults to the summed cost of the drained source fulfillment(s), keeping the
|
|
33
|
+
# order total unchanged; an explicit cost changes the order total and payment state.
|
|
34
|
+
# Guaranteed to persist only with status: 'shipped' — pending fulfillments are
|
|
35
|
+
# re-priced by the rate engine (see class docs)
|
|
36
|
+
# @param status [String, nil] pass 'shipped' to register an already-shipped fulfillment
|
|
37
|
+
# @param metadata [Hash, nil] metadata stored on the fulfillment
|
|
38
|
+
# @return [Spree::ServiceModule::Result] the created shipment on success
|
|
39
|
+
def call(order:, stock_location:, items: nil, tracking: nil, delivery_method: nil, cost: nil, status: nil, metadata: nil)
|
|
40
|
+
return failure(nil, Spree.t('fulfillments.errors.invalid_status')) unless status.nil? || status == 'shipped'
|
|
41
|
+
|
|
42
|
+
cost = parse_cost(cost)
|
|
43
|
+
return cost if cost.is_a?(Spree::ServiceModule::Result)
|
|
44
|
+
|
|
45
|
+
fulfillment = nil
|
|
46
|
+
|
|
47
|
+
# The order row is locked before reading fulfillable units so
|
|
48
|
+
# concurrent creations (e.g. duplicate carrier webhooks) validate and
|
|
49
|
+
# move units against a serialized snapshot. The API layer already
|
|
50
|
+
# serializes via with_order_lock; this covers direct service callers.
|
|
51
|
+
ActiveRecord::Base.transaction do
|
|
52
|
+
order.lock!
|
|
53
|
+
|
|
54
|
+
return failure(nil, Spree.t('fulfillments.errors.order_not_completed')) unless order.completed?
|
|
55
|
+
return failure(nil, Spree.t('fulfillments.errors.order_canceled')) if order.canceled?
|
|
56
|
+
|
|
57
|
+
units_by_line_item = fulfillable_units(order)
|
|
58
|
+
|
|
59
|
+
requested = normalize_items(order, items, units_by_line_item)
|
|
60
|
+
return requested if requested.is_a?(Spree::ServiceModule::Result)
|
|
61
|
+
|
|
62
|
+
fulfillment = order.shipments.new(
|
|
63
|
+
stock_location: stock_location,
|
|
64
|
+
address_id: order.ship_address_id,
|
|
65
|
+
tracking: tracking
|
|
66
|
+
)
|
|
67
|
+
fulfillment.metadata = metadata if metadata.present?
|
|
68
|
+
fulfillment.save!
|
|
69
|
+
|
|
70
|
+
source_shipments = move_units(order, fulfillment, requested, units_by_line_item)
|
|
71
|
+
inherited = destroy_drained_shipments(source_shipments, capture_delivery_method: delivery_method.nil?)
|
|
72
|
+
attach_cost_and_rate(fulfillment, delivery_method, cost, inherited)
|
|
73
|
+
|
|
74
|
+
if status == 'shipped'
|
|
75
|
+
mark_shipped(fulfillment)
|
|
76
|
+
else
|
|
77
|
+
fulfillment.update!(order)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
order.reload.update_with_updater!
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
success(fulfillment.reload)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Units that can still be moved into a manual fulfillment: on-hand or
|
|
89
|
+
# backordered units sitting in shipments that haven't shipped or been
|
|
90
|
+
# canceled (canceled shipments already restocked their stock). Loaded
|
|
91
|
+
# in one query, on-hand first so moved units stay shippable, grouped
|
|
92
|
+
# by line item for both validation and moving.
|
|
93
|
+
def fulfillable_units(order)
|
|
94
|
+
order.inventory_units.
|
|
95
|
+
on_hand_or_backordered.
|
|
96
|
+
joins(:shipment).
|
|
97
|
+
merge(Spree::Shipment.ready_or_pending).
|
|
98
|
+
preload(:shipment, :variant).
|
|
99
|
+
order(Arel.sql("CASE WHEN #{Spree::InventoryUnit.table_name}.state = 'on_hand' THEN 0 ELSE 1 END"), :id).
|
|
100
|
+
group_by(&:line_item_id)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def normalize_items(order, items, units_by_line_item)
|
|
104
|
+
available_for = ->(line_item) { units_by_line_item.fetch(line_item.id, []).sum(&:quantity) }
|
|
105
|
+
|
|
106
|
+
if items.nil?
|
|
107
|
+
derived = order.line_items.filter_map do |line_item|
|
|
108
|
+
quantity = available_for.call(line_item)
|
|
109
|
+
{ line_item: line_item, quantity: quantity } if quantity.positive?
|
|
110
|
+
end
|
|
111
|
+
return failure(nil, Spree.t('fulfillments.errors.no_items_to_fulfill')) if derived.empty?
|
|
112
|
+
|
|
113
|
+
return derived
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
return failure(nil, Spree.t('fulfillments.errors.no_items_to_fulfill')) if items.empty?
|
|
117
|
+
|
|
118
|
+
# Merge duplicate line item entries, then validate quantities.
|
|
119
|
+
merged = items.group_by { |item| item[:line_item].id }.values.map do |grouped|
|
|
120
|
+
{ line_item: grouped.first[:line_item], quantity: grouped.sum { |item| item[:quantity].to_i } }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
merged.each do |item|
|
|
124
|
+
line_item = item[:line_item]
|
|
125
|
+
quantity = item[:quantity]
|
|
126
|
+
|
|
127
|
+
unless quantity.positive?
|
|
128
|
+
return failure(nil, Spree.t('fulfillments.errors.invalid_quantity', item: line_item.prefixed_id))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
available = available_for.call(line_item)
|
|
132
|
+
if quantity > available
|
|
133
|
+
return failure(
|
|
134
|
+
nil,
|
|
135
|
+
Spree.t('fulfillments.errors.insufficient_quantity',
|
|
136
|
+
item: line_item.prefixed_id, requested: quantity, available: available)
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
merged
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Moves the requested quantities into the fulfillment, on-hand units
|
|
145
|
+
# first so the new fulfillment is shippable whenever possible. Restocks
|
|
146
|
+
# the source location and unstocks the target for tracked variants when
|
|
147
|
+
# the locations differ, keeping stock levels truthful about where the
|
|
148
|
+
# goods actually leave from.
|
|
149
|
+
#
|
|
150
|
+
# @return [Array<Spree::Shipment>] the shipments units were taken from
|
|
151
|
+
def move_units(order, fulfillment, requested, units_by_line_item)
|
|
152
|
+
source_shipments = []
|
|
153
|
+
stock_moves = Hash.new(0)
|
|
154
|
+
|
|
155
|
+
requested.each do |item|
|
|
156
|
+
remaining = item[:quantity]
|
|
157
|
+
|
|
158
|
+
units_by_line_item.fetch(item[:line_item].id, []).each do |unit|
|
|
159
|
+
break if remaining.zero?
|
|
160
|
+
|
|
161
|
+
take = [unit.quantity, remaining].min
|
|
162
|
+
source_shipments << unit.shipment
|
|
163
|
+
stock_moves[[unit.shipment, unit.variant]] += take
|
|
164
|
+
|
|
165
|
+
target = fulfillment.inventory_units.find_or_initialize_by(
|
|
166
|
+
state: unit.state,
|
|
167
|
+
variant_id: unit.variant_id,
|
|
168
|
+
line_item_id: unit.line_item_id,
|
|
169
|
+
order_id: order.id
|
|
170
|
+
)
|
|
171
|
+
target.pending = unit.pending
|
|
172
|
+
# The quantity column has a database default of 1, so a fresh
|
|
173
|
+
# record must be set to the moved quantity, not incremented.
|
|
174
|
+
target.quantity = target.new_record? ? take : target.quantity + take
|
|
175
|
+
target.save!
|
|
176
|
+
|
|
177
|
+
if take == unit.quantity
|
|
178
|
+
unit.destroy!
|
|
179
|
+
else
|
|
180
|
+
unit.update!(quantity: unit.quantity - take)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
remaining -= take
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
stock_moves.each do |(source_shipment, variant), quantity|
|
|
188
|
+
next unless variant.track_inventory?
|
|
189
|
+
next if source_shipment.stock_location_id == fulfillment.stock_location_id
|
|
190
|
+
|
|
191
|
+
source_shipment.stock_location.restock(variant, quantity, source_shipment)
|
|
192
|
+
fulfillment.stock_location.unstock(variant, quantity, fulfillment)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
source_shipments.uniq
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Destroys fully drained source shipments, capturing what the new
|
|
199
|
+
# fulfillment inherits from them — the summed cost and the first
|
|
200
|
+
# selected delivery method, read before destroy since the rates are
|
|
201
|
+
# deleted along with the shipment. The delivery method lookup costs
|
|
202
|
+
# queries, so it is skipped when the caller provided its own.
|
|
203
|
+
#
|
|
204
|
+
# @return [Hash] `{ cost: BigDecimal, delivery_method: Spree::ShippingMethod or nil }`
|
|
205
|
+
def destroy_drained_shipments(source_shipments, capture_delivery_method:)
|
|
206
|
+
inherited = { cost: 0, delivery_method: nil }
|
|
207
|
+
|
|
208
|
+
source_shipments.each do |shipment|
|
|
209
|
+
next unless shipment.inventory_units.sum(:quantity).zero?
|
|
210
|
+
|
|
211
|
+
inherited[:cost] += shipment.cost
|
|
212
|
+
inherited[:delivery_method] ||= shipment.shipping_method if capture_delivery_method
|
|
213
|
+
shipment.destroy!
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
inherited
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The fulfillment inherits the cost and carrier of the shipments it
|
|
220
|
+
# replaced — keeping the order total (and thus payment state) unchanged —
|
|
221
|
+
# unless the caller provides its own. Frozen once shipped; see the class
|
|
222
|
+
# docs for pending-path re-pricing. The carrier rides along as a
|
|
223
|
+
# selected rate.
|
|
224
|
+
def attach_cost_and_rate(fulfillment, delivery_method, cost, inherited)
|
|
225
|
+
effective_cost = cost || inherited[:cost]
|
|
226
|
+
method = delivery_method || inherited[:delivery_method]
|
|
227
|
+
|
|
228
|
+
fulfillment.update_columns(cost: effective_cost) if effective_cost.positive?
|
|
229
|
+
fulfillment.add_shipping_method(method, true) if method
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Strict decimal parsing (same semantics as Shipment#cost=, which the
|
|
233
|
+
# update_columns freeze path bypasses) — the lenient LocalizedNumber
|
|
234
|
+
# would turn garbage into 0, and 0 is a legal cost here.
|
|
235
|
+
#
|
|
236
|
+
# @return [BigDecimal, Numeric, nil] nil when no cost was given (blank
|
|
237
|
+
# counts as omitted); a failure Result for malformed or negative input
|
|
238
|
+
def parse_cost(cost)
|
|
239
|
+
return if cost.blank?
|
|
240
|
+
|
|
241
|
+
parsed = cost.is_a?(String) ? BigDecimal(cost.strip) : cost
|
|
242
|
+
return failure(nil, Spree.t('fulfillments.errors.invalid_cost')) if parsed.negative?
|
|
243
|
+
|
|
244
|
+
parsed
|
|
245
|
+
rescue ArgumentError
|
|
246
|
+
failure(nil, Spree.t('fulfillments.errors.invalid_cost'))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Registers an externally-completed fulfillment: backorders are filled
|
|
250
|
+
# (the external location evidently had the goods) and the paid-order
|
|
251
|
+
# readiness gate is bypassed deliberately — the goods already left.
|
|
252
|
+
def mark_shipped(fulfillment)
|
|
253
|
+
fulfillment.inventory_units.backordered.each(&:fill_backorder!)
|
|
254
|
+
fulfillment.update_columns(state: 'ready') unless fulfillment.ready?
|
|
255
|
+
fulfillment.reload.ship!
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -20,6 +20,15 @@ module Spree
|
|
|
20
20
|
|
|
21
21
|
private
|
|
22
22
|
|
|
23
|
+
# Memoizes a shared-record lookup (including nil misses) in the import's
|
|
24
|
+
# per-job cache so repeated rows don't re-run the same query.
|
|
25
|
+
def cached_lookup(*key)
|
|
26
|
+
cache = import.row_lookup_cache
|
|
27
|
+
return cache[key] if cache.key?(key)
|
|
28
|
+
|
|
29
|
+
cache[key] = yield
|
|
30
|
+
end
|
|
31
|
+
|
|
23
32
|
def build_schema_hash(row, mappings, schema_fields)
|
|
24
33
|
attributes = {}
|
|
25
34
|
schema_fields.each do |field|
|