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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/spree/api/v2/storefront/transit/draft_orders_controller.rb +2 -1
  4. data/app/controllers/spree/api/v2/tenant/transit/draft_orders_controller.rb +2 -1
  5. data/app/jobs/spree_cm_commissioner/seats/auto_assign_job.rb +16 -0
  6. data/app/models/concerns/spree_cm_commissioner/line_item_seat_selection.rb +25 -3
  7. data/app/models/concerns/spree_cm_commissioner/order_seat_auto_assignable.rb +73 -0
  8. data/app/models/concerns/spree_cm_commissioner/product_seat_auto_assignable.rb +29 -0
  9. data/app/models/concerns/spree_cm_commissioner/promotion/rules/oauth_application_scoped.rb +44 -0
  10. data/app/models/spree_cm_commissioner/block.rb +17 -0
  11. data/app/models/spree_cm_commissioner/line_item_decorator.rb +4 -0
  12. data/app/models/spree_cm_commissioner/oauth_application_promotion_rule.rb +8 -0
  13. data/app/models/spree_cm_commissioner/order_decorator.rb +17 -1
  14. data/app/models/spree_cm_commissioner/product_decorator.rb +1 -0
  15. data/app/models/spree_cm_commissioner/promotion/rules/customer_order_count.rb +87 -0
  16. data/app/models/spree_cm_commissioner/promotion/rules/oauth_applications.rb +34 -0
  17. data/app/models/spree_cm_commissioner/seats/auto_assign_validator.rb +63 -0
  18. data/app/overrides/spree/admin/products/_form/auto_assign_seats.html.erb.deface +14 -0
  19. data/app/queries/spree_cm_commissioner/seats/free_runs_query.rb +73 -0
  20. data/app/serializers/spree/v2/storefront/line_item_serializer_decorator.rb +1 -1
  21. data/app/serializers/spree/v2/storefront/product_serializer_decorator.rb +1 -1
  22. data/app/serializers/spree/v2/tenant/line_item_serializer.rb +1 -1
  23. data/app/services/spree_cm_commissioner/doorkeeper_refresh_token_request_decorator.rb +23 -0
  24. data/app/services/spree_cm_commissioner/doorkeeper_server_decorator.rb +28 -0
  25. data/app/services/spree_cm_commissioner/mini_app_login_application.rb +32 -0
  26. data/app/services/spree_cm_commissioner/order_holds/release.rb +3 -1
  27. data/app/services/spree_cm_commissioner/seat_layouts/save_seat_order.rb +111 -0
  28. data/app/services/spree_cm_commissioner/seats/auto_assign.rb +135 -0
  29. data/app/services/spree_cm_commissioner/seats/block_date_iteration.rb +5 -2
  30. data/app/services/spree_cm_commissioner/seats/pick_seats.rb +79 -0
  31. data/app/services/spree_cm_commissioner/transit_order/create.rb +9 -4
  32. data/app/views/spree/admin/promotions/rules/_customer_order_count.html.erb +20 -0
  33. data/app/views/spree/admin/promotions/rules/_oauth_application_select.html.erb +15 -0
  34. data/app/views/spree/admin/promotions/rules/_oauth_applications.html.erb +6 -0
  35. data/config/locales/en.yml +24 -0
  36. data/config/locales/km.yml +24 -0
  37. data/db/migrate/20261106000001_add_seat_index_to_cm_blocks.rb +16 -0
  38. data/db/migrate/20261110000001_add_seat_assignment_status_to_spree_line_items.rb +11 -0
  39. data/db/migrate/20261110000001_create_cm_oauth_application_promotion_rules.rb +16 -0
  40. data/lib/spree_cm_commissioner/engine.rb +3 -1
  41. data/lib/spree_cm_commissioner/version.rb +1 -1
  42. 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
- return unless order.should_manage_blocks?
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
- Array(dates).each do |date|
45
+ dates.each do |date|
43
46
  blocks.each do |block|
44
- item = index[[block.variant_id, date.to_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(state: 'cart', use_billing: true, user: user, created_by: created_by, channel: channel)
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>
@@ -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:
@@ -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