spree_cm_commissioner 2.4.2 → 2.5.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/.github/workflows/test_and_build_gem.yml +1 -1
- data/.tool-versions +1 -1
- data/Gemfile.lock +1 -1
- data/README.md +32 -0
- data/app/controllers/spree/admin/integration_mappings_controller.rb +21 -0
- data/app/controllers/spree/admin/integration_sessions_controller.rb +21 -0
- data/app/controllers/spree/admin/integrations_controller.rb +83 -0
- data/app/controllers/spree/api/v2/storefront/event_matches_controller.rb +15 -0
- data/app/controllers/spree/api/v2/storefront/tenants_controller.rb +30 -0
- data/app/errors/spree_cm_commissioner/integrations/external_client_error.rb +10 -0
- data/app/errors/spree_cm_commissioner/integrations/sync_error.rb +4 -0
- data/app/finders/spree_cm_commissioner/events/find_matches.rb +15 -0
- data/app/helpers/spree_cm_commissioner/external_integrations_helper.rb +58 -0
- data/app/interactors/spree_cm_commissioner/stock/inventory_item_resetter.rb +1 -1
- data/app/interactors/spree_cm_commissioner/stock/stock_movement_creator.rb +4 -2
- data/app/jobs/spree_cm_commissioner/integrations/base_job.rb +39 -0
- data/app/jobs/spree_cm_commissioner/integrations/polling_job.rb +53 -0
- data/app/jobs/spree_cm_commissioner/integrations/polling_scheduler_job.rb +30 -0
- data/app/jobs/spree_cm_commissioner/stock/inventory_items_adjuster_job.rb +5 -4
- data/app/jobs/spree_cm_commissioner/telegram_alerts/integration_sync_failure_job.rb +17 -0
- data/app/models/concerns/spree_cm_commissioner/integrations/integration_mappable.rb +58 -0
- data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +15 -3
- data/app/models/concerns/spree_cm_commissioner/store_metadata.rb +14 -2
- data/app/models/concerns/spree_cm_commissioner/tenant_preference.rb +2 -0
- data/app/models/concerns/spree_cm_commissioner/variant_options_concern.rb +1 -7
- data/app/models/spree_cm_commissioner/integration.rb +21 -0
- data/app/models/spree_cm_commissioner/integration_mapping.rb +37 -0
- data/app/models/spree_cm_commissioner/integration_sync_session.rb +15 -0
- data/app/models/spree_cm_commissioner/integrations/stadium_x_v1.rb +21 -0
- data/app/models/spree_cm_commissioner/inventory_item.rb +5 -1
- data/app/models/spree_cm_commissioner/option_type_decorator.rb +8 -0
- data/app/models/spree_cm_commissioner/option_value_decorator.rb +34 -0
- data/app/models/spree_cm_commissioner/product_decorator.rb +3 -2
- data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +2 -2
- data/app/models/spree_cm_commissioner/redis_stock/inventory_updater.rb +2 -2
- data/app/models/spree_cm_commissioner/taxon_decorator.rb +1 -0
- data/app/models/spree_cm_commissioner/taxonomy_decorator.rb +13 -1
- data/app/models/spree_cm_commissioner/variant_decorator.rb +7 -4
- data/app/models/spree_cm_commissioner/vendor_decorator.rb +1 -0
- data/app/overrides/spree/admin/shared/sub_menu/_integrations/external_integrations.html.erb.deface +6 -0
- data/app/presenters/spree/variants/{visable_options_presenter.rb → visible_options_presenter.rb} +2 -4
- data/app/serializers/spree/v2/storefront/tenant_serializer.rb +14 -0
- data/app/services/spree_cm_commissioner/integrations/base/sync_manager.rb +69 -0
- data/app/services/spree_cm_commissioner/integrations/base/sync_result.rb +183 -0
- data/app/services/spree_cm_commissioner/integrations/polling.rb +70 -0
- data/app/services/spree_cm_commissioner/integrations/polling_scheduler.rb +79 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/external_client/client.rb +152 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_matches.rb +113 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_zones.rb +215 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/base.rb +20 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/league.rb +19 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/match.rb +95 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/ticket.rb +81 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/ticket_image.rb +19 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/zone.rb +90 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_manager.rb +35 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/full_sync_strategy.rb +38 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/incremental_sync_strategy.rb +44 -0
- data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/webhook_sync_strategy.rb +16 -0
- data/app/services/spree_cm_commissioner/telegram_alerts/integration_sync_failure.rb +49 -0
- data/app/views/spree/admin/integration_mappings/_integration_mappings.html.erb +107 -0
- data/app/views/spree/admin/integration_mappings/index.html.erb +33 -0
- data/app/views/spree/admin/integration_sessions/_integration_sync_sessions.html.erb +116 -0
- data/app/views/spree/admin/integration_sessions/index.html.erb +42 -0
- data/app/views/spree/admin/integrations/_form.html.erb +104 -0
- data/app/views/spree/admin/integrations/_stadium_x_v1_fields.html.erb +29 -0
- data/app/views/spree/admin/integrations/edit.html.erb +45 -0
- data/app/views/spree/admin/integrations/index.html.erb +75 -0
- data/app/views/spree/admin/integrations/new.html.erb +25 -0
- data/app/views/spree/admin/tenants/_form.html.erb +79 -36
- data/config/locales/en.yml +8 -0
- data/config/locales/km.yml +8 -0
- data/config/routes.rb +9 -1
- data/db/migrate/20251017094845_create_cm_integrations.rb +22 -0
- data/db/migrate/20251017101555_create_cm_integration_sync_sessions.rb +68 -0
- data/db/migrate/20251017101605_create_cm_integration_mappings.rb +52 -0
- data/lib/cm_app_logger.rb +1 -0
- data/lib/spree_cm_commissioner/test_helper/factories/integration_factory.rb +25 -0
- data/lib/spree_cm_commissioner/test_helper/factories/integration_mapping_factory.rb +14 -0
- data/lib/spree_cm_commissioner/test_helper/factories/integration_sync_session_factory.rb +7 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- data/lib/spree_cm_commissioner.rb +8 -7
- metadata +58 -5
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
module Polling
|
|
3
|
+
class SyncZones
|
|
4
|
+
def initialize(client:, integration:, sync_result:)
|
|
5
|
+
@client = client
|
|
6
|
+
@integration = integration
|
|
7
|
+
@sync_result = sync_result
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(external_match_id:)
|
|
11
|
+
raise SpreeCmCommissioner::Integrations::SyncError, 'external_match_id is required' if external_match_id.nil?
|
|
12
|
+
|
|
13
|
+
match_mapping = Spree::Taxon.find_integration_mapping(integration_id: @integration.id, external_id: external_match_id)
|
|
14
|
+
raise SpreeCmCommissioner::Integrations::SyncError, "Match not found: #{external_match_id}" if match_mapping.blank?
|
|
15
|
+
|
|
16
|
+
synced_zone_mappings = sync_zones!(match_mapping)
|
|
17
|
+
cleanup_stale_mappings!(match_mapping, synced_zone_mappings)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Sync zones for a specific club and match
|
|
21
|
+
def sync_zones!(match_mapping)
|
|
22
|
+
external_club_id = match_mapping.external_payload&.dig('club_id')
|
|
23
|
+
raise SpreeCmCommissioner::Integrations::SyncError, 'club_id is required' if external_club_id.blank?
|
|
24
|
+
|
|
25
|
+
external_zones = begin
|
|
26
|
+
@sync_result.track_api_call('get_zones') { @client.get_zones!(club_id: external_club_id, match_id: match_mapping.external_id) }
|
|
27
|
+
rescue SpreeCmCommissioner::Integrations::ExternalClientError => e
|
|
28
|
+
raise SpreeCmCommissioner::Integrations::SyncError, "Failed to sync zones match #{match_mapping.external_id}: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# match ID is not returned by API, but it is important info when creating tickets in the external system
|
|
32
|
+
# We need to set it manually on each zone object so that it can be used when creating tickets.
|
|
33
|
+
# This is a temporary workaround until the API returns the match ID
|
|
34
|
+
external_zones.each do |zone|
|
|
35
|
+
zone.set_match_id(match_mapping.external_id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
taxon_match_section = match_mapping.internal.children.first_or_create! { |s| s.name = 'Zones' }
|
|
39
|
+
synced_zone_mappings = external_zones.map do |external_zone|
|
|
40
|
+
sync_zone!(external_zone, match_mapping.internal, taxon_match_section)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
CmAppLogger.log(
|
|
44
|
+
label: 'SyncZones#sync_zones! Zones synced for match',
|
|
45
|
+
data: { external_match_id: match_mapping.external_id }
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
synced_zone_mappings
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def sync_zone!(external_zone, internal_match, taxon_match_section)
|
|
54
|
+
Spree::Product.transaction do
|
|
55
|
+
product_mapping = Spree::Product.find_or_initialize_integration_mapping(integration_id: @integration.id, external_id: external_zone._id)
|
|
56
|
+
product = product_mapping.internal
|
|
57
|
+
|
|
58
|
+
zone_option_type = Spree::OptionType.ticket_type
|
|
59
|
+
color_option_type = external_zone.color_brand.present? ? Spree::OptionType.color : nil
|
|
60
|
+
|
|
61
|
+
# Track zone sync
|
|
62
|
+
@sync_result.track(:zone, product) do |tracker|
|
|
63
|
+
product.assign_attributes(
|
|
64
|
+
status: :active,
|
|
65
|
+
tenant: @integration.tenant,
|
|
66
|
+
vendor: @integration.vendor,
|
|
67
|
+
product_type: :ecommerce,
|
|
68
|
+
event: internal_match,
|
|
69
|
+
taxons: [taxon_match_section],
|
|
70
|
+
name: external_zone.title.strip,
|
|
71
|
+
price: external_zone.online_price,
|
|
72
|
+
available_on: external_zone.start_datetime,
|
|
73
|
+
discontinue_on: external_zone.end_datetime,
|
|
74
|
+
option_types: [zone_option_type, color_option_type].compact,
|
|
75
|
+
shipping_category: Spree::ShippingCategory.first,
|
|
76
|
+
stores: [Spree::Store.default]
|
|
77
|
+
)
|
|
78
|
+
tracker.save_if_changed!(product)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
product_mapping.mark_as_active!(external_payload: external_zone.to_h)
|
|
82
|
+
|
|
83
|
+
ensure_variant_with_zone_option_value!(product, external_zone, zone_option_type, color_option_type)
|
|
84
|
+
ensure_inventory!(product.variants[0], external_zone)
|
|
85
|
+
|
|
86
|
+
product_mapping
|
|
87
|
+
end
|
|
88
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
89
|
+
CmAppLogger.error(
|
|
90
|
+
label: 'SyncZones#sync_zone Failed to save zone',
|
|
91
|
+
data: { zone_id: external_zone._id, error: e.message, backtrace: e.backtrace }
|
|
92
|
+
)
|
|
93
|
+
raise SpreeCmCommissioner::Integrations::SyncError, "Failed to save zone #{external_zone.title}: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def ensure_variant_with_zone_option_value!(product, external_zone, zone_option_type, color_option_type)
|
|
97
|
+
option_values = [Spree::OptionValue.find_or_create_by_name!(zone_option_type, external_zone.title)]
|
|
98
|
+
|
|
99
|
+
if color_option_type.present? && external_zone.color_brand.present?
|
|
100
|
+
option_values << Spree::OptionValue.find_or_create_by_name!(color_option_type, external_zone.color_brand)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
variant = product.variants.first_or_initialize
|
|
104
|
+
|
|
105
|
+
# Track variant sync
|
|
106
|
+
@sync_result.track(:variant, variant) do |tracker|
|
|
107
|
+
variant.assign_attributes(
|
|
108
|
+
vendor: @integration.vendor,
|
|
109
|
+
track_inventory: true,
|
|
110
|
+
product_type: :ecommerce,
|
|
111
|
+
price: external_zone.online_price,
|
|
112
|
+
discontinue_on: external_zone.end_datetime,
|
|
113
|
+
option_values: option_values
|
|
114
|
+
)
|
|
115
|
+
tracker.save_if_changed!(variant)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Synchronize variant inventory with external StadiumX API
|
|
120
|
+
# Calculates the difference between external available seats and system inventory,
|
|
121
|
+
# then creates a stock movement to align system inventory with the external source.
|
|
122
|
+
#
|
|
123
|
+
# EXECUTION FLOW:
|
|
124
|
+
# 1. Calculate quantity difference between external and system inventory
|
|
125
|
+
# 2. Create StockMovement via StockMovementCreator (if difference != 0)
|
|
126
|
+
# 3. StockMovement.save triggers after_create callback → update_stock_item_quantity
|
|
127
|
+
# 4. StockItem.adjust_count_on_hand(quantity) updates count_on_hand
|
|
128
|
+
# 5. StockItem.save triggers after_commit callback → create_inventory_items (on first create)
|
|
129
|
+
# 6. InventoryItemsGeneratorJob → InventoryItemsGenerator → create_default_non_permanent_inventory_item!
|
|
130
|
+
# 7. Creates InventoryItem with quantity_available = stock_item.count_on_hand
|
|
131
|
+
# 8. StockMovementCreator enqueues InventoryItemsAdjusterJob (async)
|
|
132
|
+
# 9. InventoryItemsAdjuster.adjust_quantity!(quantity) updates existing inventory_items
|
|
133
|
+
# 10. Updates both DB (quantity_available, max_capacity) and Redis cache
|
|
134
|
+
#
|
|
135
|
+
# IMPORTANT: Inventory items are created AFTER stock item is saved (via callback).
|
|
136
|
+
# On first sync, the flow is: StockMovement → StockItem → InventoryItem creation → InventoryItem adjustment.
|
|
137
|
+
# On subsequent syncs: StockMovement → StockItem → InventoryItem adjustment only.
|
|
138
|
+
#
|
|
139
|
+
# Examples:
|
|
140
|
+
#
|
|
141
|
+
# 1. First sync (no inventory items yet):
|
|
142
|
+
# - External available seats: 50
|
|
143
|
+
# - System inventory: 0 (no inventory_items created)
|
|
144
|
+
# - Stock movement created: +50 (initialize system to 50)
|
|
145
|
+
# - Flow: StockMovement(+50) → StockItem.count_on_hand=50 → InventoryItem created with quantity=50 → Adjuster adjusts by +50
|
|
146
|
+
#
|
|
147
|
+
# 2. System has more inventory than external:
|
|
148
|
+
# - External available seats: 20
|
|
149
|
+
# - System inventory: 30
|
|
150
|
+
# - Stock movement created: -10 (adjust system down to 20)
|
|
151
|
+
# - Flow: StockMovement(-10) → StockItem.count_on_hand=20 → Adjuster adjusts inventory_item by -10
|
|
152
|
+
#
|
|
153
|
+
# 3. System has less inventory than external:
|
|
154
|
+
# - External available seats: 50
|
|
155
|
+
# - System inventory: 30
|
|
156
|
+
# - Stock movement created: +20 (adjust system up to 50)
|
|
157
|
+
# - Flow: StockMovement(+20) → StockItem.count_on_hand=50 → Adjuster adjusts inventory_item by +20
|
|
158
|
+
#
|
|
159
|
+
# 4. Already in sync:
|
|
160
|
+
# - External available seats: 25
|
|
161
|
+
# - System inventory: 25
|
|
162
|
+
# - Stock movement created: 0 (no change needed)
|
|
163
|
+
# - Flow: Early return, no operations performed
|
|
164
|
+
def ensure_inventory!(variant, external_zone)
|
|
165
|
+
raise ArgumentError, 'variant cannot be nil' if variant.nil? || variant.id.blank?
|
|
166
|
+
raise ArgumentError, 'external_zone cannot be nil' if external_zone.nil?
|
|
167
|
+
|
|
168
|
+
# NOTE: StadiumX zones have no permanent stock or date-based inventory, so we expect
|
|
169
|
+
# only one inventory item (index 0) per variant. Uses safe navigation to handle cases
|
|
170
|
+
# where inventory items haven't been created yet (first sync).
|
|
171
|
+
stock_location = variant.vendor.stock_locations.first || variant.vendor.send(:create_stock_location)
|
|
172
|
+
system_quantity = variant.inventory_items.active[0]&.quantity_in_redis || 0
|
|
173
|
+
external_quantity = external_zone.available_seats || 0
|
|
174
|
+
|
|
175
|
+
quantity_difference = external_quantity - system_quantity
|
|
176
|
+
return if quantity_difference.zero?
|
|
177
|
+
|
|
178
|
+
context = SpreeCmCommissioner::Stock::StockMovementCreator.call(
|
|
179
|
+
variant_id: variant.id,
|
|
180
|
+
stock_location_id: stock_location.id,
|
|
181
|
+
current_store: Spree::Store.default,
|
|
182
|
+
stock_movement_params: { quantity: quantity_difference }
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return unless context.success?
|
|
186
|
+
|
|
187
|
+
@sync_result.increment_metric(:inventory, :adjusted, quantity_difference.abs)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Archive stale zone mappings that are no longer in the external API
|
|
191
|
+
# Finds all active zone mappings for this integration and archives any
|
|
192
|
+
# that weren't included in the latest API response.
|
|
193
|
+
def cleanup_stale_mappings!(match_mapping, synced_zone_mappings)
|
|
194
|
+
synced_external_zone_ids = synced_zone_mappings.map(&:external_id)
|
|
195
|
+
|
|
196
|
+
# Find all active zone mappings for this integration and match
|
|
197
|
+
active_zone_mappings = SpreeCmCommissioner::IntegrationMapping.where(
|
|
198
|
+
integration_id: @integration.id,
|
|
199
|
+
internal_type: 'Spree::Product',
|
|
200
|
+
status: :active
|
|
201
|
+
).where(internal_id: Spree::Product.where(event_id: match_mapping.internal_id).select(:id))
|
|
202
|
+
|
|
203
|
+
# Archive zones that are no longer in the API response
|
|
204
|
+
active_zone_mappings.find_each do |mapping|
|
|
205
|
+
next if synced_external_zone_ids.include?(mapping.external_id)
|
|
206
|
+
|
|
207
|
+
mapping.internal.discontinue!
|
|
208
|
+
|
|
209
|
+
mapping.mark_as_archived!
|
|
210
|
+
@sync_result.increment_metric(:zone, :discontinued)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
module Resources
|
|
3
|
+
class Base
|
|
4
|
+
class << self
|
|
5
|
+
def from_collection(data)
|
|
6
|
+
return [] unless data['data'].is_a?(Array)
|
|
7
|
+
|
|
8
|
+
data['data'].map { |item| new(item) }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(attributes = {})
|
|
13
|
+
attributes.each do |key, value|
|
|
14
|
+
setter = "#{key}="
|
|
15
|
+
send(setter, value) if respond_to?(setter)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
module Resources
|
|
3
|
+
class League < Base
|
|
4
|
+
attr_accessor :_id, :name, :logo
|
|
5
|
+
|
|
6
|
+
def initialize(attributes = {})
|
|
7
|
+
super(attributes)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def to_h
|
|
11
|
+
{
|
|
12
|
+
_id: _id,
|
|
13
|
+
name: name,
|
|
14
|
+
logo: logo
|
|
15
|
+
}.compact
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
module Resources
|
|
3
|
+
class Match < Base
|
|
4
|
+
attr_accessor :_id,
|
|
5
|
+
:home_name,
|
|
6
|
+
:home_logo,
|
|
7
|
+
:away_name,
|
|
8
|
+
:away_logo,
|
|
9
|
+
:home_score,
|
|
10
|
+
:away_score,
|
|
11
|
+
:date,
|
|
12
|
+
:time,
|
|
13
|
+
:stadium,
|
|
14
|
+
:week,
|
|
15
|
+
:league_id,
|
|
16
|
+
:club_id,
|
|
17
|
+
:discount_ticket,
|
|
18
|
+
:on_sale,
|
|
19
|
+
:is_ended,
|
|
20
|
+
:allow_predition,
|
|
21
|
+
:file,
|
|
22
|
+
:created_by,
|
|
23
|
+
:updated_by,
|
|
24
|
+
:zones,
|
|
25
|
+
:promotion,
|
|
26
|
+
:poster,
|
|
27
|
+
:league
|
|
28
|
+
|
|
29
|
+
def initialize(attributes = {})
|
|
30
|
+
super(attributes)
|
|
31
|
+
|
|
32
|
+
@on_sale = attributes['on_sale'] != false
|
|
33
|
+
@is_ended = attributes['is_ended'] == true || attributes['is_ended'] == 'true'
|
|
34
|
+
@allow_predition = attributes['allow_predition'] == true || attributes['allow_predition'] == 'true'
|
|
35
|
+
@league = League.new(attributes['league']) if attributes['league'].is_a?(Hash)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def meta_title
|
|
39
|
+
return nil if home_name.blank? || away_name.blank?
|
|
40
|
+
|
|
41
|
+
"#{home_name} vs #{away_name} - #{display_match_datetime}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def meta_description
|
|
45
|
+
return nil if home_name.blank? || away_name.blank?
|
|
46
|
+
|
|
47
|
+
location = stadium.present? ? " at #{stadium}" : ''
|
|
48
|
+
league_info = league&.name.present? ? " - #{league.name}" : ''
|
|
49
|
+
|
|
50
|
+
"#{home_name} vs #{away_name}#{league_info} on #{display_match_datetime}#{location}. Book tickets now!"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def display_match_datetime
|
|
54
|
+
match_datetime&.strftime('%B %d, %Y at %I:%M %p')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def match_datetime
|
|
58
|
+
return nil unless @date && @time
|
|
59
|
+
|
|
60
|
+
Time.zone.parse("#{@date} #{@time}")
|
|
61
|
+
rescue StandardError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_h
|
|
66
|
+
{
|
|
67
|
+
_id: _id,
|
|
68
|
+
home_name: home_name,
|
|
69
|
+
home_logo: home_logo,
|
|
70
|
+
away_name: away_name,
|
|
71
|
+
away_logo: away_logo,
|
|
72
|
+
home_score: home_score,
|
|
73
|
+
away_score: away_score,
|
|
74
|
+
date: date,
|
|
75
|
+
time: time,
|
|
76
|
+
stadium: stadium,
|
|
77
|
+
week: week,
|
|
78
|
+
league_id: league_id,
|
|
79
|
+
club_id: club_id,
|
|
80
|
+
discount_ticket: discount_ticket,
|
|
81
|
+
on_sale: on_sale,
|
|
82
|
+
is_ended: is_ended,
|
|
83
|
+
allow_predition: allow_predition,
|
|
84
|
+
file: file,
|
|
85
|
+
created_by: created_by,
|
|
86
|
+
updated_by: updated_by,
|
|
87
|
+
zones: zones&.map(&:to_h),
|
|
88
|
+
promotion: promotion,
|
|
89
|
+
poster: poster,
|
|
90
|
+
league: league&.to_h
|
|
91
|
+
}.compact
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
module Resources
|
|
3
|
+
class Ticket < Base
|
|
4
|
+
attr_accessor :_id,
|
|
5
|
+
:match_id,
|
|
6
|
+
:uid,
|
|
7
|
+
:first_uid,
|
|
8
|
+
:generated_by,
|
|
9
|
+
:club_id,
|
|
10
|
+
:zone_id,
|
|
11
|
+
:league_id,
|
|
12
|
+
:token_id,
|
|
13
|
+
:payment_method,
|
|
14
|
+
:ticket_type,
|
|
15
|
+
:remark,
|
|
16
|
+
:is_used,
|
|
17
|
+
:is_online,
|
|
18
|
+
:used_at,
|
|
19
|
+
:is_expired,
|
|
20
|
+
:created_at,
|
|
21
|
+
:updated_at,
|
|
22
|
+
:cashback,
|
|
23
|
+
:__v
|
|
24
|
+
|
|
25
|
+
def initialize(attributes = {})
|
|
26
|
+
super(attributes)
|
|
27
|
+
|
|
28
|
+
@is_used = attributes['is_used'] == true || attributes['is_used'] == 'true'
|
|
29
|
+
@is_online = attributes['is_online'] != false
|
|
30
|
+
@is_expired = attributes['is_expired'] == true || attributes['is_expired'] == 'true'
|
|
31
|
+
@created_at = parse_timestamp(attributes['created_at'])
|
|
32
|
+
@updated_at = parse_timestamp(attributes['updated_at'])
|
|
33
|
+
@used_at = parse_timestamp(attributes['used_at'])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_h
|
|
37
|
+
{
|
|
38
|
+
_id: _id,
|
|
39
|
+
match_id: match_id,
|
|
40
|
+
uid: uid,
|
|
41
|
+
first_uid: first_uid,
|
|
42
|
+
generated_by: generated_by,
|
|
43
|
+
club_id: club_id,
|
|
44
|
+
zone_id: zone_id,
|
|
45
|
+
league_id: league_id,
|
|
46
|
+
token_id: token_id,
|
|
47
|
+
payment_method: payment_method,
|
|
48
|
+
ticket_type: ticket_type,
|
|
49
|
+
remark: remark,
|
|
50
|
+
is_used: is_used,
|
|
51
|
+
is_online: is_online,
|
|
52
|
+
used_at: used_at&.iso8601,
|
|
53
|
+
is_expired: is_expired,
|
|
54
|
+
created_at: created_at&.iso8601,
|
|
55
|
+
updated_at: updated_at&.iso8601,
|
|
56
|
+
cashback: cashback,
|
|
57
|
+
__v: __v
|
|
58
|
+
}.compact
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Parses timestamp values from the Stadium X API response.
|
|
64
|
+
# The API returns timestamps in two formats:
|
|
65
|
+
# 1. Hash format with 'low' and 'high' properties (Long type from backend): {"low"=>239159874, "high"=>411, "unsigned"=>false}
|
|
66
|
+
# 2. Numeric format (milliseconds since epoch): 1702353644000
|
|
67
|
+
# Converts millisecond timestamps to Time objects in the application's timezone.
|
|
68
|
+
# Returns nil if the value is blank or in an unsupported format.
|
|
69
|
+
def parse_timestamp(value)
|
|
70
|
+
return nil if value.blank?
|
|
71
|
+
|
|
72
|
+
if value.is_a?(Hash)
|
|
73
|
+
timestamp_ms = (value['high'].to_i << 32) | value['low'].to_i
|
|
74
|
+
Time.zone.at(timestamp_ms / 1000)
|
|
75
|
+
elsif value.is_a?(Numeric)
|
|
76
|
+
Time.zone.at(value / 1000)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
module Resources
|
|
3
|
+
class TicketImage < Base
|
|
4
|
+
attr_accessor :zone_id,
|
|
5
|
+
:ticket_image
|
|
6
|
+
|
|
7
|
+
def initialize(attributes = {})
|
|
8
|
+
super(attributes)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_h
|
|
12
|
+
{
|
|
13
|
+
zone_id: zone_id,
|
|
14
|
+
ticket_image: ticket_image
|
|
15
|
+
}.compact
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
module Resources
|
|
3
|
+
class Zone < Base
|
|
4
|
+
attr_accessor :_id,
|
|
5
|
+
:title,
|
|
6
|
+
:color_brand,
|
|
7
|
+
:total_seats,
|
|
8
|
+
:online_price,
|
|
9
|
+
:walkin_price,
|
|
10
|
+
:match_id,
|
|
11
|
+
:club_id,
|
|
12
|
+
:is_active,
|
|
13
|
+
:created_by,
|
|
14
|
+
:updated_by,
|
|
15
|
+
:created_at,
|
|
16
|
+
:updated_at,
|
|
17
|
+
:end_datetime,
|
|
18
|
+
:is_earlybird,
|
|
19
|
+
:start_datetime,
|
|
20
|
+
:available_seats,
|
|
21
|
+
:ticket_image
|
|
22
|
+
|
|
23
|
+
def initialize(attributes = {})
|
|
24
|
+
super(attributes)
|
|
25
|
+
|
|
26
|
+
@total_seats = attributes['total_seats'].to_i
|
|
27
|
+
@available_seats = attributes['available_seats'].to_i
|
|
28
|
+
@online_price = attributes['online_price'].to_f
|
|
29
|
+
@walkin_price = attributes['walkin_price'].to_f
|
|
30
|
+
@is_active = attributes['is_active'] != false
|
|
31
|
+
@color_brand = valid_hex_color?(attributes['color_brand']) ? attributes['color_brand'].upcase : nil
|
|
32
|
+
|
|
33
|
+
@created_at = Time.zone.at(attributes['created_at'] / 1000) if attributes['created_at'].present?
|
|
34
|
+
@updated_at = Time.zone.at(attributes['updated_at'] / 1000) if attributes['updated_at'].present?
|
|
35
|
+
|
|
36
|
+
@start_datetime = Time.zone.parse(attributes['start_datetime']) if attributes['start_datetime'].present?
|
|
37
|
+
@end_datetime = Time.zone.parse(attributes['end_datetime']) if attributes['end_datetime'].present?
|
|
38
|
+
@ticket_image = TicketImage.new(attributes['ticket_image']) if attributes['ticket_image'].is_a?(Hash)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Manually set the match_id on the zone object.
|
|
42
|
+
# This is needed because the API does not return match_id but it's required for ticket creation.
|
|
43
|
+
# This is a temporary workaround until the API returns the match_id.
|
|
44
|
+
def set_match_id(match_id) # rubocop:disable Naming/AccessorMethodName
|
|
45
|
+
@match_id = match_id
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_hash
|
|
49
|
+
hash = {
|
|
50
|
+
'_id' => _id,
|
|
51
|
+
'title' => title,
|
|
52
|
+
'color_brand' => color_brand,
|
|
53
|
+
'total_seats' => total_seats,
|
|
54
|
+
'online_price' => online_price,
|
|
55
|
+
'walkin_price' => walkin_price,
|
|
56
|
+
'match_id' => match_id,
|
|
57
|
+
'club_id' => club_id,
|
|
58
|
+
'is_active' => is_active,
|
|
59
|
+
'created_by' => created_by,
|
|
60
|
+
'updated_by' => updated_by,
|
|
61
|
+
'created_at' => created_at&.iso8601,
|
|
62
|
+
'updated_at' => updated_at&.iso8601,
|
|
63
|
+
'end_datetime' => end_datetime&.iso8601,
|
|
64
|
+
'is_earlybird' => is_earlybird,
|
|
65
|
+
'start_datetime' => start_datetime&.iso8601,
|
|
66
|
+
'available_seats' => available_seats,
|
|
67
|
+
'ticket_image' => ticket_image&.to_h
|
|
68
|
+
}.compact
|
|
69
|
+
|
|
70
|
+
hash['ticket_image'] = hash['ticket_image'].transform_keys(&:to_s) if hash['ticket_image']
|
|
71
|
+
|
|
72
|
+
hash
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
alias to_h to_hash
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Validates if a color value is a valid hex color code.
|
|
80
|
+
# Accepts formats: #RRGGBB, #RRGGBBAA, RRGGBB, RRGGBBAA (with or without # prefix)
|
|
81
|
+
# Returns true only if the value is a valid hex color, false otherwise.
|
|
82
|
+
def valid_hex_color?(value)
|
|
83
|
+
return false if value.blank?
|
|
84
|
+
|
|
85
|
+
hex_pattern = /\A#?([0-9a-fA-F]{6}|[0-9a-fA-F]{8})\z/
|
|
86
|
+
hex_pattern.match?(value.to_s)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
class SyncManager < ::SpreeCmCommissioner::Integrations::Base::SyncManager
|
|
3
|
+
# override
|
|
4
|
+
def sync_full!
|
|
5
|
+
run_execution(:full) do
|
|
6
|
+
SyncStrategies::FullSyncStrategy.new(
|
|
7
|
+
client: @client,
|
|
8
|
+
integration: @integration
|
|
9
|
+
).call
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# override
|
|
14
|
+
def sync_incremental!
|
|
15
|
+
run_execution(:incremental) do
|
|
16
|
+
SyncStrategies::IncrementalSyncStrategy.new(
|
|
17
|
+
client: @client,
|
|
18
|
+
integration: @integration
|
|
19
|
+
).call
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# override
|
|
24
|
+
def sync_webhook!(event_type:, event_data:)
|
|
25
|
+
run_execution(:webhook) do
|
|
26
|
+
SyncStrategies::WebhookSyncStrategy.new(
|
|
27
|
+
client: @client,
|
|
28
|
+
integration: @integration,
|
|
29
|
+
event_type: event_type,
|
|
30
|
+
event_data: event_data
|
|
31
|
+
).call
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
module SyncStrategies
|
|
3
|
+
# Full sync strategy - syncs all matches from external system.
|
|
4
|
+
#
|
|
5
|
+
# Runs on a schedule (e.g., daily) to fetch and update all matches from Stadium X V1 API.
|
|
6
|
+
# After full sync, IncrementalSyncStrategy will sync zone data one match at a time.
|
|
7
|
+
#
|
|
8
|
+
# Example flow:
|
|
9
|
+
# 1. Full sync runs at 2:00 AM
|
|
10
|
+
# - Fetches all matches from Stadium X V1
|
|
11
|
+
# - Creates/updates match records
|
|
12
|
+
# - Creates integration mappings
|
|
13
|
+
# 2. Incremental sync runs every 10 seconds
|
|
14
|
+
# - Syncs zone data for one match per interval
|
|
15
|
+
# - Keeps data fresh without overwhelming the system
|
|
16
|
+
class FullSyncStrategy
|
|
17
|
+
def initialize(client:, integration:)
|
|
18
|
+
@client = client
|
|
19
|
+
@integration = integration
|
|
20
|
+
@sync_result = ::SpreeCmCommissioner::Integrations::Base::SyncResult.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
begin
|
|
25
|
+
Polling::SyncMatches.new(
|
|
26
|
+
client: @client,
|
|
27
|
+
integration: @integration,
|
|
28
|
+
sync_result: @sync_result
|
|
29
|
+
).call
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
@sync_result.record_error(e)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@sync_result
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
module SyncStrategies
|
|
3
|
+
# Incremental sync strategy - syncs zones one match at a time.
|
|
4
|
+
#
|
|
5
|
+
# Runs every 10 seconds to sync zone data incrementally. Instead of syncing all matches
|
|
6
|
+
# at once (which would be slow), we sync one match per interval in round-robin order.
|
|
7
|
+
#
|
|
8
|
+
# Example with 3 matches:
|
|
9
|
+
# 10:00:00 - Sync zones for Match #1 (oldest last_synced_at)
|
|
10
|
+
# 10:00:10 - Sync zones for Match #2
|
|
11
|
+
# 10:00:20 - Sync zones for Match #3
|
|
12
|
+
# 10:00:30 - Sync zones for Match #1 again (cycle repeats)
|
|
13
|
+
#
|
|
14
|
+
# This ensures all matches get synced regularly without overwhelming the system.
|
|
15
|
+
class IncrementalSyncStrategy
|
|
16
|
+
def initialize(client:, integration:)
|
|
17
|
+
@client = client
|
|
18
|
+
@integration = integration
|
|
19
|
+
@sync_result = ::SpreeCmCommissioner::Integrations::Base::SyncResult.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
begin
|
|
24
|
+
oldest_sync_mapping = Spree::Taxon.find_oldest_active_mapping(integration_id: @integration.id)
|
|
25
|
+
return @sync_result if oldest_sync_mapping.nil?
|
|
26
|
+
|
|
27
|
+
@sync_result.increment_metric(:match, :synced)
|
|
28
|
+
|
|
29
|
+
Polling::SyncZones.new(
|
|
30
|
+
integration: @integration,
|
|
31
|
+
client: @integration.client,
|
|
32
|
+
sync_result: @sync_result
|
|
33
|
+
).call(external_match_id: oldest_sync_mapping.external_id)
|
|
34
|
+
|
|
35
|
+
oldest_sync_mapping.update!(last_synced_at: Time.current)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
@sync_result.record_error(e)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@sync_result
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::StadiumXV1
|
|
2
|
+
module SyncStrategies
|
|
3
|
+
class WebhookSyncStrategy
|
|
4
|
+
def initialize(client:, integration:, event_type:, event_data:)
|
|
5
|
+
@client = client
|
|
6
|
+
@integration = integration
|
|
7
|
+
@event_type = event_type
|
|
8
|
+
@event_data = event_data
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
# TODO: Implement webhook sync logic
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|