spree_core 6.0.0.rc7 → 6.0.1

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/maintenance_tasks/run_job.rb +321 -0
  3. data/app/jobs/spree/seller_transfers/execute_pending_due_job.rb +7 -5
  4. data/app/jobs/spree/seller_transfers/execute_pending_job.rb +37 -17
  5. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/backfill_order_markets.rb +48 -0
  6. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/capture_methods.rb +40 -0
  7. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/deliveries.rb +15 -0
  8. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/order_coupon_codes.rb +15 -0
  9. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/promotion_option_value_rules.rb +15 -0
  10. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/rake_step.rb +83 -0
  11. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/returns.rb +20 -0
  12. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/store_regional_settings.rb +63 -0
  13. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/users_to_customers.rb +28 -0
  14. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade_step.rb +44 -0
  15. data/app/models/concerns/spree/translatable_resource.rb +19 -0
  16. data/app/models/spree/ability.rb +9 -0
  17. data/app/models/spree/invitation.rb +11 -2
  18. data/app/models/spree/maintenance_task.rb +221 -0
  19. data/app/models/spree/maintenance_task_run.rb +78 -0
  20. data/app/models/spree/order_routing/strategy/rules.rb +8 -48
  21. data/app/models/spree/seller_transfer.rb +51 -0
  22. data/app/models/spree/stock/coordinator.rb +37 -17
  23. data/app/models/spree/stock/packer.rb +19 -9
  24. data/app/models/spree/stock/prioritizer.rb +30 -15
  25. data/app/models/spree/store.rb +35 -2
  26. data/app/models/spree/upgrade_record.rb +62 -0
  27. data/app/services/spree/carts/update.rb +2 -1
  28. data/app/services/spree/maintenance_tasks/argument_parser.rb +23 -0
  29. data/app/services/spree/maintenance_tasks/cancel.rb +51 -0
  30. data/app/services/spree/maintenance_tasks/start.rb +127 -0
  31. data/app/services/spree/seeds/all.rb +22 -0
  32. data/app/services/spree/stock_reservations/reserve.rb +73 -25
  33. data/app/workflows/spree/seller_transfers/reverse.rb +5 -44
  34. data/config/locales/en.yml +1 -0
  35. data/db/migrate/20260813130001_add_capture_method_to_payment_methods.rb +1 -1
  36. data/db/migrate/20260815000001_create_spree_maintenance_task_runs.rb +40 -0
  37. data/db/migrate/20260816000001_create_spree_upgrade_records.rb +17 -0
  38. data/db/migrate/20261029100001_add_lockout_columns_to_spree_admin_users.rb +8 -0
  39. data/lib/generators/spree/api_resource/api_resource_generator.rb +2 -2
  40. data/lib/spree/checkout/default_requirements.rb +33 -5
  41. data/lib/spree/core/configuration.rb +6 -0
  42. data/lib/spree/core/engine.rb +10 -1
  43. data/lib/spree/core/permissions/default_catalog.rb +7 -0
  44. data/lib/spree/core/version.rb +1 -1
  45. data/lib/spree/core.rb +17 -1
  46. data/lib/spree/testing_support/factories/maintenance_task_run_factory.rb +7 -0
  47. data/lib/spree/time_zones.rb +34 -0
  48. data/lib/spree/upgrade.rb +189 -0
  49. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +30 -10
  50. data/lib/tasks/maintenance_tasks.rake +42 -0
  51. data/lib/tasks/upgrade.rake +81 -60
  52. metadata +28 -6
  53. data/lib/tasks/capture_methods_migration.rake +0 -30
  54. data/lib/tasks/order_market_backfill.rake +0 -27
@@ -0,0 +1,63 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ module Upgrade
4
+ # 5.6 → 6.0 conversion of the store's time zone into an IANA identifier
5
+ # ("Eastern Time (US & Canada)" becomes "America/New_York") and of its
6
+ # admin language into a listed locale code (`en_US` becomes `en-US`), the
7
+ # only forms the API accepts and browsers read.
8
+ #
9
+ # A time zone that names no zone falls back to the store default, which is
10
+ # what the store already ran on: Ruby could not read it either. An admin
11
+ # language Spree does not list is cleared, so the dashboard shows each
12
+ # staff member their own language.
13
+ class StoreRegionalSettings < Spree::MaintenanceTask
14
+ supports_dry_run
15
+
16
+ def collection
17
+ Spree::Store.with_deleted.order(:id)
18
+ end
19
+
20
+ # Locks the store's row, so a settings save made meanwhile is read
21
+ # rather than overwritten, and counts each change once it is written.
22
+ #
23
+ # @param store [Spree::Store]
24
+ # @return [void]
25
+ def process(store)
26
+ store.with_lock do
27
+ changes = time_zone_change(store).merge(admin_locale_change(store))
28
+ next if changes.empty?
29
+
30
+ if dry_run?
31
+ changes.each_value { |outcome, _value| tally(:"would_#{outcome}") }
32
+ next
33
+ end
34
+
35
+ changes.each { |name, (_outcome, value)| store.set_preference(name, value) }
36
+ store.update_columns(preferences: store.preferences, updated_at: Time.current)
37
+ changes.each_value { |outcome, _value| tally(outcome) }
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def time_zone_change(store)
44
+ stored = store.preferred_timezone
45
+ return {} if Spree::TimeZones.valid?(stored)
46
+
47
+ converted = Spree::TimeZones.normalize(stored)
48
+ return { timezone: [:time_zone_converted, converted] } if Spree::TimeZones.valid?(converted)
49
+
50
+ { timezone: [:time_zone_reset_to_default, store.preference_default(:timezone)] }
51
+ end
52
+
53
+ def admin_locale_change(store)
54
+ stored = store.preferred_admin_locale
55
+ return {} if stored.blank? || Spree::Locales::ALL.include?(stored)
56
+
57
+ listed = Spree::Locales.listed(stored)
58
+ { admin_locale: listed ? [:admin_locale_converted, listed] : [:admin_locale_cleared, nil] }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,28 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ module Upgrade
4
+ # Copies the legacy Devise user table onto the gem-owned customer model
5
+ # and re-points every polymorphic reference that named the old class.
6
+ #
7
+ # Two of its parameters are questions only an operator can answer: whether
8
+ # a Devise pepper was ever configured (an encrypted password cannot be
9
+ # read without knowing, and Devise is gone by the time this runs), and
10
+ # what to do with source rows that cannot be copied.
11
+ class UsersToCustomers < RakeStep
12
+ runs_rake_task 'spree:upgrade:migrate_users_to_customers'
13
+
14
+ attribute :confirm_no_pepper, :boolean, default: false
15
+ attribute :skip_invalid_rows, :boolean, default: false
16
+ attribute :source_user_table, :string
17
+ attribute :source_user_type, :string
18
+ attribute :batch_size, :integer, default: 1_000
19
+
20
+ passes_to_environment :confirm_no_pepper, as: 'CONFIRM_NO_PEPPER'
21
+ passes_to_environment :skip_invalid_rows, as: 'SKIP_INVALID_ROWS'
22
+ passes_to_environment :source_user_table, as: 'SOURCE_USER_TABLE'
23
+ passes_to_environment :source_user_type, as: 'SOURCE_USER_TYPE'
24
+ passes_to_environment :batch_size, as: 'BATCH_SIZE'
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,44 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ # Runs one rake-backed step of an upgrade manifest, so a shell walk and a
4
+ # dashboard run leave the same audit record
5
+ # (docs/plans/6.0-maintenance-tasks.md).
6
+ #
7
+ # Every one-pass step names its rake task in the manifest and runs here, so
8
+ # the rake body stays the only implementation. A step gets a task class of
9
+ # its own only when it walks rows (progress, resume, dry run) or asks the
10
+ # operator for parameters.
11
+ class UpgradeStep < Spree::MaintenanceTask
12
+ no_collection
13
+
14
+ attribute :step_id, :string
15
+ validates :step_id, presence: true
16
+
17
+ validate :step_must_exist
18
+
19
+ def process
20
+ invoke_rake_task(manifest_step.fetch('task'))
21
+ end
22
+
23
+ # The manifest entry this run is executing, resolved across every
24
+ # manifest rather than a fixed one — the walker may be part way through a
25
+ # multi-release upgrade.
26
+ #
27
+ # @return [Hash, nil]
28
+ def manifest_step
29
+ @manifest_step ||= Spree::Upgrade.find_step(step_id)
30
+ end
31
+
32
+ private
33
+
34
+ # A step with a task class of its own runs as that class, so only a
35
+ # step that names a rake task can be run here.
36
+ def step_must_exist
37
+ return if step_id.blank?
38
+ return if manifest_step&.dig('task').present?
39
+
40
+ errors.add(:step_id, "no upgrade step with id #{step_id} runs a rake task")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -5,6 +5,9 @@ module Spree
5
5
  included do
6
6
  extend Mobility
7
7
  default_scope { i18n }
8
+
9
+ # Before the translations are autosaved, so it has to run first.
10
+ after_update :discard_blank_new_translations, prepend: true
8
11
  end
9
12
 
10
13
  class_methods do
@@ -178,6 +181,22 @@ module Spree
178
181
 
179
182
  private
180
183
 
184
+ # Clearing a translation's last value deletes its row, and Mobility then
185
+ # rebuilds a blank one for that locale while it finalizes dirty tracking.
186
+ # Saved, that blank row is either junk or, where translation rows are soft
187
+ # deleted (stores), a duplicate of the deleted row.
188
+ def discard_blank_new_translations
189
+ return unless association(:translations).loaded?
190
+
191
+ columns = self.class.translatable_fields.map(&:to_s)
192
+ translations.each do |translation|
193
+ next unless translation.new_record?
194
+ next if columns.any? { |column| translation[column].present? }
195
+
196
+ translation.mark_for_destruction
197
+ end
198
+ end
199
+
181
200
  # @raise [ActiveRecord::RecordInvalid] if any locale isn't supported by the store
182
201
  def validate_translation_locales!(locales)
183
202
  supported = translatable_store&.supported_locales_list || []
@@ -95,6 +95,15 @@ module Spree
95
95
  apply_staff_baseline
96
96
  grantable_keys_for(roles).each { |key| activate_permission(key) }
97
97
  end
98
+
99
+ withhold_maintenance_tasks unless Spree::Config.maintenance_tasks_dashboard_enabled
100
+ end
101
+
102
+ # Applied last so it overrides the full-access grant, and travels to the
103
+ # dashboard with the other rules, which then hides the page.
104
+ def withhold_maintenance_tasks
105
+ cannot :manage, [Spree::MaintenanceTaskRun, :maintenance_task]
106
+ @permission_keys -= %w[read_maintenance_tasks write_maintenance_tasks]
98
107
  end
99
108
 
100
109
  # Every key the roles ask for, bounded at activation by what each role's
@@ -116,6 +116,15 @@ module Spree
116
116
  publish_event('invitation.resent')
117
117
  end
118
118
 
119
+ # The staff account holding the invited address, if one exists. Matched
120
+ # the way the account's email uniqueness is, case-insensitively, so an
121
+ # address typed in another case still finds the one account it can belong
122
+ # to. Resolved by record rather than by comparing strings in Ruby, whose
123
+ # case folding is wider than the database's.
124
+ def invitee_account
125
+ Spree.admin_user_class.where('LOWER(email) = LOWER(?)', email).first
126
+ end
127
+
119
128
  private
120
129
 
121
130
  def publish_invitation_created_event
@@ -161,7 +170,7 @@ module Spree
161
170
  return if resource.blank?
162
171
 
163
172
  # Check role_users directly for better performance and to avoid potential source_type issues
164
- user_to_check = invitee || Spree.admin_user_class.find_by(email: email)
173
+ user_to_check = invitee || invitee_account
165
174
  return if user_to_check.blank?
166
175
 
167
176
  if resource.role_users.exists?(user: user_to_check)
@@ -172,7 +181,7 @@ module Spree
172
181
  def set_invitee_from_email
173
182
  return if invitee.present?
174
183
 
175
- self.invitee = Spree.admin_user_class.find_by(email: email)
184
+ self.invitee = invitee_account
176
185
  end
177
186
  end
178
187
  end
@@ -0,0 +1,221 @@
1
+ module Spree
2
+ # Base class for the data work an upgrade runs: one step of an upgrade
3
+ # manifest (docs/plans/6.0-maintenance-tasks.md).
4
+ #
5
+ # A task walks a stable-ordered collection and processes one record at a
6
+ # time. The runner (Spree::MaintenanceTasks::RunJob) supplies batching,
7
+ # checkpointing, cancelling and the audit row, so a task body says
8
+ # only what it does.
9
+ #
10
+ # class Spree::MaintenanceTasks::Upgrade::BackfillOrderMarkets < Spree::MaintenanceTask
11
+ # supports_dry_run
12
+ #
13
+ # def collection
14
+ # Spree::Order.where(market_id: nil).order(:id)
15
+ # end
16
+ #
17
+ # def process(order)
18
+ # return tally(:would_update) if dry_run?
19
+ #
20
+ # order.update_columns(market_id: order.store.default_market.id)
21
+ # tally(:updated)
22
+ # end
23
+ # end
24
+ #
25
+ # A manifest step names its task class, which registers it in
26
+ # `Spree.maintenance_tasks`; nothing else is runnable.
27
+ class MaintenanceTask
28
+ include ActiveModel::Model
29
+ include ActiveModel::Attributes
30
+ extend Spree::ApiTyped
31
+
32
+ # A rake body that stopped itself with `abort`.
33
+ class RakeTaskAborted < StandardError; end
34
+
35
+ # Two threads of one worker must not both find a task undefined and both
36
+ # load its file — the second load would make the task run twice.
37
+ RAKE_LOAD_LOCK = Mutex.new
38
+
39
+ DEFAULT_BATCH_SIZE = 100
40
+
41
+ # How long one execution may run before the runner checkpoints and
42
+ # re-enqueues itself, so a long step cannot hold a worker indefinitely.
43
+ MAX_RUN_TIME = 5.minutes
44
+
45
+ class_attribute :dry_run_supported, instance_writer: false, default: false
46
+ class_attribute :collection_kind, instance_writer: false, default: :relation
47
+ class_attribute :batch_size, instance_writer: false, default: DEFAULT_BATCH_SIZE
48
+
49
+ attr_accessor :run
50
+
51
+ class << self
52
+ # Declares that `process` honors `dry_run?`. The runner never fakes a
53
+ # dry run by rolling a transaction back, which would silently lie about
54
+ # external calls, enqueued jobs and cache writes.
55
+ def supports_dry_run
56
+ self.dry_run_supported = true
57
+ end
58
+
59
+ # The task performs a single operation instead of iterating; `process`
60
+ # is called once with no argument.
61
+ def no_collection
62
+ self.collection_kind = :none
63
+ end
64
+
65
+ def collection_batch_size(size)
66
+ self.batch_size = size
67
+ end
68
+
69
+ # @return [Array<Class>] registered task classes, sorted by name
70
+ def registered_classes
71
+ Spree.maintenance_tasks.filter_map do |entry|
72
+ entry.is_a?(Class) ? entry : entry.to_s.safe_constantize
73
+ end.uniq.sort_by(&:name)
74
+ end
75
+
76
+ # @param name [String] a task class name
77
+ # @return [Class, nil] nil when the name is not registered — an
78
+ # unregistered task is never runnable, so the runner and the API can
79
+ # treat nil as "no such task" without a second check
80
+ def find_registered(name)
81
+ return nil if name.blank?
82
+
83
+ registered_classes.find { |klass| klass.name == name.to_s }
84
+ end
85
+
86
+ # A task class name as the API shows it. A task that is no longer
87
+ # registered is shortened the same way rather than loaded by name.
88
+ #
89
+ # @param class_name [String]
90
+ # @return [String]
91
+ def api_type_for(class_name)
92
+ find_registered(class_name)&.api_type || class_name.to_s.demodulize.underscore
93
+ end
94
+
95
+ # The registered task an API caller names. Matches the short name only,
96
+ # so a Ruby class name arriving over the API is an unknown task.
97
+ #
98
+ # @param api_type [String] e.g. "capture_methods"
99
+ # @return [Class, nil]
100
+ def find_by_api_type(api_type)
101
+ Spree::ApiTyped.find(Spree.maintenance_tasks, api_type)
102
+ end
103
+ end
104
+
105
+ # The records to walk, ordered by primary key — the cursor is the last id
106
+ # processed, so any other order would resume in the wrong place.
107
+ #
108
+ # @return [ActiveRecord::Relation]
109
+ def collection
110
+ raise NotImplementedError, "#{self.class.name} must implement #collection or declare `no_collection`"
111
+ end
112
+
113
+ # @param item [Object] one record from the collection; omitted for
114
+ # `no_collection` tasks
115
+ def process(item = nil)
116
+ raise NotImplementedError, "#{self.class.name} must implement #process"
117
+ end
118
+
119
+ # Total records, for the progress bar. Return nil when counting is more
120
+ # expensive than the work itself — the run then reports ticks only.
121
+ #
122
+ # @return [Integer, nil]
123
+ def count
124
+ collection.count unless no_collection?
125
+ end
126
+
127
+ def no_collection?
128
+ self.class.collection_kind == :none
129
+ end
130
+
131
+ def dry_run?
132
+ run&.dry_run? || false
133
+ end
134
+
135
+ # Records a counter shown on the run page: what was updated, skipped, or
136
+ # would have changed in a dry run.
137
+ def tally(key, by = 1)
138
+ @tallies ||= Hash.new(0)
139
+ @tallies[key.to_s] += by
140
+ end
141
+
142
+ def tallies
143
+ @tallies ||= Hash.new(0)
144
+ end
145
+
146
+ # Runs an engine rake task as part of this task.
147
+ #
148
+ # Rake bodies stop themselves with `abort`, which raises SystemExit — not
149
+ # an error the runner records. Left alone it would end the whole process
150
+ # from the command line and, in a worker, leave the run marked running
151
+ # with nothing to finish it.
152
+ #
153
+ # @param task_name [String]
154
+ # @raise [RakeTaskAborted] carrying the body's own message
155
+ def invoke_rake_task(task_name)
156
+ load_engine_rake_tasks(task_name)
157
+
158
+ task = ::Rake::Task[task_name]
159
+ task.reenable
160
+ task.invoke
161
+ rescue SystemExit => e
162
+ raise RakeTaskAborted, e.message
163
+ end
164
+
165
+ # Finds the file that defines a task by reading the files rather than
166
+ # loading them: loading a .rake file to discover whether it is the right
167
+ # one would append its blocks to every task it defines, which is the very
168
+ # duplication this avoids.
169
+ #
170
+ # Searches every engine and the host application, since a step may ship in
171
+ # an extension gem. When two files define the same task name, the one
172
+ # that also declares the task's namespaces wins.
173
+ #
174
+ # @return [String, nil]
175
+ def rake_file_defining(task_name)
176
+ *namespaces, leaf = task_name.split(':')
177
+ candidates = rake_files.select do |path|
178
+ File.read(path).match?(/^\s*(?:desc\s.*\n\s*)?task\s+:?["']?#{Regexp.escape(leaf)}["']?\b/)
179
+ end
180
+
181
+ candidates.find do |path|
182
+ source = File.read(path)
183
+ namespaces.all? { |name| source.match?(/namespace\s+:?["']?#{Regexp.escape(name)}["']?\b/) }
184
+ end || candidates.first
185
+ end
186
+
187
+ # Called once the last record has been processed, before the run is
188
+ # marked succeeded — for a summary tally of what the walk left alone.
189
+ def after_complete; end
190
+
191
+ private
192
+
193
+ # Rake tasks are defined by the Rakefile, which a web process never loads —
194
+ # a task fired from the dashboard has to load the engine's tasks itself.
195
+ #
196
+ # Loads the one file that defines the task, and only when it is not already
197
+ # defined. Both halves matter: re-loading a .rake file APPENDS its block to
198
+ # a task that already exists, so a second load makes that task run twice,
199
+ # and loading every file would do that to every task in the engine. The
200
+ # symptom is invisible until something counts what it did.
201
+ #
202
+ # @param task_name [String] the rake task about to be invoked
203
+ def load_engine_rake_tasks(task_name)
204
+ require 'rake'
205
+
206
+ RAKE_LOAD_LOCK.synchronize do
207
+ next if ::Rake::Task.task_defined?(task_name)
208
+
209
+ ::Rake::Task.define_task(:environment) unless ::Rake::Task.task_defined?(:environment)
210
+
211
+ path = rake_file_defining(task_name)
212
+ load(path) if path
213
+ end
214
+ end
215
+
216
+ def rake_files
217
+ roots = Rails.application.railties.grep(Rails::Engine).map(&:root) + [Rails.root]
218
+ roots.uniq.flat_map { |root| Dir.glob(File.join(root, 'lib/tasks/**/*.rake')).sort }
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,78 @@
1
+ module Spree
2
+ # One execution of a Spree::MaintenanceTask — the durable record of what was
3
+ # run, by whom, with which arguments, and how far it got
4
+ # (docs/plans/6.0-maintenance-tasks.md).
5
+ #
6
+ # The row is both the checkpoint and the audit trail. Its `cursor` is the
7
+ # authoritative resume point: Active Job serializes its own continuation
8
+ # into the job data, but that copy is lost whenever the job is dropped by
9
+ # the backend, so the runner writes both in the same checkpoint and always
10
+ # seeds from this column.
11
+ #
12
+ # Runs are installation-wide: an upgrade step converts every store's data,
13
+ # so a run belongs to no store.
14
+ class MaintenanceTaskRun < Spree.base_class
15
+ has_prefix_id :mtr
16
+
17
+ include Spree::HasStatus
18
+
19
+ publishes_lifecycle_events
20
+ publishes_events :started, :succeeded, :cancelled, :interrupted, :errored
21
+
22
+ # Terminal statuses end a run for good; a failed run is started again
23
+ # rather than resumed. `cancelling` is the operator's request, written by
24
+ # the Cancel service and observed by the runner at its next checkpoint —
25
+ # the runner alone moves a running run into `cancelled`, so a stopped
26
+ # worker can never leave a lie in the column.
27
+ has_status :enqueued, :running, :interrupted, :cancelling, :cancelled,
28
+ :succeeded, :errored,
29
+ default: :enqueued
30
+
31
+ ACTIVE_STATUSES = %w[enqueued running interrupted cancelling].freeze
32
+ INITIATED_VIA = %w[dashboard api cli].freeze
33
+
34
+ belongs_to :admin_user, class_name: Spree.admin_user_class.to_s, optional: true
35
+ belongs_to :api_key, class_name: 'Spree::ApiKey', optional: true
36
+
37
+ validates :task_name, presence: true
38
+ validates :initiated_via, presence: true, inclusion: { in: INITIATED_VIA }
39
+
40
+ scope :active, -> { where(status: ACTIVE_STATUSES) }
41
+
42
+ self.whitelisted_ransackable_attributes = %w[task_name status created_at]
43
+
44
+ # Filtered by the task's short name, which is what responses carry; the
45
+ # column holds its class name. A partial value (`task_name_cont=capture`)
46
+ # is camelized so a search still matches part of the stored name.
47
+ ransacker :task_name, formatter: ->(value) {
48
+ Spree::MaintenanceTask.find_by_api_type(value)&.name || value.to_s.camelize
49
+ } do |parent|
50
+ parent.table[:task_name]
51
+ end
52
+
53
+ # @return [Class, nil] the registered task class, or nil when the task has
54
+ # been removed from the registry since the run was created
55
+ def task_class
56
+ Spree::MaintenanceTask.find_registered(task_name)
57
+ end
58
+
59
+ # The task's short name, as the API and the dashboard identify it.
60
+ #
61
+ # @return [String]
62
+ def task_api_type
63
+ Spree::MaintenanceTask.api_type_for(task_name)
64
+ end
65
+
66
+ def active?
67
+ ACTIVE_STATUSES.include?(status)
68
+ end
69
+
70
+ # Seconds of work. Time since the last checkpoint counts only while a
71
+ # worker holds the run — a queued run is not working.
72
+ def duration
73
+ return time_running unless status.in?(%w[running cancelling]) && started_at.present?
74
+
75
+ time_running + (Time.current - (updated_at || started_at))
76
+ end
77
+ end
78
+ end
@@ -10,7 +10,7 @@ module Spree
10
10
  # See docs/plans/6.0-order-routing.md.
11
11
  class Rules < Base
12
12
  def for_allocation
13
- locations = eligible_locations
13
+ locations = coordinator.candidate_stock_locations
14
14
  return [] if locations.empty?
15
15
 
16
16
  ordered = Spree::OrderRouting::Strategy::Reducer
@@ -18,9 +18,11 @@ module Spree
18
18
  .rank_all(locations)
19
19
  return [] if ordered.empty?
20
20
 
21
- packages = build_packages(ordered)
22
- packages = prioritize_packages(packages)
23
- estimate_rates(packages)
21
+ # Packed in rank order through the Coordinator, so an order allocates
22
+ # under the same delivery profile and channel coverage as its cart,
23
+ # and the Prioritizer's first-package-wins-on-hand logic honors the
24
+ # routing decision between origins that can reach the destination.
25
+ coordinator.estimate_packages(coordinator.allocated_packages(ordered))
24
26
  end
25
27
 
26
28
  # Stock decrement / restock today happens via Spree::Fulfillment's state
@@ -39,50 +41,8 @@ module Spree
39
41
  order.channel.order_routing_rules.active.ordered
40
42
  end
41
43
 
42
- def eligible_locations
43
- order.store.stock_locations.active
44
- .joins(:stock_levels)
45
- .where(Spree::StockLevel.table_name => { variant_id: requested_variant_ids })
46
- .distinct
47
- .to_a
48
- end
49
-
50
- def requested_variant_ids
51
- inventory_units.map(&:variant_id).uniq
52
- end
53
-
54
- def inventory_units
55
- @inventory_units ||= Spree::Stock::InventoryUnitBuilder.new(order).units
56
- end
57
-
58
- # Pack each ranked location independently. Packages are emitted in
59
- # rank order so the Prioritizer's first-package-wins-on-hand logic
60
- # honors the routing decision.
61
- def build_packages(locations)
62
- stock_levels = Spree::Stock::Packer.stock_levels_by_location(locations, requested_variant_ids)
63
-
64
- locations.flat_map do |location|
65
- Spree::Stock::Packer.new(location, inventory_units, Spree.stock_splitters, owner: order,
66
- stock_levels: stock_levels.fetch(location.id, {})).packages
67
- end
68
- end
69
-
70
- # Prioritizer's Adjuster distributes each inventory_unit across
71
- # packages: the first package with on-hand stock fulfills the unit,
72
- # and downstream packages have that unit removed. Packages whose
73
- # items all get stripped are pruned. Rank only decides between origins
74
- # that can reach the destination — the Prioritizer demotes the rest.
75
- def prioritize_packages(packages)
76
- Spree::Stock::Prioritizer.new(packages, estimator: estimator).prioritized_packages
77
- end
78
-
79
- def estimate_rates(packages)
80
- packages.each { |pkg| pkg.delivery_rates = estimator.delivery_rates(pkg) }
81
- packages
82
- end
83
-
84
- def estimator
85
- @estimator ||= Spree::Stock::Estimator.new(order)
44
+ def coordinator
45
+ @coordinator ||= Spree::Stock::Coordinator.new(order)
86
46
  end
87
47
  end
88
48
  end
@@ -89,6 +89,16 @@ module Spree
89
89
  # amount is negative, and sending one as a transfer pays the seller the
90
90
  # money it exists to take back.
91
91
  scope :awaiting_provider, -> { earnings.retryable.where(payout_id: nil) }
92
+ # Reversals still owing the provider a call: refused, or waiting for their
93
+ # earning to reach the provider first. Kept apart from `awaiting_provider`
94
+ # so a caller has to choose `reverse!` for these by name.
95
+ #
96
+ # A reversal of an earning nobody can account for waits with it: there is
97
+ # nothing to take back from until a person settles what happened.
98
+ scope :reversals_awaiting_provider, lambda {
99
+ reversals_only.retryable.where(payout_id: nil).
100
+ where.not(reversed_from_id: Spree::SellerTransfer.with_status('unresolved').select(:id))
101
+ }
92
102
  # Earned and confirmed, but not yet swept into a settlement — what the next
93
103
  # payout will pick up.
94
104
  scope :unsettled, -> { completed.where(payout_id: nil) }
@@ -138,6 +148,47 @@ module Spree
138
148
  [amount - reversals.sum(:amount).abs, 0].max
139
149
  end
140
150
 
151
+ # The provider that moved this row's money, not whichever one the store
152
+ # uses now. A marketplace that changes provider still has money sitting
153
+ # with the old one, and asking the new one to reverse a transfer it never
154
+ # made leaves the original standing.
155
+ #
156
+ # No silent fallback when that provider is no longer installed: handing
157
+ # the row to a different one would mark it done while the money stayed
158
+ # where it was. An operator has to know.
159
+ #
160
+ # @return [Spree::PayoutProvider::Base]
161
+ # @raise [Spree::Core::GatewayError] when the provider is not registered
162
+ def payout_provider_instance
163
+ configured = Spree::ApiTyped.registered_class(Spree.payout_providers, provider)
164
+ raise Spree::Core::GatewayError, "Payout provider #{provider} is not registered" if configured.nil?
165
+
166
+ configured.new
167
+ end
168
+
169
+ # The settlement a reversal of part of this earning records.
170
+ #
171
+ # A clawback has to settle where its earning settled. Payouts are swept by
172
+ # settlement currency, so a reversal left in the sale's currency would
173
+ # never join the batch that pays the earning it cancels — the seller would
174
+ # be paid in full for goods that came back.
175
+ #
176
+ # Prorated from this earning's settled figure rather than converted at
177
+ # today's rate, so the money comes back at the rate it went out at.
178
+ #
179
+ # @param part [BigDecimal] how much of this earning is reversed, positive
180
+ # @return [Hash] negative `settled_amount` and `settled_currency`
181
+ def reversal_settlement(part)
182
+ return {} if settled_amount.blank? || amount.zero?
183
+
184
+ share = settled_amount * (part / amount)
185
+
186
+ {
187
+ settled_amount: -Spree::Money::Rounding.quantize(share, Spree::Money::Rounding.precision(settlement_currency)),
188
+ settled_currency: settled_currency
189
+ }
190
+ end
191
+
141
192
  # @return [Boolean]
142
193
  def earning?
143
194
  kind == 'earning'