spree_cm_commissioner 2.15.1 → 2.15.2
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/Gemfile.lock +1 -1
- 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 +17 -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/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/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/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/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/pick_seats.rb +79 -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 +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5538af5130244a626ceca3f818410469c93ea7975f5cbc51b212b7536361b8e
|
|
4
|
+
data.tar.gz: 4e272e66836c76b1296aad6a65488cd17e3ef9517a4a8112594047875de4a185
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 80bc600859e7bdf199590d0a5e7982b9b03bbef4ddc9b19e03fbbbe6be76ceca11817687b1d358db20be3b9b7665154d9a05db59412e690448982cf70f543e4d
|
|
7
|
+
data.tar.gz: 646dea4a475811f6452137087b603d3bb36b6832bd6550339713b84a82f7ee0c71054b81632aebd4ddd041eef14ceca479402e679b0ab49fbf40d87f0e69ec15
|
data/Gemfile.lock
CHANGED
|
@@ -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? }
|
|
@@ -60,6 +66,17 @@ module SpreeCmCommissioner
|
|
|
60
66
|
|
|
61
67
|
private
|
|
62
68
|
|
|
69
|
+
# A seat that changes ticket type, or stops being a seat, leaves its row: its number belongs to its
|
|
70
|
+
# old type's order. Cleared rather than rejected, so a layout builder edit never fails over Seat
|
|
71
|
+
# order; the gap reads as a break, and Seats::AutoAssignValidator reports the seat as unlinked.
|
|
72
|
+
def leaves_seat_order?
|
|
73
|
+
seat_index.present? && (!seatable? || (persisted? && variant_id_changed?))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def clear_seat_index
|
|
77
|
+
self.seat_index = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
63
80
|
# Deleting a seat would cascade into its locked rows, dropping the deduction without giving the
|
|
64
81
|
# quantity back. Unlock first.
|
|
65
82
|
#
|
|
@@ -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
|
|
|
@@ -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>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Seats
|
|
3
|
+
# A variant's free seats, grouped into runs of side-by-side seats for auto-assign.
|
|
4
|
+
#
|
|
5
|
+
# Walks the variant's linked seats in Block#seat_index order (see SeatLayouts::SaveSeatOrder), one
|
|
6
|
+
# layout at a time since each layout is numbered on its own:
|
|
7
|
+
# - a skipped number is the organizer's row break, so it starts the next stroke (0, 1, 2… in sell
|
|
8
|
+
# order) and ends the run;
|
|
9
|
+
# - a taken seat ends the run too, so a run is always seats a party can sit in together.
|
|
10
|
+
#
|
|
11
|
+
# seat_index: 1 2 3 5 6 7 (4 skipped: row break)
|
|
12
|
+
# taken: ✗
|
|
13
|
+
# runs: stroke 0 [1, 2], stroke 1 [5, 6, 7]
|
|
14
|
+
#
|
|
15
|
+
# Only active layouts count: an inactive/archived layout's seats are not on sale.
|
|
16
|
+
class FreeRunsQuery
|
|
17
|
+
Run = Struct.new(:seat_layout_id, :stroke, :start_index, :block_ids, keyword_init: true) do
|
|
18
|
+
delegate :size, to: :block_ids
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(variant_id:, inventory_item_id:)
|
|
22
|
+
@variant_id = variant_id
|
|
23
|
+
@inventory_item_id = inventory_item_id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
seats = linked_seats.pluck(:id, :seat_layout_id, :seat_index)
|
|
28
|
+
taken = ReservedBlock.unavailable.where(inventory_item_id: @inventory_item_id, block_id: linked_seats.select(:id))
|
|
29
|
+
.pluck(:block_id).to_set
|
|
30
|
+
|
|
31
|
+
seats.group_by { |_id, seat_layout_id, _index| seat_layout_id }.flat_map do |seat_layout_id, layout_seats|
|
|
32
|
+
runs_in(seat_layout_id, layout_seats, taken)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def linked_seats
|
|
39
|
+
Block.joins(:seat_layout)
|
|
40
|
+
.where(variant_id: @variant_id, cm_seat_layouts: { status: SeatLayout.statuses[:active] })
|
|
41
|
+
.where.not(seat_index: nil)
|
|
42
|
+
.order(:seat_layout_id, :seat_index)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def runs_in(seat_layout_id, seats, taken)
|
|
46
|
+
runs = []
|
|
47
|
+
stroke = 0
|
|
48
|
+
run = nil
|
|
49
|
+
|
|
50
|
+
seats.each_with_index do |(id, _seat_layout_id, index), i|
|
|
51
|
+
if i.positive? && index != seats[i - 1][2] + 1
|
|
52
|
+
stroke += 1
|
|
53
|
+
run = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if taken.include?(id)
|
|
57
|
+
run = nil
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if run.nil?
|
|
62
|
+
run = Run.new(seat_layout_id: seat_layout_id, stroke: stroke, start_index: index, block_ids: [])
|
|
63
|
+
runs << run
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
run.block_ids << id
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
runs
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -11,7 +11,7 @@ module Spree
|
|
|
11
11
|
:high_demand, :jwt_token,
|
|
12
12
|
:direction, :trip_id, :boarding_trip_stop_id, :drop_off_trip_stop_id,
|
|
13
13
|
:service_fee_total, :display_service_fee_total,
|
|
14
|
-
:parent_id, :relation_type, :bundle_amount, :display_bundle_amount
|
|
14
|
+
:parent_id, :relation_type, :bundle_amount, :display_bundle_amount, :seat_assignment_status
|
|
15
15
|
|
|
16
16
|
base.attribute :required_self_check_in_location, &:required_self_check_in_location?
|
|
17
17
|
base.attribute :allowed_self_check_in, &:allowed_self_check_in?
|
|
@@ -19,7 +19,7 @@ module Spree
|
|
|
19
19
|
:use_video_as_default, :allow_transfer, :allow_gift_transfer
|
|
20
20
|
|
|
21
21
|
base.attributes :reveal_description, :discontinue_on, :public_metadata, :purchasable_on, :preview,
|
|
22
|
-
:enable_inventory_hold
|
|
22
|
+
:enable_inventory_hold, :auto_assign_seats
|
|
23
23
|
|
|
24
24
|
# Expose only the `event_id` here instead of the full event object.
|
|
25
25
|
# This lets the client fetch event details separately (usually already cached),
|
|
@@ -12,7 +12,7 @@ module Spree
|
|
|
12
12
|
:completion_steps, :available_social_contact_platforms, :allow_anonymous_booking,
|
|
13
13
|
:discontinue_on, :high_demand, :jwt_token, :pre_tax_amount, :display_pre_tax_amount, :public_metadata,
|
|
14
14
|
:direction, :trip_id, :boarding_trip_stop_id, :drop_off_trip_stop_id, :remark,
|
|
15
|
-
:service_fee_total, :display_service_fee_total
|
|
15
|
+
:service_fee_total, :display_service_fee_total, :seat_assignment_status
|
|
16
16
|
|
|
17
17
|
attribute :required_self_check_in_location, &:required_self_check_in_location?
|
|
18
18
|
attribute :allowed_self_check_in, &:allowed_self_check_in?
|
|
@@ -41,7 +41,9 @@ module SpreeCmCommissioner
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def cancel_seat_blocks!(order)
|
|
44
|
-
|
|
44
|
+
# auto_assign_seats?: Seats::AutoAssignJob may have seated the guests after this order
|
|
45
|
+
# instance was loaded, leaving its preload_block_ids stale and empty.
|
|
46
|
+
return unless order.should_manage_blocks? || order.auto_assign_seats?
|
|
45
47
|
|
|
46
48
|
result = SpreeCmCommissioner::ReservedBlocks::Cancel.call!(
|
|
47
49
|
order_id: order.id,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module SeatLayouts
|
|
3
|
+
# Persists an organizer's seat order from the "Seat order" page.
|
|
4
|
+
#
|
|
5
|
+
# Strokes arrive as ordered arrays of block ids. Each stroke is one row of side-by-side seats of a
|
|
6
|
+
# single ticket type (variant), and a variant's stroke order is its sell order — the order between
|
|
7
|
+
# strokes of different variants means nothing, since auto-assign only ever ranks one variant's
|
|
8
|
+
# seats. A seat with no variant can never be sold, so it has no place in the order.
|
|
9
|
+
#
|
|
10
|
+
# Each variant's strokes are flattened into its own Block#seat_index sequence: consecutive inside
|
|
11
|
+
# a stroke, one number skipped between strokes, so a single integer answers both "are these two
|
|
12
|
+
# seats together?" (differ by exactly 1) and "which sells first?" (lower). Numbering per variant
|
|
13
|
+
# keeps one type's edits from renumbering another's. The Seat order page rebuilds the strokes by
|
|
14
|
+
# grouping by variant and splitting at the skipped numbers, so it must agree with `indexes_for`.
|
|
15
|
+
#
|
|
16
|
+
# Replaces the whole layout's order on every save. Assignments live on ReservedBlock and
|
|
17
|
+
# Guest#block_id, never on seat_index, so re-saving mid-sale moves no sold seat — it only
|
|
18
|
+
# changes how future auto-assign picks are ranked.
|
|
19
|
+
#
|
|
20
|
+
# success(linked_count)
|
|
21
|
+
# | failure(nil, :duplicate_seat | :unknown_seat | :not_a_seat | :no_ticket_type | :mixed_ticket_types)
|
|
22
|
+
class SaveSeatOrder
|
|
23
|
+
prepend ::Spree::ServiceModule::Base
|
|
24
|
+
|
|
25
|
+
def call(seat_layout:, strokes:)
|
|
26
|
+
strokes = normalize(strokes)
|
|
27
|
+
ids = strokes.flatten
|
|
28
|
+
blocks = ids.include?(nil) ? [] : seat_layout.blocks.where(id: ids).to_a
|
|
29
|
+
variant_ids = blocks.to_h { |block| [block.id, block.variant_id] }
|
|
30
|
+
|
|
31
|
+
reason = refusal(strokes, ids, blocks, variant_ids)
|
|
32
|
+
return failure(nil, reason) if reason
|
|
33
|
+
|
|
34
|
+
indexes = indexes_for(strokes, variant_ids)
|
|
35
|
+
|
|
36
|
+
# Clear first, then write: the unique (variant_id, seat_layout_id, seat_index) index is
|
|
37
|
+
# checked row by row, so rewriting in place would collide with numbers not yet moved.
|
|
38
|
+
ActiveRecord::Base.transaction do
|
|
39
|
+
seat_layout.blocks.where.not(seat_index: nil).update_all(seat_index: nil, updated_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
|
|
40
|
+
write_indexes!(indexes) if indexes.any?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
success(indexes.size)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Each ticket type keeps its own counter, so the same number can appear once per type: VIP 1 and
|
|
49
|
+
# Regular 1 are two different first seats. `variant_ids` is { block_id => variant_id }.
|
|
50
|
+
#
|
|
51
|
+
# strokes: [[R1, R2], [V1, V2], [R3, R4]] (R = Regular seats, V = VIP seats)
|
|
52
|
+
#
|
|
53
|
+
# Regular: R1 → 1, R2 → 2, (3 skipped: row break), R3 → 4, R4 → 5
|
|
54
|
+
# VIP: V1 → 1, V2 → 2
|
|
55
|
+
def indexes_for(strokes, variant_ids)
|
|
56
|
+
next_index = Hash.new(1)
|
|
57
|
+
|
|
58
|
+
strokes.each_with_object({}) do |stroke, indexes|
|
|
59
|
+
variant_id = variant_ids[stroke.first]
|
|
60
|
+
|
|
61
|
+
stroke.each do |id|
|
|
62
|
+
indexes[id] = next_index[variant_id]
|
|
63
|
+
next_index[variant_id] += 1
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The row split: skip one number, so this row's last seat and the next row's first are 2 apart.
|
|
67
|
+
next_index[variant_id] += 1
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def refusal(strokes, ids, blocks, variant_ids)
|
|
72
|
+
return :unknown_seat if ids.include?(nil)
|
|
73
|
+
return :duplicate_seat if ids.uniq.size != ids.size
|
|
74
|
+
return :unknown_seat if blocks.size != ids.size
|
|
75
|
+
return :not_a_seat unless blocks.all?(&:seatable?)
|
|
76
|
+
return :no_ticket_type if blocks.any? { |block| block.variant_id.nil? }
|
|
77
|
+
|
|
78
|
+
:mixed_ticket_types if mixed_ticket_types?(strokes, variant_ids)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Two seats of different ticket types are never sold as a pair, and each type is numbered on its
|
|
82
|
+
# own, so a row spanning both has no single sequence to live in.
|
|
83
|
+
def mixed_ticket_types?(strokes, variant_ids)
|
|
84
|
+
strokes.any? { |stroke| stroke.map { |id| variant_ids[id] }.uniq.size > 1 }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Turns the param ids into integers and drops empty rows. A non-numeric id becomes nil, which
|
|
88
|
+
# `refusal` reports as :unknown_seat, instead of raising here.
|
|
89
|
+
#
|
|
90
|
+
# [["12", "13"], [], ["14", "x"]] → [[12, 13], [14, nil]]
|
|
91
|
+
# nil → [] (clears the whole order)
|
|
92
|
+
def normalize(strokes)
|
|
93
|
+
Array(strokes).map { |stroke| Array(stroke).map { |id| Integer(id, exception: false) } }.reject(&:empty?)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# One statement instead of one UPDATE per seat: a stadium layout is thousands of seats, and
|
|
97
|
+
# Block's callbacks/validations (layout sync, variant-unassign guard) are irrelevant to this
|
|
98
|
+
# one integer column.
|
|
99
|
+
def write_indexes!(indexes)
|
|
100
|
+
values = indexes.map { |id, index| "(#{Integer(id)}, #{Integer(index)})" }.join(', ')
|
|
101
|
+
|
|
102
|
+
ActiveRecord::Base.connection.execute(<<~SQL.squish)
|
|
103
|
+
UPDATE cm_blocks
|
|
104
|
+
SET seat_index = v.seat_index, updated_at = NOW()
|
|
105
|
+
FROM (VALUES #{values}) AS v(id, seat_index)
|
|
106
|
+
WHERE cm_blocks.id = v.id
|
|
107
|
+
SQL
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Seats
|
|
3
|
+
# Seats the unseated guests of a paid order's auto-assign line items. Runs from Seats::AutoAssignJob
|
|
4
|
+
# once completion has committed: payment and stock are already final, so a slow or failed
|
|
5
|
+
# assignment never holds up or rolls back the completion — the job just retries.
|
|
6
|
+
#
|
|
7
|
+
# One assignment per inventory item at a time (a transaction-scoped advisory lock). Jobs are
|
|
8
|
+
# enqueued in completion order, so serializing them keeps "first to pay gets the better seats",
|
|
9
|
+
# and the free runs read under the lock cannot go stale because of another assignment.
|
|
10
|
+
#
|
|
11
|
+
# Steps: advisory lock → Seats::FreeRunsQuery (free seats as side-by-side runs) →
|
|
12
|
+
# Seats::PickSeats (which seats this party gets, plain Ruby) → reserve_seats!.
|
|
13
|
+
#
|
|
14
|
+
# A seat for every paid ticket is guaranteed by the Redis count hold taken at checkout (plus
|
|
15
|
+
# Seats::AutoAssignValidator). Sitting together is best effort: near sell-out a party can be
|
|
16
|
+
# split, which is recorded on the line item (seat_assignment_status) for the organizer to fix.
|
|
17
|
+
#
|
|
18
|
+
# success([{ line_item_id:, status: :together | :split | :unseated, block_ids: [...] }, ...])
|
|
19
|
+
class AutoAssign
|
|
20
|
+
prepend ::Spree::ServiceModule::Base
|
|
21
|
+
|
|
22
|
+
# Seats::LockBlocks does not take the advisory lock, so an operator can still take a seat between
|
|
23
|
+
# the free runs read and reserve_seats!. Raised so the whole assignment rolls back and the job
|
|
24
|
+
# retries against fresh runs — rare enough not to retry in place.
|
|
25
|
+
SeatTakenError = Class.new(StandardError)
|
|
26
|
+
|
|
27
|
+
# A stuck assignment must not queue every later job for the same event behind it forever.
|
|
28
|
+
# Postgres applies lock_timeout to advisory locks too; the job retries on the timeout.
|
|
29
|
+
LOCK_TIMEOUT = '30s'.freeze
|
|
30
|
+
|
|
31
|
+
UNSEATED = { status: :unseated, block_ids: [] }.freeze
|
|
32
|
+
|
|
33
|
+
def call(order:)
|
|
34
|
+
inventory_item_ids = order.line_items.select(&:auto_assign_seats?).filter_map { |li| li.inventory_items.first&.id }.uniq
|
|
35
|
+
return success([]) if inventory_item_ids.empty?
|
|
36
|
+
|
|
37
|
+
outcomes = ActiveRecord::Base.transaction do
|
|
38
|
+
lock_inventory_items!(inventory_item_ids)
|
|
39
|
+
|
|
40
|
+
# Taken after the advisory lock so the order row is not held while queueing. The job is
|
|
41
|
+
# enqueued inside the completion transaction, which still holds this row: lock! waits for
|
|
42
|
+
# it to commit, then reloads — a rolled-back completion or a committed cancel gets no
|
|
43
|
+
# seats, and a cancel still in flight waits here, then finds the rows written below.
|
|
44
|
+
order.lock!
|
|
45
|
+
|
|
46
|
+
unless order.completed? && !order.canceled?
|
|
47
|
+
# A party canceled before the job ran is no longer waiting for a seat.
|
|
48
|
+
order.line_items.seat_assignment_pending.update_all(seat_assignment_status: nil) # rubocop:disable Rails/SkipsModelValidations
|
|
49
|
+
next []
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
order.line_items.select(&:auto_assign_seats?).filter_map { |line_item| assign_line_item!(order, line_item) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
report(order, outcomes) if outcomes.any?
|
|
56
|
+
success(outcomes)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Sorted so two orders spanning the same items always lock them in the same order.
|
|
62
|
+
def lock_inventory_items!(inventory_item_ids)
|
|
63
|
+
connection = ActiveRecord::Base.connection
|
|
64
|
+
connection.execute("SET LOCAL lock_timeout = '#{LOCK_TIMEOUT}'")
|
|
65
|
+
|
|
66
|
+
inventory_item_ids.sort.each do |id|
|
|
67
|
+
connection.execute("SELECT pg_advisory_xact_lock(#{Zlib.crc32("spree_cm_commissioner:seat_auto_assign:#{id}")})")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def assign_line_item!(order, line_item)
|
|
72
|
+
guests = line_item.guests.where(block_id: nil).order(:id).to_a
|
|
73
|
+
return nil if guests.empty?
|
|
74
|
+
|
|
75
|
+
inventory_item = line_item.inventory_items.first
|
|
76
|
+
return record!(line_item, UNSEATED) if inventory_item.nil?
|
|
77
|
+
|
|
78
|
+
runs = FreeRunsQuery.new(variant_id: line_item.variant_id, inventory_item_id: inventory_item.id).call
|
|
79
|
+
pick = PickSeats.call(runs: runs, count: guests.size)
|
|
80
|
+
return record!(line_item, UNSEATED) if pick.nil?
|
|
81
|
+
|
|
82
|
+
reserve_seats!(order, guests, pick.block_ids, inventory_item)
|
|
83
|
+
record!(line_item, { status: pick.together? ? :together : :split, block_ids: pick.block_ids })
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def reserve_seats!(order, guests, block_ids, inventory_item)
|
|
87
|
+
guests.zip(block_ids).each do |guest, block_id|
|
|
88
|
+
# Row-locked for LockBlocks, which locks the inventory item rather than the block. A seat
|
|
89
|
+
# with no row yet is covered by the unique index instead: a concurrent insert raises
|
|
90
|
+
# RecordNotUnique, which retries the job like SeatTakenError.
|
|
91
|
+
key = { block_id: block_id, inventory_item_id: inventory_item.id }
|
|
92
|
+
reserved_block = SpreeCmCommissioner::ReservedBlock.lock.find_by(key) || SpreeCmCommissioner::ReservedBlock.new(key)
|
|
93
|
+
raise SeatTakenError, "block #{block_id}" if taken?(reserved_block)
|
|
94
|
+
|
|
95
|
+
# Same attributes ReservedBlocks::Reserve writes. Gender is what neighbour rules evaluated by
|
|
96
|
+
# the next assignment read.
|
|
97
|
+
reserved_block.update!(
|
|
98
|
+
status: :reserved,
|
|
99
|
+
guest: guest,
|
|
100
|
+
gender: guest.gender || guest.saved_guest&.gender,
|
|
101
|
+
expired_at: nil,
|
|
102
|
+
payment_locked_at: nil,
|
|
103
|
+
inventory_date: inventory_item.inventory_date,
|
|
104
|
+
updated_by: order.user
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Guest#assign_seat_number_with_block copies the block label into seat_number, and
|
|
108
|
+
# Guest#preload_order_block_ids refreshes order.preload_block_ids so a later cancel releases it.
|
|
109
|
+
guest.update!(block_id: block_id)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def taken?(reserved_block)
|
|
114
|
+
reserved_block.reserved? || reserved_block.locked? || reserved_block.active_on_hold?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# update_columns: LineItem validations (purchase limits) must not block a paid order's seats.
|
|
118
|
+
def record!(line_item, outcome)
|
|
119
|
+
line_item.update_columns(seat_assignment_status: outcome[:status]) # rubocop:disable Rails/SkipsModelValidations
|
|
120
|
+
|
|
121
|
+
outcome.merge(line_item_id: line_item.id)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def report(order, outcomes)
|
|
125
|
+
data = { order_id: order.id, order_number: order.number, outcomes: outcomes }
|
|
126
|
+
|
|
127
|
+
if outcomes.all? { |outcome| outcome[:status] == :together }
|
|
128
|
+
CmAppLogger.log(label: self.class.name, data: data)
|
|
129
|
+
else
|
|
130
|
+
CmAppLogger.error(label: "#{self.class.name} party not seated together", data: data)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -28,7 +28,10 @@ module SpreeCmCommissioner
|
|
|
28
28
|
# Yields [block, date, inventory_item] for every pair that resolves to an inventory item, and
|
|
29
29
|
# records a :no_inventory skip for those that do not — a service calendar can legitimately exclude
|
|
30
30
|
# dates, so a missing item is normal input rather than an error.
|
|
31
|
+
# No dates means the undated item every event ticket has (inventory_date: nil) — there is no
|
|
32
|
+
# departure to choose, so the dashboard sends none.
|
|
31
33
|
def each_block_date(blocks, dates, skipped)
|
|
34
|
+
dates = Array(dates).presence || [nil]
|
|
32
35
|
index = inventory_index(blocks, dates)
|
|
33
36
|
|
|
34
37
|
# Warm every Redis key once for the whole grid instead of once per seat-date.
|
|
@@ -39,9 +42,9 @@ module SpreeCmCommissioner
|
|
|
39
42
|
# fewer round-trips, which matters as soon as Redis is a network hop rather than localhost.
|
|
40
43
|
SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(index.values).call if index.any?
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
dates.each do |date|
|
|
43
46
|
blocks.each do |block|
|
|
44
|
-
item = index[[block.variant_id, date
|
|
47
|
+
item = index[[block.variant_id, date&.to_date]]
|
|
45
48
|
|
|
46
49
|
if item.nil?
|
|
47
50
|
skipped << skip(date, block, :no_inventory)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
module Seats
|
|
3
|
+
# Chooses which free seats a party of `count` gets, from Seats::FreeRunsQuery runs.
|
|
4
|
+
#
|
|
5
|
+
# Rules, in order:
|
|
6
|
+
# 1. Together — the run in the earliest stroke that fits, then the best fit inside that stroke:
|
|
7
|
+
# exact fit, then leaving ≥ 2 behind, and only then leaving exactly 1 (an orphan only a solo
|
|
8
|
+
# buyer can ever use). Stroke ranks above fit so an earlier payer is never pushed back a row
|
|
9
|
+
# just to pack better. Seats come from the start of the run, keeping the rest contiguous.
|
|
10
|
+
# 2. Split — anchor on the biggest run, fill from the nearest strokes of the same layout first.
|
|
11
|
+
# Organizers drag rows in order, so the nearest stroke is usually the row behind or in front.
|
|
12
|
+
# nil — fewer than `count` free seats in total (stock/seat drift).
|
|
13
|
+
#
|
|
14
|
+
# Deliberately plain Ruby over duck-typed runs (seat_layout_id, stroke, start_index, block_ids,
|
|
15
|
+
# size): no database, so it is unit-tested with Structs and can be replayed offline against a
|
|
16
|
+
# venue to measure how often parties split.
|
|
17
|
+
class PickSeats
|
|
18
|
+
Pick = Struct.new(:block_ids, :pieces, keyword_init: true) do
|
|
19
|
+
def together? = pieces == 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.call(runs:, count:)
|
|
23
|
+
new(runs, count).call
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(runs, count)
|
|
27
|
+
@runs = runs
|
|
28
|
+
@count = count
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
return nil if @count <= 0 || @runs.sum(&:size) < @count
|
|
33
|
+
|
|
34
|
+
together || split
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def together
|
|
40
|
+
run = @runs.select { |r| r.size >= @count }
|
|
41
|
+
.min_by { |r| [r.seat_layout_id, r.stroke, fit_penalty(r), r.start_index] }
|
|
42
|
+
return nil if run.nil?
|
|
43
|
+
|
|
44
|
+
Pick.new(block_ids: run.block_ids.first(@count), pieces: 1)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def fit_penalty(run)
|
|
48
|
+
case run.size - @count
|
|
49
|
+
when 0 then 0
|
|
50
|
+
when 1 then 2
|
|
51
|
+
else 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def split
|
|
56
|
+
anchor = @runs.max_by { |r| [r.size, -r.stroke, -r.start_index] }
|
|
57
|
+
nearest = (@runs - [anchor]).sort_by do |r|
|
|
58
|
+
[
|
|
59
|
+
r.seat_layout_id == anchor.seat_layout_id ? 0 : 1,
|
|
60
|
+
(r.stroke - anchor.stroke).abs,
|
|
61
|
+
(r.start_index - anchor.start_index).abs
|
|
62
|
+
]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
ids = []
|
|
66
|
+
pieces = 0
|
|
67
|
+
|
|
68
|
+
[anchor, *nearest].each do |run|
|
|
69
|
+
break if ids.size == @count
|
|
70
|
+
|
|
71
|
+
ids.concat(run.block_ids.first(@count - ids.size))
|
|
72
|
+
pieces += 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Pick.new(block_ids: ids, pieces: pieces)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class AddSeatIndexToCmBlocks < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
add_column :cm_blocks, :seat_index, :integer, if_not_exists: true
|
|
4
|
+
|
|
5
|
+
# One seat order per ticket type on a layout: each variant's seats are numbered on their own, so
|
|
6
|
+
# editing one type's rows never renumbers another's. Unique so SeatLayouts::SaveSeatOrder can make
|
|
7
|
+
# a corrupt order (two seats claiming the same place) impossible rather than merely unlikely; in
|
|
8
|
+
# this column order because auto-assign looks up free seats the same way: by ticket type (variant_id),
|
|
9
|
+
# per layout (seat_layout_id), in seat order (seat_index).
|
|
10
|
+
add_index :cm_blocks, %i[variant_id seat_layout_id seat_index],
|
|
11
|
+
unique: true,
|
|
12
|
+
where: 'seat_index IS NOT NULL',
|
|
13
|
+
name: 'index_cm_blocks_on_variant_layout_and_seat_index',
|
|
14
|
+
if_not_exists: true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class AddSeatAssignmentStatusToSpreeLineItems < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
add_column :spree_line_items, :seat_assignment_status, :integer, if_not_exists: true
|
|
4
|
+
|
|
5
|
+
# Only auto-assign line items ever get a value, so a partial index stays tiny while serving the
|
|
6
|
+
# lookups that need it: pending parties for a bulk run or sweeper, split parties for the organizer.
|
|
7
|
+
add_index :spree_line_items, :seat_assignment_status,
|
|
8
|
+
where: 'seat_assignment_status IS NOT NULL',
|
|
9
|
+
if_not_exists: true
|
|
10
|
+
end
|
|
11
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: spree_cm_commissioner
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.15.
|
|
4
|
+
version: 2.15.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- You
|
|
@@ -1435,6 +1435,7 @@ files:
|
|
|
1435
1435
|
- app/jobs/spree_cm_commissioner/route_metrics/increase_order_count_job.rb
|
|
1436
1436
|
- app/jobs/spree_cm_commissioner/route_metrics/increase_trip_count_job.rb
|
|
1437
1437
|
- app/jobs/spree_cm_commissioner/route_prices/update_price_range_job.rb
|
|
1438
|
+
- app/jobs/spree_cm_commissioner/seats/auto_assign_job.rb
|
|
1438
1439
|
- app/jobs/spree_cm_commissioner/seats/bulk_release_on_hold_blocks_job.rb
|
|
1439
1440
|
- app/jobs/spree_cm_commissioner/seats/release_expired_blocks_job.rb
|
|
1440
1441
|
- app/jobs/spree_cm_commissioner/show_contestants/import_job.rb
|
|
@@ -1506,6 +1507,7 @@ files:
|
|
|
1506
1507
|
- app/models/concerns/spree_cm_commissioner/order_prepaid_payment.rb
|
|
1507
1508
|
- app/models/concerns/spree_cm_commissioner/order_qr_codeable.rb
|
|
1508
1509
|
- app/models/concerns/spree_cm_commissioner/order_scopes.rb
|
|
1510
|
+
- app/models/concerns/spree_cm_commissioner/order_seat_auto_assignable.rb
|
|
1509
1511
|
- app/models/concerns/spree_cm_commissioner/order_state_machine.rb
|
|
1510
1512
|
- app/models/concerns/spree_cm_commissioner/parameterize_name.rb
|
|
1511
1513
|
- app/models/concerns/spree_cm_commissioner/participation_type_bitwise.rb
|
|
@@ -1513,6 +1515,7 @@ files:
|
|
|
1513
1515
|
- app/models/concerns/spree_cm_commissioner/phone_number_sanitizer.rb
|
|
1514
1516
|
- app/models/concerns/spree_cm_commissioner/product_delegation.rb
|
|
1515
1517
|
- app/models/concerns/spree_cm_commissioner/product_relation_type.rb
|
|
1518
|
+
- app/models/concerns/spree_cm_commissioner/product_seat_auto_assignable.rb
|
|
1516
1519
|
- app/models/concerns/spree_cm_commissioner/product_type.rb
|
|
1517
1520
|
- app/models/concerns/spree_cm_commissioner/promotion/rules/oauth_application_scoped.rb
|
|
1518
1521
|
- app/models/concerns/spree_cm_commissioner/purchase_limitable.rb
|
|
@@ -1766,6 +1769,7 @@ files:
|
|
|
1766
1769
|
- app/models/spree_cm_commissioner/saved_guest.rb
|
|
1767
1770
|
- app/models/spree_cm_commissioner/seat_layout.rb
|
|
1768
1771
|
- app/models/spree_cm_commissioner/seat_section.rb
|
|
1772
|
+
- app/models/spree_cm_commissioner/seats/auto_assign_validator.rb
|
|
1769
1773
|
- app/models/spree_cm_commissioner/service_calendar.rb
|
|
1770
1774
|
- app/models/spree_cm_commissioner/service_fee_rule.rb
|
|
1771
1775
|
- app/models/spree_cm_commissioner/show.rb
|
|
@@ -1920,6 +1924,7 @@ files:
|
|
|
1920
1924
|
- app/overrides/spree/admin/products/_form/allow_gift_transfer.html.erb.deface
|
|
1921
1925
|
- app/overrides/spree/admin/products/_form/allow_self_check_in.html.erb.deface
|
|
1922
1926
|
- app/overrides/spree/admin/products/_form/allow_transfer.html.erb.deface
|
|
1927
|
+
- app/overrides/spree/admin/products/_form/auto_assign_seats.html.erb.deface
|
|
1923
1928
|
- app/overrides/spree/admin/products/_form/available_on.html.erb.deface
|
|
1924
1929
|
- app/overrides/spree/admin/products/_form/discontinue_on.html.erb.deface
|
|
1925
1930
|
- app/overrides/spree/admin/products/_form/enable_confirmation_email.html.erb.deface
|
|
@@ -2078,6 +2083,7 @@ files:
|
|
|
2078
2083
|
- app/queries/spree_cm_commissioner/pie_chart_event_aggregator_queries.rb
|
|
2079
2084
|
- app/queries/spree_cm_commissioner/recipient_searcher_query.rb
|
|
2080
2085
|
- app/queries/spree_cm_commissioner/reservation_query.rb
|
|
2086
|
+
- app/queries/spree_cm_commissioner/seats/free_runs_query.rb
|
|
2081
2087
|
- app/queries/spree_cm_commissioner/session_product_monitor_query.rb
|
|
2082
2088
|
- app/queries/spree_cm_commissioner/single_leg_trips_query.rb
|
|
2083
2089
|
- app/queries/spree_cm_commissioner/store_credit_overview_query.rb
|
|
@@ -2668,12 +2674,15 @@ files:
|
|
|
2668
2674
|
- app/services/spree_cm_commissioner/rsa_service.rb
|
|
2669
2675
|
- app/services/spree_cm_commissioner/seat_layouts/grid.rb
|
|
2670
2676
|
- app/services/spree_cm_commissioner/seat_layouts/sanitize_svg.rb
|
|
2677
|
+
- app/services/spree_cm_commissioner/seat_layouts/save_seat_order.rb
|
|
2671
2678
|
- app/services/spree_cm_commissioner/seat_layouts/update.rb
|
|
2672
2679
|
- app/services/spree_cm_commissioner/seats/apply_locks.rb
|
|
2680
|
+
- app/services/spree_cm_commissioner/seats/auto_assign.rb
|
|
2673
2681
|
- app/services/spree_cm_commissioner/seats/block_date_iteration.rb
|
|
2674
2682
|
- app/services/spree_cm_commissioner/seats/bulk_release_on_hold_blocks.rb
|
|
2675
2683
|
- app/services/spree_cm_commissioner/seats/lock_blocks.rb
|
|
2676
2684
|
- app/services/spree_cm_commissioner/seats/lock_state.rb
|
|
2685
|
+
- app/services/spree_cm_commissioner/seats/pick_seats.rb
|
|
2677
2686
|
- app/services/spree_cm_commissioner/seats/release_expired_blocks.rb
|
|
2678
2687
|
- app/services/spree_cm_commissioner/seats/unlock_blocks.rb
|
|
2679
2688
|
- app/services/spree_cm_commissioner/seeds/roles_decorator.rb
|
|
@@ -3799,6 +3808,8 @@ files:
|
|
|
3799
3808
|
- db/migrate/20261105000002_add_index_to_spree_orders_waiting_room_session_id.rb
|
|
3800
3809
|
- db/migrate/20261105000003_add_queue_fields_to_cm_waiting_room_sessions.rb
|
|
3801
3810
|
- db/migrate/20261105000004_add_oauth_application_id_to_spree_orders.rb
|
|
3811
|
+
- db/migrate/20261106000001_add_seat_index_to_cm_blocks.rb
|
|
3812
|
+
- db/migrate/20261110000001_add_seat_assignment_status_to_spree_line_items.rb
|
|
3802
3813
|
- db/migrate/20261110000001_create_cm_oauth_application_promotion_rules.rb
|
|
3803
3814
|
- docker-compose.yml
|
|
3804
3815
|
- docs/api/scoped-access-token-endpoints.md
|