spree_core 6.0.0.beta1 → 6.0.0.beta2

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/sample_data/load_job.rb +13 -0
  3. data/app/models/concerns/spree/acted_by.rb +210 -0
  4. data/app/models/concerns/spree/actor.rb +51 -0
  5. data/app/models/concerns/spree/admin_user_methods.rb +48 -17
  6. data/app/models/concerns/spree/purchase/payment_processing.rb +1 -1
  7. data/app/models/spree/api_key.rb +5 -3
  8. data/app/models/spree/catalog.rb +50 -13
  9. data/app/models/spree/claim.rb +2 -1
  10. data/app/models/spree/current.rb +41 -13
  11. data/app/models/spree/exchange.rb +2 -1
  12. data/app/models/spree/line_item.rb +1 -1
  13. data/app/models/spree/order.rb +54 -6
  14. data/app/models/spree/pricing_provider/internal.rb +37 -15
  15. data/app/models/spree/refund.rb +2 -1
  16. data/app/models/spree/return.rb +2 -1
  17. data/app/models/spree/stock_receipt.rb +7 -2
  18. data/app/models/spree/store.rb +4 -0
  19. data/app/services/spree/orders/approve.rb +3 -2
  20. data/app/workflows/spree/orders/cancel.rb +3 -2
  21. data/app/workflows/spree/refunds/create.rb +1 -1
  22. data/app/workflows/spree/returns/approve.rb +1 -1
  23. data/app/workflows/spree/returns/create.rb +2 -2
  24. data/app/workflows/spree/returns/refund.rb +1 -1
  25. data/config/locales/en.yml +1 -0
  26. data/db/migrate/20260916000001_order_operation_actors_polymorphic.rb +30 -0
  27. data/lib/generators/spree/api_resource/api_resource_generator.rb +63 -5
  28. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +5 -0
  29. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +3 -0
  30. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +4 -1
  31. data/lib/generators/spree/model/model_generator.rb +40 -1
  32. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +9 -0
  33. data/lib/generators/spree/model/templates/model.rb.tt +10 -4
  34. data/lib/spree/core/configuration.rb +25 -25
  35. data/lib/spree/core/engine.rb +27 -0
  36. data/lib/spree/core/preferences/runtime_configuration.rb +76 -2
  37. data/lib/spree/core/version.rb +1 -1
  38. data/lib/spree/core.rb +27 -0
  39. data/lib/spree/testing_support/factories/user_factory.rb +0 -1
  40. data/lib/tasks/backfill_actor_types.rake +35 -0
  41. data/lib/tasks/store_settings.rake +1 -0
  42. metadata +14 -9
  43. /data/{app/models → lib}/spree/checkout/default_requirements.rb +0 -0
  44. /data/{app/models → lib}/spree/checkout/registry.rb +0 -0
  45. /data/{app/models → lib}/spree/checkout/requirement.rb +0 -0
  46. /data/{app/models → lib}/spree/checkout/requirements.rb +0 -0
  47. /data/{app/models → lib}/spree/checkout/step.rb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d404e43490b356a04bf850e09a644143d8f399c7f39c147301184fd11c2cfed5
4
- data.tar.gz: 2b1a720b211b09a061c26b5bf1761510fdde20bf2d50ee9451fbab4a67939b0c
3
+ metadata.gz: 0c2e491640b87a7c66d80915167c1b3e0e342ebc527f98ab6be473494850ed8c
4
+ data.tar.gz: 9507987225c9b98ea0676417742b9c22e338796d9d155f309f3d0f5262617f4d
5
5
  SHA512:
6
- metadata.gz: 62dd3e3663ddf4b540127ae6bd526027adc294004e05f6069bf9041f36f5542c7daead8f747937094ddd73d8e19da153ff6ae214cc0c98ca6d7b80a6a8d2b8e8
7
- data.tar.gz: b8c49e3df74336ea44676d186c1b376979541c85610baf04b575f82d80886c2850ab4245d4274eb887535c4081e028631766a1b2fa31788c302116de4aca596e
6
+ metadata.gz: 25b1c9003b48955d08e1d26f0f64d630ae4eafb07043cd49bfb70b22736255b0315f85b6e6f22ab873fc2e24b64cb0a3a7c715ac77b522d58afe1190939f4b78
7
+ data.tar.gz: b7379fef995f651f02229b55260b8685a8e4318e7748be5f2a64aa0476a40577d877f1e32897a3d6542f47beee847e18a431bb027cc1237c189f2bde8e0c0493
@@ -0,0 +1,13 @@
1
+ module Spree
2
+ module SampleData
3
+ # Loads the demo catalog, customers and orders in the background. Enqueued
4
+ # by first-run setup when the merchant ticks "load sample data": the
5
+ # loader needs an admin to own its imports and downloads product images,
6
+ # so it can neither run before setup nor inside the setup request.
7
+ class LoadJob < Spree::BaseJob
8
+ def perform
9
+ Spree::SampleData::Loader.call
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,210 @@
1
+ module Spree
2
+ # Declares a "who performed this action" association — the canceler of an
3
+ # order, the receiver of a stock delivery, the opener of a return.
4
+ #
5
+ # class Spree::Order < Spree.base_class
6
+ # include Spree::ActedBy
7
+ # acted_by :created_by, :approver, :canceler
8
+ # end
9
+ #
10
+ # Each name becomes an optional polymorphic `belongs_to` whose type column is
11
+ # validated against `Spree.actor_classes`, so a row can only ever name a
12
+ # registered actor — an admin user, an API key, or a class an extension
13
+ # registered. See docs/plans/6.0-action-actors.md.
14
+ #
15
+ # Association names, readers and writers are the ones a plain `belongs_to`
16
+ # would give, so the workflows that assign these (`order.canceler = canceler`)
17
+ # are untouched by the conversion.
18
+ module ActedBy
19
+ extend ActiveSupport::Concern
20
+
21
+ # Every model that declares at least one `acted_by` association, so a
22
+ # caller sweeping actor columns — the backfill task, a user's erasure —
23
+ # walks all of them without being handed a list.
24
+ #
25
+ # The registry fills as models load, so it eager-loads first: in
26
+ # development nothing is loaded until something references it, and a
27
+ # caller that silently skipped an unloaded model would leave a deleted
28
+ # person's id behind or half-finish an upgrade.
29
+ #
30
+ # Names are held rather than classes, and resolved on read, so a code
31
+ # reload does not leave the registry pointing at superseded copies of the
32
+ # same model.
33
+ #
34
+ # @return [Array<Class>]
35
+ def self.models
36
+ Rails.application&.eager_load! unless Rails.application&.config&.eager_load
37
+
38
+ registered_model_names.filter_map(&:safe_constantize)
39
+ end
40
+
41
+ # @api private
42
+ # @return [Set<String>]
43
+ def self.registered_model_names
44
+ @registered_model_names ||= Set.new
45
+ end
46
+
47
+ included do
48
+ # The names declared on this class, so the backfill task can ask a model
49
+ # what it records without being told. Inherited and extended per class,
50
+ # never assigned, so a subclass adding one does not blank its parent's.
51
+ class_attribute :acted_by_associations, default: [], instance_writer: false
52
+ end
53
+
54
+ # The class an actor column names, answering what the reader would resolve
55
+ # rather than what the column literally holds: a row the backfill has not
56
+ # reached carries an id and no type, and the reader resolves it through
57
+ # the admin user class. Serializers read this so `<name>_id` and
58
+ # `<name>_type` never disagree about the same row.
59
+ #
60
+ # @param name [Symbol, String] the acted_by association
61
+ # @return [String, nil]
62
+ def acted_by_type(name)
63
+ stored = self[:"#{name}_type"]
64
+ return stored if stored.present?
65
+
66
+ Spree.admin_user_class.to_s if self[:"#{name}_id"].present?
67
+ end
68
+
69
+ # The actor's prefixed id, encoded from the columns without loading the
70
+ # row — so a page of orders costs no query per actor it names. Paired with
71
+ # {#acted_by_type} so the id and the kind always describe the same row,
72
+ # including one the backfill has not reached.
73
+ #
74
+ # @param name [Symbol, String] the acted_by association
75
+ # @return [String, nil]
76
+ def acted_by_prefixed_id(name)
77
+ Spree::Base.polymorphic_prefixed_id(acted_by_type(name), self[:"#{name}_id"])
78
+ end
79
+
80
+ # Names the admin user class on every un-backfilled row of `record`'s
81
+ # lazily-preloaded group, so the association preloads as one query rather
82
+ # than one per row. Writes the in-memory attribute only; nothing is saved.
83
+ #
84
+ # @api private
85
+ # @param record [ActiveRecord::Base]
86
+ # @param name [Symbol] the acted_by association
87
+ # @return [void]
88
+ def self.assume_admin_actor_type(record, name)
89
+ type_column = :"#{name}_type"
90
+ admin_type = Spree.admin_user_class.to_s
91
+ group = record.try(:lazy_preload_context)&.records.presence || [record]
92
+
93
+ group.each do |sibling|
94
+ next unless sibling.instance_of?(record.class)
95
+ next if sibling[type_column].present? || sibling[:"#{name}_id"].blank?
96
+
97
+ sibling[type_column] = admin_type
98
+ end
99
+ end
100
+
101
+ # Both halves of an actor column, for a caller writing through
102
+ # `update_columns` or `update_all` rather than through the association —
103
+ # an id without its type names nothing, so the pair is produced together
104
+ # and never by hand.
105
+ #
106
+ # changes.merge!(Spree::ActedBy.columns_for(:canceler, canceler))
107
+ #
108
+ # The actor is checked against the registry here because these writes go
109
+ # through `update_columns` / `update_all`, which skip validation — without
110
+ # it an unregistered actor persists as a row the model itself calls
111
+ # invalid.
112
+ #
113
+ # @param name [Symbol, String] the acted_by association
114
+ # @param actor [Object, nil]
115
+ # @raise [ArgumentError] when the actor's class is not registered
116
+ # @return [Hash{Symbol => Object, nil}]
117
+ def self.columns_for(name, actor)
118
+ type = actor && actor.class.polymorphic_name
119
+
120
+ if type.present? && Spree.actor_classes.exclude?(type)
121
+ raise ArgumentError, "#{type} is not a registered actor class — add it to Spree.actor_classes"
122
+ end
123
+
124
+ { :"#{name}_id" => actor&.id, :"#{name}_type" => type }
125
+ end
126
+
127
+ class_methods do
128
+ # @param names [Array<Symbol>] the association names to declare
129
+ # @return [void]
130
+ def acted_by(*names)
131
+ names = names.map(&:to_sym)
132
+ self.acted_by_associations = acted_by_associations + names
133
+ # An anonymous class — one built in a spec or a console — has no name
134
+ # to register, and a nil in the registry would break every later read.
135
+ Spree::ActedBy.registered_model_names << name_for_actor_registry if name_for_actor_registry
136
+
137
+ names.each do |name|
138
+ belongs_to name, polymorphic: true, optional: true
139
+
140
+ validate_actor_type(name)
141
+ define_transitional_actor_reader(name)
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ # STI subclasses register under the base class, whose table the backfill
148
+ # updates; an anonymous class registers nothing.
149
+ def name_for_actor_registry
150
+ base_class.name
151
+ end
152
+
153
+ def validate_actor_type(name)
154
+ type_column = :"#{name}_type"
155
+
156
+ validate do
157
+ value = self[type_column]
158
+ next if value.blank? || Spree.actor_classes.include?(value)
159
+
160
+ errors.add(type_column, :unregistered_actor,
161
+ message: Spree.t('errors.messages.unregistered_actor'))
162
+ end
163
+ end
164
+
165
+ # Rows written before the type column existed carry an id and no type,
166
+ # which a polymorphic association reads as nil. Until the backfill task
167
+ # has run they resolve through the admin user class, the only thing they
168
+ # could ever have pointed at.
169
+ #
170
+ # The type is filled in on the in-memory attribute and the ordinary
171
+ # association reader does the rest, so the fallback keeps every benefit
172
+ # the association has — preloading included. Resolving it with a direct
173
+ # `find_by` instead would sidestep `ar_lazy_preload`, turning a page of
174
+ # 25 orders into 75 queries while the backfill is outstanding.
175
+ #
176
+ # Nothing is saved: `[]=` writes the attribute, and a record whose only
177
+ # change is this would still need an explicit save to persist it.
178
+ # Prepended rather than defined outright, so `super` reaches the
179
+ # generated reader.
180
+ #
181
+ # Removed in 6.1, once the backfill is a release behind.
182
+ def define_transitional_actor_reader(name)
183
+ transitional_actor_readers.module_eval do
184
+ define_method(name) do
185
+ return super() if self[:"#{name}_type"].present?
186
+ return super() if self[:"#{name}_id"].blank?
187
+
188
+ Spree::Deprecation.warn(
189
+ "#{self.class.name}##{name} resolved through #{Spree.admin_user_class} because " \
190
+ "#{name}_type is blank. Run `rake spree:upgrade:backfill_actor_types`; the fallback " \
191
+ 'is removed in Spree 6.1.'
192
+ )
193
+
194
+ # Fill the type across the whole lazily-preloaded group, not just
195
+ # this record: the preloader batches on the type it finds, and
196
+ # filling one row at a time as each is read produces a query per
197
+ # row instead of one for the page.
198
+ Spree::ActedBy.assume_admin_actor_type(self, name)
199
+ super()
200
+ end
201
+ end
202
+ end
203
+
204
+ # The per-class module the transitional readers live in, prepended once.
205
+ def transitional_actor_readers
206
+ @transitional_actor_readers ||= Module.new.tap { |mod| prepend(mod) }
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,51 @@
1
+ module Spree
2
+ # What a model must answer to be recorded as the performer of an action.
3
+ #
4
+ # Included in the admin user class and in {Spree::ApiKey}; an extension's App
5
+ # or bot class includes it too and registers itself in
6
+ # `Spree.actor_classes` (see docs/plans/6.0-action-actors.md).
7
+ #
8
+ # Both methods have defaults that read whatever the including class already
9
+ # offers, so a class that names itself in one of the usual ways needs no
10
+ # override.
11
+ module Actor
12
+ extend ActiveSupport::Concern
13
+
14
+ # The kinds core itself registers, for the generated `ActorKind` type.
15
+ #
16
+ # A literal list rather than one derived from `Spree.actor_classes`: that
17
+ # registry is filled after serializer classes load, so deriving it would
18
+ # make the generated types depend on boot order and on which extensions
19
+ # are installed. The union stays open on the wire, so an extension's kind
20
+ # is still a valid value — it just does not autocomplete.
21
+ BUILT_IN_KINDS = %w[admin_user api_key].freeze
22
+
23
+ # How a timeline names this actor: a person's full name or email, an API
24
+ # key's name, an app's title.
25
+ #
26
+ # @return [String, nil]
27
+ def actor_label
28
+ %i[full_name name email].each do |method|
29
+ next unless respond_to?(method)
30
+
31
+ value = public_send(method)
32
+ value = value.to_s if value.present?
33
+ return value if value.present?
34
+ end
35
+
36
+ nil
37
+ end
38
+
39
+ # The wire shorthand for this actor's kind — `admin_user`, `api_key`, …
40
+ # The same derivation every polymorphic type column on the Admin API uses.
41
+ #
42
+ # Read from `polymorphic_name`, which is what the `*_type` column stores,
43
+ # so an STI subclass of an actor answers the same kind on the expansion
44
+ # as it does in the column beside it.
45
+ #
46
+ # @return [String]
47
+ def actor_kind
48
+ Spree::Base.polymorphic_api_type(self.class.polymorphic_name)
49
+ end
50
+ end
51
+ end
@@ -2,6 +2,7 @@ module Spree
2
2
  module AdminUserMethods
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ include Spree::Actor
5
6
  include Spree::PrefixedId
6
7
  include Spree::UserRoles
7
8
  include Spree::RansackableAttributes
@@ -33,15 +34,20 @@ module Spree
33
34
 
34
35
  # Associations
35
36
  has_many :identities, class_name: 'Spree::UserIdentity', as: :user, dependent: :destroy
36
- has_many :canceled_orders, class_name: 'Spree::Order', foreign_key: :canceler_id
37
- has_many :created_orders, class_name: 'Spree::Order', foreign_key: :created_by_id
38
- has_many :approved_orders, class_name: 'Spree::Order', foreign_key: :approver_id
37
+ # The order operations became polymorphic in 6.0, so these read through
38
+ # the association rather than a bare foreign key: matching on the id
39
+ # alone would hand this user the rows an API key with the same numeric
40
+ # id performed. The rest still point at admin users only and convert in
41
+ # 6.1 (see docs/plans/6.0-action-actors.md).
42
+ has_many :canceled_orders, class_name: 'Spree::Order', as: :canceler
43
+ has_many :created_orders, class_name: 'Spree::Order', as: :created_by
44
+ has_many :approved_orders, class_name: 'Spree::Order', as: :approver
45
+ has_many :refunded_refunds, class_name: 'Spree::Refund', as: :refunder
46
+ has_many :created_returns, class_name: 'Spree::Return', as: :created_by
47
+ has_many :created_exchanges, class_name: 'Spree::Exchange', as: :created_by
48
+ has_many :created_claims, class_name: 'Spree::Claim', as: :created_by
39
49
  has_many :created_gift_cards, class_name: 'Spree::GiftCard', foreign_key: :created_by_id
40
50
  has_many :created_gift_card_batches, class_name: 'Spree::GiftCardBatch', foreign_key: :created_by_id
41
- has_many :refunded_refunds, class_name: 'Spree::Refund', foreign_key: :refunder_id
42
- has_many :created_returns, class_name: 'Spree::Return', foreign_key: :created_by_id
43
- has_many :created_exchanges, class_name: 'Spree::Exchange', foreign_key: :created_by_id
44
- has_many :created_claims, class_name: 'Spree::Claim', foreign_key: :created_by_id
45
51
  has_many :created_store_credits, class_name: 'Spree::StoreCredit', foreign_key: :created_by_id
46
52
  has_many :exports, class_name: 'Spree::Export', foreign_key: :user_id
47
53
  has_many :saved_reports, class_name: 'Spree::SavedReport', foreign_key: :user_id
@@ -79,28 +85,53 @@ module Spree
79
85
  def nullify_approver_id_in_approved_orders
80
86
  return if self.class != Spree.admin_user_class
81
87
 
82
- approved_orders.update_all(approver_id: nil, updated_at: Time.current)
88
+ clear_actor(Spree::Order, :approver)
83
89
  end
84
90
 
85
91
  def cleanup_admin_user_resources
86
92
  return if self.class != Spree.admin_user_class
87
93
 
88
- # resources to nullify
89
- # TODO: we should change these associations to polymorphic and resolve this via standard rails association
90
- # declarations with dependent: :nullify
91
- canceled_orders.update_all(canceler_id: nil, updated_at: Time.current)
92
- created_orders.update_all(created_by_id: nil, updated_at: Time.current)
94
+ # Every actor column that names this user, derived from the `acted_by`
95
+ # declarations rather than listed here, so the 6.1 conversion of the
96
+ # remaining associations needs no edit in this file.
97
+ clear_every_actor
98
+
99
+ # The plain foreign keys that are still admin-user-only. They join the
100
+ # loop above once they convert in 6.1.
93
101
  created_gift_cards.update_all(created_by_id: nil, updated_at: Time.current)
94
102
  created_gift_card_batches.update_all(created_by_id: nil, updated_at: Time.current)
95
- refunded_refunds.update_all(refunder_id: nil, updated_at: Time.current)
96
- created_returns.update_all(created_by_id: nil, updated_at: Time.current)
97
- created_exchanges.update_all(created_by_id: nil, updated_at: Time.current)
98
- created_claims.update_all(created_by_id: nil, updated_at: Time.current)
99
103
  created_store_credits.update_all(created_by_id: nil, updated_at: Time.current)
100
104
  saved_reports.update_all(user_id: nil, updated_at: Time.current)
101
105
 
102
106
  # resources to destroy
103
107
  exports.destroy_all
104
108
  end
109
+
110
+ # Erases this user from every actor column any model declares with
111
+ # `acted_by`.
112
+ def clear_every_actor
113
+ Spree::ActedBy.models.each do |model|
114
+ model.acted_by_associations.each { |name| clear_actor(model, name) }
115
+ end
116
+ end
117
+
118
+ # Erases this user from one polymorphic actor column, clearing both halves
119
+ # together — a type left behind names a class no id points at.
120
+ #
121
+ # Matched on the id with either this class or no class at all, because a
122
+ # row written before the type column existed carries only the id until
123
+ # `spree:upgrade:backfill_actor_types` runs. Skipping those would leave a
124
+ # deleted person's id on the record through exactly the upgrade window the
125
+ # backfill exists for. The id alone cannot collide here: it is paired with
126
+ # a type that is ours or absent, and absent meant an admin user.
127
+ #
128
+ # @param model [Class]
129
+ # @param name [Symbol] the acted_by association
130
+ def clear_actor(model, name)
131
+ model.
132
+ where(:"#{name}_id" => id).
133
+ where(:"#{name}_type" => [self.class.polymorphic_name, nil]).
134
+ update_all(Spree::ActedBy.columns_for(name, nil).merge(updated_at: Time.current))
135
+ end
105
136
  end
106
137
  end
@@ -50,7 +50,7 @@ module Spree
50
50
  #
51
51
  # @return [Boolean]
52
52
  def confirmation_required?
53
- Spree::Config[:always_include_confirm_step] ||
53
+ Spree::StorePreferences.read(store, :always_include_confirm_step) ||
54
54
  payments.valid.map(&:payment_method).compact.any?(&:confirmation_required?)
55
55
  end
56
56
 
@@ -2,6 +2,7 @@ module Spree
2
2
  class ApiKey < Spree.base_class
3
3
  has_prefix_id :key # Spree-specific: api key
4
4
 
5
+ include Spree::Actor
5
6
  include Spree::SingleStoreResource
6
7
 
7
8
  KEY_TYPES = %w[publishable secret].freeze
@@ -124,10 +125,11 @@ module Spree
124
125
 
125
126
  # Revokes this API key by setting +revoked_at+ to the current time.
126
127
  #
127
- # @param user [Object, nil] the user who performed the revocation
128
+ # @param actor [Object, nil] who performed the revocation — an admin user
129
+ # or another API key (see Spree.actor_classes)
128
130
  # @return [Boolean] true if the update succeeded
129
- def revoke!(user = nil)
130
- update!(revoked_at: Time.current, revoked_by: user)
131
+ def revoke!(actor = nil)
132
+ update!(revoked_at: Time.current, revoked_by: actor)
131
133
  end
132
134
 
133
135
  # Whether this key carries the given scope. `write_*` implies the matching
@@ -14,8 +14,9 @@ module Spree
14
14
  # catalog to those companies or divisions rather than to a group.
15
15
  #
16
16
  # Within whichever audience answered, visibility is the union of its
17
- # catalogs' assortments and pricing takes the price list of the nearest
18
- # assignment first (docs/plans/6.0-b2b-companies-and-catalogs.md).
17
+ # catalogs' assortments; pricing walks the assigning nodes nearest first and
18
+ # within one node charges the best price any of its catalogs gives
19
+ # (docs/plans/6.0-b2b-companies-and-catalogs.md).
19
20
  class Catalog < Spree.base_class
20
21
  has_prefix_id :cat
21
22
 
@@ -97,6 +98,17 @@ module Spree
97
98
  # @param company [Spree::Company, nil]
98
99
  # @return [Array<Spree::Catalog>]
99
100
  def self.for_company(company)
101
+ groups_for_company(company).flatten
102
+ end
103
+
104
+ # The same catalogs, grouped by the node that assigns them, nearest first.
105
+ # Only pricing needs the boundaries: catalogs sharing a node rank equally
106
+ # and the best price among them wins, while a nearer node outranks a
107
+ # further one. Everything else takes the flat {.for_company}.
108
+ #
109
+ # @param company [Spree::Company, nil]
110
+ # @return [Array<Array<Spree::Catalog>>]
111
+ def self.groups_for_company(company)
100
112
  return [] if company.nil?
101
113
 
102
114
  nodes = company.self_and_ancestors
@@ -110,9 +122,15 @@ module Spree
110
122
  includes(:catalog).
111
123
  group_by(&:assignable_id)
112
124
 
113
- nodes.flat_map do |node|
114
- Array(assignments[node.id]).map(&:catalog).select(&:active?).sort_by { |catalog| catalog.position.to_i }
115
- end.uniq
125
+ # A catalog assigned to a node and to an ancestor answers from the nearer.
126
+ seen = Set.new
127
+
128
+ nodes.filter_map do |node|
129
+ group = Array(assignments[node.id]).map(&:catalog).
130
+ select { |catalog| catalog.active? && seen.add?(catalog.id) }.
131
+ sort_by { |catalog| catalog.position.to_i }
132
+ group.presence
133
+ end
116
134
  end
117
135
 
118
136
  # The catalogs assigned to any of the given customer groups, by position.
@@ -151,18 +169,30 @@ module Spree
151
169
  # @param channel [Spree::Channel, nil]
152
170
  # @return [Array<Spree::Catalog>]
153
171
  def self.for_context(store:, company: nil, user: nil, channel: nil)
172
+ groups_for_context(store: store, company: company, user: user, channel: channel).flatten
173
+ end
174
+
175
+ # The same resolution, grouped by precedence rank ({.groups_for_company}).
176
+ # Only the company axis has more than one rank.
177
+ #
178
+ # @return [Array<Array<Spree::Catalog>>]
179
+ def self.groups_for_context(store:, company: nil, user: nil, channel: nil)
154
180
  return [] if store.nil?
155
181
 
156
- catalogs = store.catalogs.for_company(company)
182
+ groups = store.catalogs.groups_for_company(company)
183
+
157
184
  # Only when the company axis found nothing: a buyer purchasing for a
158
185
  # company is on that company's agreement, and adding their personal
159
186
  # segment's catalogs on top would price one buyer under two agreements
160
187
  # at once.
161
- if catalogs.empty? && user
162
- groups = user.try(:customer_groups)&.where(store_id: store.id) || []
163
- catalogs = store.catalogs.for_customer_groups(groups)
188
+ if groups.empty? && user
189
+ customer_groups = user.try(:customer_groups)&.where(store_id: store.id) || []
190
+ groups = [store.catalogs.for_customer_groups(customer_groups).presence].compact
164
191
  end
165
- catalogs = [channel&.default_catalog].compact.select(&:active?) if catalogs.empty?
192
+
193
+ groups = [[channel&.default_catalog].compact.select(&:active?).presence].compact if groups.empty?
194
+
195
+ catalogs = groups.flatten
166
196
  # Pricing reads each catalog's price list, and the list's contextual
167
197
  # rules decide whether it applies to the quantity being bought; without
168
198
  # this the walk is one query per catalog again, plus one per list.
@@ -171,7 +201,7 @@ module Spree
171
201
  records: catalogs, associations: { price_list: [:price_rules, :price_adjustment_tiers] }
172
202
  ).call
173
203
  end
174
- catalogs
204
+ groups
175
205
  end
176
206
 
177
207
  # The catalogs applying to a buyer, resolving the company for them when
@@ -190,15 +220,22 @@ module Spree
190
220
  # @param channel [Spree::Channel, nil]
191
221
  # @return [Array<Spree::Catalog>]
192
222
  def self.for_buyer(store:, customer: nil, company: nil, channel: nil)
223
+ groups_for_buyer(store: store, customer: customer, company: company, channel: channel).flatten
224
+ end
225
+
226
+ # {.for_buyer}, grouped by precedence rank. What pricing walks.
227
+ #
228
+ # @return [Array<Array<Spree::Catalog>>]
229
+ def self.groups_for_buyer(store:, customer: nil, company: nil, channel: nil)
193
230
  return [] if store.nil?
194
231
 
195
232
  if store == Spree::Current.store
196
233
  company ||= Spree::Current.standing_company_for(customer)
197
234
  channel ||= Spree::Current.channel
198
- Spree::Current.catalogs_for(company: company, user: customer, channel: channel)
235
+ Spree::Current.catalog_groups_for(company: company, user: customer, channel: channel)
199
236
  else
200
237
  company ||= Spree::Company.sole_standing_for(store: store, customer: customer)
201
- for_context(store: store, company: company, user: customer, channel: channel)
238
+ groups_for_context(store: store, company: company, user: customer, channel: channel)
202
239
  end
203
240
  end
204
241
 
@@ -10,6 +10,7 @@ module Spree
10
10
  has_prefix_id :claim
11
11
 
12
12
  has_spree_number prefix: 'CLM'
13
+ include Spree::ActedBy
13
14
  include Spree::NumberIdentifier
14
15
  include Spree::SingleStoreResource
15
16
  include Spree::HasStatus
@@ -27,7 +28,7 @@ module Spree
27
28
  belongs_to :store, class_name: 'Spree::Store'
28
29
  belongs_to :order, class_name: 'Spree::Order', inverse_of: :claims
29
30
  belongs_to :reason, class_name: 'Spree::ClaimReason', optional: true, inverse_of: :claims
30
- belongs_to :created_by, class_name: Spree.admin_user_class.to_s, optional: true
31
+ acted_by :created_by
31
32
 
32
33
  has_many :claim_line_items, class_name: 'Spree::ClaimLineItem',
33
34
  dependent: :destroy, inverse_of: :claim
@@ -4,7 +4,7 @@ module Spree
4
4
  # All attributes are automatically reset between requests by Rails.
5
5
  # Fallback chains ensure sensible defaults when attributes are not explicitly set.
6
6
  class Current < ::ActiveSupport::CurrentAttributes
7
- attribute :store, :channel, :market, :currency, :locale, :content_locale, :tax_country, :price_lists, :applicable_catalogs, :quantity_rules_resolvers, :standing_companies, :global_pricing_context, :provider_cache, :integrations
7
+ attribute :store, :channel, :market, :currency, :locale, :content_locale, :tax_country, :price_lists, :applicable_catalogs, :applicable_catalog_groups, :quantity_rules_resolvers, :standing_companies, :global_pricing_context, :provider_cache, :integrations
8
8
 
9
9
  # Scratch space for provider strategies to memoize a call across the
10
10
  # request — part of the delivery rate provider contract (nothing in core
@@ -103,16 +103,22 @@ module Spree
103
103
  # @param channel [Spree::Channel, nil]
104
104
  # @return [Array<Spree::Catalog>]
105
105
  def catalogs_for(company: nil, user: nil, channel: nil)
106
- channel ||= self.channel
107
- # Normalized before the key is built, not just before the query: the
108
- # key is an id, so an admin and a customer sharing one would otherwise
109
- # share an entry and whichever resolved first would decide for both.
110
- # Only a customer has customer-group catalogs to resolve.
111
- user = nil unless user.is_a?(Spree.customer_class)
112
-
113
- key = [store&.id, company&.id, user&.id, channel&.id]
114
- applicable_catalogs[key] ||= Spree::Catalog.for_context(
115
- store: store, company: company, user: user, channel: channel
106
+ key = catalog_memo_key(company: company, user: user, channel: channel)
107
+ applicable_catalogs[key] ||= catalog_groups_for(company: company, user: user, channel: channel).flatten
108
+ end
109
+
110
+ # The same catalogs grouped by precedence rank, which is what pricing
111
+ # walks ({Spree::Catalog.groups_for_company}). The resolved form — the
112
+ # flat set above is a cached flattening of it, never a second resolution.
113
+ #
114
+ # @param company [Spree::Company, nil]
115
+ # @param user [Object, nil]
116
+ # @param channel [Spree::Channel, nil]
117
+ # @return [Array<Array<Spree::Catalog>>]
118
+ def catalog_groups_for(company: nil, user: nil, channel: nil)
119
+ key = catalog_memo_key(company: company, user: user, channel: channel)
120
+ applicable_catalog_groups[key] ||= Spree::Catalog.groups_for_context(
121
+ store: store, company: company, user: normalized_catalog_user(user), channel: channel || self.channel
116
122
  )
117
123
  end
118
124
 
@@ -121,6 +127,11 @@ module Spree
121
127
  super || (self.applicable_catalogs = {})
122
128
  end
123
129
 
130
+ # @return [Hash]
131
+ def applicable_catalog_groups
132
+ super || (self.applicable_catalog_groups = {})
133
+ end
134
+
124
135
  # The quantity-rule resolver for a buyer in this request, keyed the same
125
136
  # way their catalogs are.
126
137
  #
@@ -133,8 +144,7 @@ module Spree
133
144
  # @param channel [Spree::Channel, nil]
134
145
  # @return [Spree::Catalogs::ResolveQuantityRules]
135
146
  def quantity_rules_resolver_for(company: nil, user: nil, channel: nil)
136
- channel ||= self.channel
137
- key = [store&.id, company&.id, user&.id, channel&.id]
147
+ key = catalog_memo_key(company: company, user: user, channel: channel)
138
148
  quantity_rules_resolvers[key] ||= Spree::Catalogs::ResolveQuantityRules.new(
139
149
  catalogs_for(company: company, user: user, channel: channel)
140
150
  )
@@ -176,6 +186,7 @@ module Spree
176
186
  # @return [void]
177
187
  def reset_catalog_memos
178
188
  self.applicable_catalogs = nil
189
+ self.applicable_catalog_groups = nil
179
190
  self.quantity_rules_resolvers = nil
180
191
  self.standing_companies = nil
181
192
  self.price_lists = nil
@@ -194,5 +205,22 @@ module Spree
194
205
  )
195
206
  end
196
207
  end
208
+
209
+ private
210
+
211
+ # One key for every buyer-derived memo, so they cannot drift apart.
212
+ #
213
+ # @return [Array]
214
+ def catalog_memo_key(company: nil, user: nil, channel: nil)
215
+ [store&.id, company&.id, normalized_catalog_user(user)&.id, (channel || self.channel)&.id]
216
+ end
217
+
218
+ # Normalized before the key is built, not just before the query: the key
219
+ # is an id, so an admin and a customer sharing one would share an entry.
220
+ #
221
+ # @return [Object, nil]
222
+ def normalized_catalog_user(user)
223
+ user if user.is_a?(Spree.customer_class)
224
+ end
197
225
  end
198
226
  end
@@ -11,6 +11,7 @@ module Spree
11
11
  has_prefix_id :exch
12
12
 
13
13
  has_spree_number prefix: 'EX'
14
+ include Spree::ActedBy
14
15
  include Spree::NumberIdentifier
15
16
  include Spree::SingleStoreResource
16
17
  include Spree::HasStatus
@@ -26,7 +27,7 @@ module Spree
26
27
  belongs_to :order, class_name: 'Spree::Order', inverse_of: :exchanges
27
28
  belongs_to :stock_location, class_name: 'Spree::StockLocation'
28
29
  belongs_to :reason, class_name: 'Spree::ReturnReason', optional: true, inverse_of: :exchanges
29
- belongs_to :created_by, class_name: Spree.admin_user_class.to_s, optional: true
30
+ acted_by :created_by
30
31
 
31
32
  has_many :exchange_line_items, class_name: 'Spree::ExchangeLineItem',
32
33
  dependent: :destroy, inverse_of: :exchange