spree_cm_commissioner 2.11.0 → 2.12.0.pre.pre1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/spree/api/v2/operator/check_ins_controller.rb +22 -4
  4. data/app/controllers/spree/api/v2/operator/dashboard_crew_events_controller.rb +1 -1
  5. data/app/controllers/spree/api/v2/operator/event_charts_controller.rb +34 -12
  6. data/app/controllers/spree/api/v2/resource_controller_decorator.rb +7 -0
  7. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +1 -1
  8. data/app/controllers/spree/api/v2/storefront/queue_draft_orders_controller.rb +55 -0
  9. data/app/interactors/spree_cm_commissioner/payment_incomplete_notification_sender.rb +12 -1
  10. data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +8 -4
  11. data/app/jobs/spree_cm_commissioner/queue_draft_orders/process_job.rb +23 -0
  12. data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +2 -0
  13. data/app/models/spree_cm_commissioner/inventory_item.rb +26 -10
  14. data/app/models/spree_cm_commissioner/line_item_decorator.rb +8 -1
  15. data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +38 -19
  16. data/app/models/spree_cm_commissioner/stock/availability_checker.rb +15 -7
  17. data/app/models/spree_cm_commissioner/stock/availability_validator_decorator.rb +57 -5
  18. data/app/models/spree_cm_commissioner/stock/line_item_availability_checker.rb +7 -4
  19. data/app/models/spree_cm_commissioner/stock/order_availability_checker.rb +7 -1
  20. data/app/models/spree_cm_commissioner/taxon_decorator.rb +4 -0
  21. data/app/models/spree_cm_commissioner/variant_decorator.rb +5 -1
  22. data/app/queries/spree_cm_commissioner/dashboard_crew_event_query.rb +10 -5
  23. data/app/services/spree_cm_commissioner/cart/add_guest.rb +12 -7
  24. data/app/services/spree_cm_commissioner/inventory_holds/acquire.rb +27 -12
  25. data/app/services/spree_cm_commissioner/inventory_holds/convert.rb +10 -9
  26. data/app/services/spree_cm_commissioner/inventory_holds/release.rb +12 -11
  27. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities.rb +30 -9
  28. data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold.rb +33 -8
  29. data/app/services/spree_cm_commissioner/inventory_items/move_to_locked.rb +159 -0
  30. data/app/services/spree_cm_commissioner/inventory_items/reset.rb +38 -18
  31. data/app/services/spree_cm_commissioner/queue_draft_orders/enqueue.rb +64 -0
  32. data/app/services/spree_cm_commissioner/queue_draft_orders/process.rb +81 -0
  33. data/app/services/spree_cm_commissioner/queue_draft_orders/publish_status.rb +39 -0
  34. data/app/services/spree_cm_commissioner/redis_stock/restock.rb +16 -5
  35. data/app/services/spree_cm_commissioner/redis_stock/unstock.rb +12 -5
  36. data/app/views/spree/admin/stock_managements/index.html.erb +8 -0
  37. data/config/locales/en.yml +13 -4
  38. data/config/locales/km.yml +3 -1
  39. data/config/routes.rb +1 -0
  40. data/db/migrate/20260820000000_add_locked_quantities_to_cm_inventory_items.rb +12 -0
  41. data/lib/spree_cm_commissioner/cached_inventory_item.rb +9 -0
  42. data/lib/spree_cm_commissioner/version.rb +1 -1
  43. metadata +11 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58bbc65915f1f69096fabf6a1778dd9d810ebdbf8e788c2b1597e3b2173f2204
4
- data.tar.gz: c32858179db78672e07bc34c5f7395b59b228d5585e35647c3d5486cf0c0ea9c
3
+ metadata.gz: 1bcaa0a7cc8d39b507cd072bfd4576b28f72c83f2344b11ef2097306aa8454dd
4
+ data.tar.gz: bf8156fe0e0f2a41febcacc07188e8b0bbd9b2cd79c3ccad5a8d1394fa307db7
5
5
  SHA512:
6
- metadata.gz: 6493ccc10ddcb0dfae06115f11168207bdee8f8d687367108ba59e41885e10a989f8adcb17a220019039ef3a5c224ab3fe4356c6f1e90ae9130e4a607347e015
7
- data.tar.gz: 363d64fd5b2aa2f809163134dd050b85d46d529fe35a34994381c3bc7861acd4f57afab353fdd2772081e019a5f49eb78f4d9141ee9d7a44e06991b07170d764
6
+ metadata.gz: 19b750a2f5c27cabbc5c507a0e50424a26e295aefa94369787ff490376475f63e56993950e87a9a1b2c3be624f6ec29f2192a51a7e019bfed05e3ee4dcb54018
7
+ data.tar.gz: 4acf6f94be21c3960010398b9a431b950b2e3e7bffed455096ea0dc2ed05fca769bd53884b6ed26be86cc11db2a8c220c73711be800bccebf7fc005854454f11
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.11.0)
37
+ spree_cm_commissioner (2.12.0.pre.pre1)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -10,10 +10,14 @@ module Spree
10
10
  CACHE_EXPIRES_IN = 1.minute
11
11
 
12
12
  def collection
13
- @collection ||= SpreeCmCommissioner::CheckIn
14
- .where(checkinable_type: 'Spree::Taxon', checkinable_id: params[:taxon_id])
15
- .page(params[:page])
16
- .per(params[:per_page])
13
+ @collection ||= if restricted?
14
+ SpreeCmCommissioner::CheckIn.none
15
+ else
16
+ SpreeCmCommissioner::CheckIn
17
+ .where(checkinable_type: 'Spree::Taxon', checkinable_id: params[:taxon_id])
18
+ .page(params[:page])
19
+ .per(params[:per_page])
20
+ end
17
21
  end
18
22
 
19
23
  # override
@@ -41,6 +45,20 @@ module Spree
41
45
 
42
46
  private
43
47
 
48
+ # Non-organizers may only see check-in history for an event they're assigned
49
+ # to, and only while it's ongoing — once it ends, it becomes organizer-owned
50
+ # history (admins assign themselves the organizer role explicitly if they
51
+ # need access). A missing/nonexistent taxon_id is restricted the same way.
52
+ def restricted?
53
+ return false if spree_current_user.organizer?
54
+
55
+ taxon = Spree::Taxon.find_by(id: params[:taxon_id])
56
+ return true if taxon.nil?
57
+ return true unless spree_current_user.events.exists?(id: taxon.id)
58
+
59
+ taxon.ended?
60
+ end
61
+
44
62
  def model_class
45
63
  SpreeCmCommissioner::CheckIn
46
64
  end
@@ -7,7 +7,7 @@ module Spree
7
7
 
8
8
  def collection
9
9
  SpreeCmCommissioner::DashboardCrewEventQuery.new(
10
- user_id: spree_current_user.id,
10
+ user: spree_current_user,
11
11
  section: params[:section] || 'incoming'
12
12
  ).events
13
13
  end
@@ -7,23 +7,31 @@ module Spree
7
7
  ADDITIONAL_FIELDS = %i[participation entry_type].freeze
8
8
 
9
9
  def index
10
- kyc_fields = fetch_filtered_kyc_fields
11
- types = ADDITIONAL_FIELDS + kyc_fields
12
-
13
- resources = types.map do |type|
14
- # Create unique ID by using chart_type as the identifier
15
- SpreeCmCommissioner::EventChart.new(
16
- id: type.to_s,
17
- chart_type: type,
18
- product_charts: nil
19
- )
20
- end
10
+ resources = if restricted?
11
+ []
12
+ else
13
+ kyc_fields = fetch_filtered_kyc_fields
14
+ types = ADDITIONAL_FIELDS + kyc_fields
15
+
16
+ types.map do |type|
17
+ # Create unique ID by using chart_type as the identifier
18
+ SpreeCmCommissioner::EventChart.new(
19
+ id: type.to_s,
20
+ chart_type: type,
21
+ product_charts: nil
22
+ )
23
+ end
24
+ end
21
25
 
22
26
  render json: SpreeCmCommissioner::V2::Operator::PieChartEventAggregatorSerializer.new(resources).serializable_hash
23
27
  end
24
28
 
25
29
  def resource
26
- @resource = event_chart_aggregator_queries.call
30
+ @resource = if restricted?
31
+ SpreeCmCommissioner::EventChart.new(id: params[:id], chart_type: params[:id] || 'participation', product_charts: [])
32
+ else
33
+ event_chart_aggregator_queries.call
34
+ end
27
35
  end
28
36
 
29
37
  def resource_serializer
@@ -32,6 +40,20 @@ module Spree
32
40
 
33
41
  private
34
42
 
43
+ # Non-organizers may only see chart data for an event they're assigned to,
44
+ # and only while it's ongoing — once it ends, it becomes organizer-owned
45
+ # history (admins assign themselves the organizer role explicitly if they
46
+ # need access). A missing/nonexistent taxon_id is restricted the same way.
47
+ def restricted?
48
+ return false if spree_current_user&.organizer?
49
+
50
+ taxon = Spree::Taxon.find_by(id: params[:taxon_id])
51
+ return true if taxon.nil?
52
+ return true unless spree_current_user&.events&.exists?(id: taxon.id)
53
+
54
+ taxon.ended?
55
+ end
56
+
35
57
  def event_chart_aggregator_queries
36
58
  SpreeCmCommissioner::EventChartQueries.new(
37
59
  taxon_id: params[:taxon_id],
@@ -18,6 +18,13 @@ module Spree
18
18
  request.headers['X-Cm-App-Name']
19
19
  end
20
20
 
21
+ # Set by DefaultInterceptor (cm-market-app/packages/core) on every request, across every
22
+ # app (customer, operator, terminal) that shares it — the single source for device_id,
23
+ # replacing the old per-endpoint params[:device_id] (never actually sent by any client).
24
+ def device_id
25
+ request.headers['X-Cm-Device-Id']
26
+ end
27
+
21
28
  def required_schema_validation!
22
29
  return true if required_schema.nil?
23
30
 
@@ -16,7 +16,7 @@ module Spree
16
16
  user: spree_current_user,
17
17
  store: current_store,
18
18
  currency: current_currency,
19
- options: { device_id: params[:device_id], last_ip_address: request.remote_ip }
19
+ options: { device_id: device_id, last_ip_address: request.remote_ip }
20
20
  )
21
21
 
22
22
  if result.success?
@@ -0,0 +1,55 @@
1
+ module Spree
2
+ module Api
3
+ module V2
4
+ module Storefront
5
+ class QueueDraftOrdersController < ::Spree::Api::V2::ResourceController
6
+ # Creating the order (once the job runs) acquires the inventory hold, so honor the
7
+ # load-test bypass here too, same as DraftOrdersController.
8
+ include SpreeCmCommissioner::LoadTestBypassable
9
+
10
+ # Accepts a draft order request without building it: writes a queued Firestore status
11
+ # document and schedules the real build (SpreeCmCommissioner::DraftOrders::Create) on a
12
+ # background job. The app watches the returned document for the eventual outcome
13
+ # instead of waiting on this request.
14
+ #
15
+ # Endpoint: POST /api/v2/storefront/queue_draft_orders
16
+ # Params: same as POST /api/v2/storefront/draft_orders — line_items: Array of
17
+ # { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} } (required)
18
+ def create
19
+ result = SpreeCmCommissioner::QueueDraftOrders::Enqueue.call(
20
+ create_type: 'generic',
21
+ creation_params: {
22
+ line_items: draft_order_line_items,
23
+ user: spree_current_user,
24
+ store: current_store,
25
+ currency: current_currency,
26
+ options: { device_id: device_id, last_ip_address: request.remote_ip }
27
+ },
28
+ locale: I18n.locale.to_s
29
+ )
30
+
31
+ if result.success?
32
+ render_serialized_payload { result.value }
33
+ else
34
+ render_error_payload(result.error)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # .to_h.deep_symbolize_keys converts every level (including line_items and their nested
41
+ # options/metadata) from ActionController::Parameters into plain Ruby Hashes with symbol
42
+ # keys — required because this crosses an ActiveJob serialization boundary
43
+ # (QueueDraftOrders::ProcessJob), unlike DraftOrdersController's version of this method,
44
+ # which hands Parameters straight to DraftOrders::Create within the same request.
45
+ def draft_order_line_items
46
+ params.permit(line_items: [:variant_id, :quantity, { options: {}, public_metadata: {}, private_metadata: {} }])
47
+ .to_h
48
+ .deep_symbolize_keys
49
+ .fetch(:line_items, [])
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -8,12 +8,23 @@ module SpreeCmCommissioner
8
8
  SpreeCmCommissioner::OrderGeneralNotification.with(
9
9
  notificable: order,
10
10
  title: title,
11
- message: message(order)
11
+ message: message(order),
12
+ payload: payload(order)
12
13
  ).deliver_later(order.user)
13
14
  end
14
15
 
15
16
  private
16
17
 
18
+ # Overrides notification_type so the app can route this tap to the payment screen instead of
19
+ # the booking details every other OrderGeneralNotification lands on. The screen loads the cart
20
+ # by token, not by number, so the token has to ride along or the tap has nothing to open.
21
+ def payload(order)
22
+ {
23
+ notification_type: 'payment_incomplete_notification',
24
+ order_token: order.token
25
+ }
26
+ end
27
+
17
28
  def title
18
29
  I18n.t('notifications.spree_cm_commissioner.payment_incomplete_notification.title')
19
30
  end
@@ -24,13 +24,17 @@ module SpreeCmCommissioner
24
24
  "order:#{options[:order_id]}:hold:#{adjustments_fingerprint(options[:inventory_id_and_quantities])}"
25
25
  end
26
26
 
27
- # Readable, order-independent fingerprint of the adjustment payload, e.g. "1=2,2=-1".
27
+ # Readable, order-independent fingerprint of the adjustment payload, e.g. "1=2,2=-1:locked".
28
28
  # Delimiter hierarchy: ":" separates key fields, "," separates adjustments, "=" pairs id/qty.
29
+ #
30
+ # The pool is part of the identity: a mixed order can carry a public and a locked adjustment
31
+ # with the same inventory_id and quantity, and without this they fingerprint identically — so
32
+ # one of the two would be dropped as a duplicate and its counter never mirrored.
29
33
  def adjustments_fingerprint(adjustments)
30
34
  Array(adjustments)
31
- .map { |adj| adj.with_indifferent_access.values_at(:inventory_id, :quantity) }
32
- .sort_by { |inventory_id, quantity| [inventory_id.to_s, quantity.to_s] }
33
- .map { |inventory_id, quantity| "#{inventory_id}=#{quantity}" }
35
+ .map { |adj| adj.with_indifferent_access.values_at(:inventory_id, :quantity, :locked) }
36
+ .sort_by { |inventory_id, quantity, locked| [inventory_id.to_s, quantity.to_s, locked.to_s] }
37
+ .map { |inventory_id, quantity, locked| locked ? "#{inventory_id}=#{quantity}:locked" : "#{inventory_id}=#{quantity}" }
34
38
  .join(',')
35
39
  end
36
40
 
@@ -0,0 +1,23 @@
1
+ module SpreeCmCommissioner
2
+ module QueueDraftOrders
3
+ # Thin trigger for QueueDraftOrders::Process — owns only the locale wrapping (so any
4
+ # user-facing message the create call surfaces renders in the request's original locale, not
5
+ # the job worker's default), not the order-creation/Firestore-publishing logic itself.
6
+ class ProcessJob < SpreeCmCommissioner::ApplicationJob
7
+ # Own queue (not the inherited :default) — before this feature, order creation happened
8
+ # synchronously in the request cycle, i.e. at effectively top priority. Sharing :default's
9
+ # weight-3 slot with unrelated background work would let a :default backlog slow down
10
+ # checkout, the opposite of what a flash-sale/high-demand feature should do. See
11
+ # bookmebus/cm-market-server's config/sidekiq.yml for the matching queue weight.
12
+ queue_as :queue_draft_orders
13
+
14
+ def perform(document_path, create_type, creation_params, locale = nil)
15
+ I18n.with_locale(locale.presence || I18n.default_locale) do
16
+ SpreeCmCommissioner::QueueDraftOrders::Process.call(
17
+ document_path: document_path, create_type: create_type, creation_params: creation_params
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -51,6 +51,8 @@ module SpreeCmCommissioner
51
51
  store_public_metadata :max_price, :string
52
52
  store_public_metadata :currency, :string
53
53
 
54
+ store_public_metadata :organizer_rule, :text
55
+
54
56
  store_public_metadata :spotify_embed_url, :string
55
57
  store_public_metadata :spotify_embed_skeleton_color, :string, default: '#900033'
56
58
 
@@ -22,6 +22,8 @@ module SpreeCmCommissioner
22
22
  # Validation
23
23
  validates :quantity_available, numericality: { greater_than_or_equal_to: 0 }
24
24
  validates :quantity_on_hold, numericality: { greater_than_or_equal_to: 0 }
25
+ validates :quantity_locked, numericality: { greater_than_or_equal_to: 0 }
26
+ validates :quantity_locked_on_hold, numericality: { greater_than_or_equal_to: 0 }
25
27
  validates :max_capacity, numericality: { greater_than_or_equal_to: 0 } # Originally inventory of each variant.
26
28
  validates :inventory_date, presence: true, if: :permanent_stock?
27
29
  validates :variant_id, uniqueness: { scope: :inventory_date, message: -> (object, _data) { "The variant is taken on #{object.inventory_date}" } }
@@ -40,15 +42,27 @@ module SpreeCmCommissioner
40
42
  # This is temporary until we have a more robust cache invalidation strategy in place.
41
43
  def schedule_product_cache_invalidation
42
44
  return unless ecommerce?
43
- return unless saved_change_to_quantity_available?
45
+ return unless saved_change_to_quantity_available? || saved_change_to_quantity_locked?
44
46
 
45
- # Only fires on in_stock status transition (0 ↔ N); non-zero to non-zero changes are skipped.
46
- old_val, new_val = saved_change_to_quantity_available
47
- return if old_val.to_i.positive? && new_val.to_i.positive?
47
+ # Only fires on an in_stock status transition (0 ↔ N); non-zero to non-zero changes are skipped.
48
+ #
49
+ # Measured across BOTH pools, because Variant#in_stock? counts both. Watching quantity_available
50
+ # alone would miss an agent buying out the locked pool while public stock was already 0 — the
51
+ # column never moves, so no task is scheduled and the card keeps advertising stock that is gone.
52
+ return if total_quantity_before_last_save.positive? == (quantity_available + quantity_locked).positive?
48
53
 
49
54
  SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: variant.product)
50
55
  end
51
56
 
57
+ # Both pools as they stood before this save. saved_change_to_* is nil for a column that did not
58
+ # change, so an unchanged pool falls back to its current value rather than to zero.
59
+ def total_quantity_before_last_save
60
+ available_before = saved_change_to_quantity_available&.first || quantity_available
61
+ locked_before = saved_change_to_quantity_locked&.first || quantity_locked
62
+
63
+ available_before.to_i + locked_before.to_i
64
+ end
65
+
52
66
  def public_quantity_available
53
67
  [quantity_available, MAX_DISPLAY_STOCK].min
54
68
  end
@@ -87,19 +101,21 @@ module SpreeCmCommissioner
87
101
  end
88
102
  end
89
103
 
90
- def redis_key
91
- "inventory:#{id}"
104
+ # Stock and hold keys. `locked: true` addresses the withheld pool — see LineItem#locked_stock?,
105
+ # which is what every caller on the checkout path passes.
106
+ def redis_key(locked: false)
107
+ locked ? "inventory:locked:#{id}" : "inventory:#{id}"
92
108
  end
93
109
 
94
- def redis_hold_key
95
- "inventory:on_hold:#{id}"
110
+ def redis_hold_key(locked: false)
111
+ locked ? "inventory:locked_on_hold:#{id}" : "inventory:on_hold:#{id}"
96
112
  end
97
113
 
98
114
  def quantity_in_redis
99
115
  SpreeCmCommissioner.inventory_redis_pool.with { |redis| redis.get(redis_key).to_i }
100
116
  end
101
117
 
102
- def adjust_quantity_in_redis(quantity)
118
+ def adjust_quantity_in_redis(quantity, locked: false)
103
119
  SpreeCmCommissioner.inventory_redis_pool.with do |redis|
104
120
  # Always update Redis cache, even if it doesn't exist yet.
105
121
  # This prevents admin adjustments from being lost when cache is later initialized.
@@ -116,7 +132,7 @@ module SpreeCmCommissioner
116
132
  return new_value
117
133
  LUA
118
134
 
119
- redis.eval(script, keys: [redis_key], argv: [quantity, redis_expired_in])
135
+ redis.eval(script, keys: [redis_key(locked: locked)], argv: [quantity, redis_expired_in])
120
136
  end
121
137
  end
122
138
 
@@ -16,6 +16,7 @@ module SpreeCmCommissioner
16
16
  }, through: :variant
17
17
 
18
18
  base.has_many :taxons, class_name: 'Spree::Taxon', through: :product
19
+
19
20
  base.has_many :guests, class_name: 'SpreeCmCommissioner::Guest', dependent: :destroy
20
21
  base.has_many :guests_with_blocks, -> { where.not(block_id: nil) }, class_name: 'SpreeCmCommissioner::Guest'
21
22
  base.has_many :reserved_blocks, through: :guests, class_name: 'SpreeCmCommissioner::ReservedBlock'
@@ -43,6 +44,12 @@ module SpreeCmCommissioner
43
44
 
44
45
  base.store_public_metadata :completion_steps, :array
45
46
 
47
+ # Set by DraftOrders::Create from the verified PrivateSale, never from params — the client
48
+ # cannot ask for locked stock, the server decides which lines the token covers. Persisted
49
+ # with the line item, so the same value answers AvailabilityValidator on this save and
50
+ # Restock on a cancellation months later.
51
+ base.store_private_metadata :locked_stock, :boolean, default: false
52
+
46
53
  base.accepts_nested_attributes_for :guests, allow_destroy: true
47
54
 
48
55
  def base.json_api_columns
@@ -265,7 +272,7 @@ module SpreeCmCommissioner
265
272
  def sufficient_stock?
266
273
  return true if order&.ticket_transfer?
267
274
 
268
- SpreeCmCommissioner::Stock::LineItemAvailabilityChecker.new(self).can_supply?(quantity)
275
+ SpreeCmCommissioner::Stock::LineItemAvailabilityChecker.new(self).checker.can_supply?(quantity)
269
276
  end
270
277
 
271
278
  def month
@@ -8,32 +8,51 @@ module SpreeCmCommissioner
8
8
  end
9
9
 
10
10
  # output: [ CachedInventoryItem(...), CachedInventoryItem(...) ]
11
+ #
12
+ # Reads BOTH pools in one MGET. Reporting only the pool a caller asked for would mean taking a
13
+ # pool argument here and threading it through every service that warms or reads stock; four
14
+ # numbers in the row we already fetch costs one MGET either way.
11
15
  def call
12
- stock_keys = inventory_items.map(&:redis_key)
13
- hold_keys = inventory_items.map(&:redis_hold_key)
14
- all_keys = stock_keys + hold_keys
15
- return [] unless all_keys.any?
16
+ return [] if inventory_items.empty?
16
17
 
17
- all_counts = SpreeCmCommissioner.inventory_redis_pool.with { |redis| redis.mget(*all_keys) }
18
- stock_counts = all_counts.first(inventory_items.length)
19
- hold_counts = all_counts.last(inventory_items.length)
18
+ keys = pool_keys
19
+ counts = SpreeCmCommissioner.inventory_redis_pool.with { |redis| redis.mget(*keys.flatten) }
20
+ counts_by_pool = counts.each_slice(inventory_items.length).to_a
20
21
 
21
- inventory_items.map.with_index do |inventory_item, i|
22
- ::SpreeCmCommissioner::CachedInventoryItem.new(
23
- inventory_key: stock_keys[i],
24
- inventory_hold_key: hold_keys[i],
25
- active: inventory_item.active?,
26
- quantity_available: cache_inventory(stock_keys[i], inventory_item, inventory_item.quantity_available, stock_counts[i]),
27
- quantity_on_hold: cache_inventory(hold_keys[i], inventory_item, inventory_item.quantity_on_hold, hold_counts[i]),
28
- max_capacity: inventory_item.max_capacity,
29
- inventory_item_id: inventory_item.id,
30
- variant_id: inventory_item.variant_id
31
- )
32
- end
22
+ inventory_items.map.with_index { |item, i| build_cached_item(item, i, keys, counts_by_pool) }
33
23
  end
34
24
 
35
25
  private
36
26
 
27
+ # [public_stock, public_hold, locked_stock, locked_hold] — one row of keys per pool, each in the
28
+ # same item order, so a slice of the MGET result lines up with the item at the same index.
29
+ def pool_keys
30
+ [
31
+ inventory_items.map(&:redis_key),
32
+ inventory_items.map(&:redis_hold_key),
33
+ inventory_items.map { |item| item.redis_key(locked: true) },
34
+ inventory_items.map { |item| item.redis_hold_key(locked: true) }
35
+ ]
36
+ end
37
+
38
+ def build_cached_item(item, index, keys, counts_by_pool)
39
+ stock_keys, hold_keys, locked_keys, locked_hold_keys = keys
40
+ stock, hold, locked, locked_hold = counts_by_pool
41
+
42
+ ::SpreeCmCommissioner::CachedInventoryItem.new(
43
+ inventory_key: stock_keys[index],
44
+ inventory_hold_key: hold_keys[index],
45
+ active: item.active?,
46
+ quantity_available: cache_inventory(stock_keys[index], item, item.quantity_available, stock[index]),
47
+ quantity_on_hold: cache_inventory(hold_keys[index], item, item.quantity_on_hold, hold[index]),
48
+ quantity_locked: cache_inventory(locked_keys[index], item, item.quantity_locked, locked[index]),
49
+ quantity_locked_on_hold: cache_inventory(locked_hold_keys[index], item, item.quantity_locked_on_hold, locked_hold[index]),
50
+ max_capacity: item.max_capacity,
51
+ inventory_item_id: item.id,
52
+ variant_id: item.variant_id
53
+ )
54
+ end
55
+
37
56
  def cache_inventory(key, inventory_item, db_value, count_in_redis)
38
57
  return count_in_redis.to_i if count_in_redis.present?
39
58
  return db_value unless inventory_item.active?
@@ -3,27 +3,35 @@ module SpreeCmCommissioner
3
3
  class AvailabilityChecker
4
4
  attr_reader :variant, :options, :error_message
5
5
 
6
+ # options[:locked] checks the operator's withheld allotment instead of public stock — set for a
7
+ # line item whose LineItem#locked_stock? is true.
6
8
  def initialize(variant, options = {})
7
9
  @variant = variant
8
10
  @options = options
9
11
  @error_message = nil
10
12
  end
11
13
 
12
- def can_supply?(quantity = 1)
14
+ # Can `quantity` be supplied? By default from THIS line's pool — which is what add-to-cart gates
15
+ # on, so it must never quietly include stock the caller cannot buy.
16
+ #
17
+ # include_locked: true adds the locked pool to the total instead, for callers asking "is this
18
+ # worth showing at all" rather than "may this order have it". See Variant#in_stock?.
19
+ def can_supply?(quantity = 1, include_locked: false)
13
20
  return false unless variant.available?
14
21
  return true unless variant.should_track_inventory?
15
22
  return true if variant.backorderable?
16
23
  return true if variant.need_confirmation?
24
+ return false if cached_inventory_items.empty?
17
25
 
18
- variant_available?(quantity)
26
+ cached_inventory_items.all? { |item| item.active? && quantity_in_pool(item, include_locked: include_locked) >= quantity }
19
27
  end
20
28
 
21
- def variant_available?(quantity = 1)
22
- return false if cached_inventory_items.empty?
29
+ # The cache row carries both pools, so choosing between them is a read, not another Redis trip.
30
+ def quantity_in_pool(item, include_locked: false)
31
+ return item.quantity_available + item.quantity_locked if include_locked
32
+ return item.quantity_locked if options[:locked]
23
33
 
24
- cached_inventory_items.all? do |cached_inventory_item|
25
- cached_inventory_item.active? && cached_inventory_item.quantity_available >= quantity
26
- end
34
+ item.quantity_available
27
35
  end
28
36
 
29
37
  def cached_inventory_items
@@ -2,14 +2,66 @@ module SpreeCmCommissioner
2
2
  module Stock
3
3
  module AvailabilityValidatorDecorator
4
4
  # override
5
- def item_available?(line_item, quantity)
5
+ # Complexity cops: core's own `validate` already trips these under similarly strict config.
6
+ def validate(line_item) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
7
+ unit_count = line_item.inventory_units.reject(&:pending?).sum(&:quantity)
8
+ return if unit_count >= line_item.quantity
9
+
10
+ quantity = line_item.quantity - unit_count
11
+ return if quantity.zero?
12
+
6
13
  # Ticket-transfer orders move existing inventory between guests rather than
7
- # purchasing new stock, so they must not be gated by the live stock check.
8
- return true if line_item.order&.ticket_transfer?
14
+ # purchasing new stock, so they must not be gated by the live stock check — and
15
+ # skipping it here means we never even build a checker for them.
16
+ return if line_item.order&.ticket_transfer?
17
+
18
+ checker = SpreeCmCommissioner::Stock::LineItemAvailabilityChecker.new(line_item).checker
19
+ return if checker.can_supply?(quantity)
20
+
21
+ if line_item.variant.available?
22
+ if line_item.locked_stock?
23
+ # own allotment exhausted — ordinary sold out, never "reserved"
24
+ add_sold_out_error(line_item)
25
+ else
26
+ # public line: own pool already failed — check both pools together
27
+ if checker.can_supply?(quantity, include_locked: true) # rubocop:disable Style/IfInsideElse -- elsif would collapse the locked/public split
28
+ add_reserved_error(line_item)
29
+ else
30
+ add_sold_out_error(line_item)
31
+ end
32
+ end
33
+ else
34
+ add_inactive_product_error(line_item)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # Formatting below carries core's own style for this error (display_name construction,
41
+ # multi-line errors.add) verbatim — reformatting to satisfy the cops would fight that.
42
+ # rubocop:disable Rails/Present, Layout/MultilineMethodCallBraceLayout
43
+ def add_sold_out_error(line_item)
44
+ variant = line_item.variant
45
+ display_name = variant.name.to_s
46
+ display_name += " (#{variant.options_text})" unless variant.options_text.blank?
47
+
48
+ line_item.errors.add(:quantity,
49
+ :selected_quantity_not_available,
50
+ message: Spree.t(:selected_quantity_not_available, item: display_name.inspect))
51
+ end
52
+
53
+ def add_reserved_error(line_item)
54
+ line_item.errors.add(:quantity,
55
+ :stock_reserved,
56
+ message: I18n.t('spree_cm_commissioner.stock.reserved'))
57
+ end
9
58
 
10
- SpreeCmCommissioner::Stock::LineItemAvailabilityChecker.new(line_item)
11
- .can_supply?(quantity)
59
+ def add_inactive_product_error(line_item)
60
+ line_item.errors.add(:base,
61
+ :only_active_products_can_be_added_to_cart,
62
+ message: Spree.t(:only_active_products_can_be_added_to_cart))
12
63
  end
64
+ # rubocop:enable Rails/Present, Layout/MultilineMethodCallBraceLayout
13
65
  end
14
66
  end
15
67
  end
@@ -7,15 +7,18 @@ module SpreeCmCommissioner
7
7
  @line_item = line_item
8
8
  end
9
9
 
10
- def can_supply?(quantity)
11
- ::SpreeCmCommissioner::Stock::AvailabilityChecker.new(line_item.variant, options)
12
- .can_supply?(quantity)
10
+ def checker
11
+ @checker ||= ::SpreeCmCommissioner::Stock::AvailabilityChecker.new(line_item.variant, options)
13
12
  end
14
13
 
15
14
  def options
16
15
  {
17
16
  from_date: line_item.date_range.first,
18
- to_date: line_item.date_range.last
17
+ to_date: line_item.date_range.last,
18
+ # A locked-stock line is measured against the operator's allotment, not public stock. Without
19
+ # this it would read sold out whenever public stock ran out — which is precisely when the
20
+ # allotment is supposed to still be sellable.
21
+ locked: line_item.locked_stock?
19
22
  }
20
23
  end
21
24
  end
@@ -40,7 +40,13 @@ module SpreeCmCommissioner
40
40
  return true if line_item.variant.backorderable?
41
41
  return true if line_item.variant.need_confirmation?
42
42
 
43
- cached_inventory_items.all? { |item| item.active? && item.quantity_available >= line_item.quantity }
43
+ # Each line is measured against ITS OWN pool. The cache row carries both, so this needs no
44
+ # extra query — before it did, and a locked-stock line was silently checked against public
45
+ # stock, i.e. read sold out exactly when its allotment was still sellable.
46
+ cached_inventory_items.all? do |item|
47
+ quantity = line_item.locked_stock? ? item.quantity_locked : item.quantity_available
48
+ item.active? && quantity >= line_item.quantity
49
+ end
44
50
  end
45
51
  end
46
52
  end
@@ -162,6 +162,10 @@ module SpreeCmCommissioner
162
162
  "https://#{Spree::Store.default.url}/t/#{permalink}"
163
163
  end
164
164
 
165
+ def ended?
166
+ to_date.present? && to_date <= Time.current
167
+ end
168
+
165
169
  def send_transaction_email_to_organizers
166
170
  return unless kind == 'event' && level == 1
167
171