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.
Files changed (45) 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/services/spree_cm_commissioner/voting_credits/de_allocate.rb +11 -0
  36. data/app/views/spree/admin/inventory_holds/_insights.html.erb +99 -0
  37. data/app/views/spree/admin/inventory_holds/_insights_drawer.html.erb +83 -0
  38. data/app/views/spree/admin/inventory_holds/_order_status.html.erb +9 -0
  39. data/app/views/spree/admin/inventory_holds/_overview.html.erb +109 -0
  40. data/app/views/spree/admin/inventory_holds/_section.html.erb +5 -0
  41. data/app/views/spree/admin/inventory_holds/_stat.html.erb +4 -0
  42. data/app/views/spree/admin/inventory_holds/index.html.erb +12 -1
  43. data/config/routes.rb +4 -0
  44. data/lib/spree_cm_commissioner/version.rb +1 -1
  45. metadata +18 -2
@@ -50,19 +50,25 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::SyncStrategies
50
50
 
51
51
  def call
52
52
  sync_locations!
53
- sync_routes!
54
- sync_all_trips!
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
- ).call
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
- # Sync trips for all routes
80
- # Uses custom routes if provided, otherwise syncs all active routes
81
- def sync_all_trips!
82
- routes = @custom_routes.presence || find_all_routes
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
- routes.each do |route|
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: 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
- # Incremental sync strategy - syncs trip data for specific routes.
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
- def call
20
- vendor_route = find_next_vendor_route_to_sync
21
- return @sync_result unless vendor_route
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
- sync_trips_for_vendor_route!(vendor_route)
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
- def find_next_vendor_route_to_sync
34
- # Get routes for this vendor, join with route mappings to find oldest synced
35
- mapping_table = SpreeCmCommissioner::IntegrationMapping.table_name
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 sync_trips_for_vendor_route!(vendor_route)
52
- # Sync trips for today and tomorrow to keep availability fresh
53
- today = Date.current
54
- on_dates = [today, today + 1.day].map { |date| date.strftime('%Y-%m-%d') }
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: vendor_route
60
- ).call(on_dates: on_dates, full_sync: false)
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
- return true if last_full_sync.blank?
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: :completed)
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]. Owned here so the gem never reaches into a controller helper.
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? || date.blank?
96
+ next if variant_id.blank?
95
97
 
96
- begin
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.to_date
76
+ (acc[variant_id] ||= []) << date&.to_date
75
77
  end
76
78
  end
77
79