spree_cm_commissioner 2.12.2 → 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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/spree/admin/inventory_lock_movements_controller.rb +37 -0
  4. data/app/controllers/spree/admin/inventory_locks_controller.rb +4 -1
  5. data/app/controllers/spree/admin/private_sales_controller.rb +2 -0
  6. data/app/helpers/spree_cm_commissioner/admin/inventory_locks_helper.rb +33 -0
  7. data/app/jobs/spree_cm_commissioner/inventory_holds/bulk_release_stale_job.rb +1 -1
  8. data/app/jobs/spree_cm_commissioner/inventory_holds/bulk_release_stale_payment_locked_job.rb +1 -1
  9. data/app/jobs/spree_cm_commissioner/inventory_holds/release_job.rb +1 -1
  10. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant_job.rb +2 -0
  11. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_job.rb +2 -0
  12. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert_job.rb +47 -0
  13. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +1 -1
  14. data/app/models/spree_cm_commissioner/inventory_item.rb +7 -2
  15. data/app/models/spree_cm_commissioner/inventory_lock_movement.rb +20 -0
  16. data/app/overrides/spree/admin/shared/_product_tabs/clock_history.html.erb.deface +8 -0
  17. data/app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb +13 -11
  18. data/app/services/spree_cm_commissioner/inventory_holds/convert.rb +44 -23
  19. data/app/services/spree_cm_commissioner/inventory_items/adjust_available_quantity.rb +16 -11
  20. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities.rb +17 -13
  21. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert.rb +96 -0
  22. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold.rb +17 -13
  23. data/app/services/spree_cm_commissioner/inventory_items/move_to_locked.rb +16 -3
  24. data/app/services/spree_cm_commissioner/redis_stock/unstock.rb +4 -0
  25. data/app/views/spree/admin/inventory_lock_movements/index.html.erb +43 -0
  26. data/app/views/spree/admin/inventory_locks/index.html.erb +6 -2
  27. data/app/views/spree/admin/private_sales/_form.html.erb +1 -0
  28. data/app/views/spree/admin/private_sales/_stock.html.erb +50 -30
  29. data/app/views/spree/admin/shared/_availability_percentage.html.erb +18 -0
  30. data/config/locales/en.yml +15 -4
  31. data/config/locales/km.yml +0 -3
  32. data/config/routes.rb +2 -0
  33. data/db/migrate/20260831080000_create_cm_inventory_lock_movements.rb +16 -0
  34. data/lib/spree_cm_commissioner/version.rb +1 -1
  35. metadata +11 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 518b50594502cdd95eb7710824cb6f8a104974d44e9602f2babb3235bcc17559
4
- data.tar.gz: 4906dc6a8f57707ebf5fcb9712b6a074bbef1af0437e14923f61f2ff6606c32e
3
+ metadata.gz: f4502090937a0e1728668be217bf74bf24b37a424b3b412c1df11e061e72268c
4
+ data.tar.gz: bca33ab83038577e339e01d24cc008c48d71e3af09a3131aa102f35d70433644
5
5
  SHA512:
6
- metadata.gz: 67a54630258eda9e5bf72e26ce22177d6b1ce8da399d71434319403f1df8d4a71469ec115f7015b7b9064544688fe833f3230329e456e09eeb88b4f8a488212c
7
- data.tar.gz: 6aa524ac8653f151fd629c4bef58258212ee3eb67f5f1bba328f7e3e964c4efb9f67ec9692c359c80f4b2d8abac088541b7b692503d02bb8afecedc21e550072
6
+ metadata.gz: ca38d3fff9dd926ed84e6bf3ff0565d9af7b8fa6e816448b386fb460a757fdb7b31788dab65d287bedcaba869cd47b4351960bb8ddf0eef86c05caf3db118257
7
+ data.tar.gz: ea4d544a09afe3fe32fce93f52587ffd1b25c0cf986c2ec94a291a2e07e2744d674acb6ef6c0c6a2886303360318d9b9f0d3cfabc2427a6fe775b7e025313e49
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.12.2)
37
+ spree_cm_commissioner (2.12.4)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -0,0 +1,37 @@
1
+ module Spree
2
+ module Admin
3
+ # Read-only history of InventoryItems::MoveToLocked for one product — kept on its own page,
4
+ # separate from InventoryLocksController, so changing a lock and auditing past changes don't
5
+ # compete for space on one screen.
6
+ class InventoryLockMovementsController < Spree::Admin::ResourceController
7
+ skip_before_action :load_resource
8
+
9
+ before_action :load_parent
10
+
11
+ # GET /admin/products/:product_id/inventory_lock_movements
12
+ def index
13
+ # Not scoped to `active` inventory items — a movement stays visible after its date has
14
+ # passed, since the question "who locked this and when" doesn't expire with the stock.
15
+ @lock_movements = SpreeCmCommissioner::InventoryLockMovement
16
+ .where(inventory_item: @product.inventory_items)
17
+ .includes(:created_by, inventory_item: { variant: { option_values: :option_type } })
18
+ .order(created_at: :desc)
19
+ .page(params[:page]).per(50)
20
+ end
21
+
22
+ private
23
+
24
+ def load_parent
25
+ @product = Spree::Product.find_by(slug: params[:product_id])
26
+ return if @product
27
+
28
+ flash[:error] = t('spree.admin.inventory_locks.create.product_not_found')
29
+ redirect_to spree.admin_products_path
30
+ end
31
+
32
+ def model_class
33
+ SpreeCmCommissioner::InventoryLockMovement
34
+ end
35
+ end
36
+ end
37
+ end
@@ -11,6 +11,8 @@ module Spree
11
11
  # product, calls it, and turns its named failures into flash messages.
12
12
  #
13
13
  class InventoryLocksController < Spree::Admin::ResourceController
14
+ helper SpreeCmCommissioner::Admin::InventoryLocksHelper
15
+
14
16
  skip_before_action :load_resource
15
17
 
16
18
  before_action :load_parent
@@ -71,7 +73,8 @@ module Spree
71
73
  SpreeCmCommissioner::InventoryItems::MoveToLocked.call(
72
74
  inventory_item: inventory_item,
73
75
  quantity: params[:quantity],
74
- caller_source: "#{self.class.name}#create"
76
+ caller_source: "#{self.class.name}#create",
77
+ user: try_spree_current_user
75
78
  )
76
79
  end
77
80
 
@@ -8,6 +8,8 @@ module Spree
8
8
  # allotment is actually set up now that the products are known.
9
9
  #
10
10
  class PrivateSalesController < Spree::Admin::ResourceController
11
+ helper SpreeCmCommissioner::Admin::InventoryLocksHelper
12
+
11
13
  # Also on update: ResourceController re-renders :edit when validation fails, and the stock
12
14
  # partial reads these ivars — without this a rejected save is a 500 instead of a form.
13
15
  before_action :load_stock_rows, only: %i[edit update]
@@ -0,0 +1,33 @@
1
+ module SpreeCmCommissioner
2
+ module Admin
3
+ module InventoryLocksHelper
4
+ # Share of this date's capacity still on public sale.
5
+ #
6
+ # max_capacity is the right denominator because MoveToLocked only ever moves units BETWEEN
7
+ # quantity_available and quantity_locked — it never touches max_capacity — so this figure falls
8
+ # by exactly what an operator withholds.
9
+ #
10
+ # nil when there is nothing to divide by, so the caller renders a dash instead of a 0% that
11
+ # would read as "sold out" on a date that simply has no stock configured.
12
+ def available_capacity_percentage(inventory_item)
13
+ return nil if inventory_item.nil?
14
+
15
+ capacity = inventory_item.max_capacity.to_i
16
+ return nil if capacity.zero?
17
+
18
+ (inventory_item.quantity_available.to_f / capacity * 100).round
19
+ end
20
+
21
+ # High/medium/low as risk of running out of public stock, not as a grade — a bare number in a
22
+ # dense table doesn't say whether 22% is fine or a problem, so the color carries that instead.
23
+ # Reuses the badge-success/warning/danger classes already styled for this admin (see
24
+ # gems/spree_backend's _badges.scss) rather than introducing new color tokens.
25
+ def availability_percentage_badge_class(percentage)
26
+ return 'badge-danger' if percentage <= 20
27
+ return 'badge-warning' if percentage <= 50
28
+
29
+ 'badge-success'
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,7 +1,7 @@
1
1
  module SpreeCmCommissioner
2
2
  module InventoryHolds
3
3
  class BulkReleaseStaleJob < SpreeCmCommissioner::ApplicationJob
4
- queue_as :inventory_hold
4
+ queue_as :inventory_item
5
5
 
6
6
  def perform
7
7
  SpreeCmCommissioner::InventoryHolds::BulkReleaseStale.call!
@@ -1,7 +1,7 @@
1
1
  module SpreeCmCommissioner
2
2
  module InventoryHolds
3
3
  class BulkReleaseStalePaymentLockedJob < SpreeCmCommissioner::ApplicationJob
4
- queue_as :inventory_hold
4
+ queue_as :inventory_item
5
5
 
6
6
  def perform
7
7
  SpreeCmCommissioner::InventoryHolds::BulkReleaseStalePaymentLocked.call!
@@ -3,7 +3,7 @@ module SpreeCmCommissioner
3
3
  # Primary release for :active holds. Scheduled at hold creation (wait_until: expires_at).
4
4
  # Safety net: BulkReleaseStaleJob catches any active holds this job misses.
5
5
  class ReleaseJob < SpreeCmCommissioner::ApplicationJob
6
- queue_as :inventory_hold
6
+ queue_as :inventory_item
7
7
 
8
8
  def perform(options = {})
9
9
  hold = SpreeCmCommissioner::InventoryHold.find(options[:hold_id])
@@ -1,6 +1,8 @@
1
1
  module SpreeCmCommissioner
2
2
  module InventoryItems
3
3
  class BulkAdjustQuantitiesByVariantJob < ApplicationUniqueJob
4
+ queue_as :inventory_item
5
+
4
6
  def perform(options = {})
5
7
  variant = Spree::Variant.find_by(id: options[:variant_id])
6
8
 
@@ -3,6 +3,8 @@ module SpreeCmCommissioner
3
3
  class BulkAdjustQuantitiesJob < ApplicationUniqueJob
4
4
  include SpreeCmCommissioner::IdempotentJob
5
5
 
6
+ queue_as :inventory_item
7
+
6
8
  def perform(options = {})
7
9
  with_idempotency(idempotency_key_for(options)) do
8
10
  bulk_adjust_quantities!(options)
@@ -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
@@ -3,7 +3,7 @@ module SpreeCmCommissioner
3
3
  class BulkAdjustQuantitiesOnHoldJob < ApplicationUniqueJob
4
4
  include SpreeCmCommissioner::IdempotentJob
5
5
 
6
- queue_as :default
6
+ queue_as :inventory_item
7
7
 
8
8
  def perform(options = {})
9
9
  with_idempotency(idempotency_key_for(options)) do
@@ -20,6 +20,8 @@ module SpreeCmCommissioner
20
20
  # not re-query a already-loaded association.
21
21
  has_many :locked_reserved_blocks, -> { locked }, class_name: 'SpreeCmCommissioner::ReservedBlock', inverse_of: :inventory_item
22
22
 
23
+ has_many :lock_movements, class_name: 'SpreeCmCommissioner::InventoryLockMovement', inverse_of: :inventory_item
24
+
23
25
  # Validation
24
26
  validates :quantity_available, numericality: { greater_than_or_equal_to: 0 }
25
27
  validates :quantity_on_hold, numericality: { greater_than_or_equal_to: 0 }
@@ -120,8 +122,11 @@ module SpreeCmCommissioner
120
122
  self.quantity_available = quantity_available + quantity
121
123
  save!
122
124
 
123
- # When user has been searched or booked a product, it has cached the quantity in redis,
124
- # So we need to update redis cache if inventory key has been created in redis
125
+ # Deliberately still inside with_lock, unlike the checkout-path deductors (e.g.
126
+ # InventoryItems::BulkAdjustQuantities): this is a rare admin-only edit, not a
127
+ # high-contention hot path, so keeping it in the same transaction as `save!` means a
128
+ # Redis failure rolls the DB change back too, instead of leaving the DB and Redis counts
129
+ # out of sync with nothing to reconcile them.
125
130
  adjust_quantity_in_redis(quantity)
126
131
  end
127
132
  end
@@ -0,0 +1,20 @@
1
+ module SpreeCmCommissioner
2
+ # An immutable ledger row for one InventoryItems::MoveToLocked call — the audit trail that pool
3
+ # transfer otherwise leaves no trace of. See docs/concepts/locked-stock-flow.md.
4
+ class InventoryLockMovement < Base
5
+ belongs_to :inventory_item, class_name: 'SpreeCmCommissioner::InventoryItem'
6
+ belongs_to :created_by, class_name: 'Spree::User', optional: true
7
+
8
+ validates :quantity, numericality: { other_than: 0 }
9
+
10
+ # Matches Spree::StockMovement's own immutability guard — an audit ledger that can be edited
11
+ # after the fact isn't an audit ledger.
12
+ def readonly?
13
+ persisted?
14
+ end
15
+
16
+ def locked?
17
+ quantity.positive?
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,8 @@
1
+ <!-- insert_after "erb[silent]:contains('Spree::Digital')" -->
2
+
3
+ <%= content_tag :li, class: 'nav-item' do %>
4
+ <%= link_to_with_icon 'clock-history.svg',
5
+ t('spree.admin.inventory_lock_movements.tab'),
6
+ admin_product_inventory_lock_movements_url(@product),
7
+ class: "nav-link #{'active' if current == :inventory_lock_movements}" %>
8
+ <% end %>
@@ -21,8 +21,10 @@ module SpreeCmCommissioner
21
21
 
22
22
  # The complete set of stock-status buckets this service can publish — listed in the order
23
23
  # `bucket_stock_status` checks them (most-depleted first), so the full state space is visible
24
- # here without reading every branch. Each has a translation at config/locales/*.yml#stock_status
25
- # except IN_STOCK, which intentionally has none — see `localized_message`.
24
+ # here without reading every branch. SOLD_OUT and PENDING_HOLD have a translation at
25
+ # config/locales/*.yml#stock_status; IN_STOCK and LOW_STOCK intentionally have none — the
26
+ # low-stock message is disabled for now, so that status still publishes but with a nil
27
+ # message, same as IN_STOCK — see `localized_message`.
26
28
  SOLD_OUT = :sold_out
27
29
  PENDING_HOLD = :pending_hold
28
30
  LOW_STOCK = :low_stock
@@ -170,9 +172,11 @@ module SpreeCmCommissioner
170
172
  end
171
173
 
172
174
  # Bucket transitions always count; a same-bucket change only counts while LOW_STOCK (the one
173
- # bucket whose Firestore payload shows an exact "N left" count) AND the quantity actually
174
- # differs from what's already stored otherwise a poll tick that finds truly nothing new
175
- # would still write every live event's every product on every cycle.
175
+ # bucket whose raw `quantity_available`/`quantity_on_hold` fields still move meaningfully
176
+ # within the bucket, even though its `message` is nil like IN_STOCK's see
177
+ # `localized_message`) AND the quantity actually differs from what's already stored
178
+ # otherwise a poll tick that finds truly nothing new would still write every live event's
179
+ # every product on every cycle.
176
180
  def changed?(existing_entry, status, available, on_hold)
177
181
  existing_entry ||= {}
178
182
  previous_status = existing_entry['status']&.to_sym
@@ -196,14 +200,12 @@ module SpreeCmCommissioner
196
200
 
197
201
  # Pre-translated, per-locale copy baked in at publish time — see
198
202
  # config/locales/*.yml#stock_status. nil for in_stock: the design has no badge for the common
199
- # case, and omitting the field is what tells the client that directly.
200
- #
201
- # low_stock interpolates `available` into the translation (`%{count}`, pluralized) so the
202
- # exact "N left" pill text is built once here rather than duplicated client-side from the
203
- # bare `quantity_available` field — the client just displays this string verbatim (see
203
+ # case, and omitting the field is what tells the client that directly. Also nil for low_stock
204
+ # for now — the "N left" pill text is disabled; clients fall back to their own generic
205
+ # "Available" copy when message is nil for that status (see
204
206
  # `cm_ticket_availability_card.dart`'s `_StatusDisplay._label`).
205
207
  def localized_message(status, available)
206
- return nil if status == IN_STOCK
208
+ return nil if [IN_STOCK, LOW_STOCK].include?(status)
207
209
 
208
210
  I18n.available_locales.index_with do |locale|
209
211
  I18n.t("stock_status.#{status}", count: available, locale: locale)
@@ -14,8 +14,8 @@ module SpreeCmCommissioner
14
14
 
15
15
  unless hold.finalized?
16
16
  hold.update!(status: :converted)
17
- clear_hold_redis!(order)
18
- enqueue_sync_inventory_on_hold(order)
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
- def clear_hold_redis!(order)
32
- keys = []
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
- keys << item.redis_hold_key(locked: locked)
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 keys.empty?
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(clear_hold_lua_script, keys: keys, argv: quantities)
69
+ redis.eval(convert_hold_lua_script, keys: interleaved_keys, argv: quantities)
65
70
  end
66
71
  end
67
72
 
68
- def enqueue_sync_inventory_on_hold(order)
69
- inventory_id_and_quantities = order.line_items.flat_map do |line_item|
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 clear_hold_redis!.
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 inventory_id_and_quantities.blank?
103
+ return if adjustments.blank?
83
104
 
84
- SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnHoldJob.perform_later(
105
+ SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnConvertJob.perform_later(
85
106
  order_id: order.id,
86
- caller_source: "#{self.class.name}#enqueue_sync_inventory_on_hold",
87
- inventory_id_and_quantities: 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
@@ -37,27 +37,32 @@ module SpreeCmCommissioner
37
37
  # thing RedisStock::Unstock and InventoryHolds::Convert do before their own Lua scripts.
38
38
  warm_redis_key!(inventory_item) if warm_redis
39
39
 
40
+ before = after = nil
41
+
40
42
  apply = lambda do
41
43
  before = inventory_item.quantity_available
42
44
  after = before + delta
43
45
 
44
46
  inventory_item.update!(quantity_available: after)
45
47
  inventory_item.adjust_quantity_in_redis(delta)
46
-
47
- CmAppLogger.log(
48
- label: "#{self.class.name}#call",
49
- data: {
50
- caller_source: caller_source,
51
- inventory_item_id: inventory_item.id,
52
- quantity_available_before: before,
53
- delta: delta,
54
- quantity_available_after: after
55
- }
56
- )
57
48
  end
58
49
 
59
50
  lock ? inventory_item.with_lock(&apply) : apply.call
60
51
 
52
+ # Logged after the lock releases: I/O here would otherwise extend how long the row's
53
+ # SELECT ... FOR UPDATE is held under high-traffic contention (this also covers
54
+ # Seats::LockBlocks/UnlockBlocks, which call in with lock: false while already holding it).
55
+ CmAppLogger.log(
56
+ label: "#{self.class.name}#call",
57
+ data: {
58
+ caller_source: caller_source,
59
+ inventory_item_id: inventory_item.id,
60
+ quantity_available_before: before,
61
+ delta: delta,
62
+ quantity_available_after: after
63
+ }
64
+ )
65
+
61
66
  success(inventory_item)
62
67
  rescue ActiveRecord::RecordInvalid => e
63
68
  CmAppLogger.error(
@@ -49,25 +49,29 @@ module SpreeCmCommissioner
49
49
  # 1. with_lock (Pessimistic): SELECT ... FOR UPDATE prevents concurrent writes
50
50
  # 2. lock_version (Optimistic): Catches race conditions if row is modified between
51
51
  # SELECT and UPDATE (e.g., admin adjusts stock while job runs)
52
+ before = after = nil
53
+
52
54
  inventory_item.with_lock do
53
55
  before = inventory_item[column]
54
56
  after = before + quantity
55
57
 
56
- CmAppLogger.log(
57
- label: "#{self.class.name}#adjust_quantity_available",
58
- data: {
59
- caller_source: caller_source,
60
- inventory_item_id: inventory_item.id,
61
- locked: locked,
62
- column: column,
63
- quantity_available_before: before,
64
- delta: quantity,
65
- quantity_available_after: after
66
- }
67
- )
68
-
69
58
  inventory_item.update!(column => after)
70
59
  end
60
+
61
+ # Logged after the lock releases: I/O here would otherwise extend how long the row's
62
+ # SELECT ... FOR UPDATE is held under high-traffic contention.
63
+ CmAppLogger.log(
64
+ label: "#{self.class.name}#adjust_quantity_available",
65
+ data: {
66
+ caller_source: caller_source,
67
+ inventory_item_id: inventory_item.id,
68
+ locked: locked,
69
+ column: column,
70
+ quantity_available_before: before,
71
+ delta: quantity,
72
+ quantity_available_after: after
73
+ }
74
+ )
71
75
  rescue ActiveRecord::RecordInvalid
72
76
  CmAppLogger.error(
73
77
  label: "#{self.class.name}#adjust_quantity_available failed",
@@ -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
@@ -55,25 +55,29 @@ module SpreeCmCommissioner
55
55
  # 1. with_lock (Pessimistic): SELECT ... FOR UPDATE prevents concurrent writes
56
56
  # 2. lock_version (Optimistic): Catches race conditions if row is modified between
57
57
  # SELECT and UPDATE (e.g., admin adjusts stock while job runs)
58
+ before = after = nil
59
+
58
60
  inventory_item.with_lock do
59
61
  before = inventory_item[column]
60
62
  after = before + quantity
61
63
 
62
- CmAppLogger.log(
63
- label: "#{self.class.name}#adjust_quantity_on_hold",
64
- data: {
65
- caller_source: caller_source,
66
- inventory_item_id: inventory_item.id,
67
- locked: locked,
68
- column: column,
69
- quantity_on_hold_before: before,
70
- delta: quantity,
71
- quantity_on_hold_after: after
72
- }
73
- )
74
-
75
64
  inventory_item.update!(column => after)
76
65
  end
66
+
67
+ # Logged after the lock releases: I/O here would otherwise extend how long the row's
68
+ # SELECT ... FOR UPDATE is held under high-traffic contention.
69
+ CmAppLogger.log(
70
+ label: "#{self.class.name}#adjust_quantity_on_hold",
71
+ data: {
72
+ caller_source: caller_source,
73
+ inventory_item_id: inventory_item.id,
74
+ locked: locked,
75
+ column: column,
76
+ quantity_on_hold_before: before,
77
+ delta: quantity,
78
+ quantity_on_hold_after: after
79
+ }
80
+ )
77
81
  rescue ActiveRecord::RecordInvalid
78
82
  CmAppLogger.error(
79
83
  label: "#{self.class.name}#adjust_quantity_on_hold failed",
@@ -16,7 +16,7 @@ module SpreeCmCommissioner
16
16
  prepend ::Spree::ServiceModule::Base
17
17
  extend SpreeCmCommissioner::ServiceModuleThrowable
18
18
 
19
- def call(inventory_item:, quantity:, caller_source: nil)
19
+ def call(inventory_item:, quantity:, caller_source: nil, user: nil)
20
20
  quantity = quantity.to_i
21
21
  return failure(nil, :invalid_quantity) if quantity.zero?
22
22
 
@@ -32,7 +32,7 @@ module SpreeCmCommissioner
32
32
  # moved. The savepoint makes the rollback real wherever this is called from.
33
33
  inventory_item.transaction(requires_new: true) do
34
34
  inventory_item.lock!
35
- outcome = transfer!(inventory_item, quantity, caller_source)
35
+ outcome = transfer!(inventory_item, quantity, caller_source, user)
36
36
  raise ActiveRecord::Rollback unless outcome == :moved
37
37
  end
38
38
 
@@ -45,7 +45,7 @@ module SpreeCmCommissioner
45
45
  private
46
46
 
47
47
  # Runs inside the locked transaction. Returns the outcome symbol.
48
- def transfer!(inventory_item, quantity, caller_source)
48
+ def transfer!(inventory_item, quantity, caller_source, user)
49
49
  new_locked = inventory_item.quantity_locked + quantity
50
50
  return :quantity_exceeds_lock if new_locked.negative?
51
51
 
@@ -67,11 +67,24 @@ module SpreeCmCommissioner
67
67
 
68
68
  return insufficient_stock_reason(quantity) unless move_stock_in_redis(inventory_item, quantity)
69
69
 
70
+ # Inside the same savepoint as the row update above, so a later failure in this same call would
71
+ # roll this row back with it — the ledger can never disagree with quantity_locked.
72
+ create_movement!(inventory_item, quantity, caller_source, user)
70
73
  log_adjustment(inventory_item, quantity, caller_source)
71
74
 
72
75
  :moved
73
76
  end
74
77
 
78
+ # The one durable record of this transfer — CmAppLogger below is a log line, not a queryable row.
79
+ def create_movement!(inventory_item, quantity, caller_source, user)
80
+ inventory_item.lock_movements.create!(
81
+ quantity: quantity,
82
+ quantity_locked_after: inventory_item.quantity_locked,
83
+ created_by: user,
84
+ caller_source: caller_source
85
+ )
86
+ end
87
+
75
88
  # Both pools as Redis currently holds them, SET-NX'd from the row where a key was cold.
76
89
  def warm_pools(inventory_item)
77
90
  SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new([inventory_item]).call.first
@@ -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.
@@ -0,0 +1,43 @@
1
+ <%# current: :inventory_lock_movements deliberately matches no tab — this is its own page, not a
2
+ sub-view of Stock Managements, so nothing in the tab bar should light up as if it were. %>
3
+ <%= render partial: 'spree/admin/shared/product_tabs', locals: { current: :inventory_lock_movements } %>
4
+
5
+ <h1 class="mb-3"><%= t('.title') %></h1>
6
+
7
+ <div class="bg-white border rounded table-responsive">
8
+ <table class="table">
9
+ <thead class="text-muted">
10
+ <tr>
11
+ <th><%= Spree.t(:variant) %></th>
12
+ <th><%= Spree.t(:date) %></th>
13
+ <th class="text-right"><%= t('.quantity') %></th>
14
+ <th class="text-right"><%= t('.locked') %></th>
15
+ <th><%= t('.by') %></th>
16
+ <th><%= t('.at') %></th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% if @lock_movements.empty? %>
21
+ <tr>
22
+ <td colspan="6" class="text-center text-muted"><%= t('.no_movements') %></td>
23
+ </tr>
24
+ <% else %>
25
+ <% @lock_movements.each do |movement| %>
26
+ <% item = movement.inventory_item %>
27
+ <tr>
28
+ <td><%= item.variant.sku_and_options_text %></td>
29
+ <td><%= item.inventory_date %></td>
30
+ <td class="text-right <%= movement.locked? ? 'text-success' : 'text-danger' %>">
31
+ <%= movement.locked? ? "+#{movement.quantity}" : movement.quantity %>
32
+ </td>
33
+ <td class="text-right"><%= movement.quantity_locked_after %></td>
34
+ <td><%= movement.created_by&.email || t('.system') %></td>
35
+ <td><%= movement.created_at.strftime('%d %b %H:%M:%S') %></td>
36
+ </tr>
37
+ <% end %>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+
42
+ <%= paginate @lock_movements, theme: 'admin-twitter-bootstrap-4' %>
43
+ </div>
@@ -34,7 +34,8 @@
34
34
  <small class="form-text text-muted"><%= t('.quantity_hint') %></small>
35
35
  </div>
36
36
 
37
- <%= submit_tag t('.apply'), class: 'btn btn-primary' %>
37
+ <%= submit_tag t('.apply'), class: 'btn btn-primary',
38
+ data: { confirm: @product.permanent_stock? ? t('.apply_confirm_per_date') : t('.apply_confirm') } %>
38
39
  <% end %>
39
40
  </div>
40
41
  </div>
@@ -47,6 +48,8 @@
47
48
  <tr>
48
49
  <th><%= Spree.t(:variant) %></th>
49
50
  <th><%= Spree.t(:date) %></th>
51
+ <%# The denominator behind the "% left" figure in the Available column. %>
52
+ <th class="text-right"><%= t('.max_capacity') %></th>
50
53
  <th class="text-right"><%= t('.public_available') %></th>
51
54
  <th class="text-right"><%= t('.locked') %></th>
52
55
  <%# In an agent's cart right now. Without this column, a refused release looks arbitrary. %>
@@ -58,7 +61,8 @@
58
61
  <tr>
59
62
  <td><%= item.variant.sku_and_options_text %></td>
60
63
  <td><%= item.inventory_date %></td>
61
- <td class="text-right"><%= item.quantity_available %></td>
64
+ <td class="text-right text-muted"><%= item.max_capacity %></td>
65
+ <td class="text-right"><%= render partial: 'spree/admin/shared/availability_percentage', locals: { inventory_item: item } %></td>
62
66
  <td class="text-right"><strong><%= item.quantity_locked %></strong></td>
63
67
  <td class="text-right"><%= item.quantity_locked_on_hold %></td>
64
68
  </tr>
@@ -21,6 +21,7 @@
21
21
  data: {
22
22
  autocomplete_url_value: 'products_api_v2',
23
23
  autocomplete_multiple_value: true,
24
+ autocomplete_search_query_value: 'name_or_slug_i_cont',
24
25
  autocomplete_placeholder_value: t('.products_placeholder')
25
26
  } %>
26
27
  <small class="form-text text-muted"><%= t('.products_hint') %></small>
@@ -16,32 +16,51 @@
16
16
  <% else %>
17
17
  <p class="text-muted small"><%= t('.hint') %></p>
18
18
 
19
- <% @private_sale.product_ids.each do |product_id| %>
20
- <% product = @products_by_id[product_id] %>
21
- <% next if product.nil? %>
19
+ <%# One shared table for every product, not one table per product: separate tables each size their
20
+ own columns from their own content, so the same column (e.g. Max capacity) lands at a different
21
+ x-position under every product — misaligned the moment two products' SKUs differ in length. A
22
+ product is a header row inside this table instead of its own card, so every row shares one set
23
+ of column widths. %>
24
+ <div class="bg-white border rounded table-responsive">
25
+ <table class="table mb-0">
26
+ <thead class="text-muted">
27
+ <tr>
28
+ <%# Same wording as the product's Stock screen, which names the columns after the data they
29
+ hold. Two screens showing the same numbers under different names is how an operator ends
30
+ up mistrusting both. %>
31
+ <th><%= Spree.t(:variant) %></th>
32
+ <th class="text-center"><%= t('.max_capacity') %></th>
33
+ <th class="text-center"><%= t('.quantity_available') %></th>
34
+ <th class="text-center"><%= t('.quantity_locked') %></th>
35
+ <th class="text-center"><%= t('.quantity_locked_on_hold') %></th>
36
+ <th class="text-center"><%= t('.lock_release') %></th>
37
+ <th></th>
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ <% @private_sale.product_ids.each do |product_id| %>
42
+ <% product = @products_by_id[product_id] %>
43
+ <% next if product.nil? %>
22
44
 
23
- <div class="card mb-3">
24
- <div class="card-header d-flex justify-content-between align-items-center">
25
- <strong><%= product.name %></strong>
26
- <%= link_to t('.manage_product_stock'), admin_product_stock_managements_path(product),
27
- class: 'btn btn-sm btn-outline-secondary' %>
28
- </div>
29
-
30
- <table class="table mb-0">
31
- <thead class="text-muted">
32
- <tr>
33
- <%# Same wording as the product's Stock screen, which names the columns after the data
34
- they hold. Two screens showing the same numbers under different names is how an
35
- operator ends up mistrusting both. %>
36
- <th><%= Spree.t(:variant) %></th>
37
- <th class="text-center"><%= t('.max_capacity') %></th>
38
- <th class="text-center"><%= t('.quantity_available') %></th>
39
- <th class="text-center"><%= t('.quantity_locked') %></th>
40
- <th class="text-center"><%= t('.quantity_locked_on_hold') %></th>
41
- <th class="text-center"><%= t('.lock_release') %></th>
45
+ <tr class="bg-light">
46
+ <td colspan="6"><strong><%= product.name %></strong></td>
47
+ <td class="text-right">
48
+ <%# Own column so these line up under the same x-position for every product, rather than
49
+ sharing space with the name — icon-only, matching the with-tip pattern used for row
50
+ actions elsewhere in this admin (e.g. import_existing_orders#index). Regardless of
51
+ stock type — the per-date link below only appears for permanent-stock products with
52
+ no undated row, but every product's lock/release history lives here. %>
53
+ <%= link_to admin_product_inventory_lock_movements_path(product),
54
+ class: 'btn btn-sm btn-outline-secondary with-tip icon-link', title: t('.lock_history') do %>
55
+ <%= svg_icon name: 'clock-history.svg', width: '14', height: '14' %>
56
+ <% end %>
57
+ <%= link_to admin_product_stock_managements_path(product),
58
+ class: 'btn btn-sm btn-outline-secondary with-tip icon-link', title: t('.manage_product_stock') do %>
59
+ <%= svg_icon name: 'box-seam.svg', width: '14', height: '14' %>
60
+ <% end %>
61
+ </td>
42
62
  </tr>
43
- </thead>
44
- <tbody>
63
+
45
64
  <% (@variants_by_product[product_id] || []).each do |variant| %>
46
65
  <% item = @inventory_item_by_variant[variant.id] %>
47
66
  <tr class="<%= 'text-muted' if item.nil? || item.quantity_locked.zero? %>">
@@ -50,12 +69,12 @@
50
69
  <% if item.nil? %>
51
70
  <%# No undated row means this product's stock is per date — hundreds of rows a single
52
71
  input cannot express. Those are managed on the product's own screen. %>
53
- <td colspan="5" class="text-center text-muted">
72
+ <td colspan="6" class="text-center text-muted">
54
73
  <%= link_to t('.per_date_stock'), admin_product_inventory_locks_path(product) %>
55
74
  </td>
56
75
  <% else %>
57
76
  <td class="text-center text-muted"><%= item.max_capacity %></td>
58
- <td class="text-center"><%= item.quantity_available %></td>
77
+ <td class="text-center"><%= render partial: 'spree/admin/shared/availability_percentage', locals: { inventory_item: item } %></td>
59
78
  <td class="text-center"><strong><%= item.quantity_locked %></strong></td>
60
79
  <td class="text-center"><%= item.quantity_locked_on_hold %></td>
61
80
  <td class="text-center">
@@ -73,11 +92,12 @@
73
92
  <% end %>
74
93
  </div>
75
94
  </td>
95
+ <td></td>
76
96
  <% end %>
77
97
  </tr>
78
98
  <% end %>
79
- </tbody>
80
- </table>
81
- </div>
82
- <% end %>
99
+ <% end %>
100
+ </tbody>
101
+ </table>
102
+ </div>
83
103
  <% end %>
@@ -0,0 +1,18 @@
1
+ <%# Public quantity available, with a small color-coded badge next to it for what share of capacity
2
+ that represents — red/yellow/green by high/medium/low, so an operator scanning a table of dates
3
+ spots the ones worth a second look without reading every number.
4
+
5
+ Sized down via inline style rather than a new CSS class: Bootstrap's default .badge already reads
6
+ as a chunky block, and this admin already sizes one-off controls this way (e.g. the width on the
7
+ +/- input groups in this same directory).
8
+
9
+ Shared by the product's lock screen and the private sale's stock table so it never renders two
10
+ different ways on two screens an operator compares side by side. %>
11
+ <% percentage = available_capacity_percentage(inventory_item) %>
12
+
13
+ <%= inventory_item.quantity_available %>
14
+ <% if percentage %>
15
+ <span class="badge <%= availability_percentage_badge_class(percentage) %> ml-1" style="font-size: 0.7em; padding: 0.2em 0.45em;">
16
+ <%= percentage %>%
17
+ </span>
18
+ <% end %>
@@ -597,6 +597,7 @@ en:
597
597
  quantity_locked_on_hold: "Quantity locked on hold"
598
598
  lock_release: "Lock (+) / Release (−)"
599
599
  manage_product_stock: "Product stock"
600
+ lock_history: "Lock history"
600
601
  per_date_stock: "This product's stock is per date — manage it on the product"
601
602
  revoke:
602
603
  revoked: "Private sale revoked. Its link no longer works."
@@ -609,7 +610,10 @@ en:
609
610
  to_date: "To date"
610
611
  per_date_hint: "The quantity applies to EACH date in the range, not across the range as a whole. Leave both dates empty to change only undated stock."
611
612
  apply: "Apply"
612
- public_available: "Public available"
613
+ apply_confirm: "Apply this change to locked stock? This takes effect immediately."
614
+ apply_confirm_per_date: "Apply this change to EVERY date in the range? This takes effect immediately."
615
+ max_capacity: "Max capacity"
616
+ public_available: "Available"
613
617
  locked: "Locked"
614
618
  locked_on_hold: "On hold"
615
619
  create:
@@ -618,6 +622,16 @@ en:
618
622
  no_inventory_item: "Pick a variant that has stock on sale."
619
623
  invalid_date_range: "Check the dates: both are required together, and From must not be after To."
620
624
  product_not_found: "That product no longer exists."
625
+ inventory_lock_movements:
626
+ tab: "Lock history"
627
+ index:
628
+ title: "Lock history"
629
+ quantity: "Quantity"
630
+ locked: "Locked"
631
+ no_movements: "No locked stock has been moved yet."
632
+ by: "By"
633
+ at: "At"
634
+ system: "System"
621
635
  display_on:
622
636
  frontend_for_early_adopter: "Storefront for Early Adopter"
623
637
  external_integrations_title: "External Integrations"
@@ -992,9 +1006,6 @@ en:
992
1006
  # SpreeCmCommissioner::Products::PublishStockStatusToFirestore) — not read via I18n at request
993
1007
  # time, so every locale we publish for must have an entry here.
994
1008
  stock_status:
995
- low_stock:
996
- one: "%{count} left"
997
- other: "%{count} left"
998
1009
  pending_hold: "In high demand"
999
1010
  sold_out: "Sold out"
1000
1011
 
@@ -607,9 +607,6 @@ km:
607
607
  hold_cooldown: "ការកក់មុនរបស់អ្នកទើបតែផុតកំណត់។ សូមរង់ចាំពីរបីនាទី មុនពេលកក់ម្តងទៀត។"
608
608
 
609
609
  stock_status:
610
- low_stock:
611
- one: "នៅសល់ %{count}"
612
- other: "នៅសល់ %{count}"
613
610
  pending_hold: "តម្រូវការខ្ពស់"
614
611
  sold_out: "លក់អស់"
615
612
 
data/config/routes.rb CHANGED
@@ -234,6 +234,8 @@ Spree::Core::Engine.add_routes do
234
234
  # Withholding stock from public sale, and the private sales that sell it.
235
235
  resources :inventory_locks, only: %i[index create]
236
236
 
237
+ resources :inventory_lock_movements, only: %i[index]
238
+
237
239
  resources :product_completion_steps do
238
240
  collection do
239
241
  post :update_positions
@@ -0,0 +1,16 @@
1
+ class CreateCmInventoryLockMovements < ActiveRecord::Migration[7.0]
2
+ # Immutable audit ledger for SpreeCmCommissioner::InventoryItems::MoveToLocked — one row per lock
3
+ # or release, mirroring Spree::StockMovement's shape (signed quantity + actor + append-only) since
4
+ # that table cannot be reused: its after_create callback adjusts count_on_hand, which locking must
5
+ # never touch.
6
+ def change
7
+ create_table :cm_inventory_lock_movements, if_not_exists: true do |t|
8
+ t.references :inventory_item, null: false, foreign_key: { to_table: :cm_inventory_items }
9
+ t.integer :quantity, null: false
10
+ t.integer :quantity_locked_after, null: false
11
+ t.references :created_by, foreign_key: { to_table: :spree_users }
12
+ t.string :caller_source
13
+ t.datetime :created_at, null: false
14
+ end
15
+ end
16
+ end
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.12.2'.freeze
2
+ VERSION = '2.12.4'.freeze
3
3
 
4
4
  module_function
5
5
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_cm_commissioner
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.12.2
4
+ version: 2.12.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-29 00:00:00.000000000 Z
11
+ date: 2026-08-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: spree
@@ -867,6 +867,7 @@ files:
867
867
  - app/controllers/spree/admin/integrations_controller.rb
868
868
  - app/controllers/spree/admin/inventory_holds_controller.rb
869
869
  - app/controllers/spree/admin/inventory_items_controller.rb
870
+ - app/controllers/spree/admin/inventory_lock_movements_controller.rb
870
871
  - app/controllers/spree/admin/inventory_locks_controller.rb
871
872
  - app/controllers/spree/admin/inventory_monitorings_controller.rb
872
873
  - app/controllers/spree/admin/kyc_controller.rb
@@ -1229,6 +1230,7 @@ files:
1229
1230
  - app/helpers/spree/transit/sortable_tree_helper.rb
1230
1231
  - app/helpers/spree_cm_commissioner/admin/guest_helper.rb
1231
1232
  - app/helpers/spree_cm_commissioner/admin/homepage_segment_helper.rb
1233
+ - app/helpers/spree_cm_commissioner/admin/inventory_locks_helper.rb
1232
1234
  - app/helpers/spree_cm_commissioner/admin/kycable_helper.rb
1233
1235
  - app/helpers/spree_cm_commissioner/admin/service_calendars_helper.rb
1234
1236
  - app/helpers/spree_cm_commissioner/admin/ticket_transfers_helper.rb
@@ -1402,6 +1404,7 @@ files:
1402
1404
  - app/jobs/spree_cm_commissioner/inventory_holds/release_job.rb
1403
1405
  - app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant_job.rb
1404
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
1405
1408
  - app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb
1406
1409
  - app/jobs/spree_cm_commissioner/inventory_items/bulk_generate_permanent_items_job.rb
1407
1410
  - app/jobs/spree_cm_commissioner/inventory_items/generate_inventory_items_job.rb
@@ -1604,6 +1607,7 @@ files:
1604
1607
  - app/models/spree_cm_commissioner/inventory.rb
1605
1608
  - app/models/spree_cm_commissioner/inventory_hold.rb
1606
1609
  - app/models/spree_cm_commissioner/inventory_item.rb
1610
+ - app/models/spree_cm_commissioner/inventory_lock_movement.rb
1607
1611
  - app/models/spree_cm_commissioner/invite.rb
1608
1612
  - app/models/spree_cm_commissioner/invite_guest.rb
1609
1613
  - app/models/spree_cm_commissioner/invite_guest_group.rb
@@ -1881,6 +1885,7 @@ files:
1881
1885
  - app/overrides/spree/admin/shared/_order_tabs/guests.html.erb.deface
1882
1886
  - app/overrides/spree/admin/shared/_order_tabs/notifications.html.erb.deface
1883
1887
  - app/overrides/spree/admin/shared/_order_tabs/ticket_transfers.html.erb.deface
1888
+ - app/overrides/spree/admin/shared/_product_tabs/clock_history.html.erb.deface
1884
1889
  - app/overrides/spree/admin/shared/_product_tabs/google_wallet_html.erb.deface
1885
1890
  - app/overrides/spree/admin/shared/_product_tabs/guest_card.html.erb.deface
1886
1891
  - app/overrides/spree/admin/shared/_product_tabs/kyc.html.erb.deface
@@ -2302,6 +2307,7 @@ files:
2302
2307
  - app/services/spree_cm_commissioner/inventory_items/adjust_available_quantity.rb
2303
2308
  - app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities.rb
2304
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
2305
2311
  - app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold.rb
2306
2312
  - app/services/spree_cm_commissioner/inventory_items/bulk_generate_permanent_items.rb
2307
2313
  - app/services/spree_cm_commissioner/inventory_items/generate_non_permanent_item.rb
@@ -2580,6 +2586,7 @@ files:
2580
2586
  - app/views/spree/admin/inventory_holds/index.html.erb
2581
2587
  - app/views/spree/admin/inventory_items/prices.html.erb
2582
2588
  - app/views/spree/admin/inventory_items/stocks.html.erb
2589
+ - app/views/spree/admin/inventory_lock_movements/index.html.erb
2583
2590
  - app/views/spree/admin/inventory_locks/index.html.erb
2584
2591
  - app/views/spree/admin/inventory_monitorings/index.html.erb
2585
2592
  - app/views/spree/admin/kyc/_form.html.erb
@@ -2644,6 +2651,7 @@ files:
2644
2651
  - app/views/spree/admin/promotions/rules/_guest_occupations.html.erb
2645
2652
  - app/views/spree/admin/promotions/rules/_vendors.html.erb
2646
2653
  - app/views/spree/admin/promotions/rules/_weekend.html.erb
2654
+ - app/views/spree/admin/shared/_availability_percentage.html.erb
2647
2655
  - app/views/spree/admin/shared/_cms_pages_tabs.html.erb
2648
2656
  - app/views/spree/admin/shared/_customer_notification_tabs.html.erb
2649
2657
  - app/views/spree/admin/shared/_direct_upload.html.erb
@@ -3548,6 +3556,7 @@ files:
3548
3556
  - db/migrate/20260824120000_add_user_id_to_cm_waiting_room_sessions.rb
3549
3557
  - db/migrate/20260825092416_add_live_stock_window_to_spree_taxons.rb
3550
3558
  - db/migrate/20260828080349_add_normalized_login_to_spree_users.rb
3559
+ - db/migrate/20260831080000_create_cm_inventory_lock_movements.rb
3551
3560
  - docker-compose.yml
3552
3561
  - docs/api/scoped-access-token-endpoints.md
3553
3562
  - docs/option_types/attr_types.md