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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/base_helper.rb +12 -2
  3. data/app/helpers/spree/currency_helper.rb +1 -4
  4. data/app/helpers/spree/locale_helper.rb +18 -13
  5. data/app/helpers/spree/localized_names_helper.rb +25 -0
  6. data/app/jobs/spree/imports/process_group_job.rb +20 -9
  7. data/app/jobs/spree/imports/process_rows_job.rb +12 -4
  8. data/app/mailers/spree/admin_user_mailer.rb +71 -0
  9. data/app/mailers/spree/base_mailer.rb +73 -11
  10. data/app/mailers/spree/export_mailer.rb +7 -7
  11. data/app/mailers/spree/import_mailer.rb +19 -0
  12. data/app/mailers/spree/invitation_mailer.rb +16 -11
  13. data/app/mailers/spree/report_mailer.rb +6 -6
  14. data/app/mailers/spree/webhook_mailer.rb +7 -6
  15. data/app/models/concerns/spree/admin_user_methods.rb +44 -0
  16. data/app/models/concerns/spree/legacy_multi_store_support.rb +48 -0
  17. data/app/models/concerns/spree/presentation_translatable.rb +39 -0
  18. data/app/models/concerns/spree/product_scopes.rb +21 -3
  19. data/app/models/concerns/spree/single_store_resource.rb +5 -0
  20. data/app/models/concerns/spree/store_scoped_resource.rb +10 -0
  21. data/app/models/concerns/spree/stores/markets.rb +9 -1
  22. data/app/models/concerns/spree/stores/setup.rb +37 -23
  23. data/app/models/concerns/spree/translatable_resource.rb +95 -5
  24. data/app/models/concerns/spree/user_methods.rb +27 -2
  25. data/app/models/spree/ability.rb +5 -4
  26. data/app/models/spree/allowed_origin.rb +31 -0
  27. data/app/models/spree/category.rb +12 -2
  28. data/app/models/spree/channel/gating.rb +67 -0
  29. data/app/models/spree/channel.rb +1 -0
  30. data/app/models/spree/classification.rb +12 -0
  31. data/app/models/spree/country.rb +22 -1
  32. data/app/models/spree/credit_card.rb +29 -0
  33. data/app/models/spree/currency.rb +47 -0
  34. data/app/models/spree/current.rb +12 -1
  35. data/app/models/spree/customer_group.rb +1 -0
  36. data/app/models/spree/export.rb +19 -0
  37. data/app/models/spree/exports/coupon_codes.rb +1 -2
  38. data/app/models/spree/import.rb +93 -1
  39. data/app/models/spree/import_row.rb +7 -1
  40. data/app/models/spree/imports/product_translations.rb +6 -0
  41. data/app/models/spree/locale.rb +104 -0
  42. data/app/models/spree/option_type.rb +11 -13
  43. data/app/models/spree/option_value.rb +5 -17
  44. data/app/models/spree/order.rb +19 -2
  45. data/app/models/spree/payment_method.rb +6 -5
  46. data/app/models/spree/policy.rb +2 -1
  47. data/app/models/spree/product.rb +23 -2
  48. data/app/models/spree/promotion/rules/channel.rb +37 -0
  49. data/app/models/spree/promotion/rules/market.rb +37 -0
  50. data/app/models/spree/promotion.rb +5 -3
  51. data/app/models/spree/promotion_handler/promotion_duplicator.rb +1 -1
  52. data/app/models/spree/role_user.rb +4 -0
  53. data/app/models/spree/search_provider/meilisearch.rb +22 -9
  54. data/app/models/spree/setup_task.rb +12 -0
  55. data/app/models/spree/setup_tasks.rb +86 -0
  56. data/app/models/spree/shipment.rb +38 -1
  57. data/app/models/spree/shipment_handler.rb +1 -1
  58. data/app/models/spree/stock/availability_validator.rb +1 -1
  59. data/app/models/spree/stock/quantifier.rb +21 -1
  60. data/app/models/spree/stock_item.rb +2 -1
  61. data/app/models/spree/stock_location.rb +4 -1
  62. data/app/models/spree/store.rb +45 -9
  63. data/app/models/spree/store_payment_method.rb +5 -1
  64. data/app/models/spree/store_promotion.rb +4 -0
  65. data/app/models/spree/taxon.rb +112 -9
  66. data/app/models/spree/taxonomy.rb +1 -1
  67. data/app/models/spree/translations/batch.rb +104 -0
  68. data/app/models/spree/variant.rb +29 -2
  69. data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
  70. data/app/services/spree/carts/complete.rb +3 -0
  71. data/app/services/spree/fulfillments/create.rb +259 -0
  72. data/app/services/spree/imports/row_processors/base.rb +9 -0
  73. data/app/services/spree/imports/row_processors/product_variant.rb +37 -19
  74. data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
  75. data/app/services/spree/locales.rb +52 -0
  76. data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
  77. data/app/services/spree/shipments/update.rb +1 -1
  78. data/app/services/spree/stock_reservations/reserve.rb +4 -1
  79. data/app/services/spree/taxons/add_products.rb +3 -0
  80. data/app/services/spree/taxons/remove_products.rb +3 -0
  81. data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
  82. data/app/subscribers/spree/import_email_subscriber.rb +26 -0
  83. data/app/views/layouts/spree/base_mailer.html.erb +14 -17
  84. data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
  85. data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
  86. data/app/views/spree/export_mailer/export_done.html.erb +6 -14
  87. data/app/views/spree/import_mailer/import_done.html.erb +12 -0
  88. data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
  89. data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
  90. data/app/views/spree/report_mailer/report_done.html.erb +6 -14
  91. data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
  92. data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
  93. data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
  94. data/app/views/spree/shared/_mailer_button.html.erb +10 -0
  95. data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
  96. data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
  97. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
  98. data/config/initializers/spree_store_setup_tasks.rb +23 -0
  99. data/config/locales/en.yml +42 -992
  100. data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
  101. data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
  102. data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
  103. data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
  104. data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
  105. data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
  106. data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
  107. data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
  108. data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
  109. data/db/sample_data/promotions.rb +1 -1
  110. data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
  111. data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
  112. data/lib/spree/core/configuration.rb +1 -0
  113. data/lib/spree/core/controller_helpers/locale.rb +4 -1
  114. data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
  115. data/lib/spree/core/dependencies.rb +3 -0
  116. data/lib/spree/core/engine.rb +24 -0
  117. data/lib/spree/core/preferences/preferable.rb +6 -1
  118. data/lib/spree/core/previews/admin_user_preview.rb +22 -0
  119. data/lib/spree/core/previews/export_preview.rb +37 -0
  120. data/lib/spree/core/previews/invitation_preview.rb +47 -0
  121. data/lib/spree/core/previews/preview_data.rb +52 -0
  122. data/lib/spree/core/previews/report_preview.rb +40 -0
  123. data/lib/spree/core/previews/webhook_preview.rb +34 -0
  124. data/lib/spree/core/version.rb +1 -1
  125. data/lib/spree/core.rb +22 -1
  126. data/lib/spree/permitted_attributes.rb +6 -5
  127. data/lib/spree/testing_support/factories/export_factory.rb +4 -0
  128. data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
  129. data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
  130. data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
  131. data/lib/spree/translations.rb +113 -0
  132. data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
  133. data/lib/tasks/role_users.rake +21 -0
  134. data/lib/tasks/single_store_associations.rake +65 -0
  135. data/lib/tasks/taxons.rake +62 -1
  136. metadata +47 -5
  137. data/app/models/spree/product/legacy_multi_store_support.rb +0 -40
@@ -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
- has_one :store, through: :taxonomy
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, uniqueness: { scope: %i[parent_id taxonomy_id], case_sensitive: false }
60
- validates :taxonomy, presence: true
61
- validates :permalink, uniqueness: { case_sensitive: false, scope: %i[parent_id taxonomy_id] }
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
- scope :for_store, ->(store) { joins(:taxonomy).where(spree_taxonomies: { store_id: store.id }) }
89
- scope :for_stores, ->(stores) { joins(:taxonomy).where(spree_taxonomies: { store_id: stores.ids }) }
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
- translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
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.try!(:touch)
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: !Spree.always_use_translations?)
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
@@ -13,7 +13,7 @@ module Spree
13
13
 
14
14
  publishes_lifecycle_events
15
15
 
16
- MEMOIZED_METHODS = %w(purchasable in_stock on_sale backorderable tax_category options_text compare_at_price)
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
- @purchasable ||= in_stock? || backorderable?
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|