spree_cm_commissioner 2.15.2 → 2.15.4
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/.env.example +3 -0
- data/Gemfile.lock +2 -2
- data/app/controllers/spree/admin/inventory_holds_controller.rb +32 -6
- data/app/errors/spree_cm_commissioner/integrations/rate_limited_error.rb +28 -0
- data/app/jobs/spree_cm_commissioner/integrations/book_me_bus_v1/sync_route_job.rb +70 -0
- data/app/models/spree_cm_commissioner/block.rb +16 -0
- data/app/models/spree_cm_commissioner/integration_mapping.rb +8 -0
- data/app/models/spree_cm_commissioner/integrations/book_me_bus_v1.rb +5 -0
- data/app/models/spree_cm_commissioner/reserved_block.rb +19 -3
- data/app/queries/spree_cm_commissioner/inventory_hold_insights_query.rb +151 -0
- data/app/queries/spree_cm_commissioner/inventory_hold_overview_query.rb +79 -0
- data/app/services/spree_cm_commissioner/integrations/base/sync_result.rb +7 -0
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/client.rb +24 -7
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/connection.rb +19 -13
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_item_availability.rb +12 -6
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_locations.rb +10 -3
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_routes.rb +11 -2
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_seat_layout.rb +8 -59
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trips.rb +55 -35
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip.rb +0 -4
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/route_freshness.rb +48 -0
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/seat_layout_freshness.rb +46 -0
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_manager.rb +5 -6
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/full_sync_strategy.rb +32 -15
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/incremental_sync_strategy.rb +43 -40
- data/app/services/spree_cm_commissioner/integrations/call_limiter.rb +89 -0
- data/app/services/spree_cm_commissioner/integrations/polling_scheduler.rb +13 -7
- data/app/services/spree_cm_commissioner/integrations/sync_backoff.rb +61 -0
- data/app/services/spree_cm_commissioner/seats/apply_locks.rb +13 -7
- data/app/services/spree_cm_commissioner/seats/block_history.rb +168 -0
- data/app/services/spree_cm_commissioner/seats/lock_state.rb +3 -1
- data/app/services/spree_cm_commissioner/seats/swap_seats.rb +265 -0
- data/app/services/spree_cm_commissioner/seats/unlock_blocks.rb +11 -12
- data/app/services/spree_cm_commissioner/voting_credits/de_allocate.rb +11 -0
- data/app/views/spree/admin/inventory_holds/_insights.html.erb +99 -0
- data/app/views/spree/admin/inventory_holds/_insights_drawer.html.erb +83 -0
- data/app/views/spree/admin/inventory_holds/_order_status.html.erb +9 -0
- data/app/views/spree/admin/inventory_holds/_overview.html.erb +109 -0
- data/app/views/spree/admin/inventory_holds/_section.html.erb +5 -0
- data/app/views/spree/admin/inventory_holds/_stat.html.erb +4 -0
- data/app/views/spree/admin/inventory_holds/index.html.erb +12 -1
- data/config/routes.rb +4 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +18 -2
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/connection.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
55
|
-
|
|
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)
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_locations.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_seat_layout.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
382
|
-
#
|
|
383
|
-
touch_route_mapping
|
|
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:
|
|
609
|
+
ignoring: VOLATILE_PAYLOAD_KEYS
|
|
584
610
|
)
|
|
585
611
|
mapping
|
|
586
612
|
end
|
|
587
613
|
|
|
588
|
-
# A
|
|
589
|
-
#
|
|
590
|
-
#
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
632
|
+
@seat_layout_synced_variant_ids << variant.id
|
|
633
|
+
@seat_layout_freshness.forget(vehicle_type)
|
|
615
634
|
end
|
|
616
635
|
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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)
|