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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b32073d2114c66c1a2f712bbcfb05712cb153b8a651c2c33800665b0422d2541
4
- data.tar.gz: 790420e894c681441fba0109c0aafc51bbcbd19a3c076743404db71dd0c6ce7b
3
+ metadata.gz: a5538af5130244a626ceca3f818410469c93ea7975f5cbc51b212b7536361b8e
4
+ data.tar.gz: 4e272e66836c76b1296aad6a65488cd17e3ef9517a4a8112594047875de4a185
5
5
  SHA512:
6
- metadata.gz: 79d3b977f4d3d63523ff5920cc17c7598eb320ed3b6e3dae89bff7d3ee3a0d3f40744a2788e033910da0fa61fcddeaff589414d2b6d9373c52b9f444fe44ff2a
7
- data.tar.gz: ca37c84b2064fe10e2187b9610385c6db5ab1016aa64df2f8e34b520fd65f37c59566f1d879aedb822fa43467f43da8f2acda7609ae9aba56ea67d40dc807e72
6
+ metadata.gz: 80bc600859e7bdf199590d0a5e7982b9b03bbef4ddc9b19e03fbbbe6be76ceca11817687b1d358db20be3b9b7665154d9a05db59412e690448982cf70f543e4d
7
+ data.tar.gz: 646dea4a475811f6452137087b603d3bb36b6832bd6550339713b84a82f7ee0c71054b81632aebd4ddd041eef14ceca479402e679b0ab49fbf40d87f0e69ec15
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.15.0)
37
+ spree_cm_commissioner (2.15.2)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -25,7 +25,8 @@ module Spree
25
25
  outbound_legs: @outbound_legs,
26
26
  inbound_legs: @inbound_legs,
27
27
  user: spree_current_user,
28
- include_return: ActiveModel::Type::Boolean.new.cast(params[:include_return])
28
+ include_return: ActiveModel::Type::Boolean.new.cast(params[:include_return]),
29
+ oauth_application_id: doorkeeper_token&.application_id
29
30
  )
30
31
 
31
32
  if result.success?
@@ -25,7 +25,8 @@ module Spree
25
25
  outbound_legs: @outbound_legs,
26
26
  inbound_legs: @inbound_legs,
27
27
  user: spree_current_user,
28
- include_return: ActiveModel::Type::Boolean.new.cast(params[:include_return])
28
+ include_return: ActiveModel::Type::Boolean.new.cast(params[:include_return]),
29
+ oauth_application_id: doorkeeper_token&.application_id
29
30
  )
30
31
 
31
32
  if result.success?
@@ -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
- private_metadata['required_seat_selection'] = if product.trip.present?
34
- product.trip.allow_seat_selection && variant.blocks.any?
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
- variant.blocks.any?
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
@@ -0,0 +1,44 @@
1
+ module SpreeCmCommissioner
2
+ module Promotion
3
+ module Rules
4
+ # The OAuth applications a promotion rule is scoped to, shared by every rule that narrows by
5
+ # app. Kept in `cm_oauth_application_promotion_rules` rather than a preference so "which
6
+ # promotions use this app" is a join, and deleting an app cannot leave a dead id in a rule.
7
+ # How an empty list behaves is up to the rule.
8
+ module OauthApplicationScoped
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ has_many :oauth_application_promotion_rules,
13
+ class_name: 'SpreeCmCommissioner::OauthApplicationPromotionRule',
14
+ foreign_key: :promotion_rule_id,
15
+ inverse_of: :promotion_rule,
16
+ autosave: true,
17
+ dependent: :destroy
18
+
19
+ # For reading and reporting. Not written through: that would load and validate every
20
+ # Spree::OauthApplication (whose `api_key` is encrypted) just to link it, and the admin form
21
+ # posts ids anyway.
22
+ has_many :oauth_applications, through: :oauth_application_promotion_rules, class_name: 'Spree::OauthApplication'
23
+ end
24
+
25
+ # Reads the join rows, loaded once per rule instance: eligibility is re-checked several times in
26
+ # one cart update, and a plain `pluck` would query every time. Join rows rather than
27
+ # `oauth_applications`, which would load every app just to get its id.
28
+ def oauth_application_ids
29
+ oauth_application_promotion_rules.load.reject(&:marked_for_destruction?).map(&:oauth_application_id)
30
+ end
31
+
32
+ # What the admin form posts. Only the join rows change, saved with the rule. The blank value a
33
+ # multi-select always submits is dropped.
34
+ def oauth_application_ids=(ids)
35
+ wanted = Array(ids).compact_blank.map(&:to_i).uniq
36
+ existing = oauth_application_promotion_rules.load.to_a
37
+
38
+ existing.each { |row| row.mark_for_destruction unless wanted.include?(row.oauth_application_id) }
39
+ (wanted - existing.map(&:oauth_application_id)).each { |id| oauth_application_promotion_rules.build(oauth_application_id: id) }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ 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
 
@@ -0,0 +1,8 @@
1
+ module SpreeCmCommissioner
2
+ class OauthApplicationPromotionRule < Base
3
+ belongs_to :promotion_rule, class_name: 'Spree::PromotionRule'
4
+ belongs_to :oauth_application, class_name: 'Spree::OauthApplication'
5
+
6
+ validates :oauth_application_id, uniqueness: { scope: :promotion_rule_id }
7
+ end
8
+ end
@@ -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,87 @@
1
+ module SpreeCmCommissioner
2
+ module Promotion
3
+ module Rules
4
+ # Limits a promotion to a customer's Nth booking: 2 → 2 is "2nd booking only", 5 → (empty) is
5
+ # "5th booking and later". With apps chosen, only bookings placed through them count (the 2nd
6
+ # Wing booking); with none, bookings from every app count. Narrow by vendor with its own rule.
7
+ class CustomerOrderCount < Spree::PromotionRule
8
+ include OauthApplicationScoped
9
+
10
+ # Bookings that still stand. Also exactly the predicate of the partial index
11
+ # `idx_orders_user_for_limits` (user_id WHERE state IN complete, resumed), so the count is an
12
+ # index lookup on one user's rows.
13
+ COUNTED_STATES = %w[complete resumed].freeze
14
+
15
+ preference :min_order_number, :integer, default: 2
16
+ # Spree casts an empty integer field to 0, so 0 (or empty) means "no upper limit".
17
+ preference :max_order_number, :integer, default: 0
18
+
19
+ validate :order_number_range_valid
20
+
21
+ # Line items too: PromotionHandler::Cart activates a promotion when it is eligible for the
22
+ # line item just added, and a rule that does not apply to line items is skipped there.
23
+ def applicable?(promotable)
24
+ promotable.is_a?(Spree::Order) || promotable.is_a?(Spree::LineItem)
25
+ end
26
+
27
+ def eligible?(promotable, _options = {})
28
+ order = promotable.is_a?(Spree::LineItem) ? promotable.order : promotable
29
+
30
+ if order.user_id.nil?
31
+ eligibility_errors.add(:base, eligibility_error_message(:customer_order_count_login_required))
32
+ elsif !order_number_in_range?(order_number(order))
33
+ eligibility_errors.add(:base, eligibility_error_message(:customer_order_count_not_eligible))
34
+ end
35
+
36
+ eligibility_errors.empty?
37
+ end
38
+
39
+ # Which booking this order is for its customer, counted no further than the range needs.
40
+ # Memoized: one cart update calls eligible? several times (line item, order, each adjustment)
41
+ # and the answer can't change within it.
42
+ def order_number(order)
43
+ @order_numbers ||= {}
44
+ @order_numbers[[order.id, order.completed_at]] ||= previous_orders(order).limit(count_cap).count + 1
45
+ end
46
+
47
+ def min_order_number
48
+ preferred_min_order_number.to_i
49
+ end
50
+
51
+ def max_order_number
52
+ max = preferred_max_order_number.to_i
53
+ max.positive? ? max : nil
54
+ end
55
+
56
+ private
57
+
58
+ # A completed order counts only bookings completed before it, so re-checking it later
59
+ # (recalculation, cancel) isn't changed by bookings the customer made afterwards.
60
+ def previous_orders(order)
61
+ scope = Spree::Order.where(user_id: order.user_id, state: COUNTED_STATES).where.not(id: order.id)
62
+ app_ids = oauth_application_ids
63
+ scope = scope.where(oauth_application_id: app_ids) if app_ids.any?
64
+ order.completed_at ? scope.where(completed_at: ...order.completed_at) : scope
65
+ end
66
+
67
+ # Counting past this can't change the answer, so a customer with 300 bookings costs the
68
+ # same as one with 3.
69
+ def count_cap
70
+ max_order_number || min_order_number
71
+ end
72
+
73
+ def order_number_in_range?(number)
74
+ number >= min_order_number && (max_order_number.nil? || number <= max_order_number)
75
+ end
76
+
77
+ def order_number_range_valid
78
+ if min_order_number < 1
79
+ errors.add(:base, :customer_order_count_min_too_low)
80
+ elsif max_order_number && max_order_number < min_order_number
81
+ errors.add(:base, :customer_order_count_max_below_min)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,34 @@
1
+ module SpreeCmCommissioner
2
+ module Promotion
3
+ module Rules
4
+ # Limits a promotion to orders placed through chosen OAuth applications — a tenant app or a
5
+ # mini app (Wing, ACLEDA, Vattanac), each of which logs its users in with its own client.
6
+ #
7
+ # Unlike Vendors/GuestAgeGroup, an empty list matches nothing: a channel promotion saved
8
+ # before anyone picked a channel must not leak to every channel. An order with no
9
+ # `oauth_application_id` (older orders, or a path that does not record it) never matches.
10
+ class OauthApplications < Spree::PromotionRule
11
+ include OauthApplicationScoped
12
+
13
+ # Line items too: PromotionHandler::Cart activates a promotion when it is eligible for the
14
+ # line item just added, and a rule that does not apply to line items is skipped there — so
15
+ # an order-only rule let every cart attach the promotion with a dead (ineligible) adjustment.
16
+ def applicable?(promotable)
17
+ promotable.is_a?(Spree::Order) || promotable.is_a?(Spree::LineItem)
18
+ end
19
+
20
+ # Reads the column rather than the association, so eligibility — re-checked on every cart
21
+ # recalculation — costs no query.
22
+ def eligible?(promotable, _options = {})
23
+ order = promotable.is_a?(Spree::LineItem) ? promotable.order : promotable
24
+
25
+ unless oauth_application_ids.include?(order.oauth_application_id)
26
+ eligibility_errors.add(:base, eligibility_error_message(:oauth_application_not_eligible))
27
+ end
28
+
29
+ eligibility_errors.empty?
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,63 @@
1
+ module SpreeCmCommissioner
2
+ module Seats
3
+ # Guards switching a product's auto_assign_seats on: every seat is linked in Seat order, and there is
4
+ # a free seat for every ticket still to be sold. Checks each variant that has seats on an active
5
+ # layout; every problem becomes one error on :auto_assign_seats.
6
+ #
7
+ # validates_with SpreeCmCommissioner::Seats::AutoAssignValidator
8
+ class AutoAssignValidator < ActiveModel::Validator
9
+ def validate(product)
10
+ seated = product.variants_including_master.filter_map do |variant|
11
+ seats = seats_for(variant)
12
+ [variant, seats] if seats.exists?
13
+ end
14
+
15
+ return product.errors.add(:auto_assign_seats, 'has no seats on an active seat layout to assign') if seated.empty?
16
+
17
+ seated.each do |variant, seats|
18
+ [unlinked_seats_error(variant, seats), *not_enough_seats_errors(variant, seats)].compact.each do |message|
19
+ product.errors.add(:auto_assign_seats, message)
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def seats_for(variant)
27
+ SpreeCmCommissioner::Block.joins(:seat_layout)
28
+ .where(variant_id: variant.id, block_type: %i[seat sleeping_seat])
29
+ .where(cm_seat_layouts: { status: SpreeCmCommissioner::SeatLayout.statuses[:active] })
30
+ end
31
+
32
+ # A seat left out of Seat order (no seat_index) is never handed out, while its ticket still sells.
33
+ #
34
+ # 20 seats, 18 linked → 'VIP: 2 seats are not linked in Seat order'
35
+ def unlinked_seats_error(variant, seats)
36
+ count = seats.where(seat_index: nil).count
37
+ return if count.zero?
38
+
39
+ "#{variant.descriptive_name}: #{count} #{count == 1 ? 'seat is' : 'seats are'} not linked in Seat order"
40
+ end
41
+
42
+ # Every ticket still to be sold needs a free seat, per date (one undated item for an event).
43
+ # Fewer seats than tickets means a paid guest with no seat; more is fine, the extra stays empty.
44
+ #
45
+ # Tickets still to be sold: public stock + public carts + private-sale pool + its carts. A seat
46
+ # locked with Seats::LockBlocks is in neither side: it already lowered quantity_available, and its
47
+ # ReservedBlock marks it taken.
48
+ #
49
+ # 100 seats, 5 taken, 98 tickets to sell → 'VIP on 2026-12-01: 98 tickets can still be sold but only 95 seats are free'
50
+ def not_enough_seats_errors(variant, seats)
51
+ variant.inventory_items.active.filter_map do |item|
52
+ taken = SpreeCmCommissioner::ReservedBlock.unavailable.where(inventory_item_id: item.id).select(:block_id)
53
+ free = seats.where.not(id: taken).count
54
+ to_sell = item.quantity_available + item.quantity_on_hold + item.quantity_locked + item.quantity_locked_on_hold
55
+ next if free >= to_sell
56
+
57
+ on = item.inventory_date ? " on #{item.inventory_date}" : ''
58
+ "#{variant.descriptive_name}#{on}: #{to_sell} tickets can still be sold but only #{free} seats are free"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,14 @@
1
+ <!-- insert_after "[data-hook='admin_product_form_promotionable']" -->
2
+
3
+ <div>
4
+ <%= f.field_container :auto_assign_seats do %>
5
+ <%= f.label :auto_assign_seats do %>
6
+ <%= f.check_box :auto_assign_seats %>
7
+ Auto-assign seats
8
+ <% end %>
9
+ <%= f.error_message_on :auto_assign_seats %>
10
+ <small class="form-text text-muted">
11
+ Customers don't pick seats; each paid order gets the best free seats together, following the layout's Seat order. Can only be switched on once every seat of this product is linked in Seat order.
12
+ </small>
13
+ <% end %>
14
+ </div>
@@ -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),