spree_core 6.0.0 → 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/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/store_regional_settings.rb +63 -0
- data/app/models/concerns/spree/translatable_resource.rb +19 -0
- data/app/models/spree/invitation.rb +11 -2
- 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/services/spree/carts/update.rb +2 -1
- data/app/services/spree/stock_reservations/reserve.rb +73 -25
- data/app/workflows/spree/seller_transfers/reverse.rb +5 -44
- data/db/migrate/20261029100001_add_lockout_columns_to_spree_admin_users.rb +8 -0
- data/lib/spree/checkout/default_requirements.rb +33 -5
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +1 -0
- data/lib/spree/time_zones.rb +34 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +13 -0
- metadata +7 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2407d5fb2d24d9dd893a8c9dae11cfd8aba5a4b1d621a82938a2cb7009581936
|
|
4
|
+
data.tar.gz: f3384773e3442f383e979f60d58b7b06672a72065cd8d117a114ef49827fda5e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ebc83d9db9ebc13afea53ae0b184789f32a81121dcd9608461d93fb7e2e62b020cef774e69de3404fc0d23adb62df13f770281eacd6fd4671e5c0f5328ecae5b
|
|
7
|
+
data.tar.gz: 99ecc2439e9a73c1f805898c35e48079cd573b947110951c1a8349b26934e7ea8023b55a53cfdf1b3af94a5013290c52e5a097e4e5dff61d1249af8ca3327fb7
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module Spree
|
|
2
2
|
module SellerTransfers
|
|
3
|
-
# Hands every seller holding an unconfirmed earning to a job of
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
51
|
+
sending = sending.where.not(payouts_enabled_at: nil) if store.payout_provider_class.requires_payout_account?
|
|
51
52
|
|
|
52
|
-
|
|
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?
|
|
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,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
|
|
@@ -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 || []
|
|
@@ -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
|
|
@@ -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'
|
|
@@ -22,13 +22,45 @@ module Spree
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def packages
|
|
25
|
-
|
|
26
|
-
packages = prioritize_packages(packages)
|
|
27
|
-
packages = estimate_packages(packages)
|
|
25
|
+
estimate_packages(allocated_packages)
|
|
28
26
|
end
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
# Where each unit comes from, without quoting a rate, so a caller that
|
|
29
|
+
# only needs the origins — a checkout hold — never costs a carrier request.
|
|
30
|
+
#
|
|
31
|
+
# @param stock_locations [Array<Spree::StockLocation>] candidate origins, preferred first
|
|
32
|
+
# @return [Array<Spree::Stock::Package>]
|
|
33
|
+
def allocated_packages(stock_locations = candidate_stock_locations)
|
|
34
|
+
prioritize_packages(build_packages(stock_locations: stock_locations))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether any delivery method can carry the package to the order's
|
|
38
|
+
# address, by the rule allocation ranked origins with, so no rate is requested.
|
|
39
|
+
#
|
|
40
|
+
# @param package [Spree::Stock::Package]
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
def deliverable?(package)
|
|
43
|
+
estimator.deliverable?(package)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param packages [Array<Spree::Stock::Package>]
|
|
47
|
+
# @return [Array<Spree::Stock::Package>] the same packages, with their delivery rates
|
|
48
|
+
def estimate_packages(packages)
|
|
49
|
+
packages.each do |package|
|
|
50
|
+
package.delivery_rates = estimator.delivery_rates(package)
|
|
51
|
+
end
|
|
52
|
+
packages
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Active locations stocking any requested variant, in database order.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<Spree::StockLocation>]
|
|
58
|
+
def candidate_stock_locations
|
|
59
|
+
order.store.stock_locations.active.joins(:stock_levels)
|
|
60
|
+
.where(Spree::StockLevel.table_name => { variant_id: requested_variant_ids }).distinct.to_a
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_packages(packages = [], stock_locations: candidate_stock_locations)
|
|
32
64
|
stock_levels = Packer.stock_levels_by_location(stock_locations, requested_variant_ids)
|
|
33
65
|
|
|
34
66
|
stock_locations.each do |stock_location|
|
|
@@ -44,11 +76,6 @@ module Spree
|
|
|
44
76
|
|
|
45
77
|
private
|
|
46
78
|
|
|
47
|
-
def stock_locations_with_requested_variants
|
|
48
|
-
order.store.stock_locations.active.joins(:stock_levels).
|
|
49
|
-
where(Spree::StockLevel.table_name => { variant_id: requested_variant_ids }).distinct
|
|
50
|
-
end
|
|
51
|
-
|
|
52
79
|
# An item may only be allocated from locations its delivery profile
|
|
53
80
|
# covers — a profile narrowed to the cold-storage warehouse never packs
|
|
54
81
|
# from anywhere else — intersected with the channel's served set when
|
|
@@ -104,13 +131,6 @@ module Spree
|
|
|
104
131
|
prioritizer.prioritized_packages
|
|
105
132
|
end
|
|
106
133
|
|
|
107
|
-
def estimate_packages(packages)
|
|
108
|
-
packages.each do |package|
|
|
109
|
-
package.delivery_rates = estimator.delivery_rates(package)
|
|
110
|
-
end
|
|
111
|
-
packages
|
|
112
|
-
end
|
|
113
|
-
|
|
114
134
|
# Shared between prioritization (which origins can deliver at all) and
|
|
115
135
|
# the rate refresh, so both answer with the same eligibility rules.
|
|
116
136
|
def estimator
|
|
@@ -44,25 +44,35 @@ module Spree
|
|
|
44
44
|
package = build_package
|
|
45
45
|
|
|
46
46
|
# Group by variant_id as grouping by variant fires cached query.
|
|
47
|
-
units_by_variant_id = inventory_units.
|
|
47
|
+
units_by_variant_id = inventory_units.group_by(&:variant_id)
|
|
48
48
|
# The package's variants and their levels here, each in one query
|
|
49
49
|
# rather than one per variant. Lowest id first, as StockLocation#stock_level reads.
|
|
50
50
|
variants = variants_for(units_by_variant_id)
|
|
51
51
|
stock_levels = @stock_levels || stock_location.stock_levels.where(variant_id: units_by_variant_id.keys).order(:id).
|
|
52
52
|
group_by(&:variant_id).transform_values(&:first)
|
|
53
53
|
|
|
54
|
-
units_by_variant_id.each do |variant_id,
|
|
54
|
+
units_by_variant_id.each do |variant_id, units|
|
|
55
55
|
variant = variants[variant_id] || Spree::Variant.find(variant_id)
|
|
56
|
-
|
|
56
|
+
# Can be used by others, do not use directly
|
|
57
|
+
units = units.map(&:dup)
|
|
57
58
|
if variant.should_track_inventory?
|
|
58
59
|
stock_level = stock_levels[variant_id]
|
|
59
60
|
next if stock_level.nil?
|
|
60
61
|
|
|
61
|
-
on_hand, backordered = stock_location.fill_status(variant,
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
on_hand, backordered = stock_location.fill_status(variant, units.sum(&:quantity), stock_level: stock_level)
|
|
63
|
+
# Two lines of one variant draw on one shelf, so it is split
|
|
64
|
+
# between them in order rather than offered to each in full.
|
|
65
|
+
units.each do |unit|
|
|
66
|
+
unit_on_hand = [on_hand, unit.quantity].min
|
|
67
|
+
unit_backordered = [backordered, unit.quantity - unit_on_hand].min
|
|
68
|
+
on_hand -= unit_on_hand
|
|
69
|
+
backordered -= unit_backordered
|
|
70
|
+
|
|
71
|
+
package.add(FulfillmentItem.split(unit, unit_backordered), :backordered) if unit_backordered.positive?
|
|
72
|
+
package.add(FulfillmentItem.split(unit, unit_on_hand), :on_hand) if unit_on_hand.positive?
|
|
73
|
+
end
|
|
64
74
|
else
|
|
65
|
-
package.add unit
|
|
75
|
+
units.each { |unit| package.add unit }
|
|
66
76
|
end
|
|
67
77
|
end
|
|
68
78
|
|
|
@@ -84,8 +94,8 @@ module Spree
|
|
|
84
94
|
# The units' own variants when they carry them, so a cart's variants are
|
|
85
95
|
# read once however many locations are packed.
|
|
86
96
|
def variants_for(units_by_variant_id)
|
|
87
|
-
variants = units_by_variant_id.each_with_object({}) do |(variant_id,
|
|
88
|
-
loaded[variant_id] =
|
|
97
|
+
variants = units_by_variant_id.each_with_object({}) do |(variant_id, units), loaded|
|
|
98
|
+
loaded[variant_id] = units.first.variant if units.first.association(:variant).loaded?
|
|
89
99
|
end
|
|
90
100
|
missing_ids = units_by_variant_id.keys - variants.keys
|
|
91
101
|
missing_ids.empty? ? variants : variants.merge(Spree::Variant.where(id: missing_ids).index_by(&:id))
|
|
@@ -18,6 +18,7 @@ module Spree
|
|
|
18
18
|
|
|
19
19
|
def prioritized_packages
|
|
20
20
|
sort_packages
|
|
21
|
+
withdraw_covered_items
|
|
21
22
|
adjust_packages
|
|
22
23
|
prune_packages
|
|
23
24
|
packages
|
|
@@ -44,11 +45,10 @@ module Spree
|
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
# Origins that can deliver go first, so the Adjuster fills each unit from
|
|
47
|
-
# one of them
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# though another warehouse could have shipped it.
|
|
48
|
+
# one of them. A profile with per-region origin groups would otherwise
|
|
49
|
+
# allocate to whichever warehouse came first, and when that warehouse's
|
|
50
|
+
# group has no method for the destination the item is dropped as
|
|
51
|
+
# undeliverable even though another warehouse could have shipped it.
|
|
52
52
|
#
|
|
53
53
|
# The caller's order survives within each group, so routing rank and the
|
|
54
54
|
# store's location order still decide between origins that can both
|
|
@@ -56,25 +56,40 @@ module Spree
|
|
|
56
56
|
def sort_packages
|
|
57
57
|
return if estimator.nil?
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
deliverable, @undeliverable_packages = packages.partition { |package| estimator.deliverable?(package) }
|
|
60
|
+
packages.replace(deliverable + @undeliverable_packages)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Ordering alone loses to the Adjuster, which takes stock on hand anywhere
|
|
64
|
+
# over a backorder; a unit the capable origins cannot cover stays, so it is
|
|
65
|
+
# still reported as undeliverable rather than lost.
|
|
66
|
+
def withdraw_covered_items
|
|
67
|
+
return if @undeliverable_packages.blank? || @undeliverable_packages.size == packages.size
|
|
68
|
+
|
|
69
|
+
supplied = supplied_quantities(packages - @undeliverable_packages)
|
|
70
|
+
|
|
71
|
+
@undeliverable_packages.each do |package|
|
|
72
|
+
package.contents.each do |item|
|
|
73
|
+
package.remove_item(item) if supplied[hash_item(item)] >= item.inventory_unit.required_quantity
|
|
74
|
+
end
|
|
61
75
|
end
|
|
76
|
+
end
|
|
62
77
|
|
|
63
|
-
|
|
78
|
+
def supplied_quantities(packages)
|
|
79
|
+
packages.flat_map(&:contents).each_with_object(Hash.new(0)) do |item, supplied|
|
|
80
|
+
supplied[hash_item(item)] += item.quantity
|
|
81
|
+
end
|
|
64
82
|
end
|
|
65
83
|
|
|
66
84
|
def prune_packages
|
|
67
85
|
packages.reject!(&:empty?)
|
|
68
86
|
end
|
|
69
87
|
|
|
88
|
+
# A proposed unit has no fulfillment, so its line item keeps two lines of
|
|
89
|
+
# one variant apart; a pair, as XORed ids collide whenever they match.
|
|
70
90
|
def hash_item(item)
|
|
71
|
-
|
|
72
|
-
variant
|
|
73
|
-
if shipment.present?
|
|
74
|
-
variant.hash ^ shipment.hash
|
|
75
|
-
else
|
|
76
|
-
variant.hash
|
|
77
|
-
end
|
|
91
|
+
unit = item.inventory_unit
|
|
92
|
+
[unit.variant, unit.fulfillment || unit.line_item_id]
|
|
78
93
|
end
|
|
79
94
|
end
|
|
80
95
|
end
|
data/app/models/spree/store.rb
CHANGED
|
@@ -42,6 +42,11 @@ module Spree
|
|
|
42
42
|
acts_as_paranoid
|
|
43
43
|
# deleted translation values still need to be accessible - remove deleted_at scope
|
|
44
44
|
default_scope { unscope(where: :deleted_at) }
|
|
45
|
+
|
|
46
|
+
# Mobility deletes a row once its last value is cleared. The soft delete
|
|
47
|
+
# stamps only `deleted_at`, and the row stays readable, so the cleared
|
|
48
|
+
# values have to be written too or the old translation keeps showing.
|
|
49
|
+
before_destroy { update_columns(changes_to_save.transform_values(&:last)) if has_changes_to_save? }
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
#
|
|
@@ -50,8 +55,9 @@ module Spree
|
|
|
50
55
|
UNIT_SYSTEMS = %w[metric imperial].freeze
|
|
51
56
|
|
|
52
57
|
# general preferences
|
|
53
|
-
preference :admin_locale, :string, format: :locale
|
|
54
|
-
preference :timezone, :string, format: :timezone, default: Time.zone.name
|
|
58
|
+
preference :admin_locale, :string, format: :locale, parse_on_set: ->(value) { Spree::Locales.listed(value) || value.presence }
|
|
59
|
+
preference :timezone, :string, format: :timezone, default: Spree::TimeZones.normalize(Time.zone.name),
|
|
60
|
+
parse_on_set: ->(value) { Spree::TimeZones.normalize(value) }
|
|
55
61
|
preference :weight_unit, :string, default: 'lb', choices: -> { Spree::Variant::WEIGHT_UNITS }
|
|
56
62
|
preference :unit_system, :string, default: 'imperial', choices: -> { UNIT_SYSTEMS }
|
|
57
63
|
# Anonymous identifier of this installation, kept on the default store
|
|
@@ -403,6 +409,8 @@ module Spree
|
|
|
403
409
|
message: :invalid_document_number_affix }
|
|
404
410
|
validates :preferred_capture_method, inclusion: { in: Spree::CaptureMethod::CAPTURE_METHODS }
|
|
405
411
|
validate :preferred_storefront_url_is_an_origin
|
|
412
|
+
validate :preferred_admin_locale_is_listed
|
|
413
|
+
validate :preferred_timezone_is_a_time_zone
|
|
406
414
|
validate :order_number_sequence_start_unchanged_after_first_number, on: :update
|
|
407
415
|
validates :mail_from_address, email: { allow_blank: false }
|
|
408
416
|
validates :customer_support_email, email: { allow_blank: true }
|
|
@@ -836,6 +844,31 @@ module Spree
|
|
|
836
844
|
errors.add(:preferred_storefront_url, :invalid)
|
|
837
845
|
end
|
|
838
846
|
|
|
847
|
+
def preferred_admin_locale_is_listed
|
|
848
|
+
locale = preferred_admin_locale
|
|
849
|
+
return if locale.blank? || Spree::Locales::ALL.include?(locale)
|
|
850
|
+
return if awaiting_upgrade?(:admin_locale) { Spree::Locales.listed(locale) }
|
|
851
|
+
|
|
852
|
+
errors.add(:preferred_admin_locale, :inclusion)
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def preferred_timezone_is_a_time_zone
|
|
856
|
+
return if Spree::TimeZones.valid?(preferred_timezone)
|
|
857
|
+
return if awaiting_upgrade?(:timezone) { Spree::TimeZones.valid?(Spree::TimeZones.normalize(preferred_timezone)) }
|
|
858
|
+
|
|
859
|
+
errors.add(:preferred_timezone, :invalid)
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
# A value saved before 6.0 that the store settings upgrade step converts
|
|
863
|
+
# must not block saving the store's other settings until the step runs.
|
|
864
|
+
# The block says whether the step can convert it.
|
|
865
|
+
def awaiting_upgrade?(name)
|
|
866
|
+
return false unless persisted?
|
|
867
|
+
return false unless public_send(:"preferred_#{name}") == (attribute_was('preferences') || {})[name]
|
|
868
|
+
|
|
869
|
+
yield.present?
|
|
870
|
+
end
|
|
871
|
+
|
|
839
872
|
# The starting value only shapes a counter that does not exist yet — once
|
|
840
873
|
# the first number is issued the counter owns the value, so accepting a
|
|
841
874
|
# change here would persist a setting that can never apply again. The
|
|
@@ -252,12 +252,13 @@ module Spree
|
|
|
252
252
|
|
|
253
253
|
# Three-way dispatch on the cart→checkout transition:
|
|
254
254
|
# entering checkout → Reserve, mid-checkout mutation → Extend, reverting to cart → Release.
|
|
255
|
+
# A new destination reserves again, because it can move the units to another origin.
|
|
255
256
|
# A failed Reserve raises so the enclosing transaction rolls back and the
|
|
256
257
|
# outer rescue surfaces the error to the API caller.
|
|
257
258
|
def sync_stock_reservations(was_in_checkout:)
|
|
258
259
|
if !cart.in_checkout?
|
|
259
260
|
Spree::StockReservations::Release.call(cart: cart) if was_in_checkout
|
|
260
|
-
elsif was_in_checkout
|
|
261
|
+
elsif was_in_checkout && !@destination_changed && !@new_delivery_destination
|
|
261
262
|
Spree::StockReservations::Extend.call(cart: cart)
|
|
262
263
|
else
|
|
263
264
|
result = Spree::StockReservations::Reserve.call(cart: cart)
|
|
@@ -14,13 +14,17 @@ module Spree
|
|
|
14
14
|
|
|
15
15
|
ApplicationRecord.transaction do
|
|
16
16
|
targets = build_targets(cart)
|
|
17
|
-
break if targets.empty?
|
|
18
17
|
|
|
19
18
|
# Pessimistic lock + fresh read of count_on_hand. The lock serializes
|
|
20
19
|
# concurrent checkouts and we use the locked rows below so we never
|
|
21
|
-
# check stock against a stale association cache.
|
|
20
|
+
# check stock against a stale association cache. The shelves held
|
|
21
|
+
# now are locked with the new ones, in id order, so two checkouts
|
|
22
|
+
# moving holds past each other cannot deadlock.
|
|
23
|
+
stock_level_ids = targets.map { |_, si, _| si.id } |
|
|
24
|
+
Spree::StockReservation.for_order(cart).pluck(:stock_level_id)
|
|
22
25
|
locked_stock_levels = Spree::StockLevel
|
|
23
|
-
.where(id:
|
|
26
|
+
.where(id: stock_level_ids)
|
|
27
|
+
.order(:id)
|
|
24
28
|
.lock
|
|
25
29
|
.index_by(&:id)
|
|
26
30
|
|
|
@@ -29,22 +33,13 @@ module Spree
|
|
|
29
33
|
|
|
30
34
|
this_order_used = Hash.new(0)
|
|
31
35
|
|
|
32
|
-
targets.
|
|
36
|
+
reservations = targets.map do |line_item, stock_level, quantity|
|
|
33
37
|
stock_level = locked_stock_levels.fetch(stock_level.id)
|
|
34
38
|
available = stock_level.available_count - held.fetch(stock_level.id, 0) - this_order_used[stock_level.id]
|
|
35
39
|
|
|
36
|
-
if available <
|
|
37
|
-
raise InsufficientStockError.new(
|
|
38
|
-
line_item,
|
|
39
|
-
I18n.t(
|
|
40
|
-
'spree.insufficient_stock_for_reservation',
|
|
41
|
-
item: line_item.variant.name,
|
|
42
|
-
available: [available, 0].max
|
|
43
|
-
)
|
|
44
|
-
)
|
|
45
|
-
end
|
|
40
|
+
raise_insufficient_stock(line_item, available) if available < quantity
|
|
46
41
|
|
|
47
|
-
this_order_used[stock_level.id] +=
|
|
42
|
+
this_order_used[stock_level.id] += quantity
|
|
48
43
|
|
|
49
44
|
reservation = existing[[stock_level.id, line_item.id]] ||
|
|
50
45
|
Spree::StockReservation.new(stock_level: stock_level, line_item: line_item)
|
|
@@ -55,10 +50,15 @@ module Spree
|
|
|
55
50
|
reservation.order = cart
|
|
56
51
|
reservation.cart = nil
|
|
57
52
|
end
|
|
58
|
-
reservation.quantity =
|
|
53
|
+
reservation.quantity = quantity
|
|
59
54
|
reservation.expires_at = expires_at
|
|
60
55
|
reservation.save!
|
|
56
|
+
reservation
|
|
61
57
|
end
|
|
58
|
+
|
|
59
|
+
# Left in place, a hold the allocation moved away from blocks other buyers until it expires.
|
|
60
|
+
moved = Spree::StockReservation.for_order(cart).where.not(id: reservations.map(&:id))
|
|
61
|
+
Spree::StockReservation.withdraw(moved)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
success(cart)
|
|
@@ -68,23 +68,71 @@ module Spree
|
|
|
68
68
|
|
|
69
69
|
private
|
|
70
70
|
|
|
71
|
+
# Held where the allocation packs each unit, so the hold protects the stock the order will ship.
|
|
71
72
|
def build_targets(cart)
|
|
72
|
-
cart.line_items.
|
|
73
|
+
cart.line_items.reset
|
|
74
|
+
coordinator = Spree::Stock::Coordinator.new(cart)
|
|
75
|
+
packages = coordinator.allocated_packages
|
|
76
|
+
check_allocated_in_full(cart, packages_items(packages))
|
|
77
|
+
|
|
78
|
+
# Once the address is known, a unit only an origin unable to reach it
|
|
79
|
+
# would pack cannot ship, so it is left to the delivery check rather
|
|
80
|
+
# than held where it blocks buyers that origin does serve.
|
|
81
|
+
packages = packages.select { |package| coordinator.deliverable?(package) } if cart.ship_address.present?
|
|
82
|
+
items = packages_items(packages)
|
|
83
|
+
stock_levels = Spree::Stock::Packer.stock_levels_by_location(packages.map(&:stock_location).uniq,
|
|
84
|
+
cart.line_items.map(&:variant_id).uniq)
|
|
85
|
+
|
|
86
|
+
# Summed because a splitter can pack one line into several packages from one location.
|
|
87
|
+
quantities = Hash.new(0)
|
|
88
|
+
items.each do |stock_location, item|
|
|
89
|
+
next unless item.on_hand?
|
|
90
|
+
|
|
91
|
+
line_item = item.inventory_unit.line_item
|
|
73
92
|
variant = line_item.variant
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
stock_level = select_stock_level(variant)
|
|
93
|
+
stock_level = stock_levels.dig(stock_location.id, variant.id)
|
|
77
94
|
# Backorderable and pre-cart variants oversell past count_on_hand,
|
|
78
95
|
# so a reservation capped at on-hand stock would wrongly reject them;
|
|
79
96
|
# their cap is enforced by Stock::Quantifier#can_supply? instead.
|
|
97
|
+
next unless variant.should_track_inventory?
|
|
80
98
|
next if stock_level.nil? || stock_level.backorderable? || variant.preorder?
|
|
81
99
|
|
|
82
|
-
[line_item, stock_level]
|
|
100
|
+
quantities[[line_item, stock_level]] += item.quantity
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
quantities.map { |(line_item, stock_level), quantity| [line_item, stock_level, quantity] }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def packages_items(packages)
|
|
107
|
+
packages.flat_map { |package| package.contents.map { |item| [package.stock_location, item] } }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A line placed nowhere is left to the delivery check, which can say why;
|
|
111
|
+
# one placed in part has shelves that fall short, and is refused.
|
|
112
|
+
def check_allocated_in_full(cart, items)
|
|
113
|
+
allocated = items.each_with_object(Hash.new(0)) do |(_, item), quantities|
|
|
114
|
+
quantities[item.inventory_unit.line_item_id] += item.quantity
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
cart.line_items.each do |line_item|
|
|
118
|
+
variant = line_item.variant
|
|
119
|
+
next unless variant&.should_track_inventory?
|
|
120
|
+
next if variant.preorder?
|
|
121
|
+
|
|
122
|
+
placed = allocated[line_item.id]
|
|
123
|
+
raise_insufficient_stock(line_item, placed) if placed.positive? && placed < line_item.quantity
|
|
83
124
|
end
|
|
84
125
|
end
|
|
85
126
|
|
|
86
|
-
def
|
|
87
|
-
|
|
127
|
+
def raise_insufficient_stock(line_item, available)
|
|
128
|
+
raise InsufficientStockError.new(
|
|
129
|
+
line_item,
|
|
130
|
+
I18n.t(
|
|
131
|
+
'spree.insufficient_stock_for_reservation',
|
|
132
|
+
item: line_item.variant.name,
|
|
133
|
+
available: [available, 0].max
|
|
134
|
+
)
|
|
135
|
+
)
|
|
88
136
|
end
|
|
89
137
|
|
|
90
138
|
def held_by_others(stock_level_ids, owner)
|
|
@@ -104,8 +152,8 @@ module Spree
|
|
|
104
152
|
def existing_reservations_for(targets)
|
|
105
153
|
return {} if targets.empty?
|
|
106
154
|
|
|
107
|
-
stock_level_ids = targets.map { |_, si| si.id }
|
|
108
|
-
line_item_ids = targets.map { |li, _| li.id }
|
|
155
|
+
stock_level_ids = targets.map { |_, si, _| si.id }
|
|
156
|
+
line_item_ids = targets.map { |li, _, _| li.id }
|
|
109
157
|
|
|
110
158
|
Spree::StockReservation
|
|
111
159
|
.where(stock_level_id: stock_level_ids, line_item_id: line_item_ids)
|
|
@@ -202,34 +202,15 @@ module Spree
|
|
|
202
202
|
kind: 'refund_reversal',
|
|
203
203
|
provider: @earning.provider,
|
|
204
204
|
status: 'pending'
|
|
205
|
-
}.merge(
|
|
205
|
+
}.merge(@earning.reversal_settlement(bounded))
|
|
206
206
|
)
|
|
207
207
|
end
|
|
208
208
|
end
|
|
209
209
|
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
# never join the batch that pays the earning it cancels — the seller would
|
|
213
|
-
# be paid in full for goods that came back, and the row would sit in a
|
|
214
|
-
# currency their account cannot pay.
|
|
215
|
-
#
|
|
216
|
-
# Prorated from the earning's own settled figure rather than converted at
|
|
217
|
-
# today's rate, so the money comes back at the rate it went out at.
|
|
218
|
-
def settlement_of(bounded)
|
|
219
|
-
return {} if @earning.settled_amount.blank? || @earning.amount.zero?
|
|
220
|
-
|
|
221
|
-
share = @earning.settled_amount * (bounded / @earning.amount)
|
|
222
|
-
|
|
223
|
-
{
|
|
224
|
-
settled_amount: -Spree::Money::Rounding.quantize(
|
|
225
|
-
share, Spree::Money::Rounding.precision(@earning.settlement_currency)
|
|
226
|
-
),
|
|
227
|
-
settled_currency: @earning.settled_currency
|
|
228
|
-
}
|
|
229
|
-
end
|
|
230
|
-
|
|
210
|
+
# Asked of the provider that made the earning — see
|
|
211
|
+
# {Spree::SellerTransfer#payout_provider_instance}.
|
|
231
212
|
def execute_reversal
|
|
232
|
-
|
|
213
|
+
@earning.payout_provider_instance.reverse!(reversal)
|
|
233
214
|
rescue Spree::Core::AmbiguousGatewayError => e
|
|
234
215
|
# Whether the clawback happened is the provider's to say. Recorded as
|
|
235
216
|
# such rather than as a refusal, so an operator reconciling knows which
|
|
@@ -238,31 +219,11 @@ module Spree
|
|
|
238
219
|
Rails.error.report(e, handled: true, context: { seller_transfer_id: reversal.id }, source: 'spree.core')
|
|
239
220
|
failure(reversal, e.message)
|
|
240
221
|
rescue StandardError => e
|
|
222
|
+
# Left retryable: `ExecutePendingJob` asks the provider again.
|
|
241
223
|
reversal.update!(status: 'processing')
|
|
242
224
|
Rails.error.report(e, handled: true, context: { seller_transfer_id: reversal.id }, source: 'spree.core')
|
|
243
225
|
failure(reversal, e.message)
|
|
244
226
|
end
|
|
245
|
-
|
|
246
|
-
# The provider that made the earning, not whichever one the store uses
|
|
247
|
-
# now. A marketplace that changes provider still has money sitting with
|
|
248
|
-
# the old one, and asking the new one to reverse a transfer it never
|
|
249
|
-
# made leaves the original standing — the seller keeps a refunded sale.
|
|
250
|
-
def provider
|
|
251
|
-
@provider ||= begin
|
|
252
|
-
configured = Spree.payout_providers.find { |candidate| candidate.to_s == @earning.provider }
|
|
253
|
-
|
|
254
|
-
# No silent fallback to whatever the store uses now. A provider that
|
|
255
|
-
# is no longer installed still holds the transfer this reverses, and
|
|
256
|
-
# handing the job to a different one would mark the row reversed
|
|
257
|
-
# while the money stayed where it was. An operator has to know.
|
|
258
|
-
if configured.nil?
|
|
259
|
-
raise Spree::Core::GatewayError,
|
|
260
|
-
"Payout provider #{@earning.provider} is not registered, so its transfer cannot be reversed"
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
configured.new
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
227
|
end
|
|
267
228
|
end
|
|
268
229
|
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Spree::AccountLockout reads these columns. Installs upgraded from a Devise
|
|
2
|
+
# admin model without :lockable reach 6.0 with neither of them.
|
|
3
|
+
class AddLockoutColumnsToSpreeAdminUsers < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
add_column :spree_admin_users, :failed_attempts, :integer unless column_exists?(:spree_admin_users, :failed_attempts)
|
|
6
|
+
add_column :spree_admin_users, :locked_at, :datetime unless column_exists?(:spree_admin_users, :locked_at)
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -10,6 +10,12 @@ module Spree
|
|
|
10
10
|
#
|
|
11
11
|
# @see Requirements
|
|
12
12
|
class DefaultRequirements
|
|
13
|
+
# Everything the discontinuation and stock checks read, so a set of
|
|
14
|
+
# lines is checked at once.
|
|
15
|
+
STOCK_CHECK_ASSOCIATIONS = {
|
|
16
|
+
variant: [{ product: %i[store product_publications] }, { stock_levels: %i[stock_location active_stock_reservations] }]
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
13
19
|
# @param cart [Spree::Cart]
|
|
14
20
|
def initialize(cart)
|
|
15
21
|
@cart = cart
|
|
@@ -33,6 +39,7 @@ module Spree
|
|
|
33
39
|
r << req('address', 'email', I18n.t('spree.checkout_requirements.email_required')) unless @cart.email.present?
|
|
34
40
|
r << req('address', 'ship_address', I18n.t('spree.checkout_requirements.ship_address_required')) if @cart.shipping_address_required? && @cart.ship_address.blank?
|
|
35
41
|
r << req('delivery', 'delivery_method', I18n.t('spree.checkout_requirements.delivery_method_required')) if delivery_step_required? && !delivery_method_selected?
|
|
42
|
+
r.concat(undeliverable_errors)
|
|
36
43
|
r << req('payment', 'payment', I18n.t('spree.checkout_requirements.payment_required')) if payment_required? && !payment_satisfied?
|
|
37
44
|
r << req('address', 'po_number', I18n.t('spree.checkout_requirements.po_number_required')) if po_number_missing?
|
|
38
45
|
r << order_minimum_requirement if below_order_minimum?
|
|
@@ -102,16 +109,37 @@ module Spree
|
|
|
102
109
|
end
|
|
103
110
|
end
|
|
104
111
|
|
|
112
|
+
# A rebuild drops a proposal no method can quote with only a transient
|
|
113
|
+
# warning, so part of a line can be in no fulfillment. Advisory, so the
|
|
114
|
+
# buyer reads it while they can still change the address or the cart.
|
|
115
|
+
def undeliverable_errors
|
|
116
|
+
return [] unless delivery_step_required? && @cart.fulfillments.any?
|
|
117
|
+
|
|
118
|
+
undelivered_line_items.map do |line_item|
|
|
119
|
+
req('delivery', 'line_items', I18n.t('spree.cart_line_item.delivery_unavailable', li_name: line_item.name),
|
|
120
|
+
code: 'delivery_unavailable')
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A line short of stock is packed short too; the stock check already
|
|
125
|
+
# names it. Only lines short of a fulfillment pay for that check, so a
|
|
126
|
+
# cart read stays at one query.
|
|
127
|
+
def undelivered_line_items
|
|
128
|
+
allocated = Spree::FulfillmentItem.where(fulfillment_id: @cart.fulfillments.select(:id))
|
|
129
|
+
.group(:line_item_id).sum(:quantity)
|
|
130
|
+
short = @cart.line_items.select { |line_item| allocated.fetch(line_item.id, 0) < line_item.quantity }
|
|
131
|
+
return [] if short.empty?
|
|
132
|
+
|
|
133
|
+
ActiveRecord::Associations::Preloader.new(records: short, associations: STOCK_CHECK_ASSOCIATIONS).call
|
|
134
|
+
short.reject { |line_item| discontinued?(line_item) || !line_item.sufficient_stock? }
|
|
135
|
+
end
|
|
136
|
+
|
|
105
137
|
def discontinued?(line_item)
|
|
106
138
|
line_item.variant.nil? || line_item.variant.discontinued? || line_item.variant.product.discontinued?
|
|
107
139
|
end
|
|
108
140
|
|
|
109
141
|
def completion_line_items
|
|
110
|
-
|
|
111
|
-
# at once.
|
|
112
|
-
@completion_line_items ||= @cart.line_items.includes(
|
|
113
|
-
variant: [{ product: %i[store product_publications] }, { stock_levels: %i[stock_location active_stock_reservations] }]
|
|
114
|
-
).to_a
|
|
142
|
+
@completion_line_items ||= @cart.line_items.includes(STOCK_CHECK_ASSOCIATIONS).to_a
|
|
115
143
|
end
|
|
116
144
|
|
|
117
145
|
def delivery_step_required?
|
data/lib/spree/core/version.rb
CHANGED
data/lib/spree/core.rb
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# Time zones are stored and exchanged as IANA identifiers (`Europe/Berlin`),
|
|
3
|
+
# the names browsers and every non-Ruby client understand. Rails' display
|
|
4
|
+
# names ("Eastern Time (US & Canada)") are converted to them on the way in.
|
|
5
|
+
module TimeZones
|
|
6
|
+
# The zones clients are offered: the IANA zones Rails gives a display name
|
|
7
|
+
# to, with plain `UTC` standing in for `Etc/UTC`. Any other valid IANA
|
|
8
|
+
# identifier is accepted too. The list comes with the Rails version rather
|
|
9
|
+
# than the machine's time zone database, so generated types match wherever
|
|
10
|
+
# the same Rails version runs.
|
|
11
|
+
SUGGESTED = (ActiveSupport::TimeZone::MAPPING.values - ['Etc/UTC'] + ['UTC']).uniq.sort.freeze
|
|
12
|
+
|
|
13
|
+
# @param identifier [String, nil]
|
|
14
|
+
# @return [Boolean] whether the value is an IANA time zone identifier
|
|
15
|
+
def self.valid?(identifier)
|
|
16
|
+
return false if identifier.blank?
|
|
17
|
+
|
|
18
|
+
TZInfo::Timezone.get(identifier.to_s)
|
|
19
|
+
true
|
|
20
|
+
rescue TZInfo::InvalidTimezoneIdentifier
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param value [String, nil] an IANA identifier or a Rails time zone name
|
|
25
|
+
# @return [String, nil] the IANA identifier, or the value unchanged when it
|
|
26
|
+
# names no zone the installed time zone database knows
|
|
27
|
+
def self.normalize(value)
|
|
28
|
+
return value if value.blank? || valid?(value)
|
|
29
|
+
|
|
30
|
+
identifier = ActiveSupport::TimeZone::MAPPING[value.to_s]
|
|
31
|
+
valid?(identifier) ? identifier : value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -597,6 +597,19 @@ steps:
|
|
|
597
597
|
is dropped in 6.1. Idempotent — only rows without a capture_method are
|
|
598
598
|
written.
|
|
599
599
|
|
|
600
|
+
- id: store_regional_settings
|
|
601
|
+
name: "Convert store time zones and admin languages to standard codes"
|
|
602
|
+
task_class: "Spree::MaintenanceTasks::Upgrade::StoreRegionalSettings"
|
|
603
|
+
notes: |
|
|
604
|
+
A store's time zone is now always an IANA name such as America/New_York,
|
|
605
|
+
and its admin language a locale code Spree lists, the forms the API
|
|
606
|
+
accepts and browsers understand. Rails time zone names such as
|
|
607
|
+
"Eastern Time (US & Canada)" and locale spellings such as en_US are
|
|
608
|
+
converted. A time zone that names no zone is reset to the store default,
|
|
609
|
+
which is what the store already ran on; an admin language Spree does not
|
|
610
|
+
list is cleared, so staff see their own language. Idempotent — stores
|
|
611
|
+
already on standard codes are skipped.
|
|
612
|
+
|
|
600
613
|
- id: product_types_backfill
|
|
601
614
|
name: "Backfill product type store ownership"
|
|
602
615
|
task: "spree:product_types:backfill"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: spree_core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 6.0.
|
|
4
|
+
version: 6.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sean Schofield
|
|
@@ -10,7 +10,7 @@ authors:
|
|
|
10
10
|
autorequire:
|
|
11
11
|
bindir: bin
|
|
12
12
|
cert_chain: []
|
|
13
|
-
date: 2026-10-
|
|
13
|
+
date: 2026-10-29 00:00:00.000000000 Z
|
|
14
14
|
dependencies:
|
|
15
15
|
- !ruby/object:Gem::Dependency
|
|
16
16
|
name: i18n-tasks
|
|
@@ -785,6 +785,7 @@ files:
|
|
|
785
785
|
- app/maintenance_tasks/spree/maintenance_tasks/upgrade/promotion_option_value_rules.rb
|
|
786
786
|
- app/maintenance_tasks/spree/maintenance_tasks/upgrade/rake_step.rb
|
|
787
787
|
- app/maintenance_tasks/spree/maintenance_tasks/upgrade/returns.rb
|
|
788
|
+
- app/maintenance_tasks/spree/maintenance_tasks/upgrade/store_regional_settings.rb
|
|
788
789
|
- app/maintenance_tasks/spree/maintenance_tasks/upgrade/users_to_customers.rb
|
|
789
790
|
- app/maintenance_tasks/spree/maintenance_tasks/upgrade_step.rb
|
|
790
791
|
- app/models/concerns/spree/account_deactivation.rb
|
|
@@ -2027,6 +2028,7 @@ files:
|
|
|
2027
2028
|
- db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb
|
|
2028
2029
|
- db/migrate/20261022000001_store_credit_actor_polymorphic.rb
|
|
2029
2030
|
- db/migrate/20261023100001_add_deactivated_at_to_spree_admin_users.rb
|
|
2031
|
+
- db/migrate/20261029100001_add_lockout_columns_to_spree_admin_users.rb
|
|
2030
2032
|
- db/sample_data/channels.rb
|
|
2031
2033
|
- db/sample_data/collections.rb
|
|
2032
2034
|
- db/sample_data/custom_field_definitions.rb
|
|
@@ -2312,6 +2314,7 @@ files:
|
|
|
2312
2314
|
- lib/spree/testing_support/store.rb
|
|
2313
2315
|
- lib/spree/testing_support/tax_identifier_validators.rb
|
|
2314
2316
|
- lib/spree/testing_support/url_helpers.rb
|
|
2317
|
+
- lib/spree/time_zones.rb
|
|
2315
2318
|
- lib/spree/translations.rb
|
|
2316
2319
|
- lib/spree/upgrade.rb
|
|
2317
2320
|
- lib/spree/upgrades/5_4_to_5_5/manifest.yml
|
|
@@ -2397,9 +2400,9 @@ licenses:
|
|
|
2397
2400
|
- BSD-3-Clause
|
|
2398
2401
|
metadata:
|
|
2399
2402
|
bug_tracker_uri: https://github.com/spree/spree/issues
|
|
2400
|
-
changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.
|
|
2403
|
+
changelog_uri: https://github.com/spree/spree/releases/tag/v6.0.1
|
|
2401
2404
|
documentation_uri: https://docs.spreecommerce.org/
|
|
2402
|
-
source_code_uri: https://github.com/spree/spree/tree/v6.0.
|
|
2405
|
+
source_code_uri: https://github.com/spree/spree/tree/v6.0.1
|
|
2403
2406
|
post_install_message:
|
|
2404
2407
|
rdoc_options: []
|
|
2405
2408
|
require_paths:
|