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.
- checksums.yaml +4 -4
- data/app/jobs/spree/maintenance_tasks/run_job.rb +321 -0
- data/app/jobs/spree/seller_transfers/execute_pending_due_job.rb +7 -5
- data/app/jobs/spree/seller_transfers/execute_pending_job.rb +37 -17
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/backfill_order_markets.rb +48 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/capture_methods.rb +40 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/deliveries.rb +15 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/order_coupon_codes.rb +15 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/promotion_option_value_rules.rb +15 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/rake_step.rb +83 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/returns.rb +20 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/store_regional_settings.rb +63 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/users_to_customers.rb +28 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade_step.rb +44 -0
- data/app/models/concerns/spree/translatable_resource.rb +19 -0
- data/app/models/spree/ability.rb +9 -0
- data/app/models/spree/invitation.rb +11 -2
- data/app/models/spree/maintenance_task.rb +221 -0
- data/app/models/spree/maintenance_task_run.rb +78 -0
- data/app/models/spree/order_routing/strategy/rules.rb +8 -48
- data/app/models/spree/seller_transfer.rb +51 -0
- data/app/models/spree/stock/coordinator.rb +37 -17
- data/app/models/spree/stock/packer.rb +19 -9
- data/app/models/spree/stock/prioritizer.rb +30 -15
- data/app/models/spree/store.rb +35 -2
- data/app/models/spree/upgrade_record.rb +62 -0
- data/app/services/spree/carts/update.rb +2 -1
- data/app/services/spree/maintenance_tasks/argument_parser.rb +23 -0
- data/app/services/spree/maintenance_tasks/cancel.rb +51 -0
- data/app/services/spree/maintenance_tasks/start.rb +127 -0
- data/app/services/spree/seeds/all.rb +22 -0
- data/app/services/spree/stock_reservations/reserve.rb +73 -25
- data/app/workflows/spree/seller_transfers/reverse.rb +5 -44
- data/config/locales/en.yml +1 -0
- data/db/migrate/20260813130001_add_capture_method_to_payment_methods.rb +1 -1
- data/db/migrate/20260815000001_create_spree_maintenance_task_runs.rb +40 -0
- data/db/migrate/20260816000001_create_spree_upgrade_records.rb +17 -0
- data/db/migrate/20261029100001_add_lockout_columns_to_spree_admin_users.rb +8 -0
- data/lib/generators/spree/api_resource/api_resource_generator.rb +2 -2
- data/lib/spree/checkout/default_requirements.rb +33 -5
- data/lib/spree/core/configuration.rb +6 -0
- data/lib/spree/core/engine.rb +10 -1
- data/lib/spree/core/permissions/default_catalog.rb +7 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +17 -1
- data/lib/spree/testing_support/factories/maintenance_task_run_factory.rb +7 -0
- data/lib/spree/time_zones.rb +34 -0
- data/lib/spree/upgrade.rb +189 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +30 -10
- data/lib/tasks/maintenance_tasks.rake +42 -0
- data/lib/tasks/upgrade.rake +81 -60
- metadata +28 -6
- data/lib/tasks/capture_methods_migration.rake +0 -30
- 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 || []
|
data/app/models/spree/ability.rb
CHANGED
|
@@ -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 ||
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
43
|
-
order
|
|
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'
|