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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/app/controllers/spree/api/v2/operator/check_ins_controller.rb +22 -4
- data/app/controllers/spree/api/v2/operator/dashboard_crew_events_controller.rb +1 -1
- data/app/controllers/spree/api/v2/operator/event_charts_controller.rb +34 -12
- data/app/controllers/spree/api/v2/resource_controller_decorator.rb +7 -0
- data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +1 -1
- data/app/controllers/spree/api/v2/storefront/queue_draft_orders_controller.rb +55 -0
- data/app/interactors/spree_cm_commissioner/payment_incomplete_notification_sender.rb +12 -1
- data/app/jobs/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold_job.rb +8 -4
- data/app/jobs/spree_cm_commissioner/queue_draft_orders/process_job.rb +23 -0
- data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +2 -0
- data/app/models/spree_cm_commissioner/inventory_item.rb +26 -10
- data/app/models/spree_cm_commissioner/line_item_decorator.rb +8 -1
- data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +38 -19
- data/app/models/spree_cm_commissioner/stock/availability_checker.rb +15 -7
- data/app/models/spree_cm_commissioner/stock/availability_validator_decorator.rb +57 -5
- data/app/models/spree_cm_commissioner/stock/line_item_availability_checker.rb +7 -4
- data/app/models/spree_cm_commissioner/stock/order_availability_checker.rb +7 -1
- data/app/models/spree_cm_commissioner/taxon_decorator.rb +4 -0
- data/app/models/spree_cm_commissioner/variant_decorator.rb +5 -1
- data/app/queries/spree_cm_commissioner/dashboard_crew_event_query.rb +10 -5
- data/app/services/spree_cm_commissioner/cart/add_guest.rb +12 -7
- data/app/services/spree_cm_commissioner/inventory_holds/acquire.rb +27 -12
- data/app/services/spree_cm_commissioner/inventory_holds/convert.rb +10 -9
- data/app/services/spree_cm_commissioner/inventory_holds/release.rb +12 -11
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities.rb +30 -9
- data/app/services/spree_cm_commissioner/inventory_items/bulk_adjust_quantities_on_hold.rb +33 -8
- data/app/services/spree_cm_commissioner/inventory_items/move_to_locked.rb +159 -0
- data/app/services/spree_cm_commissioner/inventory_items/reset.rb +38 -18
- data/app/services/spree_cm_commissioner/queue_draft_orders/enqueue.rb +64 -0
- data/app/services/spree_cm_commissioner/queue_draft_orders/process.rb +81 -0
- data/app/services/spree_cm_commissioner/queue_draft_orders/publish_status.rb +39 -0
- data/app/services/spree_cm_commissioner/redis_stock/restock.rb +16 -5
- data/app/services/spree_cm_commissioner/redis_stock/unstock.rb +12 -5
- data/app/views/spree/admin/stock_managements/index.html.erb +8 -0
- data/config/locales/en.yml +13 -4
- data/config/locales/km.yml +3 -1
- data/config/routes.rb +1 -0
- data/db/migrate/20260820000000_add_locked_quantities_to_cm_inventory_items.rb +12 -0
- data/lib/spree_cm_commissioner/cached_inventory_item.rb +9 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +11 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1bcaa0a7cc8d39b507cd072bfd4576b28f72c83f2344b11ef2097306aa8454dd
|
|
4
|
+
data.tar.gz: bf8156fe0e0f2a41febcacc07188e8b0bbd9b2cd79c3ccad5a8d1394fa307db7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 19b750a2f5c27cabbc5c507a0e50424a26e295aefa94369787ff490376475f63e56993950e87a9a1b2c3be624f6ec29f2192a51a7e019bfed05e3ee4dcb54018
|
|
7
|
+
data.tar.gz: 4acf6f94be21c3960010398b9a431b950b2e3e7bffed455096ea0dc2ed05fca769bd53884b6ed26be86cc11db2a8c220c73711be800bccebf7fc005854454f11
|
data/Gemfile.lock
CHANGED
|
@@ -10,10 +10,14 @@ module Spree
|
|
|
10
10
|
CACHE_EXPIRES_IN = 1.minute
|
|
11
11
|
|
|
12
12
|
def collection
|
|
13
|
-
@collection ||=
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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,23 +7,31 @@ module Spree
|
|
|
7
7
|
ADDITIONAL_FIELDS = %i[participation entry_type].freeze
|
|
8
8
|
|
|
9
9
|
def index
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
26
|
+
cached_inventory_items.all? { |item| item.active? && quantity_in_pool(item, include_locked: include_locked) >= quantity }
|
|
19
27
|
end
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|