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
|
@@ -3,19 +3,50 @@ module Spree
|
|
|
3
3
|
class Cancel
|
|
4
4
|
prepend Spree::ServiceModule::Base
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
DEFAULT_REASON = 'other'.freeze
|
|
7
|
+
|
|
8
|
+
# Cancels an order and records a Spree::OrderCancellation history record.
|
|
9
|
+
# Legacy `canceler:` and `canceled_at:` remain valid; new keywords are additive.
|
|
10
|
+
#
|
|
11
|
+
# @param order [Spree::Order]
|
|
12
|
+
# @param canceler [Object, nil] the user/admin who initiated the cancellation
|
|
13
|
+
# @param canceled_at [Time, nil] timestamp (defaults to Time.current)
|
|
14
|
+
# @param reason [String] one of Spree::OrderCancellation::REASONS
|
|
15
|
+
# @param note [String, nil] staff-facing note
|
|
16
|
+
# @param restock_items [Boolean] whether to return inventory
|
|
17
|
+
# @param refund_payments [Boolean] whether to refund captured payments
|
|
18
|
+
# @param refund_amount [BigDecimal, Numeric, nil] amount to refund;
|
|
19
|
+
# when refund_payments is true and this is nil, defaults to order.payment_total
|
|
20
|
+
# @param notify_customer [Boolean] hint for subscribers
|
|
21
|
+
# @return [Spree::ServiceModule::Result]
|
|
22
|
+
def call(order:, canceler: nil, canceled_at: nil,
|
|
23
|
+
reason: DEFAULT_REASON, note: nil,
|
|
24
|
+
restock_items: false, refund_payments: false, refund_amount: nil,
|
|
25
|
+
notify_customer: false)
|
|
7
26
|
canceled_at ||= Time.current
|
|
27
|
+
refund_amount ||= order.payment_total if refund_payments
|
|
8
28
|
|
|
9
29
|
order.transaction do
|
|
30
|
+
order.cancellations.create!(
|
|
31
|
+
reason: reason,
|
|
32
|
+
note: note,
|
|
33
|
+
restock_items: restock_items,
|
|
34
|
+
refund_payments: refund_payments,
|
|
35
|
+
refund_amount: refund_amount,
|
|
36
|
+
notify_customer: notify_customer,
|
|
37
|
+
canceled_by: canceler,
|
|
38
|
+
created_at: canceled_at
|
|
39
|
+
)
|
|
40
|
+
|
|
10
41
|
changes = { canceled_at: canceled_at }
|
|
11
42
|
changes[:canceler_id] = canceler.id if canceler.present?
|
|
12
43
|
order.update_columns(changes)
|
|
13
44
|
order.cancel!
|
|
14
45
|
end
|
|
15
46
|
|
|
16
|
-
order.publish_event('order.canceled')
|
|
47
|
+
order.publish_event('order.canceled', order.event_payload.merge(notify_customer: notify_customer))
|
|
17
48
|
success(order.reload)
|
|
18
|
-
rescue ActiveRecord::
|
|
49
|
+
rescue ActiveRecord::RecordInvalid, StateMachines::InvalidTransition
|
|
19
50
|
failure(order)
|
|
20
51
|
end
|
|
21
52
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Orders
|
|
3
|
+
# Admin-side order completion.
|
|
4
|
+
#
|
|
5
|
+
# Distinct from Spree::Carts::Complete (storefront checkout). Callers must
|
|
6
|
+
# wrap invocation in Spree::Api::V3::OrderLock#with_order_lock — this
|
|
7
|
+
# service does not lock the row itself.
|
|
8
|
+
#
|
|
9
|
+
# @param order [Spree::Order]
|
|
10
|
+
# @param payment_pending [Boolean] if true, completes the order without
|
|
11
|
+
# processing payments. Order is placed but `payment_status` may be
|
|
12
|
+
# 'balance_due'. Useful for B2B / invoice-later flows.
|
|
13
|
+
# @param notify_customer [Boolean] if true, the customer receives the
|
|
14
|
+
# standard order confirmation email. Defaults to false — admin orders
|
|
15
|
+
# complete silently unless explicitly opted in.
|
|
16
|
+
# @return [Spree::ServiceModule::Result]
|
|
17
|
+
class Complete
|
|
18
|
+
prepend Spree::ServiceModule::Base
|
|
19
|
+
|
|
20
|
+
def call(order:, payment_pending: false, notify_customer: false)
|
|
21
|
+
order.notify_customer = notify_customer
|
|
22
|
+
|
|
23
|
+
return success(order) if order.completed?
|
|
24
|
+
return failure(order, 'Order is canceled') if order.canceled?
|
|
25
|
+
|
|
26
|
+
process_payments!(order) if order.payment_required? && !payment_pending
|
|
27
|
+
|
|
28
|
+
return failure(order, order.errors.full_messages.to_sentence) if order.errors.any?
|
|
29
|
+
|
|
30
|
+
advance_to_complete!(order)
|
|
31
|
+
|
|
32
|
+
if order.reload.complete?
|
|
33
|
+
success(order)
|
|
34
|
+
else
|
|
35
|
+
failure(order, order.errors.full_messages.to_sentence.presence || 'Could not complete order')
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def process_payments!(order)
|
|
42
|
+
return if order.payment_total >= order.total
|
|
43
|
+
return if order.payments.valid.any?(&:completed?) && order.unprocessed_payments.empty?
|
|
44
|
+
|
|
45
|
+
order.process_payments!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def advance_to_complete!(order)
|
|
49
|
+
order.next until order.complete? || order.errors.present?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Orders
|
|
3
|
+
# Admin-side order creation. One-shot: customer, items, addresses, currency,
|
|
4
|
+
# market, locale, notes, metadata, and a coupon code in a single call.
|
|
5
|
+
# Invalid coupons are non-fatal — the order is created and `result.value`
|
|
6
|
+
# carries `discount_application_errors`.
|
|
7
|
+
#
|
|
8
|
+
# Standalone from Spree::Carts::Create (storefront). Admin-created orders
|
|
9
|
+
# are first-class Spree::Order records (status: 'draft') in 5.x and remain
|
|
10
|
+
# so in 6.0 — Spree::Cart in 6.0 is storefront-only.
|
|
11
|
+
class Create
|
|
12
|
+
prepend Spree::ServiceModule::Base
|
|
13
|
+
|
|
14
|
+
attr_reader :discount_application_errors
|
|
15
|
+
|
|
16
|
+
# @param store [Spree::Store]
|
|
17
|
+
# @param user [Object, nil] resolved customer (Spree.user_class instance)
|
|
18
|
+
# @param params [Hash] order params (see admin API docs)
|
|
19
|
+
# @return [Spree::ServiceModule::Result]
|
|
20
|
+
def call(store:, user: nil, params: {})
|
|
21
|
+
@store = store
|
|
22
|
+
@user = user
|
|
23
|
+
@params = params.to_h.deep_symbolize_keys
|
|
24
|
+
@discount_application_errors = []
|
|
25
|
+
|
|
26
|
+
return failure(:store_is_required) if store.nil?
|
|
27
|
+
|
|
28
|
+
order = nil
|
|
29
|
+
ApplicationRecord.transaction do
|
|
30
|
+
order = build_order
|
|
31
|
+
assign_addresses(order)
|
|
32
|
+
order.tags = @params[:tags] if @params[:tags]
|
|
33
|
+
order.save!
|
|
34
|
+
|
|
35
|
+
add_items(order) if @params[:items].present?
|
|
36
|
+
build_shipments(order)
|
|
37
|
+
apply_coupon(order) if @params[:coupon_code].present?
|
|
38
|
+
order.update_with_updater!
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
success(order.reload)
|
|
42
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
43
|
+
failure(e.record, e.record.errors.full_messages.to_sentence)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def build_order
|
|
49
|
+
attrs = {
|
|
50
|
+
user: @user,
|
|
51
|
+
email: @params[:email] || @user&.email,
|
|
52
|
+
currency: @params[:currency].presence&.upcase || @store.default_currency,
|
|
53
|
+
locale: @params[:locale] || Spree::Current.locale,
|
|
54
|
+
customer_note: @params[:customer_note],
|
|
55
|
+
internal_note: @params[:internal_note],
|
|
56
|
+
metadata: @params[:metadata].to_h,
|
|
57
|
+
token: Spree::GenerateToken.new.call(Spree::Order),
|
|
58
|
+
status: 'draft'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
attrs[:market] = resolve_market if @params[:market_id].present?
|
|
62
|
+
attrs[:channel] = resolve_channel if @params[:channel_id].present?
|
|
63
|
+
attrs[:preferred_stock_location] = resolve_preferred_stock_location if @params[:preferred_stock_location_id].present?
|
|
64
|
+
attrs.compact_blank!
|
|
65
|
+
|
|
66
|
+
@store.orders.new(attrs)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_market
|
|
70
|
+
@store.markets.find_by_param!(@params[:market_id])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolve_channel
|
|
74
|
+
@store.channels.find_by_param!(@params[:channel_id])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def resolve_preferred_stock_location
|
|
78
|
+
Spree::StockLocation.for_store(@store).find_by_param!(@params[:preferred_stock_location_id])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def assign_addresses(order)
|
|
82
|
+
if @params[:use_customer_default_address] && @user
|
|
83
|
+
@user.association(:bill_address).load_target
|
|
84
|
+
@user.association(:ship_address).load_target
|
|
85
|
+
order.bill_address = @user.bill_address&.dup
|
|
86
|
+
order.ship_address = @user.ship_address&.dup
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
assign_address(order, :ship_address, @params[:shipping_address_id], @params[:shipping_address])
|
|
90
|
+
assign_address(order, :bill_address, @params[:billing_address_id], @params[:billing_address])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def assign_address(order, association, address_id, address_attrs)
|
|
94
|
+
if address_id.present?
|
|
95
|
+
address = resolve_user_address(address_id)
|
|
96
|
+
order.public_send(:"#{association}_id=", address.id) if address
|
|
97
|
+
elsif address_attrs.present?
|
|
98
|
+
order.public_send(:"#{association}_attributes=", address_attrs)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def resolve_user_address(address_id)
|
|
103
|
+
return unless @user
|
|
104
|
+
|
|
105
|
+
@user.addresses.find_by_param(address_id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def add_items(order)
|
|
109
|
+
result = Spree::Orders::UpsertItems.call(order: order, items: @params[:items])
|
|
110
|
+
return if result.success?
|
|
111
|
+
|
|
112
|
+
propagate_step_failure!(order, result, fallback: 'Failed to add items to order')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_shipments(order)
|
|
116
|
+
result = Spree::Orders::BuildShipments.call(order: order)
|
|
117
|
+
return if result.success?
|
|
118
|
+
|
|
119
|
+
propagate_step_failure!(order, result, fallback: 'Failed to build shipments')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Surface the failing record's errors on the order so the API response
|
|
123
|
+
# carries an actionable message instead of an empty +processing_error+.
|
|
124
|
+
# Falls back to a static message when neither the record nor the result
|
|
125
|
+
# carry one — better than raising +RecordInvalid+ with an empty errors
|
|
126
|
+
# collection.
|
|
127
|
+
def propagate_step_failure!(order, result, fallback:)
|
|
128
|
+
record = result.value
|
|
129
|
+
if record.respond_to?(:errors) && record.errors.any?
|
|
130
|
+
record.errors.full_messages.each { |msg| order.errors.add(:base, msg) }
|
|
131
|
+
elsif result.error.to_s.present?
|
|
132
|
+
order.errors.add(:base, result.error.to_s)
|
|
133
|
+
else
|
|
134
|
+
order.errors.add(:base, fallback)
|
|
135
|
+
end
|
|
136
|
+
raise ActiveRecord::RecordInvalid, order
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def apply_coupon(order)
|
|
140
|
+
order.coupon_code = @params[:coupon_code]
|
|
141
|
+
handler = Spree::PromotionHandler::Coupon.new(order).apply
|
|
142
|
+
|
|
143
|
+
if handler.successful?
|
|
144
|
+
order.save!
|
|
145
|
+
else
|
|
146
|
+
@discount_application_errors << {
|
|
147
|
+
code: handler.status_code,
|
|
148
|
+
message: handler.error,
|
|
149
|
+
coupon_code: @params[:coupon_code]
|
|
150
|
+
}
|
|
151
|
+
order.coupon_code = nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Orders
|
|
3
|
+
# Admin-side order update.
|
|
4
|
+
#
|
|
5
|
+
# Updates Order attributes plus optional line items via a flat `items: [...]`
|
|
6
|
+
# array (matches POST shape and Store API convention). Standalone from
|
|
7
|
+
# Spree::Carts::Update (storefront).
|
|
8
|
+
class Update
|
|
9
|
+
prepend Spree::ServiceModule::Base
|
|
10
|
+
|
|
11
|
+
def call(order:, params: {})
|
|
12
|
+
@order = order
|
|
13
|
+
@params = params.to_h.deep_symbolize_keys
|
|
14
|
+
|
|
15
|
+
items_param = @params.delete(:items)
|
|
16
|
+
|
|
17
|
+
ApplicationRecord.transaction do
|
|
18
|
+
ship_address_id_before = @order.ship_address_id
|
|
19
|
+
|
|
20
|
+
if @order.update(@params)
|
|
21
|
+
process_items(items_param) if items_param
|
|
22
|
+
else
|
|
23
|
+
return failure(@order, @order.errors.full_messages.to_sentence)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if items_param || @order.ship_address_id != ship_address_id_before
|
|
27
|
+
build_shipments
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@order.update_with_updater!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
success(@order.reload)
|
|
34
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
35
|
+
failure(e.record, e.record.errors.full_messages.to_sentence)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def process_items(items)
|
|
41
|
+
result = Spree::Orders::UpsertItems.call(order: @order, items: items)
|
|
42
|
+
raise ActiveRecord::RecordInvalid, @order if result.failure?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build_shipments
|
|
46
|
+
result = Spree::Orders::BuildShipments.call(order: @order)
|
|
47
|
+
raise ActiveRecord::RecordInvalid, @order if result.failure?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Orders
|
|
3
|
+
# Bulk upsert line items on an order. Mirrors Spree::Carts::UpsertItems
|
|
4
|
+
# but is admin/order-side (separate from the cart pipeline per the 6.0
|
|
5
|
+
# cart/order split — see docs/plans/6.0-cart-order-split.md).
|
|
6
|
+
#
|
|
7
|
+
# For each entry in +items+:
|
|
8
|
+
# - If a line item for the variant already exists -> sets its quantity
|
|
9
|
+
# - If no line item exists -> creates one with the given quantity
|
|
10
|
+
#
|
|
11
|
+
# Order totals are NOT recalculated here. Callers (Spree::Orders::Create
|
|
12
|
+
# and Spree::Orders::Update) are responsible for running shipment
|
|
13
|
+
# rebuilding and a final `order.update_with_updater!` once their
|
|
14
|
+
# full pipeline (items, shipments, coupons) has run.
|
|
15
|
+
class UpsertItems
|
|
16
|
+
prepend Spree::ServiceModule::Base
|
|
17
|
+
|
|
18
|
+
def call(order:, items:)
|
|
19
|
+
items = Array(items)
|
|
20
|
+
return success(order) if items.empty?
|
|
21
|
+
|
|
22
|
+
store = order.store || Spree::Current.store
|
|
23
|
+
|
|
24
|
+
ApplicationRecord.transaction do
|
|
25
|
+
items.each do |item_params|
|
|
26
|
+
item_params = item_params.to_h.deep_symbolize_keys
|
|
27
|
+
variant = resolve_variant(store, item_params[:variant_id])
|
|
28
|
+
next unless variant
|
|
29
|
+
|
|
30
|
+
quantity = (item_params[:quantity] || 1).to_i
|
|
31
|
+
next if quantity <= 0
|
|
32
|
+
|
|
33
|
+
return failure(variant, "#{variant.name} is not available in #{order.currency}") if variant.amount_in(order.currency).nil?
|
|
34
|
+
|
|
35
|
+
line_item = Spree.line_item_by_variant_finder.new.execute(order: order, variant: variant)
|
|
36
|
+
|
|
37
|
+
if line_item
|
|
38
|
+
line_item.quantity = quantity
|
|
39
|
+
line_item.metadata = line_item.metadata.merge(item_params[:metadata].to_h) if item_params[:metadata].present?
|
|
40
|
+
else
|
|
41
|
+
line_item = order.line_items.new(quantity: quantity, variant: variant, options: { currency: order.currency })
|
|
42
|
+
line_item.metadata = item_params[:metadata].to_h if item_params[:metadata].present?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
return failure(line_item) unless line_item.save
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
success(order)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def resolve_variant(store, variant_id)
|
|
55
|
+
return nil if variant_id.blank?
|
|
56
|
+
|
|
57
|
+
variant = store.variants.find_by_param(variant_id)
|
|
58
|
+
|
|
59
|
+
raise ActiveRecord::RecordNotFound.new(
|
|
60
|
+
"Variant '#{variant_id}' not found in this store",
|
|
61
|
+
'Spree::Variant',
|
|
62
|
+
'id',
|
|
63
|
+
variant_id
|
|
64
|
+
) unless variant
|
|
65
|
+
|
|
66
|
+
variant
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -28,6 +28,8 @@ module Spree
|
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
+
# `Spree::Payment#confirm!` honors the payment method's `auto_capture?` setting:
|
|
32
|
+
# auto_capture → complete! + capture_event; otherwise → pend! (auth-only, payment_state=balance_due).
|
|
31
33
|
def handle_success(payment_session, order, metadata)
|
|
32
34
|
order.with_lock do
|
|
33
35
|
# Idempotency: if the session was already completed (by the API
|
|
@@ -36,19 +38,10 @@ module Spree
|
|
|
36
38
|
return success(payment_session)
|
|
37
39
|
end
|
|
38
40
|
|
|
39
|
-
# Ensure payment record exists
|
|
40
41
|
payment = payment_session.find_or_create_payment!(metadata)
|
|
41
|
-
|
|
42
|
-
# Mark payment as completed — the webhook confirms the gateway processed it
|
|
43
|
-
if payment.present? && !payment.completed?
|
|
44
|
-
payment.started_processing! if payment.checkout?
|
|
45
|
-
payment.complete! if payment.can_complete?
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Mark session as completed
|
|
42
|
+
payment.confirm! if payment.present? && !payment.completed?
|
|
49
43
|
payment_session.complete if payment_session.can_complete?
|
|
50
44
|
|
|
51
|
-
# Complete order if not already done
|
|
52
45
|
unless order.reload.completed?
|
|
53
46
|
Spree::Dependencies.carts_complete_service.constantize.call(cart: order)
|
|
54
47
|
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Prices
|
|
3
|
+
# Bulk-writes Spree::Price rows and sweeps stale placeholder rows in
|
|
4
|
+
# one transaction.
|
|
5
|
+
#
|
|
6
|
+
# `spree_prices` is guarded by two partial unique indexes on PG/SQLite
|
|
7
|
+
# (collapsed to one composite index on MySQL):
|
|
8
|
+
# - base prices (price_list_id IS NULL): unique on (variant_id, currency)
|
|
9
|
+
# - overrides (price_list_id IS NOT NULL): unique on (variant_id, currency, price_list_id)
|
|
10
|
+
# A single `upsert_all` can only target one index, so rows ship in two
|
|
11
|
+
# batches — base vs override — each routed to the correct ON CONFLICT.
|
|
12
|
+
#
|
|
13
|
+
# Both indexes are also partial on `amount IS NOT NULL`, so `upsert_all`
|
|
14
|
+
# can't see placeholder rows (amount IS NULL) as conflict targets —
|
|
15
|
+
# filling in a placeholder via upsert inserts a sibling row instead of
|
|
16
|
+
# updating. The post-write sweep removes those.
|
|
17
|
+
class BulkUpsert
|
|
18
|
+
prepend Spree::ServiceModule::Base
|
|
19
|
+
|
|
20
|
+
# Two partial unique indexes guard `spree_prices` on PG/SQLite:
|
|
21
|
+
# - base prices (price_list_id IS NULL): unique on (variant_id, currency)
|
|
22
|
+
# - overrides (price_list_id IS NOT NULL): unique on (variant_id, currency, price_list_id)
|
|
23
|
+
# A single `upsert_all` can only target one index, so base-price rows
|
|
24
|
+
# and override rows ship in separate batches.
|
|
25
|
+
BASE_UNIQUE_BY = %i[variant_id currency].freeze
|
|
26
|
+
OVERRIDE_UNIQUE_BY = %i[variant_id currency price_list_id].freeze
|
|
27
|
+
|
|
28
|
+
# @param rows [Array<Hash>] each row must carry
|
|
29
|
+
# `variant_id`, `currency`, and `amount`; `price_list_id` and
|
|
30
|
+
# `compare_at_amount` are optional. A blank `amount` is treated
|
|
31
|
+
# as "clear this price."
|
|
32
|
+
# @return [Spree::ServiceModule::Result] success carries
|
|
33
|
+
# `{ price_count: N }` — the number of rows passed to
|
|
34
|
+
# `upsert_all`.
|
|
35
|
+
def call(rows:)
|
|
36
|
+
rows = Array(rows).map { |r| r.with_indifferent_access }
|
|
37
|
+
keyed = rows.select { |r| r[:variant_id].present? && r[:currency].present? }
|
|
38
|
+
# PG rejects an upsert with two rows hitting the same unique-key
|
|
39
|
+
# triple in one statement ("ON CONFLICT DO UPDATE command cannot
|
|
40
|
+
# affect row a second time"). Last-write-wins: keep the last
|
|
41
|
+
# occurrence of each triple.
|
|
42
|
+
deduped = keyed.reverse.uniq { |r| [r[:variant_id], r[:currency], r[:price_list_id]] }.reverse
|
|
43
|
+
upsert_rows, clear_rows = deduped.partition { |r| r[:amount].present? }
|
|
44
|
+
|
|
45
|
+
payload = build_payload(upsert_rows)
|
|
46
|
+
affected_keys = deduped.map { |r| [r[:variant_id], r[:currency], r[:price_list_id]] }
|
|
47
|
+
|
|
48
|
+
return success(price_count: 0) if affected_keys.empty?
|
|
49
|
+
|
|
50
|
+
base_rows, override_rows = payload.partition { |r| r[:price_list_id].nil? }
|
|
51
|
+
|
|
52
|
+
Spree::Price.transaction do
|
|
53
|
+
# MySQL treats NULL values as distinct in unique indexes, so
|
|
54
|
+
# `ON DUPLICATE KEY UPDATE` never fires for base prices —
|
|
55
|
+
# `upsert_all` would silently insert a sibling row. Route base
|
|
56
|
+
# rows through a SELECT-then-UPDATE/INSERT path on MySQL only.
|
|
57
|
+
if base_rows.any? && mysql?
|
|
58
|
+
upsert_base_rows_for_mysql(base_rows)
|
|
59
|
+
else
|
|
60
|
+
upsert_batch(base_rows, BASE_UNIQUE_BY)
|
|
61
|
+
end
|
|
62
|
+
upsert_batch(override_rows, OVERRIDE_UNIQUE_BY)
|
|
63
|
+
sweep(affected_keys, clear_rows)
|
|
64
|
+
# `upsert_all` and `delete_all` both skip AR callbacks, so the
|
|
65
|
+
# `Price -> Variant -> Product` `touch:` chain never fires —
|
|
66
|
+
# downstream caches (`cache_key_with_version`) would stay stale.
|
|
67
|
+
# Re-trigger the chain with one `.touch` per affected variant.
|
|
68
|
+
touch_variants(affected_keys.map(&:first).uniq)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
success(price_count: payload.length)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# `update_only` lists only domain columns. Rails adds `updated_at`
|
|
77
|
+
# automatically (when `record_timestamps` is on, which it is for
|
|
78
|
+
# `Spree::Price`); listing it explicitly here produces
|
|
79
|
+
# `SET updated_at = …, updated_at = …` on PG and the statement fails
|
|
80
|
+
# with "multiple assignments to same column".
|
|
81
|
+
def upsert_batch(rows, unique_by)
|
|
82
|
+
return if rows.empty?
|
|
83
|
+
|
|
84
|
+
Spree::Price.upsert_all(
|
|
85
|
+
rows,
|
|
86
|
+
update_only: %i[amount compare_at_amount],
|
|
87
|
+
**upsert_opts(unique_by)
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_payload(rows)
|
|
92
|
+
now = Time.current
|
|
93
|
+
rows.map do |row|
|
|
94
|
+
{
|
|
95
|
+
variant_id: row[:variant_id],
|
|
96
|
+
currency: row[:currency],
|
|
97
|
+
price_list_id: row[:price_list_id],
|
|
98
|
+
amount: parse_amount(row[:amount]),
|
|
99
|
+
compare_at_amount: parse_amount(row[:compare_at_amount]),
|
|
100
|
+
created_at: now,
|
|
101
|
+
updated_at: now
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Parses locale-aware decimal input ("1.234,56" in DE, "1,234.56"
|
|
107
|
+
# in en-US). Numeric values pass through; blank values become nil.
|
|
108
|
+
def parse_amount(value)
|
|
109
|
+
return nil if value.blank?
|
|
110
|
+
return value if value.is_a?(Numeric)
|
|
111
|
+
|
|
112
|
+
Spree::LocalizedNumber.parse(value)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def sweep(affected_keys, clear_rows)
|
|
116
|
+
cleared_keys = clear_rows.map { |r| [r[:variant_id], r[:currency], r[:price_list_id]] }.to_set
|
|
117
|
+
affected_set = affected_keys.to_set
|
|
118
|
+
|
|
119
|
+
candidates = Spree::Price
|
|
120
|
+
.where(
|
|
121
|
+
variant_id: affected_keys.map(&:first).uniq,
|
|
122
|
+
currency: affected_keys.map { |k| k[1] }.uniq,
|
|
123
|
+
price_list_id: affected_keys.map(&:last).uniq
|
|
124
|
+
)
|
|
125
|
+
.pluck(:id, :variant_id, :currency, :price_list_id, :amount)
|
|
126
|
+
|
|
127
|
+
doomed_ids = candidates.filter_map do |id, variant_id, currency, price_list_id, amount|
|
|
128
|
+
key = [variant_id, currency, price_list_id]
|
|
129
|
+
next unless affected_set.include?(key)
|
|
130
|
+
next id if amount.nil?
|
|
131
|
+
next id if cleared_keys.include?(key)
|
|
132
|
+
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
Spree::Price.where(id: doomed_ids).delete_all if doomed_ids.any?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Bumps `updated_at` on the affected variants and their parent
|
|
140
|
+
# products to invalidate `cache_key_with_version`-based caches —
|
|
141
|
+
# `upsert_all` and `delete_all` skip the `Price -> Variant` and
|
|
142
|
+
# `Variant -> Product` `touch:` chains otherwise.
|
|
143
|
+
#
|
|
144
|
+
# We deliberately bypass AR callbacks: invoking `Variant#touch`
|
|
145
|
+
# would fire `after_commit :remove_prices_from_master_variant`,
|
|
146
|
+
# which `delete_all`s the master's prices whenever a non-master
|
|
147
|
+
# sibling has any prices — wiping exactly the rows the bulk
|
|
148
|
+
# upsert just persisted on a freshly-created list. `touch_all`
|
|
149
|
+
# gives us the cache bust without that side effect.
|
|
150
|
+
def touch_variants(variant_ids)
|
|
151
|
+
return if variant_ids.empty?
|
|
152
|
+
|
|
153
|
+
variants = Spree::Variant.where(id: variant_ids)
|
|
154
|
+
product_ids = variants.pluck(:product_id).uniq
|
|
155
|
+
variants.touch_all
|
|
156
|
+
Spree::Product.where(id: product_ids).touch_all if product_ids.any?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# MySQL infers conflict targets from its own unique indexes and
|
|
160
|
+
# rejects an explicit `unique_by`.
|
|
161
|
+
def upsert_opts(unique_by)
|
|
162
|
+
return {} if mysql?
|
|
163
|
+
|
|
164
|
+
{ unique_by: unique_by }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def mysql?
|
|
168
|
+
ActiveRecord::Base.connection.adapter_name == 'Mysql2'
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# MySQL-only path: NULLs are distinct in unique indexes, so
|
|
172
|
+
# `(variant_id, currency, NULL)` doesn't conflict with another
|
|
173
|
+
# `(variant_id, currency, NULL)` — `upsert_all` would insert a
|
|
174
|
+
# sibling instead of updating. Look up existing base rows first,
|
|
175
|
+
# update them one by one, and `insert_all` the rest.
|
|
176
|
+
def upsert_base_rows_for_mysql(rows)
|
|
177
|
+
rows_by_key = rows.index_by { |r| [r[:variant_id], r[:currency]] }
|
|
178
|
+
|
|
179
|
+
Spree::Price.where(
|
|
180
|
+
variant_id: rows.map { |r| r[:variant_id] }.uniq,
|
|
181
|
+
currency: rows.map { |r| r[:currency] }.uniq,
|
|
182
|
+
price_list_id: nil
|
|
183
|
+
).find_each do |price|
|
|
184
|
+
# The `IN (...)` query can return cross-pairs (e.g. `v=1,c=EUR`
|
|
185
|
+
# exists in the DB even though the caller only passed `v=1,c=USD`
|
|
186
|
+
# and `v=2,c=EUR`). Skip rows the caller didn't request.
|
|
187
|
+
row = rows_by_key.delete([price.variant_id, price.currency])
|
|
188
|
+
next unless row
|
|
189
|
+
|
|
190
|
+
price.update_columns(
|
|
191
|
+
amount: row[:amount],
|
|
192
|
+
compare_at_amount: row[:compare_at_amount],
|
|
193
|
+
updated_at: row[:updated_at]
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
Spree::Price.insert_all(rows_by_key.values) if rows_by_key.any?
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -28,7 +28,7 @@ module Spree
|
|
|
28
28
|
new_product.status = :draft
|
|
29
29
|
new_product.name = "COPY OF #{product.name}"
|
|
30
30
|
new_product.taxons = product.taxons
|
|
31
|
-
new_product.
|
|
31
|
+
new_product.channels = product.channels
|
|
32
32
|
new_product.created_at = nil
|
|
33
33
|
new_product.deleted_at = nil
|
|
34
34
|
new_product.updated_at = nil
|
|
@@ -67,14 +67,8 @@ module Spree
|
|
|
67
67
|
end
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
# ensure
|
|
71
|
-
params[:
|
|
72
|
-
|
|
73
|
-
# Preserve taxon associations from other stores
|
|
74
|
-
# Only merge taxon_ids from other stores if taxon_ids are being updated
|
|
75
|
-
if params.key?(:taxon_ids)
|
|
76
|
-
params[:taxon_ids] = merge_taxons_from_other_stores(params[:taxon_ids])
|
|
77
|
-
end
|
|
70
|
+
# ensure the product is owned by a store
|
|
71
|
+
params[:store_id] = store.id if params[:store_id].blank? && product.store_id.blank?
|
|
78
72
|
|
|
79
73
|
# Add empty list for option_type_ids and mark variants as removed if there are no variants and options
|
|
80
74
|
if params[:variants_attributes].blank? && variants_to_remove.any? && !params.key?(:option_type_ids)
|
|
@@ -186,28 +180,6 @@ module Spree
|
|
|
186
180
|
attributes
|
|
187
181
|
end
|
|
188
182
|
|
|
189
|
-
# Merges taxon IDs from other stores with submitted taxon IDs from current store.
|
|
190
|
-
#
|
|
191
|
-
# This prevents the loss of taxon associations from other stores when a product
|
|
192
|
-
# is edited in one store. Each store's taxonomy is independent, so editing
|
|
193
|
-
# categories in Store A should not affect categories in Store B.
|
|
194
|
-
#
|
|
195
|
-
# @param submitted_taxon_ids [Array<String>] Taxon IDs from the current store
|
|
196
|
-
# @return [Array<String>] Combined unique taxon IDs
|
|
197
|
-
def merge_taxons_from_other_stores(submitted_taxon_ids)
|
|
198
|
-
return submitted_taxon_ids if product.new_record?
|
|
199
|
-
|
|
200
|
-
# Get taxon IDs from other stores that should be preserved
|
|
201
|
-
other_stores_taxon_ids = product.taxons
|
|
202
|
-
.joins(:taxonomy)
|
|
203
|
-
.where.not(spree_taxonomies: { store_id: store.id })
|
|
204
|
-
.pluck(:id)
|
|
205
|
-
.map(&:to_s)
|
|
206
|
-
|
|
207
|
-
# Merge with submitted taxon IDs from current store and remove duplicates
|
|
208
|
-
(submitted_taxon_ids + other_stores_taxon_ids).uniq
|
|
209
|
-
end
|
|
210
|
-
|
|
211
183
|
def update_option_value_variants(option_value_params, existing_variant)
|
|
212
184
|
return {} unless option_value_params.present?
|
|
213
185
|
return {} unless can_manage_option_types?
|