spree_cm_commissioner 2.14.0 → 2.14.1

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test_and_build_gem.yml +5 -3
  3. data/Gemfile.lock +1 -1
  4. data/app/assets/javascripts/spree_cm_commissioner/backend.js +1 -0
  5. data/app/assets/javascripts/spree_cm_commissioner/promotion_rule_pickers.js +12 -0
  6. data/app/controllers/spree/api/v2/platform/homepage_section_relatable_options_controller.rb +1 -0
  7. data/app/models/concerns/spree_cm_commissioner/scoped_fee_rule.rb +1 -6
  8. data/app/models/spree_cm_commissioner/product_decorator.rb +9 -4
  9. data/app/models/spree_cm_commissioner/relatable_option_label.rb +20 -0
  10. data/app/models/spree_cm_commissioner/vendor_decorator.rb +15 -0
  11. data/app/serializers/spree_cm_commissioner/api/v2/platform/homepage_section_relatable_options_serializer.rb +2 -2
  12. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/client.rb +11 -0
  13. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/inventory/external_inventory_items.rb +16 -3
  14. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/inventory/unstock_inventory.rb +35 -5
  15. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_locations.rb +39 -7
  16. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_product.rb +1 -1
  17. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_routes.rb +31 -13
  18. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_seat_layout.rb +20 -2
  19. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_service_calendars.rb +18 -10
  20. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb +7 -2
  21. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_variant.rb +9 -2
  22. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_vehicle_types.rb +12 -1
  23. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip.rb +8 -1
  24. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip_location.rb +32 -0
  25. data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/full_sync_strategy.rb +2 -1
  26. data/app/services/spree_cm_commissioner/room_types/create.rb +10 -3
  27. data/app/services/spree_cm_commissioner/room_types/update_room_variant.rb +22 -0
  28. data/app/services/spree_cm_commissioner/room_types.rb +9 -0
  29. data/app/views/spree/admin/promotions/rules/_product.html.erb +25 -0
  30. data/app/views/spree/admin/promotions/rules/_user.html.erb +21 -0
  31. data/app/views/spree/admin/promotions/rules/_vendors.html.erb +14 -1
  32. data/config/locales/en.yml +5 -0
  33. data/config/locales/km.yml +5 -0
  34. data/lib/spree_cm_commissioner/version.rb +1 -1
  35. metadata +8 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba98474e53d7bb369512ec096ad8459a8baae5d2d77985462e99d48cd4d68820
4
- data.tar.gz: 9588226b0da09b905292b7518b656ee4261d03ca1e6e55beb526a96ee3365e87
3
+ metadata.gz: 2d7734080da716bf13a816acbd174d75272acdbbf4fd5a257d5e6c535eb7cece
4
+ data.tar.gz: 310a647df40f1c0d6f0cab1a3f676d7f558e07d0a45108addd7a1d552ebd4083
5
5
  SHA512:
6
- metadata.gz: f09ce118715100ba4d56d0b3c828c946277d632329c1b84e0517f1b513ec4cf64d055bceb6696983324b46f4df428ad0626f959dc961f1e73bdf22c4fac79b51
7
- data.tar.gz: 72ca04d1fe1ab0f6741269127a90b9f005c7569b55b7e41e619fa3bce15564131414dacf36d619a4d44020a57e8e298581d42f0a06b7933a682343058a266e39
6
+ metadata.gz: efb1b8f98edd7bb4a815086717aad5722a2e22ca6dd7c9472df0aa18ce1259cbd26674c45a3b0e8a064ff69e194f6b650bfded02528d7ef6078c74e62aad0fee
7
+ data.tar.gz: e578ec7e879cfade2a629c9a452283689942890aae8ad5b82b81bb67dba4f3155aaba401f76d77ae603435614e90a610293f2e1c9fde942627dab76bec5f6241
@@ -117,7 +117,9 @@ jobs:
117
117
 
118
118
  setup:
119
119
  needs: [validate-commits]
120
- runs-on: ubuntu-latest
120
+ # Pinned (also test/build_and_publish): newer images only ship the latest 3.2.x patch, and
121
+ # production runs exactly ruby:3.2.0 (cm-market-server Dockerfile-app).
122
+ runs-on: ubuntu-24.04
121
123
 
122
124
  services:
123
125
  db:
@@ -170,7 +172,7 @@ jobs:
170
172
 
171
173
  test:
172
174
  needs: [setup]
173
- runs-on: ubuntu-latest
175
+ runs-on: ubuntu-24.04
174
176
  if: github.event_name == 'pull_request'
175
177
 
176
178
  strategy:
@@ -279,7 +281,7 @@ jobs:
279
281
 
280
282
  build_and_publish:
281
283
  needs: [setup]
282
- runs-on: ubuntu-latest
284
+ runs-on: ubuntu-24.04
283
285
  if: startsWith(github.ref, 'refs/tags/')
284
286
 
285
287
  steps:
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.14.0)
37
+ spree_cm_commissioner (2.14.1)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -4,3 +4,4 @@
4
4
  //= require spree_cm_commissioner/annual_calendar_form
5
5
  //= require spree_cm_commissioner/tenant_payment_icon_fields
6
6
  //= require spree_cm_commissioner/copy_to_clipboard
7
+ //= require spree_cm_commissioner/promotion_rule_pickers
@@ -0,0 +1,12 @@
1
+ // Our product/user/vendors promotion rule partials use select2autocomplete, which spree_backend only
2
+ // initializes on spree:load. "Add rule" is a remote form whose create.js.erb appends the new rule and
3
+ // re-inits only the core product_picker/user_picker, so without this the new rule's picker stays a
4
+ // plain, empty select until the page is reloaded.
5
+ //
6
+ // jquery_ujs fires ajax:complete as a jQuery event (not a DOM one), after the JS response has run.
7
+ // Delegated from document so Turbo page loads don't stack duplicate listeners.
8
+ $(document).on("ajax:complete", "#new_product_rule_form", function () {
9
+ $("#rules select[data-autocomplete-url-value]:not(.select2-hidden-accessible)").each(function () {
10
+ buildParamsFromDataAttrs(this);
11
+ });
12
+ });
@@ -7,6 +7,7 @@ module Spree
7
7
  return Spree::Vendor if params['model_class'] == 'Spree::Vendor'
8
8
  return Spree::Product if params['model_class'] == 'Spree::Product'
9
9
  return Spree::Taxon if params['model_class'] == 'Spree::Taxon'
10
+ return Spree.user_class if params['model_class'] == 'Spree::User'
10
11
 
11
12
  nil
12
13
  end
@@ -42,13 +42,8 @@ module SpreeCmCommissioner
42
42
  }
43
43
  end
44
44
 
45
- # "Name (slug)" for a product/event/vendor, so same-named records can be told apart in the
46
- # admin scope-target picker. Events (taxons) show their permalink first: that's what the
47
- # picker searches them by, and it reads clearer than the taxon's generated slug.
48
45
  def self.ruleable_label(record)
49
- name = record.respond_to?(:pretty_name) ? record.pretty_name : record.try(:name)
50
- slug = record.try(:permalink).presence || record.try(:slug).presence
51
- slug ? "#{name} (#{slug})" : name.to_s
46
+ SpreeCmCommissioner::RelatableOptionLabel.call(record)
52
47
  end
53
48
 
54
49
  class_methods do
@@ -187,16 +187,21 @@ module SpreeCmCommissioner
187
187
  "#{Spree::Store.default.formatted_url}/tickets/#{slug}"
188
188
  end
189
189
 
190
- # Same default stay as Vendor#hotel_url so the dashboard's room preview opens on bookable dates.
190
+ # Same default stay as Vendor#hotel_url. `hotel` is the vendor slug, matching the `vendorSlug`
191
+ # param the app's RoomDetailsRoute expects when a guest taps through from the room list.
191
192
  def room_url
192
193
  checkin_date = Date.tomorrow
194
+ # Rooms only support a single variant for now, so `first` is safe here. Once multivariant
195
+ # rooms ship, this needs to pick the specific variant being previewed instead.
196
+ room_variant = variants.first
193
197
  query = {
198
+ hotel: vendor&.slug,
194
199
  roomQty: 1,
195
- numberOfAdults: 2,
196
- numberOfChildren: 0,
200
+ numberOfAdults: room_variant&.number_of_adults || SpreeCmCommissioner::VariantOptions::DEFAULT_NUMBER_OF_ADULTS,
201
+ numberOfChildren: room_variant&.number_of_kids || 0,
197
202
  checkinDate: checkin_date.iso8601,
198
203
  checkoutDate: (checkin_date + 2.days).iso8601
199
- }.to_query
204
+ }.compact.to_query
200
205
 
201
206
  "#{Spree::Store.default.formatted_url}/rooms/#{slug}?#{query}"
202
207
  end
@@ -0,0 +1,20 @@
1
+ module SpreeCmCommissioner
2
+ # "Name (slug)" label for records picked via the homepage_section_relatable_options endpoint
3
+ # (fee rule targets, promotion rules), so same-named records can be told apart. Events (taxons)
4
+ # show their permalink: that's what pickers search them by, and it reads clearer than the
5
+ # taxon's generated slug. Users have no name/slug, so they show "Full name - email (login)".
6
+ module RelatableOptionLabel
7
+ def self.call(record)
8
+ return user_label(record) if record.is_a?(Spree.user_class)
9
+
10
+ name = record.respond_to?(:pretty_name) ? record.pretty_name : record.try(:name)
11
+ slug = record.try(:permalink).presence || record.try(:slug).presence
12
+ slug ? "#{name} (#{slug})" : name.to_s
13
+ end
14
+
15
+ def self.user_label(user)
16
+ name = [user.full_name, user.email.presence || user.phone_number].compact_blank.join(' - ')
17
+ user.login.present? ? "#{name} (#{user.login})" : name
18
+ end
19
+ end
20
+ end
@@ -14,11 +14,16 @@ module SpreeCmCommissioner
14
14
 
15
15
  base.attr_accessor :service_availabilities
16
16
 
17
+ # slug lets admin pickers (e.g. the promotion vendors rule) tell same-named vendors apart.
18
+ base.whitelisted_ransackable_attributes = %w[name state slug]
19
+
17
20
  base.store :preferences, accessors: %i[account_name account_number penalty_rate penalty_label six_months_discount twelve_months_discount]
18
21
 
19
22
  base.store_public_metadata :sellable_product_types, :array, default: []
20
23
  base.validate :validate_sellable_product_types
21
24
 
25
+ base.before_validation :strip_about_us_html, if: :accommodation?
26
+
22
27
  base.before_save :generate_code, if: :code.nil?
23
28
 
24
29
  base.before_save :set_default_service_origin, if: :new_record?
@@ -234,6 +239,16 @@ module SpreeCmCommissioner
234
239
  self.code = (code.presence || name[0, 3].upcase)
235
240
  end
236
241
 
242
+ # Accommodation vendors only expose a plain textarea for `about_us` (the app renders it as
243
+ # plain `Text`, unlike the TinyMCE-backed `about_us` on other vendor kinds, which the app
244
+ # renders as HTML) — so pasted markup (e.g. from an Elementor/WordPress export) must be
245
+ # stripped here rather than rendered literally in the app.
246
+ def strip_about_us_html
247
+ return if about_us.blank?
248
+
249
+ self.about_us = CGI.unescapeHTML(::ActionController::Base.helpers.strip_tags(about_us)).strip
250
+ end
251
+
237
252
  def organizer_url
238
253
  "#{Spree::Store.default.formatted_url}/organizers/#{slug}"
239
254
  end
@@ -3,9 +3,9 @@ module SpreeCmCommissioner
3
3
  module V2
4
4
  module Platform
5
5
  class HomepageSectionRelatableOptionsSerializer < ::Spree::Api::V2::Platform::BaseSerializer
6
- # Used by the fee rule forms' scope-target picker; other callers keep using `name`.
6
+ # Used by the fee rule and promotion rule pickers; other callers keep using `name`.
7
7
  attribute :name_with_slug do |object|
8
- SpreeCmCommissioner::ScopedFeeRule.ruleable_label(object)
8
+ SpreeCmCommissioner::RelatableOptionLabel.call(object)
9
9
  end
10
10
 
11
11
  attribute :name do |object|
@@ -23,6 +23,17 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::ExternalClient
23
23
  handle_response(response, SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::Location, collection: true)
24
24
  end
25
25
 
26
+ # Get the operator's published trips with their cities in travel order. BookMeBus caches this list for
27
+ # 7 days, so a brand-new trip can take that long to show up.
28
+ # @return [Array<SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::TripLocation>]
29
+ def get_trip_locations! # rubocop:disable Naming/AccessorMethodName
30
+ endpoint = "#{BASE_PATH}/trip_locations"
31
+ params = { operator_id: integration.operator_id }.compact
32
+
33
+ response = get(endpoint, params)
34
+ handle_response(response, SpreeCmCommissioner::Integrations::BookMeBusV1::Resources::TripLocation, collection: true) || []
35
+ end
36
+
26
37
  # Trip Operations
27
38
 
28
39
  # Search available trips for a given route and date
@@ -48,7 +48,7 @@ module SpreeCmCommissioner
48
48
 
49
49
  def fetch_seat_layout(line_item, trip_mapping)
50
50
  integration.client.get_seat_layout!(
51
- trip_id: trip_mapping.external_id.to_i,
51
+ trip_id: external_trip_id(trip_mapping),
52
52
  from_stop_id: fetch_stop_id(trip_mapping, line_item.boarding_trip_stop_id, 'from_stop_id'),
53
53
  to_stop_id: fetch_stop_id(trip_mapping, line_item.drop_off_trip_stop_id, 'to_stop_id'),
54
54
  on_date: line_item.from_date.to_date.strftime('%Y-%m-%d')
@@ -70,7 +70,7 @@ module SpreeCmCommissioner
70
70
  items = []
71
71
  items += seat_layout.seats.map do |seat|
72
72
  {
73
- id: "#{trip_mapping.external_id}-#{seat.label}",
73
+ id: "#{item_key(trip_mapping)}-#{seat.label}",
74
74
  seat_label: seat.label,
75
75
  seat_type: seat.seat_type_name,
76
76
  layer: seat.layer,
@@ -79,11 +79,24 @@ module SpreeCmCommissioner
79
79
  position: seat.position,
80
80
  from_stop_id: seat_layout.from_stop_id,
81
81
  departure_date: line_item.from_date.to_date.strftime('%Y-%m-%d'),
82
- trip_id: trip_mapping.external_id.to_i
82
+ trip_id: external_trip_id(trip_mapping)
83
83
  }
84
84
  end
85
85
  items
86
86
  end
87
+
88
+ # The dashboard builds turbo-frame/drawer DOM ids from the item id, and a colon makes those ids unusable in
89
+ # CSS selectors - Turbo then cannot match the frame and shows "Content missing". The segment key
90
+ # ("1669:1:2") therefore has its colons swapped for dashes.
91
+ def item_key(trip_mapping)
92
+ trip_mapping.external_id.to_s.tr(':', '-')
93
+ end
94
+
95
+ # The mapping's external_id is a per-segment key (see Resources::Trip#sync_identifier); the raw
96
+ # BookMeBus trip id lives in the payload.
97
+ def external_trip_id(trip_mapping)
98
+ (trip_mapping.external_payload&.dig('trip_id') || trip_mapping.external_id).to_i
99
+ end
87
100
  end
88
101
  end
89
102
  end
@@ -48,7 +48,9 @@ class SpreeCmCommissioner::Integrations::BookMeBusV1::Inventory::UnstockInventor
48
48
  )
49
49
  raise SpreeCmCommissioner::Integrations::SyncError, "Integration mapping not found for trip ##{internal_trip_id}" if trip_mapping.nil?
50
50
 
51
- external_trip_id = trip_mapping.external_id
51
+ # The mapping's external_id is a per-segment key (see Resources::Trip#sync_identifier); the raw
52
+ # BookMeBus trip id lives in the payload.
53
+ external_trip_id = trip_mapping.external_payload&.dig('trip_id') || trip_mapping.external_id
52
54
  raise SpreeCmCommissioner::Integrations::SyncError, 'External trip_id missing from mapping' if external_trip_id.blank?
53
55
 
54
56
  boarding_mapping = find_trip_stop_mapping(integration, line_item.boarding_trip_stop_id)
@@ -71,10 +73,10 @@ class SpreeCmCommissioner::Integrations::BookMeBusV1::Inventory::UnstockInventor
71
73
  from_stop_time_id: from_stop_time_id,
72
74
  to_stop_time_id: to_stop_time_id,
73
75
  departure_date: line_item.from_date.to_date.strftime('%Y-%m-%d'),
74
- reserved_seats: build_reserved_seats(line_item, from_stop_id, integration),
76
+ reserved_seats: build_reserved_seats(line_item, from_stop_id, integration, single_layer_name(trip_mapping.internal, integration)),
75
77
  contact_name: address&.full_name.presence || order.email,
76
78
  contact_email: order.email,
77
- contact_phone: address&.phone.presence || order.user&.phone,
79
+ contact_phone: contact_phone_for(order, address),
78
80
  internal_order: order
79
81
  }
80
82
  )
@@ -145,7 +147,7 @@ class SpreeCmCommissioner::Integrations::BookMeBusV1::Inventory::UnstockInventor
145
147
  # expects a full passenger list with label/layer nil in that case, not an empty payload, so it
146
148
  # can assign real seats server-side - dropping those guests entirely would undercount
147
149
  # number_of_seats and reserve fewer seats than the order was actually paid for.
148
- def build_reserved_seats(line_item, from_stop_id, integration) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
150
+ def build_reserved_seats(line_item, from_stop_id, integration, default_layer = nil) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
149
151
  seats = line_item.guests.map do |guest|
150
152
  seat_label = guest.block&.label
151
153
 
@@ -162,7 +164,7 @@ class SpreeCmCommissioner::Integrations::BookMeBusV1::Inventory::UnstockInventor
162
164
  name: name,
163
165
  label: seat_label,
164
166
  seat_type: block_type == 'seat' || block_type.blank? ? 'normal' : block_type,
165
- layer: seat_label.present? ? (guest.block&.seat_section&.name || 'BUS') : nil,
167
+ layer: seat_label.present? ? (guest.block&.seat_section&.name || default_layer || 'BUS') : nil,
166
168
  nationality: guest_nationality_code(guest, integration)
167
169
  }
168
170
  end
@@ -170,6 +172,34 @@ class SpreeCmCommissioner::Integrations::BookMeBusV1::Inventory::UnstockInventor
170
172
  seats.empty? ? {} : { from_stop_id.to_s => seats }
171
173
  end
172
174
 
175
+ # A single-layer layout keeps its blocks top-level (no SeatSection, see SyncSeatLayout#build_seat_sections_by_layer),
176
+ # so the block carries no layer name. BookMeBus only marks a seat taken when label *and* layer match its own
177
+ # layout, and a single layer is not always called "BUS" ("VIP VAN", "2", ...) - sending the 'BUS' fallback left
178
+ # the seat reserved but still showing free in BookMeBus's seat map. The real name is recorded in the payload of
179
+ # the vehicle type's seat-layout mapping.
180
+ def single_layer_name(trip, integration)
181
+ seat_layout = trip&.vehicle_type&.seat_layout
182
+ return if seat_layout.blank?
183
+
184
+ layers = SpreeCmCommissioner::IntegrationMapping.find_by(
185
+ integration_id: integration.id,
186
+ internal_type: 'SpreeCmCommissioner::SeatLayout',
187
+ internal_id: seat_layout.id,
188
+ status: :active
189
+ )&.external_payload&.dig('layers')
190
+
191
+ layers.first if layers.is_a?(Array) && layers.size == 1
192
+ end
193
+
194
+ # The billing address first. A booking made in the app often has no phone on it, so fall back to the contact
195
+ # number the order itself carries, then the account's, preferring the international format. Spree::User has
196
+ # `phone_number` / `intel_phone_number` - there is no `phone`, which raised NoMethodError on those bookings.
197
+ def contact_phone_for(order, address)
198
+ address&.phone.presence ||
199
+ order.intel_phone_number.presence || order.phone_number.presence ||
200
+ order.user&.intel_phone_number.presence || order.user&.phone_number.presence
201
+ end
202
+
173
203
  # Mirrors PricingModels::GuestContext's own fallback order (nationality_id, then
174
204
  # guest.nationality_group, then guest.saved_guest.nationality_group) so the reservation actually
175
205
  # sent to BookMeBus agrees with whatever nationality pricing already used to price the guest.
@@ -12,6 +12,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
12
12
  raise SpreeCmCommissioner::Integrations::SyncError, 'Integration must have a vendor to sync locations' if @integration.vendor.blank?
13
13
 
14
14
  synced_location_mappings = sync_items!
15
+ return if synced_location_mappings.nil?
15
16
 
16
17
  cleanup_stale_mappings!(synced_location_mappings)
17
18
  end
@@ -26,19 +27,38 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
26
27
  @geocoder ||= SpreeCmCommissioner::GoogleMapsGeocoder.new
27
28
  end
28
29
 
29
- # Sync all available locations from the API and create them as places
30
+ # Sync the cities on the operator's published trips as places. `/locations` lists every city on BookMeBus
31
+ # (240+, mixed provinces, piers, villages), almost none of which this operator serves; each extra place costs
32
+ # a geocoding call and clutters the search pickers. The trip legs carry the same city records.
30
33
  def sync_items!
31
- external_locations = begin
32
- @sync_result.track_api_call('get_locations') { client.get_locations! }
33
- rescue SpreeCmCommissioner::Integrations::ExternalClientError => e
34
- raise SpreeCmCommissioner::Integrations::SyncError, "Failed to sync locations: #{e.message}"
34
+ external_locations = fetch_external_locations
35
+
36
+ # Nothing usable: keep the current places rather than archive them all on what may be a bad response.
37
+ if external_locations.empty?
38
+ Rails.logger.warn "BookMeBusV1 SyncLocations: no locations returned for integration ##{@integration.id}, keeping existing locations"
39
+ return nil
35
40
  end
36
41
 
37
- external_locations.map do |external_location|
42
+ external_locations.filter_map do |external_location|
38
43
  sync_item!(external_location)
44
+ rescue SpreeCmCommissioner::GoogleMapsGeocoder::GeocodingError => e
45
+ # One city Google can't find (e.g. a village) must not abort the rest; without a place it can't be routed anyway.
46
+ Rails.logger.warn "BookMeBusV1 SyncLocations: skipped location #{external_location.id} (#{external_location.name}): #{e.message}"
47
+ @sync_result.increment_metric(:location, :skipped)
48
+ nil
39
49
  end
40
50
  end
41
51
 
52
+ def fetch_external_locations
53
+ trip_locations = begin
54
+ @sync_result.track_api_call('get_trip_locations') { client.get_trip_locations! }
55
+ rescue SpreeCmCommissioner::Integrations::ExternalClientError => e
56
+ raise SpreeCmCommissioner::Integrations::SyncError, "Failed to sync locations: #{e.message}"
57
+ end
58
+
59
+ trip_locations.flat_map(&:locations).uniq(&:id)
60
+ end
61
+
42
62
  # Sync a single location and create it as a place with vendor place mapping
43
63
  def sync_item!(external_location)
44
64
  mapping = SpreeCmCommissioner::VendorPlace.find_or_initialize_integration_mapping(
@@ -49,7 +69,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
49
69
  vendor_place = mapping.internal
50
70
 
51
71
  place = vendor_place.place ||
52
- SpreeCmCommissioner::Place.find_by(name: external_location.name) ||
72
+ find_city_place(external_location.name) ||
53
73
  SpreeCmCommissioner::Place.new
54
74
 
55
75
  @sync_result.track(:location, place) do |tracker|
@@ -83,6 +103,18 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
83
103
  mapping
84
104
  end
85
105
 
106
+ # A city only ever matches a top-level place. Stop places (children of a city, named after their stop) can share
107
+ # the city's name, and an unscoped `find_by(name:)` could pick one, which pointed a vendor's trips at a stop place the
108
+ # app never searches. Whitespace/case are ignored (BookMeBus names carry stray spaces), and the oldest place wins so
109
+ # the choice is stable when duplicates already exist.
110
+ def find_city_place(name)
111
+ SpreeCmCommissioner::Place
112
+ .where(parent_id: nil)
113
+ .where('LOWER(TRIM(name)) = ?', name.to_s.strip.downcase)
114
+ .order(:id)
115
+ .first
116
+ end
117
+
86
118
  # Archive stale location mappings that are no longer in the external API
87
119
  def cleanup_stale_mappings!(synced_location_mappings)
88
120
  synced_ids = synced_location_mappings.to_set(&:external_id)
@@ -18,7 +18,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
18
18
  end
19
19
 
20
20
  def call(external_trip:, price:)
21
- external_id = "product-#{external_trip.trip_id}"
21
+ external_id = "product-#{external_trip.sync_identifier}"
22
22
 
23
23
  mapping = Spree::Product.find_or_initialize_integration_mapping(
24
24
  integration_id: @integration.id,
@@ -11,18 +11,28 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
11
11
  def call
12
12
  raise SpreeCmCommissioner::Integrations::SyncError, 'Integration must have a vendor to sync routes' if @integration.vendor.blank?
13
13
 
14
- synced_route_mappings = sync_items!
14
+ location_pairs = build_location_pairs
15
+
16
+ # Nothing usable (operator has no published trips, or BookMeBus returned nothing we can map): leave the
17
+ # current routes alone rather than archive them all on what may be a bad response.
18
+ if location_pairs.empty?
19
+ Rails.logger.warn "BookMeBusV1 SyncRoutes: no routes returned for integration ##{@integration.id}, keeping existing routes"
20
+ return
21
+ end
22
+
23
+ synced_route_mappings = sync_items!(location_pairs)
15
24
 
16
25
  cleanup_stale_mappings!(synced_route_mappings)
17
26
  end
18
27
 
19
28
  private
20
29
 
21
- # Generate routes from location pairs and sync them
22
- def sync_items!
23
- location_pairs = build_location_pairs
24
- return [] if location_pairs.empty?
30
+ def client
31
+ @client ||= @integration.client
32
+ end
25
33
 
34
+ # Sync a route for each location pair
35
+ def sync_items!(location_pairs)
26
36
  location_pairs.map do |origin_mapping, destination_mapping|
27
37
  sync_item!(origin_mapping, destination_mapping)
28
38
  end.compact
@@ -77,7 +87,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
77
87
  "route-#{origin_mapping.external_id}-#{destination_mapping.external_id}"
78
88
  end
79
89
 
80
- # Build all location pairs (A->B and B->A)
90
+ # Only the pairs BookMeBus has published trips for, not every combination of locations: an operator with
91
+ # 240 locations runs ~20 routes, and every extra route costs a search per day in SyncTrips.
81
92
  def build_location_pairs
82
93
  location_mappings = SpreeCmCommissioner::IntegrationMapping
83
94
  .includes(internal: :place)
@@ -86,17 +97,24 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
86
97
  internal_type: 'SpreeCmCommissioner::VendorPlace',
87
98
  status: :active
88
99
  )
89
- .to_a
90
- pairs = []
91
- location_mappings.combination(2).each do |origin, destination|
92
- pairs << [origin, destination]
93
- pairs << [destination, origin]
100
+ .index_by(&:external_id)
101
+
102
+ external_trip_locations.flat_map(&:location_pairs).uniq.filter_map do |origin_id, destination_id|
103
+ origin_mapping = location_mappings[origin_id]
104
+ destination_mapping = location_mappings[destination_id]
105
+
106
+ [origin_mapping, destination_mapping] if origin_mapping && destination_mapping
94
107
  end
108
+ end
95
109
 
96
- pairs
110
+ # Raised before any route is written, so a failed call never reaches cleanup_stale_mappings!.
111
+ def external_trip_locations
112
+ @sync_result.track_api_call('get_trip_locations') { client.get_trip_locations! }
113
+ rescue SpreeCmCommissioner::Integrations::ExternalClientError => e
114
+ raise SpreeCmCommissioner::Integrations::SyncError, "Failed to sync routes: #{e.message}"
97
115
  end
98
116
 
99
- # Archive stale route mappings that are no longer in the generated pairs
117
+ # Archive stale route mappings that BookMeBus no longer lists
100
118
  def cleanup_stale_mappings!(synced_route_mappings)
101
119
  synced_ids = synced_route_mappings.to_set(&:external_id)
102
120
  active_mappings = SpreeCmCommissioner::IntegrationMapping.where(
@@ -121,7 +121,10 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
121
121
  # template every other trip of that vehicle_type duplicates from on creation. Only seed it
122
122
  # once; each trip's own duplicated SeatLayout is what stays continuously in sync afterward
123
123
  # (see duplicate_to_trip_if_needed!/sync_trip_seat_layout!).
124
- return seat_layout if seat_layout.persisted?
124
+ if seat_layout.persisted?
125
+ backfill_vehicle_type_seats!(vehicle_type, seat_layout)
126
+ return seat_layout
127
+ end
125
128
 
126
129
  # Calculate dimensions based on layout
127
130
  dimensions = calculate_layout_dimensions(external_seat_layout)
@@ -142,6 +145,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
142
145
  # Sync blocks (seats) for this layout - no variant for templates
143
146
  sync_blocks!(seat_layout: seat_layout, external_seat_layout: external_seat_layout, variant: nil)
144
147
 
148
+ backfill_vehicle_type_seats!(vehicle_type, seat_layout)
149
+
145
150
  # Mark mapping as active
146
151
  mapping.mark_as_active!(
147
152
  external_payload: {
@@ -156,6 +161,18 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
156
161
  seat_layout
157
162
  end
158
163
 
164
+ # VehicleType#number_of_seats is what the apps print beside the vehicle ("VIP Van · 15 Seat"). SyncVehicleTypes
165
+ # cannot set it per trip (the record is shared by many trips), so it stayed 0 and every synced trip read
166
+ # "0 Seat" while a manually created vehicle type shows its count. The template layout is seeded once per
167
+ # vehicle type, so its seat total is the one stable value to copy. Only when unset: a count an operator
168
+ # entered is never overwritten.
169
+ def backfill_vehicle_type_seats!(vehicle_type, seat_layout)
170
+ return if vehicle_type.number_of_seats.to_i.positive?
171
+
172
+ total = seat_layout.total_seats.to_i
173
+ vehicle_type.update!(number_of_seats: total) if total.positive?
174
+ end
175
+
159
176
  # Duplicate vehicle_type seat layout to trip if trip doesn't have one
160
177
  # This ensures trips always get seat layouts even when the vehicle_type template
161
178
  # is created in the same sync operation
@@ -306,7 +323,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
306
323
  # top-level (no seat_section), matching prior behavior exactly. Only a genuinely
307
324
  # multi-layer layout (double-decker: "BUS" + "UPPER_DECK") gets one SeatSection per
308
325
  # layer, so `block.seat_section&.name` reports the real deck instead of always
309
- # falling back to the 'BUS' default UnstockInventory uses when it's nil.
326
+ # falling back to the 'BUS' default UnstockInventory uses when it's nil. A single layer's real name is
327
+ # kept in the template mapping's `layers` payload instead - UnstockInventory#single_layer_name reads it.
310
328
  #
311
329
  # @return [Hash{String => SpreeCmCommissioner::SeatSection}] section per layer name
312
330
  def build_seat_sections_by_layer(seat_layout, blocks_attrs)
@@ -44,16 +44,24 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
44
44
  def build_exception_rules(external_rules)
45
45
  return [] unless external_rules.is_a?(Array)
46
46
 
47
- external_rules.filter_map do |rule|
48
- next unless rule.is_a?(Hash)
49
-
50
- {
51
- 'from' => rule['from'] || rule['start_date'],
52
- 'to' => rule['to'] || rule['end_date'],
53
- 'type' => rule['type'] || 'exclusion',
54
- 'reason' => rule['reason'] || rule['not_available_reason']
55
- }.compact
56
- end
47
+ external_rules.filter_map { |rule| build_exception_rule(rule) }
48
+ end
49
+
50
+ def build_exception_rule(rule)
51
+ return unless rule.is_a?(Hash)
52
+
53
+ # BookMeBus sends exception dates as "13/10/2023", but ServiceCalendar's JSON schema only accepts
54
+ # ISO dates - an unnormalised rule made the whole calendar invalid, which dropped the trip.
55
+ from = parse_date(rule['from'] || rule['start_date'])&.iso8601
56
+ to = parse_date(rule['to'] || rule['end_date'])&.iso8601
57
+ return unless from && to
58
+
59
+ {
60
+ 'from' => from,
61
+ 'to' => to,
62
+ 'type' => rule['type'] || 'exclusion',
63
+ 'reason' => rule['reason'] || rule['not_available_reason']
64
+ }.compact
57
65
  end
58
66
  end
59
67
  end
@@ -112,8 +112,13 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
112
112
  stop_place: stop_place,
113
113
  location_place: location_place,
114
114
  sequence: sequence,
115
- allow_boarding: sequence != total_stops,
116
- allow_drop_off: sequence != 1,
115
+ # Each route pair owns its own trip (see Resources::Trip#sync_identifier), so a trip is only
116
+ # boardable at its first stop and only droppable at its last. Letting intermediate stops board or
117
+ # drop off made the same bus match every neighbouring search through its stops (a Sihanoukville
118
+ # → Siem Reap trip also listed under Phnom Penh → Siem Reap, with its own seat count), where
119
+ # BookMeBus returns one entry per search.
120
+ allow_boarding: sequence == 1,
121
+ allow_drop_off: sequence == total_stops,
117
122
  departure_time: parse_time(stop_time_data['departure_time']),
118
123
  arrival_time: parse_time(incoming_arrival_time)
119
124
  )
@@ -23,7 +23,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
23
23
  # Product/Trip/VehicleType instead of being the one exception to the pattern.
24
24
  mapping = Spree::Variant.find_or_initialize_integration_mapping(
25
25
  integration_id: @integration.id,
26
- external_id: build_variant_sku(external_trip)
26
+ external_id: build_variant_external_id(external_trip)
27
27
  )
28
28
 
29
29
  variant = mapping.internal
@@ -47,8 +47,15 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
47
47
 
48
48
  private
49
49
 
50
+ # Mapping key; already scoped by integration_id, so it needs no vendor part.
51
+ def build_variant_external_id(external_trip)
52
+ "variant-#{external_trip.sync_identifier}"
53
+ end
54
+
55
+ # Spree requires a SKU to be unique across all vendors, so it carries the vendor id: two vendors syncing the
56
+ # same BookMeBus operator (same trip ids) would otherwise collide on "SKU has already been taken".
50
57
  def build_variant_sku(external_trip)
51
- "variant-#{external_trip.trip_id}"
58
+ "variant-#{@vendor.id}-#{external_trip.sync_identifier}"
52
59
  end
53
60
 
54
61
  def standard_seat_option_value
@@ -53,7 +53,7 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
53
53
  # per-trip seat count already lives on the trip's own SeatLayout (total_seats).
54
54
  vehicle_type.assign_attributes(
55
55
  vendor: @integration.vendor,
56
- name: external_trip.vehicle_type_name,
56
+ name: unique_name_for(vehicle_type, external_trip),
57
57
  kind: map_vehicle_type_to_kind(external_trip.vehicle_type_type_name)
58
58
  )
59
59
  tracker.save_if_changed!(vehicle_type)
@@ -65,6 +65,17 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Polling
65
65
 
66
66
  private
67
67
 
68
+ # VehicleType#name is unique per vendor, but BookMeBus can hold several vehicle types with the same
69
+ # name under different ids (e.g. two "Night Bus", ids 123 and 159). Saving the second one failed, which
70
+ # silently dropped every trip using it. The id is appended only on a real clash, so the first keeps its
71
+ # plain name and the result stays stable across re-syncs.
72
+ def unique_name_for(vehicle_type, external_trip)
73
+ name = external_trip.vehicle_type_name
74
+ taken = SpreeCmCommissioner::VehicleType.where(vendor_id: @integration.vendor.id, name: name)
75
+ .where.not(id: vehicle_type.id).exists?
76
+ taken ? "#{name} (#{external_trip.vehicle_type_id})" : name
77
+ end
78
+
68
79
  def map_vehicle_type_to_kind(type_name)
69
80
  mapping = {
70
81
  'bus' => :bus,
@@ -93,8 +93,15 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::Resources
93
93
  non_local_price || local_price
94
94
  end
95
95
 
96
+ # BookMeBus returns one physical trip under every route pair it serves (e.g. SHV→PP→SR comes back
97
+ # for SHV→SR, PP→SR and SHV→PP), each time with `stop_times` sliced to that segment. Keying on the
98
+ # trip id alone made every pair overwrite one local Trip, so the segment ends are part of the key.
99
+ # The raw BookMeBus trip id stays in `trip_id` / the mapping payload, which is what API calls need.
96
100
  def sync_identifier
97
- trip_id || id
101
+ raw_id = trip_id || id
102
+ return raw_id unless origin_location_id && destination_location_id
103
+
104
+ "#{raw_id}:#{origin_location_id}:#{destination_location_id}"
98
105
  end
99
106
 
100
107
  # BookMeBus's own search_trips! results can include trips that are not currently bookable -
@@ -0,0 +1,32 @@
1
+ module SpreeCmCommissioner::Integrations::BookMeBusV1::Resources
2
+ # One published BookMeBus trip as listed by `/trip_locations`: the cities it passes through, in travel order.
3
+ # `location_legs` holds the same city objects as `/locations`, so ids match the synced location mappings.
4
+ class TripLocation < Base
5
+ attr_accessor :id,
6
+ :type,
7
+ :head_sign,
8
+ :short_name,
9
+ :route_type,
10
+ :operator_id,
11
+ :location_legs,
12
+ :stop_legs
13
+
14
+ def location_ids
15
+ Array(location_legs).filter_map { |leg| leg['id']&.to_s if leg.is_a?(Hash) }
16
+ end
17
+
18
+ # The cities as `Resources::Location`s (the legs carry the same fields as `/locations`), so places can be
19
+ # synced from the operator's trips alone. Ids are strings, as `/locations` returns them.
20
+ def locations
21
+ Array(location_legs).filter_map do |leg|
22
+ Location.new(leg.merge('id' => leg['id'].to_s)) if leg.is_a?(Hash) && leg['id']
23
+ end
24
+ end
25
+
26
+ # Every earlier -> later city of the trip is searchable (e.g. SHV>PP>SR also serves SHV>SR, PP>SR and SHV>PP).
27
+ # A round trip lists a city twice, so same-city pairs are dropped.
28
+ def location_pairs
29
+ location_ids.combination(2).reject { |origin_id, destination_id| origin_id == destination_id }
30
+ end
31
+ end
32
+ end
@@ -13,7 +13,8 @@ module SpreeCmCommissioner::Integrations::BookMeBusV1::SyncStrategies
13
13
  # - Archives stale location mappings
14
14
  #
15
15
  # b. Sync Routes:
16
- # - Generates route pairs from synced locations
16
+ # - Asks BookMeBus for the operator's published trips (trip_locations) and builds a route for each
17
+ # earlier -> later city pair on them, instead of pairing every location with every other
17
18
  # - Creates Route (origin_place → destination_place) records
18
19
  # - Creates IntegrationMapping for each route (for incremental sync scheduling)
19
20
  #
@@ -2,8 +2,8 @@ module SpreeCmCommissioner
2
2
  module RoomTypes
3
3
  # Creates an accommodation room type (a Spree::Product under a hotel vendor) from the dashboard's
4
4
  # one-request Add Room Type wizard: product, category, facilities, properties and photos in one
5
- # transaction. No sellable variant, stock or inventory is built here, so the room starts as Draft —
6
- # it isn't bookable until rates and occupancy are set up for it.
5
+ # transaction. The room starts as Draft; its sellable variant only exists once occupancy is set
6
+ # (here, if the wizard sent it, or later via `UpdateRoomVariant`) -- without it, it isn't bookable.
7
7
  class Create
8
8
  prepend ::Spree::ServiceModule::Base
9
9
 
@@ -15,6 +15,7 @@ module SpreeCmCommissioner
15
15
  product = create_product!(vendor, name, options)
16
16
  assign_taxon!(product, options[:taxon_id])
17
17
  assign_facilities!(product, options[:facility_option_type_ids], options[:facility_option_value_ids])
18
+ assign_room_variant!(product, options[:occupancy_option_value_ids])
18
19
  assign_properties!(product, options[:properties])
19
20
  end
20
21
 
@@ -38,7 +39,7 @@ module SpreeCmCommissioner
38
39
  private
39
40
 
40
41
  # Spree requires a master price (`require_master_price`), but a room's nightly price lives on its
41
- # occupancy variants — the master is never sold, so 0 is only a placeholder.
42
+ # room variant — the master is never sold, so 0 is only a placeholder.
42
43
  def create_product!(vendor, name, options)
43
44
  Spree::Product.create!(
44
45
  name: name,
@@ -71,6 +72,12 @@ module SpreeCmCommissioner
71
72
  product.master.save!
72
73
  end
73
74
 
75
+ def assign_room_variant!(product, option_value_ids)
76
+ return if option_value_ids.blank?
77
+
78
+ SpreeCmCommissioner::RoomTypes::UpdateRoomVariant.call(product: product, option_value_ids: option_value_ids)
79
+ end
80
+
74
81
  def assign_properties!(product, properties)
75
82
  return if properties.blank?
76
83
 
@@ -0,0 +1,22 @@
1
+ module SpreeCmCommissioner
2
+ module RoomTypes
3
+ class UpdateRoomVariant
4
+ prepend ::Spree::ServiceModule::Base
5
+
6
+ def call(product:, option_value_ids:)
7
+ option_types = SpreeCmCommissioner::RoomTypes.room_variant_option_types
8
+ values = Spree::OptionValue.where(option_type: option_types, id: Array(option_value_ids).compact_blank).to_a
9
+ return success(product: product) if values.empty?
10
+
11
+ ApplicationRecord.transaction do
12
+ product.option_types = (product.option_types.to_a + option_types).uniq
13
+ room_variant = product.variants.first_or_initialize
14
+ room_variant.option_values = values
15
+ room_variant.save!
16
+ end
17
+
18
+ success(product: product)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -9,6 +9,7 @@ module SpreeCmCommissioner
9
9
  }.freeze
10
10
 
11
11
  CATEGORY_ROOT_PERMALINK = 'categories/accommodations'.freeze
12
+ ROOM_VARIANT_OPTION_TYPE_NAMES = %w[number-of-adults number-of-kids kids-age-max].freeze
12
13
 
13
14
  def self.normalize_property_name(name)
14
15
  name.to_s.squish.downcase.tr(' ', '-')
@@ -25,6 +26,14 @@ module SpreeCmCommissioner
25
26
  root ? root.descendants.order(:lft) : Spree::Taxon.none
26
27
  end
27
28
 
29
+ # In ROOM_VARIANT_OPTION_TYPE_NAMES order (not DB position), and missing ones silently dropped --
30
+ # some vendors' environments are missing one or more of these three OptionType records (seeded or
31
+ # created inconsistently), so callers must tolerate a shorter list rather than assuming all three.
32
+ def self.room_variant_option_types
33
+ by_name = Spree::OptionType.where(name: ROOM_VARIANT_OPTION_TYPE_NAMES).includes(:option_values).index_by(&:name)
34
+ ROOM_VARIANT_OPTION_TYPE_NAMES.filter_map { |name| by_name[name] }
35
+ end
36
+
28
37
  def self.kyc_value(kyc_fields)
29
38
  Array(kyc_fields).compact_blank.sum { |field| SpreeCmCommissioner::KycBitwise::BIT_FIELDS.fetch(field.to_sym, 0) }
30
39
  end
@@ -0,0 +1,25 @@
1
+ <%# Overrides spree_backend's product rule: searches by name or slug and labels options "Name (slug)",
2
+ since many products share a name (e.g. "General Access"). %>
3
+ <div class="card-body">
4
+ <div class="form-group products_rule_products">
5
+ <%= label_tag "#{param_prefix}_product_ids_string", Spree.t('product_rule.choose_products') %>
6
+ <%= select "#{param_prefix}", 'product_ids_string',
7
+ options_for_select(promotion_rule.products.map { |product| [SpreeCmCommissioner::RelatableOptionLabel.call(product), product.id] }, promotion_rule.product_ids),
8
+ { include_hidden: true },
9
+ multiple: true,
10
+ class: 'select2autocomplete d-block w-100',
11
+ data: {
12
+ autocomplete_url_value: 'homepage_section_relatables_api_v2',
13
+ autocomplete_return_attr_value: :name_with_slug,
14
+ autocomplete_search_query_value: 'name_or_slug_i_cont',
15
+ autocomplete_min_input_value: 2,
16
+ autocomplete_multiple_value: true,
17
+ autocomplete_additional_url_params_value: 'model_class=Spree::Product'
18
+ } %>
19
+ <small class="form-text text-muted"><%= Spree.t('product_rule.search_hint') %></small>
20
+ </div>
21
+ <div class="form-group mb-0">
22
+ <%= label_tag Spree.t('product_rule.label') %>
23
+ <%= select_tag("#{param_prefix}[preferred_match_policy]", options_for_select(Spree::Promotion::Rules::Product::MATCH_POLICIES.map{|s| [Spree.t("product_rule.match_#{s}"),s] }, promotion_rule.preferred_match_policy), {class: 'select_product form-control custom-select'}) %>
24
+ </div>
25
+ </div>
@@ -0,0 +1,21 @@
1
+ <%# Overrides spree_backend's user rule: searches by email, login, phone or name and labels options
2
+ "Full name - email (login)", since core only shows (and searches) email. %>
3
+ <div class="card-body">
4
+ <div class="form-group mb-0">
5
+ <%= label_tag Spree.t('user_rule.choose_users') %>
6
+ <%= select "#{param_prefix}", 'user_ids_string',
7
+ options_for_select(promotion_rule.users.map { |user| [SpreeCmCommissioner::RelatableOptionLabel.call(user), user.id] }, promotion_rule.user_ids),
8
+ { include_hidden: true },
9
+ multiple: true,
10
+ class: 'select2autocomplete d-block w-100',
11
+ data: {
12
+ autocomplete_url_value: 'homepage_section_relatables_api_v2',
13
+ autocomplete_return_attr_value: :name_with_slug,
14
+ autocomplete_search_query_value: 'email_or_normalized_login_or_phone_number_or_first_name_or_last_name_i_cont',
15
+ autocomplete_min_input_value: 3,
16
+ autocomplete_multiple_value: true,
17
+ autocomplete_additional_url_params_value: 'model_class=Spree::User'
18
+ } %>
19
+ <small class="form-text text-muted"><%= Spree.t('user_rule.search_hint') %></small>
20
+ </div>
21
+ </div>
@@ -1,7 +1,20 @@
1
1
  <div class="card-body">
2
2
  <div class="form-group">
3
3
  <%= label_tag "#{param_prefix}_vendor_ids_string", Spree.t('vendors_rule.choose_vendors') %>
4
- <%= select "#{param_prefix}", 'vendor_ids_string', options_from_collection_for_select(Spree::Vendor.active, :id, :name, promotion_rule.vendors.pluck(:id)), { }, multiple: true, class: 'select2 form-control' %>
4
+ <%= select "#{param_prefix}", 'vendor_ids_string',
5
+ options_for_select(promotion_rule.vendors.map { |vendor| [SpreeCmCommissioner::RelatableOptionLabel.call(vendor), vendor.id] }, promotion_rule.vendor_ids),
6
+ { include_hidden: true },
7
+ multiple: true,
8
+ class: 'select2autocomplete form-control',
9
+ data: {
10
+ autocomplete_url_value: 'homepage_section_relatables_api_v2',
11
+ autocomplete_return_attr_value: :name_with_slug,
12
+ autocomplete_search_query_value: 'name_or_slug_i_cont',
13
+ autocomplete_min_input_value: 1,
14
+ autocomplete_multiple_value: true,
15
+ autocomplete_additional_url_params_value: 'model_class=Spree::Vendor&filter[state_eq]=active'
16
+ } %>
17
+ <small class="form-text text-muted"><%= Spree.t('vendors_rule.search_hint') %></small>
5
18
  </div>
6
19
  <div class="form-group mb-0">
7
20
  <%= label_tag Spree.t('vendors_rule.label') %>
@@ -420,6 +420,11 @@ en:
420
420
  choose_vendors: "Choose vendors"
421
421
  match_any: "Some line items must from selected vendor"
422
422
  match_all: "All line items must from selected vendor"
423
+ search_hint: "Search by name or slug"
424
+ product_rule:
425
+ search_hint: "Search by name or slug"
426
+ user_rule:
427
+ search_hint: "Search by email, login, phone number or name"
423
428
  customer_rule:
424
429
  label: "Customer"
425
430
  choose_customers: "Choose customers"
@@ -362,6 +362,11 @@ km:
362
362
  choose_vendors: "Choose vendors"
363
363
  match_any: "Some line items must from selected vendor"
364
364
  match_all: "All line items must from selected vendor"
365
+ search_hint: "Search by name or slug"
366
+ product_rule:
367
+ search_hint: "Search by name or slug"
368
+ user_rule:
369
+ search_hint: "Search by email, login, phone number or name"
365
370
  guest_occupation_rule:
366
371
  label: "Guest Occupation"
367
372
  choose_guest_occupation: "Choose Guest occupations"
@@ -1,5 +1,5 @@
1
1
  module SpreeCmCommissioner
2
- VERSION = '2.14.0'.freeze
2
+ VERSION = '2.14.1'.freeze
3
3
 
4
4
  module_function
5
5
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_cm_commissioner
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.14.0
4
+ version: 2.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-21 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: spree
@@ -791,6 +791,7 @@ files:
791
791
  - app/assets/javascripts/spree_cm_commissioner/annual_calendar_form.js
792
792
  - app/assets/javascripts/spree_cm_commissioner/backend.js
793
793
  - app/assets/javascripts/spree_cm_commissioner/copy_to_clipboard.js
794
+ - app/assets/javascripts/spree_cm_commissioner/promotion_rule_pickers.js
794
795
  - app/assets/javascripts/spree_cm_commissioner/routes.js
795
796
  - app/assets/javascripts/spree_cm_commissioner/telegram.js
796
797
  - app/assets/javascripts/spree_cm_commissioner/telegram/order_confirmation.js
@@ -1732,6 +1733,7 @@ files:
1732
1733
  - app/models/spree_cm_commissioner/redis_stock/cached_inventory_items_builder.rb
1733
1734
  - app/models/spree_cm_commissioner/redis_stock/line_items_cached_inventory_items_builder.rb
1734
1735
  - app/models/spree_cm_commissioner/redis_stock/variant_cached_inventory_items_builder.rb
1736
+ - app/models/spree_cm_commissioner/relatable_option_label.rb
1735
1737
  - app/models/spree_cm_commissioner/request_guard.rb
1736
1738
  - app/models/spree_cm_commissioner/reschedule_policy.rb
1737
1739
  - app/models/spree_cm_commissioner/reschedule_policy_assignment.rb
@@ -2402,6 +2404,7 @@ files:
2402
2404
  - app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/seat_layout.rb
2403
2405
  - app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/stop.rb
2404
2406
  - app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip.rb
2407
+ - app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip_location.rb
2405
2408
  - app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/wallet_payment.rb
2406
2409
  - app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_manager.rb
2407
2410
  - app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/full_sync_strategy.rb
@@ -2625,6 +2628,7 @@ files:
2625
2628
  - app/services/spree_cm_commissioner/room_types.rb
2626
2629
  - app/services/spree_cm_commissioner/room_types/create.rb
2627
2630
  - app/services/spree_cm_commissioner/room_types/update_properties.rb
2631
+ - app/services/spree_cm_commissioner/room_types/update_room_variant.rb
2628
2632
  - app/services/spree_cm_commissioner/route_metrics/decrease_trip_count.rb
2629
2633
  - app/services/spree_cm_commissioner/route_metrics/increase_fulfilled_order_count.rb
2630
2634
  - app/services/spree_cm_commissioner/route_metrics/increase_order_count.rb
@@ -2933,6 +2937,8 @@ files:
2933
2937
  - app/views/spree/admin/promotions/rules/_fixed_date.html.erb
2934
2938
  - app/views/spree/admin/promotions/rules/_guest_age_group.html.erb
2935
2939
  - app/views/spree/admin/promotions/rules/_guest_occupations.html.erb
2940
+ - app/views/spree/admin/promotions/rules/_product.html.erb
2941
+ - app/views/spree/admin/promotions/rules/_user.html.erb
2936
2942
  - app/views/spree/admin/promotions/rules/_vendors.html.erb
2937
2943
  - app/views/spree/admin/promotions/rules/_weekend.html.erb
2938
2944
  - app/views/spree/admin/rate_plans/_form.html.erb