spree_cm_commissioner 2.15.1 → 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/jobs/spree_cm_commissioner/seats/auto_assign_job.rb +16 -0
- data/app/models/concerns/spree_cm_commissioner/line_item_seat_selection.rb +25 -3
- data/app/models/concerns/spree_cm_commissioner/order_seat_auto_assignable.rb +73 -0
- data/app/models/concerns/spree_cm_commissioner/product_seat_auto_assignable.rb +29 -0
- data/app/models/spree_cm_commissioner/block.rb +33 -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/line_item_decorator.rb +4 -0
- data/app/models/spree_cm_commissioner/order_decorator.rb +17 -1
- data/app/models/spree_cm_commissioner/product_decorator.rb +1 -0
- data/app/models/spree_cm_commissioner/reserved_block.rb +19 -3
- data/app/models/spree_cm_commissioner/seats/auto_assign_validator.rb +63 -0
- data/app/overrides/spree/admin/products/_form/auto_assign_seats.html.erb.deface +14 -0
- 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/queries/spree_cm_commissioner/seats/free_runs_query.rb +73 -0
- data/app/serializers/spree/v2/storefront/line_item_serializer_decorator.rb +1 -1
- data/app/serializers/spree/v2/storefront/product_serializer_decorator.rb +1 -1
- data/app/serializers/spree/v2/tenant/line_item_serializer.rb +1 -1
- 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/order_holds/release.rb +3 -1
- data/app/services/spree_cm_commissioner/seat_layouts/save_seat_order.rb +111 -0
- data/app/services/spree_cm_commissioner/seats/apply_locks.rb +13 -7
- data/app/services/spree_cm_commissioner/seats/auto_assign.rb +135 -0
- data/app/services/spree_cm_commissioner/seats/block_date_iteration.rb +5 -2
- 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/pick_seats.rb +79 -0
- 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/db/migrate/20261106000001_add_seat_index_to_cm_blocks.rb +16 -0
- data/db/migrate/20261110000001_add_seat_assignment_status_to_spree_line_items.rb +11 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +29 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32502c54cc175c2fc2c4d6c86d97385fe1c02192ddfa240e6ac28b043fc923c3
|
|
4
|
+
data.tar.gz: be2a8f37d0f078acd74e6e16155928065a2915b868525c01ae01232dcc8d9e39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eddc350347e96f820443be5e8e9524cab22623c8c1f1cd16241a86d5d1e851e206e264328d1c9734b427243682f8bffcdacb1215fb0647b472071d2c5484429b
|
|
7
|
+
data.tar.gz: 767d8e127f3cd3ddb498e0fe3237571acb753b6481480531d4094500b4d58e5f60d360184475f951c75217d6c2eb91b2b1e3862fd83b2ba140dcc35de81e880e
|
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.3)
|
|
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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Seats
|
|
3
|
+
class AutoAssignJob < ApplicationJob
|
|
4
|
+
def perform(options = {})
|
|
5
|
+
order = Spree::Order.find(options[:order_id])
|
|
6
|
+
SpreeCmCommissioner::Seats::AutoAssign.call(order: order)
|
|
7
|
+
|
|
8
|
+
# Lets the vpago processing page move past "assigning your seats" (Order#pending_completion_steps),
|
|
9
|
+
# once the assignment has committed. Every outcome counts as done: the party's tickets are valid
|
|
10
|
+
# either way, and a split or unseated party shows on the line item. A failed write raises, so the
|
|
11
|
+
# retry re-sends it; re-seating is a no-op.
|
|
12
|
+
order.user_informer.notify_step(:seats_assigning, status: :done)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -4,6 +4,15 @@ module SpreeCmCommissioner
|
|
|
4
4
|
|
|
5
5
|
included do
|
|
6
6
|
before_create :set_required_seat_selection_flag
|
|
7
|
+
|
|
8
|
+
# Where an auto-assign party is in seat assignment; nil for every other line item.
|
|
9
|
+
#
|
|
10
|
+
# pending — paid, waiting for Seats::AutoAssignJob (set by OrderSeatAutoAssignable)
|
|
11
|
+
# together / split / unseated — the job's outcome (Seats::AutoAssign)
|
|
12
|
+
#
|
|
13
|
+
# A column so parties can be queried by it (e.g. split ones for the organizer). The app reads it
|
|
14
|
+
# only for a guest without a seat_number yet: after a resume, one party can hold both.
|
|
15
|
+
enum seat_assignment_status: { pending: 0, together: 1, split: 2, unseated: 3 }, _prefix: :seat_assignment
|
|
7
16
|
end
|
|
8
17
|
|
|
9
18
|
# Caches the required_seat_selection flag from the trip in private_metadata.
|
|
@@ -24,16 +33,29 @@ module SpreeCmCommissioner
|
|
|
24
33
|
ActiveModel::Type::Boolean.new.cast(private_metadata['required_seat_selection'])
|
|
25
34
|
end
|
|
26
35
|
|
|
36
|
+
# Cached at creation alongside required_seat_selection, and mutually exclusive with it: the
|
|
37
|
+
# customer either picks seats or Seats::AutoAssignJob assigns them once the order completes.
|
|
38
|
+
# Flipping the product flag mid-sale therefore never changes how an existing order completes.
|
|
39
|
+
def auto_assign_seats?
|
|
40
|
+
ActiveModel::Type::Boolean.new.cast(private_metadata['auto_assign_seats']) == true
|
|
41
|
+
end
|
|
42
|
+
|
|
27
43
|
private
|
|
28
44
|
|
|
29
45
|
def set_required_seat_selection_flag
|
|
30
46
|
return if private_metadata.key?('required_seat_selection')
|
|
31
47
|
return if product.nil?
|
|
32
48
|
|
|
33
|
-
|
|
34
|
-
|
|
49
|
+
has_blocks = variant.blocks.any?
|
|
50
|
+
auto_assign = product.auto_assign_seats? && has_blocks
|
|
51
|
+
|
|
52
|
+
private_metadata['auto_assign_seats'] = auto_assign
|
|
53
|
+
private_metadata['required_seat_selection'] = if auto_assign
|
|
54
|
+
false
|
|
55
|
+
elsif product.trip.present?
|
|
56
|
+
product.trip.allow_seat_selection && has_blocks
|
|
35
57
|
else
|
|
36
|
-
|
|
58
|
+
has_blocks
|
|
37
59
|
end
|
|
38
60
|
end
|
|
39
61
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Order side of seat auto-assign: hands paid orders to Seats::AutoAssignJob and, on resume, sends
|
|
3
|
+
# guests whose seat was resold meanwhile back to it.
|
|
4
|
+
#
|
|
5
|
+
# Include before OrderStateMachine: its before_transition to :resumed must run ahead of that
|
|
6
|
+
# concern's around_transition unstock_inventory!, whose reserve_order_holds! re-reserves the seats
|
|
7
|
+
# kept here (state_machines runs before and around callbacks in definition order).
|
|
8
|
+
module OrderSeatAutoAssignable
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
state_machine.before_transition to: :resumed, do: :unseat_guests_with_resold_seats!, if: :auto_assign_seats?
|
|
13
|
+
|
|
14
|
+
# Seats are assigned in the background: payment and stock are final by now, and choosing seats
|
|
15
|
+
# inside completion would hold its locks while buyers compete for them. Runs inside the
|
|
16
|
+
# transition's transaction on purpose: `pending` rolls back with a failed completion, and
|
|
17
|
+
# Seats::AutoAssign row-locks this order before seating anyone, so the job waits for the
|
|
18
|
+
# transition to commit, and skips it on rollback.
|
|
19
|
+
state_machine.after_transition to: %i[complete resumed], do: :queue_seat_auto_assign, if: :auto_assign_seats?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def auto_assign_seats?
|
|
23
|
+
line_items.any?(&:auto_assign_seats?)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Keeps the vpago processing page on "assigning your seats" until Seats::AutoAssignJob finishes;
|
|
27
|
+
# appended to Order#pending_completion_steps by OrderDecorator.
|
|
28
|
+
def seat_assignment_pending?
|
|
29
|
+
line_items.seat_assignment_pending.exists?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Only parties with someone left to seat: on resume, a party whose seats were all kept keeps its
|
|
35
|
+
# outcome, since the job would find nobody to seat and never move it off `pending`.
|
|
36
|
+
def queue_seat_auto_assign
|
|
37
|
+
waiting = line_items.select(&:auto_assign_seats?).select { |line_item| line_item.guests.exists?(block_id: nil) }
|
|
38
|
+
return if waiting.empty?
|
|
39
|
+
|
|
40
|
+
Spree::LineItem.where(id: waiting.map(&:id)).update_all(seat_assignment_status: :pending) # rubocop:disable Rails/SkipsModelValidations
|
|
41
|
+
SpreeCmCommissioner::Seats::AutoAssignJob.perform_later(order_id: id)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Cancelling released the seat rows but left guest.block_id pointing at them, and they may have
|
|
45
|
+
# been resold since. A guest whose seat was resold is unseated here, which queue_seat_auto_assign
|
|
46
|
+
# then hands to Seats::AutoAssignJob; the rest keep their seat, which
|
|
47
|
+
# reserve_order_holds! re-reserves in this same transition.
|
|
48
|
+
def unseat_guests_with_resold_seats!
|
|
49
|
+
line_items.select(&:auto_assign_seats?).each do |line_item|
|
|
50
|
+
inventory_item = line_item.inventory_items.first
|
|
51
|
+
next if inventory_item.nil?
|
|
52
|
+
|
|
53
|
+
line_item.guests.where.not(block_id: nil).find_each do |guest|
|
|
54
|
+
guest.update!(block_id: nil) if resold?(guest, inventory_item)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Guest#preload_order_block_ids refreshed another instance of this order; the transition's
|
|
59
|
+
# save of this one would write the stale list back.
|
|
60
|
+
self.preload_block_ids = blocks.pluck(:id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Row-locked until the transition commits, so Seats::AutoAssign cannot give a kept seat to
|
|
64
|
+
# another order before reserve_order_holds! re-reserves it. A locked row has no guest, so an
|
|
65
|
+
# operator lock counts as resold too.
|
|
66
|
+
def resold?(guest, inventory_item)
|
|
67
|
+
row = SpreeCmCommissioner::ReservedBlock.lock.find_by(inventory_item_id: inventory_item.id, block_id: guest.block_id)
|
|
68
|
+
return false if row.nil?
|
|
69
|
+
|
|
70
|
+
(row.reserved? || row.locked? || row.active_on_hold?) && row.guest_id != guest.id
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Seats are picked by the server once the order completes, instead of by the customer. Turning it
|
|
3
|
+
# on is guarded by Seats::AutoAssignValidator so the product can never sell more units than there
|
|
4
|
+
# are linked seats to hand out.
|
|
5
|
+
#
|
|
6
|
+
# Needs StoreMetadata included first.
|
|
7
|
+
module ProductSeatAutoAssignable
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
store_public_metadata :auto_assign_seats, :boolean, default: false
|
|
12
|
+
|
|
13
|
+
validates_with SpreeCmCommissioner::Seats::AutoAssignValidator, if: :enabling_auto_assign_seats?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Only on the off → on flip: re-checking on every later save would block unrelated edits mid-sale
|
|
19
|
+
# whenever stock and seats briefly disagree (the async DB stock sync lags Redis).
|
|
20
|
+
def enabling_auto_assign_seats?
|
|
21
|
+
return false unless auto_assign_seats?
|
|
22
|
+
|
|
23
|
+
previous = attribute_in_database(:public_metadata)
|
|
24
|
+
previous = {} unless previous.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
!ActiveModel::Type::Boolean.new.cast(previous['auto_assign_seats'])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -26,6 +26,12 @@ module SpreeCmCommissioner
|
|
|
26
26
|
validates :rotation, presence: true, numericality: { greater_than_or_equal_to: -360, less_than_or_equal_to: 360 }
|
|
27
27
|
validates :shape_path, format: { with: /\A\s*[Mm]/, message: 'must start with a move command' }, allow_blank: true # rubocop:disable Rails/I18nLocaleTexts
|
|
28
28
|
|
|
29
|
+
# Position in its ticket type's seat order on this layout (written only by
|
|
30
|
+
# SeatLayouts::SaveSeatOrder): consecutive numbers are side by side, a skipped number is a break,
|
|
31
|
+
# lower sells first.
|
|
32
|
+
validates :seat_index, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
|
|
33
|
+
before_validation :clear_seat_index, if: :leaves_seat_order?
|
|
34
|
+
|
|
29
35
|
validate :cannot_unassign_variant_with_active_blocks
|
|
30
36
|
|
|
31
37
|
before_validation :assign_layout_from_section, if: -> { seat_layout.nil? && seat_section.present? }
|
|
@@ -54,12 +60,39 @@ module SpreeCmCommissioner
|
|
|
54
60
|
self.class.sellable?(block_type)
|
|
55
61
|
end
|
|
56
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
|
+
|
|
57
79
|
def assign_layout_from_section
|
|
58
80
|
self.seat_layout = seat_section.seat_layout
|
|
59
81
|
end
|
|
60
82
|
|
|
61
83
|
private
|
|
62
84
|
|
|
85
|
+
# A seat that changes ticket type, or stops being a seat, leaves its row: its number belongs to its
|
|
86
|
+
# old type's order. Cleared rather than rejected, so a layout builder edit never fails over Seat
|
|
87
|
+
# order; the gap reads as a break, and Seats::AutoAssignValidator reports the seat as unlinked.
|
|
88
|
+
def leaves_seat_order?
|
|
89
|
+
seat_index.present? && (!seatable? || (persisted? && variant_id_changed?))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def clear_seat_index
|
|
93
|
+
self.seat_index = nil
|
|
94
|
+
end
|
|
95
|
+
|
|
63
96
|
# Deleting a seat would cascade into its locked rows, dropping the deduction without giving the
|
|
64
97
|
# quantity back. Unlock first.
|
|
65
98
|
#
|
|
@@ -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
|
|
@@ -164,6 +164,10 @@ module SpreeCmCommissioner
|
|
|
164
164
|
return false unless variant.should_track_inventory?
|
|
165
165
|
return false if required_seat_selection?
|
|
166
166
|
|
|
167
|
+
# The count hold is what guarantees an auto-assigned seat exists at completion, so these lines
|
|
168
|
+
# take it regardless of the product's enable_inventory_hold setting.
|
|
169
|
+
return true if auto_assign_seats?
|
|
170
|
+
|
|
167
171
|
return true if ENV.fetch('INVENTORY_HOLD_ENABLED', nil) == 'true'
|
|
168
172
|
return true if product.enable_inventory_hold?
|
|
169
173
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
module SpreeCmCommissioner
|
|
2
|
-
module OrderDecorator
|
|
2
|
+
module OrderDecorator # rubocop:disable Metrics/ModuleLength
|
|
3
3
|
def self.prepended(base) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
4
4
|
base.include SpreeCmCommissioner::OrderChannelBitwise
|
|
5
5
|
base.include SpreeCmCommissioner::StoreMetadata
|
|
6
6
|
base.include SpreeCmCommissioner::PhoneNumberSanitizer
|
|
7
7
|
base.include SpreeCmCommissioner::OrderIntegration
|
|
8
8
|
base.include SpreeCmCommissioner::OrderHoldable
|
|
9
|
+
base.include SpreeCmCommissioner::OrderSeatAutoAssignable
|
|
9
10
|
base.include SpreeCmCommissioner::OrderStateMachine
|
|
10
11
|
base.include SpreeCmCommissioner::RouteOrderCountable
|
|
11
12
|
base.include SpreeCmCommissioner::OrderScopes
|
|
@@ -88,6 +89,15 @@ module SpreeCmCommissioner
|
|
|
88
89
|
end
|
|
89
90
|
end
|
|
90
91
|
|
|
92
|
+
# override: Spree::Api::Webhooks::OrderDecorator#after_resume ends with
|
|
93
|
+
# `self.state_machine_resumed = false`, and state_machines halts the after_transition chain when
|
|
94
|
+
# a callback returns false — so every `after_transition to: :resumed` registered after core's
|
|
95
|
+
# (OrderStateMachine's precalculate_conversion, Seats::AutoAssignJob) silently never ran.
|
|
96
|
+
def after_resume
|
|
97
|
+
super
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
91
101
|
# override: imported orders are bulk-loaded from CSV and the contact email
|
|
92
102
|
# often belongs to a third party (organizer, importer) rather than the buyer,
|
|
93
103
|
# so the cancellation email would notify the wrong recipient. Normal orders
|
|
@@ -191,6 +201,12 @@ module SpreeCmCommissioner
|
|
|
191
201
|
super
|
|
192
202
|
end
|
|
193
203
|
|
|
204
|
+
# override Vpago::OrderDecorator. Must live here, not in OrderSeatAutoAssignable: vpago's decorator
|
|
205
|
+
# is prepended above the included concerns, so a definition there would never be reached.
|
|
206
|
+
def pending_completion_steps
|
|
207
|
+
seat_assignment_pending? ? super + [:seats_assigning] : super
|
|
208
|
+
end
|
|
209
|
+
|
|
194
210
|
# Must live here, not in OrderStateMachine: that concern is included (below Spree::Order),
|
|
195
211
|
# while Spree::Emails::OrderDecorator is prepended above it and never calls super, so an
|
|
196
212
|
# override there is silently dead.
|
|
@@ -11,6 +11,7 @@ module SpreeCmCommissioner
|
|
|
11
11
|
base.include SpreeCmCommissioner::StoreMetadata
|
|
12
12
|
base.include SpreeCmCommissioner::HomepageSectionRelatableConcern
|
|
13
13
|
base.include SpreeCmCommissioner::PurchaseLimitable
|
|
14
|
+
base.include SpreeCmCommissioner::ProductSeatAutoAssignable
|
|
14
15
|
|
|
15
16
|
base.delegate :is_open_dated, :is_open_dated?, to: :trip, allow_nil: true
|
|
16
17
|
|
|
@@ -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,63 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Seats
|
|
3
|
+
# Guards switching a product's auto_assign_seats on: every seat is linked in Seat order, and there is
|
|
4
|
+
# a free seat for every ticket still to be sold. Checks each variant that has seats on an active
|
|
5
|
+
# layout; every problem becomes one error on :auto_assign_seats.
|
|
6
|
+
#
|
|
7
|
+
# validates_with SpreeCmCommissioner::Seats::AutoAssignValidator
|
|
8
|
+
class AutoAssignValidator < ActiveModel::Validator
|
|
9
|
+
def validate(product)
|
|
10
|
+
seated = product.variants_including_master.filter_map do |variant|
|
|
11
|
+
seats = seats_for(variant)
|
|
12
|
+
[variant, seats] if seats.exists?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
return product.errors.add(:auto_assign_seats, 'has no seats on an active seat layout to assign') if seated.empty?
|
|
16
|
+
|
|
17
|
+
seated.each do |variant, seats|
|
|
18
|
+
[unlinked_seats_error(variant, seats), *not_enough_seats_errors(variant, seats)].compact.each do |message|
|
|
19
|
+
product.errors.add(:auto_assign_seats, message)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def seats_for(variant)
|
|
27
|
+
SpreeCmCommissioner::Block.joins(:seat_layout)
|
|
28
|
+
.where(variant_id: variant.id, block_type: %i[seat sleeping_seat])
|
|
29
|
+
.where(cm_seat_layouts: { status: SpreeCmCommissioner::SeatLayout.statuses[:active] })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A seat left out of Seat order (no seat_index) is never handed out, while its ticket still sells.
|
|
33
|
+
#
|
|
34
|
+
# 20 seats, 18 linked → 'VIP: 2 seats are not linked in Seat order'
|
|
35
|
+
def unlinked_seats_error(variant, seats)
|
|
36
|
+
count = seats.where(seat_index: nil).count
|
|
37
|
+
return if count.zero?
|
|
38
|
+
|
|
39
|
+
"#{variant.descriptive_name}: #{count} #{count == 1 ? 'seat is' : 'seats are'} not linked in Seat order"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Every ticket still to be sold needs a free seat, per date (one undated item for an event).
|
|
43
|
+
# Fewer seats than tickets means a paid guest with no seat; more is fine, the extra stays empty.
|
|
44
|
+
#
|
|
45
|
+
# Tickets still to be sold: public stock + public carts + private-sale pool + its carts. A seat
|
|
46
|
+
# locked with Seats::LockBlocks is in neither side: it already lowered quantity_available, and its
|
|
47
|
+
# ReservedBlock marks it taken.
|
|
48
|
+
#
|
|
49
|
+
# 100 seats, 5 taken, 98 tickets to sell → 'VIP on 2026-12-01: 98 tickets can still be sold but only 95 seats are free'
|
|
50
|
+
def not_enough_seats_errors(variant, seats)
|
|
51
|
+
variant.inventory_items.active.filter_map do |item|
|
|
52
|
+
taken = SpreeCmCommissioner::ReservedBlock.unavailable.where(inventory_item_id: item.id).select(:block_id)
|
|
53
|
+
free = seats.where.not(id: taken).count
|
|
54
|
+
to_sell = item.quantity_available + item.quantity_on_hold + item.quantity_locked + item.quantity_locked_on_hold
|
|
55
|
+
next if free >= to_sell
|
|
56
|
+
|
|
57
|
+
on = item.inventory_date ? " on #{item.inventory_date}" : ''
|
|
58
|
+
"#{variant.descriptive_name}#{on}: #{to_sell} tickets can still be sold but only #{free} seats are free"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
|
|
2
|
+
|
|
3
|
+
<div>
|
|
4
|
+
<%= f.field_container :auto_assign_seats do %>
|
|
5
|
+
<%= f.label :auto_assign_seats do %>
|
|
6
|
+
<%= f.check_box :auto_assign_seats %>
|
|
7
|
+
Auto-assign seats
|
|
8
|
+
<% end %>
|
|
9
|
+
<%= f.error_message_on :auto_assign_seats %>
|
|
10
|
+
<small class="form-text text-muted">
|
|
11
|
+
Customers don't pick seats; each paid order gets the best free seats together, following the layout's Seat order. Can only be switched on once every seat of this product is linked in Seat order.
|
|
12
|
+
</small>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|