spree_cm_commissioner 2.12.2 → 2.12.3

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 (31) 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_hold_job.rb +1 -1
  13. data/app/models/spree_cm_commissioner/inventory_item.rb +7 -2
  14. data/app/models/spree_cm_commissioner/inventory_lock_movement.rb +20 -0
  15. data/app/overrides/spree/admin/shared/_product_tabs/clock_history.html.erb.deface +8 -0
  16. data/app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb +13 -11
  17. data/app/services/spree_cm_commissioner/inventory_items/adjust_available_quantity.rb +16 -11
  18. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities.rb +17 -13
  19. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold.rb +17 -13
  20. data/app/services/spree_cm_commissioner/inventory_items/move_to_locked.rb +16 -3
  21. data/app/views/spree/admin/inventory_lock_movements/index.html.erb +43 -0
  22. data/app/views/spree/admin/inventory_locks/index.html.erb +6 -2
  23. data/app/views/spree/admin/private_sales/_form.html.erb +1 -0
  24. data/app/views/spree/admin/private_sales/_stock.html.erb +50 -30
  25. data/app/views/spree/admin/shared/_availability_percentage.html.erb +18 -0
  26. data/config/locales/en.yml +15 -4
  27. data/config/locales/km.yml +0 -3
  28. data/config/routes.rb +2 -0
  29. data/db/migrate/20260831080000_create_cm_inventory_lock_movements.rb +16 -0
  30. data/lib/spree_cm_commissioner/version.rb +1 -1
  31. metadata +9 -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: b375a606aa20cb41edb29f05952ed603df382741dc452a218c55d63c2017e867
4
+ data.tar.gz: 4752e598b58b115740b2205a50517fabb95617e2cea760f1e7a8e9501823033b
5
5
  SHA512:
6
- metadata.gz: 67a54630258eda9e5bf72e26ce22177d6b1ce8da399d71434319403f1df8d4a71469ec115f7015b7b9064544688fe833f3230329e456e09eeb88b4f8a488212c
7
- data.tar.gz: 6aa524ac8653f151fd629c4bef58258212ee3eb67f5f1bba328f7e3e964c4efb9f67ec9692c359c80f4b2d8abac088541b7b692503d02bb8afecedc21e550072
6
+ metadata.gz: c2c18a4eed93d148dc07d07726d1575a5ebb44b5e743f991d452d1ea2754f5772ea20f6c909f729e02907c5d4e5ed8adc4560ae4afee057f38a351bbe87afa91
7
+ data.tar.gz: 64e3a068061c742b7684adddab10b3984094f5f77ef8a3ba9fbbe9978ba2d34c7ceb7b68c3bde121da8bde8ed4a4f80873d125813df01627cb50ac38d5045953
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.3)
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)
@@ -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)
@@ -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",
@@ -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
@@ -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.3'.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.3
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
@@ -1604,6 +1606,7 @@ files:
1604
1606
  - app/models/spree_cm_commissioner/inventory.rb
1605
1607
  - app/models/spree_cm_commissioner/inventory_hold.rb
1606
1608
  - app/models/spree_cm_commissioner/inventory_item.rb
1609
+ - app/models/spree_cm_commissioner/inventory_lock_movement.rb
1607
1610
  - app/models/spree_cm_commissioner/invite.rb
1608
1611
  - app/models/spree_cm_commissioner/invite_guest.rb
1609
1612
  - app/models/spree_cm_commissioner/invite_guest_group.rb
@@ -1881,6 +1884,7 @@ files:
1881
1884
  - app/overrides/spree/admin/shared/_order_tabs/guests.html.erb.deface
1882
1885
  - app/overrides/spree/admin/shared/_order_tabs/notifications.html.erb.deface
1883
1886
  - app/overrides/spree/admin/shared/_order_tabs/ticket_transfers.html.erb.deface
1887
+ - app/overrides/spree/admin/shared/_product_tabs/clock_history.html.erb.deface
1884
1888
  - app/overrides/spree/admin/shared/_product_tabs/google_wallet_html.erb.deface
1885
1889
  - app/overrides/spree/admin/shared/_product_tabs/guest_card.html.erb.deface
1886
1890
  - app/overrides/spree/admin/shared/_product_tabs/kyc.html.erb.deface
@@ -2580,6 +2584,7 @@ files:
2580
2584
  - app/views/spree/admin/inventory_holds/index.html.erb
2581
2585
  - app/views/spree/admin/inventory_items/prices.html.erb
2582
2586
  - app/views/spree/admin/inventory_items/stocks.html.erb
2587
+ - app/views/spree/admin/inventory_lock_movements/index.html.erb
2583
2588
  - app/views/spree/admin/inventory_locks/index.html.erb
2584
2589
  - app/views/spree/admin/inventory_monitorings/index.html.erb
2585
2590
  - app/views/spree/admin/kyc/_form.html.erb
@@ -2644,6 +2649,7 @@ files:
2644
2649
  - app/views/spree/admin/promotions/rules/_guest_occupations.html.erb
2645
2650
  - app/views/spree/admin/promotions/rules/_vendors.html.erb
2646
2651
  - app/views/spree/admin/promotions/rules/_weekend.html.erb
2652
+ - app/views/spree/admin/shared/_availability_percentage.html.erb
2647
2653
  - app/views/spree/admin/shared/_cms_pages_tabs.html.erb
2648
2654
  - app/views/spree/admin/shared/_customer_notification_tabs.html.erb
2649
2655
  - app/views/spree/admin/shared/_direct_upload.html.erb
@@ -3548,6 +3554,7 @@ files:
3548
3554
  - db/migrate/20260824120000_add_user_id_to_cm_waiting_room_sessions.rb
3549
3555
  - db/migrate/20260825092416_add_live_stock_window_to_spree_taxons.rb
3550
3556
  - db/migrate/20260828080349_add_normalized_login_to_spree_users.rb
3557
+ - db/migrate/20260831080000_create_cm_inventory_lock_movements.rb
3551
3558
  - docker-compose.yml
3552
3559
  - docs/api/scoped-access-token-endpoints.md
3553
3560
  - docs/option_types/attr_types.md