ti2-tourplan 1.0.131 → 1.0.133

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.
@@ -18,6 +18,8 @@ const {
18
18
  const {
19
19
  CROSS_SEASON_CAL_USING_RATE_OF_FIRST_RATE_PERIOD,
20
20
  } = require('./product-connect/itinerary-pc-option-helper');
21
+ const { isEnabled, normalizePickupPoints } = require('../tp-helpers/pickup-points');
22
+ const { getOptionInfoTag } = require('../utils');
21
23
 
22
24
  const NO_RATE_FOUND_FOR_LAST_YEAR_ERROR_MESSAGE = 'Custom rates cannot be calculated as the previous year\'s rate could not be found. Please change the date and try again.';
23
25
  const NO_RATE_FOUND_FOR_IMMEDIATE_LAST_DATE_RANGE_ERROR_MESSAGE = 'Custom rates cannot be calculated no last rates available. Please change the date and try again.';
@@ -131,6 +133,7 @@ const GENERIC_AVALABILITY_CHK_ERROR_MESSAGE = 'Not bookable for the requested da
131
133
  + '(e.g. no rates, block out period, on request, minimum stay etc.).';
132
134
  const RATES_AVAILABLE_TILL_ERROR_TEMPLATE = ' Rates are only available until {dateTill}. '
133
135
  + 'Please change the date and try again.';
136
+
134
137
  /**
135
138
  * Calculate days in a year for a given date, accounting for leap years
136
139
  * @param {string} dateString - Date string in YYYY-MM-DD format
@@ -163,6 +166,7 @@ const getAvailabilityOnly = async ({
163
166
  axios,
164
167
  startDate,
165
168
  requestedEndDate,
169
+ pickupPointsRequired,
166
170
  callTourplan,
167
171
  }) => {
168
172
  const isValidRequestedEndDate = moment(requestedEndDate, 'YYYY-MM-DD', true).isValid();
@@ -172,7 +176,7 @@ const getAvailabilityOnly = async ({
172
176
  const model = {
173
177
  OptionInfoRequest: {
174
178
  Opt: optionId,
175
- Info: 'GAR',
179
+ Info: getOptionInfoTag({ checkType: 'GAR', pickupPointsRequired }),
176
180
  AgentID: hostConnectAgentID,
177
181
  Password: hostConnectAgentPassword,
178
182
  DateFrom: startDate,
@@ -187,9 +191,12 @@ const getAvailabilityOnly = async ({
187
191
  xmlOptions: hostConnectXmlOptions,
188
192
  });
189
193
  const option = R.path(['OptionInfoReply', 'Option'], replyObj);
194
+ const pickupPoints = normalizePickupPoints(R.path(['OptGeneral', 'PickupPoints'], option))
195
+ || normalizePickupPoints(R.path(['PickupPoints'], option));
190
196
  return {
191
197
  optAvail: R.pathOr([], ['OptAvail'], option),
192
198
  optRates: R.path(['OptRates'], option),
199
+ ...(isEnabled(pickupPointsRequired) && pickupPoints ? { pickupPoints } : {}),
193
200
  };
194
201
  };
195
202
  /*
@@ -43,6 +43,8 @@ const {
43
43
  CROSS_SEASON_CAL_USING_RATE_OF_FIRST_RATE_PERIOD,
44
44
  } = require('./product-connect/itinerary-pc-option-helper');
45
45
 
46
+ const { isEnabled } = require('../tp-helpers/pickup-points');
47
+
46
48
  const MIN_STAY_WARNING_MESSAGE = 'Please note that the a minimum stay requirement of {minSCU} was required for {customPeriodInfoMsg}';
47
49
  const PREVIOUS_RATE_CLOSED_PERIODS_ERROR_MESSAGE = 'Not bookable for the requested date/stay using {customPeriodInfoMsg} {closedDateRanges}';
48
50
  const INVALID_DAY_OF_WEEK_ERROR_TEMPLATE = 'The start date day can only be on {allowedDays}. Please try again with the allowed day.';
@@ -66,7 +68,19 @@ const doAllDatesHaveRatesAvailable = (lastDateRangeEndDate, startDate, chargeUni
66
68
  return !atLeastOneDateDoesNotHaveRatesAvailable;
67
69
  };
68
70
 
69
- const returnEmptyRatesOrError = (allowSendingServicesWithoutARate, agentCurrencyCode, errorMsg) => {
71
+ // getAvailabilityOnly already normalizes via tp-helpers/pickup-points, so pickupPoints
72
+ // here (when present) is always a non-empty PickupPoint array; just check for it.
73
+ const attachPickupPoints = (response, pickupPoints) => (
74
+ pickupPoints && Array.isArray(pickupPoints.PickupPoint) && pickupPoints.PickupPoint.length
75
+ ? { ...response, pickupPoints }
76
+ : response
77
+ );
78
+
79
+ const returnEmptyRatesOrError = (
80
+ allowSendingServicesWithoutARate,
81
+ agentCurrencyCode,
82
+ errorMsg,
83
+ ) => {
70
84
  // if sending services without a rate is enabled return empty rates (0.00)
71
85
  if (allowSendingServicesWithoutARate && agentCurrencyCode) {
72
86
  return {
@@ -145,6 +159,7 @@ const searchAvailabilityForItinerary = async ({
145
159
  chargeUnitQuantity,
146
160
  roomTypeRequired = true,
147
161
  availabilityOnly = false,
162
+ pickupPointsRequired = false,
148
163
  },
149
164
  callTourplan,
150
165
  cache,
@@ -152,7 +167,7 @@ const searchAvailabilityForItinerary = async ({
152
167
  const isAvailabilityOnly = availabilityOnly === true;
153
168
 
154
169
  if (isAvailabilityOnly) {
155
- const { optAvail, optRates } = await getAvailabilityOnly({
170
+ const { optAvail, optRates, pickupPoints } = await getAvailabilityOnly({
156
171
  optionId,
157
172
  hostConnectEndpoint,
158
173
  hostConnectAgentID,
@@ -160,12 +175,13 @@ const searchAvailabilityForItinerary = async ({
160
175
  axios,
161
176
  startDate,
162
177
  requestedEndDate,
178
+ pickupPointsRequired,
163
179
  callTourplan,
164
180
  });
165
181
  const optAvailValues = getOptAvailValues(optAvail);
166
182
  const SCheckPass = isOptAvailBookable(optAvailValues);
167
183
  const rates = (SCheckPass && optRates) ? [optRates] : [];
168
- return {
184
+ const availabilityResponse = {
169
185
  bookable: SCheckPass,
170
186
  type: 'availability',
171
187
  ...(requestedEndDate && SCheckPass ? { endDate: requestedEndDate } : {}),
@@ -175,6 +191,9 @@ const searchAvailabilityForItinerary = async ({
175
191
  availability: optAvailValues,
176
192
  rates,
177
193
  };
194
+ return isEnabled(pickupPointsRequired)
195
+ ? attachPickupPoints(availabilityResponse, pickupPoints)
196
+ : availabilityResponse;
178
197
  }
179
198
 
180
199
  // Get agent currency code & cache it
@@ -0,0 +1,219 @@
1
+ /* globals describe, it, expect, jest */
2
+
3
+ const {
4
+ getAvailabilityOnly,
5
+ getStayResults,
6
+ } = require('./itinerary-availability-helper');
7
+ const { searchAvailabilityForItinerary } = require('./itinerary-availability');
8
+
9
+ describe('availability pickup points', () => {
10
+ const baseParams = {
11
+ optionId: 'LONSMPICKUP00001',
12
+ hostConnectEndpoint: 'https://test-host-connect.com',
13
+ hostConnectAgentID: 'test-agent-id',
14
+ hostConnectAgentPassword: 'test-agent-password',
15
+ axios: jest.fn(),
16
+ startDate: '2025-04-01',
17
+ requestedEndDate: '2025-04-05',
18
+ };
19
+
20
+ const singlePickupPoint = {
21
+ Point_ID: '504',
22
+ PointDescription: 'Keio Plaza Hotel Tokyo',
23
+ CanPickup: 'Y',
24
+ CanDropoff: 'Y',
25
+ puTime: '0815',
26
+ doTime: '1800',
27
+ };
28
+
29
+ const optionInfoReply = ({ pickupPoints, optAvail = '-2 -2 -2' } = {}) => ({
30
+ OptionInfoReply: {
31
+ Option: {
32
+ OptAvail: optAvail,
33
+ OptRates: { Currency: 'USD' },
34
+ OptGeneral: {
35
+ ...(pickupPoints !== undefined ? { PickupPoints: pickupPoints } : {}),
36
+ },
37
+ },
38
+ },
39
+ });
40
+
41
+ it('keeps GAR OptionInfo by default', async () => {
42
+ const callTourplan = jest.fn(async () => optionInfoReply());
43
+
44
+ await getAvailabilityOnly({
45
+ ...baseParams,
46
+ callTourplan,
47
+ });
48
+
49
+ expect(callTourplan).toHaveBeenCalledWith(expect.objectContaining({
50
+ model: expect.objectContaining({
51
+ OptionInfoRequest: expect.objectContaining({
52
+ Info: 'GAR',
53
+ }),
54
+ }),
55
+ }));
56
+ });
57
+
58
+ it.each([false, 'false', 0, null, undefined])(
59
+ 'keeps GAR OptionInfo when pickupPointsRequired is %p',
60
+ async pickupPointsRequired => {
61
+ const callTourplan = jest.fn(async () => optionInfoReply());
62
+
63
+ await getAvailabilityOnly({
64
+ ...baseParams,
65
+ pickupPointsRequired,
66
+ callTourplan,
67
+ });
68
+
69
+ expect(callTourplan).toHaveBeenCalledWith(expect.objectContaining({
70
+ model: expect.objectContaining({
71
+ OptionInfoRequest: expect.objectContaining({
72
+ Info: 'GAR',
73
+ }),
74
+ }),
75
+ }));
76
+ },
77
+ );
78
+
79
+ it.each([true, 1, 'true', 'yes', 'YES'])(
80
+ 'adds P to OptionInfo when pickupPointsRequired is %p',
81
+ async pickupPointsRequired => {
82
+ const callTourplan = jest.fn(async () => optionInfoReply({
83
+ pickupPoints: { PickupPoint: singlePickupPoint },
84
+ }));
85
+
86
+ const result = await getAvailabilityOnly({
87
+ ...baseParams,
88
+ pickupPointsRequired,
89
+ callTourplan,
90
+ });
91
+
92
+ expect(callTourplan).toHaveBeenCalledWith(expect.objectContaining({
93
+ model: expect.objectContaining({
94
+ OptionInfoRequest: expect.objectContaining({
95
+ Info: 'GARP',
96
+ }),
97
+ }),
98
+ }));
99
+ expect(result.pickupPoints).toEqual({
100
+ PickupPoint: [singlePickupPoint],
101
+ });
102
+ },
103
+ );
104
+
105
+ it('omits pickupPoints when Tourplan returns none', async () => {
106
+ const callTourplan = jest.fn(async () => optionInfoReply({
107
+ pickupPoints: { PickupPoint: [] },
108
+ }));
109
+
110
+ const result = await getAvailabilityOnly({
111
+ ...baseParams,
112
+ pickupPointsRequired: true,
113
+ callTourplan,
114
+ });
115
+
116
+ expect(result).not.toHaveProperty('pickupPoints');
117
+ });
118
+
119
+ it('omits pickupPoints when pickupPointsRequired is disabled', async () => {
120
+ const callTourplan = jest.fn(async () => optionInfoReply({
121
+ pickupPoints: { PickupPoint: singlePickupPoint },
122
+ }));
123
+
124
+ const result = await getAvailabilityOnly({
125
+ ...baseParams,
126
+ pickupPointsRequired: false,
127
+ callTourplan,
128
+ });
129
+
130
+ expect(result).not.toHaveProperty('pickupPoints');
131
+ });
132
+
133
+ it('falls back to top-level pickup points when OptGeneral pickup points are empty', async () => {
134
+ const callTourplan = jest.fn(async () => ({
135
+ OptionInfoReply: {
136
+ Option: {
137
+ OptAvail: '-2 -2 -2',
138
+ OptRates: { Currency: 'USD' },
139
+ OptGeneral: {
140
+ PickupPoints: { PickupPoint: [] },
141
+ },
142
+ PickupPoints: { PickupPoint: singlePickupPoint },
143
+ },
144
+ },
145
+ }));
146
+
147
+ const result = await getAvailabilityOnly({
148
+ ...baseParams,
149
+ pickupPointsRequired: true,
150
+ callTourplan,
151
+ });
152
+
153
+ expect(result.pickupPoints).toEqual({
154
+ PickupPoint: [singlePickupPoint],
155
+ });
156
+ });
157
+
158
+ it('keeps GS OptionInfo for stay results (pickup points come from GD/GAR)', async () => {
159
+ const callTourplan = jest.fn(async () => ({
160
+ OptionInfoReply: {
161
+ Option: {
162
+ OptStayResults: [],
163
+ },
164
+ },
165
+ }));
166
+
167
+ await getStayResults(
168
+ baseParams.optionId,
169
+ baseParams.hostConnectEndpoint,
170
+ baseParams.hostConnectAgentID,
171
+ baseParams.hostConnectAgentPassword,
172
+ baseParams.axios,
173
+ baseParams.startDate,
174
+ 1,
175
+ [{ Adults: 2 }],
176
+ undefined,
177
+ callTourplan,
178
+ );
179
+
180
+ expect(callTourplan).toHaveBeenCalledWith(expect.objectContaining({
181
+ model: expect.objectContaining({
182
+ OptionInfoRequest: expect.objectContaining({
183
+ Info: 'GS',
184
+ }),
185
+ }),
186
+ }));
187
+ });
188
+
189
+ it('returns pickupPoints on the availabilityOnly search response', async () => {
190
+ const callTourplan = jest.fn(async () => optionInfoReply({
191
+ pickupPoints: { PickupPoint: singlePickupPoint },
192
+ optAvail: '-2 -2 -2',
193
+ }));
194
+
195
+ const result = await searchAvailabilityForItinerary({
196
+ axios: jest.fn(),
197
+ token: {
198
+ hostConnectEndpoint: baseParams.hostConnectEndpoint,
199
+ hostConnectAgentID: baseParams.hostConnectAgentID,
200
+ hostConnectAgentPassword: baseParams.hostConnectAgentPassword,
201
+ },
202
+ payload: {
203
+ optionId: baseParams.optionId,
204
+ startDate: baseParams.startDate,
205
+ endDate: baseParams.requestedEndDate,
206
+ availabilityOnly: true,
207
+ pickupPointsRequired: 'true',
208
+ paxConfigs: [{ adults: 2 }],
209
+ },
210
+ callTourplan,
211
+ cache: { getOrExec: async ({ fn, fnParams }) => fn(...fnParams) },
212
+ });
213
+
214
+ expect(result.bookable).toBe(true);
215
+ expect(result.pickupPoints).toEqual({
216
+ PickupPoint: [singlePickupPoint],
217
+ });
218
+ });
219
+ });
@@ -183,6 +183,128 @@ describe('searchAvailabilityForItinerary validation flags', () => {
183
183
  });
184
184
  });
185
185
 
186
+ it('passes pickupPointsRequired through the availabilityOnly flow', async () => {
187
+ await searchAvailabilityForItinerary({
188
+ axios: jest.fn(),
189
+ token: baseToken,
190
+ payload: {
191
+ ...basePayload,
192
+ availabilityOnly: true,
193
+ endDate: '2025-04-05',
194
+ pickupPointsRequired: true,
195
+ },
196
+ callTourplan: jest.fn(),
197
+ cache: { getOrExec: async ({ fn, fnParams }) => fn(...fnParams) },
198
+ });
199
+
200
+ expect(itineraryAvailabilityHelper.getAvailabilityOnly).toHaveBeenCalledWith(
201
+ expect.objectContaining({ pickupPointsRequired: true }),
202
+ );
203
+ });
204
+
205
+ it('does not pass pickupPointsRequired through the rates availability flow', async () => {
206
+ const result = await searchAvailabilityForItinerary({
207
+ axios: jest.fn(),
208
+ token: baseToken,
209
+ payload: { ...basePayload, pickupPointsRequired: true },
210
+ callTourplan: jest.fn(),
211
+ cache: { getOrExec: async ({ fn, fnParams }) => fn(...fnParams) },
212
+ });
213
+
214
+ expect(itineraryAvailabilityHelper.getAvailabilityConfig).toHaveBeenCalledWith(
215
+ expect.not.objectContaining({ pickupPointsRequired: expect.anything() }),
216
+ );
217
+ expect(itineraryAvailabilityHelper.getStayResults).toHaveBeenCalledWith(
218
+ basePayload.optionId,
219
+ baseToken.hostConnectEndpoint,
220
+ baseToken.hostConnectAgentID,
221
+ baseToken.hostConnectAgentPassword,
222
+ expect.any(Function),
223
+ basePayload.startDate,
224
+ basePayload.chargeUnitQuantity,
225
+ expect.any(Array),
226
+ undefined,
227
+ expect.any(Function),
228
+ );
229
+ expect(result).not.toHaveProperty('pickupPoints');
230
+ });
231
+
232
+ it('returns pickupPoints on the availabilityOnly response when provided', async () => {
233
+ const pickupPoints = {
234
+ PickupPoint: [{ Point_ID: '504', PointDescription: 'Keio Plaza Hotel Tokyo' }],
235
+ };
236
+ itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce({
237
+ optAvail: '-2 -2 -2',
238
+ optRates: { Currency: 'USD' },
239
+ pickupPoints,
240
+ });
241
+
242
+ const result = await searchAvailabilityForItinerary({
243
+ axios: jest.fn(),
244
+ token: baseToken,
245
+ payload: {
246
+ ...basePayload,
247
+ availabilityOnly: true,
248
+ endDate: '2025-04-05',
249
+ pickupPointsRequired: 'yes',
250
+ },
251
+ callTourplan: jest.fn(),
252
+ cache: { getOrExec: async ({ fn, fnParams }) => fn(...fnParams) },
253
+ });
254
+
255
+ expect(result.pickupPoints).toEqual(pickupPoints);
256
+ expect(result.bookable).toBe(true);
257
+ });
258
+
259
+ it('omits pickupPoints on the availabilityOnly response when pickupPointsRequired is disabled', async () => {
260
+ itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce({
261
+ optAvail: '-2 -2 -2',
262
+ optRates: { Currency: 'USD' },
263
+ pickupPoints: {
264
+ PickupPoint: [{ Point_ID: '504', PointDescription: 'Keio Plaza Hotel Tokyo' }],
265
+ },
266
+ });
267
+
268
+ const result = await searchAvailabilityForItinerary({
269
+ axios: jest.fn(),
270
+ token: baseToken,
271
+ payload: {
272
+ ...basePayload,
273
+ availabilityOnly: true,
274
+ endDate: '2025-04-05',
275
+ pickupPointsRequired: false,
276
+ },
277
+ callTourplan: jest.fn(),
278
+ cache: { getOrExec: async ({ fn, fnParams }) => fn(...fnParams) },
279
+ });
280
+
281
+ expect(result).not.toHaveProperty('pickupPoints');
282
+ expect(result.bookable).toBe(true);
283
+ });
284
+
285
+ it('omits pickupPoints when the helper returns an empty PickupPoint array', async () => {
286
+ itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce({
287
+ optAvail: '-2 -2 -2',
288
+ optRates: { Currency: 'USD' },
289
+ pickupPoints: { PickupPoint: [] },
290
+ });
291
+
292
+ const result = await searchAvailabilityForItinerary({
293
+ axios: jest.fn(),
294
+ token: baseToken,
295
+ payload: {
296
+ ...basePayload,
297
+ availabilityOnly: true,
298
+ endDate: '2025-04-05',
299
+ pickupPointsRequired: true,
300
+ },
301
+ callTourplan: jest.fn(),
302
+ cache: { getOrExec: async ({ fn, fnParams }) => fn(...fnParams) },
303
+ });
304
+
305
+ expect(result).not.toHaveProperty('pickupPoints');
306
+ });
307
+
186
308
  it('marks availabilityOnly response as not bookable when all days are unavailable', async () => {
187
309
  itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce({
188
310
  optAvail: '-1 -1 -1',
package/index.test.js CHANGED
@@ -1804,6 +1804,52 @@ describe('search tests', () => {
1804
1804
  expect(retVal.bookings[0].bookingId).toBe('316559');
1805
1805
  });
1806
1806
 
1807
+ it('searchItineraries keeps successful fallback criteria when one bookingId criterion fails', async () => {
1808
+ axios.mockImplementation(getFixture);
1809
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
1810
+ mockCallTourplan.mockImplementation(async request => {
1811
+ const { model } = request;
1812
+ const listRequest = model && model.ListBookingsRequest;
1813
+ if (listRequest && listRequest.BookingId === '2694192') {
1814
+ throw new Error('Request failed with status code 500: Object not found in BookingData cache 2694192');
1815
+ }
1816
+ if (listRequest && listRequest.Ref === '2694192') {
1817
+ return { ListBookingsReply: { BookingHeaders: { BookingHeader: [] } } };
1818
+ }
1819
+ if (listRequest && listRequest.AgentRef === '2694192') {
1820
+ return {
1821
+ ListBookingsReply: {
1822
+ BookingHeaders: {
1823
+ BookingHeader: [{ BookingId: '316559' }],
1824
+ },
1825
+ },
1826
+ };
1827
+ }
1828
+ return defaultCallTourplanImplementation(request);
1829
+ });
1830
+
1831
+ try {
1832
+ const retVal = await app.searchItineraries({
1833
+ axios,
1834
+ token,
1835
+ typeDefsAndQueries,
1836
+ payload: {
1837
+ bookingId: '2694192',
1838
+ },
1839
+ });
1840
+
1841
+ expect(retVal.bookings.length).toBe(1);
1842
+ expect(retVal.bookings[0].bookingId).toBe('316559');
1843
+ expect(warnSpy).toHaveBeenCalledWith(
1844
+ '[tourplan] ListBookingsRequest failed',
1845
+ { BookingId: '2694192' },
1846
+ 'Request failed with status code 500: Object not found in BookingData cache 2694192',
1847
+ );
1848
+ } finally {
1849
+ warnSpy.mockRestore();
1850
+ }
1851
+ });
1852
+
1807
1853
  it('searchItineraries by name (NameContains)', async () => {
1808
1854
  axios.mockImplementation(getFixture);
1809
1855
  const retVal = await app.searchItineraries({
@@ -7,30 +7,11 @@ const { getCachedLocations } = require('./tp-helpers/locations');
7
7
  const { getCachedServices } = require('./tp-helpers/services');
8
8
  const { getCachedDestinationCountries } = require('./tp-helpers/system-settings');
9
9
  const { enrichOptionWithCodeTables } = require('./tp-helpers/option-enrichment');
10
- const { asArray } = require('./tp-helpers/values');
11
-
12
- const isEnabled = value => {
13
- if (value === true || value === 1) return true;
14
- const normalized = String(value == null ? '' : value).trim().toLowerCase();
15
- return normalized === 'true' || normalized === '1' || normalized === 'yes';
16
- };
17
-
18
- // Keep Tourplan PascalCase under camelCase pickupPoints (same pattern as optRates).
19
- // Bokun channel-manager reads container.PickupPoint / point.Point_ID etc.
20
- const normalizePickupPoints = rawPickupPoints => {
21
- if (!rawPickupPoints || typeof rawPickupPoints !== 'object') return undefined;
22
- const points = asArray(R.path(['PickupPoint'], rawPickupPoints))
23
- .filter(point => point && typeof point === 'object' && !Array.isArray(point));
24
- if (!points.length) return undefined;
25
- return {
26
- ...rawPickupPoints,
27
- PickupPoint: points,
28
- };
29
- };
30
-
31
- const getOptionInfoTag = ({ includeRates = false, pickupPointsRequired = false } = {}) => (
32
- `${includeRates ? 'GR' : 'G'}${pickupPointsRequired ? 'P' : ''}`
33
- );
10
+ const {
11
+ isEnabled,
12
+ normalizePickupPoints,
13
+ } = require('./tp-helpers/pickup-points');
14
+ const { getOptionInfoTag } = require('./utils');
34
15
 
35
16
  const searchProductsForItinerary = async ({
36
17
  axios,
@@ -273,10 +254,12 @@ const searchProductsForItinerary = async ({
273
254
  || R.path(['__destination', 'city'], rawOption);
274
255
  const country = currentOption.country
275
256
  || R.path(['__destination', 'country'], rawOption);
276
- const pickupPoints = normalizePickupPoints(
277
- R.path(['OptGeneral', 'PickupPoints'], rawOption)
278
- || R.path(['PickupPoints'], rawOption),
279
- );
257
+ const pickupPoints = shouldRequestPickupPoints
258
+ ? normalizePickupPoints(
259
+ R.path(['OptGeneral', 'PickupPoints'], rawOption)
260
+ || R.path(['PickupPoints'], rawOption),
261
+ )
262
+ : undefined;
280
263
  return {
281
264
  ...R.omit(['city', 'country', 'rateContext'], currentOption),
282
265
  ...(city ? { city } : {}),
@@ -231,13 +231,15 @@ const searchItineraries = async ({
231
231
 
232
232
  // Step4: Fetch for bookings based on the search criterias.
233
233
  const allSearches = searchCriterias.length
234
- ? searchCriterias.map(async keyObj => {
235
- let reply;
236
- try {
237
- reply = await callTourplan(getPayload('ListBookingsRequest', {
238
- ...(baseSearchFilters ? { ...baseSearchFilters } : {}),
239
- ...keyObj,
240
- }));
234
+ ? searchCriterias.map(keyObj => ({
235
+ keyObj,
236
+ promise: (async () => {
237
+ let reply;
238
+ try {
239
+ reply = await callTourplan(getPayload('ListBookingsRequest', {
240
+ ...(baseSearchFilters ? { ...baseSearchFilters } : {}),
241
+ ...keyObj,
242
+ }));
241
243
  /*
242
244
  <Reply>
243
245
  <ListBookingsReply>
@@ -262,33 +264,55 @@ const searchItineraries = async ({
262
264
  </ListBookingsReply>
263
265
  </Reply>
264
266
  */
265
- } catch (err) {
266
- const errMsg = typeof err === 'string' ? err : (err && err.message) || String(err);
267
- if (errMsg.includes('Request failed with status code')) {
268
- throw Error(errMsg);
267
+ } catch (err) {
268
+ const errMsg = typeof err === 'string' ? err : (err && err.message) || String(err);
269
+ if (errMsg.includes('Request failed with status code')) {
270
+ throw Error(errMsg);
271
+ }
272
+ // if it's not server error, we just considered as no booking is found
273
+ reply = { ListBookingsReply: { BookingHeaders: { BookingHeader: [] } } };
269
274
  }
270
- // if it's not server error, we just considered as no booking is found
271
- reply = { ListBookingsReply: { BookingHeaders: { BookingHeader: [] } } };
272
- }
273
- return reply;
274
- })
275
+ return reply;
276
+ })(),
277
+ }))
275
278
  : [
276
279
  // Date-range-only search (no explicit criteria). Wrap in the same try/catch
277
280
  // pattern as the map branch so a non-HTTP error is treated as zero results
278
281
  // rather than crashing the entire search.
279
- (async () => {
280
- try {
281
- return await callTourplan(getPayload('ListBookingsRequest', baseSearchFilters));
282
- } catch (err) {
283
- const errMsg = typeof err === 'string' ? err : (err && err.message) || String(err);
284
- if (errMsg.includes('Request failed with status code')) throw Error(errMsg);
285
- return { ListBookingsReply: { BookingHeaders: { BookingHeader: [] } } };
286
- }
287
- })(),
282
+ {
283
+ keyObj: baseSearchFilters,
284
+ promise: (async () => {
285
+ try {
286
+ return await callTourplan(getPayload('ListBookingsRequest', baseSearchFilters));
287
+ } catch (err) {
288
+ const errMsg = typeof err === 'string' ? err : (err && err.message) || String(err);
289
+ if (errMsg.includes('Request failed with status code')) throw Error(errMsg);
290
+ return { ListBookingsReply: { BookingHeaders: { BookingHeader: [] } } };
291
+ }
292
+ })(),
293
+ },
288
294
  ];
289
295
 
290
296
  // Step5: Get full booking details for each booking.
291
- const replyObjs = await Promise.all(allSearches);
297
+ const settledSearches = await global.Promise.allSettled(allSearches.map(({ promise }) => promise));
298
+ const replyObjs = [];
299
+ const rejectedSearches = [];
300
+
301
+ settledSearches.forEach((result, idx) => {
302
+ if (result.status === 'fulfilled') {
303
+ replyObjs.push(result.value);
304
+ return;
305
+ }
306
+ const { keyObj } = allSearches[idx];
307
+ const { reason } = result;
308
+ const errMsg = reason instanceof Error ? reason.message : String(reason);
309
+ rejectedSearches.push(reason);
310
+ console.warn('[tourplan] ListBookingsRequest failed', keyObj, errMsg);
311
+ });
312
+
313
+ if (!replyObjs.length && rejectedSearches.length) {
314
+ throw rejectedSearches[0];
315
+ }
292
316
  const bookingHeadersRaw = R.flatten(
293
317
  replyObjs.map(o => R.pathOr(
294
318
  [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2-tourplan",
3
- "version": "1.0.131",
3
+ "version": "1.0.133",
4
4
  "description": "Tourplan's TI2 Plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,21 @@
1
+ const R = require('ramda');
2
+ const { asArray } = require('./values');
3
+ const { isEnabled } = require('../utils');
4
+
5
+ // Keep Tourplan PascalCase under camelCase pickupPoints (same pattern as optRates).
6
+ // Bokun channel-manager reads container.PickupPoint / point.Point_ID etc.
7
+ const normalizePickupPoints = rawPickupPoints => {
8
+ if (!rawPickupPoints || typeof rawPickupPoints !== 'object') return undefined;
9
+ const points = asArray(R.path(['PickupPoint'], rawPickupPoints))
10
+ .filter(point => point && typeof point === 'object' && !Array.isArray(point));
11
+ if (!points.length) return undefined;
12
+ return {
13
+ ...rawPickupPoints,
14
+ PickupPoint: points,
15
+ };
16
+ };
17
+
18
+ module.exports = {
19
+ isEnabled,
20
+ normalizePickupPoints,
21
+ };
@@ -0,0 +1,51 @@
1
+ /* globals describe, it, expect */
2
+
3
+ const {
4
+ isEnabled,
5
+ normalizePickupPoints,
6
+ } = require('./pickup-points');
7
+
8
+ describe('pickup-points helpers', () => {
9
+ describe('isEnabled', () => {
10
+ it.each([true, 1, 'true', 'TRUE', '1', 'yes', ' Yes '])(
11
+ 'treats %p as enabled',
12
+ value => {
13
+ expect(isEnabled(value)).toBe(true);
14
+ },
15
+ );
16
+
17
+ it.each([false, 0, 'false', 'no', '', null, undefined, 'maybe'])(
18
+ 'treats %p as disabled',
19
+ value => {
20
+ expect(isEnabled(value)).toBe(false);
21
+ },
22
+ );
23
+ });
24
+
25
+ describe('normalizePickupPoints', () => {
26
+ const singlePickupPoint = {
27
+ Point_ID: '504',
28
+ PointDescription: 'Keio Plaza Hotel Tokyo',
29
+ CanPickup: 'Y',
30
+ CanDropoff: 'Y',
31
+ };
32
+
33
+ it('normalizes a single PickupPoint object to an array', () => {
34
+ expect(normalizePickupPoints({ PickupPoint: singlePickupPoint })).toEqual({
35
+ PickupPoint: [singlePickupPoint],
36
+ });
37
+ });
38
+
39
+ it('keeps already-array PickupPoint values', () => {
40
+ const pickupPoints = { PickupPoint: [singlePickupPoint] };
41
+ expect(normalizePickupPoints(pickupPoints)).toEqual(pickupPoints);
42
+ });
43
+
44
+ it('omits empty shells and non-objects', () => {
45
+ expect(normalizePickupPoints(undefined)).toBeUndefined();
46
+ expect(normalizePickupPoints({})).toBeUndefined();
47
+ expect(normalizePickupPoints({ PickupPoint: [] })).toBeUndefined();
48
+ expect(normalizePickupPoints('nope')).toBeUndefined();
49
+ });
50
+ });
51
+ });
package/utils.js CHANGED
@@ -71,6 +71,33 @@ const escapeInvalidXmlChars = str => {
71
71
  .replace(BAD_XML_CHARS, '');
72
72
  };
73
73
 
74
+ const isEnabled = value => {
75
+ if (value === true || value === 1) return true;
76
+ const normalized = String(value == null ? '' : value).trim().toLowerCase();
77
+ return normalized === 'true' || normalized === '1' || normalized === 'yes';
78
+ };
79
+
80
+ /**
81
+ * Build a HostConnect OptionInfoRequest.Info tag (e.g. G, GR, GAR, GD, + optional P).
82
+ *
83
+ * @param {object} [options]
84
+ * @param {string} [options.checkType] - Explicit base tag (e.g. 'GAR', 'GD', 'GS').
85
+ * When provided, this takes precedence and `includeRates` is ignored.
86
+ * @param {boolean} [options.includeRates=false] - Only used when `checkType` is omitted;
87
+ * selects between the product-search base tags 'GR' (rates included) and 'G'.
88
+ * @param {boolean|string|number} [options.pickupPointsRequired=false] - When enabled
89
+ * (see `isEnabled`), appends 'P' to the base tag to request pickup/dropoff points.
90
+ * @returns {string} The resulting Info tag, e.g. 'G', 'GR', 'GARP'.
91
+ */
92
+ const getOptionInfoTag = ({
93
+ checkType,
94
+ includeRates = false,
95
+ pickupPointsRequired = false,
96
+ } = {}) => {
97
+ const base = checkType || (includeRates ? 'GR' : 'G');
98
+ return `${base}${isEnabled(pickupPointsRequired) ? 'P' : ''}`;
99
+ };
100
+
74
101
  const getValidDateOfBirth = dob => {
75
102
  if (typeof dob !== 'string') return undefined;
76
103
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dob);
@@ -188,4 +215,6 @@ module.exports = {
188
215
  hostConnectXmlOptions,
189
216
  productConnectXmlOptions,
190
217
  passengerTypeMap,
218
+ isEnabled,
219
+ getOptionInfoTag,
191
220
  };
package/utils.test.js CHANGED
@@ -1,4 +1,10 @@
1
- const { escapeInvalidXmlChars, getRoomConfigs } = require('./utils');
1
+ /* globals describe, it, expect */
2
+
3
+ const {
4
+ escapeInvalidXmlChars,
5
+ getOptionInfoTag,
6
+ getRoomConfigs,
7
+ } = require('./utils');
2
8
 
3
9
  describe('getRoomConfigs', () => {
4
10
  const getPaxDetails = passenger => getRoomConfigs([{
@@ -135,3 +141,24 @@ describe('escapeInvalidXmlChars', () => {
135
141
  expect(escapeInvalidXmlChars('X\uD800Y')).toBe('XY');
136
142
  });
137
143
  });
144
+
145
+ describe('getOptionInfoTag', () => {
146
+ it('uses includeRates for product-search tags when checkType is omitted', () => {
147
+ expect(getOptionInfoTag()).toBe('G');
148
+ expect(getOptionInfoTag({ includeRates: true })).toBe('GR');
149
+ expect(getOptionInfoTag({ includeRates: false, pickupPointsRequired: 'false' })).toBe('G');
150
+ });
151
+
152
+ it('keeps an explicit checkType when pickup points are not required', () => {
153
+ expect(getOptionInfoTag({ checkType: 'GAR' })).toBe('GAR');
154
+ expect(getOptionInfoTag({ checkType: 'GD', pickupPointsRequired: false })).toBe('GD');
155
+ expect(getOptionInfoTag({ includeRates: true, pickupPointsRequired: 'false' })).toBe('GR');
156
+ });
157
+
158
+ it('appends P for truthy pickupPointsRequired values', () => {
159
+ expect(getOptionInfoTag({ checkType: 'GAR', pickupPointsRequired: true })).toBe('GARP');
160
+ expect(getOptionInfoTag({ checkType: 'GD', pickupPointsRequired: 'yes' })).toBe('GDP');
161
+ expect(getOptionInfoTag({ includeRates: true, pickupPointsRequired: 1 })).toBe('GRP');
162
+ expect(getOptionInfoTag({ pickupPointsRequired: true })).toBe('GP');
163
+ });
164
+ });