spree_core 5.5.3 → 5.6.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 +12 -2
- data/app/helpers/spree/currency_helper.rb +1 -4
- data/app/helpers/spree/locale_helper.rb +18 -13
- data/app/helpers/spree/localized_names_helper.rb +25 -0
- data/app/mailers/spree/admin_user_mailer.rb +71 -0
- data/app/mailers/spree/base_mailer.rb +73 -11
- data/app/mailers/spree/export_mailer.rb +7 -7
- data/app/mailers/spree/import_mailer.rb +19 -0
- data/app/mailers/spree/invitation_mailer.rb +16 -11
- data/app/mailers/spree/report_mailer.rb +6 -6
- data/app/mailers/spree/webhook_mailer.rb +7 -6
- data/app/models/concerns/spree/admin_user_methods.rb +44 -0
- data/app/models/concerns/spree/legacy_multi_store_support.rb +48 -0
- data/app/models/concerns/spree/presentation_translatable.rb +39 -0
- data/app/models/concerns/spree/product_scopes.rb +21 -3
- data/app/models/concerns/spree/single_store_resource.rb +5 -0
- data/app/models/concerns/spree/store_scoped_resource.rb +10 -0
- data/app/models/concerns/spree/stores/markets.rb +9 -1
- data/app/models/concerns/spree/stores/setup.rb +37 -23
- data/app/models/concerns/spree/translatable_resource.rb +95 -5
- data/app/models/concerns/spree/user_methods.rb +27 -2
- data/app/models/spree/ability.rb +5 -4
- data/app/models/spree/allowed_origin.rb +31 -0
- data/app/models/spree/category.rb +12 -2
- data/app/models/spree/channel/gating.rb +67 -0
- data/app/models/spree/channel.rb +1 -0
- data/app/models/spree/classification.rb +12 -0
- data/app/models/spree/country.rb +22 -1
- data/app/models/spree/credit_card.rb +29 -0
- data/app/models/spree/currency.rb +47 -0
- data/app/models/spree/current.rb +12 -1
- data/app/models/spree/customer_group.rb +1 -0
- data/app/models/spree/export.rb +19 -0
- data/app/models/spree/exports/coupon_codes.rb +1 -2
- data/app/models/spree/import.rb +84 -1
- data/app/models/spree/import_row.rb +7 -1
- data/app/models/spree/imports/product_translations.rb +6 -0
- data/app/models/spree/locale.rb +104 -0
- data/app/models/spree/option_type.rb +11 -13
- data/app/models/spree/option_value.rb +5 -17
- data/app/models/spree/order.rb +19 -2
- data/app/models/spree/payment_method.rb +6 -5
- data/app/models/spree/policy.rb +2 -1
- data/app/models/spree/product.rb +23 -2
- data/app/models/spree/promotion/rules/channel.rb +37 -0
- data/app/models/spree/promotion/rules/market.rb +37 -0
- data/app/models/spree/promotion.rb +5 -3
- data/app/models/spree/promotion_handler/promotion_duplicator.rb +1 -1
- data/app/models/spree/role_user.rb +4 -0
- data/app/models/spree/search_provider/meilisearch.rb +22 -9
- data/app/models/spree/setup_task.rb +12 -0
- data/app/models/spree/setup_tasks.rb +86 -0
- data/app/models/spree/shipment.rb +38 -1
- data/app/models/spree/shipment_handler.rb +1 -1
- data/app/models/spree/stock/availability_validator.rb +1 -1
- data/app/models/spree/stock/quantifier.rb +21 -1
- data/app/models/spree/stock_item.rb +2 -1
- data/app/models/spree/stock_location.rb +4 -1
- data/app/models/spree/store.rb +45 -9
- data/app/models/spree/store_payment_method.rb +5 -1
- data/app/models/spree/store_promotion.rb +4 -0
- data/app/models/spree/taxon.rb +112 -9
- data/app/models/spree/taxonomy.rb +1 -1
- data/app/models/spree/translations/batch.rb +104 -0
- data/app/models/spree/variant.rb +29 -2
- data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
- data/app/services/spree/carts/complete.rb +3 -0
- data/app/services/spree/fulfillments/create.rb +259 -0
- data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
- data/app/services/spree/locales.rb +52 -0
- data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
- data/app/services/spree/shipments/update.rb +1 -1
- data/app/services/spree/stock_reservations/reserve.rb +4 -1
- data/app/services/spree/taxons/add_products.rb +3 -0
- data/app/services/spree/taxons/remove_products.rb +3 -0
- data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
- data/app/subscribers/spree/import_email_subscriber.rb +26 -0
- data/app/views/layouts/spree/base_mailer.html.erb +14 -17
- data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
- data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
- data/app/views/spree/export_mailer/export_done.html.erb +6 -14
- data/app/views/spree/import_mailer/import_done.html.erb +12 -0
- data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
- data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
- data/app/views/spree/report_mailer/report_done.html.erb +6 -14
- data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
- data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
- data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
- data/app/views/spree/shared/_mailer_button.html.erb +10 -0
- data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
- data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
- data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
- data/config/initializers/spree_store_setup_tasks.rb +23 -0
- data/config/locales/en.yml +42 -992
- data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
- data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
- data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
- data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
- data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
- data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
- data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
- data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
- data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
- data/db/sample_data/promotions.rb +1 -1
- data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
- data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
- data/lib/spree/core/configuration.rb +1 -0
- data/lib/spree/core/controller_helpers/locale.rb +4 -1
- data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
- data/lib/spree/core/dependencies.rb +3 -0
- data/lib/spree/core/engine.rb +24 -0
- data/lib/spree/core/preferences/preferable.rb +6 -1
- data/lib/spree/core/previews/admin_user_preview.rb +22 -0
- data/lib/spree/core/previews/export_preview.rb +37 -0
- data/lib/spree/core/previews/invitation_preview.rb +47 -0
- data/lib/spree/core/previews/preview_data.rb +52 -0
- data/lib/spree/core/previews/report_preview.rb +40 -0
- data/lib/spree/core/previews/webhook_preview.rb +34 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +22 -1
- data/lib/spree/permitted_attributes.rb +5 -4
- data/lib/spree/testing_support/factories/export_factory.rb +4 -0
- data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
- data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
- data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
- data/lib/spree/translations.rb +113 -0
- data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
- data/lib/tasks/role_users.rake +21 -0
- data/lib/tasks/single_store_associations.rake +65 -0
- data/lib/tasks/taxons.rake +62 -1
- metadata +47 -5
- data/app/models/spree/product/legacy_multi_store_support.rb +0 -40
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Translations
|
|
3
|
+
# Atomically upserts translations across MANY records of (possibly)
|
|
4
|
+
# different translatable resource types in one operation — the domain
|
|
5
|
+
# behind +POST /api/v3/admin/translations/batch+.
|
|
6
|
+
#
|
|
7
|
+
# The input is a flat list of independent registry writes, each naming its
|
|
8
|
+
# own +resource_type+ + +resource_id+ (no nested parent-owns-children
|
|
9
|
+
# payload), so there's no per-model branching. Records are resolved within
|
|
10
|
+
# the current store, then upserted in a single transaction: any failure
|
|
11
|
+
# rolls back the whole batch and surfaces the offending entry's index via a
|
|
12
|
+
# typed {EntryError}.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# Spree::Translations::Batch.new(entries).process! do |record|
|
|
16
|
+
# authorize!(:update, record)
|
|
17
|
+
# end
|
|
18
|
+
class Batch
|
|
19
|
+
include ActiveModel::Model
|
|
20
|
+
|
|
21
|
+
# Raised when the batch payload is empty (or not a list of entries).
|
|
22
|
+
class EmptyError < StandardError; end
|
|
23
|
+
|
|
24
|
+
# Raised when an entry can't be processed — unknown/non-translatable
|
|
25
|
+
# resource type, a record missing in the current store, or an invalid
|
|
26
|
+
# save. Carries the entry +index+ so the caller can map the error back to
|
|
27
|
+
# the offending row.
|
|
28
|
+
class EntryError < StandardError
|
|
29
|
+
attr_reader :index
|
|
30
|
+
|
|
31
|
+
def initialize(message, index)
|
|
32
|
+
@index = index
|
|
33
|
+
super(message)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<Spree.base_class>] records written by the last successful {#process!}
|
|
38
|
+
attr_reader :records
|
|
39
|
+
|
|
40
|
+
# @param entries [Array<Hash>] [{ resource_type:, resource_id:, values: { locale => { field => value } } }]
|
|
41
|
+
def initialize(entries)
|
|
42
|
+
@entries = Array(entries)
|
|
43
|
+
@records = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Distinct +write_<resource>+ scopes a caller needs to authorize this
|
|
47
|
+
# batch — one per resource type present. Lets the API layer gate an
|
|
48
|
+
# API-key request without re-deriving scope names.
|
|
49
|
+
# @return [Array<String>]
|
|
50
|
+
def required_scopes
|
|
51
|
+
@entries.map { |entry| "write_#{entry[:resource_type].to_s.pluralize}" }.uniq
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Upserts every entry in one transaction. Yields each resolved record
|
|
55
|
+
# before it is written so the caller can authorize it; a raised error in
|
|
56
|
+
# the block (or any entry failure) rolls back the whole batch.
|
|
57
|
+
#
|
|
58
|
+
# @yieldparam record [Spree.base_class] the resolved record, pre-write
|
|
59
|
+
# @raise [EmptyError] when there are no entries
|
|
60
|
+
# @raise [EntryError] when an entry can't be resolved or saved
|
|
61
|
+
# @return [Array<Spree.base_class>] the written records
|
|
62
|
+
def process!
|
|
63
|
+
raise EmptyError if @entries.empty?
|
|
64
|
+
|
|
65
|
+
@records = []
|
|
66
|
+
ActiveRecord::Base.transaction do
|
|
67
|
+
@entries.each_with_index do |entry, index|
|
|
68
|
+
record = resolve_record!(entry, index)
|
|
69
|
+
yield record if block_given?
|
|
70
|
+
upsert!(record, entry[:values], index)
|
|
71
|
+
@records << record
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
@records
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def upsert!(record, values, index)
|
|
80
|
+
record.upsert_translations(values)
|
|
81
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
82
|
+
raise EntryError.new(e.record.errors.full_messages.join(', '), index)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def resolve_record!(entry, index)
|
|
86
|
+
klass = resource_class(entry[:resource_type])
|
|
87
|
+
raise EntryError.new("Unknown translatable resource type: #{entry[:resource_type]}", index) if klass.nil?
|
|
88
|
+
|
|
89
|
+
relation = klass.respond_to?(:for_store) ? klass.for_store(Spree::Store.current) : klass
|
|
90
|
+
relation.find_by_prefix_id!(entry[:resource_id])
|
|
91
|
+
rescue ActiveRecord::RecordNotFound
|
|
92
|
+
raise EntryError.new("Resource not found: #{entry[:resource_id]}", index)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Memoized per instance so a batch of N entries doesn't rebuild the
|
|
96
|
+
# registry map N times. A Batch is request-scoped, so dev-mode class
|
|
97
|
+
# reloads are still picked up on the next request.
|
|
98
|
+
def resource_class(token)
|
|
99
|
+
@resource_class_map ||= Hash.new { |h, t| h[t] = Spree::Translations.resource_class(t) }
|
|
100
|
+
@resource_class_map[token]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/app/models/spree/variant.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Spree
|
|
|
13
13
|
|
|
14
14
|
publishes_lifecycle_events
|
|
15
15
|
|
|
16
|
-
MEMOIZED_METHODS = %w(
|
|
16
|
+
MEMOIZED_METHODS = %w(in_stock on_sale backorderable tax_category options_text compare_at_price)
|
|
17
17
|
|
|
18
18
|
DIMENSION_UNITS = %w[mm cm in ft]
|
|
19
19
|
WEIGHT_UNITS = %w[g kg lb oz]
|
|
@@ -88,6 +88,8 @@ module Spree
|
|
|
88
88
|
validates :dimensions_unit, inclusion: { in: DIMENSION_UNITS }, allow_blank: true
|
|
89
89
|
validates :weight_unit, inclusion: { in: WEIGHT_UNITS }, allow_blank: true
|
|
90
90
|
|
|
91
|
+
validates :backorder_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
|
|
92
|
+
|
|
91
93
|
after_create :create_stock_items
|
|
92
94
|
after_create :set_master_out_of_stock, unless: :is_master?
|
|
93
95
|
after_commit :clear_line_items_cache, on: :update
|
|
@@ -238,6 +240,19 @@ module Spree
|
|
|
238
240
|
!discontinued? && product.available?
|
|
239
241
|
end
|
|
240
242
|
|
|
243
|
+
# Returns true if the variant is sold as a pre-order: the product is active
|
|
244
|
+
# and not deleted, the variant is flagged preorderable and not discontinued,
|
|
245
|
+
# and the "ships by" date is open-ended or still in the future.
|
|
246
|
+
# Pre-order relaxes only the publish-date embargo, not the rest of the
|
|
247
|
+
# availability rules. Like backorder, a pre-order can make the variant
|
|
248
|
+
# purchasable beyond on-hand stock; the oversell cap is +backorder_limit+
|
|
249
|
+
# (empty ⇒ unlimited), and it adds the ship-by promise.
|
|
250
|
+
# @return [Boolean] true if the variant is a pre-order
|
|
251
|
+
def preorder?
|
|
252
|
+
!discontinued? && preorderable? && product.active? && !product.deleted? &&
|
|
253
|
+
(preorder_ships_at.nil? || preorder_ships_at > Time.current)
|
|
254
|
+
end
|
|
255
|
+
|
|
241
256
|
# Returns true if the variant is in stock or backorderable.
|
|
242
257
|
# @return [Boolean] true if the variant is in stock or backorderable
|
|
243
258
|
def in_stock_or_backorderable?
|
|
@@ -647,7 +662,19 @@ module Spree
|
|
|
647
662
|
alias is_backorderable? backorderable?
|
|
648
663
|
|
|
649
664
|
def purchasable?
|
|
650
|
-
|
|
665
|
+
in_stock? || oversellable_now?
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Whether the variant can currently be bought by overselling — via
|
|
669
|
+
# backorder or pre-order. Unlimited when +backorder_limit+ is nil (empty =
|
|
670
|
+
# no cap); with a limit, purchasable only while the oversell allowance
|
|
671
|
+
# remains.
|
|
672
|
+
#
|
|
673
|
+
# @return [Boolean]
|
|
674
|
+
def oversellable_now?
|
|
675
|
+
return false unless backorderable? || preorder?
|
|
676
|
+
|
|
677
|
+
backorder_limit.nil? || can_supply?(1)
|
|
651
678
|
end
|
|
652
679
|
|
|
653
680
|
# Shortcut method to determine if inventory tracking is enabled for this variant
|
|
@@ -58,6 +58,9 @@ module Spree
|
|
|
58
58
|
status: product.status,
|
|
59
59
|
sku: product.sku,
|
|
60
60
|
in_stock: product.in_stock?,
|
|
61
|
+
# True when the product has an active pre-order variant, so a
|
|
62
|
+
# scheduled (future-published) launch still surfaces in search.
|
|
63
|
+
preorder: product.preorder?,
|
|
61
64
|
store_ids: Array(product.store_id).map(&:to_s),
|
|
62
65
|
channel_ids: channel_ids_for_store,
|
|
63
66
|
discontinue_on: product.discontinue_on&.to_i || 0,
|
|
@@ -10,6 +10,9 @@ module Spree
|
|
|
10
10
|
def call(cart:)
|
|
11
11
|
return success(cart) if cart.completed?
|
|
12
12
|
return failure(cart, 'Order is canceled') if cart.canceled?
|
|
13
|
+
# Enforced here (not only in the controller) so every completion path —
|
|
14
|
+
# API, payment-session webhook — honors the channel's guest-checkout gate.
|
|
15
|
+
return failure(cart, Spree.t(:guest_checkout_not_allowed)) if cart.guest_checkout_disallowed?
|
|
13
16
|
|
|
14
17
|
cart.with_lock do
|
|
15
18
|
process_payments!(cart) if cart.payment_required?
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Fulfillments
|
|
3
|
+
# Manually creates a fulfillment (Spree::Shipment) on a completed order,
|
|
4
|
+
# bypassing order routing. Moves the requested quantities of each line
|
|
5
|
+
# item's not-yet-shipped inventory units out of their current shipments
|
|
6
|
+
# into the new fulfillment, mirroring externally-managed fulfillment
|
|
7
|
+
# (3PL, courier API, drop-shipping) back into Spree.
|
|
8
|
+
#
|
|
9
|
+
# Stock bookkeeping follows the split/transfer semantics: when the source
|
|
10
|
+
# and target stock locations differ, moved quantities are restocked at the
|
|
11
|
+
# source and unstocked at the target. Source shipments left empty are
|
|
12
|
+
# destroyed and their cost and selected delivery method carry over to the
|
|
13
|
+
# new fulfillment, unless the caller provides its own +cost+ /
|
|
14
|
+
# +delivery_method+.
|
|
15
|
+
#
|
|
16
|
+
# The resulting cost is frozen only for fulfillments registered with
|
|
17
|
+
# status: 'shipped' (rate refresh skips shipped shipments). Pending/ready
|
|
18
|
+
# fulfillments participate in the standard rate machinery — the order
|
|
19
|
+
# updater re-prices them from the delivery method calculators on the next
|
|
20
|
+
# recalculation, exactly like shipments created via split/transfer.
|
|
21
|
+
class Create
|
|
22
|
+
prepend Spree::ServiceModule::Base
|
|
23
|
+
|
|
24
|
+
# @param order [Spree::Order] completed order to fulfill
|
|
25
|
+
# @param stock_location [Spree::StockLocation] location the fulfillment ships from
|
|
26
|
+
# @param items [Array<Hash>, nil] `[{ line_item: Spree::LineItem, quantity: Integer }]`;
|
|
27
|
+
# nil fulfills every not-yet-shipped unit on the order
|
|
28
|
+
# @param tracking [String, nil] carrier tracking number
|
|
29
|
+
# @param delivery_method [Spree::ShippingMethod, nil] carrier; stored as the selected rate.
|
|
30
|
+
# Defaults to the delivery method of the drained source fulfillment(s)
|
|
31
|
+
# @param cost [String, Numeric, nil] explicit shipping cost (e.g. the 3PL's price).
|
|
32
|
+
# Defaults to the summed cost of the drained source fulfillment(s), keeping the
|
|
33
|
+
# order total unchanged; an explicit cost changes the order total and payment state.
|
|
34
|
+
# Guaranteed to persist only with status: 'shipped' — pending fulfillments are
|
|
35
|
+
# re-priced by the rate engine (see class docs)
|
|
36
|
+
# @param status [String, nil] pass 'shipped' to register an already-shipped fulfillment
|
|
37
|
+
# @param metadata [Hash, nil] metadata stored on the fulfillment
|
|
38
|
+
# @return [Spree::ServiceModule::Result] the created shipment on success
|
|
39
|
+
def call(order:, stock_location:, items: nil, tracking: nil, delivery_method: nil, cost: nil, status: nil, metadata: nil)
|
|
40
|
+
return failure(nil, Spree.t('fulfillments.errors.invalid_status')) unless status.nil? || status == 'shipped'
|
|
41
|
+
|
|
42
|
+
cost = parse_cost(cost)
|
|
43
|
+
return cost if cost.is_a?(Spree::ServiceModule::Result)
|
|
44
|
+
|
|
45
|
+
fulfillment = nil
|
|
46
|
+
|
|
47
|
+
# The order row is locked before reading fulfillable units so
|
|
48
|
+
# concurrent creations (e.g. duplicate carrier webhooks) validate and
|
|
49
|
+
# move units against a serialized snapshot. The API layer already
|
|
50
|
+
# serializes via with_order_lock; this covers direct service callers.
|
|
51
|
+
ActiveRecord::Base.transaction do
|
|
52
|
+
order.lock!
|
|
53
|
+
|
|
54
|
+
return failure(nil, Spree.t('fulfillments.errors.order_not_completed')) unless order.completed?
|
|
55
|
+
return failure(nil, Spree.t('fulfillments.errors.order_canceled')) if order.canceled?
|
|
56
|
+
|
|
57
|
+
units_by_line_item = fulfillable_units(order)
|
|
58
|
+
|
|
59
|
+
requested = normalize_items(order, items, units_by_line_item)
|
|
60
|
+
return requested if requested.is_a?(Spree::ServiceModule::Result)
|
|
61
|
+
|
|
62
|
+
fulfillment = order.shipments.new(
|
|
63
|
+
stock_location: stock_location,
|
|
64
|
+
address_id: order.ship_address_id,
|
|
65
|
+
tracking: tracking
|
|
66
|
+
)
|
|
67
|
+
fulfillment.metadata = metadata if metadata.present?
|
|
68
|
+
fulfillment.save!
|
|
69
|
+
|
|
70
|
+
source_shipments = move_units(order, fulfillment, requested, units_by_line_item)
|
|
71
|
+
inherited = destroy_drained_shipments(source_shipments, capture_delivery_method: delivery_method.nil?)
|
|
72
|
+
attach_cost_and_rate(fulfillment, delivery_method, cost, inherited)
|
|
73
|
+
|
|
74
|
+
if status == 'shipped'
|
|
75
|
+
mark_shipped(fulfillment)
|
|
76
|
+
else
|
|
77
|
+
fulfillment.update!(order)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
order.reload.update_with_updater!
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
success(fulfillment.reload)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Units that can still be moved into a manual fulfillment: on-hand or
|
|
89
|
+
# backordered units sitting in shipments that haven't shipped or been
|
|
90
|
+
# canceled (canceled shipments already restocked their stock). Loaded
|
|
91
|
+
# in one query, on-hand first so moved units stay shippable, grouped
|
|
92
|
+
# by line item for both validation and moving.
|
|
93
|
+
def fulfillable_units(order)
|
|
94
|
+
order.inventory_units.
|
|
95
|
+
on_hand_or_backordered.
|
|
96
|
+
joins(:shipment).
|
|
97
|
+
merge(Spree::Shipment.ready_or_pending).
|
|
98
|
+
preload(:shipment, :variant).
|
|
99
|
+
order(Arel.sql("CASE WHEN #{Spree::InventoryUnit.table_name}.state = 'on_hand' THEN 0 ELSE 1 END"), :id).
|
|
100
|
+
group_by(&:line_item_id)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def normalize_items(order, items, units_by_line_item)
|
|
104
|
+
available_for = ->(line_item) { units_by_line_item.fetch(line_item.id, []).sum(&:quantity) }
|
|
105
|
+
|
|
106
|
+
if items.nil?
|
|
107
|
+
derived = order.line_items.filter_map do |line_item|
|
|
108
|
+
quantity = available_for.call(line_item)
|
|
109
|
+
{ line_item: line_item, quantity: quantity } if quantity.positive?
|
|
110
|
+
end
|
|
111
|
+
return failure(nil, Spree.t('fulfillments.errors.no_items_to_fulfill')) if derived.empty?
|
|
112
|
+
|
|
113
|
+
return derived
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
return failure(nil, Spree.t('fulfillments.errors.no_items_to_fulfill')) if items.empty?
|
|
117
|
+
|
|
118
|
+
# Merge duplicate line item entries, then validate quantities.
|
|
119
|
+
merged = items.group_by { |item| item[:line_item].id }.values.map do |grouped|
|
|
120
|
+
{ line_item: grouped.first[:line_item], quantity: grouped.sum { |item| item[:quantity].to_i } }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
merged.each do |item|
|
|
124
|
+
line_item = item[:line_item]
|
|
125
|
+
quantity = item[:quantity]
|
|
126
|
+
|
|
127
|
+
unless quantity.positive?
|
|
128
|
+
return failure(nil, Spree.t('fulfillments.errors.invalid_quantity', item: line_item.prefixed_id))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
available = available_for.call(line_item)
|
|
132
|
+
if quantity > available
|
|
133
|
+
return failure(
|
|
134
|
+
nil,
|
|
135
|
+
Spree.t('fulfillments.errors.insufficient_quantity',
|
|
136
|
+
item: line_item.prefixed_id, requested: quantity, available: available)
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
merged
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Moves the requested quantities into the fulfillment, on-hand units
|
|
145
|
+
# first so the new fulfillment is shippable whenever possible. Restocks
|
|
146
|
+
# the source location and unstocks the target for tracked variants when
|
|
147
|
+
# the locations differ, keeping stock levels truthful about where the
|
|
148
|
+
# goods actually leave from.
|
|
149
|
+
#
|
|
150
|
+
# @return [Array<Spree::Shipment>] the shipments units were taken from
|
|
151
|
+
def move_units(order, fulfillment, requested, units_by_line_item)
|
|
152
|
+
source_shipments = []
|
|
153
|
+
stock_moves = Hash.new(0)
|
|
154
|
+
|
|
155
|
+
requested.each do |item|
|
|
156
|
+
remaining = item[:quantity]
|
|
157
|
+
|
|
158
|
+
units_by_line_item.fetch(item[:line_item].id, []).each do |unit|
|
|
159
|
+
break if remaining.zero?
|
|
160
|
+
|
|
161
|
+
take = [unit.quantity, remaining].min
|
|
162
|
+
source_shipments << unit.shipment
|
|
163
|
+
stock_moves[[unit.shipment, unit.variant]] += take
|
|
164
|
+
|
|
165
|
+
target = fulfillment.inventory_units.find_or_initialize_by(
|
|
166
|
+
state: unit.state,
|
|
167
|
+
variant_id: unit.variant_id,
|
|
168
|
+
line_item_id: unit.line_item_id,
|
|
169
|
+
order_id: order.id
|
|
170
|
+
)
|
|
171
|
+
target.pending = unit.pending
|
|
172
|
+
# The quantity column has a database default of 1, so a fresh
|
|
173
|
+
# record must be set to the moved quantity, not incremented.
|
|
174
|
+
target.quantity = target.new_record? ? take : target.quantity + take
|
|
175
|
+
target.save!
|
|
176
|
+
|
|
177
|
+
if take == unit.quantity
|
|
178
|
+
unit.destroy!
|
|
179
|
+
else
|
|
180
|
+
unit.update!(quantity: unit.quantity - take)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
remaining -= take
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
stock_moves.each do |(source_shipment, variant), quantity|
|
|
188
|
+
next unless variant.track_inventory?
|
|
189
|
+
next if source_shipment.stock_location_id == fulfillment.stock_location_id
|
|
190
|
+
|
|
191
|
+
source_shipment.stock_location.restock(variant, quantity, source_shipment)
|
|
192
|
+
fulfillment.stock_location.unstock(variant, quantity, fulfillment)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
source_shipments.uniq
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Destroys fully drained source shipments, capturing what the new
|
|
199
|
+
# fulfillment inherits from them — the summed cost and the first
|
|
200
|
+
# selected delivery method, read before destroy since the rates are
|
|
201
|
+
# deleted along with the shipment. The delivery method lookup costs
|
|
202
|
+
# queries, so it is skipped when the caller provided its own.
|
|
203
|
+
#
|
|
204
|
+
# @return [Hash] `{ cost: BigDecimal, delivery_method: Spree::ShippingMethod or nil }`
|
|
205
|
+
def destroy_drained_shipments(source_shipments, capture_delivery_method:)
|
|
206
|
+
inherited = { cost: 0, delivery_method: nil }
|
|
207
|
+
|
|
208
|
+
source_shipments.each do |shipment|
|
|
209
|
+
next unless shipment.inventory_units.sum(:quantity).zero?
|
|
210
|
+
|
|
211
|
+
inherited[:cost] += shipment.cost
|
|
212
|
+
inherited[:delivery_method] ||= shipment.shipping_method if capture_delivery_method
|
|
213
|
+
shipment.destroy!
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
inherited
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The fulfillment inherits the cost and carrier of the shipments it
|
|
220
|
+
# replaced — keeping the order total (and thus payment state) unchanged —
|
|
221
|
+
# unless the caller provides its own. Frozen once shipped; see the class
|
|
222
|
+
# docs for pending-path re-pricing. The carrier rides along as a
|
|
223
|
+
# selected rate.
|
|
224
|
+
def attach_cost_and_rate(fulfillment, delivery_method, cost, inherited)
|
|
225
|
+
effective_cost = cost || inherited[:cost]
|
|
226
|
+
method = delivery_method || inherited[:delivery_method]
|
|
227
|
+
|
|
228
|
+
fulfillment.update_columns(cost: effective_cost) if effective_cost.positive?
|
|
229
|
+
fulfillment.add_shipping_method(method, true) if method
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Strict decimal parsing (same semantics as Shipment#cost=, which the
|
|
233
|
+
# update_columns freeze path bypasses) — the lenient LocalizedNumber
|
|
234
|
+
# would turn garbage into 0, and 0 is a legal cost here.
|
|
235
|
+
#
|
|
236
|
+
# @return [BigDecimal, Numeric, nil] nil when no cost was given (blank
|
|
237
|
+
# counts as omitted); a failure Result for malformed or negative input
|
|
238
|
+
def parse_cost(cost)
|
|
239
|
+
return if cost.blank?
|
|
240
|
+
|
|
241
|
+
parsed = cost.is_a?(String) ? BigDecimal(cost.strip) : cost
|
|
242
|
+
return failure(nil, Spree.t('fulfillments.errors.invalid_cost')) if parsed.negative?
|
|
243
|
+
|
|
244
|
+
parsed
|
|
245
|
+
rescue ArgumentError
|
|
246
|
+
failure(nil, Spree.t('fulfillments.errors.invalid_cost'))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Registers an externally-completed fulfillment: backorders are filled
|
|
250
|
+
# (the external location evidently had the goods) and the paid-order
|
|
251
|
+
# readiness gate is bypassed deliberately — the goods already left.
|
|
252
|
+
def mark_shipped(fulfillment)
|
|
253
|
+
fulfillment.inventory_units.backordered.each(&:fill_backorder!)
|
|
254
|
+
fulfillment.update_columns(state: 'ready') unless fulfillment.ready?
|
|
255
|
+
fulfillment.reload.ship!
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -9,7 +9,13 @@ module Spree
|
|
|
9
9
|
object[locale] = [store_default_locale]
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# Pass the store default as the terminal default so EVERY locale —
|
|
13
|
+
# including regional variants not in `supported_locales_list` (e.g.
|
|
14
|
+
# `pt-BR` when only `pt` is configured) — ultimately falls back to it.
|
|
15
|
+
# The store default resolves to the populated DB column via Mobility's
|
|
16
|
+
# `column_fallback`, so translated reads never return nil for a locale
|
|
17
|
+
# that lacks a translation row.
|
|
18
|
+
fallbacks_instance = I18n::Locale::Fallbacks.new(store_default_locale, fallbacks)
|
|
13
19
|
|
|
14
20
|
Mobility.store_based_fallbacks = fallbacks_instance
|
|
15
21
|
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Locales
|
|
3
|
+
# Canonical set of locale codes a merchant may translate **data** into
|
|
4
|
+
# (Mobility-backed content such as product names, descriptions, taxons,
|
|
5
|
+
# option values, etc.).
|
|
6
|
+
#
|
|
7
|
+
# This is deliberately **independent of the admin/storefront UI translation
|
|
8
|
+
# set** (`Spree.available_locales`, which reflects which `spree_i18n` UI
|
|
9
|
+
# bundles happen to be installed). In a headless setup the storefront ships
|
|
10
|
+
# its own UI translations, so the languages a merchant can store content in
|
|
11
|
+
# must not be constrained by which admin chrome translations exist.
|
|
12
|
+
#
|
|
13
|
+
# Codes follow BCP-47 casing (lowercase language, uppercase region —
|
|
14
|
+
# `pt-BR`, `zh-CN`) so the browser's `Intl.DisplayNames` resolves a clean
|
|
15
|
+
# localized label for each. The list is the full ISO 639-1 base-language set
|
|
16
|
+
# plus the regional variants that matter for commerce (British vs. US
|
|
17
|
+
# English, Brazilian vs. European Portuguese, Simplified vs. Traditional
|
|
18
|
+
# Chinese, etc.).
|
|
19
|
+
#
|
|
20
|
+
# The underlying translation tables accept any locale string, so this list
|
|
21
|
+
# governs only what the locale pickers offer — it is not a hard storage
|
|
22
|
+
# constraint.
|
|
23
|
+
ALL = %w[
|
|
24
|
+
aa ab ae af ak am an ar as av ay az
|
|
25
|
+
ba be bg bh bi bm bn bo br bs
|
|
26
|
+
ca ce ch co cr cs cu cv cy
|
|
27
|
+
da de de-AT de-CH dv dz
|
|
28
|
+
ee el en en-AU en-CA en-GB en-IN en-NZ eo es es-419 es-MX et eu
|
|
29
|
+
fa ff fi fj fo fr fr-CA fy
|
|
30
|
+
ga gd gl gn gu gv
|
|
31
|
+
ha he hi ho hr ht hu hy hz
|
|
32
|
+
ia id ie ig ii ik io is it iu
|
|
33
|
+
ja jv
|
|
34
|
+
ka kg ki kj kk kl km kn ko kr ks ku kv kw ky
|
|
35
|
+
la lb lg li ln lo lt lu lv
|
|
36
|
+
mg mh mi mk ml mn mr ms mt my
|
|
37
|
+
na nb nd ne ng nl nn no nr nv ny
|
|
38
|
+
oc oj om or os
|
|
39
|
+
pa pi pl ps pt pt-BR pt-PT
|
|
40
|
+
qu
|
|
41
|
+
rm rn ro ru rw
|
|
42
|
+
sa sc sd se sg si sk sl sm sn so sq sr ss st su sv sw
|
|
43
|
+
ta te tg th ti tk tl tn to tr ts tt tw ty
|
|
44
|
+
ug uk ur uz
|
|
45
|
+
ve vi vo
|
|
46
|
+
wa wo
|
|
47
|
+
xh
|
|
48
|
+
yi yo
|
|
49
|
+
za zh-CN zh-HK zh-TW zu
|
|
50
|
+
].freeze
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -7,6 +7,12 @@ module Spree
|
|
|
7
7
|
# from other stores are preserved. This prevents accidental data loss when a store
|
|
8
8
|
# admin updates product categories in their store without affecting other stores.
|
|
9
9
|
#
|
|
10
|
+
# Variant removal is opt-in: only variants explicitly listed in the
|
|
11
|
+
# `removed_variant_ids` param are marked for destruction (or collected for
|
|
12
|
+
# discontinuation when they have completed orders). Variants merely absent from
|
|
13
|
+
# `variants_attributes` are left untouched, so a partially rendered or broken
|
|
14
|
+
# form can never silently mass-delete variants.
|
|
15
|
+
#
|
|
10
16
|
# @example
|
|
11
17
|
# service = Spree::Products::PrepareNestedAttributes.new(
|
|
12
18
|
# product,
|
|
@@ -28,10 +34,13 @@ module Spree
|
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def call
|
|
37
|
+
extract_removed_variant_ids
|
|
38
|
+
|
|
31
39
|
if params[:variants_attributes]
|
|
32
40
|
params[:variants_attributes].each do |key, variant_params|
|
|
33
41
|
existing_variant = variant_params[:id].presence && @product.variants.find_by(id: variant_params[:id])
|
|
34
|
-
|
|
42
|
+
# a re-submitted variant always wins over a removal request
|
|
43
|
+
variants_to_remove.delete(variant_params[:id].to_s) if variant_params[:id].present?
|
|
35
44
|
|
|
36
45
|
variant_params.delete(:price) # remove legacy price param
|
|
37
46
|
|
|
@@ -72,17 +81,20 @@ module Spree
|
|
|
72
81
|
# ensure the product is owned by a store
|
|
73
82
|
params[:store_id] = store.id if params[:store_id].blank? && product.store_id.blank?
|
|
74
83
|
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
# The variants matrix was emptied: no variant rows re-submitted, removals sent instead.
|
|
85
|
+
# Option types are detached only when the removal list covers every variant, so a
|
|
86
|
+
# partial (possibly broken) submission never turns the product into a simple one.
|
|
87
|
+
# Variants kept alive by discontinuation still count as removed from the matrix,
|
|
88
|
+
# so the detachment can't hinge on any of them yielding a `_destroy` row.
|
|
89
|
+
if params[:variants_attributes].blank? && variants_to_remove.any? && can_remove_variants?
|
|
90
|
+
attributes = removed_variants_attributes
|
|
79
91
|
|
|
80
|
-
|
|
81
|
-
variant_ids_to_destroy.each_with_index do |variant_id, index|
|
|
82
|
-
params[:variants_attributes][index.to_s] = { id: variant_id, _destroy: '1' }
|
|
83
|
-
end
|
|
92
|
+
params[:option_type_ids] = [] if removing_all_variants? && !params.key?(:option_type_ids)
|
|
84
93
|
|
|
85
|
-
|
|
94
|
+
if attributes.any?
|
|
95
|
+
params[:variants_attributes] = attributes
|
|
96
|
+
params[:variants_attributes].permit!
|
|
97
|
+
end
|
|
86
98
|
end
|
|
87
99
|
|
|
88
100
|
params
|
|
@@ -116,8 +128,24 @@ module Spree
|
|
|
116
128
|
@product_option_types_to_remove ||= product.product_option_type_ids
|
|
117
129
|
end
|
|
118
130
|
|
|
131
|
+
# Pulls `removed_variant_ids` out of the params so it never reaches Product#update.
|
|
132
|
+
# Must run before any `variants_to_remove` access.
|
|
133
|
+
def extract_removed_variant_ids
|
|
134
|
+
@removed_variant_ids = Array(params.delete(:removed_variant_ids)).map(&:to_s)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Only variants the client explicitly asked to remove, and only ones that
|
|
138
|
+
# actually belong to this product.
|
|
119
139
|
def variants_to_remove
|
|
120
|
-
@variants_to_remove ||=
|
|
140
|
+
@variants_to_remove ||= (@removed_variant_ids || []).uniq & all_variant_ids
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def all_variant_ids
|
|
144
|
+
@all_variant_ids ||= product.variant_ids.map(&:to_s)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def removing_all_variants?
|
|
148
|
+
(all_variant_ids - variants_to_remove).empty?
|
|
121
149
|
end
|
|
122
150
|
|
|
123
151
|
def can_update_prices?
|
|
@@ -142,7 +170,7 @@ module Spree
|
|
|
142
170
|
populate_variants_to_discontinue
|
|
143
171
|
|
|
144
172
|
attributes = {}
|
|
145
|
-
last_index = params[:variants_attributes].keys
|
|
173
|
+
last_index = params[:variants_attributes].presence&.keys&.map(&:to_i)&.max || -1
|
|
146
174
|
variant_ids_to_destroy.each_with_index do |variant_id, index|
|
|
147
175
|
attributes[(last_index + 1 + index).to_s] = { id: variant_id, _destroy: '1' }
|
|
148
176
|
end
|
|
@@ -8,7 +8,7 @@ module Spree
|
|
|
8
8
|
ActiveRecord::Base.transaction do
|
|
9
9
|
return failure(shipment) unless shipment.update(shipment_attributes)
|
|
10
10
|
|
|
11
|
-
if shipment_attributes.key?(:selected_shipping_rate_id)
|
|
11
|
+
if shipment_attributes.key?(:selected_shipping_rate_id) || shipment_attributes.key?(:selected_delivery_rate_id)
|
|
12
12
|
order = shipment.order
|
|
13
13
|
|
|
14
14
|
# Changing the selected Shipping Rate won't update the cost (for now)
|
|
@@ -65,7 +65,10 @@ module Spree
|
|
|
65
65
|
next unless variant&.should_track_inventory?
|
|
66
66
|
|
|
67
67
|
stock_item = select_stock_item(variant)
|
|
68
|
-
|
|
68
|
+
# Backorderable and pre-order variants oversell past count_on_hand,
|
|
69
|
+
# so a reservation capped at on-hand stock would wrongly reject them;
|
|
70
|
+
# their cap is enforced by Stock::Quantifier#can_supply? instead.
|
|
71
|
+
next if stock_item.nil? || stock_item.backorderable? || variant.preorder?
|
|
69
72
|
|
|
70
73
|
[line_item, stock_item]
|
|
71
74
|
end
|
|
@@ -33,6 +33,9 @@ module Spree
|
|
|
33
33
|
product_ids = products.pluck(:id)
|
|
34
34
|
taxon_ids.each { |id| Spree::Taxon.reset_counters(id, :classifications) }
|
|
35
35
|
product_ids.each { |id| Spree::Product.reset_counters(id, :classifications) }
|
|
36
|
+
# Recompute the descendant-inclusive products_count for the taxons and
|
|
37
|
+
# their ancestors (bulk insert skips Classification callbacks).
|
|
38
|
+
Spree::Taxon.recalculate_products_count(taxon_ids)
|
|
36
39
|
|
|
37
40
|
# clear cache & index products
|
|
38
41
|
Spree::Product.where(id: product_ids).touch_all
|
|
@@ -48,6 +48,9 @@ module Spree
|
|
|
48
48
|
# update counter caches
|
|
49
49
|
taxon_ids.each { |id| Spree::Taxon.reset_counters(id, :classifications) }
|
|
50
50
|
product_ids.each { |id| Spree::Product.reset_counters(id, :classifications) }
|
|
51
|
+
# Recompute the descendant-inclusive products_count for the taxons and
|
|
52
|
+
# their ancestors (delete_all skips Classification callbacks).
|
|
53
|
+
Spree::Taxon.recalculate_products_count(taxon_ids)
|
|
51
54
|
|
|
52
55
|
# clear cache & index products
|
|
53
56
|
Spree::Product.where(id: product_ids).touch_all
|