spree_core 5.4.2 → 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 +3 -2
- 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/publishable.rb +1 -1
- 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 +37 -14
- 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/event.rb +6 -6
- data/app/models/spree/export.rb +32 -5
- data/app/models/spree/exports/product_translations.rb +1 -1
- data/app/models/spree/gateway/bogus.rb +6 -1
- 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 +7 -2
- data/app/models/spree/market.rb +57 -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_inventory.rb +24 -2
- 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/payment_setup_sessions/bogus.rb +4 -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/shipment.rb +10 -4
- 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/subscriber.rb +12 -12
- 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/formula_sanitizer.rb +28 -0
- 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/credit_cards/destroy.rb +1 -1
- 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/payments/handle_webhook.rb +3 -10
- 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/event_log_subscriber.rb +1 -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 +35 -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/20260504103113_add_type_to_spree_payment_setup_sessions.rb +6 -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/events/adapters/active_support_notifications.rb +1 -1
- data/lib/spree/events/adapters/base.rb +3 -3
- data/lib/spree/events/registry.rb +1 -1
- data/lib/spree/events.rb +1 -1
- 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 +86 -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,28 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module OrderRouting
|
|
3
|
+
# Shared validation for models carrying a +preferred_order_routing_strategy+
|
|
4
|
+
# preference (Spree::Store, Spree::Channel). A blank value is allowed (it
|
|
5
|
+
# falls back to the next level / the default Rules strategy); a present value
|
|
6
|
+
# must name a registered Spree::OrderRouting::Strategy::Base subclass.
|
|
7
|
+
module HasStrategyPreference
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
validate :order_routing_strategy_must_be_registered
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def order_routing_strategy_must_be_registered
|
|
17
|
+
value = preferred_order_routing_strategy
|
|
18
|
+
return if value.blank?
|
|
19
|
+
return if Spree.order_routing.strategies.any? { |strategy| strategy.to_s == value.to_s }
|
|
20
|
+
|
|
21
|
+
errors.add(
|
|
22
|
+
:preferred_order_routing_strategy,
|
|
23
|
+
Spree.t(:invalid_order_routing_strategy, scope: [:errors, :messages], default: 'is not a registered order routing strategy')
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module OrderRouting
|
|
3
|
+
module Rules
|
|
4
|
+
# Ranks the StockLocation marked `default: true` at 0 and others at 1.
|
|
5
|
+
# Provides a deterministic baseline so the reducer always has a winner
|
|
6
|
+
# once higher-priority rules abstain or tie.
|
|
7
|
+
class DefaultLocation < Spree::OrderRoutingRule
|
|
8
|
+
def rank(_order, locations)
|
|
9
|
+
locations.map do |loc|
|
|
10
|
+
LocationRanking.new(location: loc, rank: loc.default? ? 0 : 1)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module OrderRouting
|
|
3
|
+
module Rules
|
|
4
|
+
# Prefers locations that can fulfill more demand on their own.
|
|
5
|
+
# Higher coverage → lower (better) rank, so the location that single-handedly
|
|
6
|
+
# covers the most variants wins. Coverage is counted per distinct variant
|
|
7
|
+
# so a variant repeated across multiple line items isn't double-counted.
|
|
8
|
+
class MinimizeSplits < Spree::OrderRoutingRule
|
|
9
|
+
def rank(order, locations)
|
|
10
|
+
demand = required_quantity_by_variant(order)
|
|
11
|
+
counts = stock_item_counts(demand.keys, locations)
|
|
12
|
+
|
|
13
|
+
locations.map do |loc|
|
|
14
|
+
coverage = demand.count do |variant_id, qty|
|
|
15
|
+
(counts[[loc.id, variant_id]] || 0) >= qty
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
LocationRanking.new(location: loc, rank: -coverage)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def required_quantity_by_variant(order)
|
|
25
|
+
order.line_items.each_with_object(Hash.new(0)) do |li, h|
|
|
26
|
+
next if li.variant_id.nil?
|
|
27
|
+
|
|
28
|
+
h[li.variant_id] += li.quantity
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# One query for the entire location × variant matrix instead of
|
|
33
|
+
# N variants × M locations stock_item lookups.
|
|
34
|
+
def stock_item_counts(variant_ids, locations)
|
|
35
|
+
return {} if variant_ids.empty? || locations.empty?
|
|
36
|
+
|
|
37
|
+
Spree::StockItem
|
|
38
|
+
.where(stock_location_id: locations.map(&:id), variant_id: variant_ids)
|
|
39
|
+
.pluck(:stock_location_id, :variant_id, :count_on_hand)
|
|
40
|
+
.each_with_object({}) { |(loc_id, var_id, count), h| h[[loc_id, var_id]] = count }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module OrderRouting
|
|
3
|
+
module Rules
|
|
4
|
+
# Ranks the order's inferred preferred location at 0 and abstains for
|
|
5
|
+
# everything else. Lets admins / staff / B2B contexts pin "fulfill from
|
|
6
|
+
# this location" without preventing fallback when the preferred location
|
|
7
|
+
# doesn't actually stock the items — subsequent rules tie-break.
|
|
8
|
+
class PreferredLocation < Spree::OrderRoutingRule
|
|
9
|
+
def rank(order, locations)
|
|
10
|
+
preferred_id = order.inferred_preferred_stock_location_id
|
|
11
|
+
|
|
12
|
+
locations.map do |loc|
|
|
13
|
+
LocationRanking.new(
|
|
14
|
+
location: loc,
|
|
15
|
+
rank: (preferred_id.present? && loc.id.to_s == preferred_id.to_s) ? 0 : nil
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module OrderRouting
|
|
3
|
+
module Strategy
|
|
4
|
+
# Contract for order routing strategies. Subclasses implement all four
|
|
5
|
+
# methods — there are no defaults. New routing *signals* (proximity,
|
|
6
|
+
# day-of-week, etc.) ship as STI subclasses of Spree::OrderRoutingRule;
|
|
7
|
+
# a custom strategy is appropriate only when the algorithm itself is a
|
|
8
|
+
# different shape (OMS delegation, ML model, optimization solver).
|
|
9
|
+
#
|
|
10
|
+
# Selected per Order via Spree::Order#order_routing_strategy.
|
|
11
|
+
# See docs/plans/6.0-order-routing.md.
|
|
12
|
+
class Base
|
|
13
|
+
attr_reader :order
|
|
14
|
+
|
|
15
|
+
# Human label for admin strategy pickers. Override in a subclass or add
|
|
16
|
+
# an i18n key under +spree.order_routing.strategies+.
|
|
17
|
+
#
|
|
18
|
+
# @return [String]
|
|
19
|
+
def self.display_name
|
|
20
|
+
Spree.t(name.demodulize.underscore, scope: 'order_routing.strategies', default: name.demodulize.titleize)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(order:)
|
|
24
|
+
@order = order
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Array<Spree::Stock::Package>]
|
|
28
|
+
def for_allocation
|
|
29
|
+
raise NotImplementedError, "#{self.class} must implement #for_allocation"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param fulfillment [Spree::Shipment]
|
|
33
|
+
def for_sale(fulfillment:)
|
|
34
|
+
raise NotImplementedError, "#{self.class} must implement #for_sale"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def for_release
|
|
38
|
+
raise NotImplementedError, "#{self.class} must implement #for_release"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def for_cancellation
|
|
42
|
+
raise NotImplementedError, "#{self.class} must implement #for_cancellation"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module OrderRouting
|
|
3
|
+
module Strategy
|
|
4
|
+
# Pre-5.5 routing behavior. Delegates to Spree::Stock::Coordinator,
|
|
5
|
+
# which packs every active stock location and lets Prioritizer's
|
|
6
|
+
# Adjuster distribute units across the resulting packages — no rules
|
|
7
|
+
# consulted, no merchant-driven preferences, location order is
|
|
8
|
+
# whatever the database returns.
|
|
9
|
+
#
|
|
10
|
+
# Provided as an opt-in escape hatch for merchants upgrading from 5.4
|
|
11
|
+
# who are not ready to adopt rules-based routing. Configure via:
|
|
12
|
+
#
|
|
13
|
+
# store.update!(preferred_order_routing_strategy: 'Spree::OrderRouting::Strategy::Legacy')
|
|
14
|
+
#
|
|
15
|
+
# Spree 6.0 drops this strategy along with the underlying Coordinator.
|
|
16
|
+
# See docs/plans/6.0-order-routing.md.
|
|
17
|
+
class Legacy < Base
|
|
18
|
+
def for_allocation
|
|
19
|
+
Spree::Stock::Coordinator.new(order).packages
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Stock decrement / restock today happens via Spree::Shipment's state
|
|
23
|
+
# machine (after_ship / after_cancel). The strategy hooks below are
|
|
24
|
+
# part of the contract for the future reservation + typed-movement
|
|
25
|
+
# phase. In 5.5 they are no-ops; existing model callbacks already do
|
|
26
|
+
# the right thing.
|
|
27
|
+
def for_sale(fulfillment:); end
|
|
28
|
+
def for_release; end
|
|
29
|
+
def for_cancellation; end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require 'set'
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module OrderRouting
|
|
5
|
+
module Strategy
|
|
6
|
+
# Walks rules in priority order and applies a "first non-tie wins" reducer.
|
|
7
|
+
#
|
|
8
|
+
# For each rule:
|
|
9
|
+
# 1. Drop rankings where rank is nil (rule abstains for that location).
|
|
10
|
+
# 2. Find the location(s) with the lowest rank (best).
|
|
11
|
+
# 3. Unique winner -> return it.
|
|
12
|
+
# 4. Tie -> carry the tied set forward to the next rule.
|
|
13
|
+
#
|
|
14
|
+
# Out of rules with ties: prefer the StockLocation marked default,
|
|
15
|
+
# then by id. Guarantees a winner whenever locations is non-empty.
|
|
16
|
+
class Reducer
|
|
17
|
+
def initialize(rules, order:)
|
|
18
|
+
@rules = rules
|
|
19
|
+
@order = order
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param locations [Array<Spree::StockLocation>]
|
|
23
|
+
# @return [Spree::StockLocation, nil]
|
|
24
|
+
def pick(locations)
|
|
25
|
+
return nil if locations.empty?
|
|
26
|
+
|
|
27
|
+
remaining = locations
|
|
28
|
+
remaining_ids = remaining.map(&:id).to_set
|
|
29
|
+
|
|
30
|
+
@rules.each do |rule|
|
|
31
|
+
rankings = rule.rank(@order, remaining).select do |r|
|
|
32
|
+
r.rank && remaining_ids.include?(r.location.id)
|
|
33
|
+
end
|
|
34
|
+
next if rankings.empty?
|
|
35
|
+
|
|
36
|
+
min_rank = rankings.map(&:rank).min
|
|
37
|
+
top = rankings.select { |r| r.rank == min_rank }.map(&:location)
|
|
38
|
+
|
|
39
|
+
return top.first if top.size == 1
|
|
40
|
+
|
|
41
|
+
remaining = top
|
|
42
|
+
remaining_ids = top.map(&:id).to_set
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
remaining.min_by { |l| [l.default? ? 0 : 1, l.id] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns every input location, ordered best-first by the same rule
|
|
49
|
+
# chain that drives #pick. Each successive location is the best of
|
|
50
|
+
# what remains — used by Strategy::Rules to fan out an allocation
|
|
51
|
+
# across multiple locations when no single location covers the cart.
|
|
52
|
+
#
|
|
53
|
+
# @param locations [Array<Spree::StockLocation>]
|
|
54
|
+
# @return [Array<Spree::StockLocation>]
|
|
55
|
+
def rank_all(locations)
|
|
56
|
+
remaining = locations.dup
|
|
57
|
+
ordered = []
|
|
58
|
+
until remaining.empty?
|
|
59
|
+
chosen = pick(remaining) or break
|
|
60
|
+
ordered << chosen
|
|
61
|
+
remaining = remaining.reject { |l| l.id == chosen.id }
|
|
62
|
+
end
|
|
63
|
+
ordered
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -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? }
|