spree_core 6.0.0.beta2 → 6.0.0.beta3
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/payments/handle_webhook_job.rb +1 -0
- data/app/jobs/spree/sample_data/load_job.rb +7 -2
- data/app/jobs/spree/seller_transfers/execute_pending_job.rb +1 -0
- data/app/mailers/spree/admin_user_mailer.rb +2 -2
- data/app/models/concerns/spree/customer_methods.rb +17 -1
- data/app/models/concerns/spree/has_number.rb +3 -2
- data/app/models/concerns/spree/prefixed_id.rb +4 -1
- data/app/models/concerns/spree/purchase/totals.rb +3 -1
- data/app/models/concerns/spree/ransackable_attributes.rb +38 -6
- data/app/models/concerns/spree/translatable_resource.rb +6 -7
- data/app/models/concerns/spree/webhook_payload_redaction.rb +132 -21
- data/app/models/spree/adjusters/promotion.rb +1 -1
- data/app/models/spree/authentication/lockout.rb +34 -0
- data/app/models/spree/authentication/strategies/email_password_strategy.rb +4 -12
- data/app/models/spree/authentication/strategies/oidc_strategy.rb +1 -1
- data/app/models/spree/claim.rb +1 -0
- data/app/models/spree/commission_line.rb +4 -2
- data/app/models/spree/commission_rule.rb +1 -1
- data/app/models/spree/coupon_code.rb +14 -2
- data/app/models/spree/current.rb +15 -0
- data/app/models/spree/exchange.rb +1 -0
- data/app/models/spree/export.rb +8 -5
- data/app/models/spree/exports/customers.rb +2 -2
- data/app/models/spree/fulfillment_group.rb +127 -0
- data/app/models/spree/gift_card.rb +9 -0
- data/app/models/spree/invitation.rb +18 -5
- data/app/models/spree/number_generators/sequential.rb +2 -2
- data/app/models/spree/option_value.rb +7 -0
- data/app/models/spree/order.rb +35 -12
- data/app/models/spree/order_group.rb +78 -6
- data/app/models/spree/payment_source.rb +1 -1
- data/app/models/spree/payment_split.rb +24 -0
- data/app/models/spree/price.rb +74 -0
- data/app/models/spree/product.rb +4 -0
- data/app/models/spree/product_option_type.rb +11 -0
- data/app/models/spree/promotion/actions/create_line_items.rb +92 -30
- data/app/models/spree/promotion/rules/category.rb +14 -9
- data/app/models/spree/promotion/rules/product.rb +17 -1
- data/app/models/spree/promotion.rb +1 -1
- data/app/models/spree/promotion_action.rb +12 -1
- data/app/models/spree/promotion_handler/coupon.rb +50 -5
- data/app/models/spree/promotion_rule.rb +14 -0
- data/app/models/spree/return.rb +16 -6
- data/app/models/spree/return_line_item.rb +19 -0
- data/app/models/spree/search_provider/database.rb +10 -2
- data/app/models/spree/seller_payout.rb +3 -1
- data/app/models/spree/seller_transfer.rb +3 -1
- data/app/models/spree/store_credit.rb +4 -0
- data/app/models/spree/translations/batch.rb +4 -2
- data/app/models/spree/user_identity.rb +10 -6
- data/app/models/spree/variant.rb +8 -3
- data/app/models/spree/webhook_delivery.rb +28 -0
- data/app/models/spree/webhook_endpoint.rb +14 -0
- data/app/presenters/spree/csv/order_line_item_presenter.rb +2 -2
- data/app/services/spree/carts/split_by_seller.rb +22 -12
- data/app/services/spree/companies/add_member.rb +8 -2
- data/app/services/spree/imports/row_processors/price_list_price.rb +6 -0
- data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
- data/app/services/spree/imports/row_processors/product_variant.rb +17 -11
- data/app/services/spree/order_groups/allocate_payments.rb +3 -0
- data/app/services/spree/orders/build_fulfillments.rb +6 -0
- data/app/services/spree/orders/update_statuses.rb +8 -2
- data/app/services/spree/prices/bulk_upsert.rb +186 -47
- data/app/services/spree/sample_data/import_builder.rb +1 -1
- data/app/services/spree/sample_data/loader.rb +54 -40
- data/app/services/spree/seeds/all.rb +1 -26
- data/app/services/spree/seeds/allowed_origins.rb +5 -4
- data/app/services/spree/seeds/api_keys.rb +10 -9
- data/app/services/spree/seeds/channels.rb +9 -8
- data/app/services/spree/seeds/commission_rates.rb +21 -20
- data/app/services/spree/seeds/customer_groups.rb +5 -4
- data/app/services/spree/seeds/digital_delivery.rb +7 -8
- data/app/services/spree/seeds/payment_methods.rb +12 -11
- data/app/services/spree/seeds/product_types.rb +7 -8
- data/app/services/spree/seeds/returns_environment.rb +12 -11
- data/app/services/spree/seeds/roles.rb +8 -7
- data/app/services/spree/seeds/saved_reports.rb +19 -20
- data/app/services/spree/seeds/seller_requirements.rb +5 -4
- data/app/services/spree/seeds/store_resources.rb +41 -0
- data/app/services/spree/seeds/store_scoped.rb +15 -0
- data/app/services/spree/seeds/tax_categories.rb +10 -9
- data/app/services/spree/stock_locations/stock_items/create.rb +1 -1
- data/app/services/spree/stock_locations/stock_levels/create.rb +19 -3
- data/app/subscribers/spree/order_status_subscriber.rb +15 -7
- data/app/workflows/spree/carts/add_item.rb +5 -7
- data/app/workflows/spree/carts/complete.rb +20 -64
- data/app/workflows/spree/carts/recalculate_totals.rb +3 -1
- data/app/workflows/spree/claims/create.rb +31 -1
- data/app/workflows/spree/claims/resolve.rb +7 -11
- data/app/workflows/spree/exchanges/create.rb +11 -0
- data/app/workflows/spree/exchanges/fulfill.rb +21 -13
- data/app/workflows/spree/orders/complete.rb +99 -2
- data/app/workflows/spree/price_lists/update.rb +17 -0
- data/app/workflows/spree/refunds/order_payments.rb +55 -1
- data/app/workflows/spree/returns/create.rb +9 -15
- data/app/workflows/spree/returns/refund.rb +15 -24
- data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
- data/app/workflows/spree/sellers/invite.rb +1 -1
- data/config/locales/en.yml +7 -119
- data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
- data/db/migrate/20260922000001_add_refunded_order_to_spree_store_credits.rb +5 -0
- data/db/migrate/20260923000001_change_spree_user_identities_tokens_to_text.rb +10 -0
- data/db/sample_data/channels.rb +1 -1
- data/db/sample_data/collections.rb +1 -1
- data/db/sample_data/custom_field_definitions.rb +1 -1
- data/db/sample_data/digital_products.rb +1 -1
- data/db/sample_data/fulfillment.rb +1 -1
- data/db/sample_data/options.rb +4 -2
- data/db/sample_data/orders.rb +9 -10
- data/db/sample_data/payment_methods.rb +18 -18
- data/db/sample_data/product_type_categories.rb +1 -1
- data/db/sample_data/product_types.rb +2 -2
- data/db/sample_data/promotions.rb +2 -4
- data/db/sample_data/wholesale.rb +1 -1
- data/lib/generators/spree/api_resource/api_resource_generator.rb +58 -1
- data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +6 -5
- data/lib/generators/spree/api_resource/templates/permissions.en.yml.tt +6 -0
- data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +2 -2
- data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
- data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
- data/lib/spree/core/engine.rb +8 -8
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/iso_data.rb +23 -4
- data/lib/spree/store_scope_guard.rb +16 -1
- data/lib/spree/testing_support/factories/invitation_factory.rb +1 -0
- data/lib/spree/testing_support/factories/order_group_factory.rb +49 -0
- data/lib/spree/testing_support/factories/payment_factory.rb +7 -2
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
- data/lib/tasks/sample_data.rake +8 -2
- data/lib/tasks/upgrade.rake +6 -21
- metadata +13 -4
|
@@ -130,7 +130,7 @@ module Spree
|
|
|
130
130
|
index.zero? ? order.bill_address&.company : nil,
|
|
131
131
|
index.zero? ? order.bill_address&.city : nil,
|
|
132
132
|
index.zero? ? order.bill_address&.postal_code : nil,
|
|
133
|
-
index.zero? ? order.bill_address&.
|
|
133
|
+
index.zero? ? order.bill_address&.state_text : nil,
|
|
134
134
|
index.zero? ? order.bill_address&.country&.name : nil,
|
|
135
135
|
index.zero? ? order.bill_address&.phone : nil,
|
|
136
136
|
index.zero? ? order.ship_address&.full_name : nil,
|
|
@@ -139,7 +139,7 @@ module Spree
|
|
|
139
139
|
index.zero? ? order.ship_address&.company : nil,
|
|
140
140
|
index.zero? ? order.ship_address&.city : nil,
|
|
141
141
|
index.zero? ? order.ship_address&.postal_code : nil,
|
|
142
|
-
index.zero? ? order.ship_address&.
|
|
142
|
+
index.zero? ? order.ship_address&.state_text : nil,
|
|
143
143
|
index.zero? ? order.ship_address&.country&.name : nil,
|
|
144
144
|
index.zero? ? order.ship_address&.phone : nil,
|
|
145
145
|
index.zero? ? format_date(order.completed_at) : nil,
|
|
@@ -29,20 +29,26 @@ module Spree
|
|
|
29
29
|
# blocklist would carry every future column onto siblings by default.
|
|
30
30
|
# Adding a column here is a decision that it describes the checkout
|
|
31
31
|
# rather than one seller's part of it.
|
|
32
|
+
#
|
|
33
|
+
# +metadata+ is here because at this point it holds what the checkout
|
|
34
|
+
# recorded about the purchase (copied from the cart), so every child gets
|
|
35
|
+
# the same hash — and so does the group. Once split, each order's
|
|
36
|
+
# metadata is its own: edits never propagate between siblings.
|
|
32
37
|
CARRIED_TO_SIBLING = %w[
|
|
33
38
|
email currency locale market_id channel_id company_id
|
|
34
39
|
customer_id token accept_marketing preferred_stock_location_id
|
|
35
|
-
customer_note last_ip_address po_number
|
|
40
|
+
customer_note last_ip_address po_number metadata
|
|
36
41
|
].freeze
|
|
37
42
|
|
|
38
|
-
# @param cart [Spree::Cart]
|
|
39
43
|
# @param order [Spree::Order] the paid draft order, adopted as the
|
|
40
44
|
# group's first child rather than replaced — it is the record the
|
|
41
45
|
# payment was taken against
|
|
42
46
|
# @param partitions [Array<Spree::Carts::SellerPartition>] ordered, first
|
|
43
47
|
# party first
|
|
48
|
+
# @param cart [Spree::Cart, nil] nil for an order raised from the admin,
|
|
49
|
+
# which never had one
|
|
44
50
|
# @return [Spree::ServiceModule::Result] value is the Spree::OrderGroup
|
|
45
|
-
def call(
|
|
51
|
+
def call(order:, partitions:, cart: nil)
|
|
46
52
|
group = nil
|
|
47
53
|
# Every apportionment here divides money in this one currency.
|
|
48
54
|
@currency = order.currency
|
|
@@ -76,20 +82,24 @@ module Spree
|
|
|
76
82
|
# keeps the reference they saw at checkout, and the children number off
|
|
77
83
|
# it: R1001-1, R1001-2. The cart_id moves up too — the group is what that
|
|
78
84
|
# cart completed into, and it becomes the replay anchor.
|
|
85
|
+
#
|
|
86
|
+
# The facts come off the order rather than the cart because the order
|
|
87
|
+
# carries every one of them and an admin-raised order has no cart; the
|
|
88
|
+
# cart is only linked, as the replay anchor, when there is one.
|
|
79
89
|
def build_group(cart, order)
|
|
80
90
|
order.update_columns(cart_id: nil)
|
|
81
91
|
|
|
82
|
-
|
|
92
|
+
order.store.order_groups.create!(
|
|
83
93
|
cart: cart,
|
|
84
|
-
customer:
|
|
94
|
+
customer: order.customer,
|
|
85
95
|
number: order.number,
|
|
86
|
-
currency:
|
|
87
|
-
email:
|
|
88
|
-
token:
|
|
89
|
-
|
|
90
|
-
|
|
96
|
+
currency: order.currency,
|
|
97
|
+
email: order.email,
|
|
98
|
+
token: order.token,
|
|
99
|
+
metadata: order.metadata.to_h.deep_dup,
|
|
100
|
+
ship_address: order.ship_address&.snapshot,
|
|
101
|
+
bill_address: order.bill_address&.snapshot
|
|
91
102
|
)
|
|
92
|
-
group
|
|
93
103
|
end
|
|
94
104
|
|
|
95
105
|
# Files the paid order under the group as its first child. Its own
|
|
@@ -109,7 +119,7 @@ module Spree
|
|
|
109
119
|
# settled during completion.
|
|
110
120
|
def hand_off(source_order, group, partition, index)
|
|
111
121
|
sibling = source_order.store.orders.create!(
|
|
112
|
-
source_order.attributes.slice(*CARRIED_TO_SIBLING).merge(
|
|
122
|
+
source_order.attributes.slice(*CARRIED_TO_SIBLING).deep_dup.merge(
|
|
113
123
|
'order_group_id' => group.id,
|
|
114
124
|
'seller_id' => partition.seller_id,
|
|
115
125
|
'number' => "#{group.number}-#{index}",
|
|
@@ -18,11 +18,17 @@ module Spree
|
|
|
18
18
|
# (memberships have no metadata column; a direct-membership payload is
|
|
19
19
|
# applied by the caller through the `company_membership.form_fields`
|
|
20
20
|
# contract, not stored here)
|
|
21
|
+
# @param require_acceptance [Boolean] always invite, even an existing
|
|
22
|
+
# customer — for callers who cannot vouch that the email is the
|
|
23
|
+
# person they mean (a storefront member typing an address). The
|
|
24
|
+
# emailed token is what proves the invitee owns the address and
|
|
25
|
+
# agrees to join; a direct membership would hand the company to
|
|
26
|
+
# whoever happens to hold that email.
|
|
21
27
|
# @return [Spree::ServiceModule::Result] value is the created
|
|
22
28
|
# Spree::CompanyMembership or Spree::CompanyInvitation
|
|
23
|
-
def call(company:, email:, inviter: nil, metadata: nil)
|
|
29
|
+
def call(company:, email:, inviter: nil, metadata: nil, require_acceptance: false)
|
|
24
30
|
normalized_email = email.to_s.strip.downcase
|
|
25
|
-
customer = Spree.customer_class.find_by(email: normalized_email)
|
|
31
|
+
customer = Spree.customer_class.find_by(email: normalized_email) unless require_acceptance
|
|
26
32
|
|
|
27
33
|
record =
|
|
28
34
|
if customer
|
|
@@ -154,6 +154,12 @@ module Spree
|
|
|
154
154
|
Spree.t(:price_list_import_too_many_breaks, count: Spree::Price::MAXIMUM_BREAKS_PER_VARIANT)
|
|
155
155
|
elsif value[:invalid_quantities].present?
|
|
156
156
|
Spree.t(:price_list_import_invalid_quantity, value: attributes['min_quantity'])
|
|
157
|
+
elsif (rising = value[:rising_ladders].presence)
|
|
158
|
+
# Named rung and floor, like the sibling branches: a file is written
|
|
159
|
+
# one rung per row, so the merchant needs to know which one and what
|
|
160
|
+
# it had to beat.
|
|
161
|
+
Spree.t(:price_list_import_price_rises_with_quantity,
|
|
162
|
+
quantity: rising.first[:min_quantity], floor: rising.first[:floor])
|
|
157
163
|
else
|
|
158
164
|
result.error.to_s.presence || Spree.t(:price_list_import_failed)
|
|
159
165
|
end
|
|
@@ -125,15 +125,17 @@ module Spree
|
|
|
125
125
|
|
|
126
126
|
# Which products this import may resolve an existing row onto.
|
|
127
127
|
#
|
|
128
|
-
#
|
|
129
|
-
# ability cannot answer
|
|
130
|
-
# a seller holding `write_products` gets
|
|
131
|
-
# the whole class (see Spree::Ability)
|
|
132
|
-
# the
|
|
133
|
-
#
|
|
134
|
-
#
|
|
128
|
+
# The import's own store's products, and for a seller's import only
|
|
129
|
+
# that seller's. The ability cannot answer either: it grants
|
|
130
|
+
# capability, never tenancy — a seller holding `write_products` gets
|
|
131
|
+
# `can :manage, Spree::Product`, the whole class (see Spree::Ability),
|
|
132
|
+
# and an admin of two stores reaches both. On the API that is safe because
|
|
133
|
+
# the controller scope-fetches through `current_store` and
|
|
134
|
+
# `current_seller`; a background row processor has no controller, so
|
|
135
|
+
# the narrowing has to happen here or a CSV naming another store's or
|
|
136
|
+
# seller's slug would edit their product.
|
|
135
137
|
def product_scope
|
|
136
|
-
scope =
|
|
138
|
+
scope = store.products.accessible_by(import.current_ability, :manage)
|
|
137
139
|
seller.present? ? scope.where(seller_id: seller.id) : scope
|
|
138
140
|
end
|
|
139
141
|
|
|
@@ -237,7 +239,7 @@ module Spree
|
|
|
237
239
|
def prepare_tax_category
|
|
238
240
|
tax_category_name = attributes['tax_category'].strip
|
|
239
241
|
cached_lookup(:tax_category, tax_category_name) do
|
|
240
|
-
|
|
242
|
+
store.tax_categories.find_by(name: tax_category_name)
|
|
241
243
|
end
|
|
242
244
|
end
|
|
243
245
|
|
|
@@ -302,15 +304,19 @@ module Spree
|
|
|
302
304
|
def find_or_create_option_type!(label)
|
|
303
305
|
cached_lookup(:option_type, label) do
|
|
304
306
|
begin
|
|
305
|
-
|
|
307
|
+
store_option_types.search_by_name(label).first || store_option_types.create!(label: label)
|
|
306
308
|
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
|
|
307
309
|
raise unless uniqueness_conflict?(e, :name)
|
|
308
310
|
|
|
309
|
-
|
|
311
|
+
store_option_types.search_by_name(label).first!
|
|
310
312
|
end
|
|
311
313
|
end
|
|
312
314
|
end
|
|
313
315
|
|
|
316
|
+
def store_option_types
|
|
317
|
+
Spree::OptionType.for_store(store)
|
|
318
|
+
end
|
|
319
|
+
|
|
314
320
|
def find_or_create_option_value!(option_type, label)
|
|
315
321
|
cached_lookup(:option_value, option_type.id, label) do
|
|
316
322
|
begin
|
|
@@ -87,6 +87,9 @@ module Spree
|
|
|
87
87
|
next if split.persisted?
|
|
88
88
|
|
|
89
89
|
share = Spree::Money::Rounding.from_minor_units(shares[index], currency)
|
|
90
|
+
# So re-summing its payment total on save reuses this instance rather
|
|
91
|
+
# than loading a second copy of the row per share.
|
|
92
|
+
split.order = order
|
|
90
93
|
split.currency = payment.currency
|
|
91
94
|
split.authorized_amount = share
|
|
92
95
|
split.captured_amount = captured ? share : 0
|
|
@@ -9,10 +9,16 @@ module Spree
|
|
|
9
9
|
#
|
|
10
10
|
# No-op when the order has no shipping address, no line items, or does
|
|
11
11
|
# not require delivery (digital orders, etc.).
|
|
12
|
+
#
|
|
13
|
+
# Also a no-op once the order is placed. Its fulfillments hold the stock
|
|
14
|
+
# promised at placement, and an edit adjusts them in place through
|
|
15
|
+
# Spree::OrderInventory. Rebuilding would delete them and leave that
|
|
16
|
+
# promise on rows nothing can cancel or ship, so the stock stays held.
|
|
12
17
|
class BuildFulfillments
|
|
13
18
|
prepend Spree::ServiceModule::Base
|
|
14
19
|
|
|
15
20
|
def call(order:)
|
|
21
|
+
return success(order) if order.completed?
|
|
16
22
|
return success(order) unless order.ship_address_id.present?
|
|
17
23
|
return success(order) unless order.line_items.any?
|
|
18
24
|
return success(order) unless order.delivery_step_required?
|
|
@@ -60,14 +60,20 @@ module Spree
|
|
|
60
60
|
# shipped and been captured while another has not, no proportion of the
|
|
61
61
|
# group's totals can describe either.
|
|
62
62
|
#
|
|
63
|
+
# Refunded spans both ledgers. A refund paid as store credit writes no
|
|
64
|
+
# {Spree::Refund} row, and counting only those rows left an order that
|
|
65
|
+
# had given every penny back still reading `paid`.
|
|
66
|
+
#
|
|
63
67
|
# @return [Array(BigDecimal, BigDecimal, BigDecimal)] captured,
|
|
64
68
|
# authorized and refunded, in that order
|
|
65
69
|
def money_for(order)
|
|
70
|
+
credited = order.store_credit_refunds.sum(:amount)
|
|
71
|
+
|
|
66
72
|
unless order.order_group_id.present?
|
|
67
73
|
return [
|
|
68
74
|
order.payments.valid.completed.sum(:amount),
|
|
69
75
|
order.payments.valid.pending.sum(:amount),
|
|
70
|
-
order.refunds.sum(:amount)
|
|
76
|
+
order.refunds.sum(:amount) + credited
|
|
71
77
|
]
|
|
72
78
|
end
|
|
73
79
|
|
|
@@ -76,7 +82,7 @@ module Spree
|
|
|
76
82
|
|
|
77
83
|
# Authorized means still to draw: what the shares allow, less what has
|
|
78
84
|
# already been taken against them.
|
|
79
|
-
[captured, splits.sum(&:authorized_amount) - captured, splits.sum(&:refunded_amount)]
|
|
85
|
+
[captured, splits.sum(&:authorized_amount) - captured, splits.sum(&:refunded_amount) + credited]
|
|
80
86
|
end
|
|
81
87
|
|
|
82
88
|
# Rolls the fulfillments up into one word for filtering and display.
|
|
@@ -35,11 +35,13 @@ module Spree
|
|
|
35
35
|
# @return [Spree::ServiceModule::Result] success carries
|
|
36
36
|
# `{ price_count: N }` — the number of rows passed to `upsert_all`.
|
|
37
37
|
# Fails with the offending rows when a batch carries an unusable
|
|
38
|
-
# `min_quantity`,
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
38
|
+
# `min_quantity`, puts a quantity on a base price, names one rung twice,
|
|
39
|
+
# would take a variant past
|
|
40
|
+
# {Spree::Price::MAXIMUM_BREAKS_PER_VARIANT} breaks on one list, or
|
|
41
|
+
# would leave a ladder charging more for a bigger order. This path
|
|
42
|
+
# writes in SQL and runs no model validations, so the checks live here
|
|
43
|
+
# rather than in each of the three writers that reach it
|
|
44
|
+
# (docs/plans/6.0-volume-pricing.md).
|
|
43
45
|
def call(rows:)
|
|
44
46
|
rows = Array(rows).map { |r| r.with_indifferent_access }
|
|
45
47
|
# Amounts are parsed once, here; everything below reads numbers.
|
|
@@ -54,20 +56,35 @@ module Spree
|
|
|
54
56
|
negative = rows_with_negative_amount(keyed)
|
|
55
57
|
return failure(nil, invalid_amounts: negative) if negative.any?
|
|
56
58
|
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
# A base price is one row per (variant, currency) whatever quantity it
|
|
60
|
+
# names, so a quantity here would upsert onto a key it does not
|
|
61
|
+
# describe. The model refuses it in `break_requires_price_list`, which
|
|
62
|
+
# this SQL path never runs.
|
|
63
|
+
misplaced = base_rows_with_quantity(keyed)
|
|
64
|
+
return failure(nil, quantities_on_base_prices: misplaced) if misplaced.any?
|
|
65
|
+
|
|
66
|
+
duplicates = rows_with_duplicate_quantity(keyed)
|
|
67
|
+
return failure(nil, duplicate_quantities: duplicates) if duplicates.any?
|
|
68
|
+
|
|
69
|
+
upsert_rows, clear_rows = keyed.partition { |r| r[:amount].present? }
|
|
70
|
+
# A clear at a quantity the batch also prices is a rung moving onto
|
|
71
|
+
# it, so the amount wins.
|
|
72
|
+
claimed = upsert_rows.map { |r| row_key(r) }.to_set
|
|
73
|
+
clear_rows = clear_rows.reject { |r| claimed.include?(row_key(r)) }
|
|
62
74
|
|
|
63
75
|
payload = build_payload(upsert_rows)
|
|
64
|
-
affected_keys =
|
|
76
|
+
affected_keys = (upsert_rows + clear_rows).map { |r| row_key(r) }
|
|
65
77
|
|
|
66
78
|
return success(price_count: 0) if affected_keys.empty?
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
stored = stored_rungs(upsert_rows + clear_rows)
|
|
81
|
+
|
|
82
|
+
over_cap = ladders_over_cap(upsert_rows, clear_rows, stored)
|
|
69
83
|
return failure(nil, over_cap: over_cap) if over_cap.any?
|
|
70
84
|
|
|
85
|
+
rising = rising_ladders(upsert_rows, clear_rows, stored)
|
|
86
|
+
return failure(nil, rising_ladders: rising) if rising.any?
|
|
87
|
+
|
|
71
88
|
base_rows, override_rows = payload.partition { |r| r[:price_list_id].nil? }
|
|
72
89
|
|
|
73
90
|
Spree::Price.transaction do
|
|
@@ -129,7 +146,7 @@ module Spree
|
|
|
129
146
|
# quantity address the bottom rung, which is every row written before
|
|
130
147
|
# breaks existed (docs/plans/6.0-volume-pricing.md).
|
|
131
148
|
def row_key(row)
|
|
132
|
-
[row[:variant_id], row[:currency], row[:price_list_id], quantity_of(row)]
|
|
149
|
+
[row[:variant_id].to_s, row[:currency], row[:price_list_id].presence&.to_s, quantity_of(row)]
|
|
133
150
|
end
|
|
134
151
|
|
|
135
152
|
# Absent means the ladder's bottom rung. Anything present must be a
|
|
@@ -182,55 +199,177 @@ module Spree
|
|
|
182
199
|
# constant is named: breaks *above* the bottom rung, so a variant priced
|
|
183
200
|
# at one figure plus ten breaks is exactly at the limit.
|
|
184
201
|
#
|
|
185
|
-
# Only ladders the batch actually adds a break to are checked, so an
|
|
186
|
-
# ordinary spreadsheet save — one quantity-1 row per variant — costs no
|
|
187
|
-
# extra queries at all.
|
|
188
|
-
#
|
|
189
202
|
# @param rows [Array<Hash>] the rows carrying an amount
|
|
190
203
|
# @param cleared_rows [Array<Hash>] the rows whose blank amount removes a rung
|
|
204
|
+
# @param stored [Hash] `{ ladder_key => { quantity => amount } }` as stored
|
|
191
205
|
# @return [Array<Hash>] `[{ variant_id:, currency:, price_list_id: }, ...]`
|
|
192
|
-
def ladders_over_cap(rows, cleared_rows
|
|
193
|
-
touched = rows
|
|
194
|
-
reject { |row| row[:price_list_id].blank? || quantity_of(row) == 1 }.
|
|
195
|
-
group_by { |row| [row[:variant_id].to_s, row[:currency], row[:price_list_id].to_s] }.
|
|
196
|
-
transform_values { |group| group.map { |row| quantity_of(row) }.to_set }
|
|
206
|
+
def ladders_over_cap(rows, cleared_rows, stored)
|
|
207
|
+
touched = breaks_by_ladder(rows)
|
|
197
208
|
return [] if touched.empty?
|
|
198
209
|
|
|
199
|
-
cleared = cleared_rows
|
|
200
|
-
reject { |row| row[:price_list_id].blank? || quantity_of(row) == 1 }.
|
|
201
|
-
group_by { |row| [row[:variant_id].to_s, row[:currency], row[:price_list_id].to_s] }.
|
|
202
|
-
transform_values { |group| group.map { |row| quantity_of(row) }.to_set }
|
|
203
|
-
|
|
204
|
-
# One query for every ladder in the batch rather than one each: an
|
|
205
|
-
# import writing a ladder across two hundred variants would otherwise
|
|
206
|
-
# issue two hundred round trips inside the request. Cross-pairs the
|
|
207
|
-
# `IN` clauses pull in are dropped by the lookup below, the same way
|
|
208
|
-
# #sweep handles them.
|
|
209
|
-
# Placeholder rows charge nothing, so they do not fill a ladder — the
|
|
210
|
-
# model's own guard counts the same way, and a cap two writers
|
|
211
|
-
# disagree about is one that refuses what it just allowed.
|
|
212
|
-
stored = Spree::Price.
|
|
213
|
-
where(variant_id: touched.keys.map { |key| key[0] },
|
|
214
|
-
currency: touched.keys.map { |key| key[1] },
|
|
215
|
-
price_list_id: touched.keys.map { |key| key[2] }).
|
|
216
|
-
breaks.
|
|
217
|
-
where.not(amount: nil).
|
|
218
|
-
pluck(:variant_id, :currency, :price_list_id, :min_quantity).
|
|
219
|
-
group_by { |variant_id, currency, price_list_id, _| [variant_id.to_s, currency, price_list_id.to_s] }
|
|
210
|
+
cleared = breaks_by_ladder(cleared_rows)
|
|
220
211
|
|
|
221
212
|
touched.filter_map do |key, incoming|
|
|
222
213
|
# Stored rungs this batch does not name are the ones that survive it;
|
|
223
214
|
# counting the rest as well would refuse a merchant who deleted two
|
|
224
215
|
# rungs and added two others, whose ladder ends the size it began.
|
|
225
|
-
|
|
226
|
-
# what is left after this batch is (survivors + incoming).
|
|
227
|
-
survivors = stored.fetch(key, []).map(&:last).to_set - cleared.fetch(key, Set.new)
|
|
216
|
+
survivors = stored.fetch(key, {}).keys.reject { |quantity| quantity == 1 }.to_set - cleared.fetch(key, Set.new)
|
|
228
217
|
next if (survivors | incoming).size <= Spree::Price::MAXIMUM_BREAKS_PER_VARIANT
|
|
229
218
|
|
|
230
219
|
{ variant_id: key[0], currency: key[1], price_list_id: key[2] }
|
|
231
220
|
end
|
|
232
221
|
end
|
|
233
222
|
|
|
223
|
+
# The break quantities each ladder in these rows carries.
|
|
224
|
+
def breaks_by_ladder(rows)
|
|
225
|
+
rows.
|
|
226
|
+
reject { |row| row[:price_list_id].blank? || quantity_of(row) == 1 }.
|
|
227
|
+
group_by { |row| ladder_key(row) }.
|
|
228
|
+
transform_values { |group| group.map { |row| quantity_of(row) }.to_set }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Base-price rows naming a quantity. A base price is one row per
|
|
232
|
+
# (variant, currency), so the quantity describes nothing and the row
|
|
233
|
+
# would upsert onto a key it does not match.
|
|
234
|
+
#
|
|
235
|
+
# @return [Array<Hash>] `[{ index: }, ...]`
|
|
236
|
+
def base_rows_with_quantity(rows)
|
|
237
|
+
rows.each_with_index.filter_map do |row, index|
|
|
238
|
+
{ index: index } if row[:price_list_id].blank? && quantity_of(row) != 1
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Rows repeating a rung the batch has already priced. Only amounts
|
|
243
|
+
# collide: a clear at the same key is a rung moving onto it, which #call
|
|
244
|
+
# resolves in favour of the amount.
|
|
245
|
+
#
|
|
246
|
+
# @param rows [Array<Hash>]
|
|
247
|
+
# @return [Array<Hash>] `[{ index:, min_quantity: }, ...]`
|
|
248
|
+
def rows_with_duplicate_quantity(rows)
|
|
249
|
+
seen = Set.new
|
|
250
|
+
|
|
251
|
+
rows.each_with_index.filter_map do |row, index|
|
|
252
|
+
next if row[:amount].blank?
|
|
253
|
+
|
|
254
|
+
{ index: index, min_quantity: quantity_of(row) } unless seen.add?(row_key(row))
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Ladders this batch would leave charging more for a bigger order. A rung
|
|
259
|
+
# must not cost more than the rung beneath it, or than the base price for
|
|
260
|
+
# a ladder starting above one unit; a bottom rung above base is a dearer
|
|
261
|
+
# agreement, which is allowed. Judged on the state the batch leaves, not
|
|
262
|
+
# on what it changed: no write path can produce a rising ladder, so one
|
|
263
|
+
# found rising came from outside them and must be repaired rather than
|
|
264
|
+
# built on. Base is the floor even where another list would undercut it
|
|
265
|
+
# below the threshold — the write path cannot know which list wins for
|
|
266
|
+
# which buyer (docs/plans/6.0-volume-pricing.md).
|
|
267
|
+
#
|
|
268
|
+
# @param rows [Array<Hash>] the rows carrying an amount
|
|
269
|
+
# @param cleared_rows [Array<Hash>] the rows whose blank amount removes a rung
|
|
270
|
+
# @param stored [Hash] `{ ladder_key => { quantity => amount } }` as stored
|
|
271
|
+
# @return [Array<Hash>] the offending rung and the price it must not exceed
|
|
272
|
+
def rising_ladders(rows, cleared_rows, stored)
|
|
273
|
+
keys = ladder_keys_to_check(rows + cleared_rows, stored)
|
|
274
|
+
return [] if keys.empty?
|
|
275
|
+
|
|
276
|
+
incoming = rungs_by_ladder(rows)
|
|
277
|
+
cleared = breaks_and_bottom_by_ladder(cleared_rows)
|
|
278
|
+
floors = base_floors(rows, cleared_rows, keys)
|
|
279
|
+
|
|
280
|
+
keys.filter_map do |key|
|
|
281
|
+
dropped = cleared.fetch(key, Set.new)
|
|
282
|
+
now = stored.fetch(key, {}).reject { |quantity, _| dropped.include?(quantity) }.
|
|
283
|
+
merge(incoming.fetch(key, {}))
|
|
284
|
+
|
|
285
|
+
breach = Spree::Price.rising_rung(now.sort_by(&:first), floors[key.first(2)])
|
|
286
|
+
next if breach.nil?
|
|
287
|
+
|
|
288
|
+
{ variant_id: key[0], currency: key[1], price_list_id: key[2],
|
|
289
|
+
min_quantity: breach[0], amount: breach[1], floor: breach[2] }
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# The ladders whose shape or floor this batch could change: those it
|
|
294
|
+
# writes rows on, plus every stored ladder of a variant whose base price
|
|
295
|
+
# it moves — lowering the shop price under a break raises the bill at the
|
|
296
|
+
# threshold just as surely as mispricing the break.
|
|
297
|
+
def ladder_keys_to_check(rows, stored)
|
|
298
|
+
own = rows.reject { |row| row[:price_list_id].blank? }.map { |row| ladder_key(row) }
|
|
299
|
+
rebased = rows.select { |row| row[:price_list_id].blank? }.
|
|
300
|
+
map { |row| [row[:variant_id].to_s, row[:currency]] }.to_set
|
|
301
|
+
return own.uniq if rebased.empty?
|
|
302
|
+
|
|
303
|
+
(own + stored.keys.select { |key| rebased.include?(key.first(2)) }).uniq
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# `{ ladder_key => { quantity => amount } }` for these rows.
|
|
307
|
+
def rungs_by_ladder(rows)
|
|
308
|
+
rows.
|
|
309
|
+
reject { |row| row[:price_list_id].blank? }.
|
|
310
|
+
group_by { |row| ladder_key(row) }.
|
|
311
|
+
transform_values { |group| group.to_h { |row| [quantity_of(row), row[:amount]] } }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Every quantity these rows remove, bottom rung included — unlike the cap
|
|
315
|
+
# check, which only counts breaks.
|
|
316
|
+
def breaks_and_bottom_by_ladder(rows)
|
|
317
|
+
rows.
|
|
318
|
+
reject { |row| row[:price_list_id].blank? }.
|
|
319
|
+
group_by { |row| ladder_key(row) }.
|
|
320
|
+
transform_values { |group| group.map { |row| quantity_of(row) }.to_set }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# The stored rungs of every ladder this batch touches, and of every
|
|
324
|
+
# ladder whose base price it moves. Placeholder rows charge nothing, so a
|
|
325
|
+
# ladder whose quantity-1 row is a placeholder starts at its first real
|
|
326
|
+
# break. Cross-pairs the `IN` clauses pull in are dropped by the lookup,
|
|
327
|
+
# as in #sweep.
|
|
328
|
+
#
|
|
329
|
+
# @param rows [Array<Hash>]
|
|
330
|
+
# @return [Hash] `{ ladder_key => { quantity => amount } }`
|
|
331
|
+
def stored_rungs(rows)
|
|
332
|
+
variant_ids = rows.map { |row| row[:variant_id] }.uniq
|
|
333
|
+
currencies = rows.map { |row| row[:currency] }.uniq
|
|
334
|
+
return {} if variant_ids.empty?
|
|
335
|
+
|
|
336
|
+
Spree::Price.
|
|
337
|
+
where(variant_id: variant_ids, currency: currencies).
|
|
338
|
+
where.not(price_list_id: nil).
|
|
339
|
+
where.not(amount: nil).
|
|
340
|
+
pluck(:variant_id, :currency, :price_list_id, :min_quantity, :amount).
|
|
341
|
+
group_by { |variant_id, currency, price_list_id, _, _| [variant_id.to_s, currency, price_list_id.to_s] }.
|
|
342
|
+
transform_values { |rungs| rungs.to_h { |rung| [rung[3], rung[4]] } }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# The base price each ladder falls through to, as this batch would leave
|
|
346
|
+
# it, so a save that moves both the shop price and a break is judged on
|
|
347
|
+
# the pair it sent.
|
|
348
|
+
#
|
|
349
|
+
# @return [Hash] `{ [variant_id, currency] => amount }`
|
|
350
|
+
def base_floors(rows, cleared_rows, keys)
|
|
351
|
+
pairs = keys.map { |key| key.first(2) }.uniq
|
|
352
|
+
return {} if pairs.empty?
|
|
353
|
+
|
|
354
|
+
stored = Spree::Price.
|
|
355
|
+
where(variant_id: pairs.map(&:first), currency: pairs.map(&:last), price_list_id: nil).
|
|
356
|
+
where.not(amount: nil).
|
|
357
|
+
pluck(:variant_id, :currency, :amount).
|
|
358
|
+
to_h { |variant_id, currency, amount| [[variant_id.to_s, currency], amount] }
|
|
359
|
+
|
|
360
|
+
written = rows.select { |row| row[:price_list_id].blank? }.
|
|
361
|
+
to_h { |row| [[row[:variant_id].to_s, row[:currency]], row[:amount]] }
|
|
362
|
+
removed = cleared_rows.select { |row| row[:price_list_id].blank? }.
|
|
363
|
+
map { |row| [row[:variant_id].to_s, row[:currency]] }
|
|
364
|
+
|
|
365
|
+
stored.merge(written).except(*removed)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# A ladder is one variant's rungs, in one currency, on one list.
|
|
369
|
+
def ladder_key(row)
|
|
370
|
+
[row[:variant_id].to_s, row[:currency], row[:price_list_id].to_s]
|
|
371
|
+
end
|
|
372
|
+
|
|
234
373
|
# Parses locale-aware decimal input ("1.234,56" in DE, "1,234.56"
|
|
235
374
|
# in en-US). Numeric values pass through; blank values become nil.
|
|
236
375
|
def parse_amount(value)
|
|
@@ -254,7 +393,7 @@ module Spree
|
|
|
254
393
|
.pluck(:id, :variant_id, :currency, :price_list_id, :min_quantity, :amount)
|
|
255
394
|
|
|
256
395
|
doomed_ids = candidates.filter_map do |id, variant_id, currency, price_list_id, min_quantity, amount|
|
|
257
|
-
key = [variant_id, currency, price_list_id, min_quantity]
|
|
396
|
+
key = [variant_id.to_s, currency, price_list_id&.to_s, min_quantity]
|
|
258
397
|
next unless affected_set.include?(key)
|
|
259
398
|
next id if amount.nil?
|
|
260
399
|
next id if cleared_keys.include?(key)
|
|
@@ -7,7 +7,7 @@ module Spree
|
|
|
7
7
|
# runs — a price-list import's `preferred_price_list_id`, say
|
|
8
8
|
# @return [Spree::Import] persisted, unprocessed
|
|
9
9
|
def self.call(csv_path:, import_class:, store: nil, user: nil, inline: false, skip_events: false, attributes: {})
|
|
10
|
-
store ||= Spree::
|
|
10
|
+
store ||= Spree::Current.store
|
|
11
11
|
# An admin of the target store, not `admin_user_class.first` — in a
|
|
12
12
|
# multi-store or multi-tenant app that global first can belong to a
|
|
13
13
|
# different store entirely.
|