spree_core 5.4.3 → 5.5.0.rc1
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/helpers/spree/base_helper.rb +0 -82
- data/app/helpers/spree/currency_helper.rb +0 -12
- data/app/helpers/spree/products_helper.rb +0 -8
- data/app/jobs/spree/base_job.rb +18 -0
- data/app/jobs/spree/events/subscriber_job.rb +2 -1
- data/app/jobs/spree/exports/generate_job.rb +11 -0
- data/app/jobs/spree/images/save_from_url_job.rb +23 -8
- data/app/jobs/spree/imports/assign_tags_job.rb +11 -0
- data/app/jobs/spree/imports/base_job.rb +15 -0
- data/app/jobs/spree/imports/create_categories_job.rb +37 -0
- data/app/jobs/spree/imports/create_rows_job.rb +1 -3
- data/app/jobs/spree/imports/process_group_job.rb +8 -6
- data/app/jobs/spree/imports/process_rows_job.rb +1 -3
- data/app/jobs/spree/media/migrate_product_assets_job.rb +83 -0
- data/app/jobs/spree/products/refresh_metrics_job.rb +15 -4
- data/app/jobs/spree/reports/generate_job.rb +11 -0
- data/app/jobs/spree/search_provider/index_job.rb +5 -1
- data/app/jobs/spree/search_provider/remove_job.rb +4 -0
- data/app/jobs/spree/stock_reservations/expire_job.rb +11 -0
- data/app/models/concerns/spree/calculated_adjustments.rb +34 -1
- data/app/models/concerns/spree/display_on.rb +31 -0
- data/app/models/concerns/spree/metafields.rb +167 -5
- data/app/models/concerns/spree/preference_schema.rb +191 -0
- data/app/models/concerns/spree/prefixed_id.rb +94 -11
- data/app/models/concerns/spree/product_scopes.rb +36 -17
- data/app/models/concerns/spree/ransackable_attributes.rb +5 -1
- data/app/models/concerns/spree/search_indexable.rb +8 -7
- data/app/models/concerns/spree/searchable.rb +11 -2
- data/app/models/concerns/spree/stores/channels.rb +20 -0
- data/app/models/concerns/spree/stores/markets.rb +21 -5
- data/app/models/concerns/spree/typed_associations.rb +120 -0
- data/app/models/concerns/spree/user_methods.rb +71 -12
- data/app/models/spree/ability.rb +4 -117
- data/app/models/spree/api_key.rb +53 -0
- data/app/models/spree/asset.rb +28 -5
- data/app/models/spree/authentication/strategy_registry.rb +72 -0
- data/app/models/spree/base.rb +18 -1
- data/app/models/spree/channel.rb +159 -0
- data/app/models/spree/country.rb +2 -0
- data/app/models/spree/current.rb +5 -1
- data/app/models/spree/custom_field.rb +9 -0
- data/app/models/spree/custom_field_definition.rb +7 -0
- data/app/models/spree/customer_group.rb +8 -2
- data/app/models/spree/export.rb +30 -3
- data/app/models/spree/gateway.rb +25 -0
- data/app/models/spree/gift_card.rb +1 -1
- data/app/models/spree/gift_card_batch.rb +4 -1
- data/app/models/spree/import.rb +5 -0
- data/app/models/spree/import_row.rb +12 -0
- data/app/models/spree/line_item.rb +6 -1
- data/app/models/spree/market.rb +32 -1
- data/app/models/spree/metafield.rb +38 -0
- data/app/models/spree/metafield_definition.rb +29 -6
- data/app/models/spree/metafields/json.rb +10 -0
- data/app/models/spree/newsletter_subscriber.rb +19 -3
- data/app/models/spree/option_type.rb +48 -7
- data/app/models/spree/order/checkout.rb +3 -3
- data/app/models/spree/order.rb +102 -6
- data/app/models/spree/order_approval.rb +19 -0
- data/app/models/spree/order_cancellation.rb +19 -0
- data/app/models/spree/order_routing/has_strategy_preference.rb +28 -0
- data/app/models/spree/order_routing/rules/default_location.rb +16 -0
- data/app/models/spree/order_routing/rules/minimize_splits.rb +45 -0
- data/app/models/spree/order_routing/rules/preferred_location.rb +22 -0
- data/app/models/spree/order_routing/strategy/base.rb +47 -0
- data/app/models/spree/order_routing/strategy/legacy.rb +33 -0
- data/app/models/spree/order_routing/strategy/reducer.rb +68 -0
- data/app/models/spree/order_routing/strategy/rules.rb +81 -0
- data/app/models/spree/order_routing_rule.rb +75 -0
- data/app/models/spree/permission_sets/configuration_management.rb +16 -0
- data/app/models/spree/permission_sets/product_display.rb +2 -0
- data/app/models/spree/permission_sets/product_management.rb +2 -0
- data/app/models/spree/price.rb +14 -1
- data/app/models/spree/price_list.rb +129 -17
- data/app/models/spree/price_rule.rb +11 -1
- data/app/models/spree/price_rules/customer_group_rule.rb +15 -1
- data/app/models/spree/price_rules/market_rule.rb +16 -1
- data/app/models/spree/price_rules/user_rule.rb +21 -2
- data/app/models/spree/product/channels.rb +149 -0
- data/app/models/spree/product/legacy_multi_store_support.rb +40 -0
- data/app/models/spree/product/slugs.rb +1 -1
- data/app/models/spree/product.rb +172 -31
- data/app/models/spree/product_publication.rb +43 -0
- data/app/models/spree/promotion/actions/create_adjustment.rb +4 -0
- data/app/models/spree/promotion/actions/create_item_adjustments.rb +4 -0
- data/app/models/spree/promotion/actions/create_line_items.rb +32 -14
- data/app/models/spree/promotion/rules/country.rb +40 -18
- data/app/models/spree/promotion/rules/customer_group.rb +10 -1
- data/app/models/spree/promotion/rules/product.rb +4 -0
- data/app/models/spree/promotion/rules/taxon.rb +24 -1
- data/app/models/spree/promotion/rules/user.rb +21 -0
- data/app/models/spree/promotion/rules/user_logged_in.rb +6 -0
- data/app/models/spree/promotion.rb +22 -1
- data/app/models/spree/promotion_action.rb +17 -11
- data/app/models/spree/promotion_rule.rb +17 -18
- data/app/models/spree/search_provider/meilisearch.rb +12 -2
- data/app/models/spree/stock/availability_validator.rb +1 -1
- data/app/models/spree/stock/quantifier.rb +89 -9
- data/app/models/spree/stock_item.rb +36 -0
- data/app/models/spree/stock_location.rb +52 -0
- data/app/models/spree/stock_reservation.rb +38 -0
- data/app/models/spree/stock_reservations/insufficient_stock_error.rb +12 -0
- data/app/models/spree/store.rb +18 -72
- data/app/models/spree/store_credit.rb +0 -8
- data/app/models/spree/store_product.rb +11 -23
- data/app/models/spree/taxon.rb +0 -5
- data/app/models/spree/user_identity.rb +1 -2
- data/app/models/spree/variant.rb +132 -18
- data/app/models/spree/variant_media.rb +46 -0
- data/app/models/spree/webhook_delivery.rb +1 -1
- data/app/models/spree/webhook_endpoint.rb +24 -0
- data/app/models/spree/wished_item.rb +0 -13
- data/app/presenters/spree/csv/product_variant_presenter.rb +23 -3
- data/app/presenters/spree/search_provider/product_presenter.rb +11 -4
- data/app/presenters/spree/variant_presenter.rb +4 -3
- data/app/services/spree/addresses/update.rb +6 -8
- data/app/services/spree/cart/add_item.rb +10 -0
- data/app/services/spree/cart/empty.rb +2 -0
- data/app/services/spree/cart/remove_line_item.rb +10 -0
- data/app/services/spree/cart/remove_out_of_stock_items.rb +1 -1
- data/app/services/spree/cart/set_quantity.rb +10 -0
- data/app/services/spree/carts/complete.rb +1 -0
- data/app/services/spree/carts/create.rb +1 -0
- data/app/services/spree/carts/update.rb +18 -2
- data/app/services/spree/carts/upsert_items.rb +6 -6
- data/app/services/spree/imports/row_processors/customer.rb +4 -1
- data/app/services/spree/imports/row_processors/product_variant.rb +95 -57
- data/app/services/spree/newsletter/link_user.rb +53 -0
- data/app/services/spree/newsletter/subscribe.rb +31 -9
- data/app/services/spree/orders/approve.rb +27 -6
- data/app/services/spree/orders/build_shipments.rb +29 -0
- data/app/services/spree/orders/cancel.rb +34 -3
- data/app/services/spree/orders/complete.rb +53 -0
- data/app/services/spree/orders/create.rb +156 -0
- data/app/services/spree/orders/update.rb +51 -0
- data/app/services/spree/orders/upsert_items.rb +70 -0
- data/app/services/spree/prices/bulk_upsert.rb +201 -0
- data/app/services/spree/products/duplicator.rb +1 -1
- data/app/services/spree/products/prepare_nested_attributes.rb +2 -30
- data/app/services/spree/sample_data/loader.rb +30 -0
- data/app/services/spree/stock_reservations/extend.rb +19 -0
- data/app/services/spree/stock_reservations/release.rb +12 -0
- data/app/services/spree/stock_reservations/reserve.rb +103 -0
- data/app/services/spree/taxons/remove_products.rb +7 -1
- data/app/subscribers/spree/product_metrics_subscriber.rb +3 -7
- data/app/views/spree/invitation_mailer/invitation_email.html.erb +4 -0
- data/config/locales/en.yml +27 -10
- data/config/routes.rb +9 -0
- data/db/migrate/20260429000001_create_spree_order_cancellations.rb +25 -0
- data/db/migrate/20260429000002_create_spree_order_approvals.rb +22 -0
- data/db/migrate/20260429000003_add_status_to_spree_orders.rb +6 -0
- data/db/migrate/20260429000004_add_scopes_to_spree_api_keys.rb +11 -0
- data/db/migrate/20260501000001_create_spree_stock_reservations.rb +19 -0
- data/db/migrate/20260507162651_create_spree_variant_media.rb +23 -0
- data/db/migrate/20260508175303_add_pickup_to_spree_stock_locations.rb +12 -0
- data/db/migrate/20260508204040_create_spree_channels.rb +18 -0
- data/db/migrate/20260508204041_create_spree_order_routing_rules.rb +18 -0
- data/db/migrate/20260508204042_add_preferred_stock_location_to_spree_orders.rb +5 -0
- data/db/migrate/20260508204043_add_channel_id_to_spree_orders.rb +10 -0
- data/db/migrate/20260511000001_backfill_status_on_spree_orders.rb +57 -0
- data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +25 -0
- data/db/migrate/20260529000001_add_unique_index_to_spree_price_rules.rb +41 -0
- data/db/migrate/20260529000002_add_unique_index_to_spree_promotion_rules.rb +37 -0
- data/db/migrate/20260601000001_create_spree_product_publications.rb +14 -0
- data/db/migrate/20260601000002_add_store_id_to_spree_products.rb +16 -0
- data/db/migrate/20260602000001_add_default_to_spree_channels.rb +14 -0
- data/db/sample_data/channels.rb +12 -0
- data/db/sample_data/orders.rb +1 -1
- data/db/sample_data/products.csv +212 -212
- data/lib/generators/spree/api_resource/api_resource_generator.rb +353 -0
- data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +23 -0
- data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +59 -0
- data/lib/generators/spree/api_resource/templates/admin_serializer.rb.tt +11 -0
- data/lib/generators/spree/api_resource/templates/factory.rb.tt +26 -0
- data/lib/generators/spree/api_resource/templates/store_aliased_serializer.rb.tt +12 -0
- data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +31 -0
- data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +61 -0
- data/lib/generators/spree/api_resource/templates/store_serializer.rb.tt +14 -0
- data/lib/generators/spree/controller_decorator/controller_decorator_generator.rb +66 -0
- data/lib/generators/spree/controller_decorator/templates/controller_decorator.rb.tt +25 -0
- data/lib/generators/spree/model/model_generator.rb +73 -7
- data/lib/generators/spree/model/templates/create_table_migration.rb.tt +40 -0
- data/lib/generators/spree/model/templates/model.rb.tt +28 -2
- data/lib/spree/core/configuration.rb +7 -0
- data/lib/spree/core/controller_helpers/auth.rb +0 -12
- data/lib/spree/core/controller_helpers/currency.rb +0 -17
- data/lib/spree/core/controller_helpers/order.rb +0 -19
- data/lib/spree/core/dependencies.rb +5 -2
- data/lib/spree/core/engine.rb +54 -7
- data/lib/spree/core/permission_configuration.rb +15 -0
- data/lib/spree/core/preferences/masking.rb +47 -0
- data/lib/spree/core/preferences/preferable_class_methods.rb +7 -1
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +56 -5
- data/lib/spree/permitted_attributes.rb +9 -7
- data/lib/spree/testing_support/factories/address_factory.rb +16 -9
- data/lib/spree/testing_support/factories/api_key_factory.rb +1 -0
- data/lib/spree/testing_support/factories/channel_factory.rb +8 -0
- data/lib/spree/testing_support/factories/line_item_factory.rb +2 -8
- data/lib/spree/testing_support/factories/newsletter_subscriber_factory.rb +2 -0
- data/lib/spree/testing_support/factories/product_factory.rb +16 -7
- data/lib/spree/testing_support/factories/product_publication_factory.rb +6 -0
- data/lib/spree/testing_support/factories/refresh_token_factory.rb +15 -0
- data/lib/spree/testing_support/factories/stock_location_factory.rb +2 -2
- data/lib/spree/testing_support/factories/stock_reservation_factory.rb +31 -0
- data/lib/spree/testing_support/factories/variant_factory.rb +3 -3
- data/lib/spree/testing_support/order_walkthrough.rb +1 -1
- data/lib/spree/testing_support/store.rb +10 -0
- data/lib/spree/upgrades/5_4_to_5_5/manifest.yml +53 -0
- data/lib/tasks/channels.rake +94 -0
- data/lib/tasks/core.rake +1 -0
- data/lib/tasks/media.rake +27 -0
- data/lib/tasks/products.rake +4 -6
- data/lib/tasks/publications.rake +60 -0
- data/lib/tasks/upgrade.rake +211 -0
- metadata +83 -18
- data/app/finders/spree/variants/visible_finder.rb +0 -23
- data/app/paginators/spree/shared/paginate.rb +0 -30
- data/app/presenters/spree/filters/price_presenter.rb +0 -23
- data/app/presenters/spree/filters/price_range_presenter.rb +0 -30
- data/app/presenters/spree/filters/quantified_price_range_presenter.rb +0 -45
- data/app/presenters/spree/product_summary_presenter.rb +0 -27
- data/app/presenters/spree/variants/options_presenter.rb +0 -82
- data/app/services/spree/classifications/reposition.rb +0 -23
- data/app/sorters/spree/orders/sort.rb +0 -10
- data/lib/spree/core/controller_helpers/common.rb +0 -14
- data/lib/spree/core/token_generator.rb +0 -23
- data/lib/spree/database_type_utilities.rb +0 -22
- data/lib/spree/testing_support/bar_ability.rb +0 -14
- data/lib/spree/testing_support/factories/store_product_factory.rb +0 -6
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module OrderRouting
|
|
3
|
+
module Strategy
|
|
4
|
+
# Default order routing strategy: walks Spree::OrderRoutingRule rows in
|
|
5
|
+
# priority order, runs the Reducer to fully rank eligible locations,
|
|
6
|
+
# packs each location, and lets Spree::Stock::Prioritizer distribute
|
|
7
|
+
# inventory units across packages so units that the top-ranked
|
|
8
|
+
# location can't cover spill over to subsequent locations.
|
|
9
|
+
#
|
|
10
|
+
# See docs/plans/6.0-order-routing.md.
|
|
11
|
+
class Rules < Base
|
|
12
|
+
def for_allocation
|
|
13
|
+
locations = eligible_locations
|
|
14
|
+
return [] if locations.empty?
|
|
15
|
+
|
|
16
|
+
ordered = Reducer.new(applicable_rules.to_a, order: order).rank_all(locations)
|
|
17
|
+
return [] if ordered.empty?
|
|
18
|
+
|
|
19
|
+
packages = build_packages(ordered)
|
|
20
|
+
packages = prioritize_packages(packages)
|
|
21
|
+
estimate_rates(packages)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Stock decrement / restock today happens via Spree::Shipment's state
|
|
25
|
+
# machine (after_ship / after_cancel). The strategy methods below are
|
|
26
|
+
# part of the contract for the future reservation + typed-movement
|
|
27
|
+
# phase — see 6.0-stock-reservations.md and 6.0-typed-stock-movements.md.
|
|
28
|
+
# In 5.5 they are no-ops; the existing model callbacks already do the
|
|
29
|
+
# right thing.
|
|
30
|
+
def for_sale(fulfillment:); end
|
|
31
|
+
def for_release; end
|
|
32
|
+
def for_cancellation; end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def applicable_rules
|
|
37
|
+
order.channel.order_routing_rules.active.ordered
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def eligible_locations
|
|
41
|
+
Spree::StockLocation.active
|
|
42
|
+
.joins(:stock_items)
|
|
43
|
+
.where(spree_stock_items: { variant_id: requested_variant_ids })
|
|
44
|
+
.distinct
|
|
45
|
+
.to_a
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def requested_variant_ids
|
|
49
|
+
inventory_units.map(&:variant_id).uniq
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def inventory_units
|
|
53
|
+
@inventory_units ||= Spree::Stock::InventoryUnitBuilder.new(order).units
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Pack each ranked location independently. Packages are emitted in
|
|
57
|
+
# rank order so the Prioritizer's first-package-wins-on-hand logic
|
|
58
|
+
# honors the routing decision.
|
|
59
|
+
def build_packages(locations)
|
|
60
|
+
locations.flat_map do |location|
|
|
61
|
+
Spree::Stock::Packer.new(location, inventory_units, Spree.stock_splitters).packages
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Prioritizer's Adjuster distributes each inventory_unit across
|
|
66
|
+
# packages: the first package with on-hand stock fulfills the unit,
|
|
67
|
+
# and downstream packages have that unit removed. Packages whose
|
|
68
|
+
# items all get stripped are pruned.
|
|
69
|
+
def prioritize_packages(packages)
|
|
70
|
+
Spree::Stock::Prioritizer.new(packages).prioritized_packages
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def estimate_rates(packages)
|
|
74
|
+
estimator = Spree::Stock::Estimator.new(order)
|
|
75
|
+
packages.each { |pkg| pkg.shipping_rates = estimator.shipping_rates(pkg) }
|
|
76
|
+
packages
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# STI base for order routing rules. Subclasses live in
|
|
3
|
+
# app/models/spree/order_routing/rules/ and implement #rank(order, locations).
|
|
4
|
+
#
|
|
5
|
+
# Plugins extend the engine by defining a new subclass:
|
|
6
|
+
#
|
|
7
|
+
# class AcmeFresh::OrderRouting::RefrigeratedRule < Spree::OrderRoutingRule
|
|
8
|
+
# preference :max_temp_c, :integer, default: 4
|
|
9
|
+
#
|
|
10
|
+
# def rank(order, locations)
|
|
11
|
+
# # ... return Array<LocationRanking>
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# See docs/plans/6.0-order-routing.md.
|
|
16
|
+
class OrderRoutingRule < Spree.base_class
|
|
17
|
+
self.table_name = 'spree_order_routing_rules'
|
|
18
|
+
|
|
19
|
+
# `rank` is integer (lower = better) when the rule has an opinion,
|
|
20
|
+
# nil to abstain (the reducer skips abstaining rankings).
|
|
21
|
+
LocationRanking = Struct.new(:location, :rank, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
has_prefix_id :orule
|
|
24
|
+
|
|
25
|
+
include Spree::SingleStoreResource
|
|
26
|
+
|
|
27
|
+
belongs_to :store, class_name: 'Spree::Store'
|
|
28
|
+
belongs_to :channel, class_name: 'Spree::Channel'
|
|
29
|
+
|
|
30
|
+
attribute :active, :boolean, default: true
|
|
31
|
+
|
|
32
|
+
validates :type, :channel, presence: true
|
|
33
|
+
validates :position, presence: true, numericality: { only_integer: true }
|
|
34
|
+
validate :channel_belongs_to_store
|
|
35
|
+
|
|
36
|
+
scope :active, -> { where(active: true) }
|
|
37
|
+
scope :ordered, -> { order(:position) }
|
|
38
|
+
scope :for_channel, ->(channel) { where(channel_id: channel.id) }
|
|
39
|
+
|
|
40
|
+
acts_as_list scope: :channel_id
|
|
41
|
+
|
|
42
|
+
self.whitelisted_ransackable_attributes = %w[type position active store_id channel_id]
|
|
43
|
+
|
|
44
|
+
validate :type_must_be_registered
|
|
45
|
+
|
|
46
|
+
# Subclasses override. Returns an Array<LocationRanking> — one per location,
|
|
47
|
+
# with rank=nil to abstain.
|
|
48
|
+
#
|
|
49
|
+
# @param order [Spree::Order]
|
|
50
|
+
# @param locations [Array<Spree::StockLocation>]
|
|
51
|
+
# @return [Array<LocationRanking>]
|
|
52
|
+
def rank(_order, _locations)
|
|
53
|
+
raise NotImplementedError, "#{self.class} must implement #rank(order, locations)"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# The +type+ presence validation already covers blank; here we only reject
|
|
59
|
+
# a present-but-unregistered STI type so arbitrary class names can't be
|
|
60
|
+
# persisted via the +type+ column.
|
|
61
|
+
def type_must_be_registered
|
|
62
|
+
return if type.blank?
|
|
63
|
+
return if Spree.order_routing.rules.any? { |rule| rule.to_s == type }
|
|
64
|
+
|
|
65
|
+
errors.add(:type, Spree.t(:invalid_order_routing_rule, scope: [:errors, :messages], default: 'is not a registered order routing rule'))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def channel_belongs_to_store
|
|
69
|
+
return if channel.nil? || store_id.nil?
|
|
70
|
+
return if channel.store_id == store_id
|
|
71
|
+
|
|
72
|
+
errors.add(:channel, Spree.t('errors.messages.channel_store_mismatch'))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -23,15 +23,31 @@ module Spree
|
|
|
23
23
|
can :manage, Spree::Zone
|
|
24
24
|
can :manage, Spree::ZoneMember
|
|
25
25
|
|
|
26
|
+
# Markets — Channel / Market is the long-term replacement for
|
|
27
|
+
# Zone (see docs/plans/6.0-tax-provider.md), but both coexist
|
|
28
|
+
# during the migration and need admin read/write either way.
|
|
29
|
+
can :manage, Spree::Market
|
|
30
|
+
|
|
26
31
|
# Tax configuration
|
|
27
32
|
can :manage, Spree::TaxCategory
|
|
28
33
|
can :manage, Spree::TaxRate
|
|
29
34
|
|
|
35
|
+
# CORS allowlist used by Rack::Cors + admin cookie auth (see
|
|
36
|
+
# docs/plans/5.5-admin-auth-cookie-refresh.md).
|
|
37
|
+
can :manage, Spree::AllowedOrigin
|
|
38
|
+
|
|
39
|
+
# Webhooks
|
|
40
|
+
can :manage, Spree::WebhookEndpoint
|
|
41
|
+
can :manage, Spree::WebhookDelivery
|
|
42
|
+
|
|
30
43
|
# General configuration
|
|
31
44
|
can :manage, Spree::RefundReason
|
|
32
45
|
can :manage, Spree::ReimbursementType
|
|
33
46
|
can :manage, Spree::ReturnReason
|
|
34
47
|
|
|
48
|
+
# Channels
|
|
49
|
+
can :manage, Spree::Channel
|
|
50
|
+
|
|
35
51
|
# Restrictions on immutable types
|
|
36
52
|
cannot [:edit, :update], Spree::RefundReason, mutable: false
|
|
37
53
|
cannot [:edit, :update], Spree::ReimbursementType, mutable: false
|
data/app/models/spree/price.rb
CHANGED
|
@@ -55,7 +55,20 @@ module Spree
|
|
|
55
55
|
money_methods :amount, :price, :compare_at_amount
|
|
56
56
|
alias display_compare_at_price display_compare_at_amount
|
|
57
57
|
|
|
58
|
-
self.whitelisted_ransackable_attributes = [
|
|
58
|
+
self.whitelisted_ransackable_attributes = %w[amount compare_at_amount currency price_list_id variant_id]
|
|
59
|
+
self.whitelisted_ransackable_associations = %w[variant price_list]
|
|
60
|
+
self.whitelisted_ransackable_scopes = %i[search]
|
|
61
|
+
|
|
62
|
+
# Free-text search delegated to `Spree::Variant.search` (SKU + product
|
|
63
|
+
# name + option-value presentation), wrapped in a subquery so that
|
|
64
|
+
# multi-option-value variants don't produce duplicate Price rows —
|
|
65
|
+
# the prices index has `collection_distinct?` off (PG DISTINCT +
|
|
66
|
+
# ORDER BY incompat), so any join-based predicate would double up.
|
|
67
|
+
scope :search, ->(query) {
|
|
68
|
+
next all if query.blank?
|
|
69
|
+
|
|
70
|
+
where(variant_id: Spree::Variant.search(query).select(:id))
|
|
71
|
+
}
|
|
59
72
|
|
|
60
73
|
attribute :eligible_for_taxon_matching, :boolean, default: false
|
|
61
74
|
before_validation -> { self.eligible_for_taxon_matching = new_record? ? discounted? : discounted? != was_discounted? }
|
|
@@ -11,10 +11,11 @@ module Spree
|
|
|
11
11
|
|
|
12
12
|
belongs_to :store, class_name: 'Spree::Store'
|
|
13
13
|
|
|
14
|
-
has_many :price_rules, class_name: 'Spree::PriceRule', dependent: :destroy
|
|
14
|
+
has_many :price_rules, class_name: 'Spree::PriceRule', autosave: true, dependent: :destroy
|
|
15
|
+
alias rules price_rules
|
|
15
16
|
has_many :prices, class_name: 'Spree::Price', dependent: :destroy_async
|
|
16
|
-
has_many :variants, -> {
|
|
17
|
-
has_many :products, -> {
|
|
17
|
+
has_many :variants, -> { distinct }, through: :prices, source: :variant
|
|
18
|
+
has_many :products, -> { distinct }, through: :variants, source: :product
|
|
18
19
|
alias price_list_products products
|
|
19
20
|
|
|
20
21
|
# Override default nested attributes to use bulk_update_prices for performance
|
|
@@ -28,20 +29,59 @@ module Spree
|
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
after_update :process_bulk_prices_update
|
|
32
|
+
after_save :apply_pending_rules
|
|
33
|
+
after_save :apply_pending_product_ids
|
|
34
|
+
after_save :apply_pending_prices
|
|
35
|
+
|
|
36
|
+
# @return [Array<String>] prefixed product ids in this list,
|
|
37
|
+
# encoded inline to avoid hydrating N Product records.
|
|
38
|
+
def product_prefixed_ids
|
|
39
|
+
prefix = Spree::Product._prefix_id_prefix
|
|
40
|
+
product_ids.sort.map { |pk| "#{prefix}_#{Spree::PrefixedId::SQIDS.encode([pk])}" }
|
|
41
|
+
end
|
|
31
42
|
|
|
32
|
-
#
|
|
43
|
+
# Reconciles list membership. Removes prices for products no longer
|
|
44
|
+
# in `ids` and adds placeholder prices for the new ones.
|
|
45
|
+
#
|
|
46
|
+
# @param ids [Array<String>] raw product PKs (prefixed strings are
|
|
47
|
+
# resolved upstream by `Spree::Base#assign_attributes`).
|
|
33
48
|
# @return [void]
|
|
34
|
-
def
|
|
35
|
-
|
|
49
|
+
def product_ids=(ids)
|
|
50
|
+
@pending_product_ids = Array(ids).compact.uniq
|
|
51
|
+
end
|
|
36
52
|
|
|
37
|
-
|
|
38
|
-
|
|
53
|
+
# Flat-payload writer for `prices`. Bulk-upserts the listed rows in
|
|
54
|
+
# `after_save` so newly-added products have their placeholder rows
|
|
55
|
+
# materialized first. Nullability contract:
|
|
56
|
+
# - `nil` → no-op
|
|
57
|
+
# - `[]` → clear every override on this list
|
|
58
|
+
# - `[…]` → upsert listed rows, leave the rest alone
|
|
59
|
+
#
|
|
60
|
+
# @param rows [Array<Hash>, Array<Spree::Price>, nil]
|
|
61
|
+
# @return [void]
|
|
62
|
+
def prices=(rows)
|
|
63
|
+
first = Array(rows).first
|
|
64
|
+
return super(rows) if first.is_a?(Spree::Price)
|
|
65
|
+
return if rows.nil?
|
|
66
|
+
|
|
67
|
+
@pending_prices = Array(rows).map do |row|
|
|
68
|
+
row.respond_to?(:to_unsafe_h) ? row.to_unsafe_h.with_indifferent_access : row.with_indifferent_access
|
|
69
|
+
end
|
|
70
|
+
@pending_prices_clear = rows.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Flat-payload writer for `rules`. See
|
|
74
|
+
# {Spree::TypedAssociations#assign_typed_association}.
|
|
75
|
+
def rules=(rows)
|
|
76
|
+
assign_typed_association(:price_rules, rows)
|
|
39
77
|
end
|
|
40
78
|
|
|
41
79
|
validates :name, :store, presence: true
|
|
42
80
|
validates :match_policy, presence: true, inclusion: { in: MATCH_POLICIES }
|
|
43
81
|
validate :starts_at_before_ends_at
|
|
44
82
|
|
|
83
|
+
self.whitelisted_ransackable_attributes = %w[status match_policy starts_at ends_at]
|
|
84
|
+
|
|
45
85
|
scope :by_position, -> { order(position: :asc) }
|
|
46
86
|
scope :for_store, ->(store) { where(store: store) }
|
|
47
87
|
scope :current, lambda { |timezone = nil|
|
|
@@ -119,9 +159,10 @@ module Spree
|
|
|
119
159
|
active_or_scheduled? && within_date_range?(Time.current)
|
|
120
160
|
end
|
|
121
161
|
|
|
122
|
-
# Adds products to the price
|
|
123
|
-
#
|
|
124
|
-
#
|
|
162
|
+
# Adds products to the list, materializing a placeholder price
|
|
163
|
+
# (amount nil) for every variant × store currency.
|
|
164
|
+
#
|
|
165
|
+
# @param product_ids [Array<String>] raw product PKs
|
|
125
166
|
# @return [void]
|
|
126
167
|
def add_products(product_ids)
|
|
127
168
|
return if product_ids.blank?
|
|
@@ -130,7 +171,6 @@ module Spree
|
|
|
130
171
|
variant_ids = Spree::Variant.eligible.where(product_id: product_ids).distinct.pluck(:id)
|
|
131
172
|
return if variant_ids.empty?
|
|
132
173
|
|
|
133
|
-
# Get existing variant_id/currency combinations to avoid duplicates
|
|
134
174
|
existing = prices.where(variant_id: variant_ids)
|
|
135
175
|
.pluck(:variant_id, :currency)
|
|
136
176
|
.to_set
|
|
@@ -160,9 +200,12 @@ module Spree
|
|
|
160
200
|
touch
|
|
161
201
|
end
|
|
162
202
|
|
|
163
|
-
# Removes products from the
|
|
164
|
-
#
|
|
165
|
-
#
|
|
203
|
+
# Removes products from the list. Hard-deletes their prices so the
|
|
204
|
+
# unique index doesn't block re-adding the same products later
|
|
205
|
+
# (acts_as_paranoid would leave soft-deleted rows blocking the
|
|
206
|
+
# `(variant_id, currency, price_list_id)` slot).
|
|
207
|
+
#
|
|
208
|
+
# @param product_ids [Array<String>] raw product PKs
|
|
166
209
|
# @return [void]
|
|
167
210
|
def remove_products(product_ids)
|
|
168
211
|
return if product_ids.blank?
|
|
@@ -195,7 +238,12 @@ module Spree
|
|
|
195
238
|
next if attrs[:id].blank?
|
|
196
239
|
|
|
197
240
|
price_id = attrs[:id].to_i
|
|
198
|
-
|
|
241
|
+
# Reject rows that aren't in *this* list's prices — `upsert_all`
|
|
242
|
+
# otherwise keys solely by primary id and would silently cross
|
|
243
|
+
# list boundaries.
|
|
244
|
+
next unless current_values.key?(price_id)
|
|
245
|
+
|
|
246
|
+
current = current_values[price_id]
|
|
199
247
|
|
|
200
248
|
# Parse amounts using LocalizedNumber for proper decimal handling
|
|
201
249
|
amount = attrs[:amount].present? ? Spree::LocalizedNumber.parse(attrs[:amount]) : nil
|
|
@@ -222,7 +270,7 @@ module Spree
|
|
|
222
270
|
return true if records_to_upsert.empty?
|
|
223
271
|
|
|
224
272
|
opts = { update_only: [:amount, :compare_at_amount], on_duplicate: :update }
|
|
225
|
-
opts[:unique_by] = :id unless
|
|
273
|
+
opts[:unique_by] = :id unless mysql_adapter?
|
|
226
274
|
|
|
227
275
|
Spree::Price.upsert_all(records_to_upsert, **opts)
|
|
228
276
|
|
|
@@ -232,6 +280,70 @@ module Spree
|
|
|
232
280
|
|
|
233
281
|
private
|
|
234
282
|
|
|
283
|
+
# Processes the bulk prices update
|
|
284
|
+
# @return [void]
|
|
285
|
+
def process_bulk_prices_update
|
|
286
|
+
return if @prices_attributes.blank?
|
|
287
|
+
|
|
288
|
+
bulk_update_prices(@prices_attributes)
|
|
289
|
+
@prices_attributes = nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def apply_pending_rules
|
|
293
|
+
flush_pending_typed_association(:price_rules)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def apply_pending_product_ids
|
|
297
|
+
return unless @pending_product_ids
|
|
298
|
+
|
|
299
|
+
desired = @pending_product_ids
|
|
300
|
+
@pending_product_ids = nil
|
|
301
|
+
|
|
302
|
+
current = product_ids
|
|
303
|
+
to_remove = current - desired
|
|
304
|
+
to_add = desired - current
|
|
305
|
+
|
|
306
|
+
remove_products(to_remove) if to_remove.any?
|
|
307
|
+
add_products(to_add) if to_add.any?
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def apply_pending_prices
|
|
311
|
+
pending = @pending_prices
|
|
312
|
+
cleared = @pending_prices_clear
|
|
313
|
+
return if pending.nil?
|
|
314
|
+
|
|
315
|
+
@pending_prices = nil
|
|
316
|
+
@pending_prices_clear = nil
|
|
317
|
+
|
|
318
|
+
if cleared
|
|
319
|
+
variant_ids = prices.distinct.pluck(:variant_id)
|
|
320
|
+
prices.update_all(amount: nil, compare_at_amount: nil, updated_at: Time.current)
|
|
321
|
+
touch_variants(variant_ids)
|
|
322
|
+
return
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
rows = pending.filter_map do |row|
|
|
326
|
+
# `variant_id` may arrive as a prefixed string (legacy callers,
|
|
327
|
+
# console) or already decoded (the controller's `permitted_params`
|
|
328
|
+
# runs through `normalize_params`). Handle both.
|
|
329
|
+
raw = row[:variant_id]
|
|
330
|
+
variant_id = Spree::PrefixedId.prefixed_id?(raw) ? Spree::PrefixedId.decode_prefixed_id(raw) : raw
|
|
331
|
+
next if variant_id.blank? || row[:currency].blank?
|
|
332
|
+
|
|
333
|
+
{
|
|
334
|
+
variant_id: variant_id,
|
|
335
|
+
currency: row[:currency],
|
|
336
|
+
price_list_id: id,
|
|
337
|
+
amount: row[:amount],
|
|
338
|
+
compare_at_amount: row[:compare_at_amount]
|
|
339
|
+
}
|
|
340
|
+
end
|
|
341
|
+
return if rows.empty?
|
|
342
|
+
|
|
343
|
+
Spree::Prices::BulkUpsert.call(rows: rows)
|
|
344
|
+
touch_variants(rows.map { |r| r[:variant_id] }.uniq)
|
|
345
|
+
end
|
|
346
|
+
|
|
235
347
|
# Touches the variants in a background job
|
|
236
348
|
# @param variant_ids [Array<String>] array of variant ids
|
|
237
349
|
# @return [void]
|
|
@@ -4,7 +4,10 @@ module Spree
|
|
|
4
4
|
|
|
5
5
|
belongs_to :price_list, class_name: 'Spree::PriceList', touch: true
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
delegate :store, to: :price_list
|
|
8
|
+
|
|
9
|
+
validates :type, :price_list, presence: true
|
|
10
|
+
validates :type, uniqueness: { scope: [:price_list_id, *spree_base_uniqueness_scope] }
|
|
8
11
|
|
|
9
12
|
# Returns true if the price rule is applicable to the context
|
|
10
13
|
# @param context [Spree::Pricing::Context]
|
|
@@ -24,5 +27,12 @@ module Spree
|
|
|
24
27
|
def self.description
|
|
25
28
|
''
|
|
26
29
|
end
|
|
30
|
+
|
|
31
|
+
# Pull the rule registry off the global pricing config so
|
|
32
|
+
# PreferenceSchema's `.subclasses_with_preference_schema` can power
|
|
33
|
+
# the admin's "Add rule" picker.
|
|
34
|
+
def self.registered_subclasses
|
|
35
|
+
Array(Spree.pricing&.rules)
|
|
36
|
+
end
|
|
27
37
|
end
|
|
28
38
|
end
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
module Spree
|
|
2
2
|
module PriceRules
|
|
3
3
|
class CustomerGroupRule < Spree::PriceRule
|
|
4
|
-
|
|
4
|
+
# Stored as raw IDs. Accepts prefixed IDs (`cg_…`) from API
|
|
5
|
+
# callers and decodes them on write so eligibility checks compare
|
|
6
|
+
# against raw `customer_group_id` rows directly. Scope confines
|
|
7
|
+
# the existence check to the price-list's store.
|
|
8
|
+
preference :customer_group_ids, :array, default: [],
|
|
9
|
+
parse_on_set: normalize_id_preference(
|
|
10
|
+
klass: Spree::CustomerGroup,
|
|
11
|
+
scope: ->(rule) { rule.store.customer_groups }
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def customer_groups
|
|
15
|
+
return [] if preferred_customer_group_ids.blank?
|
|
16
|
+
|
|
17
|
+
Spree::CustomerGroup.where(id: preferred_customer_group_ids)
|
|
18
|
+
end
|
|
5
19
|
|
|
6
20
|
def applicable?(context)
|
|
7
21
|
return false unless context.user
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
module Spree
|
|
2
2
|
module PriceRules
|
|
3
3
|
class MarketRule < Spree::PriceRule
|
|
4
|
-
|
|
4
|
+
# Stored as raw IDs. Accepts prefixed IDs (`mkt_…`) from API
|
|
5
|
+
# callers and decodes them on write so eligibility checks compare
|
|
6
|
+
# against raw `market_id` rows directly. Scope confines the
|
|
7
|
+
# existence check to the price-list's store so cross-store market
|
|
8
|
+
# IDs can't sneak in.
|
|
9
|
+
preference :market_ids, :array, default: [],
|
|
10
|
+
parse_on_set: normalize_id_preference(
|
|
11
|
+
klass: Spree::Market,
|
|
12
|
+
scope: ->(rule) { rule.store.markets }
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
def markets
|
|
16
|
+
return [] if preferred_market_ids.blank?
|
|
17
|
+
|
|
18
|
+
Spree::Market.where(id: preferred_market_ids)
|
|
19
|
+
end
|
|
5
20
|
|
|
6
21
|
def applicable?(context)
|
|
7
22
|
return false unless context.market
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
module Spree
|
|
2
2
|
module PriceRules
|
|
3
3
|
class UserRule < Spree::PriceRule
|
|
4
|
-
|
|
4
|
+
# Stored as raw IDs. Accepts prefixed IDs (the user class's prefix,
|
|
5
|
+
# e.g. `usr_…`) from API callers and decodes them on write. Resolves
|
|
6
|
+
# `Spree.user_class` lazily — the user class is configured at boot,
|
|
7
|
+
# and class-body evaluation runs before that on cold loads.
|
|
8
|
+
preference :user_ids, :array, default: [], parse_on_set: ->(values) {
|
|
9
|
+
normalize_id_preference(klass: Spree.user_class).call(values)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
def users
|
|
13
|
+
return [] if preferred_user_ids.blank?
|
|
14
|
+
|
|
15
|
+
Spree.user_class.where(id: preferred_user_ids)
|
|
16
|
+
end
|
|
5
17
|
|
|
6
18
|
def applicable?(context)
|
|
7
19
|
return false unless context.user
|
|
@@ -12,7 +24,14 @@ module Spree
|
|
|
12
24
|
end
|
|
13
25
|
|
|
14
26
|
def self.description
|
|
15
|
-
'Apply pricing to specific
|
|
27
|
+
'Apply pricing to specific customers'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Public-facing label — keeps the wire `api_type` as `user_rule`
|
|
31
|
+
# (preference column is `user_ids`) so existing data stays valid,
|
|
32
|
+
# but every UI surface reads "Customer rule".
|
|
33
|
+
def self.human_name
|
|
34
|
+
'Customer rule'
|
|
16
35
|
end
|
|
17
36
|
end
|
|
18
37
|
end
|