spree_cm_commissioner 2.15.0 → 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/controllers/spree/api/v2/storefront/transit/draft_orders_controller.rb +2 -1
- data/app/controllers/spree/api/v2/tenant/transit/draft_orders_controller.rb +2 -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/concerns/spree_cm_commissioner/promotion/rules/oauth_application_scoped.rb +44 -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/oauth_application_promotion_rule.rb +8 -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/promotion/rules/customer_order_count.rb +87 -0
- data/app/models/spree_cm_commissioner/promotion/rules/oauth_applications.rb +34 -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/doorkeeper_refresh_token_request_decorator.rb +23 -0
- data/app/services/spree_cm_commissioner/doorkeeper_server_decorator.rb +28 -0
- data/app/services/spree_cm_commissioner/mini_app_login_application.rb +32 -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/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/app/services/spree_cm_commissioner/transit_order/create.rb +9 -4
- data/app/views/spree/admin/promotions/rules/_customer_order_count.html.erb +20 -0
- data/app/views/spree/admin/promotions/rules/_oauth_application_select.html.erb +15 -0
- data/app/views/spree/admin/promotions/rules/_oauth_applications.html.erb +6 -0
- data/config/locales/en.yml +24 -0
- data/config/locales/km.yml +24 -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/db/migrate/20261110000001_create_cm_oauth_application_promotion_rules.rb +16 -0
- data/lib/spree_cm_commissioner/engine.rb +3 -1
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +24 -2
|
@@ -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?
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Doorkeeper only refreshes a token for the client it was issued to. A mini app token belongs to its
|
|
3
|
+
# mini app's application (see DoorkeeperServerDecorator), but the client that refreshes it holds no
|
|
4
|
+
# credentials for that application, so the match would always fail.
|
|
5
|
+
#
|
|
6
|
+
# For these tokens the refresh token itself is the proof, as it is for a public client. A wrong client
|
|
7
|
+
# secret is still refused: Doorkeeper checks it before the match, and this only relaxes the match.
|
|
8
|
+
module DoorkeeperRefreshTokenRequestDecorator
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def validate_client_match
|
|
12
|
+
super || mini_app_token?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def mini_app_token?
|
|
16
|
+
refresh_token.application&.provider.present?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
unless Doorkeeper::OAuth::RefreshTokenRequest.included_modules.include?(SpreeCmCommissioner::DoorkeeperRefreshTokenRequestDecorator)
|
|
22
|
+
Doorkeeper::OAuth::RefreshTokenRequest.prepend(SpreeCmCommissioner::DoorkeeperRefreshTokenRequestDecorator)
|
|
23
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# A mini app login is issued its token for that mini app's OAuth application, found by
|
|
3
|
+
# MiniAppLoginApplication, whatever client credentials the request carries. Doorkeeper would otherwise
|
|
4
|
+
# take the client from those credentials (the default BookMe+ client, or none), and the order made with
|
|
5
|
+
# the token would not say which mini app it came from.
|
|
6
|
+
#
|
|
7
|
+
# The login itself is the proof: UserAuthenticator only accepts a session key the bank signed for the
|
|
8
|
+
# user, so no client secret is needed to say which bank it is.
|
|
9
|
+
module DoorkeeperServerDecorator
|
|
10
|
+
def client
|
|
11
|
+
mini_app_client || super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Only the password grant: a refresh keeps the application its token was issued for.
|
|
17
|
+
def mini_app_client
|
|
18
|
+
return @mini_app_client if defined?(@mini_app_client)
|
|
19
|
+
|
|
20
|
+
application = MiniAppLoginApplication.for(parameters) if parameters[:grant_type] == Doorkeeper::OAuth::PASSWORD
|
|
21
|
+
@mini_app_client = application && Doorkeeper::OAuth::Client.new(application)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
unless Doorkeeper::Server.included_modules.include?(SpreeCmCommissioner::DoorkeeperServerDecorator)
|
|
27
|
+
Doorkeeper::Server.prepend(SpreeCmCommissioner::DoorkeeperServerDecorator)
|
|
28
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module SpreeCmCommissioner
|
|
2
|
+
# Finds the OAuth application a mini app login belongs to, from the login itself: the parameter the
|
|
3
|
+
# client sends already names the bank (`session_key` → ACLEDA, `wing_session_key` → Wing, `session_id` →
|
|
4
|
+
# Vattanac), so the client needs no client id or secret of its own. The application is the one whose
|
|
5
|
+
# `provider` is that bank (see MiniAppProvider::Base.providers).
|
|
6
|
+
#
|
|
7
|
+
# Nil for any other login (web, Telegram, password), or when no application carries that provider yet.
|
|
8
|
+
class MiniAppLoginApplication
|
|
9
|
+
FLOW_SUFFIX = '_web_app_auth'.freeze
|
|
10
|
+
|
|
11
|
+
def self.for(params)
|
|
12
|
+
provider = provider_of(params)
|
|
13
|
+
return nil if provider.nil?
|
|
14
|
+
|
|
15
|
+
application = Spree::OauthApplication.find_by(provider: provider)
|
|
16
|
+
CmAppLogger.log(label: 'Mini app login: no OAuth application with this provider', data: { provider: provider }) if application.nil?
|
|
17
|
+
application
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.provider_of(params)
|
|
21
|
+
flow = UserAuthenticator.flow_type(params)
|
|
22
|
+
return nil unless flow.end_with?(FLOW_SUFFIX)
|
|
23
|
+
|
|
24
|
+
provider = flow.delete_suffix(FLOW_SUFFIX)
|
|
25
|
+
provider if MiniAppProvider::Base.providers.map(&:provider).include?(provider)
|
|
26
|
+
rescue Doorkeeper::Errors::DoorkeeperError
|
|
27
|
+
# Params no flow recognises: the login fails on its own further down, with its own message.
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
private_class_method :provider_of
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -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
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
# - outbound_legs: [Array<SpreeCmCommissioner::Transit::Leg>] Outbound transit legs (one for direct, multiple for connecting)
|
|
7
7
|
# - inbound_legs: [Array<SpreeCmCommissioner::Transit::Leg>] Inbound transit legs (one for direct, multiple for connecting)
|
|
8
8
|
# - user: [User] The user for whom the order is being created
|
|
9
|
+
# - oauth_application_id: [Integer, nil] The client app of the request's access token (nil without a token or
|
|
10
|
+
# from a path that has none), recorded on the order for promotions limited to one app.
|
|
9
11
|
#
|
|
10
12
|
# Each line item is built with precise from_date and to_date based on legs and trip stop times.
|
|
11
13
|
# Each line item has guests built corresponding to seat selection blocks.
|
|
@@ -17,13 +19,13 @@ module SpreeCmCommissioner
|
|
|
17
19
|
prepend ::Spree::ServiceModule::Base
|
|
18
20
|
|
|
19
21
|
def call(outbound_date:, inbound_date:, outbound_legs:, inbound_legs: [], user: nil, created_by: nil, # rubocop:disable Metrics/ParameterLists
|
|
20
|
-
include_return: false, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL
|
|
22
|
+
include_return: false, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL, oauth_application_id: nil
|
|
21
23
|
)
|
|
22
24
|
return failure(nil, 'Outbound legs are missing') if outbound_legs.blank?
|
|
23
25
|
|
|
24
26
|
begin
|
|
25
27
|
order = create_order!(outbound_date, inbound_date, outbound_legs, inbound_legs, user, include_return,
|
|
26
|
-
created_by: created_by, channel: channel
|
|
28
|
+
created_by: created_by, channel: channel, oauth_application_id: oauth_application_id
|
|
27
29
|
)
|
|
28
30
|
success(order: order)
|
|
29
31
|
rescue StandardError => e
|
|
@@ -32,9 +34,12 @@ module SpreeCmCommissioner
|
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def create_order!(outbound_date, inbound_date, outbound_legs, inbound_legs, user, include_return, # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/ParameterLists
|
|
35
|
-
created_by: nil, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL
|
|
37
|
+
created_by: nil, channel: SpreeCmCommissioner::OrderChannelBitwise::SPREE_CHANNEL, oauth_application_id: nil
|
|
36
38
|
)
|
|
37
|
-
order = Spree::Order.new(
|
|
39
|
+
order = Spree::Order.new(
|
|
40
|
+
state: 'cart', use_billing: true, user: user, created_by: created_by, channel: channel,
|
|
41
|
+
oauth_application_id: oauth_application_id
|
|
42
|
+
)
|
|
38
43
|
|
|
39
44
|
all_legs = outbound_legs + inbound_legs
|
|
40
45
|
preload_trips_and_stops!(all_legs)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<div class="card-body">
|
|
2
|
+
<div class="row mb-0">
|
|
3
|
+
<div class="form-group col-6 mb-0">
|
|
4
|
+
<%= label_tag "#{param_prefix}_preferred_min_order_number", Spree.t('customer_order_count_rule.from') %>
|
|
5
|
+
<%= number_field_tag "#{param_prefix}[preferred_min_order_number]", promotion_rule.min_order_number,
|
|
6
|
+
min: 1, step: 1, class: 'form-control', id: "#{param_prefix}_preferred_min_order_number" %>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="form-group col-6 mb-0">
|
|
9
|
+
<%= label_tag "#{param_prefix}_preferred_max_order_number", Spree.t('customer_order_count_rule.to') %>
|
|
10
|
+
<%= number_field_tag "#{param_prefix}[preferred_max_order_number]", promotion_rule.max_order_number,
|
|
11
|
+
min: 1, step: 1, class: 'form-control', id: "#{param_prefix}_preferred_max_order_number" %>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="mt-3">
|
|
15
|
+
<%= render 'spree/admin/promotions/rules/oauth_application_select',
|
|
16
|
+
param_prefix: param_prefix, promotion_rule: promotion_rule,
|
|
17
|
+
label: Spree.t('customer_order_count_rule.oauth_applications'),
|
|
18
|
+
hint: Spree.t('customer_order_count_rule.oauth_applications_hint') %>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# Labelled with the tenant or mini-app provider, because app names alone ("Client app",
|
|
2
|
+
"Storefront") do not say which channel an admin is picking.
|
|
3
|
+
Locals: param_prefix, promotion_rule, label, hint. %>
|
|
4
|
+
<% applications = Spree::OauthApplication.includes(:tenant).order(:name) %>
|
|
5
|
+
<div class="form-group mb-0">
|
|
6
|
+
<%= label_tag "#{param_prefix}_oauth_application_ids", label %>
|
|
7
|
+
<%= select_tag("#{param_prefix}[oauth_application_ids][]",
|
|
8
|
+
options_for_select(
|
|
9
|
+
applications.map { |app| [[app.name, app.tenant&.name || app.provider.presence].compact.join(' — '), app.id] },
|
|
10
|
+
promotion_rule.oauth_application_ids
|
|
11
|
+
),
|
|
12
|
+
{ class: 'select2 form-control', multiple: true, id: "#{param_prefix}_oauth_application_ids" }
|
|
13
|
+
) %>
|
|
14
|
+
<small class="form-text text-muted"><%= hint %></small>
|
|
15
|
+
</div>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<div class="card-body">
|
|
2
|
+
<%= render 'spree/admin/promotions/rules/oauth_application_select',
|
|
3
|
+
param_prefix: param_prefix, promotion_rule: promotion_rule,
|
|
4
|
+
label: Spree.t('oauth_applications_rule.choose_oauth_applications'),
|
|
5
|
+
hint: Spree.t('oauth_applications_rule.hint') %>
|
|
6
|
+
</div>
|
data/config/locales/en.yml
CHANGED
|
@@ -424,6 +424,12 @@ en:
|
|
|
424
424
|
guest_age_group:
|
|
425
425
|
name: "Guest Age Group"
|
|
426
426
|
description: "Guest Age Group"
|
|
427
|
+
oauth_applications:
|
|
428
|
+
name: "OAuth Applications"
|
|
429
|
+
description: "Order must be placed through one of the selected apps (tenant app or mini app)"
|
|
430
|
+
customer_order_count:
|
|
431
|
+
name: "Customer Order Count"
|
|
432
|
+
description: "Order must be the customer's Nth booking (e.g. 2nd only, or 5th and later)"
|
|
427
433
|
customers:
|
|
428
434
|
name: "Customers"
|
|
429
435
|
description: "Customers"
|
|
@@ -464,6 +470,19 @@ en:
|
|
|
464
470
|
choose_guest_age_group: "Choose Guest Age Group"
|
|
465
471
|
match_any: "Some line items must from selected age group"
|
|
466
472
|
match_all: "All line items must from selected age group"
|
|
473
|
+
oauth_applications_rule:
|
|
474
|
+
choose_oauth_applications: "Choose apps"
|
|
475
|
+
hint: "Leave empty and the promotion applies to no app."
|
|
476
|
+
customer_order_count_rule:
|
|
477
|
+
from: "Eligible from the customer's booking number"
|
|
478
|
+
to: "Up to booking number (empty = no limit)"
|
|
479
|
+
oauth_applications: "Count only bookings from these apps"
|
|
480
|
+
oauth_applications_hint: "Empty = bookings from every app are counted."
|
|
481
|
+
eligibility_errors:
|
|
482
|
+
messages:
|
|
483
|
+
oauth_application_not_eligible: "This coupon is for a special offer and can't be used here."
|
|
484
|
+
customer_order_count_login_required: "Please log in to use this promotion."
|
|
485
|
+
customer_order_count_not_eligible: "This promotion is not available for this booking."
|
|
467
486
|
billing:
|
|
468
487
|
six_month_discount: "6 Months discount"
|
|
469
488
|
twelve_month_discount: "12 Months discount"
|
|
@@ -940,6 +959,11 @@ en:
|
|
|
940
959
|
place: "Place"
|
|
941
960
|
errors:
|
|
942
961
|
models:
|
|
962
|
+
spree_cm_commissioner/promotion/rules/customer_order_count:
|
|
963
|
+
attributes:
|
|
964
|
+
base:
|
|
965
|
+
customer_order_count_min_too_low: "'Eligible from the customer's booking number' must be 1 or more."
|
|
966
|
+
customer_order_count_max_below_min: "'Up to booking number' must be empty or not less than the 'from' number."
|
|
943
967
|
spree_cm_commissioner/reschedule_policy:
|
|
944
968
|
attributes:
|
|
945
969
|
base:
|
data/config/locales/km.yml
CHANGED
|
@@ -368,6 +368,12 @@ km:
|
|
|
368
368
|
guest_age_group:
|
|
369
369
|
name: "Guest Age Group"
|
|
370
370
|
description: "Guest Age Group"
|
|
371
|
+
oauth_applications:
|
|
372
|
+
name: "OAuth Applications"
|
|
373
|
+
description: "Order must be placed through one of the selected apps (tenant app or mini app)"
|
|
374
|
+
customer_order_count:
|
|
375
|
+
name: "Customer Order Count"
|
|
376
|
+
description: "Order must be the customer's Nth booking (e.g. 2nd only, or 5th and later)"
|
|
371
377
|
promotion_action_types:
|
|
372
378
|
create_date_specific_item_adjustments:
|
|
373
379
|
name: "Create date specific item adjustment"
|
|
@@ -402,6 +408,19 @@ km:
|
|
|
402
408
|
choose_guest_age_group: "Choose Guest Age Group"
|
|
403
409
|
match_any: "Some line items must from selected age group"
|
|
404
410
|
match_all: "All line items must from selected age group"
|
|
411
|
+
oauth_applications_rule:
|
|
412
|
+
choose_oauth_applications: "Choose apps"
|
|
413
|
+
hint: "Leave empty and the promotion applies to no app."
|
|
414
|
+
customer_order_count_rule:
|
|
415
|
+
from: "Eligible from the customer's booking number"
|
|
416
|
+
to: "Up to booking number (empty = no limit)"
|
|
417
|
+
oauth_applications: "Count only bookings from these apps"
|
|
418
|
+
oauth_applications_hint: "Empty = bookings from every app are counted."
|
|
419
|
+
eligibility_errors:
|
|
420
|
+
messages:
|
|
421
|
+
oauth_application_not_eligible: "គូប៉ុងនេះសម្រាប់ការផ្តល់ជូនពិសេស ហើយមិនអាចប្រើនៅទីនេះបានទេ។"
|
|
422
|
+
customer_order_count_login_required: "Please log in to use this promotion."
|
|
423
|
+
customer_order_count_not_eligible: "This promotion is not available for this booking."
|
|
405
424
|
billing:
|
|
406
425
|
six_month_discount: "Six Month discount"
|
|
407
426
|
twelve_month_discount: "Twelve Month discount"
|
|
@@ -555,6 +574,11 @@ km:
|
|
|
555
574
|
cashier: "អ្នកគិតលុយ"
|
|
556
575
|
errors:
|
|
557
576
|
models:
|
|
577
|
+
spree_cm_commissioner/promotion/rules/customer_order_count:
|
|
578
|
+
attributes:
|
|
579
|
+
base:
|
|
580
|
+
customer_order_count_min_too_low: "'Eligible from the customer's booking number' must be 1 or more."
|
|
581
|
+
customer_order_count_max_below_min: "'Up to booking number' must be empty or not less than the 'from' number."
|
|
558
582
|
spree_cm_commissioner/inventory_item:
|
|
559
583
|
attributes:
|
|
560
584
|
variant_id:
|
|
@@ -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
|