spree_cm_commissioner 2.13.3.pre.pre1 → 2.13.3.pre.pre3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/app/controllers/spree/admin/integrations_controller.rb +1 -3
- data/app/controllers/spree/api/switch_cm/ota/avail_notifs_controller.rb +1 -1
- data/app/controllers/spree/api/switch_cm/ota/pushes_controller.rb +2 -2
- data/app/controllers/spree/api/switch_cm/ota/rate_amount_notifs_controller.rb +1 -1
- data/app/controllers/spree/api/switch_cm/ota/res_modify_notifs_controller.rb +2 -2
- data/app/controllers/spree/api/switch_cm/ota/res_notifs_controller.rb +2 -2
- data/app/controllers/spree/api/switch_cm/ota/reservation_streams_controller.rb +6 -6
- data/app/controllers/spree/api/switch_cm/xml/availabilities_controller.rb +2 -2
- data/app/controllers/spree/api/switch_cm/xml/reservation_summaries_controller.rb +2 -2
- data/app/controllers/spree/api/switch_cm/xml/reservations_controller.rb +4 -4
- data/app/controllers/spree/api/switch_cm/xml/room_rate_availabilities_controller.rb +1 -1
- data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +41 -3
- data/app/controllers/spree/api/v2/storefront/guest_qrs_controller.rb +2 -0
- data/app/models/spree_cm_commissioner/guest.rb +8 -0
- data/app/models/spree_cm_commissioner/integration.rb +0 -12
- data/app/models/spree_cm_commissioner/integrations/switch_cm.rb +11 -5
- data/app/serializers/spree_cm_commissioner/v2/storefront/cart_serializer_decorator.rb +2 -1
- data/app/services/spree_cm_commissioner/integrations/polling_scheduler.rb +1 -1
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/accept_push.rb +103 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/accommodation_lock.rb +43 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/apply_updates.rb +121 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/availability_snapshot.rb +88 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/inventory_update.rb +26 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/pushed_nights.rb +81 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/unseen_room_nights.rb +41 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/validate_updates.rb +138 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/{avail_notif_parser.rb → availability_parser.rb} +3 -3
- data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/{rate_amount_notif_parser.rb → price_parser.rb} +3 -3
- data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/push_response.rb +2 -2
- data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/{reservation_presenter.rb → reservation_serializer.rb} +1 -1
- data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/acknowledge.rb +45 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/feed.rb +145 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/mark_seen.rb +55 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/reservation.rb +120 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_manager.rb +16 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_strategies/full_sync_strategy.rb +39 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_strategies/incremental_sync_strategy.rb +51 -0
- data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/availability_parser.rb +2 -2
- data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/push_response.rb +2 -2
- data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/{reservation_presenter.rb → reservation_serializer.rb} +1 -1
- data/app/services/spree_cm_commissioner/inventory_items/reset.rb +1 -1
- data/app/views/spree/admin/integration_sessions/index.html.erb +6 -8
- data/config/locales/en.yml +0 -1
- data/config/locales/km.yml +0 -1
- data/lib/spree_cm_commissioner/version.rb +1 -1
- metadata +20 -19
- data/app/jobs/spree_cm_commissioner/integrations/switch_cm/unseen_reservations_alert_job.rb +0 -7
- data/app/services/spree_cm_commissioner/integrations/switch_cm/accept_push.rb +0 -101
- data/app/services/spree_cm_commissioner/integrations/switch_cm/accommodation_lock.rb +0 -41
- data/app/services/spree_cm_commissioner/integrations/switch_cm/acknowledge_reservations.rb +0 -43
- data/app/services/spree_cm_commissioner/integrations/switch_cm/apply_ari.rb +0 -119
- data/app/services/spree_cm_commissioner/integrations/switch_cm/ari_update.rb +0 -24
- data/app/services/spree_cm_commissioner/integrations/switch_cm/availability_readback.rb +0 -86
- data/app/services/spree_cm_commissioner/integrations/switch_cm/mark_seen.rb +0 -53
- data/app/services/spree_cm_commissioner/integrations/switch_cm/pushed_nights.rb +0 -69
- data/app/services/spree_cm_commissioner/integrations/switch_cm/reservation.rb +0 -118
- data/app/services/spree_cm_commissioner/integrations/switch_cm/reservation_feed.rb +0 -143
- data/app/services/spree_cm_commissioner/integrations/switch_cm/unseen_reservations_alert.rb +0 -45
- data/app/services/spree_cm_commissioner/integrations/switch_cm/unseen_room_nights.rb +0 -39
- data/app/services/spree_cm_commissioner/integrations/switch_cm/validate_ari_updates.rb +0 -136
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c2ffd7af989f00931883cac1fc7aa3ff4173458f57a42abd9294ecba2687c2bb
|
|
4
|
+
data.tar.gz: d2ee7a2a5d558f018075eded5ef178d294a61afc6e53b5d77018c3ef3a239c04
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: db45749501e065ded53bef1baa37ebbbca96badd6384ac47505f72ed08dd45c0040e6ea22f50dfa4454ea32a3059d63aa7f9ce3e80fe4b97f99d3dca06e1f1ea
|
|
7
|
+
data.tar.gz: fc0b52d82222015696750733ecc702ef57e425256ca4e460644f21fe48369251e01e41adee33848e9d78be225e3342d845db8c4df93e973c70a9a360c21e72cb
|
data/Gemfile.lock
CHANGED
|
@@ -12,9 +12,7 @@ module Spree
|
|
|
12
12
|
def enqueue_polling
|
|
13
13
|
sync_type = params[:sync_type].presence || :full
|
|
14
14
|
|
|
15
|
-
if
|
|
16
|
-
flash[:error] = Spree.t('admin.integration_polling_not_supported')
|
|
17
|
-
elsif @integration.active?
|
|
15
|
+
if @integration.active?
|
|
18
16
|
SpreeCmCommissioner::Integrations::PollingJob.perform_later(
|
|
19
17
|
integration_id: @integration.id,
|
|
20
18
|
sync_type: sync_type
|
|
@@ -5,7 +5,7 @@ module Spree::Api::SwitchCm
|
|
|
5
5
|
private
|
|
6
6
|
|
|
7
7
|
# override
|
|
8
|
-
def parser_class = SpreeCmCommissioner::Integrations::SwitchCm::Ota::
|
|
8
|
+
def parser_class = SpreeCmCommissioner::Integrations::SwitchCm::Ota::AvailabilityParser
|
|
9
9
|
|
|
10
10
|
# override
|
|
11
11
|
def message_name = 'OTA_HotelAvailNotif'
|
|
@@ -6,9 +6,9 @@ module Spree::Api::SwitchCm
|
|
|
6
6
|
|
|
7
7
|
# POST /api/switch_cm/hotels/ota/OTA_HotelAvailNotif (AvailNotifsController): rooms to sell and open/close
|
|
8
8
|
# POST /api/switch_cm/hotels/ota/OTA_HotelRateAmountNotif (RateAmountNotifsController): per-night prices
|
|
9
|
-
# Validated and applied in the request (AcceptPush); the answer is the real outcome.
|
|
9
|
+
# Validated and applied in the request (Inventory::AcceptPush); the answer is the real outcome.
|
|
10
10
|
def create
|
|
11
|
-
result = SpreeCmCommissioner::Integrations::SwitchCm::AcceptPush.call(
|
|
11
|
+
result = SpreeCmCommissioner::Integrations::SwitchCm::Inventory::AcceptPush.call(
|
|
12
12
|
integration: current_integration,
|
|
13
13
|
message: message_name,
|
|
14
14
|
updates: updates,
|
|
@@ -5,7 +5,7 @@ module Spree::Api::SwitchCm
|
|
|
5
5
|
private
|
|
6
6
|
|
|
7
7
|
# override
|
|
8
|
-
def parser_class = SpreeCmCommissioner::Integrations::SwitchCm::Ota::
|
|
8
|
+
def parser_class = SpreeCmCommissioner::Integrations::SwitchCm::Ota::PriceParser
|
|
9
9
|
|
|
10
10
|
# override
|
|
11
11
|
def message_name = 'OTA_HotelRateAmountNotif'
|
|
@@ -10,10 +10,10 @@ module Spree::Api::SwitchCm
|
|
|
10
10
|
integrations, unknown_hotel_ids = requested_integrations(params[:hotel_ids])
|
|
11
11
|
return render_fault(:forbidden, 'Not authorized for these hotels') if integrations.empty?
|
|
12
12
|
|
|
13
|
-
reservations = SpreeCmCommissioner::Integrations::SwitchCm::
|
|
13
|
+
reservations = SpreeCmCommissioner::Integrations::SwitchCm::Reservations::Feed.new(integrations).public_send(
|
|
14
14
|
feed_method,
|
|
15
15
|
id: params[:id],
|
|
16
|
-
last_change: SpreeCmCommissioner::Integrations::SwitchCm::
|
|
16
|
+
last_change: SpreeCmCommissioner::Integrations::SwitchCm::Reservations::Feed.parse_last_change(params[:last_change]),
|
|
17
17
|
limit: params[:limit]
|
|
18
18
|
)
|
|
19
19
|
|
|
@@ -22,7 +22,7 @@ module Spree::Api::SwitchCm
|
|
|
22
22
|
) do |xml|
|
|
23
23
|
xml.send(container) do
|
|
24
24
|
reservations.each do |reservation|
|
|
25
|
-
|
|
25
|
+
serialize(SpreeCmCommissioner::Integrations::SwitchCm::Ota::ReservationSerializer.new(reservation), xml)
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
end
|
|
@@ -37,7 +37,7 @@ module Spree::Api::SwitchCm
|
|
|
37
37
|
# (code 193) means the channel manager couldn't process them and they stay queued.
|
|
38
38
|
return render_success if processing_failed?
|
|
39
39
|
|
|
40
|
-
result = SpreeCmCommissioner::Integrations::SwitchCm::
|
|
40
|
+
result = SpreeCmCommissioner::Integrations::SwitchCm::Reservations::Acknowledge.call(
|
|
41
41
|
integrations: switch_cm_integrations.includes(:vendor).to_a,
|
|
42
42
|
reservation_ids: request_document.xpath('//HotelReservationID/@ResID_Value').map(&:value),
|
|
43
43
|
status: acknowledged_status
|
|
@@ -67,8 +67,8 @@ module Spree::Api::SwitchCm
|
|
|
67
67
|
raise NotImplementedError, "#{self.class.name} must implement container"
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
def
|
|
71
|
-
raise NotImplementedError, "#{self.class.name} must implement
|
|
70
|
+
def serialize(_serializer, _xml)
|
|
71
|
+
raise NotImplementedError, "#{self.class.name} must implement serialize"
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
def processing_failed?
|
|
@@ -5,9 +5,9 @@ module Spree::Api::SwitchCm
|
|
|
5
5
|
|
|
6
6
|
# POST /api/switch_cm/hotels/xml/availability
|
|
7
7
|
# Rooms to sell, prices and closed in one message, validated and applied in the request
|
|
8
|
-
# (AcceptPush); the answer is the real outcome.
|
|
8
|
+
# (Inventory::AcceptPush); the answer is the real outcome.
|
|
9
9
|
def create
|
|
10
|
-
result = SpreeCmCommissioner::Integrations::SwitchCm::AcceptPush.call(
|
|
10
|
+
result = SpreeCmCommissioner::Integrations::SwitchCm::Inventory::AcceptPush.call(
|
|
11
11
|
integration: current_integration,
|
|
12
12
|
message: 'availability',
|
|
13
13
|
updates: parser.updates,
|
|
@@ -8,11 +8,11 @@ module Spree::Api::SwitchCm
|
|
|
8
8
|
# booked. The channel manager imports these while setting the hotel up, so they count against
|
|
9
9
|
# its availability before its first push. Read-only: nothing is marked seen.
|
|
10
10
|
def create
|
|
11
|
-
reservations = SpreeCmCommissioner::Integrations::SwitchCm::
|
|
11
|
+
reservations = SpreeCmCommissioner::Integrations::SwitchCm::Reservations::Feed.new([current_integration]).summary
|
|
12
12
|
|
|
13
13
|
render_document do |xml|
|
|
14
14
|
reservations.each do |reservation|
|
|
15
|
-
SpreeCmCommissioner::Integrations::SwitchCm::Xml::
|
|
15
|
+
SpreeCmCommissioner::Integrations::SwitchCm::Xml::ReservationSerializer.new(reservation, summary: true).build(xml)
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
end
|
|
@@ -8,14 +8,14 @@ module Spree::Api::SwitchCm
|
|
|
8
8
|
integrations, = requested_integrations(request_value('hotel_id') || request_value('hotel_ids'))
|
|
9
9
|
return render_fault(:forbidden, 'Not authorized for these hotels') if integrations.empty?
|
|
10
10
|
|
|
11
|
-
reservations = SpreeCmCommissioner::Integrations::SwitchCm::
|
|
11
|
+
reservations = SpreeCmCommissioner::Integrations::SwitchCm::Reservations::Feed.new(integrations).changed_reservations(
|
|
12
12
|
id: request_value('id'),
|
|
13
|
-
last_change: SpreeCmCommissioner::Integrations::SwitchCm::
|
|
13
|
+
last_change: SpreeCmCommissioner::Integrations::SwitchCm::Reservations::Feed.parse_last_change(request_value('last_change')),
|
|
14
14
|
limit: request_value('limit')
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
document = SpreeCmCommissioner::Integrations::SwitchCm::Xml::ResponseBuilder.build(response_root, ruid: ruid) do |xml|
|
|
18
|
-
reservations.each { |reservation| SpreeCmCommissioner::Integrations::SwitchCm::Xml::
|
|
18
|
+
reservations.each { |reservation| SpreeCmCommissioner::Integrations::SwitchCm::Xml::ReservationSerializer.new(reservation).build(xml) }
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Marked only once the document is built. One that fails to mark is simply returned
|
|
@@ -35,7 +35,7 @@ module Spree::Api::SwitchCm
|
|
|
35
35
|
|
|
36
36
|
def mark_seen(reservations)
|
|
37
37
|
reservations.each_with_object([]) do |reservation, failed|
|
|
38
|
-
SpreeCmCommissioner::Integrations::SwitchCm::MarkSeen.call(
|
|
38
|
+
SpreeCmCommissioner::Integrations::SwitchCm::Reservations::MarkSeen.call(
|
|
39
39
|
integration: reservation.integration, order: reservation.order, status: reservation.status
|
|
40
40
|
)
|
|
41
41
|
rescue StandardError => e
|
|
@@ -13,7 +13,7 @@ module Spree::Api::SwitchCm
|
|
|
13
13
|
days = (request_value('number_of_days').presence || DAYS_RANGE.max).to_i
|
|
14
14
|
return render_fault(:bad_request, "number_of_days must be between #{DAYS_RANGE.min} and #{DAYS_RANGE.max}") unless DAYS_RANGE.cover?(days)
|
|
15
15
|
|
|
16
|
-
rows = SpreeCmCommissioner::Integrations::SwitchCm::
|
|
16
|
+
rows = SpreeCmCommissioner::Integrations::SwitchCm::Inventory::AvailabilitySnapshot.new(
|
|
17
17
|
integration: current_integration,
|
|
18
18
|
dates: (start_date...(start_date + days)).to_a,
|
|
19
19
|
room_id: request_value('room_id')
|
|
@@ -12,7 +12,9 @@ module Spree
|
|
|
12
12
|
# Creates a generic draft order from a locally-assembled cart in a single request.
|
|
13
13
|
# Endpoint: POST /api/v2/storefront/draft_orders
|
|
14
14
|
# Params:
|
|
15
|
-
# - line_items: Array of { variant_id:, quantity:, options: {
|
|
15
|
+
# - line_items: Array of { variant_id:, quantity:, options: { from_date:, to_date:, guests_attributes: [] },
|
|
16
|
+
# public_metadata: {}, private_metadata: {} } (required) — guests_attributes fields mirror the checkout
|
|
17
|
+
# API's line_items_attributes.guests_attributes (see Spree::PermittedAttributes checkout_attributes).
|
|
16
18
|
# - st: private sale token granting this order the product's locked stock (optional).
|
|
17
19
|
def create
|
|
18
20
|
result = SpreeCmCommissioner::DraftOrders::Create.call(
|
|
@@ -45,8 +47,44 @@ module Spree
|
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
def draft_order_line_items
|
|
48
|
-
params.permit(
|
|
49
|
-
|
|
50
|
+
params.permit(
|
|
51
|
+
line_items: [
|
|
52
|
+
:variant_id,
|
|
53
|
+
:quantity,
|
|
54
|
+
{
|
|
55
|
+
options: [
|
|
56
|
+
:from_date,
|
|
57
|
+
:to_date,
|
|
58
|
+
{
|
|
59
|
+
guests_attributes: [
|
|
60
|
+
:block_id,
|
|
61
|
+
:first_name,
|
|
62
|
+
:last_name,
|
|
63
|
+
:address,
|
|
64
|
+
:dob,
|
|
65
|
+
:gender,
|
|
66
|
+
:occupation_id,
|
|
67
|
+
:other_occupation,
|
|
68
|
+
:nationality_id,
|
|
69
|
+
:age,
|
|
70
|
+
:emergency_contact,
|
|
71
|
+
:phone_number,
|
|
72
|
+
:other_organization,
|
|
73
|
+
:social_contact,
|
|
74
|
+
:social_contact_platform,
|
|
75
|
+
:expectation,
|
|
76
|
+
:country_code,
|
|
77
|
+
:upload_later,
|
|
78
|
+
:contact,
|
|
79
|
+
{ guest_dynamic_fields_attributes: %i[dynamic_field_id dynamic_field_option_id value] }
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
public_metadata: {},
|
|
84
|
+
private_metadata: {}
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
).fetch(:line_items, [])
|
|
50
88
|
end
|
|
51
89
|
|
|
52
90
|
def draft_order_products
|
|
@@ -9,6 +9,8 @@ module Spree
|
|
|
9
9
|
before_action :load_guest, only: [:show]
|
|
10
10
|
|
|
11
11
|
def show
|
|
12
|
+
return head :not_found if @guest.qr_data.blank?
|
|
13
|
+
|
|
12
14
|
respond_to do |format|
|
|
13
15
|
format.png { send_data @guest.generate_png_qr, type: 'image/png', disposition: 'inline' }
|
|
14
16
|
format.svg { send_data @guest.generate_svg_qr, type: 'image/svg+xml', disposition: 'inline' }
|
|
@@ -201,6 +201,8 @@ module SpreeCmCommissioner
|
|
|
201
201
|
end
|
|
202
202
|
|
|
203
203
|
def generate_svg_qr
|
|
204
|
+
return nil if qr_data.blank?
|
|
205
|
+
|
|
204
206
|
qrcode = RQRCode::QRCode.new(qr_data)
|
|
205
207
|
qrcode.as_svg(
|
|
206
208
|
color: '000',
|
|
@@ -213,6 +215,8 @@ module SpreeCmCommissioner
|
|
|
213
215
|
end
|
|
214
216
|
|
|
215
217
|
def generate_png_qr(size = 120)
|
|
218
|
+
return nil if qr_data.blank?
|
|
219
|
+
|
|
216
220
|
qrcode = RQRCode::QRCode.new(qr_data)
|
|
217
221
|
qrcode.as_png(
|
|
218
222
|
bit_depth: 1,
|
|
@@ -228,7 +232,11 @@ module SpreeCmCommissioner
|
|
|
228
232
|
)
|
|
229
233
|
end
|
|
230
234
|
|
|
235
|
+
# Transferred guests no longer own the ticket, so they must not receive any
|
|
236
|
+
# scannable QR data. The ownership moved to the guest created by
|
|
237
|
+
# SpreeCmCommissioner::TicketTransfers::TransferGuest.
|
|
231
238
|
def qr_data
|
|
239
|
+
return nil if transferred?
|
|
232
240
|
return external_qr_data if external_qr_data.present?
|
|
233
241
|
|
|
234
242
|
token
|
|
@@ -22,22 +22,10 @@ module SpreeCmCommissioner
|
|
|
22
22
|
.sort
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
# Integrations the partner calls into (rather than us pulling from them) have no sync_manager
|
|
26
|
-
# and override this to false. Class-level so the pollable scope can filter on `type`.
|
|
27
|
-
def self.supports_polling?
|
|
28
|
-
true
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
scope :pollable, -> { where(type: types.select { |type| type.constantize.supports_polling? }) }
|
|
32
|
-
|
|
33
25
|
def sync_manager
|
|
34
26
|
raise NotImplementedError, 'Subclasses must implement the sync_manager method'
|
|
35
27
|
end
|
|
36
28
|
|
|
37
|
-
def supports_polling?
|
|
38
|
-
self.class.supports_polling?
|
|
39
|
-
end
|
|
40
|
-
|
|
41
29
|
# Whether the partner, not BookMe+, sets this variant's per-night stock, prices and closings.
|
|
42
30
|
# When true, stock resets and dashboard edits leave its inventory items alone, and new nights
|
|
43
31
|
# start closed until the partner opens them (InventoryItem). Off by default.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# A hotel connected to switch.cm, which calls our Booking.com-compatible channel-manager API
|
|
2
2
|
# (Spree::Api::SwitchCm) instead of us calling it: it pushes availability, prices and
|
|
3
|
-
# restrictions, and pulls reservations. So there's nothing to
|
|
3
|
+
# restrictions, and pulls reservations. So there's nothing to pull, hold or unstock externally;
|
|
4
|
+
# polling only runs our own checks (SyncManager).
|
|
4
5
|
#
|
|
5
6
|
# Every switch.cm row shares one Doorkeeper app; the hotels switch.cm may manage are the rows
|
|
6
7
|
# pointing at it. `paused` is the setup state: switch.cm can read the hotel (room list,
|
|
@@ -18,11 +19,16 @@ class SpreeCmCommissioner::Integrations::SwitchCm < SpreeCmCommissioner::Integra
|
|
|
18
19
|
before_create :set_connected_at
|
|
19
20
|
after_commit :sync_unpushed_nights, on: %i[create update]
|
|
20
21
|
|
|
22
|
+
# Polling runs the hotel's periodic checks (SyncManager), and the incremental one alerts on
|
|
23
|
+
# every run while reservations sit unpulled, so the column's 10-second default would flood
|
|
24
|
+
# Telegram. Full runs daily upkeep.
|
|
25
|
+
attribute :incremental_sync_interval_seconds, :integer, default: -> { SyncStrategies::IncrementalSyncStrategy::UNSEEN_AFTER.to_i }
|
|
26
|
+
|
|
21
27
|
scope :readable, -> { where(status: READABLE_STATUSES) }
|
|
22
28
|
|
|
23
29
|
# override
|
|
24
|
-
def
|
|
25
|
-
|
|
30
|
+
def sync_manager
|
|
31
|
+
SyncManager.new(integration: self)
|
|
26
32
|
end
|
|
27
33
|
|
|
28
34
|
# Only for rooms switch.cm can see (RoomCatalog): a hotel's other products, and rooms that need
|
|
@@ -60,7 +66,7 @@ class SpreeCmCommissioner::Integrations::SwitchCm < SpreeCmCommissioner::Integra
|
|
|
60
66
|
# While switch.cm owns the rooms, nights it hasn't pushed stay closed, so the admin page shows
|
|
61
67
|
# this until the first full push lands.
|
|
62
68
|
def push_progress
|
|
63
|
-
PushedNights.new(self).progress
|
|
69
|
+
Inventory::PushedNights.new(self).progress
|
|
64
70
|
end
|
|
65
71
|
|
|
66
72
|
private
|
|
@@ -75,6 +81,6 @@ class SpreeCmCommissioner::Integrations::SwitchCm < SpreeCmCommissioner::Integra
|
|
|
75
81
|
owned_before = status_before_last_save.to_s == 'active' && conflict_strategy_before_last_save.to_s == 'external_wins'
|
|
76
82
|
return if owned_before == owns_inventory?
|
|
77
83
|
|
|
78
|
-
|
|
84
|
+
Inventory::PushedNights.new(self).reconcile_unpushed!
|
|
79
85
|
end
|
|
80
86
|
end
|
|
@@ -10,7 +10,8 @@ module SpreeCmCommissioner
|
|
|
10
10
|
base.attributes :phone_number, :intel_phone_number, :country_code, :request_state,
|
|
11
11
|
:term_accepted_at,
|
|
12
12
|
:channel, :hold_expires_at,
|
|
13
|
-
:service_fee_total, :display_service_fee_total
|
|
13
|
+
:service_fee_total, :display_service_fee_total,
|
|
14
|
+
:order_total_after_store_credit, :display_order_total_after_store_credit
|
|
14
15
|
|
|
15
16
|
base.attribute :has_incomplete_guest_info, &:has_incomplete_guest_info?
|
|
16
17
|
base.attribute :required_payway_payout, &:required_payway_payout?
|
|
@@ -4,7 +4,7 @@ module SpreeCmCommissioner
|
|
|
4
4
|
BATCH_SIZE = 10 # Process integrations in batches to avoid overwhelming the server
|
|
5
5
|
|
|
6
6
|
def call
|
|
7
|
-
SpreeCmCommissioner::Integration.active.
|
|
7
|
+
SpreeCmCommissioner::Integration.active.find_each(batch_size: BATCH_SIZE) do |integration|
|
|
8
8
|
if should_run_full_sync?(integration)
|
|
9
9
|
schedule_full_sync(integration)
|
|
10
10
|
elsif should_run_incremental_sync?(integration)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::SwitchCm
|
|
2
|
+
module Inventory
|
|
3
|
+
# Booking.com's push contract: validated and applied before we answer, and the answer is the real
|
|
4
|
+
# outcome. It stays affordable because Booking.com asks for one hotel and one month per request,
|
|
5
|
+
# so a connector built for it already sends month-sized pushes; a larger one is rejected so it
|
|
6
|
+
# knows to split, rather than queued.
|
|
7
|
+
#
|
|
8
|
+
# outcome: :applied (200), :rejected (400, nothing valid or over a month), :busy (503, another
|
|
9
|
+
# push for this hotel held the lock too long), :failed (500, the apply raised). The channel
|
|
10
|
+
# manager resends :busy and :failed, which is safe because every write is absolute.
|
|
11
|
+
#
|
|
12
|
+
# Every push is logged as an IntegrationSyncSession, written after the lock is released.
|
|
13
|
+
class AcceptPush
|
|
14
|
+
prepend ::Spree::ServiceModule::Base
|
|
15
|
+
|
|
16
|
+
MAX_SPAN_DAYS = 31
|
|
17
|
+
LOCK_WAIT = 5.seconds
|
|
18
|
+
HTTP_STATUS = { applied: :ok, rejected: :bad_request, busy: :service_unavailable, failed: :internal_server_error }.freeze
|
|
19
|
+
|
|
20
|
+
def call(integration:, message:, updates:, request_id:)
|
|
21
|
+
started_at = Time.current
|
|
22
|
+
validation = ValidateUpdates.call(integration: integration, updates: updates).value
|
|
23
|
+
outcome, applied = apply(integration, validation)
|
|
24
|
+
add_missing_nights_warning(validation, applied) if outcome == :applied
|
|
25
|
+
|
|
26
|
+
request = { message: message, request_id: request_id, started_at: started_at }
|
|
27
|
+
session = log_session!(integration, request, validation, outcome, applied)
|
|
28
|
+
log_attention(session, outcome, applied)
|
|
29
|
+
|
|
30
|
+
success(validation.merge(outcome: outcome, session: session))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def apply(integration, validation)
|
|
36
|
+
accepted = validation[:accepted]
|
|
37
|
+
return [:rejected, nil] if accepted.empty?
|
|
38
|
+
|
|
39
|
+
unless within_one_month?(accepted)
|
|
40
|
+
validation[:errors] << { code: 'REQUEST_SPANS_MORE_THAN_ONE_MONTH', message: 'Send at most one month per request', index: nil }
|
|
41
|
+
return [:rejected, nil]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
result = AccommodationLock.with_lock(integration, wait: LOCK_WAIT) { ApplyUpdates.new(integration: integration).call(accepted) }
|
|
45
|
+
result == AccommodationLock::BUSY ? [:busy, nil] : [:applied, result]
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
[:failed, { error_class: e.class.name, error_message: e.message, backtrace: e.backtrace&.first(5) }]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def within_one_month?(accepted)
|
|
51
|
+
(accepted.map(&:date_to).max - accepted.map(&:date_from).min).to_i < MAX_SPAN_DAYS
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def add_missing_nights_warning(validation, applied)
|
|
55
|
+
return unless applied[:missing_nights].positive?
|
|
56
|
+
|
|
57
|
+
validation[:warnings] << {
|
|
58
|
+
code: 'NIGHTS_NOT_OPEN_FOR_SALE',
|
|
59
|
+
message: "#{applied[:missing_nights]} night(s) have no inventory on BookMe+ yet and were skipped",
|
|
60
|
+
index: nil
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def log_session!(integration, request, validation, outcome, applied)
|
|
65
|
+
SpreeCmCommissioner::IntegrationSyncSession.create!(
|
|
66
|
+
integration: integration,
|
|
67
|
+
tenant: integration.tenant,
|
|
68
|
+
sync_type: :webhook_triggered,
|
|
69
|
+
status: outcome == :applied ? :completed : :failed,
|
|
70
|
+
started_at: request[:started_at],
|
|
71
|
+
ended_at: Time.current,
|
|
72
|
+
sync_result: {
|
|
73
|
+
message: request[:message],
|
|
74
|
+
request_id: request[:request_id],
|
|
75
|
+
outcome: outcome,
|
|
76
|
+
accepted: validation[:accepted].size,
|
|
77
|
+
room_nights: validation[:accepted].sum(&:nights),
|
|
78
|
+
errors: validation[:errors],
|
|
79
|
+
warnings: validation[:warnings],
|
|
80
|
+
applied: applied
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Worth a look: pushes we didn't apply, and nights where holds kept stock above the pushed count.
|
|
86
|
+
def log_attention(session, outcome, applied)
|
|
87
|
+
clamped = outcome == :applied ? applied[:clamped_nights] : []
|
|
88
|
+
return if outcome == :applied && clamped.empty?
|
|
89
|
+
|
|
90
|
+
CmAppLogger.log(
|
|
91
|
+
label: "#{self.class.name}#call",
|
|
92
|
+
data: {
|
|
93
|
+
session_id: session.id,
|
|
94
|
+
integration_id: session.integration_id,
|
|
95
|
+
outcome: outcome,
|
|
96
|
+
clamped_nights: clamped,
|
|
97
|
+
failure: (applied if outcome == :failed)
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/accommodation_lock.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::SwitchCm
|
|
2
|
+
module Inventory
|
|
3
|
+
# One push per hotel at a time, so pushes land in arrival order and two pushes never interleave
|
|
4
|
+
# their per-night deltas. A session-level Postgres advisory lock, like
|
|
5
|
+
# InventoryHolds::Acquire#with_advisory_lock!, but waiting briefly instead of failing at once:
|
|
6
|
+
# a connector resends on 503, and a short wait usually saves it the round trip.
|
|
7
|
+
module AccommodationLock
|
|
8
|
+
BUSY = Object.new.freeze
|
|
9
|
+
POLL_INTERVAL = 0.1
|
|
10
|
+
|
|
11
|
+
# Yields and returns the block's value, or BUSY if another push for this hotel still holds the
|
|
12
|
+
# lock after `wait`.
|
|
13
|
+
def self.with_lock(integration, wait:)
|
|
14
|
+
lock_id = lock_id(integration)
|
|
15
|
+
deadline = Time.current + wait
|
|
16
|
+
|
|
17
|
+
acquired = try_lock(lock_id)
|
|
18
|
+
until acquired || Time.current >= deadline
|
|
19
|
+
sleep POLL_INTERVAL
|
|
20
|
+
acquired = try_lock(lock_id)
|
|
21
|
+
end
|
|
22
|
+
return BUSY unless acquired
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
yield
|
|
26
|
+
ensure
|
|
27
|
+
ActiveRecord::Base.connection.execute("SELECT pg_advisory_unlock(#{lock_id})")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# select_value returns true (pg adapter >= 1.0) or "t" (older adapters).
|
|
32
|
+
def self.try_lock(lock_id)
|
|
33
|
+
[true, 't'].include?(ActiveRecord::Base.connection.select_value("SELECT pg_try_advisory_lock(#{lock_id})"))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.lock_id(integration)
|
|
37
|
+
Zlib.crc32("spree_cm_commissioner:switch_cm_push:#{integration.id}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private_class_method :try_lock, :lock_id
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
class SpreeCmCommissioner::Integrations::SwitchCm
|
|
2
|
+
module Inventory
|
|
3
|
+
# Writes validated updates to the hotel's inventory rows. Callers hold the hotel's
|
|
4
|
+
# AccommodationLock, so this never logs; it returns what happened for the caller to log.
|
|
5
|
+
#
|
|
6
|
+
# Pushed rooms to sell is an absolute "rooms BookMe+ may still sell", applied as a delta:
|
|
7
|
+
#
|
|
8
|
+
# 1. Subtract rooms on BookMe+ reservations the channel manager hasn't seen (UnseenRoomNights),
|
|
9
|
+
# and the night's withheld (locked) pool, which is sold on top of public stock.
|
|
10
|
+
# 2. Never go below rooms held by guests already paying, so their holds can still convert;
|
|
11
|
+
# otherwise conversion would take Redis negative and its async DB sync would fail validation.
|
|
12
|
+
# When this clamps, we've sold more than the channel manager allowed: returned for an alert.
|
|
13
|
+
# 3. Set Redis stock in one Lua script that returns the delta, then add that delta to the row's
|
|
14
|
+
# quantity_available and max_capacity. Relative, so it commutes with BulkAdjustQuantities jobs
|
|
15
|
+
# still in flight for the row; an absolute write would be undone by them.
|
|
16
|
+
#
|
|
17
|
+
# Closing and prices are plain writes: the row's `closed`, and the per-night Spree::Price row
|
|
18
|
+
# Variant#price_for_date reads. A night's first availability push also opens it (PushedNights),
|
|
19
|
+
# unless the same update closes it.
|
|
20
|
+
class ApplyUpdates
|
|
21
|
+
# KEYS: stock, hold. ARGV: target, ttl. Returns [final - stock, final].
|
|
22
|
+
SET_STOCK_SCRIPT = <<~LUA.freeze
|
|
23
|
+
local stock = tonumber(redis.call('GET', KEYS[1]) or 0)
|
|
24
|
+
local held = tonumber(redis.call('GET', KEYS[2]) or 0)
|
|
25
|
+
local target = tonumber(ARGV[1])
|
|
26
|
+
local final = math.max(target, held)
|
|
27
|
+
redis.call('SET', KEYS[1], final, 'EX', tonumber(ARGV[2]))
|
|
28
|
+
return { final - stock, final }
|
|
29
|
+
LUA
|
|
30
|
+
|
|
31
|
+
def initialize(integration:)
|
|
32
|
+
@integration = integration
|
|
33
|
+
@unseen = UnseenRoomNights.new(integration)
|
|
34
|
+
@pushed_nights = PushedNights.new(integration)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call(updates)
|
|
38
|
+
outcome = { room_nights: 0, missing_nights: 0, clamped_nights: [] }
|
|
39
|
+
|
|
40
|
+
updates.each do |update|
|
|
41
|
+
dates = update.dates
|
|
42
|
+
items = SpreeCmCommissioner::InventoryItem.where(variant_id: update.room_id, inventory_date: dates).order(:inventory_date).to_a
|
|
43
|
+
outcome[:missing_nights] += dates.size - items.size
|
|
44
|
+
next if items.empty?
|
|
45
|
+
|
|
46
|
+
write_closed(items.map(&:id), update.closed) unless update.closed.nil?
|
|
47
|
+
write_prices(items, update) if update.price
|
|
48
|
+
if update.rooms_to_sell
|
|
49
|
+
outcome[:clamped_nights].concat(write_availability(items, update, dates))
|
|
50
|
+
mark_pushed(items, update)
|
|
51
|
+
end
|
|
52
|
+
outcome[:room_nights] += items.size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
outcome
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def write_closed(item_ids, closed)
|
|
61
|
+
SpreeCmCommissioner::InventoryItem.where(id: item_ids).update_all( # rubocop:disable Rails/SkipsModelValidations
|
|
62
|
+
['closed = :closed, lock_version = lock_version + 1, updated_at = :now', { closed: closed, now: Time.current }]
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def mark_pushed(items, update)
|
|
67
|
+
first_pushed = items.map(&:id) - @pushed_nights.pushed_at_by_item_id(items).keys
|
|
68
|
+
|
|
69
|
+
SpreeCmCommissioner::InventoryItem.transaction do
|
|
70
|
+
write_closed(first_pushed, false) if update.closed.nil? && first_pushed.any?
|
|
71
|
+
@pushed_nights.record!(items, at: Time.current)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def write_prices(items, update)
|
|
76
|
+
items.each do |item|
|
|
77
|
+
price = Spree::Price.find_or_initialize_by(variant_id: item.variant_id, inventory_item_id: item.id, currency: update.currency)
|
|
78
|
+
price.update!(amount: update.price) if price.new_record? || price.amount != update.price
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Returns the nights where in-flight holds kept stock above the target.
|
|
83
|
+
def write_availability(items, update, dates)
|
|
84
|
+
# A cold key reads as 0 in the script; seed every pool from the row first (SET NX).
|
|
85
|
+
cached = SpreeCmCommissioner::RedisStock::CachedInventoryItemsBuilder.new(items).call.index_by(&:inventory_item_id)
|
|
86
|
+
unseen = @unseen.by_date(update.room_id, dates)
|
|
87
|
+
|
|
88
|
+
items.filter_map do |item|
|
|
89
|
+
target = [update.rooms_to_sell - unseen[item.inventory_date] - cached[item.id].quantity_locked, 0].max
|
|
90
|
+
delta, final = set_stock(item, target)
|
|
91
|
+
add_delta!(item, delta)
|
|
92
|
+
|
|
93
|
+
{ room_id: item.variant_id, date: item.inventory_date, target: target, held: final } if final > target
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def set_stock(item, target)
|
|
98
|
+
SpreeCmCommissioner.inventory_redis_pool.with do |redis|
|
|
99
|
+
redis.eval(SET_STOCK_SCRIPT, keys: [item.redis_key, item.redis_hold_key], argv: [target, item.redis_expired_in])
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Bumps lock_version so a writer holding a stale copy of the row fails instead of overwriting.
|
|
104
|
+
# If the row can't take the delta, Redis is put back by the same delta, which commutes with any
|
|
105
|
+
# sale that landed in between; the push then fails and the channel manager resends it.
|
|
106
|
+
def add_delta!(item, delta)
|
|
107
|
+
# A relative SQL update is the point here (see the class comment), so no model save.
|
|
108
|
+
SpreeCmCommissioner::InventoryItem.where(id: item.id).update_all( # rubocop:disable Rails/SkipsModelValidations
|
|
109
|
+
[
|
|
110
|
+
'quantity_available = quantity_available + :delta, max_capacity = max_capacity + :delta, ' \
|
|
111
|
+
'lock_version = lock_version + 1, updated_at = :now',
|
|
112
|
+
{ delta: delta, now: Time.current }
|
|
113
|
+
]
|
|
114
|
+
)
|
|
115
|
+
rescue StandardError
|
|
116
|
+
SpreeCmCommissioner.inventory_redis_pool.with { |redis| redis.incrby(item.redis_key, -delta) } unless delta.zero?
|
|
117
|
+
raise
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|