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.
- 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/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
|
@@ -50,19 +50,25 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::SyncStrategies
|
|
|
50
50
|
|
|
51
51
|
def call
|
|
52
52
|
sync_locations!
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
# A 429 ends the run: every call after it would be refused too.
|
|
54
|
+
sync_routes! unless rate_limited?
|
|
55
|
+
sync_trips! unless rate_limited?
|
|
55
56
|
|
|
56
57
|
@sync_result
|
|
57
58
|
end
|
|
58
59
|
|
|
59
60
|
private
|
|
60
61
|
|
|
62
|
+
def rate_limited?
|
|
63
|
+
@sync_result.rate_limited?
|
|
64
|
+
end
|
|
65
|
+
|
|
61
66
|
def sync_locations!
|
|
62
|
-
SpreeCmCommissioner::Integrations::BookMeBusV1::Polling::SyncLocations.new(
|
|
67
|
+
@sync_locations = SpreeCmCommissioner::Integrations::BookMeBusV1::Polling::SyncLocations.new(
|
|
63
68
|
integration: @integration,
|
|
64
69
|
sync_result: @sync_result
|
|
65
|
-
)
|
|
70
|
+
)
|
|
71
|
+
@sync_locations.call
|
|
66
72
|
rescue StandardError => e
|
|
67
73
|
@sync_result.record_error(e)
|
|
68
74
|
end
|
|
@@ -70,21 +76,36 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::SyncStrategies
|
|
|
70
76
|
def sync_routes!
|
|
71
77
|
SpreeCmCommissioner::Integrations::BookMeBusV1::Polling::SyncRoutes.new(
|
|
72
78
|
integration: @integration,
|
|
73
|
-
sync_result: @sync_result
|
|
79
|
+
sync_result: @sync_result,
|
|
80
|
+
trip_locations: @sync_locations&.trip_locations
|
|
74
81
|
).call
|
|
75
82
|
rescue StandardError => e
|
|
76
83
|
@sync_result.record_error(e)
|
|
77
84
|
end
|
|
78
85
|
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
def
|
|
82
|
-
|
|
86
|
+
# Routes passed in (a manual run) are synced here. Otherwise one SyncRouteJob is queued per route, so this
|
|
87
|
+
# session only covers locations and routes.
|
|
88
|
+
def sync_trips!
|
|
89
|
+
@custom_routes.present? ? sync_custom_routes! : enqueue_route_jobs!
|
|
90
|
+
end
|
|
83
91
|
|
|
84
|
-
|
|
92
|
+
# Skips routes already synced today, so a rerun after a failure carries on where it stopped.
|
|
93
|
+
def enqueue_route_jobs!
|
|
94
|
+
synced_today = SpreeCmCommissioner::IntegrationMapping.active_routes_of(@integration)
|
|
95
|
+
.where("private_metadata ->> 'full_synced_on' = ?", Date.current.iso8601)
|
|
96
|
+
.pluck(:internal_id).to_set
|
|
97
|
+
|
|
98
|
+
find_all_routes.reject { |route| synced_today.include?(route.id) }.each do |route|
|
|
99
|
+
SpreeCmCommissioner::Integrations::BookMeBusV1::SyncRouteJob.perform_later(integration_id: @integration.id, route_id: route.id)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def sync_custom_routes!
|
|
104
|
+
@custom_routes.each do |route|
|
|
85
105
|
sync_trips_for_route!(route)
|
|
86
106
|
rescue StandardError => e
|
|
87
107
|
@sync_result.record_error(e)
|
|
108
|
+
break if rate_limited?
|
|
88
109
|
end
|
|
89
110
|
end
|
|
90
111
|
|
|
@@ -103,15 +124,11 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::SyncStrategies
|
|
|
103
124
|
end
|
|
104
125
|
|
|
105
126
|
def sync_trips_for_route!(route)
|
|
106
|
-
# Sync trips for the configured number of days (default: 7)
|
|
107
|
-
today = Date.current
|
|
108
|
-
on_dates = (0...@integration.sync_days).map { |days_ahead| (today + days_ahead).strftime('%Y-%m-%d') }
|
|
109
|
-
|
|
110
127
|
SpreeCmCommissioner::Integrations::BookMeBusV1::Polling::SyncTrips.new(
|
|
111
128
|
integration: @integration,
|
|
112
129
|
sync_result: @sync_result,
|
|
113
130
|
route: route
|
|
114
|
-
).call(on_dates:
|
|
131
|
+
).call(on_dates: @integration.sync_dates)
|
|
115
132
|
end
|
|
116
133
|
end
|
|
117
134
|
end
|
|
@@ -1,63 +1,66 @@
|
|
|
1
1
|
module SpreeCmCommissioner::Integrations::BookMeBusV1::SyncStrategies
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
# Runs periodically to fetch updated trip information for active routes.
|
|
5
|
-
# This allows keeping trip availability fresh without re-syncing all locations.
|
|
6
|
-
#
|
|
7
|
-
# Flow:
|
|
8
|
-
# 1. Gets the next vendor_route needing sync (oldest last_synced_at)
|
|
9
|
-
# 2. Gets the route's direction mapping to find origin_id/destination_id
|
|
10
|
-
# 3. Calls search_trips API and syncs trip data
|
|
11
|
-
# 4. Updates route mapping's last_synced_at
|
|
12
|
-
|
|
2
|
+
# Keeps seats fresh between full syncs: asks BookMeBus only for the routes and dates that are due (see
|
|
3
|
+
# RouteFreshness), stalest first, at most ROUTES_PER_RUN routes per run.
|
|
13
4
|
class IncrementalSyncStrategy
|
|
5
|
+
# The routes beyond this wait for the next run.
|
|
6
|
+
ROUTES_PER_RUN = 10
|
|
7
|
+
|
|
14
8
|
def initialize(integration:)
|
|
15
9
|
@integration = integration
|
|
16
10
|
@sync_result = ::SpreeCmCommissioner::Integrations::Base::SyncResult.new
|
|
17
11
|
end
|
|
18
12
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
# False when every route is fresh: the manager then skips the run (and its session row) altogether.
|
|
14
|
+
def work_due?
|
|
15
|
+
due_routes.any?
|
|
16
|
+
end
|
|
22
17
|
|
|
23
|
-
|
|
18
|
+
def call
|
|
19
|
+
due_routes.first(ROUTES_PER_RUN).each do |route, dates|
|
|
20
|
+
sync_trips_for_route!(route, dates)
|
|
21
|
+
# A 429 ends the run: every call after it would be refused too.
|
|
22
|
+
break if @sync_result.rate_limited?
|
|
23
|
+
end
|
|
24
24
|
|
|
25
|
-
@sync_result
|
|
26
|
-
rescue StandardError => e
|
|
27
|
-
@sync_result.record_error(e)
|
|
28
25
|
@sync_result
|
|
29
26
|
end
|
|
30
27
|
|
|
31
28
|
private
|
|
32
29
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
SpreeCmCommissioner::Route
|
|
38
|
-
.joins(:integration_mappings)
|
|
39
|
-
.where(vendor: @integration.vendor)
|
|
40
|
-
.where(
|
|
41
|
-
mapping_table => {
|
|
42
|
-
integration_id: @integration.id,
|
|
43
|
-
internal_type: 'SpreeCmCommissioner::Route',
|
|
44
|
-
status: :active
|
|
45
|
-
}
|
|
46
|
-
)
|
|
47
|
-
.order("#{mapping_table}.last_synced_at ASC NULLS FIRST")
|
|
48
|
-
.first
|
|
30
|
+
# [route, due dates] for each route with something due, stalest first (never refreshed comes first).
|
|
31
|
+
def due_routes
|
|
32
|
+
@due_routes ||= find_due_routes
|
|
49
33
|
end
|
|
50
34
|
|
|
51
|
-
def
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
35
|
+
def find_due_routes
|
|
36
|
+
due = route_mappings.filter_map do |mapping|
|
|
37
|
+
freshness = SpreeCmCommissioner::Integrations::BookMeBusV1::RouteFreshness.new(mapping)
|
|
38
|
+
dates = freshness.due_dates(days: @integration.sync_days)
|
|
39
|
+
[mapping.internal, dates, freshness.oldest_stamp(dates)] if dates.any? && mapping.internal
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
due.sort_by(&:last).map { |route, dates, _| [route, dates] }
|
|
43
|
+
end
|
|
55
44
|
|
|
45
|
+
# Only routes a SyncRouteJob has finished once (`full_synced_on`): the full sync's own jobs are still fetching the
|
|
46
|
+
# others, and refreshing them here too would compete with those jobs for the call limit.
|
|
47
|
+
# Only the columns read here: the payload column is large and not needed.
|
|
48
|
+
def route_mappings
|
|
49
|
+
SpreeCmCommissioner::IntegrationMapping.active_routes_of(@integration)
|
|
50
|
+
.where("private_metadata ->> 'full_synced_on' IS NOT NULL")
|
|
51
|
+
.select(:id, :internal_type, :internal_id, :private_metadata)
|
|
52
|
+
.includes(:internal)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# One route failing does not stop the others.
|
|
56
|
+
def sync_trips_for_route!(route, dates)
|
|
56
57
|
SpreeCmCommissioner::Integrations::BookMeBusV1::Polling::SyncTrips.new(
|
|
57
58
|
integration: @integration,
|
|
58
59
|
sync_result: @sync_result,
|
|
59
|
-
route:
|
|
60
|
-
).call(on_dates:
|
|
60
|
+
route: route
|
|
61
|
+
).call(on_dates: dates, full_sync: false)
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
@sync_result.record_error(e)
|
|
61
64
|
end
|
|
62
65
|
end
|
|
63
66
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Integrations
|
|
3
|
+
# Caps the calls BookMe+ sends to one partner host per minute, shared by every vendor, process and thread.
|
|
4
|
+
#
|
|
5
|
+
# - Checkout calls are never limited or counted.
|
|
6
|
+
# - A customer opening a trip (lane: :customer) is not counted either, so a busy sync cannot refuse it. It still
|
|
7
|
+
# waits out a 429 pause.
|
|
8
|
+
# - Any other call is refused with RateLimitedError (the error a real 429 raises, so the sync stops the same
|
|
9
|
+
# way) when the minute's limit is used up or the partner answered 429 and its Retry-After is still running.
|
|
10
|
+
# - Redis down: calls go through. An outage must not stop syncing or selling.
|
|
11
|
+
class CallLimiter
|
|
12
|
+
WINDOW = 60
|
|
13
|
+
DEFAULT_MAX_CALLS_PER_MINUTE = 60
|
|
14
|
+
MAX_PAUSE = RateLimitedError::MAX_RETRY_AFTER.to_i
|
|
15
|
+
|
|
16
|
+
# @param base_url [String] the partner's base URL; calls are counted per host
|
|
17
|
+
def initialize(base_url:)
|
|
18
|
+
@host = URI.parse(base_url.to_s).host.presence || base_url.to_s
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Counts one call, or raises RateLimitedError when it must not be sent.
|
|
22
|
+
def take!(lane: :background)
|
|
23
|
+
return if lane == :checkout
|
|
24
|
+
|
|
25
|
+
raise_if_paused!
|
|
26
|
+
return if lane == :customer
|
|
27
|
+
|
|
28
|
+
result = SpreeCmCommissioner::RateLimiter.call(key: calls_key, limit: max_calls_per_minute, window: WINDOW)
|
|
29
|
+
return unless result.exceeded?
|
|
30
|
+
|
|
31
|
+
raise_rate_limited("BookMe+ call limit reached (#{result.limit} calls a minute) for #{host}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True while the pause after a 429 is running (checkout ignores it).
|
|
35
|
+
def paused?
|
|
36
|
+
paused_for.positive?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Seconds left of that pause, 0 if none.
|
|
40
|
+
def paused_for
|
|
41
|
+
SpreeCmCommissioner.redis_pool.with { |redis| redis.ttl(pause_key) }.to_i.clamp(0, MAX_PAUSE)
|
|
42
|
+
rescue Redis::BaseError, ConnectionPool::TimeoutError => e
|
|
43
|
+
log_redis_error(e)
|
|
44
|
+
0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The partner answered 429: pause every non-checkout call for `seconds`.
|
|
48
|
+
def pause!(seconds)
|
|
49
|
+
SpreeCmCommissioner.redis_pool.with { |redis| redis.set(pause_key, '1', ex: seconds.to_i.clamp(1, MAX_PAUSE)) }
|
|
50
|
+
rescue Redis::BaseError, ConnectionPool::TimeoutError => e
|
|
51
|
+
log_redis_error(e)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :host
|
|
57
|
+
|
|
58
|
+
def raise_if_paused!
|
|
59
|
+
seconds_left = paused_for
|
|
60
|
+
return unless seconds_left.positive?
|
|
61
|
+
|
|
62
|
+
raise_rate_limited("#{host} answered 429 recently, not calling it for #{seconds_left} more seconds", retry_after: seconds_left)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def raise_rate_limited(message, retry_after: nil)
|
|
66
|
+
raise RateLimitedError.new(message, retry_after: retry_after)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def max_calls_per_minute
|
|
70
|
+
ENV.fetch('BOOKMEBUS_MAX_CALLS_PER_MINUTE', DEFAULT_MAX_CALLS_PER_MINUTE).to_i
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def calls_key
|
|
74
|
+
"integrations:call_limiter:#{host}:calls"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def pause_key
|
|
78
|
+
"integrations:call_limiter:#{host}:paused"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def log_redis_error(error)
|
|
82
|
+
CmAppLogger.error(
|
|
83
|
+
label: 'Integrations::CallLimiter Redis error - failing open',
|
|
84
|
+
data: { host: host, error_class: error.class.name, error_message: error.message }
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -6,9 +6,9 @@ module SpreeCmCommissioner
|
|
|
6
6
|
def call
|
|
7
7
|
SpreeCmCommissioner::Integration.active.find_each(batch_size: BATCH_SIZE) do |integration|
|
|
8
8
|
if should_run_full_sync?(integration)
|
|
9
|
-
schedule_full_sync(integration)
|
|
9
|
+
schedule_full_sync(integration) unless paused_by_partner?(integration)
|
|
10
10
|
elsif should_run_incremental_sync?(integration)
|
|
11
|
-
schedule_incremental_sync(integration)
|
|
11
|
+
schedule_incremental_sync(integration) unless paused_by_partner?(integration)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -20,6 +20,8 @@ module SpreeCmCommissioner
|
|
|
20
20
|
# 1. No full sync is running or pending (excluding sessions older than 1 hour)
|
|
21
21
|
# 2. No previous full sync exists, OR
|
|
22
22
|
# 3. Last full sync was more than the configured interval ago (eg. 24 hours ago)
|
|
23
|
+
# And the last full syncs are not failing, with their wait still running (SyncBackoff): only completed sessions
|
|
24
|
+
# count here, so a failed one would otherwise be queued again on the next 10 s tick.
|
|
23
25
|
def should_run_full_sync?(integration)
|
|
24
26
|
return false if integration.integration_sync_sessions
|
|
25
27
|
.where(sync_type: %i[full], status: %i[in_progress pending])
|
|
@@ -30,10 +32,8 @@ module SpreeCmCommissioner
|
|
|
30
32
|
.order(created_at: :desc)
|
|
31
33
|
.first
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
full_sync_interval = integration.full_sync_interval_hours.hours
|
|
36
|
-
(Time.current - last_full_sync.created_at) > full_sync_interval
|
|
35
|
+
due = last_full_sync.blank? || (Time.current - last_full_sync.created_at) > integration.full_sync_interval_hours.hours
|
|
36
|
+
due && !SyncBackoff.new(integration).full_sync_waiting?
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# Check if incremental sync should run
|
|
@@ -43,8 +43,9 @@ module SpreeCmCommissioner
|
|
|
43
43
|
.where(sync_type: %i[full incremental], status: %i[in_progress pending])
|
|
44
44
|
.exists?(['created_at > ?', 1.hour.ago])
|
|
45
45
|
|
|
46
|
+
# A failed run counts too, so it waits the interval instead of retrying on the next tick.
|
|
46
47
|
last_incremental_sync = integration.integration_sync_sessions
|
|
47
|
-
.where(sync_type: :incremental, status:
|
|
48
|
+
.where(sync_type: :incremental, status: %i[completed failed])
|
|
48
49
|
.order(created_at: :desc)
|
|
49
50
|
.first
|
|
50
51
|
|
|
@@ -57,6 +58,11 @@ module SpreeCmCommissioner
|
|
|
57
58
|
(Time.current - last_incremental_sync.created_at) > incremental_sync_interval
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
# The partner answered 429 and its Retry-After is still running.
|
|
62
|
+
def paused_by_partner?(integration)
|
|
63
|
+
SyncBackoff.new(integration).rate_limited?
|
|
64
|
+
end
|
|
65
|
+
|
|
60
66
|
def schedule_full_sync(integration)
|
|
61
67
|
CmAppLogger.log(
|
|
62
68
|
label: 'Integrations::PollingSchedulerJob#schedule_full_sync Enqueued full sync',
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Integrations
|
|
3
|
+
# Tells PollingScheduler when to wait after failed syncs, so a failure is not queued again on the next tick.
|
|
4
|
+
#
|
|
5
|
+
# - Full sync: after failures in a row it waits 15, 30, 60, then at most 120 minutes since the failed run ended.
|
|
6
|
+
# A success resets it.
|
|
7
|
+
# - A 429 pauses every sync of the integration for the Retry-After the partner sent.
|
|
8
|
+
class SyncBackoff
|
|
9
|
+
BASE_WAIT = 15.minutes
|
|
10
|
+
MAX_WAIT = 2.hours
|
|
11
|
+
MAX_STREAK = 5 # four failures reach MAX_WAIT, so older history changes nothing
|
|
12
|
+
FINISHED_STATUSES = %i[completed failed].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(integration)
|
|
15
|
+
@integration = integration
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def full_sync_waiting?
|
|
19
|
+
failures = recent_full_sync_failures
|
|
20
|
+
return false if failures.empty?
|
|
21
|
+
|
|
22
|
+
wait = [BASE_WAIT * (2**(failures.size - 1)), MAX_WAIT].min
|
|
23
|
+
finished_at(*failures.first) + wait > Time.current
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def rate_limited?
|
|
27
|
+
ended_at, created_at, error = last_finished_session
|
|
28
|
+
return false unless error&.dig('code') == 'RateLimitedError'
|
|
29
|
+
|
|
30
|
+
retry_after = error['retry_after'] || RateLimitedError::DEFAULT_RETRY_AFTER
|
|
31
|
+
finished_at(ended_at, created_at) + retry_after.to_i.seconds > Time.current
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# [ended_at, created_at] of each full sync that failed in a row, newest first.
|
|
37
|
+
def recent_full_sync_failures
|
|
38
|
+
@integration.integration_sync_sessions
|
|
39
|
+
.where(sync_type: :full, status: FINISHED_STATUSES)
|
|
40
|
+
.order(created_at: :desc)
|
|
41
|
+
.limit(MAX_STREAK)
|
|
42
|
+
.pluck(:status, :ended_at, :created_at)
|
|
43
|
+
.take_while { |status, _, _| status == 'failed' }
|
|
44
|
+
.map { |_, ended_at, created_at| [ended_at, created_at] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# [ended_at, created_at, error] of the latest run. Only the error is read from the (larger) sync result.
|
|
48
|
+
def last_finished_session
|
|
49
|
+
@integration.integration_sync_sessions
|
|
50
|
+
.where(status: FINISHED_STATUSES)
|
|
51
|
+
.order(created_at: :desc)
|
|
52
|
+
.pick(:ended_at, :created_at, Arel.sql("sync_result -> 'error'"))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A run that died has no ended_at: use its start.
|
|
56
|
+
def finished_at(ended_at, created_at)
|
|
57
|
+
ended_at || created_at
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -87,19 +87,25 @@ module SpreeCmCommissioner
|
|
|
87
87
|
.pluck(:id)
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
-
# "512:2026-08-08" => [512, Date].
|
|
90
|
+
# "512:2026-08-08" => [512, Date]. "512:" (no date) => [512, nil] -- an Event session-variant, whose
|
|
91
|
+
# single InventoryItem has inventory_date: nil. Owned here so the gem never reaches into a
|
|
92
|
+
# controller helper.
|
|
91
93
|
def parse_selections(keys)
|
|
92
94
|
Array(keys).filter_map do |key|
|
|
93
95
|
variant_id, date = key.to_s.split(':')
|
|
94
|
-
next if variant_id.blank?
|
|
96
|
+
next if variant_id.blank?
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
[variant_id.to_i, Date.parse(date)]
|
|
98
|
-
rescue Date::Error
|
|
99
|
-
nil
|
|
100
|
-
end
|
|
98
|
+
[variant_id.to_i, parse_date(date)]
|
|
101
99
|
end
|
|
102
100
|
end
|
|
101
|
+
|
|
102
|
+
def parse_date(date)
|
|
103
|
+
return nil if date.blank?
|
|
104
|
+
|
|
105
|
+
Date.parse(date)
|
|
106
|
+
rescue Date::Error
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
103
109
|
end
|
|
104
110
|
end
|
|
105
111
|
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Seats
|
|
3
|
+
# A seat's change history, newest first, read out of the PaperTrail versions of the
|
|
4
|
+
# ReservedBlock rows on that block.
|
|
5
|
+
#
|
|
6
|
+
# `versions` has no object_changes column, and a version's `object` is the row *before* that
|
|
7
|
+
# change -- so what a change wrote is the next version's `object`, or the live row for the newest
|
|
8
|
+
# one. Each entry is the diff between those two snapshots, limited to TRACKED attributes.
|
|
9
|
+
#
|
|
10
|
+
# The actor is the `updated_by_id` the change itself wrote, not `whodunnit`: every ReservedBlock
|
|
11
|
+
# writer stamps updated_by, including background jobs (ApplyLocks) where whodunnit is blank, and
|
|
12
|
+
# the expiry jobs deliberately write nil for "system". whodunnit is only a fallback.
|
|
13
|
+
#
|
|
14
|
+
# Only live rows are read: a destroyed row's versions are keyed by its old item_id alone and can't
|
|
15
|
+
# be found from the block. Seats::UnlockBlocks cancels rather than destroys for that reason, but
|
|
16
|
+
# locks released before it did are gone from a seat's history.
|
|
17
|
+
class BlockHistory
|
|
18
|
+
Entry = Struct.new(:version_id, :event, :created_at, :actor, :changes, keyword_init: true)
|
|
19
|
+
GuestRef = Struct.new(:id, :name, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
TRACKED = %w[status guest_id lock_reason lock_note expired_at payment_locked_at].freeze
|
|
22
|
+
|
|
23
|
+
# Bounded because versions are never pruned and a seat's hold/expire churn during checkout can
|
|
24
|
+
# write dozens of versions per day -- the panel only needs the recent story.
|
|
25
|
+
LIMIT = 50
|
|
26
|
+
|
|
27
|
+
def initialize(block)
|
|
28
|
+
@block = block
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def entries
|
|
32
|
+
drafts = versions.group_by(&:item_id).flat_map { |item_id, row_versions| drafts_for(rows[item_id], row_versions) }
|
|
33
|
+
drafts = drafts.sort_by { |draft| -draft[:version].id }
|
|
34
|
+
drafts.reject! { |draft| draft[:version].event == 'update' && draft[:changes].empty? }
|
|
35
|
+
|
|
36
|
+
guests = guests_by_id(drafts)
|
|
37
|
+
actors = actors_by_id(drafts)
|
|
38
|
+
|
|
39
|
+
drafts.map { |draft| build_entry(draft, guests, actors) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
attr_reader :block
|
|
45
|
+
|
|
46
|
+
def rows
|
|
47
|
+
@rows ||= SpreeCmCommissioner::ReservedBlock.where(block_id: block.id).index_by(&:id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def versions
|
|
51
|
+
return [] if rows.empty?
|
|
52
|
+
|
|
53
|
+
PaperTrail::Version.where(item_type: SpreeCmCommissioner::ReservedBlock.name, item_id: rows.keys)
|
|
54
|
+
.reorder(id: :desc)
|
|
55
|
+
.limit(LIMIT)
|
|
56
|
+
.to_a
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# row_versions arrive newest first, so each version's "after" is the snapshot just processed.
|
|
60
|
+
def drafts_for(row, row_versions)
|
|
61
|
+
after = row ? normalize(row.attributes) : {}
|
|
62
|
+
|
|
63
|
+
row_versions.map do |version|
|
|
64
|
+
before = state_in(version)
|
|
65
|
+
written = version.event == 'destroy' ? {} : after
|
|
66
|
+
after = before
|
|
67
|
+
|
|
68
|
+
{ version: version, before: before, after: written, changes: diff(before, written) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def diff(before, after)
|
|
73
|
+
TRACKED.each_with_object({}) do |attribute, changes|
|
|
74
|
+
from = before[attribute]
|
|
75
|
+
to = after[attribute]
|
|
76
|
+
changes[attribute] = [from, to] unless from == to
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def state_in(version)
|
|
81
|
+
return {} if version.object.blank?
|
|
82
|
+
|
|
83
|
+
normalize(YAML.unsafe_load(version.object.to_s) || {})
|
|
84
|
+
rescue Psych::Exception, ArgumentError
|
|
85
|
+
{}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Versions and the live row disagree on representation (enum integer vs name, jsonb as a JSON
|
|
89
|
+
# string vs a Hash, Time vs String), so both are reduced to the same plain shape before diffing.
|
|
90
|
+
def normalize(attributes)
|
|
91
|
+
metadata = metadata_in(attributes['private_metadata'])
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
'status' => status_name(attributes['status']),
|
|
95
|
+
'guest_id' => attributes['guest_id'],
|
|
96
|
+
'lock_reason' => metadata['lock_reason'].presence,
|
|
97
|
+
'lock_note' => metadata['lock_note'].presence,
|
|
98
|
+
'expired_at' => to_time(attributes['expired_at']),
|
|
99
|
+
'payment_locked_at' => to_time(attributes['payment_locked_at']),
|
|
100
|
+
'updated_by_id' => attributes['updated_by_id']
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# PaperTrail serialises jsonb through the column type, which lands in `object` as a JSON string
|
|
105
|
+
# of a JSON string -- hence decoding until it stops being one, not just once.
|
|
106
|
+
def metadata_in(value)
|
|
107
|
+
2.times { value = JSON.parse(value) if value.is_a?(String) }
|
|
108
|
+
value.is_a?(Hash) ? value.stringify_keys : {}
|
|
109
|
+
rescue JSON::ParserError
|
|
110
|
+
{}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def status_name(value)
|
|
114
|
+
return value.to_s if value.is_a?(String) || value.is_a?(Symbol)
|
|
115
|
+
return nil if value.nil?
|
|
116
|
+
|
|
117
|
+
SpreeCmCommissioner::ReservedBlock.statuses.key(value)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Second precision: a YAML snapshot and the live column can differ in sub-second digits for
|
|
121
|
+
# the same write, which would otherwise show up as a phantom change.
|
|
122
|
+
def to_time(value)
|
|
123
|
+
time = value.is_a?(String) ? Time.zone.parse(value) : value&.in_time_zone
|
|
124
|
+
time&.change(usec: 0)
|
|
125
|
+
rescue ArgumentError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A destroy has no "after" -- the row's own final snapshot (UnlockBlocks stamps updated_by on it
|
|
130
|
+
# just before destroying) is what names the actor.
|
|
131
|
+
def actor_id(draft)
|
|
132
|
+
source = draft[:version].event == 'destroy' ? draft[:before] : draft[:after]
|
|
133
|
+
source['updated_by_id'].presence || draft[:version].whodunnit.presence
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def actors_by_id(drafts)
|
|
137
|
+
ids = drafts.filter_map { |draft| actor_id(draft) }.map(&:to_i).uniq
|
|
138
|
+
Spree.user_class.where(id: ids).index_by(&:id)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def guests_by_id(drafts)
|
|
142
|
+
ids = drafts.flat_map { |draft| draft[:changes].fetch('guest_id', []) }.compact.uniq
|
|
143
|
+
SpreeCmCommissioner::Guest.where(id: ids).index_by(&:id)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def build_entry(draft, guests, actors)
|
|
147
|
+
version = draft[:version]
|
|
148
|
+
changes = draft[:changes].dup
|
|
149
|
+
guest_ids = changes.delete('guest_id')
|
|
150
|
+
changes['guest'] = guest_ids.map { |id| guest_ref(id, guests) } if guest_ids
|
|
151
|
+
|
|
152
|
+
Entry.new(
|
|
153
|
+
version_id: version.id,
|
|
154
|
+
event: version.event,
|
|
155
|
+
created_at: version.created_at,
|
|
156
|
+
actor: actors[actor_id(draft).to_i],
|
|
157
|
+
changes: changes
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def guest_ref(id, guests)
|
|
162
|
+
return nil if id.nil?
|
|
163
|
+
|
|
164
|
+
GuestRef.new(id: id, name: guests[id]&.full_name.presence)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -11,6 +11,8 @@ module SpreeCmCommissioner
|
|
|
11
11
|
# those two disagreed the operator saw one thing and the server did another.
|
|
12
12
|
class LockState
|
|
13
13
|
# selections: array of [variant_id, Date] pairs, parsed from the calendar's "variant_id:date" keys.
|
|
14
|
+
# A pair may carry a nil date instead -- an Event session-variant, whose single InventoryItem row
|
|
15
|
+
# has inventory_date: nil (there is no date to choose, unlike Transit's permanent-stock variants).
|
|
14
16
|
def initialize(selections:)
|
|
15
17
|
@selections = Array(selections)
|
|
16
18
|
end
|
|
@@ -71,7 +73,7 @@ module SpreeCmCommissioner
|
|
|
71
73
|
# per variant.
|
|
72
74
|
def dates_by_variant
|
|
73
75
|
@dates_by_variant ||= selections.each_with_object({}) do |(variant_id, date), acc|
|
|
74
|
-
(acc[variant_id] ||= []) << date
|
|
76
|
+
(acc[variant_id] ||= []) << date&.to_date
|
|
75
77
|
end
|
|
76
78
|
end
|
|
77
79
|
|