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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9729da447b28b6f2e0beafb9333442257c6d9efb60a317dd8012810248085654
|
|
4
|
+
data.tar.gz: 5ff7d533406299db932f42c402b26e495d2e4127859611291344248083b7f1dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 014cf7cea9a4d55083a537cbcc4e9c4afa529edaf8fb8468b078200ccd92342991e050c58ffd139e8409cc1e85d08b0fa54214cac356ca2e93367f278527f295
|
|
7
|
+
data.tar.gz: 1312f2a920a682b616b907687576ea6e8a9b9170780270e4d41853fa343865645f93406bd88bd32ce86b0d8cef012e5c04d9e5a4bf5167cfcc6ff0c0bb787a5a
|
data/.env.example
CHANGED
|
@@ -94,3 +94,6 @@ PRE_INVENTORY_DAYS_TRANSIT=180 # How many days ahead inventory items are pre-ge
|
|
|
94
94
|
PRE_INVENTORY_DAYS_ACCOMMODATION=365 # How many days ahead inventory items are pre-generated for accommodation variants
|
|
95
95
|
PRE_INVENTORY_DAYS_SERVICE=180 # How many days ahead inventory items are pre-generated for service variants
|
|
96
96
|
PAYMENT_BASE_URL=http://localhost:4000
|
|
97
|
+
|
|
98
|
+
# See: app/services/spree_cm_commissioner/integrations/call_limiter.rb
|
|
99
|
+
BOOKMEBUS_MAX_CALLS_PER_MINUTE=60 # Most calls a minute BookMe+ sends to BookMeBus, shared by all vendors (checkout is never limited); lower it to protect their server, raise it only after agreeing a limit with them
|
data/Gemfile.lock
CHANGED
|
@@ -34,7 +34,7 @@ GIT
|
|
|
34
34
|
PATH
|
|
35
35
|
remote: .
|
|
36
36
|
specs:
|
|
37
|
-
spree_cm_commissioner (2.15.
|
|
37
|
+
spree_cm_commissioner (2.15.4)
|
|
38
38
|
activerecord-multi-tenant
|
|
39
39
|
activerecord_json_validator (~> 2.1, >= 2.1.3)
|
|
40
40
|
aws-sdk-cloudfront
|
|
@@ -886,7 +886,7 @@ GEM
|
|
|
886
886
|
spree_backend (>= 4.3.0.rc1)
|
|
887
887
|
spree_emails (>= 4.3.0.rc1)
|
|
888
888
|
spree_extension
|
|
889
|
-
spree_vpago (2.4.
|
|
889
|
+
spree_vpago (2.4.7)
|
|
890
890
|
faraday
|
|
891
891
|
google-cloud-firestore
|
|
892
892
|
spree_api (>= 4.5)
|
|
@@ -4,12 +4,7 @@ module Spree
|
|
|
4
4
|
def index
|
|
5
5
|
authorize! :manage, SpreeCmCommissioner::InventoryHold
|
|
6
6
|
|
|
7
|
-
q =
|
|
8
|
-
q = q.respond_to?(:to_unsafe_h) ? q.to_unsafe_h.deep_dup : q.deep_dup
|
|
9
|
-
q['s'] ||= 'created_at desc'
|
|
10
|
-
q['created_at_gt'] = parse_time_or_nil(q['created_at_gt'], :beginning_of_day) if q['created_at_gt'].present?
|
|
11
|
-
q['created_at_lt'] = parse_time_or_nil(q['created_at_lt'], :end_of_day) if q['created_at_lt'].present?
|
|
12
|
-
normalize_enum_filters!(q)
|
|
7
|
+
q = search_query
|
|
13
8
|
|
|
14
9
|
@search = scope.ransack(q)
|
|
15
10
|
result_scope = @search.result(distinct: true)
|
|
@@ -26,6 +21,27 @@ module Spree
|
|
|
26
21
|
.reorder(nil).group(:status).count
|
|
27
22
|
end
|
|
28
23
|
|
|
24
|
+
# Same filters as the table; without a start date it covers the last 24h so the GROUP BYs stay cheap.
|
|
25
|
+
def overview
|
|
26
|
+
authorize! :manage, SpreeCmCommissioner::InventoryHold
|
|
27
|
+
|
|
28
|
+
q = search_query
|
|
29
|
+
@overview_from = q['created_at_gt'].presence || 24.hours.ago
|
|
30
|
+
q['created_at_gt'] = @overview_from
|
|
31
|
+
@overview = SpreeCmCommissioner::InventoryHoldOverviewQuery.new(holds: scope.ransack(q).result).call
|
|
32
|
+
|
|
33
|
+
render partial: 'overview', layout: false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def insights
|
|
37
|
+
authorize! :manage, SpreeCmCommissioner::InventoryHold
|
|
38
|
+
|
|
39
|
+
hold = scope.includes(:user, order: :user).find(params[:id])
|
|
40
|
+
@insights = SpreeCmCommissioner::InventoryHoldInsightsQuery.new(hold: hold).call
|
|
41
|
+
|
|
42
|
+
render partial: 'insights', layout: false
|
|
43
|
+
end
|
|
44
|
+
|
|
29
45
|
def release
|
|
30
46
|
authorize! :manage, SpreeCmCommissioner::InventoryHold
|
|
31
47
|
|
|
@@ -49,6 +65,16 @@ module Spree
|
|
|
49
65
|
|
|
50
66
|
private
|
|
51
67
|
|
|
68
|
+
def search_query
|
|
69
|
+
q = params.fetch(:q, {})
|
|
70
|
+
q = q.respond_to?(:to_unsafe_h) ? q.to_unsafe_h.deep_dup : q.deep_dup
|
|
71
|
+
q['s'] ||= 'created_at desc'
|
|
72
|
+
q['created_at_gt'] = parse_time_or_nil(q['created_at_gt'], :beginning_of_day) if q['created_at_gt'].present?
|
|
73
|
+
q['created_at_lt'] = parse_time_or_nil(q['created_at_lt'], :end_of_day) if q['created_at_lt'].present?
|
|
74
|
+
normalize_enum_filters!(q)
|
|
75
|
+
q
|
|
76
|
+
end
|
|
77
|
+
|
|
52
78
|
# `status` and `release_reason` are integer-backed enums. Ransack casts a string
|
|
53
79
|
# value for an integer column with String#to_i, so "payment_locked" becomes 0
|
|
54
80
|
# (== :pending) and every label silently filters to the wrong status. Translate
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module SpreeCmCommissioner::Integrations
|
|
2
|
+
# The partner answered 429 (or CallLimiter refused the call). Sync code stops at the first one instead of
|
|
3
|
+
# retrying. An ExternalClientError, so checkout handles it like any other failed call.
|
|
4
|
+
class RateLimitedError < ExternalClientError
|
|
5
|
+
DEFAULT_RETRY_AFTER = 60.seconds
|
|
6
|
+
MAX_RETRY_AFTER = 2.hours
|
|
7
|
+
|
|
8
|
+
attr_reader :retry_after
|
|
9
|
+
|
|
10
|
+
# @param retry_after [Integer, nil] seconds to wait (the Retry-After header)
|
|
11
|
+
def initialize(message, retry_after: nil)
|
|
12
|
+
super(message, 429)
|
|
13
|
+
@retry_after = (retry_after || DEFAULT_RETRY_AFTER).to_i.clamp(DEFAULT_RETRY_AFTER.to_i, MAX_RETRY_AFTER.to_i)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Retry-After is a number of seconds or an HTTP date; anything else is nil.
|
|
17
|
+
def self.parse_retry_after(header)
|
|
18
|
+
value = header.to_s.strip
|
|
19
|
+
return nil if value.empty?
|
|
20
|
+
return value.to_i if value.match?(/\A\d+\z/)
|
|
21
|
+
|
|
22
|
+
seconds = (Time.httpdate(value) - Time.current).ceil
|
|
23
|
+
seconds.positive? ? seconds : nil
|
|
24
|
+
rescue ArgumentError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Syncs the trips of one route for the next `sync_days` days. The daily full sync queues one per route, so a failing
|
|
2
|
+
# route only fails itself, a deploy redoes one route (seconds, not minutes), and a rerun skips routes done today.
|
|
3
|
+
class SpreeCmCommissioner::Integrations::BookMeBusV1::SyncRouteJob < SpreeCmCommissioner::ApplicationUniqueJob
|
|
4
|
+
queue_as :integration
|
|
5
|
+
|
|
6
|
+
MAX_ATTEMPTS = 3
|
|
7
|
+
RETRY_DELAY = 15.minutes
|
|
8
|
+
MIN_PAUSE = 1.minute # the call limit refills every minute
|
|
9
|
+
|
|
10
|
+
# @param options [Hash] :integration_id, :route_id, :attempt (1 for the first run, set by the retries)
|
|
11
|
+
def perform(options)
|
|
12
|
+
integration = SpreeCmCommissioner::Integration.find_by(id: options[:integration_id])
|
|
13
|
+
route = SpreeCmCommissioner::Route.find_by(id: options[:route_id])
|
|
14
|
+
return unless integration&.active? && route
|
|
15
|
+
|
|
16
|
+
# While BookMeBus is paused after a 429, don't call it: retry after the pause.
|
|
17
|
+
limiter = SpreeCmCommissioner::Integrations::CallLimiter.new(base_url: integration.base_url)
|
|
18
|
+
return retry_after_pause(options, limiter.paused_for) if limiter.paused?
|
|
19
|
+
|
|
20
|
+
sync_result = SpreeCmCommissioner::Integrations::Base::SyncResult.new
|
|
21
|
+
sync(integration, route, sync_result)
|
|
22
|
+
|
|
23
|
+
# Told to wait (our call limit or a 429): nothing wrong with the route, so no outcome is recorded.
|
|
24
|
+
return retry_after_pause(options, sync_result.to_h.dig(:error, :retry_after)) if sync_result.rate_limited?
|
|
25
|
+
|
|
26
|
+
record_outcome(integration, route, sync_result)
|
|
27
|
+
retry_later(options) unless sync_result.success?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def sync(integration, route, sync_result)
|
|
33
|
+
SpreeCmCommissioner::Integrations::BookMeBusV1::Polling::SyncTrips.new(
|
|
34
|
+
integration: integration, sync_result: sync_result, route: route
|
|
35
|
+
).call(on_dates: integration.sync_dates)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
sync_result.record_error(e)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Kept on the route's mapping: `full_synced_on` (the next full sync skips it) or `last_error`.
|
|
41
|
+
def record_outcome(integration, route, sync_result)
|
|
42
|
+
mapping = SpreeCmCommissioner::IntegrationMapping.active_routes_of(integration).find_by(internal_id: route.id)
|
|
43
|
+
return unless mapping
|
|
44
|
+
|
|
45
|
+
outcome = sync_result.success? ? { 'full_synced_on' => Date.current.iso8601 } : { 'last_error' => sync_result.to_h.dig(:error, :message) }
|
|
46
|
+
|
|
47
|
+
# The incremental sync stamps the same column: the lock (which reloads first) keeps either from erasing the other's key.
|
|
48
|
+
mapping.with_lock do
|
|
49
|
+
mapping.update!(private_metadata: (mapping.private_metadata || {}).except('last_error', 'full_synced_on').merge(outcome))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A real failure: tries again a few times today (15 min, then 30), then leaves it to the next full sync.
|
|
54
|
+
def retry_later(options)
|
|
55
|
+
attempt = (options[:attempt] || 1).to_i
|
|
56
|
+
return if attempt >= MAX_ATTEMPTS
|
|
57
|
+
|
|
58
|
+
self.class.set(wait: RETRY_DELAY * attempt).perform_later(options.merge(attempt: attempt + 1))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Waits out the pause (at least a minute) and tries again as the same attempt: a large vendor queues all its routes
|
|
62
|
+
# at once, and the ones the call limit turns away must not use up their attempts just by waiting in line.
|
|
63
|
+
#
|
|
64
|
+
# `waits` changes the arguments on purpose: ApplicationUniqueJob drops a job whose arguments match one that is still
|
|
65
|
+
# running (`unique :until_executed`), and this job is still running while it queues its own retry. Without it the
|
|
66
|
+
# retry is dropped silently and the route is never synced.
|
|
67
|
+
def retry_after_pause(options, wait)
|
|
68
|
+
self.class.set(wait: [wait.to_i.seconds, MIN_PAUSE].max).perform_later(options.merge(waits: options[:waits].to_i + 1))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -60,6 +60,22 @@ module SpreeCmCommissioner
|
|
|
60
60
|
self.class.sellable?(block_type)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# The ReservedBlock row currently making this seat unbookable (reserved, on_hold, or
|
|
64
|
+
# operator-locked and not expired) — nil when the seat is open for sale. Reads off the
|
|
65
|
+
# already-loaded all_reserved_blocks association rather than querying, so a caller that
|
|
66
|
+
# preloads it (e.g. the organizer seat status view, rendering every block on a layout) does
|
|
67
|
+
# not pay a query per block.
|
|
68
|
+
#
|
|
69
|
+
# Only meaningful for a block with a single relevant InventoryItem, e.g. an Event
|
|
70
|
+
# session-variant's undated item. A Transit block spans one InventoryItem per departure date, so
|
|
71
|
+
# all_reserved_blocks can hold several simultaneously-active rows (locked on one date, sold on
|
|
72
|
+
# another) and this returns an arbitrary one of them, not "the" status for any particular date.
|
|
73
|
+
# Do not call this for a date-scoped seat map — use Seats::LockState instead, which resolves
|
|
74
|
+
# status per (block, date).
|
|
75
|
+
def current_reserved_block
|
|
76
|
+
all_reserved_blocks.detect(&:unavailable?)
|
|
77
|
+
end
|
|
78
|
+
|
|
63
79
|
def assign_layout_from_section
|
|
64
80
|
self.seat_layout = seat_section.seat_layout
|
|
65
81
|
end
|
|
@@ -6,6 +6,8 @@ module SpreeCmCommissioner
|
|
|
6
6
|
|
|
7
7
|
enum status: { active: 0, archived: 1 }
|
|
8
8
|
|
|
9
|
+
scope :active_routes_of, -> (integration) { active.where(integration_id: integration.id, internal_type: 'SpreeCmCommissioner::Route') }
|
|
10
|
+
|
|
9
11
|
# polymorphic (Spree::Taxon, Spree::Product, Spree::Variant, Spree::Vendor)
|
|
10
12
|
belongs_to :internal, polymorphic: true, optional: false
|
|
11
13
|
belongs_to :integration, class_name: 'SpreeCmCommissioner::Integration', inverse_of: :integration_mappings, optional: false
|
|
@@ -32,6 +34,12 @@ module SpreeCmCommissioner
|
|
|
32
34
|
save!
|
|
33
35
|
end
|
|
34
36
|
|
|
37
|
+
# True when `new_payload` differs from the stored one only in `ignoring` keys (at any depth), i.e. nothing the
|
|
38
|
+
# rest of the sync reads back has changed. Does not write anything.
|
|
39
|
+
def payload_unchanged?(new_payload, ignoring: [])
|
|
40
|
+
persisted? && active? && stable_payload(new_payload.as_json, ignoring) == stable_payload(external_payload.as_json, ignoring)
|
|
41
|
+
end
|
|
42
|
+
|
|
35
43
|
# For polling, which confirms the same mappings every few seconds. last_synced_at is indexed, so each save rewrites
|
|
36
44
|
# every index on this table; this skips the save while nothing a reader uses has changed. `ignoring` lists payload
|
|
37
45
|
# keys that move on every poll (seats, for one), at any depth.
|
|
@@ -8,6 +8,11 @@ class SpreeCmCommissioner::Integrations::BookMeBusV1 < SpreeCmCommissioner::Inte
|
|
|
8
8
|
store_private_metadata :operator_id, :string
|
|
9
9
|
store_private_metadata :sync_days, :integer, default: 7
|
|
10
10
|
# override
|
|
11
|
+
# The dates a full sync covers: today and the next sync_days - 1 days.
|
|
12
|
+
def sync_dates
|
|
13
|
+
(0...sync_days).map { |ahead| (Date.current + ahead).iso8601 }
|
|
14
|
+
end
|
|
15
|
+
|
|
11
16
|
def sync_manager
|
|
12
17
|
SpreeCmCommissioner::Integrations::BookMeBusV1::SyncManager.new(integration: self)
|
|
13
18
|
end
|
|
@@ -5,7 +5,12 @@ module SpreeCmCommissioner
|
|
|
5
5
|
has_paper_trail
|
|
6
6
|
|
|
7
7
|
# Why a seat was taken off sale by an operator. Stored in private_metadata rather than as a
|
|
8
|
-
# typed column, so it cannot be an ActiveRecord enum
|
|
8
|
+
# typed column, so it cannot be an ActiveRecord enum.
|
|
9
|
+
#
|
|
10
|
+
# Suggested options for a picklist-style UI (Transit's "Lock Seats" drawer) -- NOT a validation
|
|
11
|
+
# allowlist. `lock_reason` itself accepts any free text (see the presence/length validations
|
|
12
|
+
# below): the Organizer "Manage Hold Seats" screen lets the operator type their own reason, so
|
|
13
|
+
# enforcing this list at the model would reject a perfectly legitimate custom reason.
|
|
9
14
|
#
|
|
10
15
|
# `gender_balance` is deliberately absent: female-only seating is driven by the `gender` column,
|
|
11
16
|
# and a locked row has no gender, so a lock structurally cannot express it.
|
|
@@ -25,19 +30,24 @@ module SpreeCmCommissioner
|
|
|
25
30
|
|
|
26
31
|
enum gender: { :other => 0, :male => 1, :female => 2 }
|
|
27
32
|
|
|
33
|
+
# Every status in which a seat cannot be sold. Single source of truth for the `unavailable`
|
|
34
|
+
# scope (SQL) and `#unavailable?` (in-memory, for a caller that already preloaded the rows and
|
|
35
|
+
# cannot afford one query per row) -- kept as one list so a future status addition cannot update
|
|
36
|
+
# one and silently miss the other.
|
|
37
|
+
UNAVAILABLE_STATUSES = %w[reserved on_hold locked].freeze
|
|
38
|
+
|
|
28
39
|
store_private_metadata :lock_reason, :string
|
|
29
40
|
store_private_metadata :lock_note, :text
|
|
30
41
|
|
|
31
42
|
validates :status, presence: true
|
|
32
43
|
validates :expired_at, presence: true, if: -> { on_hold? }
|
|
33
|
-
validates :lock_reason, inclusion: { in: LOCK_REASONS }, allow_nil: true
|
|
34
44
|
validates :lock_reason, presence: true, if: -> { locked? }
|
|
35
45
|
validate :locked_rows_have_no_guest, if: -> { locked? }
|
|
36
46
|
|
|
37
47
|
scope :reserved_or_on_hold, -> { where(status: %i[reserved on_hold]).where('expired_at IS NULL OR expired_at > ?', Time.current) }
|
|
38
48
|
# Every state in which a seat cannot be sold. `reserved_or_on_hold` is kept as-is because other
|
|
39
49
|
# callers depend on its narrower meaning.
|
|
40
|
-
scope :unavailable, -> { where(status:
|
|
50
|
+
scope :unavailable, -> { where(status: UNAVAILABLE_STATUSES).where('expired_at IS NULL OR expired_at > ?', Time.current) }
|
|
41
51
|
scope :active_on_hold, -> { on_hold.where('expired_at IS NULL OR expired_at > ?', Time.current) }
|
|
42
52
|
scope :active, -> { where('expired_at IS NULL OR expired_at > ?', Time.current) }
|
|
43
53
|
|
|
@@ -49,6 +59,12 @@ module SpreeCmCommissioner
|
|
|
49
59
|
on_hold? && !expired?
|
|
50
60
|
end
|
|
51
61
|
|
|
62
|
+
# In-memory equivalent of the `unavailable` scope, for a caller iterating a preloaded
|
|
63
|
+
# association rather than querying -- see Block#current_reserved_block.
|
|
64
|
+
def unavailable?
|
|
65
|
+
status.in?(UNAVAILABLE_STATUSES) && !expired?
|
|
66
|
+
end
|
|
67
|
+
|
|
52
68
|
def payment_locked?
|
|
53
69
|
payment_locked_at.present?
|
|
54
70
|
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Test/abandon/fraud signals for one hold, shown in the admin inventory holds drawer. Every
|
|
3
|
+
# section is a single aggregate (COUNT ... FILTER) or a LIMITed pluck, so the query count is
|
|
4
|
+
# fixed no matter how many orders/holds the user or IP has.
|
|
5
|
+
class InventoryHoldInsightsQuery
|
|
6
|
+
RECENT_LIMIT = 10
|
|
7
|
+
# Shared with InventoryHoldOverviewQuery as a Postgres `~*` pattern, so keep it POSIX-compatible.
|
|
8
|
+
TEST_EMAIL_SOURCE = '(\+test|test@|@example\.com|@bookme\.local)'.freeze
|
|
9
|
+
TEST_EMAIL_PATTERN = Regexp.new(TEST_EMAIL_SOURCE, Regexp::IGNORECASE)
|
|
10
|
+
|
|
11
|
+
IP_BURST_5M = 5
|
|
12
|
+
IP_DISTINCT_USERS_24H = 3
|
|
13
|
+
USER_CONCURRENT_HOLDS = 2
|
|
14
|
+
USER_ABANDONED_NO_CONVERT = 3
|
|
15
|
+
LARGE_QUANTITY = 10
|
|
16
|
+
|
|
17
|
+
# Highest-severity flag kind wins the drawer's headline verdict.
|
|
18
|
+
VERDICT_ORDER = %i[fraud test abandon].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(hold:)
|
|
21
|
+
@hold = hold
|
|
22
|
+
@order = hold.order
|
|
23
|
+
@user = hold.user || hold.order&.user
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
result = {
|
|
28
|
+
hold: hold,
|
|
29
|
+
order: order,
|
|
30
|
+
user: user,
|
|
31
|
+
roles: roles,
|
|
32
|
+
user_orders: user_order_stats,
|
|
33
|
+
recent_orders: recent_orders,
|
|
34
|
+
user_holds: hold_stats(:user_id, user&.id),
|
|
35
|
+
ip_holds: hold_stats(:ip_address, hold.ip_address, distinct_users: true),
|
|
36
|
+
ip_recent_holds: ip_recent_holds
|
|
37
|
+
}
|
|
38
|
+
flags = flags(result)
|
|
39
|
+
result.merge(flags: flags, verdict: VERDICT_ORDER.find { |kind| flags[kind].any? } || :normal)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
attr_reader :hold, :order, :user
|
|
45
|
+
|
|
46
|
+
def roles
|
|
47
|
+
return [] unless user
|
|
48
|
+
|
|
49
|
+
Spree::Role.joins(:role_users).where(spree_role_users: { user_id: user.id }).pluck(:name)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def user_order_stats
|
|
53
|
+
empty = { completed: 0, incomplete: 0, last_completed_at: nil }
|
|
54
|
+
return empty unless user
|
|
55
|
+
|
|
56
|
+
completed, incomplete, last_completed_at = Spree::Order.where(user_id: user.id).pick(
|
|
57
|
+
Arel.sql('COUNT(*) FILTER (WHERE completed_at IS NOT NULL)'),
|
|
58
|
+
Arel.sql('COUNT(*) FILTER (WHERE completed_at IS NULL)'),
|
|
59
|
+
Arel.sql('MAX(completed_at)')
|
|
60
|
+
)
|
|
61
|
+
{ completed: completed.to_i, incomplete: incomplete.to_i, last_completed_at: last_completed_at }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def recent_orders
|
|
65
|
+
return [] unless user
|
|
66
|
+
|
|
67
|
+
Spree::Order.where(user_id: user.id).order(created_at: :desc).limit(RECENT_LIMIT)
|
|
68
|
+
.pluck(:number, :state, :payment_state, :total, :currency, :created_at, :completed_at)
|
|
69
|
+
.map { |row| %i[number state payment_state total currency created_at completed_at].zip(row).to_h }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Burst windows end at this hold's created_at so an old hold shows the burst it was part of.
|
|
73
|
+
def hold_stats(column, value, distinct_users: false)
|
|
74
|
+
keys = %i[total converted concurrent abandoned in_3m in_5m]
|
|
75
|
+
return keys.index_with(0).merge(distinct_users: 0, distinct_users_24h: 0) if value.blank?
|
|
76
|
+
|
|
77
|
+
relation = InventoryHold.where(column => value)
|
|
78
|
+
|
|
79
|
+
at = hold.created_at
|
|
80
|
+
selects = [
|
|
81
|
+
'COUNT(*)',
|
|
82
|
+
"COUNT(*) FILTER (WHERE status = #{InventoryHold.statuses[:converted]})",
|
|
83
|
+
"COUNT(*) FILTER (WHERE status IN (#{InventoryHold.statuses.values_at(:active, :payment_locked).join(',')}))",
|
|
84
|
+
"COUNT(*) FILTER (WHERE release_reason IN (#{InventoryHold.release_reasons.values_at(:hold_expired, :payment_timeout).join(',')}))",
|
|
85
|
+
window_count(at - 3.minutes, at),
|
|
86
|
+
window_count(at - 5.minutes, at)
|
|
87
|
+
]
|
|
88
|
+
if distinct_users
|
|
89
|
+
keys += %i[distinct_users distinct_users_24h]
|
|
90
|
+
selects << 'COUNT(DISTINCT user_id)'
|
|
91
|
+
selects << sanitize('COUNT(DISTINCT user_id) FILTER (WHERE created_at BETWEEN ? AND ?)', at - 24.hours, at)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
keys.zip(relation.pick(*selects.map { |sql| Arel.sql(sql) }).map(&:to_i)).to_h
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def window_count(from, to)
|
|
98
|
+
sanitize('COUNT(*) FILTER (WHERE created_at BETWEEN ? AND ?)', from, to)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def sanitize(sql, *binds)
|
|
102
|
+
InventoryHold.sanitize_sql_array([sql, *binds])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def ip_recent_holds
|
|
106
|
+
return [] if hold.ip_address.blank?
|
|
107
|
+
|
|
108
|
+
InventoryHold.where(ip_address: hold.ip_address).where.not(id: hold.id)
|
|
109
|
+
.left_joins(:order, :user).order(created_at: :desc).limit(RECENT_LIMIT)
|
|
110
|
+
.pluck(:id, :status, :created_at, 'spree_orders.number', 'spree_orders.state', 'spree_orders.payment_state', 'spree_users.email')
|
|
111
|
+
.map { |row| %i[id status created_at order_number order_state payment_state email].zip(row).to_h }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def flags(result)
|
|
115
|
+
{ test: test_flags(result[:roles]), abandon: abandon_flags(result), fraud: fraud_flags(result) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def test_flags(roles)
|
|
119
|
+
email = user&.email.presence || order&.email.to_s
|
|
120
|
+
[
|
|
121
|
+
('Test-like email' if email.match?(TEST_EMAIL_PATTERN)),
|
|
122
|
+
("Staff role: #{roles.join(', ')}" if roles.any?),
|
|
123
|
+
('Zero total order' if order&.total.to_d.zero?),
|
|
124
|
+
('Created by another user (dashboard)' if created_by_other_user?)
|
|
125
|
+
].compact
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def created_by_other_user?
|
|
129
|
+
order&.created_by_id.present? && order.created_by_id != order.user_id
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def abandon_flags(result)
|
|
133
|
+
holds = result[:user_holds]
|
|
134
|
+
orders = result[:user_orders]
|
|
135
|
+
[
|
|
136
|
+
("#{holds[:abandoned]} abandoned holds, 0 converted" if holds[:abandoned] >= USER_ABANDONED_NO_CONVERT && holds[:converted].zero?),
|
|
137
|
+
("#{orders[:incomplete]} incomplete orders, 0 completed" if orders[:incomplete].positive? && orders[:completed].zero?)
|
|
138
|
+
].compact
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def fraud_flags(result)
|
|
142
|
+
ip = result[:ip_holds]
|
|
143
|
+
[
|
|
144
|
+
("IP burst: #{ip[:in_5m]} holds in 5m" if ip[:in_5m] >= IP_BURST_5M),
|
|
145
|
+
("#{ip[:distinct_users_24h]} different users on this IP in 24h" if ip[:distinct_users_24h] >= IP_DISTINCT_USERS_24H),
|
|
146
|
+
("#{result[:user_holds][:concurrent]} holds open at once" if result[:user_holds][:concurrent] >= USER_CONCURRENT_HOLDS),
|
|
147
|
+
("Large hold: #{hold.total_quantity_on_hold} items" if hold.total_quantity_on_hold.to_i >= LARGE_QUANTITY)
|
|
148
|
+
].compact
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Aggregate test/abandon/fraud signals across a filtered set of holds, for the admin holds
|
|
3
|
+
# "Overall insights" drawer. Six grouped queries, each LIMITed, regardless of how many holds match.
|
|
4
|
+
class InventoryHoldOverviewQuery
|
|
5
|
+
TOP_LIMIT = 10
|
|
6
|
+
BURST_BINS = { '3m' => 180, '5m' => 300 }.freeze
|
|
7
|
+
|
|
8
|
+
# `holds` may carry ransack joins; wrapping it as an id subquery keeps the raw SQL below unambiguous.
|
|
9
|
+
def initialize(holds:)
|
|
10
|
+
@holds = InventoryHold.where(id: holds.reorder(nil).select(:id))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
{
|
|
15
|
+
funnel: funnel,
|
|
16
|
+
top_ips: top_ips,
|
|
17
|
+
bursts: BURST_BINS.transform_values { |seconds| bursts(seconds) },
|
|
18
|
+
abandoners: abandoners,
|
|
19
|
+
test_accounts: test_accounts
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :holds
|
|
26
|
+
|
|
27
|
+
def funnel
|
|
28
|
+
statuses = InventoryHold.statuses
|
|
29
|
+
reasons = InventoryHold.release_reasons
|
|
30
|
+
keys = %i[total converted open cart_abandoned payment_abandoned user_canceled cart_changed]
|
|
31
|
+
row = holds.pick(
|
|
32
|
+
Arel.sql('COUNT(*)'),
|
|
33
|
+
Arel.sql("COUNT(*) FILTER (WHERE status = #{statuses[:converted]})"),
|
|
34
|
+
Arel.sql("COUNT(*) FILTER (WHERE status IN (#{statuses.values_at(:pending, :active, :payment_locked).join(',')}))"),
|
|
35
|
+
*reasons.values_at(:hold_expired, :payment_timeout, :user_canceled, :cart_changed)
|
|
36
|
+
.map { |value| Arel.sql("COUNT(*) FILTER (WHERE release_reason = #{value})") }
|
|
37
|
+
)
|
|
38
|
+
result = keys.zip(row.map(&:to_i)).to_h
|
|
39
|
+
result.merge(conversion_rate: result[:total].zero? ? 0 : (100.0 * result[:converted] / result[:total]).round(1))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def top_ips
|
|
43
|
+
holds.where.not(ip_address: [nil, '']).group(:ip_address)
|
|
44
|
+
.order(Arel.sql('COUNT(*) DESC')).limit(TOP_LIMIT)
|
|
45
|
+
.pluck(:ip_address, Arel.sql('COUNT(*)'), Arel.sql('COUNT(DISTINCT user_id)'),
|
|
46
|
+
Arel.sql("COUNT(*) FILTER (WHERE status = #{InventoryHold.statuses[:converted]})")
|
|
47
|
+
)
|
|
48
|
+
.map { |row| %i[ip_address holds users converted].zip(row).to_h }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Fixed-width time buckets per IP; the busiest buckets are the bursts worth checking.
|
|
52
|
+
def bursts(seconds)
|
|
53
|
+
bucket = "to_timestamp(floor(extract(epoch FROM created_at) / #{seconds.to_i}) * #{seconds.to_i})"
|
|
54
|
+
holds.where.not(ip_address: [nil, '']).group(:ip_address, Arel.sql(bucket))
|
|
55
|
+
.having('COUNT(*) > 1').order(Arel.sql('COUNT(*) DESC')).limit(TOP_LIMIT)
|
|
56
|
+
.pluck(:ip_address, Arel.sql(bucket), Arel.sql('COUNT(*)'), Arel.sql('COUNT(DISTINCT user_id)'))
|
|
57
|
+
.map { |ip, started_at, count, users| { ip_address: ip, started_at: started_at&.in_time_zone, holds: count, users: users } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def abandoners
|
|
61
|
+
reasons = InventoryHold.release_reasons.values_at(:hold_expired, :payment_timeout).join(',')
|
|
62
|
+
abandoned = "COUNT(*) FILTER (WHERE cm_inventory_holds.release_reason IN (#{reasons}))"
|
|
63
|
+
holds.joins(:user).group(:user_id, 'spree_users.email')
|
|
64
|
+
.having("#{abandoned} > 0 AND COUNT(*) FILTER (WHERE cm_inventory_holds.status = #{InventoryHold.statuses[:converted]}) = 0")
|
|
65
|
+
.order(Arel.sql("#{abandoned} DESC")).limit(TOP_LIMIT)
|
|
66
|
+
.pluck(:user_id, 'spree_users.email', Arel.sql(abandoned), Arel.sql('COUNT(*)'))
|
|
67
|
+
.map { |row| %i[user_id email abandoned holds].zip(row).to_h }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_accounts
|
|
71
|
+
staff = 'EXISTS (SELECT 1 FROM spree_role_users ru WHERE ru.user_id = spree_users.id)'
|
|
72
|
+
holds.joins(:user)
|
|
73
|
+
.where("spree_users.email ~* ? OR #{staff}", InventoryHoldInsightsQuery::TEST_EMAIL_SOURCE)
|
|
74
|
+
.group(:user_id, 'spree_users.email').order(Arel.sql('COUNT(*) DESC')).limit(TOP_LIMIT)
|
|
75
|
+
.pluck(:user_id, 'spree_users.email', Arel.sql('COUNT(*)'), Arel.sql("bool_or(#{staff})"))
|
|
76
|
+
.map { |row| %i[user_id email holds staff].zip(row).to_h }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -166,6 +166,13 @@ module SpreeCmCommissioner::Integrations::Base
|
|
|
166
166
|
message: error.message,
|
|
167
167
|
code: error.class.name.split('::').last
|
|
168
168
|
}
|
|
169
|
+
# The scheduler reads this to honour Retry-After.
|
|
170
|
+
@error[:retry_after] = error.retry_after if error.respond_to?(:retry_after) && error.retry_after
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# True when the last recorded error is a 429 from the partner.
|
|
174
|
+
def rate_limited?
|
|
175
|
+
@error.present? && @error[:code] == 'RateLimitedError'
|
|
169
176
|
end
|
|
170
177
|
|
|
171
178
|
# Check if sync was successful
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/client.rb
CHANGED
|
@@ -41,7 +41,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
|
|
|
41
41
|
# @param destination_id [String] The destination location ID
|
|
42
42
|
# @param on_date [String] The travel date (YYYY-MM-DD format)
|
|
43
43
|
# @return [Array<SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::Trip>] Array of available trip objects
|
|
44
|
-
|
|
44
|
+
# @param lane [Symbol] :customer when a customer is waiting for the answer (see CallLimiter)
|
|
45
|
+
def search_trips!(origin_id:, destination_id:, on_date:, lane: :background)
|
|
45
46
|
params = {
|
|
46
47
|
origin_id: origin_id,
|
|
47
48
|
destination_id: destination_id,
|
|
@@ -53,7 +54,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
|
|
|
53
54
|
}.compact
|
|
54
55
|
endpoint = "#{BASE_PATH}/trip_availabilities"
|
|
55
56
|
|
|
56
|
-
response = get(endpoint, params)
|
|
57
|
+
response = get(endpoint, params, nil, lane: lane)
|
|
57
58
|
handle_response(response, SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::Trip, collection: true)
|
|
58
59
|
end
|
|
59
60
|
|
|
@@ -66,7 +67,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
|
|
|
66
67
|
# @param on_date [String] The travel date (YYYY-MM-DD format)
|
|
67
68
|
# @param show_all_stops [Boolean] Whether to show all stops (default: false)
|
|
68
69
|
# @return [SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::SeatLayout] Seat layout object
|
|
69
|
-
|
|
70
|
+
# @param lane [Symbol] :customer when a customer is waiting for the answer (see CallLimiter)
|
|
71
|
+
def get_seat_layout!(trip_id:, from_stop_id:, to_stop_id:, on_date:, show_all_stops: false, lane: :background) # rubocop:disable Metrics/ParameterLists
|
|
70
72
|
params = {
|
|
71
73
|
trip_id: trip_id,
|
|
72
74
|
from_stop_id: from_stop_id,
|
|
@@ -76,7 +78,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
|
|
|
76
78
|
}
|
|
77
79
|
endpoint = "#{BASE_PATH}/seat_layouts"
|
|
78
80
|
|
|
79
|
-
response = get(endpoint, params)
|
|
81
|
+
response = get(endpoint, params, nil, lane: lane)
|
|
80
82
|
handle_response(response, SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::SeatLayout)
|
|
81
83
|
end
|
|
82
84
|
|
|
@@ -125,7 +127,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
|
|
|
125
127
|
request: { method: 'POST', endpoint: endpoint, body: body },
|
|
126
128
|
auditable: params[:internal_order]
|
|
127
129
|
) do
|
|
128
|
-
response = post(endpoint, body)
|
|
130
|
+
response = post(endpoint, body, lane: :checkout)
|
|
129
131
|
handle_response(response, SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::ReservationCart)
|
|
130
132
|
end
|
|
131
133
|
end
|
|
@@ -142,7 +144,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
|
|
|
142
144
|
request: { method: 'POST', endpoint: endpoint, body: {} },
|
|
143
145
|
auditable: internal_order
|
|
144
146
|
) do
|
|
145
|
-
response = post(endpoint, {})
|
|
147
|
+
response = post(endpoint, {}, lane: :checkout)
|
|
146
148
|
handle_response(response, SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::WalletPayment)
|
|
147
149
|
end
|
|
148
150
|
end
|
|
@@ -158,7 +160,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
|
|
|
158
160
|
request: { method: 'POST', endpoint: endpoint, body: {} },
|
|
159
161
|
auditable: internal_order
|
|
160
162
|
) do
|
|
161
|
-
response = post(endpoint, {})
|
|
163
|
+
response = post(endpoint, {}, lane: :checkout)
|
|
162
164
|
handle_response(response, SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::WalletPayment)
|
|
163
165
|
end
|
|
164
166
|
end
|
|
@@ -216,6 +218,12 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
|
|
|
216
218
|
end
|
|
217
219
|
|
|
218
220
|
def raise_api_error(response)
|
|
221
|
+
raise_rate_limited(response) if response.status == 429
|
|
222
|
+
|
|
223
|
+
raise_failed_request(response)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def raise_failed_request(response)
|
|
219
227
|
status = response.status
|
|
220
228
|
body = response.body
|
|
221
229
|
|
|
@@ -237,5 +245,14 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
|
|
|
237
245
|
|
|
238
246
|
raise SpreeCmCommissioner::Integrations::ExternalClientError.new(message, status)
|
|
239
247
|
end
|
|
248
|
+
|
|
249
|
+
def raise_rate_limited(response)
|
|
250
|
+
retry_after = SpreeCmCommissioner::Integrations::RateLimitedError.parse_retry_after(response.headers['Retry-After'])
|
|
251
|
+
error = SpreeCmCommissioner::Integrations::RateLimitedError.new('API request failed with status 429', retry_after: retry_after)
|
|
252
|
+
|
|
253
|
+
# Everything but checkout stops calling the partner until its Retry-After has passed.
|
|
254
|
+
call_limiter.pause!(error.retry_after)
|
|
255
|
+
raise error
|
|
256
|
+
end
|
|
240
257
|
end
|
|
241
258
|
end
|