spree_cm_commissioner 2.14.0 → 2.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/test_and_build_gem.yml +5 -3
- data/Gemfile.lock +1 -1
- data/app/assets/javascripts/spree_cm_commissioner/backend.js +1 -0
- data/app/assets/javascripts/spree_cm_commissioner/promotion_rule_pickers.js +12 -0
- data/app/controllers/spree/api/v2/platform/homepage_section_relatable_options_controller.rb +1 -0
- data/app/models/concerns/spree_cm_commissioner/scoped_fee_rule.rb +1 -6
- data/app/models/spree_cm_commissioner/product_decorator.rb +9 -4
- data/app/models/spree_cm_commissioner/relatable_option_label.rb +20 -0
- data/app/models/spree_cm_commissioner/vendor_decorator.rb +15 -0
- data/app/serializers/spree_cm_commissioner/api/v2/platform/homepage_section_relatable_options_serializer.rb +2 -2
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/client.rb +11 -0
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/inventory/external_inventory_items.rb +16 -3
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/inventory/unstock_inventory.rb +35 -5
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_locations.rb +39 -7
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_product.rb +1 -1
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_routes.rb +31 -13
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_seat_layout.rb +20 -2
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_service_calendars.rb +18 -10
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb +7 -2
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_variant.rb +9 -2
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_vehicle_types.rb +12 -1
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip.rb +8 -1
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip_location.rb +32 -0
- data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/sync_strategies/full_sync_strategy.rb +2 -1
- data/app/services/spree_cm_commissioner/room_types/create.rb +10 -3
- data/app/services/spree_cm_commissioner/room_types/update_room_variant.rb +22 -0
- data/app/services/spree_cm_commissioner/room_types.rb +9 -0
- data/app/views/spree/admin/promotions/rules/_product.html.erb +25 -0
- data/app/views/spree/admin/promotions/rules/_user.html.erb +21 -0
- data/app/views/spree/admin/promotions/rules/_vendors.html.erb +14 -1
- data/config/locales/en.yml +5 -0
- data/config/locales/km.yml +5 -0
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d7be2a8511db3eae8f7fc7bfac3b348effc7ff5c99a9d9ce5816bb1a0e09cdd
|
|
4
|
+
data.tar.gz: 98a4899d6a5ca428856e869a342da28a4447ae5f65c19ec7e27f4aaf996a6b97
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 99c278aa62a3ae56513ec4489b7945545cb937279df43b51478f95169bb7b22525ba9a4dd67fb42116b3aee61d06ec2973bee3a18b9978629857ae725e26cbe6
|
|
7
|
+
data.tar.gz: 74fba240707520ee96ea0a4f27ae585c7879c77d9e4515b037927ee9f3c1c9d7aec8d19824feaeb93e7f53b9f8afff8b06e39055ac830cb27a50402a6f41c080
|
|
@@ -117,7 +117,9 @@ jobs:
|
|
|
117
117
|
|
|
118
118
|
setup:
|
|
119
119
|
needs: [validate-commits]
|
|
120
|
-
|
|
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-
|
|
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-
|
|
284
|
+
runs-on: ubuntu-24.04
|
|
283
285
|
if: startsWith(github.ref, 'refs/tags/')
|
|
284
286
|
|
|
285
287
|
steps:
|
data/Gemfile.lock
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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::
|
|
8
|
+
SpreeCmCommissioner::RelatableOptionLabel.call(object)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
attribute :name do |object|
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/external_client/client.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/inventory/unstock_inventory.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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.
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_locations.rb
CHANGED
|
@@ -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
|
|
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 =
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
#
|
|
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
|
-
.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_seat_layout.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_trip_stop.rb
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
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:
|
|
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.
|
|
58
|
+
"variant-#{@vendor.id}-#{external_trip.sync_identifier}"
|
|
52
59
|
end
|
|
53
60
|
|
|
54
61
|
def standard_seat_option_value
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/polling/sync_vehicle_types.rb
CHANGED
|
@@ -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
|
|
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 -
|
data/app/services/spree_cm_commissioner/integrations/book_me_bus_v1/resources/trip_location.rb
ADDED
|
@@ -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
|
-
# -
|
|
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.
|
|
6
|
-
#
|
|
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
|
-
#
|
|
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',
|
|
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') %>
|
data/config/locales/en.yml
CHANGED
|
@@ -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"
|
data/config/locales/km.yml
CHANGED
|
@@ -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"
|
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.
|
|
4
|
+
version: 2.14.2
|
|
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-
|
|
11
|
+
date: 2026-10-26 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
|