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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 820fa04cc9e7d880ff2a324afaf5fa2dce0f197c809b7d0af3aa59b69b65f8b6
4
- data.tar.gz: be3a6fac21d4524551c479cd02b8256f34ee9957e838f382bd37555fa40e9689
3
+ metadata.gz: 2407d5fb2d24d9dd893a8c9dae11cfd8aba5a4b1d621a82938a2cb7009581936
4
+ data.tar.gz: f3384773e3442f383e979f60d58b7b06672a72065cd8d117a114ef49827fda5e
5
5
  SHA512:
6
- metadata.gz: 8941ff574c4ba3455b1e67d90d500a61180d2567e6c5f5fa74e3db43aefb62d79d720753565a0ef4c29a6628f9e02a92385b6af7345548c8074815f6d212fa17
7
- data.tar.gz: 9e0ac49d08274a0cc82a1aea9fb8ea74bfb093b49d276401e62e34ef57b0fee0e30b3f814d465718d2d758ab855d35a36f16b31b281245a377e7d6ced174af9f
6
+ metadata.gz: ebc83d9db9ebc13afea53ae0b184789f32a81121dcd9608461d93fb7e2e62b020cef774e69de3404fc0d23adb62df13f770281eacd6fd4671e5c0f5328ecae5b
7
+ data.tar.gz: 99ecc2439e9a73c1f805898c35e48079cd573b947110951c1a8349b26934e7ea8023b55a53cfdf1b3af94a5013290c52e5a097e4e5dff61d1249af8ca3327fb7
@@ -0,0 +1,321 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ # Executes one Spree::MaintenanceTaskRun on Active Job Continuations
4
+ # (docs/plans/6.0-maintenance-tasks.md).
5
+ #
6
+ # Three things can stop a run mid-collection, and all three resolve to the
7
+ # same checkpoint: the worker is shutting down (Continuations re-queue the
8
+ # job), the execution has used its time slice (the runner re-queues
9
+ # itself so no single task holds a worker), or an operator asked to
10
+ # cancel (the Cancel service writes the request, the runner observes it
11
+ # here). The run row's cursor is written before each checkpoint, so
12
+ # whichever path fires, the next execution restarts from the same record.
13
+ class RunJob < ::Spree::BaseJob
14
+ include ActiveJob::Continuable
15
+
16
+ queue_as Spree.queues.maintenance_tasks
17
+
18
+ # A run that fails is recorded as failed; it must not be replayed by the
19
+ # backend behind the operator's back. Running it again is an explicit
20
+ # action.
21
+ #
22
+ # `inline` is a run executing in the caller's own process — the
23
+ # upgrade walk and the command line. It holds no worker, so it is not
24
+ # sliced: handing the remainder to a worker would let the caller report
25
+ # a step done that has not finished, on a deploy where no worker runs.
26
+ def perform(run_id, inline: false)
27
+ # Outside the steps on purpose — runs on every execution, resumes
28
+ # included.
29
+ @run = Spree::MaintenanceTaskRun.find_by(id: run_id)
30
+ return if @run.nil?
31
+ # `cancelling` counts as active: a job re-queued after its worker
32
+ # stopped is the only execution that will ever answer it.
33
+ return unless @run.active?
34
+
35
+ @task_class = @run.task_class
36
+ return unfindable_task if @task_class.nil?
37
+
38
+ @inline = inline
39
+ @execution_started_at = Time.current
40
+ @time_running_before = @run.time_running.to_f
41
+ prepare
42
+ return if @halted
43
+
44
+ check_operator_request
45
+ return if @halted
46
+ return refuse_duplicate if duplicate_run?
47
+
48
+ step :start
49
+ # Seeded from the row rather than from the continuation: the row
50
+ # survives a dropped job, which a re-queued execution then picks up
51
+ # with no continuation at all.
52
+ step :process_collection, start: @run.cursor
53
+ step :finish
54
+ rescue SignalException => e
55
+ # Ctrl-C or a deploy stopping an inline run. No job was queued for it,
56
+ # so nothing would ever come back to a run left `running`, and it
57
+ # would block every later run of its task.
58
+ record_error(e) if @inline && run&.reload&.active?
59
+ raise
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :run, :task
65
+
66
+ def start
67
+ return if run.running?
68
+
69
+ started = write_status('running', from: %w[enqueued interrupted],
70
+ started_at: run.started_at || Time.current,
71
+ job_id: job_id,
72
+ tick_total: run.tick_total || safe_count)
73
+ # An operator cancelled the run after the check above.
74
+ return check_operator_request unless started
75
+
76
+ publish('started')
77
+ rescue StandardError => e
78
+ record_error(e)
79
+ end
80
+
81
+ # Outside the steps, so an error here would never reach the rescues
82
+ # below: a run whose arguments no longer fit its task (a deploy removed
83
+ # a parameter) would stay queued forever, or be resumed by Continuations
84
+ # without end. Recorded as a failure instead.
85
+ def prepare
86
+ @task = @task_class.new(@run.arguments.presence || {})
87
+ @task.run = @run
88
+ resume_after_time_slice
89
+ rescue StandardError => e
90
+ record_error(e)
91
+ end
92
+
93
+ # One run per task is checked when a run is created, but two requests
94
+ # can both pass that check before either is saved. The older run wins,
95
+ # so the outcome does not depend on which worker picks a job up first.
96
+ def duplicate_run?
97
+ return false unless run.enqueued?
98
+
99
+ run.class.active.where(task_name: run.task_name).where(run.class.arel_table[:id].lt(run.id)).exists?
100
+ end
101
+
102
+ def refuse_duplicate
103
+ close_run('cancelled', 'Spree::MaintenanceTasks::AlreadyRunning',
104
+ "#{run.task_api_type} was already running, so this run did not start")
105
+ publish('cancelled')
106
+ end
107
+
108
+ # Continuations skip `start` once it has completed, so an execution
109
+ # picking up after its time slice would otherwise keep reporting
110
+ # `interrupted` while it works — and the Cancel service would treat it as
111
+ # idle. Conditional, so a cancel written while the job sat
112
+ # in the queue is not overwritten.
113
+ def resume_after_time_slice
114
+ return unless run.interrupted?
115
+
116
+ run.class.where(id: run.id, status: 'interrupted').update_all(status: 'running', updated_at: Time.current)
117
+ run.reload
118
+ end
119
+
120
+ # The collection walk. Batches are fetched by cursor rather than by
121
+ # offset so a resumed execution reads nothing it has already processed.
122
+ def process_collection(step)
123
+ return if @halted
124
+
125
+ if task.no_collection?
126
+ process_single
127
+ return
128
+ end
129
+
130
+ loop do
131
+ break if @halted
132
+
133
+ items = next_batch(step.cursor)
134
+ break if items.empty?
135
+
136
+ process_batch(items)
137
+ break if @halted
138
+
139
+ # One cursor value, the last id seen, written to the row and to the
140
+ # continuation. Persisted before the checkpoint — an interruption
141
+ # between these two lines must not lose the batch already applied.
142
+ cursor = items.last.id.to_s
143
+ persist_progress(cursor: cursor, count: items.size)
144
+ step.set!(cursor)
145
+
146
+ check_operator_request
147
+ check_time_slice(step)
148
+ end
149
+ rescue StandardError => e
150
+ # Not discard_on: Continuations resume errors raised after progress,
151
+ # so a discard handler would never see them and the run would loop.
152
+ record_error(e)
153
+ end
154
+
155
+ # The callback runs before the row is written so a task can summarise in
156
+ # `after_complete` — tallying there and persisting first would silently
157
+ # drop whatever it recorded.
158
+ def finish
159
+ return if @halted
160
+
161
+ task.after_complete
162
+ return check_operator_request unless write_status('succeeded', from: 'running',
163
+ ended_at: Time.current,
164
+ tallies: merged_tallies)
165
+
166
+ publish('succeeded')
167
+ rescue StandardError => e
168
+ record_error(e)
169
+ end
170
+
171
+ # A no_collection task is one unit of work: no cursor, no partial
172
+ # progress, and an error fails the whole run.
173
+ def process_single
174
+ task.process
175
+ persist_progress(cursor: nil, count: 1)
176
+ rescue StandardError => e
177
+ record_error(e)
178
+ end
179
+
180
+ def process_batch(items)
181
+ items.each { |item| task.process(item) }
182
+ end
183
+
184
+ # Cursor-paged rather than find_each: the cursor has to survive between
185
+ # executions, which an in-memory batch enumerator cannot do.
186
+ def next_batch(cursor)
187
+ scope = task.collection
188
+ scope = scope.where(scope.arel_table[scope.primary_key].gt(cursor)) if cursor.present?
189
+ scope.limit(task.class.batch_size).to_a
190
+ end
191
+
192
+ # update_columns rather than update!: progress is written on every batch
193
+ # and carries no validation of its own.
194
+ def persist_progress(cursor:, count:)
195
+ run.update_columns(
196
+ cursor: cursor,
197
+ tick_count: run.tick_count + count,
198
+ tallies: merged_tallies,
199
+ time_running: accumulated_time,
200
+ updated_at: Time.current
201
+ )
202
+ end
203
+
204
+ # Cancel is a request written to the row by the Cancel service; the
205
+ # runner is the only writer of the resulting state, so a killed worker
206
+ # can never leave `cancelled` on a run that is still moving.
207
+ #
208
+ # Read once per batch rather than on a timer: a primary-key read costs
209
+ # nothing beside the batch of writes it follows, and a time-based
210
+ # throttle would make a fast task ignore a cancel altogether — the
211
+ # batches would all land inside one interval.
212
+ def check_operator_request
213
+ requested = run.class.where(id: run.id).pick(:status)
214
+ return unless requested.in?(%w[cancelling cancelled])
215
+
216
+ write_status('cancelled', from: requested, ended_at: Time.current, time_running: accumulated_time)
217
+ publish('cancelled')
218
+ @halted = true
219
+ end
220
+
221
+ # The control services write to the same row this job holds in memory,
222
+ # so the write only lands while the row is still in the status the
223
+ # runner expects. Otherwise an operator's cancel, written
224
+ # between the runner's read and its write, would be overwritten.
225
+ #
226
+ # @return [Boolean] whether the row was in an expected status
227
+ def write_status(status, from:, **attributes)
228
+ written = run.class.where(id: run.id, status: from).
229
+ update_all(status: status, updated_at: Time.current, **attributes)
230
+ run.reload
231
+ written.positive?
232
+ end
233
+
234
+ def check_time_slice(step)
235
+ return if @inline
236
+ return if Time.current - @execution_started_at < Spree::MaintenanceTask::MAX_RUN_TIME
237
+
238
+ return check_operator_request unless write_status('interrupted', from: 'running',
239
+ time_running: accumulated_time)
240
+
241
+ publish('interrupted')
242
+ # Raises Continuation::Interrupt, which inherits Exception and is
243
+ # never caught by the rescues above.
244
+ interrupt!(reason: :run_time_limit)
245
+ end
246
+
247
+ # Recording a failure must never fail. This is the last thing standing
248
+ # between a crashed task and a run stuck in `running` forever, so it
249
+ # writes columns directly rather than going through validations that
250
+ # could reject the very state that describes the problem.
251
+ def record_error(error)
252
+ run.reload
253
+ run.update_columns(
254
+ status: 'errored',
255
+ ended_at: Time.current,
256
+ time_running: accumulated_time,
257
+ tallies: merged_tallies,
258
+ error_class: error.class.name,
259
+ error_message: error.message.to_s.truncate(1000),
260
+ error_backtrace: cleaned_backtrace(error),
261
+ updated_at: Time.current
262
+ )
263
+ publish('errored')
264
+ Rails.error.report(error, handled: true)
265
+ @halted = true
266
+ end
267
+
268
+ def cleaned_backtrace(error)
269
+ return nil if error.backtrace.blank?
270
+
271
+ Rails.backtrace_cleaner.clean(error.backtrace).first(50).join("\n")
272
+ end
273
+
274
+ # The task was removed from the registry between enqueue and execution —
275
+ # a deploy that dropped it. Recorded rather than raised: retrying cannot
276
+ # bring the class back.
277
+ def unfindable_task
278
+ close_run('errored', 'Spree::MaintenanceTasks::TaskNotRegistered',
279
+ "#{run.task_name} is not a registered maintenance task")
280
+ end
281
+
282
+ # Ends a run that never started work, so there is nothing to tally.
283
+ def close_run(status, error_class, error_message)
284
+ run.update_columns(status: status, ended_at: Time.current, error_class: error_class,
285
+ error_message: error_message, updated_at: Time.current)
286
+ end
287
+
288
+ # Summed, not replaced: a task instance counts only what the current
289
+ # execution did, so an interrupted run that merged would report its last
290
+ # slice as the whole total. Baselined at the first checkpoint of each
291
+ # execution so repeated writes within one execution stay accurate.
292
+ def merged_tallies
293
+ @tally_baseline ||= (run.tallies || {}).dup
294
+ return @tally_baseline.dup if task.nil?
295
+
296
+ task.tallies.each_with_object(@tally_baseline.dup) do |(key, value), result|
297
+ result[key] = result.fetch(key, 0) + value
298
+ end
299
+ end
300
+
301
+ # From the total this execution started with, never the row's current
302
+ # value: progress rewrites the row every batch, so adding elapsed time
303
+ # to it would count the same seconds again on each write.
304
+ def accumulated_time
305
+ return run.time_running if @execution_started_at.nil?
306
+
307
+ @time_running_before + (Time.current - @execution_started_at)
308
+ end
309
+
310
+ def safe_count
311
+ task.count
312
+ rescue StandardError
313
+ nil
314
+ end
315
+
316
+ def publish(event)
317
+ run.publish_event("maintenance_task_run.#{event}")
318
+ end
319
+ end
320
+ end
321
+ end
@@ -1,6 +1,7 @@
1
1
  module Spree
2
2
  module SellerTransfers
3
- # Hands every seller holding an unconfirmed earning to a job of their own.
3
+ # Hands every seller holding an unconfirmed earning or reversal to a job of
4
+ # their own.
4
5
  #
5
6
  # The transfer level's counterpart to {Spree::SellerPayouts::SweepDueJob},
6
7
  # and deliberately a separate schedule rather than something the settlement
@@ -39,17 +40,18 @@ module Spree
39
40
  # @param store [Spree::Store]
40
41
  # @return [ActiveRecord::Relation<Spree::Seller>]
41
42
  def sellers_awaiting_provider(store)
42
- scope = store.sellers.approved.
43
- where(id: Spree::SellerTransfer.for_store(store).awaiting_provider.select(:seller_id))
43
+ transfers = Spree::SellerTransfer.for_store(store)
44
+ sending = store.sellers.approved.where(id: transfers.awaiting_provider.select(:seller_id))
44
45
 
45
46
  # A seller the provider will not accept yet keeps their earnings
46
47
  # unconfirmed, and the job would return at once. Skipped rather than
47
48
  # enqueued, because a marketplace's half-onboarded sellers are a
48
49
  # standing population and this runs often — and nothing is lost by it:
49
50
  # finishing onboarding runs the job for them directly.
50
- return scope unless store.payout_provider_class.requires_payout_account?
51
+ sending = sending.where.not(payouts_enabled_at: nil) if store.payout_provider_class.requires_payout_account?
51
52
 
52
- scope.where.not(payouts_enabled_at: nil)
53
+ # Money owed back is pursued whatever the seller's standing.
54
+ sending.or(store.sellers.where(id: transfers.reversals_awaiting_provider.select(:seller_id)))
53
55
  end
54
56
  end
55
57
  end
@@ -14,6 +14,9 @@ module Spree
14
14
  # seller who was payable all along would never see a refused earning tried
15
15
  # again, since nothing about them ever changes to prompt it.
16
16
  #
17
+ # Reversals are retried the same way: a provider that pulls money back can
18
+ # refuse too, and one cannot be pulled back before its earning was sent.
19
+ #
17
20
  # It takes the retryable rows — never sent, or refused outright. A transfer
18
21
  # whose outcome the provider could not report is left alone: asking again
19
22
  # is precisely how the same money moves twice, and only a person can say
@@ -24,30 +27,47 @@ module Spree
24
27
  # @param seller_id [Integer]
25
28
  def perform(seller_id)
26
29
  seller = Spree::Seller.find_by(id: seller_id)
27
- return if seller.nil? || !seller.payouts_enabled?
30
+ return if seller.nil?
28
31
 
29
32
  Spree::Current.store = seller.store
33
+ send_earnings(seller) if seller.payouts_enabled?
34
+
35
+ # Never through `transfer!`: a reversal's amount is negative, and the
36
+ # amount sent is its absolute value, so it would pay the seller the
37
+ # money it exists to take back. Through the provider that made the
38
+ # earning, which is not necessarily the store's current one. And for
39
+ # every seller, payable or not — a seller suspended after a wave of
40
+ # refunds is exactly the one whose money must come back.
41
+ seller.seller_transfers.reversals_awaiting_provider.includes(:reversed_from, :order).find_each do |reversal|
42
+ execute(reversal) { reversal.payout_provider_instance.reverse!(reversal) }
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # Before the reversals, so a reversal waiting on its earning finds it sent.
49
+ def send_earnings(seller)
30
50
  provider = seller.store.payout_provider_instance
31
51
 
32
- # Earnings only — see the scope. A reversal is retryable the same way
33
- # when its own provider call is refused, but sending one through
34
- # `transfer!` would pay the seller the amount it exists to take back:
35
- # the row is negative and the amount sent is its absolute value.
36
52
  seller.seller_transfers.awaiting_provider.find_each do |transfer|
37
- provider.transfer!(transfer)
38
- rescue Spree::Core::AmbiguousGatewayError => e
39
- # The money may already have moved, so this row must not be offered
40
- # again: left retryable, the next run would send it a second time,
41
- # and an idempotency key stops that only while the provider still
42
- # holds its record.
43
- transfer.update!(status: 'unresolved')
44
- Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
45
- rescue StandardError => e
46
- # A refusal — the money did not move. One seller's stuck earning must
47
- # not stop the rest, and the row stays retryable for the next run.
48
- Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
53
+ execute(transfer) { provider.transfer!(transfer) }
49
54
  end
50
55
  end
56
+
57
+ def execute(transfer)
58
+ yield
59
+ rescue Spree::Core::AmbiguousGatewayError => e
60
+ # The money may already have moved, so this row must not be offered
61
+ # again: left retryable, the next run would move it a second time,
62
+ # and an idempotency key stops that only while the provider still
63
+ # holds its record.
64
+ transfer.update!(status: 'unresolved')
65
+ Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
66
+ rescue StandardError => e
67
+ # A refusal — the money did not move. One stuck row must not stop the
68
+ # rest, and it stays retryable for the next run.
69
+ Rails.error.report(e, handled: true, context: { seller_transfer_id: transfer.id }, source: 'spree.core')
70
+ end
51
71
  end
52
72
  end
53
73
  end
@@ -0,0 +1,48 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ module Upgrade
4
+ # 5.6 → 6.0: market is mandatory on orders in Spree 6, and orders placed
5
+ # before markets existed carry NULLs. Each order takes its own store's
6
+ # default market, so the walk is per order rather than one UPDATE per
7
+ # store — that is what gives the run a cursor to resume from and a
8
+ # progress bar to watch on a catalog-sized table.
9
+ #
10
+ # Orders whose store has no default market are counted and left alone;
11
+ # there is nothing to assign, and failing the run would strand every
12
+ # other store's orders behind them.
13
+ class BackfillOrderMarkets < Spree::MaintenanceTask
14
+ supports_dry_run
15
+ collection_batch_size 500
16
+
17
+ def collection
18
+ Spree::Order.unscoped.where(market_id: nil).order(:id)
19
+ end
20
+
21
+ def process(order)
22
+ market_id = default_market_id_for(order.store_id)
23
+
24
+ if market_id.nil?
25
+ tally(:skipped_no_default_market)
26
+ return
27
+ end
28
+
29
+ return tally(:would_update) if dry_run?
30
+
31
+ order.update_columns(market_id: market_id, updated_at: Time.current)
32
+ tally(:updated)
33
+ end
34
+
35
+ private
36
+
37
+ # One lookup per store rather than per order: a backfill of this size
38
+ # walks the same handful of stores over and over.
39
+ def default_market_id_for(store_id)
40
+ @default_market_ids ||= {}
41
+ return @default_market_ids[store_id] if @default_market_ids.key?(store_id)
42
+
43
+ @default_market_ids[store_id] = Spree::Store.find_by(id: store_id)&.default_market&.id
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,40 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ module Upgrade
4
+ # 5.6 → 6.0 conversion of the per-payment-method auto_capture boolean onto
5
+ # the capture_method vocabulary (docs/plans/6.0-store-scoped-configuration.md).
6
+ #
7
+ # A method that captured on authorization charges at checkout. One that
8
+ # did not only recorded "not at checkout" — the old column could not say
9
+ # whether dispatch or staff was meant to take the money, and the store's
10
+ # own setting is what decided. Leaving those rows empty is what makes them
11
+ # keep inheriting it, so only the true case is written here, and the
12
+ # collection is the true case alone: any other scope would never reach
13
+ # zero remaining.
14
+ class CaptureMethods < Spree::MaintenanceTask
15
+ supports_dry_run
16
+ collection_batch_size 5_000
17
+
18
+ def collection
19
+ Spree::PaymentMethod.unscoped.where(capture_method: nil, auto_capture: true).order(:id)
20
+ end
21
+
22
+ def process(payment_method)
23
+ return tally(:would_convert) if dry_run?
24
+
25
+ payment_method.update_columns(capture_method: 'checkout', updated_at: Time.current)
26
+ tally(:converted)
27
+ end
28
+
29
+ # Rows the conversion deliberately leaves alone, reported once so an
30
+ # operator can see they were considered rather than missed.
31
+ def after_complete
32
+ return if dry_run?
33
+
34
+ inheriting = Spree::PaymentMethod.unscoped.where(capture_method: nil, auto_capture: false).count
35
+ tally(:left_inheriting_store_setting, inheriting) if inheriting.positive?
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,15 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ module Upgrade
4
+ # Creates a delivery for every fulfillment that carries a 5.6 tracking
5
+ # number, moving carrier tracking onto its own record.
6
+ class Deliveries < RakeStep
7
+ runs_rake_task 'spree:migrate_deliveries'
8
+
9
+ attribute :batch_size, :integer, default: 1_000
10
+
11
+ passes_to_environment :batch_size, as: 'BATCH_SIZE'
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ module Upgrade
4
+ # Copies the coupon code a placed order was bought with onto the order
5
+ # itself.
6
+ class OrderCouponCodes < RakeStep
7
+ runs_rake_task 'spree:backfill_order_coupon_codes'
8
+
9
+ attribute :batch_size, :integer, default: 500
10
+
11
+ passes_to_environment :batch_size, as: 'BATCH_SIZE'
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ module Upgrade
4
+ # Rewrites Option Value promotion rules so they reference option values
5
+ # directly rather than the join rows they used to point at.
6
+ class PromotionOptionValueRules < RakeStep
7
+ runs_rake_task 'spree:upgrade:migrate_promotion_option_value_rules'
8
+
9
+ attribute :skip_failed_rows, :boolean, default: false
10
+
11
+ passes_to_environment :skip_failed_rows, as: 'SKIP_FAILED_ROWS'
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,83 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ module Upgrade
4
+ # Base for the one-pass upgrade steps that ask the operator for parameters
5
+ # (docs/plans/6.0-maintenance-tasks.md). The rake body stays the
6
+ # implementation; this class only maps its parameters onto the environment
7
+ # variables the body reads. A step without parameters runs through
8
+ # `UpgradeStep` and needs no class at all.
9
+ class RakeStep < Spree::MaintenanceTask
10
+ ENVIRONMENT_LOCK = Mutex.new
11
+
12
+ no_collection
13
+
14
+ class_attribute :rake_task_name, instance_writer: false
15
+ class_attribute :environment_parameters, instance_writer: false, default: {}.freeze
16
+
17
+ # @param name [String] the rake task this step runs
18
+ def self.runs_rake_task(name)
19
+ self.rake_task_name = name
20
+ end
21
+
22
+ # Declares a parameter the rake body reads from the environment.
23
+ #
24
+ # These bodies were written for a shell, so their answers arrive as
25
+ # environment variables. Mapping them to attributes records the answer
26
+ # on the run, and lets `ARGS` supply it when the step is run on its own.
27
+ #
28
+ # @param attribute_name [Symbol]
29
+ # @param as [String] the environment variable the body reads
30
+ def self.passes_to_environment(attribute_name, as:)
31
+ self.environment_parameters = environment_parameters.merge(attribute_name.to_sym => as).freeze
32
+ end
33
+
34
+ # The declared parameters an operator exported before running the
35
+ # upgrade walk, so the walk passes them on rather than letting each
36
+ # attribute's default overwrite them.
37
+ #
38
+ # @return [Hash{String => String}]
39
+ def self.arguments_from_environment
40
+ environment_parameters.each_with_object({}) do |(attribute_name, variable), arguments|
41
+ arguments[attribute_name.to_s] = ENV[variable] if ENV.key?(variable)
42
+ end
43
+ end
44
+
45
+ def process
46
+ with_environment { invoke_rake_task(self.class.rake_task_name) }
47
+ end
48
+
49
+ private
50
+
51
+ # Restored afterwards so one run never leaks its answers into the next.
52
+ # ENV is shared by the whole process, so two steps running at once in a
53
+ # threaded worker take turns rather than reading each other's answers.
54
+ def with_environment(&block)
55
+ return yield if self.class.environment_parameters.empty?
56
+
57
+ ENVIRONMENT_LOCK.synchronize { with_variables_set(&block) }
58
+ end
59
+
60
+ def with_variables_set
61
+ keys = self.class.environment_parameters.values
62
+ previous = ENV.to_h.slice(*keys)
63
+
64
+ self.class.environment_parameters.each do |attribute_name, variable|
65
+ ENV[variable] = environment_value(public_send(attribute_name))
66
+ end
67
+
68
+ yield
69
+ ensure
70
+ keys&.each { |key| ENV[key] = previous[key] }
71
+ end
72
+
73
+ def environment_value(value)
74
+ case value
75
+ when true then 'true'
76
+ when false, nil then nil
77
+ else value.to_s.presence
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,20 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ module Upgrade
4
+ # Moves return authorizations onto the 6.0 Return and Exchange models.
5
+ #
6
+ # Resumable by construction: the legacy number is copied onto the new
7
+ # record and uniquely indexed there, so what is still outstanding is a
8
+ # query rather than tracked state.
9
+ class Returns < RakeStep
10
+ runs_rake_task 'spree:upgrade:migrate_returns'
11
+
12
+ attribute :skip_failed_rows, :boolean, default: false
13
+ attribute :batch_size, :integer, default: 500
14
+
15
+ passes_to_environment :skip_failed_rows, as: 'SKIP_FAILED_ROWS'
16
+ passes_to_environment :batch_size, as: 'BATCH_SIZE'
17
+ end
18
+ end
19
+ end
20
+ end