spree_cm_commissioner 2.12.3 → 2.12.4
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/Gemfile.lock +1 -1
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert_job.rb +47 -0
- data/app/services/spree_cm_commissioner/inventory_holds/convert.rb +44 -23
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert.rb +96 -0
- data/app/services/spree_cm_commissioner/redis_stock/unstock.rb +4 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f4502090937a0e1728668be217bf74bf24b37a424b3b412c1df11e061e72268c
|
|
4
|
+
data.tar.gz: bca33ab83038577e339e01d24cc008c48d71e3af09a3131aa102f35d70433644
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca38d3fff9dd926ed84e6bf3ff0565d9af7b8fa6e816448b386fb460a757fdb7b31788dab65d287bedcaba869cd47b4351960bb8ddf0eef86c05caf3db118257
|
|
7
|
+
data.tar.gz: ea4d544a09afe3fe32fce93f52587ffd1b25c0cf986c2ec94a291a2e07e2744d674acb6ef6c0c6a2886303360318d9b9f0d3cfabc2427a6fe775b7e025313e49
|
data/Gemfile.lock
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module InventoryItems
|
|
3
|
+
class BulkAdjustQuantitiesOnConvertJob < ApplicationUniqueJob
|
|
4
|
+
include SpreeCmCommissioner::IdempotentJob
|
|
5
|
+
|
|
6
|
+
queue_as :inventory_item
|
|
7
|
+
|
|
8
|
+
def perform(options = {})
|
|
9
|
+
with_idempotency(idempotency_key_for(options)) do
|
|
10
|
+
bulk_adjust_quantities_on_convert(options)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Dedup on the order + the exact adjustment set, same shape as BulkAdjustQuantitiesOnHoldJob.
|
|
17
|
+
# An explicit key wins; with no order_id we fall back to the per-enqueue job_id (returning
|
|
18
|
+
# nil lets with_idempotency apply that default).
|
|
19
|
+
def idempotency_key_for(options)
|
|
20
|
+
return options[:idempotency_key] if options[:idempotency_key].present?
|
|
21
|
+
return nil if options[:order_id].blank?
|
|
22
|
+
|
|
23
|
+
"order:#{options[:order_id]}:convert:#{adjustments_fingerprint(options[:inventory_id_and_quantities])}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Readable, order-independent fingerprint of the adjustment payload, e.g. "1=2,2=-1:locked".
|
|
27
|
+
def adjustments_fingerprint(adjustments)
|
|
28
|
+
Array(adjustments)
|
|
29
|
+
.map { |adj| adj.with_indifferent_access.values_at(:inventory_id, :quantity, :locked) }
|
|
30
|
+
.sort_by { |inventory_id, quantity, locked| [inventory_id.to_s, quantity.to_s, locked.to_s] }
|
|
31
|
+
.map { |inventory_id, quantity, locked| locked ? "#{inventory_id}=#{quantity}:locked" : "#{inventory_id}=#{quantity}" }
|
|
32
|
+
.join(',')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# :order_id, :inventory_id_and_quantities, :caller_source
|
|
36
|
+
def bulk_adjust_quantities_on_convert(options = {})
|
|
37
|
+
raise ArgumentError, 'order_id is required' if options[:order_id].blank?
|
|
38
|
+
raise ArgumentError, 'inventory_id_and_quantities is required' if options[:inventory_id_and_quantities].blank?
|
|
39
|
+
|
|
40
|
+
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnConvert.call(
|
|
41
|
+
inventory_id_and_quantities: options[:inventory_id_and_quantities],
|
|
42
|
+
caller_source: options[:caller_source]
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -14,8 +14,8 @@ module SpreeCmCommissioner
|
|
|
14
14
|
|
|
15
15
|
unless hold.finalized?
|
|
16
16
|
hold.update!(status: :converted)
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
convert_hold_redis!(order)
|
|
18
|
+
enqueue_sync_inventory(order)
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -28,8 +28,19 @@ module SpreeCmCommissioner
|
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
# Moves a held unit to a permanent sale by decrementing the hold counter AND the stock
|
|
32
|
+
# counter together, in one atomic Lua call. These used to be two separate Redis round trips
|
|
33
|
+
# (hold decrement here, stock decrement in RedisStock::Unstock, called a moment later from
|
|
34
|
+
# OrderStateMachine#unstock_inventory!) — a concurrent Acquire could read Redis in the gap
|
|
35
|
+
# between them and see the hold count already down but the stock count not yet down, i.e. a
|
|
36
|
+
# phantom unit of "available" capacity that was never actually freed, letting one order too
|
|
37
|
+
# many acquire a hold.
|
|
38
|
+
#
|
|
39
|
+
# RedisStock::Unstock skips any line item that should_hold_inventory? for exactly this reason
|
|
40
|
+
# — this method is now the only place their stock key gets decremented.
|
|
41
|
+
def convert_hold_redis!(order)
|
|
42
|
+
stock_keys = []
|
|
43
|
+
hold_keys = []
|
|
33
44
|
quantities = []
|
|
34
45
|
warmable_items = []
|
|
35
46
|
|
|
@@ -42,34 +53,44 @@ module SpreeCmCommissioner
|
|
|
42
53
|
|
|
43
54
|
# Skip .active scope: holds on past-dated items must also be cleared (e.g. delayed conversion after trip date).
|
|
44
55
|
li.inventory_items.each do |item|
|
|
45
|
-
|
|
56
|
+
stock_keys << item.redis_key(locked: locked)
|
|
57
|
+
hold_keys << item.redis_hold_key(locked: locked)
|
|
46
58
|
quantities << li.quantity
|
|
47
59
|
warmable_items << item
|
|
48
60
|
end
|
|
49
61
|
end
|
|
50
62
|
|
|
51
|
-
return if
|
|
52
|
-
|
|
53
|
-
clear_hold_lua_script = <<~LUA
|
|
54
|
-
local keys = KEYS
|
|
55
|
-
local quantities = ARGV
|
|
56
|
-
for i, key in ipairs(keys) do
|
|
57
|
-
redis.call('DECRBY', key, tonumber(quantities[i]))
|
|
58
|
-
end
|
|
59
|
-
return 1
|
|
60
|
-
LUA
|
|
63
|
+
return if hold_keys.empty?
|
|
61
64
|
|
|
62
65
|
SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(warmable_items).call
|
|
66
|
+
|
|
67
|
+
interleaved_keys = stock_keys.zip(hold_keys).flatten
|
|
63
68
|
SpreeCmCommissioner.inventory_redis_pool.with do |redis|
|
|
64
|
-
redis.eval(
|
|
69
|
+
redis.eval(convert_hold_lua_script, keys: interleaved_keys, argv: quantities)
|
|
65
70
|
end
|
|
66
71
|
end
|
|
67
72
|
|
|
68
|
-
def
|
|
69
|
-
|
|
73
|
+
def convert_hold_lua_script
|
|
74
|
+
<<~LUA
|
|
75
|
+
-- KEYS = [stock_key_1, hold_key_1, stock_key_2, hold_key_2, ...]
|
|
76
|
+
-- ARGV = [qty_1, qty_2, ...]
|
|
77
|
+
for i = 1, #ARGV do
|
|
78
|
+
redis.call('DECRBY', KEYS[(i-1)*2+1], tonumber(ARGV[i]))
|
|
79
|
+
redis.call('DECRBY', KEYS[(i-1)*2+2], tonumber(ARGV[i]))
|
|
80
|
+
end
|
|
81
|
+
return 1
|
|
82
|
+
LUA
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Keeps the DB mirrors in sync with both halves of convert_hold_redis! (quantity_on_hold drops
|
|
86
|
+
# because the hold is gone, quantity_available drops because the unit is permanently sold) via
|
|
87
|
+
# ONE job that applies both under a single row lock per item — see
|
|
88
|
+
# InventoryItems::BulkAdjustQuantitiesOnConvert for why this isn't the usual two separate jobs.
|
|
89
|
+
def enqueue_sync_inventory(order)
|
|
90
|
+
adjustments = order.line_items.flat_map do |line_item|
|
|
70
91
|
next [] unless line_item.should_hold_inventory?
|
|
71
92
|
|
|
72
|
-
# Skip .active scope for the same reason as
|
|
93
|
+
# Skip .active scope for the same reason as convert_hold_redis!.
|
|
73
94
|
line_item.inventory_items.map do |inventory_item|
|
|
74
95
|
{
|
|
75
96
|
inventory_id: inventory_item.id,
|
|
@@ -79,12 +100,12 @@ module SpreeCmCommissioner
|
|
|
79
100
|
end
|
|
80
101
|
end
|
|
81
102
|
|
|
82
|
-
return if
|
|
103
|
+
return if adjustments.blank?
|
|
83
104
|
|
|
84
|
-
SpreeCmCommissioner::InventoryItems::
|
|
105
|
+
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnConvertJob.perform_later(
|
|
85
106
|
order_id: order.id,
|
|
86
|
-
caller_source: "#{self.class.name}#
|
|
87
|
-
inventory_id_and_quantities:
|
|
107
|
+
caller_source: "#{self.class.name}#enqueue_sync_inventory",
|
|
108
|
+
inventory_id_and_quantities: adjustments
|
|
88
109
|
)
|
|
89
110
|
end
|
|
90
111
|
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module InventoryItems
|
|
3
|
+
# DB mirror of InventoryHolds::Convert#convert_hold_redis!: applies the same delta to
|
|
4
|
+
# quantity_on_hold and quantity_available together, under ONE row lock per inventory item.
|
|
5
|
+
# Doing this as two separate jobs (BulkAdjustQuantitiesOnHoldJob + BulkAdjustQuantitiesJob),
|
|
6
|
+
# each locking and saving independently, left a window between the two locks where a reader
|
|
7
|
+
# could see one column updated and the other not yet — the same class of gap that made the
|
|
8
|
+
# Redis side non-atomic. One lock, one update, removes that window on the DB side too.
|
|
9
|
+
class BulkAdjustQuantitiesOnConvert
|
|
10
|
+
prepend ::Spree::ServiceModule::Base
|
|
11
|
+
extend SpreeCmCommissioner::ServiceModuleThrowable
|
|
12
|
+
|
|
13
|
+
def call(inventory_id_and_quantities:, caller_source: nil)
|
|
14
|
+
CmAppLogger.log(
|
|
15
|
+
label: "#{self.class.name}#call started",
|
|
16
|
+
data: { caller_source: caller_source, inventory_id_and_quantities: inventory_id_and_quantities }
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
ActiveRecord::Base.transaction do
|
|
20
|
+
items_by_id = inventory_items(inventory_id_and_quantities).index_by(&:id)
|
|
21
|
+
|
|
22
|
+
grouped_deltas(inventory_id_and_quantities).each do |(inventory_id, locked), quantity|
|
|
23
|
+
inventory_item = items_by_id[inventory_id]
|
|
24
|
+
next if inventory_item.nil?
|
|
25
|
+
|
|
26
|
+
adjust_quantities(inventory_item, quantity, caller_source, locked: locked)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
success(nil)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Grouped by pool flag as well as by item, same as BulkAdjustQuantities/OnHold: a mixed cart
|
|
36
|
+
# can carry a public and a locked adjustment for the same inventory item in one payload.
|
|
37
|
+
def grouped_deltas(inventory_id_and_quantities)
|
|
38
|
+
inventory_id_and_quantities
|
|
39
|
+
.group_by { |entry| [entry[:inventory_id], entry[:locked] || false] }
|
|
40
|
+
.transform_values { |entries| entries.sum { |entry| entry[:quantity] } }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def adjust_quantities(inventory_item, quantity, caller_source, locked: false)
|
|
44
|
+
available_column = locked ? :quantity_locked : :quantity_available
|
|
45
|
+
hold_column = locked ? :quantity_locked_on_hold : :quantity_on_hold
|
|
46
|
+
|
|
47
|
+
before_available = before_hold = after_available = after_hold = nil
|
|
48
|
+
|
|
49
|
+
# IMPORTANT: Apply both quantity changes directly without defensive clamping — model
|
|
50
|
+
# validation surfaces bugs in upstream Redis deduction logic rather than hiding them.
|
|
51
|
+
inventory_item.with_lock do
|
|
52
|
+
before_available = inventory_item[available_column]
|
|
53
|
+
before_hold = inventory_item[hold_column]
|
|
54
|
+
after_available = before_available + quantity
|
|
55
|
+
after_hold = before_hold + quantity
|
|
56
|
+
|
|
57
|
+
inventory_item.update!(available_column => after_available, hold_column => after_hold)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
CmAppLogger.log(
|
|
61
|
+
label: "#{self.class.name}#adjust_quantities",
|
|
62
|
+
data: {
|
|
63
|
+
caller_source: caller_source,
|
|
64
|
+
inventory_item_id: inventory_item.id,
|
|
65
|
+
locked: locked,
|
|
66
|
+
available_column: available_column,
|
|
67
|
+
quantity_available_before: before_available,
|
|
68
|
+
quantity_available_after: after_available,
|
|
69
|
+
hold_column: hold_column,
|
|
70
|
+
quantity_on_hold_before: before_hold,
|
|
71
|
+
quantity_on_hold_after: after_hold,
|
|
72
|
+
delta: quantity
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
rescue ActiveRecord::RecordInvalid
|
|
76
|
+
CmAppLogger.error(
|
|
77
|
+
label: "#{self.class.name}#adjust_quantities failed",
|
|
78
|
+
data: {
|
|
79
|
+
caller_source: caller_source,
|
|
80
|
+
inventory_item_id: inventory_item.id,
|
|
81
|
+
locked: locked,
|
|
82
|
+
delta: quantity,
|
|
83
|
+
quantity_available_current: inventory_item[available_column],
|
|
84
|
+
quantity_on_hold_current: inventory_item[hold_column],
|
|
85
|
+
errors: inventory_item.errors.full_messages
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
raise
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def inventory_items(inventory_id_and_quantities)
|
|
92
|
+
SpreeCmCommissioner::InventoryItem.where(id: inventory_id_and_quantities.pluck(:inventory_id)).includes(variant: :product)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -12,6 +12,10 @@ module SpreeCmCommissioner
|
|
|
12
12
|
warmable_items = []
|
|
13
13
|
|
|
14
14
|
line_items.each do |li|
|
|
15
|
+
# Already deducted on convert (InventoryHolds::Convert#convert_hold_redis!), so no need to
|
|
16
|
+
# deduct again here.
|
|
17
|
+
next if li.should_hold_inventory?
|
|
18
|
+
|
|
15
19
|
# Each line item deducts from its own pool: a locked-stock line was never counted in public
|
|
16
20
|
# stock, so unstocking it there would either fail the Lua guard on a paid order or, where
|
|
17
21
|
# public stock happens to cover it, oversell silently.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: spree_cm_commissioner
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.12.
|
|
4
|
+
version: 2.12.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- You
|
|
@@ -1404,6 +1404,7 @@ files:
|
|
|
1404
1404
|
- app/jobs/spree_cm_commissioner/inventory_holds/release_job.rb
|
|
1405
1405
|
- app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant_job.rb
|
|
1406
1406
|
- app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_job.rb
|
|
1407
|
+
- app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert_job.rb
|
|
1407
1408
|
- app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb
|
|
1408
1409
|
- app/jobs/spree_cm_commissioner/inventory_items/bulk_generate_permanent_items_job.rb
|
|
1409
1410
|
- app/jobs/spree_cm_commissioner/inventory_items/generate_inventory_items_job.rb
|
|
@@ -2306,6 +2307,7 @@ files:
|
|
|
2306
2307
|
- app/services/spree_cm_commissioner/inventory_items/adjust_available_quantity.rb
|
|
2307
2308
|
- app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities.rb
|
|
2308
2309
|
- app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant.rb
|
|
2310
|
+
- app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert.rb
|
|
2309
2311
|
- app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold.rb
|
|
2310
2312
|
- app/services/spree_cm_commissioner/inventory_items/bulk_generate_permanent_items.rb
|
|
2311
2313
|
- app/services/spree_cm_commissioner/inventory_items/generate_non_permanent_item.rb
|