spree_cm_commissioner 2.13.3.pre.pre2 → 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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/spree/api/switch_cm/ota/avail_notifs_controller.rb +1 -1
  4. data/app/controllers/spree/api/switch_cm/ota/pushes_controller.rb +2 -2
  5. data/app/controllers/spree/api/switch_cm/ota/rate_amount_notifs_controller.rb +1 -1
  6. data/app/controllers/spree/api/switch_cm/ota/res_modify_notifs_controller.rb +2 -2
  7. data/app/controllers/spree/api/switch_cm/ota/res_notifs_controller.rb +2 -2
  8. data/app/controllers/spree/api/switch_cm/ota/reservation_streams_controller.rb +6 -6
  9. data/app/controllers/spree/api/switch_cm/xml/availabilities_controller.rb +2 -2
  10. data/app/controllers/spree/api/switch_cm/xml/reservation_summaries_controller.rb +2 -2
  11. data/app/controllers/spree/api/switch_cm/xml/reservations_controller.rb +4 -4
  12. data/app/controllers/spree/api/switch_cm/xml/room_rate_availabilities_controller.rb +1 -1
  13. data/app/controllers/spree/api/v2/storefront/draft_orders_controller.rb +41 -3
  14. data/app/controllers/spree/api/v2/storefront/guest_qrs_controller.rb +2 -0
  15. data/app/models/spree_cm_commissioner/guest.rb +8 -0
  16. data/app/models/spree_cm_commissioner/integrations/switch_cm.rb +2 -2
  17. data/app/serializers/spree_cm_commissioner/v2/storefront/cart_serializer_decorator.rb +2 -1
  18. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/accept_push.rb +103 -0
  19. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/accommodation_lock.rb +43 -0
  20. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/apply_updates.rb +121 -0
  21. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/availability_snapshot.rb +88 -0
  22. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/inventory_update.rb +26 -0
  23. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/pushed_nights.rb +81 -0
  24. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/unseen_room_nights.rb +41 -0
  25. data/app/services/spree_cm_commissioner/integrations/switch_cm/inventory/validate_updates.rb +138 -0
  26. data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/{avail_notif_parser.rb → availability_parser.rb} +3 -3
  27. data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/{rate_amount_notif_parser.rb → price_parser.rb} +3 -3
  28. data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/push_response.rb +2 -2
  29. data/app/services/spree_cm_commissioner/integrations/switch_cm/ota/{reservation_presenter.rb → reservation_serializer.rb} +1 -1
  30. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/acknowledge.rb +45 -0
  31. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/feed.rb +145 -0
  32. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/mark_seen.rb +55 -0
  33. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservations/reservation.rb +120 -0
  34. data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_strategies/full_sync_strategy.rb +4 -4
  35. data/app/services/spree_cm_commissioner/integrations/switch_cm/sync_strategies/incremental_sync_strategy.rb +2 -2
  36. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/availability_parser.rb +2 -2
  37. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/push_response.rb +2 -2
  38. data/app/services/spree_cm_commissioner/integrations/switch_cm/xml/{reservation_presenter.rb → reservation_serializer.rb} +1 -1
  39. data/app/services/spree_cm_commissioner/inventory_items/reset.rb +1 -1
  40. data/lib/spree_cm_commissioner/version.rb +1 -1
  41. metadata +17 -17
  42. data/app/services/spree_cm_commissioner/integrations/switch_cm/accept_push.rb +0 -101
  43. data/app/services/spree_cm_commissioner/integrations/switch_cm/accommodation_lock.rb +0 -41
  44. data/app/services/spree_cm_commissioner/integrations/switch_cm/acknowledge_reservations.rb +0 -43
  45. data/app/services/spree_cm_commissioner/integrations/switch_cm/apply_ari.rb +0 -119
  46. data/app/services/spree_cm_commissioner/integrations/switch_cm/ari_update.rb +0 -24
  47. data/app/services/spree_cm_commissioner/integrations/switch_cm/availability_readback.rb +0 -86
  48. data/app/services/spree_cm_commissioner/integrations/switch_cm/mark_seen.rb +0 -53
  49. data/app/services/spree_cm_commissioner/integrations/switch_cm/pushed_nights.rb +0 -79
  50. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservation.rb +0 -118
  51. data/app/services/spree_cm_commissioner/integrations/switch_cm/reservation_feed.rb +0 -143
  52. data/app/services/spree_cm_commissioner/integrations/switch_cm/unseen_room_nights.rb +0 -39
  53. 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: 1c04efc07b0defa6eedb7b800bf8c5504401b7de9042751e32a6eb40c1ec6897
4
- data.tar.gz: 414a5fb2a306e1a1e948c9e08cbb14e412752b112c2509332ba6c15132870369
3
+ metadata.gz: c2ffd7af989f00931883cac1fc7aa3ff4173458f57a42abd9294ecba2687c2bb
4
+ data.tar.gz: d2ee7a2a5d558f018075eded5ef178d294a61afc6e53b5d77018c3ef3a239c04
5
5
  SHA512:
6
- metadata.gz: d658bc4bfad3c59f0fb602afefd148e1ba7edc05a95925e4f7da564780f434178b16a910f747a58a21d9fe3644089ebd77e38a9b5751ec77804a6e91fb427ad5
7
- data.tar.gz: fc0f5fb8a6c7ceed9874f78d2428f700afb508f1589d9ac1477c3e5768ce19885819719b74f7151309a671ec5eb51a0020a981f27da5fc977818bf4a5b067bb6
6
+ metadata.gz: db45749501e065ded53bef1baa37ebbbca96badd6384ac47505f72ed08dd45c0040e6ea22f50dfa4454ea32a3059d63aa7f9ce3e80fe4b97f99d3dca06e1f1ea
7
+ data.tar.gz: fc0b52d82222015696750733ecc702ef57e425256ca4e460644f21fe48369251e01e41adee33848e9d78be225e3342d845db8c4df93e973c70a9a360c21e72cb
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.13.3.pre.pre2)
37
+ spree_cm_commissioner (2.13.3.pre.pre3)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -5,7 +5,7 @@ module Spree::Api::SwitchCm
5
5
  private
6
6
 
7
7
  # override
8
- def parser_class = SpreeCmCommissioner::Integrations::SwitchCm::Ota::AvailNotifParser
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::RateAmountNotifParser
8
+ def parser_class = SpreeCmCommissioner::Integrations::SwitchCm::Ota::PriceParser
9
9
 
10
10
  # override
11
11
  def message_name = 'OTA_HotelRateAmountNotif'
@@ -23,8 +23,8 @@ module Spree::Api::SwitchCm
23
23
  def container = :HotelResModifies
24
24
 
25
25
  # override
26
- def present(presenter, xml)
27
- presenter.build_modification(xml)
26
+ def serialize(serializer, xml)
27
+ serializer.build_modification(xml)
28
28
  end
29
29
  end
30
30
  end
@@ -21,8 +21,8 @@ module Spree::Api::SwitchCm
21
21
  def container = :HotelReservations
22
22
 
23
23
  # override
24
- def present(presenter, xml)
25
- presenter.build_reservation(xml)
24
+ def serialize(serializer, xml)
25
+ serializer.build_reservation(xml)
26
26
  end
27
27
  end
28
28
  end
@@ -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::ReservationFeed.new(integrations).public_send(
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::ReservationFeed.parse_last_change(params[:last_change]),
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
- present(SpreeCmCommissioner::Integrations::SwitchCm::Ota::ReservationPresenter.new(reservation), xml)
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::AcknowledgeReservations.call(
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 present(_presenter, _xml)
71
- raise NotImplementedError, "#{self.class.name} must implement present"
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::ReservationFeed.new([current_integration]).summary
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::ReservationPresenter.new(reservation, summary: true).build(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::ReservationFeed.new(integrations).changed_reservations(
11
+ reservations = SpreeCmCommissioner::Integrations::SwitchCm::Reservations::Feed.new(integrations).changed_reservations(
12
12
  id: request_value('id'),
13
- last_change: SpreeCmCommissioner::Integrations::SwitchCm::ReservationFeed.parse_last_change(request_value('last_change')),
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::ReservationPresenter.new(reservation).build(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::AvailabilityReadback.new(
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: {}, public_metadata: {}, private_metadata: {} } (required)
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(line_items: [:variant_id, :quantity, { options: {}, public_metadata: {}, private_metadata: {} }])
49
- .fetch(:line_items, [])
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
@@ -66,7 +66,7 @@ class SpreeCmCommissioner::Integrations::SwitchCm < SpreeCmCommissioner::Integra
66
66
  # While switch.cm owns the rooms, nights it hasn't pushed stay closed, so the admin page shows
67
67
  # this until the first full push lands.
68
68
  def push_progress
69
- PushedNights.new(self).progress
69
+ Inventory::PushedNights.new(self).progress
70
70
  end
71
71
 
72
72
  private
@@ -81,6 +81,6 @@ class SpreeCmCommissioner::Integrations::SwitchCm < SpreeCmCommissioner::Integra
81
81
  owned_before = status_before_last_save.to_s == 'active' && conflict_strategy_before_last_save.to_s == 'external_wins'
82
82
  return if owned_before == owns_inventory?
83
83
 
84
- PushedNights.new(self).reconcile_unpushed!
84
+ Inventory::PushedNights.new(self).reconcile_unpushed!
85
85
  end
86
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?
@@ -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
@@ -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