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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/app/controllers/spree/admin/inventory_lock_movements_controller.rb +37 -0
- data/app/controllers/spree/admin/inventory_locks_controller.rb +4 -1
- data/app/controllers/spree/admin/private_sales_controller.rb +2 -0
- data/app/helpers/spree_cm_commissioner/admin/inventory_locks_helper.rb +33 -0
- data/app/jobs/spree_cm_commissioner/inventory_holds/bulk_release_stale_job.rb +1 -1
- data/app/jobs/spree_cm_commissioner/inventory_holds/bulk_release_stale_payment_locked_job.rb +1 -1
- data/app/jobs/spree_cm_commissioner/inventory_holds/release_job.rb +1 -1
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_by_variant_job.rb +2 -0
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_job.rb +2 -0
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert_job.rb +47 -0
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +1 -1
- data/app/models/spree_cm_commissioner/inventory_item.rb +7 -2
- data/app/models/spree_cm_commissioner/inventory_lock_movement.rb +20 -0
- data/app/overrides/spree/admin/shared/_product_tabs/clock_history.html.erb.deface +8 -0
- data/app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb +13 -11
- data/app/services/spree_cm_commissioner/inventory_holds/convert.rb +44 -23
- data/app/services/spree_cm_commissioner/inventory_items/adjust_available_quantity.rb +16 -11
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities.rb +17 -13
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_convert.rb +96 -0
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold.rb +17 -13
- data/app/services/spree_cm_commissioner/inventory_items/move_to_locked.rb +16 -3
- data/app/services/spree_cm_commissioner/redis_stock/unstock.rb +4 -0
- data/app/views/spree/admin/inventory_lock_movements/index.html.erb +43 -0
- data/app/views/spree/admin/inventory_locks/index.html.erb +6 -2
- data/app/views/spree/admin/private_sales/_form.html.erb +1 -0
- data/app/views/spree/admin/private_sales/_stock.html.erb +50 -30
- data/app/views/spree/admin/shared/_availability_percentage.html.erb +18 -0
- data/config/locales/en.yml +15 -4
- data/config/locales/km.yml +0 -3
- data/config/routes.rb +2 -0
- data/db/migrate/20260831080000_create_cm_inventory_lock_movements.rb +16 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f4502090937a0e1728668be217bf74bf24b37a424b3b412c1df11e061e72268c
|
|
4
|
+
data.tar.gz: bca33ab83038577e339e01d24cc008c48d71e3af09a3131aa102f35d70433644
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca38d3fff9dd926ed84e6bf3ff0565d9af7b8fa6e816448b386fb460a757fdb7b31788dab65d287bedcaba869cd47b4351960bb8ddf0eef86c05caf3db118257
|
|
7
|
+
data.tar.gz: ea4d544a09afe3fe32fce93f52587ffd1b25c0cf986c2ec94a291a2e07e2744d674acb6ef6c0c6a2886303360318d9b9f0d3cfabc2427a6fe775b7e025313e49
|
data/Gemfile.lock
CHANGED
|
@@ -0,0 +1,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
|
data/app/jobs/spree_cm_commissioner/inventory_holds/bulk_release_stale_payment_locked_job.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module SpreeCmCommissioner
|
|
2
2
|
module InventoryHolds
|
|
3
3
|
class BulkReleaseStalePaymentLockedJob < SpreeCmCommissioner::ApplicationJob
|
|
4
|
-
queue_as :
|
|
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 :
|
|
6
|
+
queue_as :inventory_item
|
|
7
7
|
|
|
8
8
|
def perform(options = {})
|
|
9
9
|
hold = SpreeCmCommissioner::InventoryHold.find(options[:hold_id])
|
|
@@ -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
|
|
@@ -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
|
-
#
|
|
124
|
-
#
|
|
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.
|
|
25
|
-
#
|
|
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
|
|
174
|
-
#
|
|
175
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
17
|
+
convert_hold_redis!(order)
|
|
18
|
+
enqueue_sync_inventory(order)
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -28,8 +28,19 @@ module SpreeCmCommissioner
|
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
# Moves a held unit to a permanent sale by decrementing the hold counter AND the stock
|
|
32
|
+
# counter together, in one atomic Lua call. These used to be two separate Redis round trips
|
|
33
|
+
# (hold decrement here, stock decrement in RedisStock::Unstock, called a moment later from
|
|
34
|
+
# OrderStateMachine#unstock_inventory!) — a concurrent Acquire could read Redis in the gap
|
|
35
|
+
# between them and see the hold count already down but the stock count not yet down, i.e. a
|
|
36
|
+
# phantom unit of "available" capacity that was never actually freed, letting one order too
|
|
37
|
+
# many acquire a hold.
|
|
38
|
+
#
|
|
39
|
+
# RedisStock::Unstock skips any line item that should_hold_inventory? for exactly this reason
|
|
40
|
+
# — this method is now the only place their stock key gets decremented.
|
|
41
|
+
def convert_hold_redis!(order)
|
|
42
|
+
stock_keys = []
|
|
43
|
+
hold_keys = []
|
|
33
44
|
quantities = []
|
|
34
45
|
warmable_items = []
|
|
35
46
|
|
|
@@ -42,34 +53,44 @@ module SpreeCmCommissioner
|
|
|
42
53
|
|
|
43
54
|
# Skip .active scope: holds on past-dated items must also be cleared (e.g. delayed conversion after trip date).
|
|
44
55
|
li.inventory_items.each do |item|
|
|
45
|
-
|
|
56
|
+
stock_keys << item.redis_key(locked: locked)
|
|
57
|
+
hold_keys << item.redis_hold_key(locked: locked)
|
|
46
58
|
quantities << li.quantity
|
|
47
59
|
warmable_items << item
|
|
48
60
|
end
|
|
49
61
|
end
|
|
50
62
|
|
|
51
|
-
return if
|
|
52
|
-
|
|
53
|
-
clear_hold_lua_script = <<~LUA
|
|
54
|
-
local keys = KEYS
|
|
55
|
-
local quantities = ARGV
|
|
56
|
-
for i, key in ipairs(keys) do
|
|
57
|
-
redis.call('DECRBY', key, tonumber(quantities[i]))
|
|
58
|
-
end
|
|
59
|
-
return 1
|
|
60
|
-
LUA
|
|
63
|
+
return if hold_keys.empty?
|
|
61
64
|
|
|
62
65
|
SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(warmable_items).call
|
|
66
|
+
|
|
67
|
+
interleaved_keys = stock_keys.zip(hold_keys).flatten
|
|
63
68
|
SpreeCmCommissioner.inventory_redis_pool.with do |redis|
|
|
64
|
-
redis.eval(
|
|
69
|
+
redis.eval(convert_hold_lua_script, keys: interleaved_keys, argv: quantities)
|
|
65
70
|
end
|
|
66
71
|
end
|
|
67
72
|
|
|
68
|
-
def
|
|
69
|
-
|
|
73
|
+
def convert_hold_lua_script
|
|
74
|
+
<<~LUA
|
|
75
|
+
-- KEYS = [stock_key_1, hold_key_1, stock_key_2, hold_key_2, ...]
|
|
76
|
+
-- ARGV = [qty_1, qty_2, ...]
|
|
77
|
+
for i = 1, #ARGV do
|
|
78
|
+
redis.call('DECRBY', KEYS[(i-1)*2+1], tonumber(ARGV[i]))
|
|
79
|
+
redis.call('DECRBY', KEYS[(i-1)*2+2], tonumber(ARGV[i]))
|
|
80
|
+
end
|
|
81
|
+
return 1
|
|
82
|
+
LUA
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Keeps the DB mirrors in sync with both halves of convert_hold_redis! (quantity_on_hold drops
|
|
86
|
+
# because the hold is gone, quantity_available drops because the unit is permanently sold) via
|
|
87
|
+
# ONE job that applies both under a single row lock per item — see
|
|
88
|
+
# InventoryItems::BulkAdjustQuantitiesOnConvert for why this isn't the usual two separate jobs.
|
|
89
|
+
def enqueue_sync_inventory(order)
|
|
90
|
+
adjustments = order.line_items.flat_map do |line_item|
|
|
70
91
|
next [] unless line_item.should_hold_inventory?
|
|
71
92
|
|
|
72
|
-
# Skip .active scope for the same reason as
|
|
93
|
+
# Skip .active scope for the same reason as convert_hold_redis!.
|
|
73
94
|
line_item.inventory_items.map do |inventory_item|
|
|
74
95
|
{
|
|
75
96
|
inventory_id: inventory_item.id,
|
|
@@ -79,12 +100,12 @@ module SpreeCmCommissioner
|
|
|
79
100
|
end
|
|
80
101
|
end
|
|
81
102
|
|
|
82
|
-
return if
|
|
103
|
+
return if adjustments.blank?
|
|
83
104
|
|
|
84
|
-
SpreeCmCommissioner::InventoryItems::
|
|
105
|
+
SpreeCmCommissioner::InventoryItems::BulkAdjustQuantitiesOnConvertJob.perform_later(
|
|
85
106
|
order_id: order.id,
|
|
86
|
-
caller_source: "#{self.class.name}#
|
|
87
|
-
inventory_id_and_quantities:
|
|
107
|
+
caller_source: "#{self.class.name}#enqueue_sync_inventory",
|
|
108
|
+
inventory_id_and_quantities: adjustments
|
|
88
109
|
)
|
|
89
110
|
end
|
|
90
111
|
end
|
|
@@ -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.
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
|
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
|
-
|
|
80
|
-
</
|
|
81
|
-
</
|
|
82
|
-
|
|
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 %>
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/config/locales/km.yml
CHANGED
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
|
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.
|
|
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-
|
|
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
|