spree_cm_commissioner 2.10.2.pre.pre1 → 2.11.0
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/concerns/spree_cm_commissioner/content_cachable.rb +1 -1
- data/app/controllers/spree/admin/system/waiting_room_sessions_controller.rb +27 -0
- data/app/controllers/spree/admin/vendor_authorized_users_controller.rb +0 -15
- data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +1 -1
- data/app/controllers/spree/api/v2/tenant/draft_orders_controller.rb +1 -1
- data/app/controllers/spree/api/v2/tenant/voting_contestants_controller.rb +1 -1
- data/app/controllers/spree/api/v2/tenant/voting_credit_transactions_controller.rb +1 -1
- data/app/controllers/spree_cm_commissioner/admin/variants_controller_decorator.rb +14 -4
- data/app/jobs/spree_cm_commissioner/voting_session_auto_open_job.rb +14 -0
- data/app/models/concerns/spree_cm_commissioner/event_metadata.rb +25 -0
- data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +6 -21
- data/app/models/concerns/spree_cm_commissioner/purchase_limitable.rb +22 -0
- data/app/models/concerns/spree_cm_commissioner/ticket_transferable.rb +2 -2
- data/app/models/concerns/spree_cm_commissioner/vendor_preference.rb +0 -19
- data/app/models/concerns/spree_cm_commissioner/voting_session_timing_concern.rb +25 -7
- data/app/models/spree_cm_commissioner/block.rb +1 -0
- data/app/models/spree_cm_commissioner/maintenance_tasks/event.rb +20 -0
- data/app/models/spree_cm_commissioner/order_decorator.rb +13 -1
- data/app/models/spree_cm_commissioner/price_decorator.rb +16 -0
- data/app/models/spree_cm_commissioner/product_decorator.rb +36 -0
- data/app/models/spree_cm_commissioner/seat_layout.rb +46 -9
- data/app/models/spree_cm_commissioner/seat_section.rb +47 -0
- data/app/models/spree_cm_commissioner/taxon_decorator.rb +8 -0
- data/app/models/spree_cm_commissioner/trip.rb +1 -1
- data/app/models/spree_cm_commissioner/variant_decorator.rb +17 -0
- data/app/models/spree_cm_commissioner/vote.rb +3 -0
- data/app/models/spree_cm_commissioner/voting_contestant.rb +1 -0
- data/app/models/spree_cm_commissioner/voting_credit_transaction.rb +2 -0
- data/app/models/spree_cm_commissioner/voting_session.rb +15 -0
- data/app/models/spree_cm_commissioner/waiting_room_session.rb +20 -0
- data/app/overrides/spree/admin/products/_form/purchase_limits.html.erb.deface +49 -0
- data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +2 -1
- data/app/serializers/spree/v2/tenant/voting_contestant_serializer.rb +1 -2
- data/app/serializers/spree/v2/tenant/voting_credit_transaction_serializer.rb +1 -0
- data/app/serializers/spree_cm_commissioner/v2/storefront/block_serializer.rb +1 -1
- data/app/serializers/spree_cm_commissioner/v2/storefront/seat_layout_serializer.rb +1 -1
- data/app/serializers/spree_cm_commissioner/v2/storefront/seat_section_serializer.rb +2 -1
- data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +3 -0
- data/app/services/spree_cm_commissioner/{draft_order → draft_orders}/create.rb +2 -2
- data/app/services/spree_cm_commissioner/intercity_taxi_order/create.rb +2 -4
- data/app/services/spree_cm_commissioner/line_items/validate_purchase_limits.rb +29 -7
- data/app/services/spree_cm_commissioner/load_test/generate_booking_ticket_data.rb +56 -20
- data/app/services/spree_cm_commissioner/seat_layouts/sanitize_svg.rb +215 -0
- data/app/services/spree_cm_commissioner/seat_layouts/update.rb +79 -0
- data/app/services/spree_cm_commissioner/ticket_transfers/accept.rb +1 -1
- data/app/services/spree_cm_commissioner/transit_order/create.rb +4 -10
- data/app/services/spree_cm_commissioner/vote_credit_deductor.rb +1 -0
- data/app/services/spree_cm_commissioner/voting_contestants/bulk_updater.rb +10 -4
- data/app/services/spree_cm_commissioner/voting_sessions/auto_open.rb +38 -0
- data/app/services/spree_cm_commissioner/voting_sessions/finalize.rb +17 -3
- data/app/services/spree_cm_commissioner/voting_sessions/publish_live_state.rb +15 -0
- data/app/services/spree_cm_commissioner/voting_sessions/publish_to_firestore.rb +32 -23
- data/app/services/spree_cm_commissioner/voting_sessions/schedule_vote_window_jobs.rb +45 -0
- data/app/services/spree_cm_commissioner/waiting_room/stamp_queue_positions.rb +16 -4
- data/app/views/spree/admin/system/waiting_room/show.html.erb +1 -0
- data/app/views/spree/admin/system/waiting_room_sessions/index.html.erb +118 -0
- data/app/views/spree/admin/variants/fixture.html.erb +3 -3
- data/app/views/spree/admin/vendor_authorized_users/index.html.erb +0 -15
- data/config/initializers/spree_permitted_attributes.rb +10 -0
- data/config/load_test_flows/platform.yml +20 -0
- data/config/locales/en.yml +30 -10
- data/config/locales/km.yml +30 -6
- data/config/routes.rb +6 -0
- data/db/migrate/20260813100000_add_guide_to_cm_seat_layouts.rb +12 -0
- data/db/migrate/20260813100001_add_sub_seat_layout_reference_to_cm_seat_sections.rb +7 -0
- data/db/migrate/20260813100002_add_shape_path_to_cm_blocks.rb +13 -0
- data/db/migrate/20260813100003_add_metadata_to_cm_seat_sections.rb +6 -0
- data/db/migrate/20260814000000_add_contestant_id_to_cm_voting_credit_transactions.rb +7 -0
- data/db/migrate/20260817000000_add_eliminated_voting_session_to_cm_voting_contestants.rb +9 -0
- data/db/migrate/20260817072341_add_metadata_to_cm_seat_layouts.rb +6 -0
- data/db/migrate/20260819100000_add_exit_at_to_cm_waiting_room_sessions.rb +5 -0
- data/lib/spree_cm_commissioner/test_helper/factories/block_factory.rb +4 -0
- data/lib/spree_cm_commissioner/test_helper/factories/seat_layout_factory.rb +12 -0
- data/lib/spree_cm_commissioner/test_helper/factories/show_factory.rb +6 -0
- data/lib/spree_cm_commissioner/test_helper/factories/voting_credit_transaction_factory.rb +4 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +22 -6
- data/app/models/concerns/spree_cm_commissioner/order_channel_bitwise.rb +0 -70
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58bbc65915f1f69096fabf6a1778dd9d810ebdbf8e788c2b1597e3b2173f2204
|
|
4
|
+
data.tar.gz: c32858179db78672e07bc34c5f7395b59b228d5585e35647c3d5486cf0c0ea9c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6493ccc10ddcb0dfae06115f11168207bdee8f8d687367108ba59e41885e10a989f8adcb17a220019039ef3a5c224ab3fe4356c6f1e90ae9130e4a607347e015
|
|
7
|
+
data.tar.gz: 363d64fd5b2aa2f809163134dd050b85d46d529fe35a34994381c3bc7861acd4f57afab353fdd2772081e019a5f49eb78f4d9141ee9d7a44e06991b07170d764
|
data/Gemfile.lock
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
module System
|
|
4
|
+
class WaitingRoomSessionsController < Spree::Admin::BaseController
|
|
5
|
+
def index
|
|
6
|
+
@filter = params[:filter] if params[:filter] == 'load_test'
|
|
7
|
+
|
|
8
|
+
sessions = SpreeCmCommissioner::WaitingRoomSession.active
|
|
9
|
+
sessions = sessions.load_test if @filter == 'load_test'
|
|
10
|
+
|
|
11
|
+
@waiting_room_sessions = sessions.order(created_at: :desc).page(params[:page]).per(50)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Kills only the sessions the admin checked (Gmail-style select + delete), instead of the
|
|
15
|
+
# room's whole active set. Firebase's active_sessions/full reflect this only on the next
|
|
16
|
+
# WaitingGuestsCaller run (they're a once-a-minute mirror, not live).
|
|
17
|
+
def kill
|
|
18
|
+
ids = Array(params[:ids])
|
|
19
|
+
count = SpreeCmCommissioner::WaitingRoomSession.kill_by_ids!(ids)
|
|
20
|
+
flash[:success] = "Killed #{count} waiting-room session(s)."
|
|
21
|
+
|
|
22
|
+
redirect_back fallback_location: admin_system_waiting_room_sessions_path
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -14,8 +14,6 @@ module Spree
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def update_telegram_chat
|
|
17
|
-
@vendor.update(preferred_telegram_alert_channels: submitted_alert_channels_mask)
|
|
18
|
-
|
|
19
17
|
context = ::SpreeCmCommissioner::TelegramChatsFinder.call(
|
|
20
18
|
telegram_chat_name: params['vendor']['preferred_telegram_chat_name'],
|
|
21
19
|
telegram_chat_type: params['vendor']['preferred_telegram_chat_type']
|
|
@@ -36,19 +34,6 @@ module Spree
|
|
|
36
34
|
redirect_to collection_url(@vendor)
|
|
37
35
|
end
|
|
38
36
|
|
|
39
|
-
# OR the bits, never sum them. Sum is not idempotent: if the same bit arrives twice
|
|
40
|
-
# (duplicated checkbox, double-submit, hand-crafted post) 1 + 1 = 2, which silently means
|
|
41
|
-
# google_form instead of spree. ["0","1","1","16"].sum => 18 => [google_form, offline_sell];
|
|
42
|
-
# the same input OR'd => 17 => [spree, offline_sell].
|
|
43
|
-
#
|
|
44
|
-
# Masking against the known bits also drops junk from a crafted post (999 => 7).
|
|
45
|
-
def submitted_alert_channels_mask
|
|
46
|
-
known = SpreeCmCommissioner::OrderChannelBitwise::CHANNEL_ALERT_BITS.values.reduce(:|)
|
|
47
|
-
|
|
48
|
-
Array(params.dig(:vendor, :telegram_alert_channels))
|
|
49
|
-
.reduce(0) { |acc, value| acc | value.to_i } & known
|
|
50
|
-
end
|
|
51
|
-
|
|
52
37
|
# @overrided
|
|
53
38
|
def model_class
|
|
54
39
|
Spree::User
|
|
@@ -11,7 +11,7 @@ module Spree
|
|
|
11
11
|
# Params:
|
|
12
12
|
# - line_items: Array of { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} } (required)
|
|
13
13
|
def create
|
|
14
|
-
result = SpreeCmCommissioner::
|
|
14
|
+
result = SpreeCmCommissioner::DraftOrders::Create.call(
|
|
15
15
|
line_items: draft_order_line_items,
|
|
16
16
|
user: spree_current_user,
|
|
17
17
|
store: current_store,
|
|
@@ -8,7 +8,7 @@ module Spree
|
|
|
8
8
|
# Params:
|
|
9
9
|
# - line_items: Array of { variant_id:, quantity:, options: {}, public_metadata: {}, private_metadata: {} } (required)
|
|
10
10
|
def create
|
|
11
|
-
result = SpreeCmCommissioner::
|
|
11
|
+
result = SpreeCmCommissioner::DraftOrders::Create.call(
|
|
12
12
|
line_items: draft_order_line_items,
|
|
13
13
|
user: spree_current_user,
|
|
14
14
|
store: current_store,
|
|
@@ -13,7 +13,7 @@ module Spree
|
|
|
13
13
|
def collection
|
|
14
14
|
@collection ||= @voting_session
|
|
15
15
|
.voting_contestants
|
|
16
|
-
.includes(:voting_session, { show_contestant: :assigned_show_person },
|
|
16
|
+
.includes(:voting_session, :eliminated_voting_session, { show_contestant: :assigned_show_person },
|
|
17
17
|
:show_contestant_images, :show_contestant_videos,
|
|
18
18
|
:advanced_to, :advanced_from
|
|
19
19
|
).order(:vote_number)
|
|
@@ -20,7 +20,7 @@ module Spree
|
|
|
20
20
|
|
|
21
21
|
def scope
|
|
22
22
|
scope = spree_current_user.voting_credit_transactions
|
|
23
|
-
.includes(:vote_credit, :originator)
|
|
23
|
+
.includes(:vote_credit, :originator, contestant: :voting_session)
|
|
24
24
|
.order(created_at: :desc)
|
|
25
25
|
|
|
26
26
|
scope = scope.by_action(params[:actions].split(',')) if params[:actions].present?
|
|
@@ -56,21 +56,31 @@ module SpreeCmCommissioner
|
|
|
56
56
|
option_value.name
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# Builds the load-test fixture for this variant so it can be used by
|
|
59
|
+
# Builds the load-test fixture for this variant so it can be used by a k6 booking flow.
|
|
60
60
|
# Shown on a page (with a check for whether the variant can be booked) for the tester to
|
|
61
61
|
# copy into the load_testing config. A product with a tenant_id belongs to a tenant
|
|
62
62
|
# (SaaS-hosted) store rather than the platform — the two k6 workflows keep separate fixture
|
|
63
63
|
# folders (load_testing/config/fixture/platform/booking_ticket_flow/ vs
|
|
64
64
|
# load_testing/config/fixture/tenant/cash_on_booking_ticket_flow/ — the tenant flow is named
|
|
65
65
|
# cash_on_* since it has an ABA sibling; the platform flow doesn't).
|
|
66
|
+
#
|
|
67
|
+
# Whether this variant needs seat_selection_booking_flow isn't picked here — it's read off
|
|
68
|
+
# the generated fixture's metadata[:select_seat] (see GenerateBookingTicketData), which
|
|
69
|
+
# derives it the same way add-to-cart itself does.
|
|
66
70
|
def fixture
|
|
67
71
|
@fixture_workflow = @variant.product.tenant_id.present? ? 'tenant' : 'platform'
|
|
68
|
-
@
|
|
72
|
+
@fixture_env = Rails.env.development? ? 'local' : Rails.env.to_s # the load_testing config calls it "local", not "development"
|
|
73
|
+
|
|
69
74
|
result = SpreeCmCommissioner::LoadTest::GenerateBookingTicketData.call(variant: @variant)
|
|
70
75
|
@fixture = result.value[:data]
|
|
71
76
|
@fixture_issues = result.value[:issues]
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
@fixture_flow = @fixture[:metadata][:select_seat] ? 'seat_selection_booking_flow' : fixture_flow_for(@fixture_workflow)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def fixture_flow_for(workflow)
|
|
83
|
+
workflow == 'tenant' ? 'cash_on_booking_ticket_flow' : 'booking_ticket_flow'
|
|
74
84
|
end
|
|
75
85
|
end
|
|
76
86
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Enqueued by VotingSessions::ScheduleVoteWindowJobs with wait_until: vote_open_at.
|
|
3
|
+
# See VotingSessions::AutoOpen for the actual open/publish logic.
|
|
4
|
+
class VotingSessionAutoOpenJob < ApplicationJob
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform(voting_session_id)
|
|
8
|
+
voting_session = SpreeCmCommissioner::VotingSession.find_by(id: voting_session_id)
|
|
9
|
+
return if voting_session.nil?
|
|
10
|
+
|
|
11
|
+
SpreeCmCommissioner::VotingSessions::AutoOpen.call(voting_session: voting_session)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -41,6 +41,19 @@ module SpreeCmCommissioner
|
|
|
41
41
|
store_public_metadata :preload_seat_layout_ids, :array, default: []
|
|
42
42
|
store_public_metadata :preload_active_seat_layout_ids, :array, default: []
|
|
43
43
|
|
|
44
|
+
# Use to indicate if a seat layout is the guide layout for this event. 1 guide layout per event.
|
|
45
|
+
store_public_metadata :preload_guide_seat_layout_id, :integer
|
|
46
|
+
store_public_metadata :preload_active_guide_seat_layout_id, :integer
|
|
47
|
+
|
|
48
|
+
# Denormalised price range across the event's variants, so the client can show it (e.g. "from $X")
|
|
49
|
+
# without loading every variant. Kept in sync by PriceDecorator's after_commit hook
|
|
50
|
+
store_public_metadata :min_price, :string
|
|
51
|
+
store_public_metadata :max_price, :string
|
|
52
|
+
store_public_metadata :currency, :string
|
|
53
|
+
|
|
54
|
+
store_public_metadata :spotify_embed_url, :string
|
|
55
|
+
store_public_metadata :spotify_embed_skeleton_color, :string, default: '#900033'
|
|
56
|
+
|
|
44
57
|
before_validation :validate_at_least_one_check_in_flow_presence
|
|
45
58
|
end
|
|
46
59
|
|
|
@@ -51,6 +64,18 @@ module SpreeCmCommissioner
|
|
|
51
64
|
flows
|
|
52
65
|
end
|
|
53
66
|
|
|
67
|
+
def display_min_price
|
|
68
|
+
return nil if min_price.blank?
|
|
69
|
+
|
|
70
|
+
Spree::Money.new(min_price, currency: currency).to_s
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def display_max_price
|
|
74
|
+
return nil if max_price.blank?
|
|
75
|
+
|
|
76
|
+
Spree::Money.new(max_price, currency: currency).to_s
|
|
77
|
+
end
|
|
78
|
+
|
|
54
79
|
private
|
|
55
80
|
|
|
56
81
|
def validate_at_least_one_check_in_flow_presence
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Make sure to put this concern below other concern or methods that generating additional order info like guests, seat number, etc.
|
|
2
2
|
# This will ensure that when each notification is sent with neccessary data.
|
|
3
3
|
module SpreeCmCommissioner
|
|
4
|
-
module OrderStateMachine
|
|
4
|
+
module OrderStateMachine
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
6
|
|
|
7
7
|
included do # rubocop:disable Metrics/BlockLength
|
|
@@ -271,30 +271,15 @@ module SpreeCmCommissioner
|
|
|
271
271
|
return unless telegram_alert_enabled?
|
|
272
272
|
|
|
273
273
|
vendor_list.each do |vendor|
|
|
274
|
-
|
|
275
|
-
# multi-vendor order must not suppress the others.
|
|
276
|
-
next unless vendor.alerts_telegram_for_channel?(channel_key)
|
|
277
|
-
|
|
274
|
+
title = '🎫 --- [NEW ORDER FROM BOOKME+] ---'
|
|
278
275
|
chat_id = vendor.preferred_telegram_chat_id
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
276
|
+
if chat_id.present?
|
|
277
|
+
factory = OrderTelegramMessageFactory.new(title: title, order: self, vendor: vendor)
|
|
278
|
+
TelegramNotificationSenderJob.perform_later(chat_id: chat_id, message: factory.message, parse_mode: factory.parse_mode)
|
|
279
|
+
end
|
|
283
280
|
end
|
|
284
281
|
end
|
|
285
282
|
|
|
286
|
-
# White-label vendors announce their own brand; platform vendors stay BookMe+.
|
|
287
|
-
#
|
|
288
|
-
# vendor.name is operator-supplied data interpolated into an HTML parse_mode message
|
|
289
|
-
# (TelegramMessageFactory#header wraps the title in <b>), so it MUST be escaped —
|
|
290
|
-
# Telegram rejects unescaped &, < and > with "400 can't parse entities", and
|
|
291
|
-
# TelegramNotificationSender swallows that error, so the alert would vanish silently.
|
|
292
|
-
def vendor_alert_title(vendor)
|
|
293
|
-
return '🎫 --- [NEW ORDER FROM BOOKME+] ---' unless vendor.tenant_present?
|
|
294
|
-
|
|
295
|
-
"🎫 --- [NEW ORDER FROM #{CGI.escapeHTML(vendor.name.to_s)}] ---"
|
|
296
|
-
end
|
|
297
|
-
|
|
298
283
|
def notify_order_complete_app_notification_to_user
|
|
299
284
|
return if products.all?(&:vote_package?) # disable app push notification for vote package orders
|
|
300
285
|
|
|
@@ -12,6 +12,14 @@ module SpreeCmCommissioner
|
|
|
12
12
|
max_quantity_per_ip
|
|
13
13
|
].freeze
|
|
14
14
|
|
|
15
|
+
# Caps that aggregate across orders. The order cap is bounded by each of these:
|
|
16
|
+
# whichever is smaller is what the buyer actually gets.
|
|
17
|
+
AGGREGATE_LIMIT_KEYS = %i[
|
|
18
|
+
max_quantity_per_account
|
|
19
|
+
max_quantity_per_device
|
|
20
|
+
max_quantity_per_ip
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
15
23
|
POSITIVE_INTEGER = { only_integer: true, greater_than: 0, message: :must_be_positive_integer }.freeze
|
|
16
24
|
|
|
17
25
|
included do
|
|
@@ -27,6 +35,7 @@ module SpreeCmCommissioner
|
|
|
27
35
|
|
|
28
36
|
validate :validate_max_quantity_per_account
|
|
29
37
|
validate :validate_max_quantity_per_device
|
|
38
|
+
validate :validate_order_cap_within_aggregate_caps
|
|
30
39
|
|
|
31
40
|
before_validation :normalize_purchase_limits
|
|
32
41
|
end
|
|
@@ -50,6 +59,19 @@ module SpreeCmCommissioner
|
|
|
50
59
|
errors.add(:base, I18n.t('purchase_limit.account_cap_requires_named_booking'))
|
|
51
60
|
end
|
|
52
61
|
|
|
62
|
+
# An order cap above an aggregate cap is unreachable - the aggregate check counts the same
|
|
63
|
+
# cart quantity plus past orders, so it rejects first. Only the tightest cap is named:
|
|
64
|
+
# fixing it clears the rest. A blank cap reads back as 0, hence the positive? filter.
|
|
65
|
+
def validate_order_cap_within_aggregate_caps
|
|
66
|
+
order_cap = max_quantity_per_order.to_i
|
|
67
|
+
key = AGGREGATE_LIMIT_KEYS.select { |k| public_send(k).to_i.positive? && public_send(k).to_i < order_cap }
|
|
68
|
+
.min_by { |k| public_send(k).to_i }
|
|
69
|
+
return if key.nil?
|
|
70
|
+
|
|
71
|
+
cap_name = I18n.t("purchase_limit.cap_names.#{key.to_s.delete_prefix('max_quantity_per_')}")
|
|
72
|
+
errors.add(:max_quantity_per_order, I18n.t('purchase_limit.order_cap_exceeds_scope_cap', cap_name: cap_name))
|
|
73
|
+
end
|
|
74
|
+
|
|
53
75
|
def validate_max_quantity_per_device
|
|
54
76
|
return unless max_quantity_per_device.to_i.positive? && !purchasable_on_app?
|
|
55
77
|
|
|
@@ -5,11 +5,11 @@ module SpreeCmCommissioner
|
|
|
5
5
|
included do
|
|
6
6
|
has_one :ticket_transfer, class_name: 'SpreeCmCommissioner::TicketTransfer'
|
|
7
7
|
|
|
8
|
-
scope :not_ticket_transfer, -> { where.not(channel: SpreeCmCommissioner::
|
|
8
|
+
scope :not_ticket_transfer, -> { where.not(channel: SpreeCmCommissioner::OrderDecorator::TICKET_TRANSFER_CHANNEL) }
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def ticket_transfer?
|
|
12
|
-
channel == SpreeCmCommissioner::
|
|
12
|
+
channel == SpreeCmCommissioner::OrderDecorator::TICKET_TRANSFER_CHANNEL
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
# Completes an accepted ticket transfer for this order. Called from
|
|
@@ -8,25 +8,6 @@ module SpreeCmCommissioner
|
|
|
8
8
|
preference :telegram_chat_id, :string
|
|
9
9
|
preference :telegram_chat_name, :string
|
|
10
10
|
preference :telegram_chat_type, :string
|
|
11
|
-
preference :telegram_alert_channels, :integer, default: 1
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
# Should this vendor's Telegram group be alerted for an order on this channel?
|
|
15
|
-
# An unmapped channel alerts — a stray message is a better failure than silence.
|
|
16
|
-
def alerts_telegram_for_channel?(channel_key)
|
|
17
|
-
bit = SpreeCmCommissioner::OrderChannelBitwise::CHANNEL_ALERT_BITS[channel_key]
|
|
18
|
-
return true if bit.nil?
|
|
19
|
-
|
|
20
|
-
telegram_alert_channels_mask.anybits?(bit)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Spree's getter is `preferences.fetch(name) { default }`, which falls back only when the
|
|
24
|
-
# key is ABSENT. A key present with a nil value returns nil, and nil.to_i == 0 would mute
|
|
25
|
-
# EVERY channel silently. Fall back to the default instead; a deliberate 0 survives
|
|
26
|
-
# because 0 is truthy in Ruby.
|
|
27
|
-
def telegram_alert_channels_mask
|
|
28
|
-
(preferred_telegram_alert_channels ||
|
|
29
|
-
SpreeCmCommissioner::OrderChannelBitwise::DEFAULT_TELEGRAM_ALERT_CHANNELS).to_i
|
|
30
11
|
end
|
|
31
12
|
end
|
|
32
13
|
end
|
|
@@ -6,8 +6,15 @@ module SpreeCmCommissioner
|
|
|
6
6
|
#
|
|
7
7
|
# There is no mode flag: the presence of vote_open_at/vote_close_at is the signal.
|
|
8
8
|
#
|
|
9
|
-
# Pre-set at creation — auto-activates:
|
|
10
|
-
#
|
|
9
|
+
# Pre-set at creation — auto-activates: #schedule_vote_window_jobs (below) enqueues
|
|
10
|
+
# VotingSessionAutoOpenJob for vote_open_at. If status is already
|
|
11
|
+
# :enabled, can_vote? was already purely time-driven, so that job just
|
|
12
|
+
# keeps Firestore's published state in sync. If status is :on_air (an
|
|
13
|
+
# organizer revealed the session but never pressed Start Vote), that
|
|
14
|
+
# same job flips status to :enabled once vote_open_at arrives — gated
|
|
15
|
+
# on visible?, so it only completes a reveal the organizer already made,
|
|
16
|
+
# never a private archived session. VotingSessionAutoCloseJob then
|
|
17
|
+
# follows the same way once enabled, for vote_close_at.
|
|
11
18
|
# Left blank — #start_vote! rewrites them to now / now + vote_duration_seconds the
|
|
12
19
|
# moment the organizer presses it, live during the broadcast.
|
|
13
20
|
#
|
|
@@ -36,6 +43,7 @@ module SpreeCmCommissioner
|
|
|
36
43
|
# yet — "reveal" is something an organizer does to an *existing* archived session.
|
|
37
44
|
before_update :promote_from_archived_when_made_visible
|
|
38
45
|
after_commit :publish_live_state_to_firestore, on: %i[create update], if: :live_state_changed?
|
|
46
|
+
after_commit :schedule_vote_window_jobs, on: %i[create update], if: :vote_window_scheduling_relevant_change?
|
|
39
47
|
|
|
40
48
|
scope :visible, -> { where(visible: true) }
|
|
41
49
|
end
|
|
@@ -56,8 +64,6 @@ module SpreeCmCommissioner
|
|
|
56
64
|
status: :enabled,
|
|
57
65
|
visible: true
|
|
58
66
|
)
|
|
59
|
-
|
|
60
|
-
SpreeCmCommissioner::VotingSessionAutoCloseJob.set(wait_until: vote_close_at).perform_later(id)
|
|
61
67
|
end
|
|
62
68
|
|
|
63
69
|
# Ends the vote window immediately and moves to closed. status: closed (not just a rewritten
|
|
@@ -104,8 +110,6 @@ module SpreeCmCommissioner
|
|
|
104
110
|
# validation.
|
|
105
111
|
def extend_vote!(seconds)
|
|
106
112
|
update!(vote_close_at: [vote_close_at, Time.current].max + seconds.to_i.seconds)
|
|
107
|
-
|
|
108
|
-
SpreeCmCommissioner::VotingSessionAutoCloseJob.set(wait_until: vote_close_at).perform_later(id)
|
|
109
113
|
end
|
|
110
114
|
|
|
111
115
|
private
|
|
@@ -131,7 +135,21 @@ module SpreeCmCommissioner
|
|
|
131
135
|
end
|
|
132
136
|
|
|
133
137
|
def publish_live_state_to_firestore
|
|
134
|
-
SpreeCmCommissioner::
|
|
138
|
+
SpreeCmCommissioner::VotingSessions::PublishLiveState.call(voting_session: self)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Either the window itself moved, or status crossed into/out of a state the scheduling cares
|
|
142
|
+
# about (archived -> on_air on reveal; on_air -> enabled when VotingSessionAutoOpenJob fires).
|
|
143
|
+
# Both matter: a reveal with a vote_open_at already set at creation only just became eligible
|
|
144
|
+
# for the open job, without vote_open_at/vote_close_at themselves changing on this save.
|
|
145
|
+
def vote_window_scheduling_relevant_change?
|
|
146
|
+
saved_change_to_attribute?(:vote_open_at) || saved_change_to_attribute?(:vote_close_at) ||
|
|
147
|
+
saved_change_to_attribute?(:status)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# See SpreeCmCommissioner::VotingSessions::ScheduleVoteWindowJobs for the scheduling rules.
|
|
151
|
+
def schedule_vote_window_jobs
|
|
152
|
+
SpreeCmCommissioner::VotingSessions::ScheduleVoteWindowJobs.call(voting_session: self)
|
|
135
153
|
end
|
|
136
154
|
end
|
|
137
155
|
end
|
|
@@ -24,6 +24,7 @@ module SpreeCmCommissioner
|
|
|
24
24
|
validates :x, presence: true, numericality: true
|
|
25
25
|
validates :y, presence: true, numericality: true
|
|
26
26
|
validates :rotation, presence: true, numericality: { greater_than_or_equal_to: -360, less_than_or_equal_to: 360 }
|
|
27
|
+
validates :shape_path, format: { with: /\A\s*[Mm]/, message: 'must start with a move command' }, allow_blank: true # rubocop:disable Rails/I18nLocaleTexts
|
|
27
28
|
|
|
28
29
|
validate :cannot_unassign_variant_with_active_blocks
|
|
29
30
|
|
|
@@ -10,6 +10,8 @@ module SpreeCmCommissioner
|
|
|
10
10
|
update_conversion_for(product_taxon)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
refresh_price_range
|
|
14
|
+
|
|
13
15
|
# These steps can be expensive and our new order state machine already prevents most cases from happening.
|
|
14
16
|
# So we should only run them as manual maintenance just in case we missed some edge cases.
|
|
15
17
|
reassign_guests_event_id if manually_triggered?
|
|
@@ -22,6 +24,24 @@ module SpreeCmCommissioner
|
|
|
22
24
|
maintainable.id
|
|
23
25
|
end
|
|
24
26
|
|
|
27
|
+
# Denormalises maintainable's price range onto EventMetadata#min_price/max_price/currency
|
|
28
|
+
# so the event-details bar can show "from $X" without loading the ticket list. Triggered
|
|
29
|
+
# by PriceDecorator's after_commit hook via a pending task, and run unconditionally here.
|
|
30
|
+
def refresh_price_range
|
|
31
|
+
currency = Spree::Store.default.default_currency
|
|
32
|
+
|
|
33
|
+
priced_variant_ids = maintainable.event_variants.where(is_master: false).select(:id)
|
|
34
|
+
min_amount, max_amount = Spree::Price
|
|
35
|
+
.where(variant_id: priced_variant_ids, currency: currency)
|
|
36
|
+
.pick(Arel.sql('MIN(amount)'), Arel.sql('MAX(amount)'))
|
|
37
|
+
|
|
38
|
+
maintainable.update!(
|
|
39
|
+
min_price: min_amount&.to_s,
|
|
40
|
+
max_price: max_amount&.to_s,
|
|
41
|
+
currency: min_amount.present? ? currency : nil
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
25
45
|
def update_conversion_for(product_taxon)
|
|
26
46
|
# Count guests (attendees) instead of line item quantity so this stays consistent
|
|
27
47
|
# with EventChartQueries, which counts DISTINCT cm_guests.id on complete orders.
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
module SpreeCmCommissioner
|
|
2
2
|
module OrderDecorator
|
|
3
|
+
SPREE_CHANNEL = 'spree'.freeze
|
|
4
|
+
GOOGLE_FORM_CHANNEL = 'google_form'.freeze
|
|
5
|
+
TELEGRAM_CHANNEL = 'telegram'.freeze
|
|
6
|
+
TICKET_TRANSFER_CHANNEL = 'ticket_transfer'.freeze
|
|
7
|
+
ALLOWED_CHANNEL_PREFIXES = [SPREE_CHANNEL, GOOGLE_FORM_CHANNEL, TELEGRAM_CHANNEL, TICKET_TRANSFER_CHANNEL].freeze
|
|
8
|
+
|
|
3
9
|
def self.prepended(base) # rubocop:disable Metrics/MethodLength
|
|
4
|
-
base.include SpreeCmCommissioner::OrderChannelBitwise
|
|
5
10
|
base.include SpreeCmCommissioner::StoreMetadata
|
|
6
11
|
base.include SpreeCmCommissioner::PhoneNumberSanitizer
|
|
7
12
|
base.include SpreeCmCommissioner::OrderIntegration
|
|
@@ -22,6 +27,7 @@ module SpreeCmCommissioner
|
|
|
22
27
|
base.store_public_metadata :order_options, :array, default: []
|
|
23
28
|
|
|
24
29
|
base.validates :promo_total, base::MONEY_VALIDATION
|
|
30
|
+
base.validate :validate_channel_prefix, if: :channel_changed?
|
|
25
31
|
base.validates :phone_number, presence: true, if: :require_phone_number
|
|
26
32
|
|
|
27
33
|
base.has_one :invoice, dependent: :destroy, class_name: 'SpreeCmCommissioner::Invoice'
|
|
@@ -357,6 +363,12 @@ module SpreeCmCommissioner
|
|
|
357
363
|
self.ship_address ||= customer.ship_address.try(:clone)
|
|
358
364
|
end
|
|
359
365
|
|
|
366
|
+
def validate_channel_prefix
|
|
367
|
+
return if ALLOWED_CHANNEL_PREFIXES.any? { |prefix| channel&.start_with?(prefix) }
|
|
368
|
+
|
|
369
|
+
errors.add(:channel, "must start with one of the following: #{ALLOWED_CHANNEL_PREFIXES.join(', ')}")
|
|
370
|
+
end
|
|
371
|
+
|
|
360
372
|
def set_tenant_id
|
|
361
373
|
self.tenant_id = MultiTenant.current_tenant_id
|
|
362
374
|
end
|
|
@@ -2,6 +2,22 @@ module SpreeCmCommissioner
|
|
|
2
2
|
module PriceDecorator
|
|
3
3
|
def self.prepended(base)
|
|
4
4
|
base.belongs_to :inventory_item, class_name: 'SpreeCmCommissioner::InventoryItem', inverse_of: :prices, optional: true
|
|
5
|
+
base.after_commit :trigger_event_price_range_refresh, if: :should_trigger_event_price_range_refresh?
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def should_trigger_event_price_range_refresh?
|
|
9
|
+
(saved_change_to_amount? || saved_change_to_currency?) && event_id.present?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def event_id
|
|
13
|
+
variant&.product&.event_id
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def trigger_event_price_range_refresh
|
|
17
|
+
SpreeCmCommissioner::MaintenanceTasks::Event.pending.create_or_find_by(
|
|
18
|
+
maintainable_type: 'Spree::Taxon',
|
|
19
|
+
maintainable_id: event_id
|
|
20
|
+
)
|
|
5
21
|
end
|
|
6
22
|
end
|
|
7
23
|
end
|
|
@@ -144,6 +144,7 @@ module SpreeCmCommissioner
|
|
|
144
144
|
|
|
145
145
|
base.after_update :update_variants_vendor_id, if: :saved_change_to_vendor_id?
|
|
146
146
|
base.after_update :sync_event_id_to_children, if: :saved_change_to_event_id?
|
|
147
|
+
base.after_commit :trigger_event_price_range_refresh_for_event_change, if: :saved_change_to_event_id?
|
|
147
148
|
|
|
148
149
|
base.enum purchasable_on: { both: 0, web: 1, app: 2 }, _prefix: true
|
|
149
150
|
base.enum call_to_action: { buy_now: 0,
|
|
@@ -162,6 +163,16 @@ module SpreeCmCommissioner
|
|
|
162
163
|
|
|
163
164
|
# Create maintaining task to purge product related caches
|
|
164
165
|
base.after_save { SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(maintainable: self) }
|
|
166
|
+
|
|
167
|
+
# App opens the event page, not the ticket/product page, so the event's cache must be purged too.
|
|
168
|
+
base.after_commit do
|
|
169
|
+
[event_id, event_id_before_last_save].compact.uniq.each do |taxon_id|
|
|
170
|
+
SpreeCmCommissioner::MaintenanceTasks::CacheInvalidation.pending.create_or_find_by(
|
|
171
|
+
maintainable_type: 'Spree::Taxon',
|
|
172
|
+
maintainable_id: taxon_id
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
165
176
|
end
|
|
166
177
|
|
|
167
178
|
def ticket_url
|
|
@@ -184,6 +195,20 @@ module SpreeCmCommissioner
|
|
|
184
195
|
end
|
|
185
196
|
end
|
|
186
197
|
|
|
198
|
+
# override
|
|
199
|
+
# [spree_core] only checks active?/deleted?, so a scheduled product (available_on in the future)
|
|
200
|
+
# reports available even though it isn't purchasable yet. Since Variant#available? delegates
|
|
201
|
+
# here, this closes the gap for every caller (cart, checkout, Stock::AvailabilityChecker) in
|
|
202
|
+
# one place — mirrors VariantDecorator#discontinued?'s window check.
|
|
203
|
+
def available?
|
|
204
|
+
return false unless active?
|
|
205
|
+
return false if deleted?
|
|
206
|
+
return false if discontinued?
|
|
207
|
+
return false if available_on.present? && available_on > Time.current
|
|
208
|
+
|
|
209
|
+
true
|
|
210
|
+
end
|
|
211
|
+
|
|
187
212
|
private
|
|
188
213
|
|
|
189
214
|
def set_tenant
|
|
@@ -202,6 +227,17 @@ module SpreeCmCommissioner
|
|
|
202
227
|
::SpreeCmCommissioner::ProductEventIdToChildrenSyncerJob.perform_later(product_id: id)
|
|
203
228
|
end
|
|
204
229
|
|
|
230
|
+
# Moving a product on/off an event stales both events' price ranges — refresh whichever
|
|
231
|
+
# side(s) actually had an event.
|
|
232
|
+
def trigger_event_price_range_refresh_for_event_change
|
|
233
|
+
[event_id, event_id_before_last_save].compact.uniq.each do |taxon_id|
|
|
234
|
+
SpreeCmCommissioner::MaintenanceTasks::Event.pending.create_or_find_by(
|
|
235
|
+
maintainable_type: 'Spree::Taxon',
|
|
236
|
+
maintainable_id: taxon_id
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
205
241
|
def validate_event_taxons
|
|
206
242
|
errors.add(:taxons, 'Event Taxon can\'t not be more than 1') if taxons.event.size > 1
|
|
207
243
|
errors.add(:taxons, 'Must add event date to taxon') if event.from_date.nil? || event.to_date.nil?
|