spree_cm_commissioner 2.15.2 → 2.15.3

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +3 -0
  3. data/Gemfile.lock +2 -2
  4. data/app/controllers/spree/admin/inventory_holds_controller.rb +32 -6
  5. data/app/errors/spree_cm_commissioner/integrations/rate_limited_error.rb +28 -0
  6. data/app/jobs/spree_cm_commissioner/integrations/book_me_bus_v1/sync_route_job.rb +70 -0
  7. data/app/models/spree_cm_commissioner/block.rb +16 -0
  8. data/app/models/spree_cm_commissioner/integration_mapping.rb +8 -0
  9. data/app/models/spree_cm_commissioner/integrations/book_me_bus_v1.rb +5 -0
  10. data/app/models/spree_cm_commissioner/reserved_block.rb +19 -3
  11. data/app/queries/spree_cm_commissioner/inventory_hold_insights_query.rb +151 -0
  12. data/app/queries/spree_cm_commissioner/inventory_hold_overview_query.rb +79 -0
  13. data/app/services/spree_cm_commissioner/integrations/base/sync_result.rb +7 -0
  14. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/client.rb +24 -7
  15. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/connection.rb +19 -13
  16. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_item_availability.rb +12 -6
  17. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_locations.rb +10 -3
  18. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_routes.rb +11 -2
  19. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_seat_layout.rb +8 -59
  20. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +55 -35
  21. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip.rb +0 -4
  22. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/route_freshness.rb +48 -0
  23. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/seat_layout_freshness.rb +46 -0
  24. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_manager.rb +5 -6
  25. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/full_sync_strategy.rb +32 -15
  26. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/incremental_sync_strategy.rb +43 -40
  27. data/app/services/spree_cm_commissioner/integrations/call_limiter.rb +89 -0
  28. data/app/services/spree_cm_commissioner/integrations/polling_scheduler.rb +13 -7
  29. data/app/services/spree_cm_commissioner/integrations/sync_backoff.rb +61 -0
  30. data/app/services/spree_cm_commissioner/seats/apply_locks.rb +13 -7
  31. data/app/services/spree_cm_commissioner/seats/block_history.rb +168 -0
  32. data/app/services/spree_cm_commissioner/seats/lock_state.rb +3 -1
  33. data/app/services/spree_cm_commissioner/seats/swap_seats.rb +265 -0
  34. data/app/services/spree_cm_commissioner/seats/unlock_blocks.rb +11 -12
  35. data/app/views/spree/admin/inventory_holds/_insights.html.erb +99 -0
  36. data/app/views/spree/admin/inventory_holds/_insights_drawer.html.erb +83 -0
  37. data/app/views/spree/admin/inventory_holds/_order_status.html.erb +9 -0
  38. data/app/views/spree/admin/inventory_holds/_overview.html.erb +109 -0
  39. data/app/views/spree/admin/inventory_holds/_section.html.erb +5 -0
  40. data/app/views/spree/admin/inventory_holds/_stat.html.erb +4 -0
  41. data/app/views/spree/admin/inventory_holds/index.html.erb +12 -1
  42. data/config/routes.rb +4 -0
  43. data/lib/spree_cm_commissioner/version.rb +1 -1
  44. metadata +18 -2
@@ -4,24 +4,24 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
4
4
  REQUEST_TIMEOUT = 30
5
5
  OPEN_TIMEOUT = 10
6
6
 
7
- def get(url, params = nil, headers = nil)
8
- execute_request { connection.get(url, params, build_headers(headers)) }
7
+ def get(url, params = nil, headers = nil, lane: :background)
8
+ execute_request(lane: lane) { connection.get(url, params, build_headers(headers)) }
9
9
  end
10
10
 
11
- def post(url, body = nil, headers = nil)
12
- execute_request { connection.post(url, body, build_headers(headers)) }
11
+ def post(url, body = nil, headers = nil, lane: :background)
12
+ execute_request(lane: lane) { connection.post(url, body, build_headers(headers)) }
13
13
  end
14
14
 
15
- def put(url, body = nil, headers = nil)
16
- execute_request { connection.put(url, body, build_headers(headers)) }
15
+ def put(url, body = nil, headers = nil, lane: :background)
16
+ execute_request(lane: lane) { connection.put(url, body, build_headers(headers)) }
17
17
  end
18
18
 
19
- def patch(url, body = nil, headers = nil)
20
- execute_request { connection.patch(url, body, build_headers(headers)) }
19
+ def patch(url, body = nil, headers = nil, lane: :background)
20
+ execute_request(lane: lane) { connection.patch(url, body, build_headers(headers)) }
21
21
  end
22
22
 
23
- def delete(url, params = nil, headers = nil)
24
- execute_request { connection.delete(url, params, build_headers(headers)) }
23
+ def delete(url, params = nil, headers = nil, lane: :background)
24
+ execute_request(lane: lane) { connection.delete(url, params, build_headers(headers)) }
25
25
  end
26
26
 
27
27
  def connection
@@ -52,6 +52,10 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
52
52
  @token_mutex ||= Mutex.new
53
53
  end
54
54
 
55
+ def call_limiter
56
+ @call_limiter ||= SpreeCmCommissioner::Integrations::CallLimiter.new(base_url: @integration.base_url)
57
+ end
58
+
55
59
  def token_expired?
56
60
  @access_token.blank? || @token_expires_at.blank? || Time.current >= @token_expires_at
57
61
  end
@@ -81,15 +85,17 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
81
85
  reset_connection
82
86
  end
83
87
 
84
- # Execute HTTP request with automatic token refresh on 401
85
- def execute_request(retry_count: 0, &block)
88
+ # Runs the request, refreshing the token once on a 401. Counted against CallLimiter first, except checkout calls.
89
+ def execute_request(lane: :background, retry_count: 0, &block)
90
+ call_limiter.take!(lane: lane)
91
+
86
92
  response = block.call
87
93
 
88
94
  if response.status == 401 && retry_count.zero?
89
95
  invalidate_token
90
96
  access_token
91
97
  reset_connection
92
- return execute_request(retry_count: 1, &block)
98
+ return execute_request(lane: lane, retry_count: 1, &block)
93
99
  end
94
100
 
95
101
  response
@@ -51,9 +51,11 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
51
51
 
52
52
  return unless should_sync_item?(inventory_item_mapping)
53
53
 
54
- ActiveRecord::Base.transaction do
55
- external_trip = fetch_external_trip(trip, on_date)
54
+ # Before the transaction: the call can take up to 30 s and would hold a database connection.
55
+ external_trip = fetch_external_trip(trip, on_date)
56
+ return if external_trip.nil? # no mapping data to look it up: leave the inventory untouched
56
57
 
58
+ ActiveRecord::Base.transaction do
57
59
  case external_trip
58
60
  when UNAVAILABLE
59
61
  # A confirmed response (not a failed/ambiguous lookup - see fetch_external_trip) shows
@@ -62,8 +64,6 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
62
64
  # Zero it out so BookMe+ stops selling a date BookMeBus has pulled, rather than only
63
65
  # discovering that at the very end of checkout, after payment has been taken.
64
66
  zero_out_inventory!(inventory_item)
65
- when nil
66
- return # rubocop:disable Rails/TransactionExitStatement -- couldn't determine (missing mapping data) - leave existing inventory untouched rather than guess
67
67
  else
68
68
  sync_price(inventory_item, external_trip)
69
69
 
@@ -79,6 +79,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
79
79
 
80
80
  update_last_sync_at(inventory_item_mapping)
81
81
  end
82
+ rescue SpreeCmCommissioner::Integrations::RateLimitedError
83
+ raise # #call records it and stops: the remaining items would only be refused too
82
84
  rescue StandardError => e
83
85
  @sync_result.record_error(e)
84
86
  CmAppLogger.error(label: '[BookMeBusV1::SyncItemAvailability] error', data: { inventory_item_id: inventory_item.id, error: e.message })
@@ -179,7 +181,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
179
181
  client.search_trips!(
180
182
  origin_id: origin_id,
181
183
  destination_id: destination_id,
182
- on_date: on_date
184
+ on_date: on_date,
185
+ lane: :customer
183
186
  ) || []
184
187
  end
185
188
  end
@@ -295,9 +298,12 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
295
298
  trip_id: external_trip.id,
296
299
  from_stop_id: external_trip.from_stop_id,
297
300
  to_stop_id: external_trip.to_stop_id,
298
- on_date: external_trip.on_date || Time.zone.today.to_s
301
+ on_date: external_trip.on_date || Time.zone.today.to_s,
302
+ lane: :customer
299
303
  )
300
304
  end
305
+ rescue SpreeCmCommissioner::Integrations::RateLimitedError
306
+ raise
301
307
  rescue SpreeCmCommissioner::Integrations::ExternalClientError => e
302
308
  CmAppLogger.error(label: '[BookMeBusV1::SyncItemAvailability] get_seat_layout failed', data: { error: e.message })
303
309
  @sync_result.record_error(e)
@@ -3,6 +3,10 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
3
3
  # Initialize SyncLocations service
4
4
  # @param integration [SpreeCmCommissioner::Integrations::BookMeBusV1] The integration instance
5
5
  # @param sync_result [SpreeCmCommissioner::Integrations::Base::SyncResult] Optional sync result tracker
6
+ # The operator's trips with their cities, as fetched by the last call. SyncRoutes builds routes from the same
7
+ # answer, so the full sync asks BookMeBus once for it, not once per step.
8
+ attr_reader :trip_locations
9
+
6
10
  def initialize(integration:, sync_result: nil)
7
11
  @integration = integration
8
12
  @sync_result = sync_result || SpreeCmCommissioner::Integrations::Base::SyncResult.new
@@ -50,13 +54,15 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
50
54
  end
51
55
 
52
56
  def fetch_external_locations
53
- trip_locations = begin
57
+ @trip_locations = begin
54
58
  @sync_result.track_api_call('get_trip_locations') { client.get_trip_locations! }
59
+ rescue SpreeCmCommissioner::Integrations::RateLimitedError
60
+ raise # the strategy stops the whole run on a 429, see FullSyncStrategy
55
61
  rescue SpreeCmCommissioner::Integrations::ExternalClientError => e
56
62
  raise SpreeCmCommissioner::Integrations::SyncError, "Failed to sync locations: #{e.message}"
57
63
  end
58
64
 
59
- trip_locations.flat_map(&:locations).uniq(&:id)
65
+ @trip_locations.flat_map(&:locations).uniq(&:id)
60
66
  end
61
67
 
62
68
  # Sync a single location and create it as a place with vendor place mapping
@@ -99,7 +105,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
99
105
  tracker.save_if_changed!(vendor_place)
100
106
  end
101
107
 
102
- mapping.mark_as_active!(external_payload: external_location.to_h)
108
+ # Saved only when something changed (or an hour has passed): most runs find every location as it was.
109
+ mapping.confirm_active!(external_payload: external_location.to_h)
103
110
  mapping
104
111
  end
105
112
 
@@ -3,9 +3,11 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
3
3
  # Initialize SyncRoutes service
4
4
  # @param integration [SpreeCmCommissioner::Integrations::BookMeBusV1] The integration instance
5
5
  # @param sync_result [SpreeCmCommissioner::Integrations::Base::SyncResult] Optional sync result tracker
6
- def initialize(integration:, sync_result: nil)
6
+ # @param trip_locations [Array, nil] the answer SyncLocations already got from BookMeBus. Fetched here when absent.
7
+ def initialize(integration:, sync_result: nil, trip_locations: nil)
7
8
  @integration = integration
8
9
  @sync_result = sync_result || SpreeCmCommissioner::Integrations::Base::SyncResult.new
10
+ @trip_locations = trip_locations
9
11
  end
10
12
 
11
13
  def call
@@ -70,7 +72,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
70
72
  tracker.save_if_changed!(route)
71
73
  end
72
74
 
73
- mapping.mark_as_active!(
75
+ # Saved only when something changed (or an hour has passed): most runs find every route as it was.
76
+ mapping.confirm_active!(
74
77
  external_payload: {
75
78
  origin_place_id: origin_mapping.external_id,
76
79
  destination_place_id: destination_mapping.external_id,
@@ -109,7 +112,13 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
109
112
 
110
113
  # Raised before any route is written, so a failed call never reaches cleanup_stale_mappings!.
111
114
  def external_trip_locations
115
+ # The answer handed in by the full sync, if any. Not remembered after a fetch: a second call on this object must
116
+ # ask BookMeBus again.
117
+ return @trip_locations if @trip_locations
118
+
112
119
  @sync_result.track_api_call('get_trip_locations') { client.get_trip_locations! }
120
+ rescue SpreeCmCommissioner::Integrations::RateLimitedError
121
+ raise # the strategy stops the whole run on a 429, see FullSyncStrategy
113
122
  rescue SpreeCmCommissioner::Integrations::ExternalClientError => e
114
123
  raise SpreeCmCommissioner::Integrations::SyncError, "Failed to sync routes: #{e.message}"
115
124
  end
@@ -34,13 +34,13 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
34
34
  # sync_service = SyncSeatLayout.new(integration: integration, sync_result: sync_result)
35
35
  # seat_layout = sync_service.call(vehicle_type: vehicle_type, external_trip: external_trip)
36
36
  #
37
- class SyncSeatLayout # rubocop:disable Metrics/ClassLength
37
+ class SyncSeatLayout
38
38
  def initialize(integration:, sync_result: nil)
39
39
  @integration = integration
40
40
  @sync_result = sync_result || SpreeCmCommissioner::Integrations::Base::SyncResult.new
41
41
  end
42
42
 
43
- def call(vehicle_type:, external_trip:, variant: nil, inventory_item: nil)
43
+ def call(vehicle_type:, external_trip:, variant: nil)
44
44
  return nil if vehicle_type.blank? || external_trip.blank?
45
45
 
46
46
  external_seat_layout = fetch_external_seat_layout(external_trip)
@@ -52,10 +52,12 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
52
52
  # If variant provided, duplicate template to trip and sync trip-specific layout
53
53
  if variant.present?
54
54
  duplicate_to_trip_if_needed!(vehicle_seat_layout: vehicle_seat_layout, variant: variant)
55
- sync_trip_seat_layout!(external_seat_layout: external_seat_layout, variant: variant, inventory_item: inventory_item)
55
+ sync_trip_seat_layout!(external_seat_layout: external_seat_layout, variant: variant)
56
56
  end
57
57
 
58
58
  vehicle_seat_layout
59
+ rescue SpreeCmCommissioner::Integrations::RateLimitedError
60
+ raise # the caller stops the whole run on a 429
59
61
  rescue SpreeCmCommissioner::Integrations::ExternalClientError => e
60
62
  @sync_result.record_error(SpreeCmCommissioner::Integrations::SyncError.new("Failed to sync seat layout: #{e.message}"))
61
63
  nil
@@ -123,6 +125,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
123
125
  if seat_layout.persisted?
124
126
  apply_builder_layout!(seat_layout, external_seat_layout, create_missing: false)
125
127
  backfill_vehicle_type_seats!(vehicle_type, seat_layout)
128
+ mapping.update!(last_synced_at: Time.current) # when SeatLayoutFreshness next asks again
126
129
  return seat_layout
127
130
  end
128
131
 
@@ -209,7 +212,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
209
212
  @sync_result.record_error(StandardError.new("[SyncSeatLayout] Failed to duplicate seat layout to trip: #{e.message}"))
210
213
  end
211
214
 
212
- def sync_trip_seat_layout!(external_seat_layout:, variant:, inventory_item:)
215
+ def sync_trip_seat_layout!(external_seat_layout:, variant:)
213
216
  return if variant.blank?
214
217
 
215
218
  trip_seat_layout = variant.trip&.seat_layout
@@ -228,10 +231,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
228
231
  end
229
232
  end
230
233
 
231
- # Sync reserved blocks if inventory_item provided
232
- return if inventory_item.blank?
233
-
234
- sync_reserved_blocks!(seat_layout: trip_seat_layout, external_seat_layout: external_seat_layout, inventory_item: inventory_item)
234
+ trip_seat_layout.touch # rubocop:disable Rails/SkipsModelValidations -- synced: no older than its vehicle type's (SeatLayoutFreshness)
235
235
  end
236
236
 
237
237
  # Build name for seat layout
@@ -292,56 +292,5 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
292
292
  section.destroy! if section.blocks.empty?
293
293
  end
294
294
  end
295
-
296
- # This method creates ReservedBlock records for seats that are marked as booked (status=1)
297
- # in the BookMeBus API response, ensuring BookMe+ knows which seats are already reserved.
298
- def sync_reserved_blocks!(seat_layout:, external_seat_layout:, inventory_item:) # rubocop:disable Metrics/PerceivedComplexity
299
- return unless seat_layout.present? && inventory_item.present?
300
-
301
- # Get all seats with their status from external API
302
- external_seats_by_label = external_seat_layout.seats.index_by(&:label)
303
-
304
- # Get all blocks for this seat layout
305
- seat_layout.blocks.each do |block|
306
- next unless block.sellable? # Only process sellable blocks (seat, sleeping_seat, standing)
307
-
308
- external_seat = external_seats_by_label[block.label]
309
- next unless external_seat # Skip if no matching external seat data
310
-
311
- # Find or initialize reserved block
312
- reserved_block = SpreeCmCommissioner::ReservedBlock.find_or_initialize_by(
313
- inventory_item: inventory_item,
314
- block: block
315
- )
316
-
317
- if external_seat.booked? # status == 1 in BookMeBus
318
- # Seat is booked in BookMeBus - create/update reservation
319
- next if reserved_block.reserved? # Already reserved, skip
320
-
321
- @sync_result.track(:reserved_block, reserved_block) do |tracker|
322
- reserved_block.assign_attributes(
323
- status: :reserved,
324
- expired_at: nil,
325
- inventory_date: inventory_item.inventory_date,
326
- guest: nil, # External reservation, no guest association
327
- updated_by: nil # System sync, no user
328
- )
329
- tracker.save_if_changed!(reserved_block)
330
- end
331
- elsif reserved_block.persisted? # external_seat.available? - status == 0 in BookMeBus
332
- # Seat is available in BookMeBus - remove external reservation if exists. Locked, and
333
- # guest_id re-checked after acquiring the lock, so a real customer hold being created
334
- # concurrently on this exact block can't be destroyed out from under them.
335
- reserved_block.with_lock do
336
- next unless reserved_block.guest_id.nil? # Only remove if it's an external reservation (no guest)
337
-
338
- @sync_result.track(:reserved_block, reserved_block) do |_tracker|
339
- reserved_block.destroy!
340
- @sync_result.increment_metric(:reserved_block, :deleted)
341
- end
342
- end
343
- end
344
- end
345
- end
346
295
  end
347
296
  end
@@ -279,9 +279,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
279
279
  class SyncTrips # rubocop:disable Metrics/ClassLength
280
280
  attr_reader :integration, :sync_result, :route, :vendor
281
281
 
282
- # Booked seats are only fetched for the dates the incremental sync keeps fresh (today and tomorrow); further out,
283
- # SyncItemAvailability fetches them when a customer opens the trip.
284
- SEAT_STATUS_DAYS_AHEAD = 1
282
+ VOLATILE_PAYLOAD_KEYS = SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::Trip::VOLATILE_PAYLOAD_KEYS
285
283
 
286
284
  def initialize(integration:, sync_result: nil, route: nil)
287
285
  @integration = integration
@@ -291,6 +289,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
291
289
  @location_mappings = {}
292
290
  @location_id_cache = {}
293
291
  @seat_layout_synced_variant_ids = Set.new
292
+ @seat_layout_freshness = SpreeCmCommissioner::Integrations::BookMeBusV1::SeatLayoutFreshness.new(integration)
294
293
  @variant_ids_needing_items = Set.new
295
294
  end
296
295
 
@@ -378,9 +377,9 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
378
377
  # Nothing came back for any date in the window - don't touch route stops/cleanup based
379
378
  # on an empty result (could be a transient outage rather than a genuinely dead route).
380
379
  if all_external_trips.empty?
381
- # Incremental sync takes the route synced longest ago, so a route BookMeBus confirmed empty must still move to
382
- # the back of the line, or every poll would pick it again and no other route would ever refresh.
383
- touch_route_mapping if bookable_ids_by_date.any?
380
+ # An empty route, or one whose every date failed, still moves to the back of the line: otherwise every poll
381
+ # would pick it again. A 429 raises before this, so that route stays first in line.
382
+ touch_route_mapping(bookable_ids_by_date.keys)
384
383
  return
385
384
  end
386
385
 
@@ -399,7 +398,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
399
398
  cleanup_stale_mappings!(synced_trip_ids) if @full_sync
400
399
 
401
400
  # Update route mapping timestamp
402
- touch_route_mapping
401
+ touch_route_mapping(bookable_ids_by_date.keys)
403
402
  end
404
403
 
405
404
  # Isolated per date: a transient failure fetching one date shouldn't abort the whole
@@ -420,6 +419,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
420
419
  on_date: on_date
421
420
  ) || []
422
421
  end
422
+ rescue SpreeCmCommissioner::Integrations::RateLimitedError
423
+ raise # not a failed date: the whole run stops, see FullSyncStrategy and IncrementalSyncStrategy
423
424
  rescue SpreeCmCommissioner::Integrations::ExternalClientError => e
424
425
  @sync_result.record_error(e)
425
426
  raise SpreeCmCommissioner::Integrations::SyncError, "Failed to fetch trips: #{e.message}"
@@ -428,6 +429,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
428
429
  def process_external_trips(external_trips)
429
430
  external_trips.filter_map do |external_trip|
430
431
  sync_trip!(external_trip)
432
+ rescue SpreeCmCommissioner::Integrations::RateLimitedError
433
+ raise # e.g. the seat layout call of this trip
431
434
  rescue SpreeCmCommissioner::Integrations::SyncError => e
432
435
  @sync_result.record_error(e)
433
436
  Rails.logger.warn("[SyncTrips] Sync error for trip #{external_trip.trip_id}: #{e.message}")
@@ -465,6 +468,9 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
465
468
  return external_trip.sync_identifier
466
469
  end
467
470
 
471
+ # The daily full sync still runs the whole pipeline for every trip.
472
+ return external_trip.sync_identifier if !@full_sync && sync_seats_only!(external_trip)
473
+
468
474
  # Prepare data outside transaction to avoid long-running transactions
469
475
  vehicle_type = sync_vehicle_type!(external_trip)
470
476
 
@@ -522,6 +528,26 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
522
528
  external_trip.sync_identifier
523
529
  end
524
530
 
531
+ # A poll usually finds a trip as stored, with only another seat count. Then only the seats are updated, not the
532
+ # product, variant, trip, calendar, layout, stops and pricing. A changed price (compared on its own) or any other
533
+ # change takes the full pipeline. Returns true when it handled the trip.
534
+ def sync_seats_only!(external_trip)
535
+ mapping = SpreeCmCommissioner::IntegrationMapping.find_by(
536
+ integration_id: integration.id, internal_type: 'SpreeCmCommissioner::Trip', external_id: external_trip.sync_identifier, status: :active
537
+ )
538
+ return false unless mapping&.payload_unchanged?(external_trip.to_h, ignoring: VOLATILE_PAYLOAD_KEYS)
539
+
540
+ variant = Spree::Variant.find_by(sku: "variant-#{vendor.id}-#{external_trip.sync_identifier}")
541
+ return false unless variant && variant.price.to_d == calculate_price(external_trip).to_d
542
+ # A trip whose layout was never assigned (a failed first fetch) still needs the full pipeline to get one.
543
+ return false unless seat_layout_current?(variant)
544
+
545
+ SyncInventory.new(sync_result: sync_result, vendor: vendor).call(variant: variant, external_trip: external_trip)
546
+ @variant_ids_needing_items << variant.id if permanent_items_needed?(variant.product.service_calendar, variant, external_trip)
547
+ sync_result.increment_metric(:trip, :seats_only)
548
+ true
549
+ end
550
+
525
551
  # A calendar change can add dates anywhere in the window. A missing item for this date means its job never ran or
526
552
  # was lost, so the next poll repairs it. Stock isn't checked: it only seeds items. New variants get their items
527
553
  # from StockItem#create_inventory_items. service_available? stops a date the job would never create from
@@ -580,18 +606,17 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
580
606
 
581
607
  mapping.confirm_active!(
582
608
  external_payload: external_trip.to_h,
583
- ignoring: SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::Trip::VOLATILE_PAYLOAD_KEYS
609
+ ignoring: VOLATILE_PAYLOAD_KEYS
584
610
  )
585
611
  mapping
586
612
  end
587
613
 
588
- # A trip's seat layout is the same on every date, so it is synced once per run (on the first bookable date, which
589
- # also refreshes it every run). Later dates only matter for which seats are booked, so they are synced again only
590
- # when BookMeBus reports that for the trip and the date is close enough to matter. The trip is marked done only
591
- # after a successful sync, so a failed first fetch is retried on the next date.
614
+ # A seat layout is the same on every date and for every trip of a vehicle type, so BookMeBus is asked for it only
615
+ # when it is missing or out of date. Which seats are booked is not synced here: SyncItemAvailability reads it when a
616
+ # customer opens the trip. A failed fetch is retried on the next date.
592
617
  def sync_seat_layout!(vehicle_type, external_trip, variant)
593
618
  return unless vehicle_type
594
- return if seat_layout_current?(variant) && !booked_seats_needed?(external_trip)
619
+ return if seat_layout_current?(variant)
595
620
 
596
621
  layout = SyncSeatLayout.new(
597
622
  integration: integration,
@@ -599,25 +624,18 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
599
624
  ).call(
600
625
  vehicle_type: vehicle_type,
601
626
  external_trip: external_trip,
602
- variant: variant,
603
- inventory_item: find_inventory_item_for_date(variant, external_trip)
627
+ variant: variant
604
628
  )
605
629
 
606
- @seat_layout_synced_variant_ids << variant.id if layout
607
- end
608
-
609
- # Checks for assigned blocks, not just a layout: a trip copies its vehicle's layout on save, before any seat is
610
- # assigned to it, and that first sync must still run.
611
- def seat_layout_current?(variant)
612
- return true if @seat_layout_synced_variant_ids.include?(variant.id)
630
+ return unless layout
613
631
 
614
- !@full_sync && variant.trip&.seat_layout&.blocks&.exists?(variant_id: variant.id)
632
+ @seat_layout_synced_variant_ids << variant.id
633
+ @seat_layout_freshness.forget(vehicle_type)
615
634
  end
616
635
 
617
- def booked_seats_needed?(external_trip)
618
- trip_date = parse_trip_date(external_trip.on_date)
619
-
620
- external_trip.per_seat_status? && trip_date.present? && trip_date <= Date.current + SEAT_STATUS_DAYS_AHEAD.days
636
+ # Synced in this run, or still matching its vehicle type's layout (see SeatLayoutFreshness).
637
+ def seat_layout_current?(variant)
638
+ @seat_layout_synced_variant_ids.include?(variant.id) || @seat_layout_freshness.current?(variant)
621
639
  end
622
640
 
623
641
  def sync_trip_stop!(trip_mapping)
@@ -732,16 +750,18 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
732
750
  inventory_item.update_column(:max_capacity, original_max_capacity) # rubocop:disable Rails/SkipsModelValidations
733
751
  end
734
752
 
735
- def touch_route_mapping
753
+ # `fresh_dates` are the dates BookMeBus answered for, recorded as refreshed (RouteFreshness).
754
+ def touch_route_mapping(fresh_dates = [])
736
755
  return unless route
737
756
 
738
- route_mapping = SpreeCmCommissioner::IntegrationMapping.find_by(
739
- integration_id: integration.id,
740
- internal_type: 'SpreeCmCommissioner::Route',
741
- internal_id: route.id,
742
- status: :active
743
- )
744
- route_mapping&.update!(last_synced_at: Time.current)
757
+ route_mapping = SpreeCmCommissioner::IntegrationMapping.active_routes_of(integration).find_by(internal_id: route.id)
758
+ return unless route_mapping
759
+
760
+ # Locked because SyncRouteJob writes `private_metadata` too (see its record_outcome).
761
+ route_mapping.with_lock do
762
+ SpreeCmCommissioner::Integrations::BookMeBusV1::RouteFreshness.new(route_mapping).stamp(fresh_dates)
763
+ route_mapping.update!(last_synced_at: Time.current)
764
+ end
745
765
  end
746
766
 
747
767
  def find_external_location_id(place)
@@ -91,10 +91,6 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Resources
91
91
  seats_remaining
92
92
  end
93
93
 
94
- # Whether BookMeBus reports which seats are booked on a date: false for auto-assigned-seat trips, and a private
95
- # taxi is booked as a whole vehicle, so seat status does not apply there either.
96
- def per_seat_status? = is_selectable == true && route_type != 'private_taxi'
97
-
98
94
  # BookMe+ charges the non-local (foreigner) price by default, falling back to local_price
99
95
  # only when BookMeBus doesn't provide one. Single source of truth for both trip-creation
100
96
  # (SyncTrips) and per-date availability refresh (SyncItemAvailability) so they can't diverge.
@@ -0,0 +1,48 @@
1
+ # When the seats of each date of a route were last refreshed: `private_metadata['seats_synced_at']` on the route's
2
+ # mapping, as date => time. Today and tomorrow are due again after 30 minutes, later dates after 6 hours, so the seat
3
+ # refresh only asks BookMeBus about what is old.
4
+ class SpreeCmCommissioner::Integrations::BookMeBusV1::RouteFreshness
5
+ KEY = 'seats_synced_at'.freeze
6
+ NEVER = Time.zone.at(0).freeze
7
+ NEAR_DAYS = 2 # today and tomorrow
8
+ NEAR_TTL = 30.minutes
9
+ FAR_TTL = 6.hours
10
+
11
+ def initialize(mapping)
12
+ @mapping = mapping
13
+ end
14
+
15
+ # The dates of the next `days` days that are due, nearest first. A date never refreshed is due.
16
+ def due_dates(days:, today: Date.current, now: Time.current)
17
+ (0...days).filter_map do |ahead|
18
+ date = today + ahead
19
+ date.iso8601 if due?(date, ahead, now)
20
+ end
21
+ end
22
+
23
+ # When the stalest of `dates` was refreshed (NEVER if one wasn't): routes are put in this order.
24
+ def oldest_stamp(dates)
25
+ dates.map { |date| stamps_by_date[date]&.then { |stamp| Time.zone.parse(stamp) } || NEVER }.min
26
+ end
27
+
28
+ # Records `dates` as refreshed now and drops dates already past. Assigns only; the caller saves.
29
+ def stamp(dates, at: Time.current, today: Date.current)
30
+ kept = stamps_by_date.reject { |date, _| Date.parse(date) < today }
31
+ refreshed = dates.to_h { |date| [date.to_s, at.iso8601] }
32
+
33
+ @mapping.private_metadata = (@mapping.private_metadata || {}).merge(KEY => kept.merge(refreshed))
34
+ end
35
+
36
+ private
37
+
38
+ def due?(date, ahead, now)
39
+ stamp = stamps_by_date[date.iso8601]
40
+ return true if stamp.nil?
41
+
42
+ Time.zone.parse(stamp) < now - (ahead < NEAR_DAYS ? NEAR_TTL : FAR_TTL)
43
+ end
44
+
45
+ def stamps_by_date
46
+ (@mapping.private_metadata || {})[KEY] || {}
47
+ end
48
+ end
@@ -0,0 +1,46 @@
1
+ # Whether a trip's seat layout still matches BookMeBus without asking again.
2
+ #
3
+ # A seat map belongs to the vehicle type: every trip of a type has the same one. So it is refreshed from BookMeBus
4
+ # once a day per vehicle type (the SeatLayout mapping's `last_synced_at`), not once per trip. A trip needs BookMeBus's
5
+ # answer again only when it has no layout assigned yet, or its layout is older than its vehicle type's.
6
+ class SpreeCmCommissioner::Integrations::BookMeBusV1::SeatLayoutFreshness
7
+ TEMPLATE_TTL = 24.hours
8
+
9
+ def initialize(integration)
10
+ @integration = integration
11
+ @templates = {}
12
+ end
13
+
14
+ def current?(variant)
15
+ trip = variant.trip
16
+ layout = trip&.seat_layout
17
+ return false unless layout&.blocks&.exists?(variant_id: variant.id)
18
+
19
+ template = template_of(trip.vehicle_type)
20
+ template.present? && template.synced_at > TEMPLATE_TTL.ago && layout.updated_at >= template.updated_at
21
+ end
22
+
23
+ # Call after the vehicle type's layout was refreshed, so the next check reads the new times.
24
+ def forget(vehicle_type)
25
+ @templates.delete(vehicle_type.id)
26
+ end
27
+
28
+ private
29
+
30
+ Template = Struct.new(:synced_at, :updated_at)
31
+
32
+ # Read once per vehicle type and run: one trip's check must not cost two queries on every poll.
33
+ def template_of(vehicle_type)
34
+ return unless vehicle_type
35
+
36
+ @templates.fetch(vehicle_type.id) { @templates[vehicle_type.id] = load_template(vehicle_type) }
37
+ end
38
+
39
+ def load_template(vehicle_type)
40
+ mapping = SpreeCmCommissioner::IntegrationMapping.active.find_by(
41
+ integration_id: @integration.id, internal_type: 'SpreeCmCommissioner::SeatLayout', external_id: vehicle_type.id.to_s
42
+ )
43
+ layout = mapping&.internal
44
+ Template.new(mapping.last_synced_at, layout.updated_at) if layout && mapping.last_synced_at
45
+ end
46
+ end
@@ -8,13 +8,12 @@ class SpreeCmCommissioner::Integrations::BookMeBusV1::SyncManager < SpreeCmCommi
8
8
  end
9
9
  end
10
10
 
11
- # Override incremental sync
11
+ # Override incremental sync. A run with nothing due makes no calls, so it leaves no session row either.
12
12
  def sync_incremental!
13
- run_execution(:incremental) do
14
- SpreeCmCommissioner::Integrations::BookMeBusV1::SyncStrategies::IncrementalSyncStrategy.new(
15
- integration: @integration
16
- ).call
17
- end
13
+ strategy = SpreeCmCommissioner::Integrations::BookMeBusV1::SyncStrategies::IncrementalSyncStrategy.new(integration: @integration)
14
+ return unless strategy.work_due?
15
+
16
+ run_execution(:incremental) { strategy.call }
18
17
  end
19
18
 
20
19
  # Override webhook sync (no-op)