spree_cm_commissioner 2.5.0.pre.pre8 → 2.5.0.pre.pre10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +3 -2
  3. data/.github/workflows/test_and_build_gem.yml +123 -57
  4. data/.tool-versions +1 -1
  5. data/Gemfile.lock +1 -1
  6. data/Rakefile +55 -29
  7. data/app/controllers/spree/admin/base_controller_decorator.rb +3 -3
  8. data/app/controllers/spree/admin/base_import_orders_controller.rb +6 -1
  9. data/app/controllers/spree/admin/classifications_controller.rb +1 -1
  10. data/app/controllers/spree/admin/integration_mappings_controller.rb +21 -0
  11. data/app/controllers/spree/admin/integration_sessions_controller.rb +21 -0
  12. data/app/controllers/spree/admin/integrations_controller.rb +83 -0
  13. data/app/controllers/spree/admin/notification_sender_controller.rb +1 -1
  14. data/app/controllers/spree/api/v2/storefront/event_matches_controller.rb +15 -0
  15. data/app/controllers/spree/api/v2/storefront/queue_cart/line_items_controller.rb +6 -6
  16. data/app/controllers/spree/api/v2/storefront/trips_controller.rb +11 -0
  17. data/app/errors/spree_cm_commissioner/integrations/external_client_error.rb +10 -0
  18. data/app/errors/spree_cm_commissioner/integrations/sync_error.rb +4 -0
  19. data/app/finders/spree_cm_commissioner/events/find_matches.rb +15 -0
  20. data/app/helpers/spree_cm_commissioner/external_integrations_helper.rb +58 -0
  21. data/app/interactors/spree_cm_commissioner/create_event.rb +1 -1
  22. data/app/interactors/spree_cm_commissioner/customer_notification_cron_executor.rb +1 -1
  23. data/app/interactors/spree_cm_commissioner/stock/inventory_item_resetter.rb +1 -1
  24. data/app/interactors/spree_cm_commissioner/stock/stock_movement_creator.rb +3 -2
  25. data/app/jobs/spree_cm_commissioner/conversion_pre_calculator_job.rb +2 -2
  26. data/app/jobs/spree_cm_commissioner/customer_notification_sender_job.rb +3 -3
  27. data/app/jobs/spree_cm_commissioner/enqueue_cart/add_item_job.rb +7 -7
  28. data/app/jobs/spree_cm_commissioner/ensure_event_for_product_line_item_guests_job.rb +1 -1
  29. data/app/jobs/spree_cm_commissioner/event_line_items_date_syncer_job.rb +2 -2
  30. data/app/jobs/spree_cm_commissioner/export_csv_job.rb +2 -2
  31. data/app/jobs/spree_cm_commissioner/import_order_job.rb +5 -5
  32. data/app/jobs/spree_cm_commissioner/integrations/base_job.rb +39 -0
  33. data/app/jobs/spree_cm_commissioner/integrations/polling_job.rb +53 -0
  34. data/app/jobs/spree_cm_commissioner/integrations/polling_scheduler_job.rb +30 -0
  35. data/app/jobs/spree_cm_commissioner/invalidate_cache_request_job.rb +2 -2
  36. data/app/jobs/spree_cm_commissioner/inventory_item_syncer_job.rb +8 -2
  37. data/app/jobs/spree_cm_commissioner/option_type_variants_public_metadata_updater_job.rb +7 -3
  38. data/app/jobs/spree_cm_commissioner/option_value_variants_public_metadata_updater_job.rb +6 -2
  39. data/app/jobs/spree_cm_commissioner/order_complete_telegram_sender_job.rb +2 -2
  40. data/app/jobs/spree_cm_commissioner/product_event_id_to_children_syncer_job.rb +2 -2
  41. data/app/jobs/spree_cm_commissioner/reports_assigner_job.rb +2 -2
  42. data/app/jobs/spree_cm_commissioner/sms_pin_code_job.rb +1 -1
  43. data/app/jobs/spree_cm_commissioner/state_job.rb +2 -2
  44. data/app/jobs/spree_cm_commissioner/stock/inventory_items_adjuster_job.rb +6 -3
  45. data/app/jobs/spree_cm_commissioner/stock/inventory_items_generator_job.rb +2 -2
  46. data/app/jobs/spree_cm_commissioner/telegram_alerts/integration_sync_failure_job.rb +17 -0
  47. data/app/jobs/spree_cm_commissioner/transit/route_fulfilled_order_count_incrementer_job.rb +2 -2
  48. data/app/jobs/spree_cm_commissioner/transit/route_order_count_incrementer_job.rb +2 -2
  49. data/app/jobs/spree_cm_commissioner/transit/route_previous_trip_count_decrementer_job.rb +2 -2
  50. data/app/jobs/spree_cm_commissioner/transit/route_trip_count_decrementer_job.rb +2 -2
  51. data/app/jobs/spree_cm_commissioner/transit/route_trip_count_incrementer_job.rb +2 -2
  52. data/app/jobs/spree_cm_commissioner/vendor_creation_telegram_alert_sender_job.rb +2 -2
  53. data/app/jobs/spree_cm_commissioner/vendor_job.rb +2 -2
  54. data/app/jobs/spree_cm_commissioner/waiting_room_session_firebase_logger_job.rb +1 -1
  55. data/app/models/concerns/spree_cm_commissioner/integrations/integration_mappable.rb +61 -0
  56. data/app/models/concerns/spree_cm_commissioner/line_item_integration.rb +36 -0
  57. data/app/models/concerns/spree_cm_commissioner/option_type_attr_type.rb +16 -4
  58. data/app/models/concerns/spree_cm_commissioner/option_value_attr_type.rb +1 -1
  59. data/app/models/concerns/spree_cm_commissioner/order_integration.rb +33 -0
  60. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +4 -2
  61. data/app/models/concerns/spree_cm_commissioner/store_metadata.rb +14 -2
  62. data/app/models/concerns/spree_cm_commissioner/variant_options_concern.rb +1 -7
  63. data/app/models/spree_cm_commissioner/export.rb +1 -1
  64. data/app/models/spree_cm_commissioner/guest.rb +13 -0
  65. data/app/models/spree_cm_commissioner/integration.rb +29 -0
  66. data/app/models/spree_cm_commissioner/integration_mapping.rb +41 -0
  67. data/app/models/spree_cm_commissioner/integration_sync_session.rb +15 -0
  68. data/app/models/spree_cm_commissioner/integrations/stadium_x_v1.rb +37 -0
  69. data/app/models/spree_cm_commissioner/inventory_item.rb +5 -1
  70. data/app/models/spree_cm_commissioner/line_item_decorator.rb +13 -1
  71. data/app/models/spree_cm_commissioner/option_type_decorator.rb +8 -0
  72. data/app/models/spree_cm_commissioner/option_value_decorator.rb +34 -0
  73. data/app/models/spree_cm_commissioner/order_decorator.rb +1 -0
  74. data/app/models/spree_cm_commissioner/product_decorator.rb +4 -3
  75. data/app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb +2 -2
  76. data/app/models/spree_cm_commissioner/redis_stock/inventory_updater.rb +6 -3
  77. data/app/models/spree_cm_commissioner/stock_item_decorator.rb +4 -4
  78. data/app/models/spree_cm_commissioner/taxon_decorator.rb +2 -1
  79. data/app/models/spree_cm_commissioner/taxonomy_decorator.rb +13 -1
  80. data/app/models/spree_cm_commissioner/variant_decorator.rb +7 -4
  81. data/app/models/spree_cm_commissioner/vendor_decorator.rb +6 -2
  82. data/app/overrides/spree/admin/shared/sub_menu/_integrations/external_integrations.html.erb.deface +6 -0
  83. data/app/presenters/spree/variants/{visable_options_presenter.rb → visible_options_presenter.rb} +2 -4
  84. data/app/serializers/spree_cm_commissioner/v2/storefront/trip_serializer.rb +1 -1
  85. data/app/services/spree_cm_commissioner/integrations/base/sync_manager.rb +69 -0
  86. data/app/services/spree_cm_commissioner/integrations/base/sync_result.rb +183 -0
  87. data/app/services/spree_cm_commissioner/integrations/polling.rb +70 -0
  88. data/app/services/spree_cm_commissioner/integrations/polling_scheduler.rb +79 -0
  89. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/external_client/client.rb +152 -0
  90. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/inventory/unstock_inventory.rb +83 -0
  91. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_matches.rb +113 -0
  92. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/polling/sync_zones.rb +215 -0
  93. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/base.rb +20 -0
  94. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/league.rb +19 -0
  95. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/match.rb +95 -0
  96. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/ticket.rb +81 -0
  97. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/ticket_image.rb +19 -0
  98. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/resources/zone.rb +90 -0
  99. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_manager.rb +35 -0
  100. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/full_sync_strategy.rb +38 -0
  101. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/incremental_sync_strategy.rb +44 -0
  102. data/app/services/spree_cm_commissioner/integrations/stadium_x_v1/sync_strategies/webhook_sync_strategy.rb +16 -0
  103. data/app/services/spree_cm_commissioner/telegram_alerts/integration_sync_failure.rb +49 -0
  104. data/app/views/spree/admin/integration_mappings/_integration_mappings.html.erb +107 -0
  105. data/app/views/spree/admin/integration_mappings/index.html.erb +33 -0
  106. data/app/views/spree/admin/integration_sessions/_integration_sync_sessions.html.erb +116 -0
  107. data/app/views/spree/admin/integration_sessions/index.html.erb +42 -0
  108. data/app/views/spree/admin/integrations/_form.html.erb +104 -0
  109. data/app/views/spree/admin/integrations/_stadium_x_v1_fields.html.erb +29 -0
  110. data/app/views/spree/admin/integrations/edit.html.erb +45 -0
  111. data/app/views/spree/admin/integrations/index.html.erb +75 -0
  112. data/app/views/spree/admin/integrations/new.html.erb +25 -0
  113. data/bin/run_spec_group +101 -0
  114. data/config/locales/en.yml +8 -0
  115. data/config/locales/km.yml +8 -0
  116. data/config/routes.rb +8 -0
  117. data/db/migrate/20251017094845_create_cm_integrations.rb +22 -0
  118. data/db/migrate/20251017101555_create_cm_integration_sync_sessions.rb +68 -0
  119. data/db/migrate/20251017101605_create_cm_integration_mappings.rb +52 -0
  120. data/lib/cm_app_logger.rb +36 -4
  121. data/lib/spree_cm_commissioner/test_helper/factories/integration_factory.rb +25 -0
  122. data/lib/spree_cm_commissioner/test_helper/factories/integration_mapping_factory.rb +14 -0
  123. data/lib/spree_cm_commissioner/test_helper/factories/integration_sync_session_factory.rb +7 -0
  124. data/lib/spree_cm_commissioner/test_helper/factories/vendor_factory.rb +1 -0
  125. data/lib/spree_cm_commissioner/test_helper/factories/vendor_place_factory.rb +3 -2
  126. data/lib/spree_cm_commissioner/version.rb +1 -1
  127. data/lib/spree_cm_commissioner.rb +8 -7
  128. metadata +58 -3
@@ -0,0 +1,36 @@
1
+ # Caches the integration status of a line item's variant in private_metadata.
2
+ #
3
+ # Why cache?
4
+ # Most products don't have integrations, so checking `variant.vendor.integration.present?`
5
+ # on every read would cause N+1 queries. Caching avoids this performance hit.
6
+ #
7
+ # Behavior:
8
+ # The flag is set once at line item creation and remains immutable. This preserves
9
+ # the historical integration state of the order, even if the vendor's integration
10
+ # status changes later. We don't have use cases requiring dynamic updates of this yet,
11
+ # so we keep it constant for simplicity and consistency.
12
+ module SpreeCmCommissioner
13
+ module LineItemIntegration
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ before_create :set_integration_flag
18
+ end
19
+
20
+ def integration?
21
+ return false if private_metadata['integration'].nil?
22
+
23
+ ActiveModel::Type::Boolean.new.cast(private_metadata['integration'])
24
+ end
25
+
26
+ private
27
+
28
+ def set_integration_flag
29
+ return if private_metadata.key?('integration')
30
+ return if variant.nil? || variant.vendor.nil?
31
+
32
+ # We use variant.vendor instead of .vendor directly as on create, the association may not be set to line item yet.
33
+ private_metadata['integration'] ||= variant.vendor.integration.present?
34
+ end
35
+ end
36
+ end
@@ -45,7 +45,8 @@ module SpreeCmCommissioner
45
45
  'color' => 'color',
46
46
  'ticket-type' => 'string',
47
47
  'seat-type' => 'string',
48
- 'intercity-taxi' => 'string'
48
+ 'intercity-taxi' => 'string',
49
+ 'rules' => 'string'
49
50
  }.freeze
50
51
 
51
52
  included do
@@ -56,7 +57,7 @@ module SpreeCmCommissioner
56
57
 
57
58
  validate :ensure_name_is_not_changed, on: :update
58
59
 
59
- before_validation :set_reverved_options_attributes, if: :reserved_option?
60
+ before_validation :set_reserved_options_attributes, if: :reserved_option?
60
61
 
61
62
  after_save :sort_date_time_option_values, if: -> { attr_type == 'date' || attr_type == 'time' }
62
63
  after_save :update_variants_metadata, if: :saved_change_to_name?
@@ -68,6 +69,17 @@ module SpreeCmCommissioner
68
69
  end
69
70
  end
70
71
 
72
+ class_methods do
73
+ def rules_option_type = find_by(name: 'rules')
74
+
75
+ def ticket_type
76
+ Spree::OptionType.find_or_create_by!(name: 'ticket-type') do |ot|
77
+ ot.presentation = 'Ticket Type'
78
+ ot.kind = :variant
79
+ end
80
+ end
81
+ end
82
+
71
83
  def reserved_option?
72
84
  return name_was.in?(RESERVED_OPTIONS.keys) if name_changed?
73
85
 
@@ -78,7 +90,7 @@ module SpreeCmCommissioner
78
90
  name == 'ticket-type'
79
91
  end
80
92
 
81
- def set_reverved_options_attributes
93
+ def set_reserved_options_attributes
82
94
  self.attr_type = RESERVED_OPTIONS[name]
83
95
  self.kind = :variant
84
96
  end
@@ -92,7 +104,7 @@ module SpreeCmCommissioner
92
104
  end
93
105
 
94
106
  def update_variants_metadata
95
- SpreeCmCommissioner::OptionTypeVariantsPublicMetadataUpdaterJob.perform_later(id)
107
+ SpreeCmCommissioner::OptionTypeVariantsPublicMetadataUpdaterJob.perform_later(option_type_id: id)
96
108
  end
97
109
 
98
110
  private
@@ -76,7 +76,7 @@ module SpreeCmCommissioner
76
76
  end
77
77
 
78
78
  def update_variants_metadata
79
- SpreeCmCommissioner::OptionValueVariantsPublicMetadataUpdaterJob.perform_later(id)
79
+ SpreeCmCommissioner::OptionValueVariantsPublicMetadataUpdaterJob.perform_later(option_value_id: id)
80
80
  end
81
81
 
82
82
  private
@@ -0,0 +1,33 @@
1
+ module SpreeCmCommissioner
2
+ module OrderIntegration
3
+ # Checks if any line item in this order has an integration (i.e., is from a vendor with an integration).
4
+ # This is used to determine if inventory management operations should be delegated to an external system.
5
+ def integration?
6
+ line_items.any?(&:integration?)
7
+ end
8
+
9
+ # Attempts to unstock inventory for all integration line items in this order.
10
+ # Groups line items by vendor and delegates to the vendor's integration service.
11
+ def unstock_inventory_from_external_system!
12
+ integration_line_items = line_items.select(&:integration?)
13
+ return if integration_line_items.empty?
14
+
15
+ integration_line_items.group_by(&:vendor_id).each do |_, line_items|
16
+ integration = line_items.first&.vendor&.integration
17
+ integration.unstock_external_inventory!(self, line_items) if integration.present?
18
+ end
19
+ end
20
+
21
+ # Attempts to restock inventory for all integration line items in this order.
22
+ # Groups line items by vendor and delegates to the vendor's integration service.
23
+ def restock_inventory_from_external_system!
24
+ integration_line_items = line_items.select(&:integration?)
25
+ return if integration_line_items.empty?
26
+
27
+ integration_line_items.group_by(&:vendor_id).each do |_, line_items|
28
+ integration = line_items.first&.vendor&.integration
29
+ integration.restock_external_inventory!(self, line_items) if integration.present?
30
+ end
31
+ end
32
+ end
33
+ end
@@ -83,7 +83,7 @@ module SpreeCmCommissioner
83
83
 
84
84
  def precalculate_conversion
85
85
  line_items.each do |item|
86
- SpreeCmCommissioner::ConversionPreCalculatorJob.perform_later(item.product_id)
86
+ SpreeCmCommissioner::ConversionPreCalculatorJob.perform_later(product_id: item.product_id)
87
87
  end
88
88
  end
89
89
 
@@ -143,6 +143,7 @@ module SpreeCmCommissioner
143
143
  # The payment will be reversed in vPago gem, and `Spree::Checkout::Complete` will be called, which checks `order.reload.complete?`.
144
144
  # This is critical because if the order state is complete, the payment will be marked as paid.
145
145
 
146
+ unstock_inventory_from_external_system!
146
147
  reserve_blocks!
147
148
  unstock_inventory_in_redis!
148
149
 
@@ -161,6 +162,7 @@ module SpreeCmCommissioner
161
162
 
162
163
  def restock_inventory!
163
164
  ActiveRecord::Base.transaction do
165
+ restock_inventory_from_external_system!
164
166
  cancel_blocks!
165
167
  restock_inventory_in_redis!
166
168
  end
@@ -244,7 +246,7 @@ module SpreeCmCommissioner
244
246
  end
245
247
 
246
248
  def notify_order_complete_telegram_notification_to_user
247
- SpreeCmCommissioner::OrderCompleteTelegramSenderJob.perform_later(id) if user_id.present?
249
+ SpreeCmCommissioner::OrderCompleteTelegramSenderJob.perform_later(order_id: id) if user_id.present?
248
250
  end
249
251
 
250
252
  def send_order_requested_app_notification_to_user
@@ -13,6 +13,7 @@
13
13
  # - integer
14
14
  # - array
15
15
  # - datetime
16
+ # - hash
16
17
  #
17
18
  # Example usage:
18
19
  # ```
@@ -22,6 +23,7 @@
22
23
  # store_public_metadata :completed, :boolean, default: true
23
24
  # store_public_metadata :count, :integer, default: 5
24
25
  # store_public_metadata :tags, :array, default: []
26
+ # store_public_metadata :tags, :hash
25
27
  # store_private_metadata :app_token, :string, default: "XYZ"
26
28
  # store_public_metadata :scheduled_at, :datetime, default: Time.now
27
29
  # end
@@ -92,7 +94,7 @@ module SpreeCmCommissioner
92
94
  # - Return default if nil
93
95
  # - Cast to correct type
94
96
  # - Add `?` predicate for booleans
95
- def define_metadata_reader(column_name, key, type, default)
97
+ def define_metadata_reader(column_name, key, type, default) # rubocop:disable Metrics/CyclomaticComplexity
96
98
  define_method(key) do
97
99
  metadata = send(column_name) || {}
98
100
  raw_value = metadata[key.to_s]
@@ -109,6 +111,8 @@ module SpreeCmCommissioner
109
111
  Array(raw_value)
110
112
  when :datetime
111
113
  Time.zone.parse(raw_value.to_s)
114
+ when :hash
115
+ raw_value.is_a?(Hash) ? raw_value : {}
112
116
  else
113
117
  raw_value
114
118
  end
@@ -118,7 +122,7 @@ module SpreeCmCommissioner
118
122
  end
119
123
 
120
124
  # Validates only new assignments (raw JSON values) for type safety
121
- def define_metadata_validation(column_name, key, type) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
125
+ def define_metadata_validation(column_name, key, type) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity,Metrics/AbcSize
122
126
  case type
123
127
  when :boolean
124
128
  validates key, inclusion: { in: [true, false] }, allow_nil: true
@@ -160,6 +164,14 @@ module SpreeCmCommissioner
160
164
  errors.add(key, 'is not a valid datetime')
161
165
  end
162
166
  end
167
+ when :hash
168
+ validate do
169
+ metadata = send(column_name) || {}
170
+ raw_value = metadata[key.to_s]
171
+ next if raw_value.nil?
172
+
173
+ errors.add(key, 'must be a hash') unless raw_value.is_a?(Hash)
174
+ end
163
175
  end
164
176
  end
165
177
  end
@@ -35,12 +35,6 @@ module SpreeCmCommissioner
35
35
  to: :options
36
36
  end
37
37
 
38
- # Override variant.rb to return cached formatted options text, avoiding repeated database queries.
39
- # Falls back to database queries & computing the format if preload data is unavailable.
40
- def options_text
41
- @options_text ||= public_metadata[:preload_options_text].presence || Spree::Variants::VisableOptionsPresenter.new(self).to_sentence
42
- end
43
-
44
38
  def options
45
39
  @options ||= VariantOptions.new(self)
46
40
  end
@@ -111,7 +105,7 @@ module SpreeCmCommissioner
111
105
  # Precomputes the human-readable format (e.g., "Red, 256GB") to avoid
112
106
  # repeated formatting and association queries.
113
107
  # Example: {"color" => "red", "storage" => "256GB"} - option_type_name => option_value_name pairs
114
- self.public_metadata[:preload_options_text] = Spree::Variants::VisableOptionsPresenter.new(self).to_sentence
108
+ self.public_metadata[:preload_options_text] = Spree::Variants::VisibleOptionsPresenter.new(self).to_sentence
115
109
  end
116
110
 
117
111
  def set_options_to_public_metadata!
@@ -22,7 +22,7 @@ module SpreeCmCommissioner
22
22
  end
23
23
 
24
24
  def export_csv
25
- SpreeCmCommissioner::ExportCsvJob.perform_later(id)
25
+ SpreeCmCommissioner::ExportCsvJob.perform_later(export_id: id)
26
26
  end
27
27
  end
28
28
  end
@@ -3,6 +3,8 @@ module SpreeCmCommissioner
3
3
  class Guest < SpreeCmCommissioner::Base # rubocop:disable Metrics/ClassLength
4
4
  include SpreeCmCommissioner::KycBitwise
5
5
  include SpreeCmCommissioner::PhoneNumberSanitizer
6
+ include SpreeCmCommissioner::StoreMetadata
7
+ include SpreeCmCommissioner::Integrations::IntegrationMappable
6
8
 
7
9
  delegate :kyc, to: :line_item, allow_nil: true
8
10
  delegate :allowed_upload_later?, to: :line_item, allow_nil: true
@@ -212,9 +214,20 @@ module SpreeCmCommissioner
212
214
  end
213
215
 
214
216
  def qr_data
217
+ return external_qr_data if external_qr_data.present?
218
+
215
219
  token
216
220
  end
217
221
 
222
+ # QR data for check-in. If external_wins? is true, use their QR data (they have their own check-in system).
223
+ # Otherwise, use our system's QR data. Only applicable to models with QR support (e.g., line_item, guest).
224
+ def external_qr_data
225
+ return nil if line_item.nil? || !line_item.integration?
226
+
227
+ mapping = external_wins_integration_mappings.first
228
+ mapping.external_qr_data if mapping.present?
229
+ end
230
+
218
231
  def current_age
219
232
  return nil if dob.nil?
220
233
 
@@ -0,0 +1,29 @@
1
+ module SpreeCmCommissioner
2
+ class Integration < Base
3
+ include StoreMetadata
4
+
5
+ enum status: { inactive: 0, active: 1, paused: 2 }
6
+ enum conflict_strategy: { newest_wins: 0, internal_wins: 1, external_wins: 2, manual_resolution: 3 }
7
+
8
+ belongs_to :tenant, class_name: 'SpreeCmCommissioner::Tenant', optional: true
9
+ belongs_to :vendor, class_name: 'Spree::Vendor', optional: false, inverse_of: :integration
10
+
11
+ has_many :integration_mappings, class_name: 'SpreeCmCommissioner::IntegrationMapping', dependent: :destroy, inverse_of: :integration
12
+ has_many :integration_sync_sessions, class_name: 'SpreeCmCommissioner::IntegrationSyncSession', dependent: :destroy, inverse_of: :integration
13
+
14
+ validates :incremental_sync_interval_seconds, presence: true, numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: 3600 }
15
+ validates :full_sync_interval_hours, presence: true, numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: 168 }
16
+
17
+ def sync_manager
18
+ raise NotImplementedError, 'Subclasses must implement the sync_manager method'
19
+ end
20
+
21
+ def restock_external_inventory!(_order, _line_items)
22
+ raise NotImplementedError, 'Subclasses must implement the restock_external_inventory! method'
23
+ end
24
+
25
+ def unstock_external_inventory!(_order, _line_items)
26
+ raise NotImplementedError, 'Subclasses must implement the unstock_external_inventory! method'
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,41 @@
1
+ module SpreeCmCommissioner
2
+ class IntegrationMapping < Base
3
+ include StoreMetadata
4
+
5
+ enum status: { active: 0, archived: 1 }
6
+
7
+ # polymorphic (Spree::Taxon, Spree::Product, Spree::Variant, Spree::Vendor)
8
+ belongs_to :internal, polymorphic: true, optional: false
9
+ belongs_to :integration, class_name: 'SpreeCmCommissioner::Integration', inverse_of: :integration_mappings, optional: false
10
+
11
+ validates :external_id, presence: true, uniqueness: { scope: %i[integration_id internal_type internal_id date] }
12
+ validate :validate_internal_exists, if: -> { internal_type.present? && internal_id.present? }
13
+
14
+ # QR data for check-in. If external_wins? is true, use their QR data (they have their own check-in system).
15
+ # Otherwise, use our system's QR data. Only applicable to map with internal that has QR support (e.g., line_item, guest).
16
+ store_public_metadata :external_qr_data, :string, default: nil
17
+
18
+ def mark_as_archived!
19
+ self.status = :archived
20
+ self.last_synced_at = Time.zone.now
21
+
22
+ save!
23
+ end
24
+
25
+ def mark_as_active!(external_payload:)
26
+ self.external_payload = external_payload
27
+ self.last_synced_at = Time.zone.now
28
+ self.status = :active
29
+
30
+ save!
31
+ end
32
+
33
+ private
34
+
35
+ def validate_internal_exists
36
+ return if internal_type.safe_constantize&.exists?(id: internal_id)
37
+
38
+ errors.add(:internal, "record (#{internal_type}##{internal_id}) does not exist")
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,15 @@
1
+ module SpreeCmCommissioner
2
+ class IntegrationSyncSession < Base
3
+ include StoreMetadata
4
+
5
+ enum status: { pending: 0, in_progress: 1, completed: 2, failed: 3, canceled: 4 }
6
+ enum :sync_type, {
7
+ full: 0, # on initial setup or periodic complete refresh
8
+ incremental: 1, # on scheduled intervals to fetch recent changes
9
+ webhook_triggered: 2 # immediately when an external webhook event is received
10
+ }, prefix: true
11
+
12
+ belongs_to :tenant, class_name: 'SpreeCmCommissioner::Tenant', optional: true
13
+ belongs_to :integration, class_name: 'SpreeCmCommissioner::Integration', inverse_of: :integration_sync_sessions, optional: false
14
+ end
15
+ end
@@ -0,0 +1,37 @@
1
+ class SpreeCmCommissioner::Integrations::StadiumXV1 < SpreeCmCommissioner::Integration
2
+ store_private_metadata :public_key, :string
3
+ store_private_metadata :private_key, :string
4
+ store_private_metadata :base_url, :string # e.g. 'https://api.stadiumx.com'
5
+
6
+ # override
7
+ def sync_manager
8
+ SpreeCmCommissioner::Integrations::StadiumXV1::SyncManager.new(
9
+ integration: self,
10
+ client: client
11
+ )
12
+ end
13
+
14
+ # override
15
+ def unstock_external_inventory!(order, line_items)
16
+ result = SpreeCmCommissioner::Integrations::StadiumXV1::Inventory::UnstockInventory.new.call(
17
+ integration: self,
18
+ order: order,
19
+ line_items: line_items
20
+ )
21
+
22
+ raise SpreeCmCommissioner::Integrations::SyncError, result.error unless result.success?
23
+ end
24
+
25
+ # override
26
+ def restock_external_inventory!(_order, _line_items)
27
+ raise SpreeCmCommissioner::Integrations::SyncError, 'Ticket cannot be cancelled'
28
+ end
29
+
30
+ def client
31
+ SpreeCmCommissioner::Integrations::StadiumXV1::ExternalClient::Client.new(
32
+ public_key: public_key,
33
+ private_key: private_key,
34
+ base_url: base_url
35
+ )
36
+ end
37
+ end
@@ -56,8 +56,12 @@ module SpreeCmCommissioner
56
56
  "inventory:#{id}"
57
57
  end
58
58
 
59
+ def quantity_in_redis
60
+ SpreeCmCommissioner.inventory_redis_pool.with { |redis| redis.get(redis_key).to_i }
61
+ end
62
+
59
63
  def adjust_quantity_in_redis(quantity)
60
- SpreeCmCommissioner.redis_pool.with do |redis|
64
+ SpreeCmCommissioner.inventory_redis_pool.with do |redis|
61
65
  # Always update Redis cache, even if it doesn't exist yet.
62
66
  # This prevents admin adjustments from being lost when cache is later initialized.
63
67
  script = <<~LUA
@@ -72,10 +72,12 @@ module SpreeCmCommissioner
72
72
  def self.include_modules(base)
73
73
  base.include Spree::Core::NumberGenerator.new(prefix: 'L')
74
74
  base.include SpreeCmCommissioner::StoreMetadata
75
+ base.include SpreeCmCommissioner::LineItemIntegration
75
76
  base.include SpreeCmCommissioner::LineItemDurationable
76
77
  base.include SpreeCmCommissioner::LineItemsFilterScope
77
78
  base.include SpreeCmCommissioner::LineItemGuestsConcern
78
79
  base.include SpreeCmCommissioner::LineItemTransitable
80
+ base.include SpreeCmCommissioner::Integrations::IntegrationMappable
79
81
  base.include SpreeCmCommissioner::ProductType
80
82
  base.include SpreeCmCommissioner::ProductDelegation
81
83
  base.include SpreeCmCommissioner::KycBitwise
@@ -198,11 +200,21 @@ module SpreeCmCommissioner
198
200
  end
199
201
 
200
202
  def qr_data
203
+ return external_qr_data if external_qr_data.present?
201
204
  return nil if order.nil?
202
205
 
203
206
  "#{order.number}-#{order.token}-L#{id}"
204
207
  end
205
208
 
209
+ # QR data for check-in. If external_wins? is true, use their QR data (they have their own check-in system).
210
+ # Otherwise, use our system's QR data. Only applicable to models with QR support (e.g., line_item, guest).
211
+ def external_qr_data
212
+ return nil unless integration?
213
+
214
+ mapping = external_wins_integration_mappings.first
215
+ mapping.external_qr_data if mapping.present?
216
+ end
217
+
206
218
  def generate_completion_steps
207
219
  generate_completion_steps!
208
220
  true
@@ -255,7 +267,7 @@ module SpreeCmCommissioner
255
267
  end
256
268
 
257
269
  def update_vendor_id
258
- self.vendor_id = variant.vendor_id
270
+ self.vendor_id = variant&.vendor_id
259
271
  end
260
272
 
261
273
  def subscription?
@@ -22,6 +22,14 @@ module SpreeCmCommissioner
22
22
  def base.rules_option_type
23
23
  Spree::OptionType.find_by(name: RULES_OPTION_TYPE_NAME)
24
24
  end
25
+
26
+ # override
27
+ def base.color
28
+ Spree::OptionType.find_or_create_by!(name: 'color') do |ot|
29
+ ot.presentation = 'Color'
30
+ ot.kind = :variant
31
+ end
32
+ end
25
33
  end
26
34
 
27
35
  private
@@ -3,6 +3,36 @@ module SpreeCmCommissioner
3
3
  def self.prepended(base)
4
4
  base.include SpreeCmCommissioner::OptionValueAttrType
5
5
  base.has_many :option_value_vehicles, class_name: 'SpreeCmCommissioner::OptionValueVehicle', foreign_key: :option_value_id, dependent: :destroy
6
+
7
+ base.validates :name, presence: true, uniqueness: { scope: :option_type_id, case_sensitive: -> { name_case_sensitive? } }
8
+
9
+ # Ticket types: case-SENSITIVE matching (preserve external API casing)
10
+ # Other types: case-INSENSITIVE storage (prevent duplicates from varying API casing; presentation keeps original)
11
+ def base.find_or_create_by_name!(option_type, name)
12
+ name = name.strip
13
+ if option_type.ticket_type?
14
+ where(option_type_id: option_type.id, name: name).first_or_create! do |ov|
15
+ ov.option_type = option_type
16
+ ov.name = name
17
+ ov.presentation = name
18
+ end
19
+ else
20
+ where(option_type_id: option_type.id).where('LOWER(name) = ?', name.downcase).first_or_create! do |ov|
21
+ ov.option_type = option_type
22
+ ov.name = name.downcase
23
+ ov.presentation = name
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ # Override Spree's name validation to make it case-sensitive only for ticket-type option values.
30
+ # Spree's default is case-insensitive (false), but we enforce case-sensitivity (true) for ticket-types.
31
+ # Which means ticket type "STANDARD" and "Standard" are different option values.
32
+ def name_case_sensitive?
33
+ return true if ticket_type?
34
+
35
+ false
6
36
  end
7
37
 
8
38
  def display_icon
@@ -14,5 +44,9 @@ module SpreeCmCommissioner
14
44
  end
15
45
 
16
46
  unless Spree::OptionValue.included_modules.include?(SpreeCmCommissioner::OptionValueDecorator)
47
+ # remove all name validations so we can override them in the decorator.
48
+ Spree::OptionValue._validators.reject! { |key, _| key == :name }
49
+ Spree::OptionValue._validate_callbacks.each { |c| c.filter.attributes.delete(:name) if c.filter.respond_to?(:attributes) }
50
+
17
51
  Spree::OptionValue.prepend SpreeCmCommissioner::OptionValueDecorator
18
52
  end
@@ -3,6 +3,7 @@ module SpreeCmCommissioner
3
3
  def self.prepended(base) # rubocop:disable Metrics/MethodLength
4
4
  base.include SpreeCmCommissioner::StoreMetadata
5
5
  base.include SpreeCmCommissioner::PhoneNumberSanitizer
6
+ base.include SpreeCmCommissioner::OrderIntegration
6
7
  base.include SpreeCmCommissioner::OrderSeatable
7
8
  base.include SpreeCmCommissioner::OrderStateMachine
8
9
  base.include SpreeCmCommissioner::RouteOrderCountable
@@ -8,6 +8,7 @@ module SpreeCmCommissioner
8
8
  base.include SpreeCmCommissioner::TenantUpdatable
9
9
  base.include SpreeCmCommissioner::ServiceType
10
10
  base.include SpreeCmCommissioner::ServiceRecommendations
11
+ base.include SpreeCmCommissioner::Integrations::IntegrationMappable
11
12
 
12
13
  base.has_many :variant_kind_option_types, -> { where(kind: :variant).order(:position) },
13
14
  through: :product_option_types, source: :option_type
@@ -89,7 +90,7 @@ module SpreeCmCommissioner
89
90
  request_to_book: 8
90
91
  }
91
92
 
92
- base.multi_tenant :tenant, class_name: 'SpreeCmCommissioner::Tenant'
93
+ base.belongs_to :tenant, class_name: 'SpreeCmCommissioner::Tenant'
93
94
  base.before_save :set_tenant
94
95
  end
95
96
 
@@ -142,7 +143,7 @@ module SpreeCmCommissioner
142
143
  end
143
144
 
144
145
  def set_event_id
145
- self.event_id = taxons.select(&:event?).first&.parent_id
146
+ self.event_id ||= taxons.select(&:event?).first&.parent_id
146
147
  end
147
148
 
148
149
  def update_variants_vendor_id
@@ -150,7 +151,7 @@ module SpreeCmCommissioner
150
151
  end
151
152
 
152
153
  def sync_event_id_to_children
153
- ::SpreeCmCommissioner::ProductEventIdToChildrenSyncerJob.perform_later(id)
154
+ ::SpreeCmCommissioner::ProductEventIdToChildrenSyncerJob.perform_later(product_id: id)
154
155
  end
155
156
 
156
157
  def validate_event_taxons
@@ -12,7 +12,7 @@ module SpreeCmCommissioner
12
12
  keys = inventory_items.map { |item| "inventory:#{item.id}" }
13
13
  return [] unless keys.any?
14
14
 
15
- counts = SpreeCmCommissioner.redis_pool.with { |redis| redis.mget(*keys) }
15
+ counts = SpreeCmCommissioner.inventory_redis_pool.with { |redis| redis.mget(*keys) }
16
16
  inventory_items.map.with_index do |inventory_item, i|
17
17
  ::SpreeCmCommissioner::CachedInventoryItem.new(
18
18
  inventory_key: keys[i],
@@ -32,7 +32,7 @@ module SpreeCmCommissioner
32
32
 
33
33
  # Use atomic SET NX to prevent race condition where multiple concurrent reads
34
34
  # initialize cache with stale values. Only the first thread wins.
35
- SpreeCmCommissioner.redis_pool.with do |redis|
35
+ SpreeCmCommissioner.inventory_redis_pool.with do |redis|
36
36
  redis.eval(set_nx_with_expiry_script, keys: [key], argv: [inventory_item.quantity_available, inventory_item.redis_expired_in])
37
37
  end
38
38
 
@@ -51,13 +51,13 @@ module SpreeCmCommissioner
51
51
  end
52
52
 
53
53
  def unstock(keys, quantities)
54
- SpreeCmCommissioner.redis_pool.with do |redis|
54
+ SpreeCmCommissioner.inventory_redis_pool.with do |redis|
55
55
  redis.eval(unstock_redis_script, keys: keys, argv: quantities)
56
56
  end.positive?
57
57
  end
58
58
 
59
59
  def restock(keys, quantities)
60
- SpreeCmCommissioner.redis_pool.with do |redis|
60
+ SpreeCmCommissioner.inventory_redis_pool.with do |redis|
61
61
  redis.eval(restock_redis_script, keys: keys, argv: quantities)
62
62
  end.positive?
63
63
  end
@@ -119,7 +119,10 @@ module SpreeCmCommissioner
119
119
  end
120
120
 
121
121
  def schedule_sync_inventory(inventory_id_and_quantities)
122
- SpreeCmCommissioner::InventoryItemSyncerJob.perform_later(inventory_id_and_quantities:)
122
+ SpreeCmCommissioner::InventoryItemSyncerJob.perform_later(
123
+ inventory_id_and_quantities: inventory_id_and_quantities,
124
+ line_item_ids: @line_item_ids
125
+ )
123
126
  end
124
127
  end
125
128
  end
@@ -9,7 +9,7 @@ module SpreeCmCommissioner
9
9
  end
10
10
 
11
11
  def update_vendor_total_inventory
12
- SpreeCmCommissioner::VendorJob.perform_later(vendor.id) if vendor.present?
12
+ SpreeCmCommissioner::VendorJob.perform_later(vendor_id: vendor.id) if vendor.present?
13
13
  end
14
14
 
15
15
  private
@@ -20,9 +20,9 @@ module SpreeCmCommissioner
20
20
 
21
21
  # When admin delete stock item, it will deduct stock from inventory item
22
22
  def adjust_inventory_items_async
23
- params = { variant_id: variant.id, quantity: -count_on_hand }
24
- CmAppLogger.log(label: "#{self.class.name}#adjust_inventory_items_async", data: params) do
25
- SpreeCmCommissioner::Stock::InventoryItemsAdjusterJob.perform_later(**params)
23
+ args = { variant_id: variant.id, quantity: -count_on_hand }
24
+ CmAppLogger.log(label: "#{self.class.name}#adjust_inventory_items_async", data: args) do
25
+ SpreeCmCommissioner::Stock::InventoryItemsAdjusterJob.perform_later(**args)
26
26
  end
27
27
  end
28
28
  end
@@ -5,6 +5,7 @@ module SpreeCmCommissioner
5
5
  base.include SpreeCmCommissioner::TaxonKind
6
6
  base.include SpreeCmCommissioner::ParticipationTypeBitwise
7
7
  base.include SpreeCmCommissioner::EventMetadata
8
+ base.include SpreeCmCommissioner::Integrations::IntegrationMappable
8
9
 
9
10
  base.has_many :taxon_vendors, class_name: 'SpreeCmCommissioner::TaxonVendor'
10
11
  base.has_many :vendors, through: :taxon_vendors
@@ -134,7 +135,7 @@ module SpreeCmCommissioner
134
135
  def sync_event_dates_to_line_items
135
136
  return unless event? && depth == 1
136
137
 
137
- ::SpreeCmCommissioner::EventLineItemsDateSyncerJob.perform_later(id)
138
+ ::SpreeCmCommissioner::EventLineItemsDateSyncerJob.perform_later(event_id: id)
138
139
  end
139
140
 
140
141
  private