spree_core 6.0.0 → 6.0.2
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/README.md +2 -2
- 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/admin_user_methods.rb +23 -0
- data/app/models/concerns/spree/translatable_resource.rb +19 -0
- data/app/models/concerns/spree/user_roles.rb +9 -4
- data/app/models/spree/invitation.rb +11 -2
- data/app/models/spree/order.rb +3 -1
- data/app/models/spree/order_promotion.rb +3 -1
- data/app/models/spree/order_routing/strategy/rules.rb +8 -48
- data/app/models/spree/payment.rb +16 -1
- data/app/models/spree/payment_method/store_credit.rb +3 -1
- data/app/models/spree/payment_method.rb +14 -1
- data/app/models/spree/payment_source.rb +3 -1
- data/app/models/spree/promotion/actions/create_item_adjustments.rb +1 -1
- data/app/models/spree/promotion.rb +10 -1
- data/app/models/spree/promotion_handler/cart.rb +7 -1
- data/app/models/spree/promotion_handler/coupon.rb +1 -29
- data/app/models/spree/refresh_token.rb +26 -0
- 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/variant.rb +1 -1
- data/app/services/spree/carts/update.rb +2 -1
- data/app/services/spree/promotions/detach_from_owner.rb +72 -0
- data/app/services/spree/stock_reservations/reserve.rb +78 -26
- data/app/workflows/spree/carts/recalculate.rb +5 -1
- data/app/workflows/spree/seller_transfers/reverse.rb +5 -44
- data/db/migrate/20260809150001_promote_shipping_categories_to_delivery_profiles.rb +15 -6
- data/db/migrate/20261029100001_add_lockout_columns_to_spree_admin_users.rb +8 -0
- data/db/migrate/20261030100001_scope_payment_sources_profile_id_uniqueness_to_payment_method.rb +38 -0
- data/db/migrate/20261031000001_add_last_active_at_to_spree_admin_users.rb +7 -0
- data/db/migrate/20261031100001_delete_orphaned_spree_option_value_variants.rb +10 -0
- data/lib/generators/spree/dummy/dummy_generator.rb +0 -1
- data/lib/generators/spree/dummy/templates/rails/database.yml +6 -9
- data/lib/generators/spree/dummy/templates/rails/test.rb +9 -5
- data/lib/generators/spree/dummy_model/templates/migration.rb.tt +1 -1
- data/lib/spree/checkout/default_requirements.rb +33 -5
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +1 -3
- data/lib/spree/testing_support/common_rake.rb +3 -2
- data/lib/spree/testing_support/lifecycle_events.rb +1 -3
- data/lib/spree/time_zones.rb +34 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +15 -1
- data/lib/tasks/delivery_migration.rake +17 -1
- data/lib/tasks/remove_orphan_order_promotions.rake +20 -0
- metadata +15 -78
- data/lib/generators/spree/authentication/dummy/dummy_generator.rb +0 -54
- data/lib/generators/spree/authentication/dummy/templates/authentication_helpers.rb.tt +0 -52
- data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -33
|
@@ -6,12 +6,19 @@ module Spree
|
|
|
6
6
|
|
|
7
7
|
has_secure_token :token
|
|
8
8
|
|
|
9
|
+
# Personal data. Rows written before encryption was enabled stay readable
|
|
10
|
+
# and are encrypted when the token next rotates.
|
|
11
|
+
encrypts :ip_address, support_unencrypted_data: true if ActiveRecord::Encryption.config.has_primary_key?
|
|
12
|
+
|
|
9
13
|
validates :user, :expires_at, presence: true
|
|
10
14
|
# On create only: rows minted before the column exists keep their NULL and
|
|
11
15
|
# stay loadable (they are simply unredeemable), while nothing new is born
|
|
12
16
|
# without the surface it belongs to.
|
|
13
17
|
validates :audience, presence: true, on: :create
|
|
14
18
|
|
|
19
|
+
# Starting a session and refreshing one both create a row.
|
|
20
|
+
after_create :record_user_activity
|
|
21
|
+
|
|
15
22
|
scope :active, -> { where('expires_at > ?', Time.current) }
|
|
16
23
|
scope :expired, -> { where('expires_at <= ?', Time.current) }
|
|
17
24
|
|
|
@@ -23,6 +30,12 @@ module Spree
|
|
|
23
30
|
expires_at <= Time.current
|
|
24
31
|
end
|
|
25
32
|
|
|
33
|
+
# Each refresh replaces the row, so its creation is the session's last use.
|
|
34
|
+
# @return [ActiveSupport::TimeWithZone]
|
|
35
|
+
def last_active_at
|
|
36
|
+
created_at
|
|
37
|
+
end
|
|
38
|
+
|
|
26
39
|
# Rotate: destroy this token and create a new one.
|
|
27
40
|
# Returns the new token.
|
|
28
41
|
def rotate!(request_env: {})
|
|
@@ -74,5 +87,18 @@ module Spree
|
|
|
74
87
|
rescue StandardError
|
|
75
88
|
30.days
|
|
76
89
|
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Outlives the session itself, which signing out deletes. Skips callbacks
|
|
94
|
+
# and +updated_at+: activity is not an edit to the account, and an older
|
|
95
|
+
# session committing last never moves it back. Users without the column
|
|
96
|
+
# (customers, custom admin classes) are left alone.
|
|
97
|
+
def record_user_activity
|
|
98
|
+
return unless user.has_attribute?(:last_active_at)
|
|
99
|
+
|
|
100
|
+
stale = user.class.where(id: user.id).where('last_active_at IS NULL OR last_active_at < ?', created_at)
|
|
101
|
+
stale.update_all(last_active_at: created_at)
|
|
102
|
+
end
|
|
77
103
|
end
|
|
78
104
|
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
|
data/app/models/spree/variant.rb
CHANGED
|
@@ -71,7 +71,7 @@ module Spree
|
|
|
71
71
|
has_many :stock_reservations
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
has_many :option_value_variants, class_name: 'Spree::OptionValueVariant'
|
|
74
|
+
has_many :option_value_variants, class_name: 'Spree::OptionValueVariant', dependent: :destroy
|
|
75
75
|
has_many :option_values, through: :option_value_variants, dependent: :destroy, class_name: 'Spree::OptionValue'
|
|
76
76
|
|
|
77
77
|
has_many :images, -> { order(:position) }, as: :viewable, dependent: :destroy, class_name: 'Spree::Media'
|
|
@@ -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)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Promotions
|
|
3
|
+
# Removes a promotion from a cart or an order the way a shopper removing the
|
|
4
|
+
# code does: the order promotion, held coupon code, discounts and items
|
|
5
|
+
# the promotion added are removed, then totals are recalculated.
|
|
6
|
+
#
|
|
7
|
+
# @example Removing a promotion from a cart
|
|
8
|
+
# Spree::Promotions::DetachFromOwner.call(owner: cart, promotion: promotion)
|
|
9
|
+
class DetachFromOwner
|
|
10
|
+
prepend Spree::ServiceModule::Base
|
|
11
|
+
|
|
12
|
+
# Action types whose line items are removed along with the promotion.
|
|
13
|
+
CREATE_LINE_ITEMS_ACTION_TYPES = %w[Spree::Promotion::Actions::CreateLineItems].freeze
|
|
14
|
+
|
|
15
|
+
# @param owner [Spree::Cart, Spree::Order] the cart or order holding the promotion
|
|
16
|
+
# @param promotion [Spree::Promotion] the promotion to remove
|
|
17
|
+
# @param clear_coupon_code [Boolean] also clear the code the shopper entered on the
|
|
18
|
+
# owner, so recalculating does not apply the promotion again. Leave it off when the
|
|
19
|
+
# shopper should be told the code was lost.
|
|
20
|
+
# @return [Spree::ServiceModule::Result] success, holding the owner; a failure when the
|
|
21
|
+
# owner is already completed, since placed orders are never changed this way
|
|
22
|
+
def call(owner:, promotion:, clear_coupon_code: false)
|
|
23
|
+
return failure(owner, 'Cannot remove a promotion from a completed order') if owner.completed?
|
|
24
|
+
|
|
25
|
+
held_code = promotion.coupon_codes.held_by(owner).first if promotion.multi_codes?
|
|
26
|
+
clear_entered_coupon_code(owner, held_code&.code || promotion.code) if clear_coupon_code
|
|
27
|
+
|
|
28
|
+
# The order promotion has to go before line items are removed
|
|
29
|
+
owner.promotions.delete(promotion)
|
|
30
|
+
|
|
31
|
+
if held_code
|
|
32
|
+
held_code.remove_from_order
|
|
33
|
+
else
|
|
34
|
+
promotion.touch
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
owner.discounts.where(promotion_action_id: promotion.actions.ids).destroy_all
|
|
38
|
+
remove_promotion_line_items(owner, promotion)
|
|
39
|
+
owner.recalculate_totals!
|
|
40
|
+
|
|
41
|
+
success(owner)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Carts keep the entered code, and recalculation would apply it again.
|
|
47
|
+
# Callers that let the shopper be told the code went keep it.
|
|
48
|
+
def clear_entered_coupon_code(owner, code)
|
|
49
|
+
return if code.blank? || !owner.has_attribute?(:coupon_code)
|
|
50
|
+
return unless owner.read_attribute(:coupon_code).to_s.casecmp?(code)
|
|
51
|
+
|
|
52
|
+
owner.update_column(:coupon_code, nil)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Removes the line items a promotion's create-line-items actions added.
|
|
56
|
+
def remove_promotion_line_items(owner, promotion)
|
|
57
|
+
action_ids = promotion.actions.where(type: CREATE_LINE_ITEMS_ACTION_TYPES).ids
|
|
58
|
+
remove_service = owner.is_a?(Spree::Cart) ? Spree.cart_remove_item_service : Spree.order_remove_item_service
|
|
59
|
+
owner_key = owner.is_a?(Spree::Cart) ? :cart : :order
|
|
60
|
+
|
|
61
|
+
Spree::PromotionActionLineItem.where(promotion_action: action_ids).find_each do |item|
|
|
62
|
+
next if owner.find_line_item_by_variant(item.variant).blank?
|
|
63
|
+
|
|
64
|
+
# The promotion is still active here, so recalculating would add its items back;
|
|
65
|
+
# other promotions are still re-evaluated.
|
|
66
|
+
options = owner.is_a?(Spree::Cart) ? { excluded_promotion_ids: [promotion.id] } : {}
|
|
67
|
+
remove_service.call(owner_key => owner, variant: item.variant, quantity: item.quantity, options: options)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -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,17 @@ module Spree
|
|
|
29
33
|
|
|
30
34
|
this_order_used = Hash.new(0)
|
|
31
35
|
|
|
32
|
-
targets.
|
|
33
|
-
|
|
36
|
+
reservations = targets.filter_map do |line_item, stock_level, quantity|
|
|
37
|
+
# A shelf deleted since the allocation was read (a variant being
|
|
38
|
+
# discontinued removes its stock) has nothing left to hold.
|
|
39
|
+
stock_level = locked_stock_levels[stock_level.id]
|
|
40
|
+
next if stock_level.nil?
|
|
41
|
+
|
|
34
42
|
available = stock_level.available_count - held.fetch(stock_level.id, 0) - this_order_used[stock_level.id]
|
|
35
43
|
|
|
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
|
|
44
|
+
raise_insufficient_stock(line_item, available) if available < quantity
|
|
46
45
|
|
|
47
|
-
this_order_used[stock_level.id] +=
|
|
46
|
+
this_order_used[stock_level.id] += quantity
|
|
48
47
|
|
|
49
48
|
reservation = existing[[stock_level.id, line_item.id]] ||
|
|
50
49
|
Spree::StockReservation.new(stock_level: stock_level, line_item: line_item)
|
|
@@ -55,10 +54,15 @@ module Spree
|
|
|
55
54
|
reservation.order = cart
|
|
56
55
|
reservation.cart = nil
|
|
57
56
|
end
|
|
58
|
-
reservation.quantity =
|
|
57
|
+
reservation.quantity = quantity
|
|
59
58
|
reservation.expires_at = expires_at
|
|
60
59
|
reservation.save!
|
|
60
|
+
reservation
|
|
61
61
|
end
|
|
62
|
+
|
|
63
|
+
# Left in place, a hold the allocation moved away from blocks other buyers until it expires.
|
|
64
|
+
moved = Spree::StockReservation.for_order(cart).where.not(id: reservations.map(&:id))
|
|
65
|
+
Spree::StockReservation.withdraw(moved)
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
success(cart)
|
|
@@ -68,23 +72,71 @@ module Spree
|
|
|
68
72
|
|
|
69
73
|
private
|
|
70
74
|
|
|
75
|
+
# Held where the allocation packs each unit, so the hold protects the stock the order will ship.
|
|
71
76
|
def build_targets(cart)
|
|
72
|
-
cart.line_items.
|
|
77
|
+
cart.line_items.reset
|
|
78
|
+
coordinator = Spree::Stock::Coordinator.new(cart)
|
|
79
|
+
packages = coordinator.allocated_packages
|
|
80
|
+
check_allocated_in_full(cart, packages_items(packages))
|
|
81
|
+
|
|
82
|
+
# Once the address is known, a unit only an origin unable to reach it
|
|
83
|
+
# would pack cannot ship, so it is left to the delivery check rather
|
|
84
|
+
# than held where it blocks buyers that origin does serve.
|
|
85
|
+
packages = packages.select { |package| coordinator.deliverable?(package) } if cart.ship_address.present?
|
|
86
|
+
items = packages_items(packages)
|
|
87
|
+
stock_levels = Spree::Stock::Packer.stock_levels_by_location(packages.map(&:stock_location).uniq,
|
|
88
|
+
cart.line_items.map(&:variant_id).uniq)
|
|
89
|
+
|
|
90
|
+
# Summed because a splitter can pack one line into several packages from one location.
|
|
91
|
+
quantities = Hash.new(0)
|
|
92
|
+
items.each do |stock_location, item|
|
|
93
|
+
next unless item.on_hand?
|
|
94
|
+
|
|
95
|
+
line_item = item.inventory_unit.line_item
|
|
73
96
|
variant = line_item.variant
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
stock_level = select_stock_level(variant)
|
|
97
|
+
stock_level = stock_levels.dig(stock_location.id, variant.id)
|
|
77
98
|
# Backorderable and pre-cart variants oversell past count_on_hand,
|
|
78
99
|
# so a reservation capped at on-hand stock would wrongly reject them;
|
|
79
100
|
# their cap is enforced by Stock::Quantifier#can_supply? instead.
|
|
101
|
+
next unless variant.should_track_inventory?
|
|
80
102
|
next if stock_level.nil? || stock_level.backorderable? || variant.preorder?
|
|
81
103
|
|
|
82
|
-
[line_item, stock_level]
|
|
104
|
+
quantities[[line_item, stock_level]] += item.quantity
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
quantities.map { |(line_item, stock_level), quantity| [line_item, stock_level, quantity] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def packages_items(packages)
|
|
111
|
+
packages.flat_map { |package| package.contents.map { |item| [package.stock_location, item] } }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A line placed nowhere is left to the delivery check, which can say why;
|
|
115
|
+
# one placed in part has shelves that fall short, and is refused.
|
|
116
|
+
def check_allocated_in_full(cart, items)
|
|
117
|
+
allocated = items.each_with_object(Hash.new(0)) do |(_, item), quantities|
|
|
118
|
+
quantities[item.inventory_unit.line_item_id] += item.quantity
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
cart.line_items.each do |line_item|
|
|
122
|
+
variant = line_item.variant
|
|
123
|
+
next unless variant&.should_track_inventory?
|
|
124
|
+
next if variant.preorder?
|
|
125
|
+
|
|
126
|
+
placed = allocated[line_item.id]
|
|
127
|
+
raise_insufficient_stock(line_item, placed) if placed.positive? && placed < line_item.quantity
|
|
83
128
|
end
|
|
84
129
|
end
|
|
85
130
|
|
|
86
|
-
def
|
|
87
|
-
|
|
131
|
+
def raise_insufficient_stock(line_item, available)
|
|
132
|
+
raise InsufficientStockError.new(
|
|
133
|
+
line_item,
|
|
134
|
+
I18n.t(
|
|
135
|
+
'spree.insufficient_stock_for_reservation',
|
|
136
|
+
item: line_item.variant.name,
|
|
137
|
+
available: [available, 0].max
|
|
138
|
+
)
|
|
139
|
+
)
|
|
88
140
|
end
|
|
89
141
|
|
|
90
142
|
def held_by_others(stock_level_ids, owner)
|
|
@@ -104,8 +156,8 @@ module Spree
|
|
|
104
156
|
def existing_reservations_for(targets)
|
|
105
157
|
return {} if targets.empty?
|
|
106
158
|
|
|
107
|
-
stock_level_ids = targets.map { |_, si| si.id }
|
|
108
|
-
line_item_ids = targets.map { |li, _| li.id }
|
|
159
|
+
stock_level_ids = targets.map { |_, si, _| si.id }
|
|
160
|
+
line_item_ids = targets.map { |li, _, _| li.id }
|
|
109
161
|
|
|
110
162
|
Spree::StockReservation
|
|
111
163
|
.where(stock_level_id: stock_level_ids, line_item_id: line_item_ids)
|
|
@@ -9,6 +9,8 @@ module Spree
|
|
|
9
9
|
|
|
10
10
|
# @param cart [Spree::Cart, Spree::Order] draft orders ride the same pipeline
|
|
11
11
|
# @param line_item [Spree::LineItem, nil] the item whose change triggered this
|
|
12
|
+
# @param options [Hash] +excluded_promotion_ids:+ lists promotions to leave out of activation,
|
|
13
|
+
# for callers that are removing a promotion's effects
|
|
12
14
|
def perform(cart: nil, order: nil, line_item: nil, line_item_created: false, options: {})
|
|
13
15
|
if order
|
|
14
16
|
Spree::Deprecation.warn('Calling Spree::Carts::Recalculate with order: is deprecated and will be removed in Spree 6.1. Pass cart: instead.')
|
|
@@ -47,7 +49,9 @@ module Spree
|
|
|
47
49
|
def activate_promotions
|
|
48
50
|
@promotion_context = run_hooks :set_promotion_context
|
|
49
51
|
|
|
50
|
-
::Spree::PromotionHandler::Cart.new(
|
|
52
|
+
::Spree::PromotionHandler::Cart.new(
|
|
53
|
+
cart, line_item, excluded_promotion_ids: options[:excluded_promotion_ids] || []
|
|
54
|
+
).activate
|
|
51
55
|
end
|
|
52
56
|
end
|
|
53
57
|
end
|