ti2-tourplan 1.0.123 → 1.0.125

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.
package/README.md CHANGED
@@ -105,3 +105,68 @@ The plugin returns both:
105
105
  - `status`: aggregate over all `ServiceStatus.Status` values:
106
106
  - same status on all lines => that status (e.g. `XX`)
107
107
  - mixed line statuses => `MIXED`
108
+
109
+ ## Confirm Booking (HostConnect)
110
+
111
+ This plugin converts a quote to a confirmed booking using HostConnect `QuoteToBookRequest`. Inventory is allocated for each service line when the quote is confirmed; the returned `ServiceStatus` values indicate whether allocation succeeded per line.
112
+
113
+ ### HostConnect XML Request
114
+
115
+ ```xml
116
+ <Request>
117
+ <QuoteToBookRequest>
118
+ <AgentID>YOUR_AGENT_ID</AgentID>
119
+ <Password>YOUR_AGENT_PASSWORD</Password>
120
+ <BookingId>14226</BookingId>
121
+ </QuoteToBookRequest>
122
+ </Request>
123
+ ```
124
+
125
+ Equivalent curl:
126
+
127
+ ```bash
128
+ curl --location 'https://<HOSTCONNECT_ENDPOINT>/api/hostConnectApi' \
129
+ --header 'Content-Type: application/xml; charset=utf-8' \
130
+ --header 'requestId: <uuid>' \
131
+ --header 'Accept: application/xml' \
132
+ --data '<Request>
133
+ <QuoteToBookRequest>
134
+ <AgentID>YOUR_AGENT_ID</AgentID>
135
+ <Password>YOUR_AGENT_PASSWORD</Password>
136
+ <BookingId>14226</BookingId>
137
+ </QuoteToBookRequest>
138
+ </Request>'
139
+ ```
140
+
141
+ ### HostConnect XML Response (example)
142
+
143
+ ```xml
144
+ <?xml version="1.0" encoding="utf-8"?>
145
+ <!DOCTYPE Reply SYSTEM "hostConnect_5_05_010.dtd">
146
+ <Reply>
147
+ <QuoteToBookReply>
148
+ <BookingId>14226</BookingId>
149
+ <Ref>A2IN111975</Ref>
150
+ <ServiceStatuses>
151
+ <ServiceStatus>
152
+ <Ref>A2IN111975</Ref>
153
+ <ServiceLineId>61738</ServiceLineId>
154
+ <Date>2026-07-03</Date>
155
+ <SequenceNumber>10</SequenceNumber>
156
+ <Status>OK</Status>
157
+ </ServiceStatus>
158
+ </ServiceStatuses>
159
+ </QuoteToBookReply>
160
+ </Reply>
161
+ ```
162
+
163
+ ### Plugin Return Shape
164
+
165
+ The plugin returns both:
166
+
167
+ - `confirmBookingReply`: raw parsed HostConnect `QuoteToBookReply` object
168
+ - `booking`: normalized object
169
+ - `ref`: from `Ref`
170
+ - `id`: from `BookingId` (or the identifier sent in the request)
171
+ - `status`: aggregate over all `ServiceStatus.Status` values (same rules as cancel), with fallbacks to `BookingStatus`, `Status`, `payload.status`, then `'Confirmed'`
172
+ - `serviceLines`: array of `{ ref, serviceLineId, date, sequenceNumber, status }`
@@ -0,0 +1,77 @@
1
+ <!-- LONTRDAVIDSHDWBVD: Fixture for searchProductsForItinerary Test -->
2
+ <?xml version="1.0" encoding="UTF-8"?>
3
+ <!DOCTYPE Reply SYSTEM "http://localhost:8080/iCom_Test/hostConnect_4_06_009.dtd">
4
+ <Reply>
5
+ <OptionInfoReply>
6
+ <Option>
7
+ <Opt>LONTRDAVIDSHDWBVD</Opt>
8
+ <OptionNumber>70820</OptionNumber>
9
+ <OptGeneral>
10
+ <SupplierId>6489</SupplierId>
11
+ <SupplierName>Davids of London Ltd</SupplierName>
12
+ <Description>Half-Day Warner Bros Studios (6-Hours)</Description>
13
+ <Comment>FIT- V- Class (1-5 Pax)</Comment>
14
+ <Locality>LON</Locality>
15
+ <LocalityDescription>London</LocalityDescription>
16
+ <SType>N</SType>
17
+ <MPFCU>5</MPFCU>
18
+ <SCU>day</SCU>
19
+ <MinSCU>1</MinSCU>
20
+ <MaxSCU>9999</MaxSCU>
21
+ <VoucherName>Davids of London Ltd</VoucherName>
22
+ <Address1>377 Fulham Palace Road</Address1>
23
+ <Address2></Address2>
24
+ <Address3>London</Address3>
25
+ <Address4>United Kingdom</Address4>
26
+ <Address5></Address5>
27
+ <PostCode>SW6 6TA</PostCode>
28
+ <InfantsAllowed>Y</InfantsAllowed>
29
+ <Infant_From>0</Infant_From>
30
+ <Infant_To>4</Infant_To>
31
+ <ChildrenAllowed>Y</ChildrenAllowed>
32
+ <Child_From>5</Child_From>
33
+ <Child_To>15</Child_To>
34
+ <AdultsAllowed>Y</AdultsAllowed>
35
+ <Adult_From>16</Adult_From>
36
+ <Adult_To>999</Adult_To>
37
+ <ButtonName>Transfers</ButtonName>
38
+ <DBAnalysisCode1>FI</DBAnalysisCode1>
39
+ <DBAnalysisDescription1>FIT</DBAnalysisDescription1>
40
+ <DBAnalysisCode2>GB</DBAnalysisCode2>
41
+ <DBAnalysisDescription2>United Kingdom</DBAnalysisDescription2>
42
+ <DBAnalysisCode3>20</DBAnalysisCode3>
43
+ <DBAnalysisDescription3>VAT 20%</DBAnalysisDescription3>
44
+ <DBAnalysisCode4></DBAnalysisCode4>
45
+ <DBAnalysisDescription4>Unassigned</DBAnalysisDescription4>
46
+ <DBAnalysisCode5></DBAnalysisCode5>
47
+ <DBAnalysisDescription5>Unassigned</DBAnalysisDescription5>
48
+ <DBAnalysisCode6></DBAnalysisCode6>
49
+ <DBAnalysisDescription6>Unassigned</DBAnalysisDescription6>
50
+ <LastUpdate>2023-11-23T14:34:53Z</LastUpdate>
51
+ </OptGeneral>
52
+ <OptRates>
53
+ <Currency>GBP</Currency>
54
+ <PaxBreaks>
55
+ <PaxBreak>1</PaxBreak>
56
+ </PaxBreaks>
57
+ <CancelPolicies>
58
+ <CancelPenalty>
59
+ <CancelRuleType>Percent</CancelRuleType>
60
+ <CancelRuleUnits>100.00</CancelRuleUnits>
61
+ </CancelPenalty>
62
+ </CancelPolicies>
63
+ <SaleFrom>2026-01-01</SaleFrom>
64
+ <SaleTo>2026-12-31</SaleTo>
65
+ <OptRate>
66
+ <Date>2026-06-01</Date>
67
+ <RateName>STD</RateName>
68
+ <RateText>Standard</RateText>
69
+ <CommissionPercent>10.00</CommissionPercent>
70
+ <PersonRates>
71
+ <AdultRate>10000</AdultRate>
72
+ </PersonRates>
73
+ </OptRate>
74
+ </OptRates>
75
+ </Option>
76
+ </OptionInfoReply>
77
+ </Reply>
@@ -4153,6 +4153,29 @@ Object {
4153
4153
  "comment": "FIT- V- Class (1-5 Pax)",
4154
4154
  "extras": Array [],
4155
4155
  "lastUpdateTimestamp": 1700750093,
4156
+ "optRates": Object {
4157
+ "CancelPolicies": Object {
4158
+ "CancelPenalty": Object {
4159
+ "CancelRuleType": "Percent",
4160
+ "CancelRuleUnits": 100,
4161
+ },
4162
+ },
4163
+ "Currency": "GBP",
4164
+ "OptRate": Object {
4165
+ "CommissionPercent": 10,
4166
+ "Date": "2026-06-01",
4167
+ "PersonRates": Object {
4168
+ "AdultRate": 10000,
4169
+ },
4170
+ "RateName": "STD",
4171
+ "RateText": "Standard",
4172
+ },
4173
+ "PaxBreaks": Object {
4174
+ "PaxBreak": 1,
4175
+ },
4176
+ "SaleFrom": "2026-01-01",
4177
+ "SaleTo": "2026-12-31",
4178
+ },
4156
4179
  "optionId": "LONTRDAVIDSHDWBVD",
4157
4180
  "optionName": "Half-Day Warner Bros Studios (6-Hours)-FIT- V- Class (1-5 Pax)",
4158
4181
  "restrictions": Object {
package/index.js CHANGED
@@ -11,6 +11,7 @@ const Normalizer = require('./normalizer');
11
11
  const { searchAvailabilityForItinerary } = require('./availability/itinerary-availability');
12
12
  const { addServiceToItinerary } = require('./itinerary-add-service');
13
13
  const { cancelBooking } = require('./itinerary-cancel-booking');
14
+ const { confirmBooking } = require('./itinerary-confirm-booking');
14
15
  const { searchProductsForItinerary } = require('./itinerary-products-search');
15
16
  const { searchItineraries } = require('./itinerary-search');
16
17
  const { hostConnectXmlOptions } = require('./utils');
@@ -680,6 +681,9 @@ class BuyerPlugin {
680
681
  });
681
682
  }
682
683
 
684
+ /**
685
+ * Cancel a booking.
686
+ */
683
687
  async cancelBooking({
684
688
  axios,
685
689
  token,
@@ -693,6 +697,22 @@ class BuyerPlugin {
693
697
  });
694
698
  }
695
699
 
700
+ /**
701
+ * Convert a quote to a confirmed booking.
702
+ */
703
+ async confirmBooking({
704
+ axios,
705
+ token,
706
+ payload,
707
+ }) {
708
+ return confirmBooking({
709
+ axios,
710
+ token,
711
+ payload,
712
+ callTourplan: this.callTourplan.bind(this),
713
+ });
714
+ }
715
+
696
716
  async searchItineraries({
697
717
  axios,
698
718
  typeDefsAndQueries,
package/index.test.js CHANGED
@@ -274,6 +274,163 @@ describe('search tests', () => {
274
274
  },
275
275
  });
276
276
  });
277
+
278
+ it('requires a booking id or reference', async () => {
279
+ await expect(app.cancelBooking({
280
+ axios,
281
+ token,
282
+ payload: {},
283
+ })).rejects.toThrow('Must provide booking id or reference for cancellation');
284
+ expect(mockCallTourplan).not.toHaveBeenCalled();
285
+ });
286
+ });
287
+ describe('confirmBooking', () => {
288
+ it('converts a quote to a booking by id', async () => {
289
+ mockCallTourplan.mockImplementationOnce(async () => ({
290
+ QuoteToBookReply: {
291
+ BookingId: '14226',
292
+ Ref: 'A2IN111975',
293
+ ServiceStatuses: {
294
+ ServiceStatus: {
295
+ Ref: 'A2IN111975',
296
+ ServiceLineId: '61738',
297
+ Date: '2026-07-03',
298
+ SequenceNumber: '10',
299
+ Status: 'OK',
300
+ },
301
+ },
302
+ },
303
+ }));
304
+ const retVal = await app.confirmBooking({
305
+ axios,
306
+ token,
307
+ payload: {
308
+ bookingId: '14226',
309
+ },
310
+ });
311
+ expect(mockCallTourplan).toHaveBeenNthCalledWith(1, expect.objectContaining({
312
+ model: {
313
+ QuoteToBookRequest: expect.objectContaining({
314
+ BookingId: '14226',
315
+ }),
316
+ },
317
+ }));
318
+ expect(retVal).toEqual({
319
+ confirmBookingReply: {
320
+ BookingId: '14226',
321
+ Ref: 'A2IN111975',
322
+ ServiceStatuses: {
323
+ ServiceStatus: {
324
+ Ref: 'A2IN111975',
325
+ ServiceLineId: '61738',
326
+ Date: '2026-07-03',
327
+ SequenceNumber: '10',
328
+ Status: 'OK',
329
+ },
330
+ },
331
+ },
332
+ booking: {
333
+ ref: 'A2IN111975',
334
+ id: '14226',
335
+ status: 'OK',
336
+ serviceLines: [{
337
+ ref: 'A2IN111975',
338
+ serviceLineId: '61738',
339
+ date: '2026-07-03',
340
+ sequenceNumber: '10',
341
+ status: 'OK',
342
+ }],
343
+ },
344
+ });
345
+ });
346
+
347
+ it('converts a quote to a booking by ref', async () => {
348
+ mockCallTourplan.mockImplementationOnce(async () => ({
349
+ QuoteToBookReply: {
350
+ BookingId: '14226',
351
+ Ref: 'A2IN111975',
352
+ ServiceStatuses: {
353
+ ServiceStatus: [{
354
+ ServiceLineId: '61738',
355
+ Date: '2026-07-03',
356
+ SequenceNumber: '10',
357
+ Status: 'OK',
358
+ }],
359
+ },
360
+ },
361
+ }));
362
+ const retVal = await app.confirmBooking({
363
+ axios,
364
+ token,
365
+ payload: {
366
+ ref: 'A2IN111975',
367
+ },
368
+ });
369
+ expect(mockCallTourplan).toHaveBeenNthCalledWith(1, expect.objectContaining({
370
+ model: {
371
+ QuoteToBookRequest: expect.objectContaining({
372
+ Ref: 'A2IN111975',
373
+ }),
374
+ },
375
+ }));
376
+ expect(retVal.booking.id).toBe('14226');
377
+ expect(retVal.booking.ref).toBe('A2IN111975');
378
+ expect(retVal.booking.status).toBe('OK');
379
+ });
380
+
381
+ it('returns MIXED status when service line statuses differ', async () => {
382
+ mockCallTourplan.mockImplementationOnce(async () => ({
383
+ QuoteToBookReply: {
384
+ BookingId: '14226',
385
+ Ref: 'A2IN111975',
386
+ ServiceStatuses: {
387
+ ServiceStatus: [{
388
+ ServiceLineId: '61738',
389
+ Status: 'OK',
390
+ }, {
391
+ ServiceLineId: '61739',
392
+ Status: 'RQ',
393
+ }],
394
+ },
395
+ },
396
+ }));
397
+ const retVal = await app.confirmBooking({
398
+ axios,
399
+ token,
400
+ payload: {
401
+ bookingId: '14226',
402
+ },
403
+ });
404
+ expect(retVal.booking.status).toBe('MIXED');
405
+ expect(retVal.booking.serviceLines).toHaveLength(2);
406
+ });
407
+
408
+ it('requires a booking id or reference', async () => {
409
+ await expect(app.confirmBooking({
410
+ axios,
411
+ token,
412
+ payload: {},
413
+ })).rejects.toThrow('Must provide booking id or reference for confirm booking');
414
+ expect(mockCallTourplan).not.toHaveBeenCalled();
415
+ });
416
+
417
+ it('falls back to Confirmed when no service statuses are returned', async () => {
418
+ mockCallTourplan.mockImplementationOnce(async () => ({
419
+ QuoteToBookReply: {
420
+ BookingId: '14226',
421
+ Ref: 'A2IN111975',
422
+ },
423
+ }));
424
+ const retVal = await app.confirmBooking({
425
+ axios,
426
+ token,
427
+ payload: {
428
+ bookingId: '14226',
429
+ },
430
+ });
431
+ expect(retVal.booking.status).toBe('Confirmed');
432
+ expect(retVal.booking.serviceLines).toEqual([]);
433
+ });
277
434
  });
278
435
  describe('template tests', () => {
279
436
  let template;
@@ -2,49 +2,17 @@ const assert = require('assert');
2
2
  const R = require('ramda');
3
3
  const {
4
4
  hostConnectXmlOptions,
5
- escapeInvalidXmlChars,
6
5
  } = require('./utils');
7
-
8
- const getIdentifier = payload => {
9
- const bookingId = payload.bookingId || payload.id;
10
- if (bookingId) {
11
- return {
12
- field: 'BookingId',
13
- value: String(bookingId),
14
- };
15
- }
16
- const bookingRef = payload.ref || payload.reference;
17
- if (bookingRef) {
18
- return {
19
- field: 'Ref',
20
- value: escapeInvalidXmlChars(String(bookingRef)),
21
- };
22
- }
23
- return null;
24
- };
6
+ const {
7
+ getBookingIdentifier,
8
+ getAggregateServiceStatus,
9
+ } = require('./itinerary-hostconnect-helpers');
25
10
 
26
11
  const extractCancellationReply = replyObj => (
27
12
  R.path(['CancelServicesReply'], replyObj)
28
13
  || {}
29
14
  );
30
15
 
31
- const getServiceStatusList = cancellationReply => {
32
- let serviceStatuses = R.pathOr([], ['ServiceStatuses', 'ServiceStatus'], cancellationReply);
33
- if (!Array.isArray(serviceStatuses)) serviceStatuses = [serviceStatuses];
34
- return serviceStatuses
35
- .map(serviceStatus => R.path(['Status'], serviceStatus))
36
- .filter(Boolean)
37
- .map(status => String(status).toUpperCase());
38
- };
39
-
40
- const getAggregateServiceStatus = cancellationReply => {
41
- const statuses = getServiceStatusList(cancellationReply);
42
- if (!statuses.length) return null;
43
- const uniqueStatuses = [...new Set(statuses)];
44
- if (uniqueStatuses.length === 1) return uniqueStatuses[0];
45
- return 'MIXED';
46
- };
47
-
48
16
  const normalizeCancellationResponse = (replyObj, payload, identifier) => {
49
17
  const cancellationReply = extractCancellationReply(replyObj);
50
18
  const aggregateServiceStatus = getAggregateServiceStatus(cancellationReply);
@@ -62,13 +30,7 @@ const normalizeCancellationResponse = (replyObj, payload, identifier) => {
62
30
  };
63
31
 
64
32
  /**
65
- * Cancels a booking by id or reference
66
- * @param {Object} params - The parameters for the cancellation
67
- * @param {Object} params.axios - The axios instance
68
- * @param {Object} params.token - The token for the cancellation
69
- * @param {Object} params.payload - The payload for the cancellation
70
- * @param {Object} params.callTourplan - The callTourplan function
71
- * @returns {Promise<Object>} The cancellation response
33
+ * Cancels a booking by id or reference via HostConnect CancelServicesRequest.
72
34
  */
73
35
  const cancelBooking = async ({
74
36
  axios,
@@ -83,7 +45,7 @@ const cancelBooking = async ({
83
45
  assert(hostConnectEndpoint, 'Must provide token.hostConnectEndpoint for booking cancellation');
84
46
  assert(hostConnectAgentID, 'Must provide token.hostConnectAgentID for booking cancellation');
85
47
  assert(hostConnectAgentPassword, 'Must provide token.hostConnectAgentPassword for booking cancellation');
86
- const identifier = getIdentifier(payload);
48
+ const identifier = getBookingIdentifier(payload);
87
49
  assert(identifier, 'Must provide booking id or reference for cancellation');
88
50
 
89
51
  const baseRequest = {
@@ -0,0 +1,72 @@
1
+ const assert = require('assert');
2
+ const R = require('ramda');
3
+ const { hostConnectXmlOptions } = require('./utils');
4
+ const {
5
+ getBookingIdentifier,
6
+ getAggregateServiceStatus,
7
+ mapServiceStatusLines,
8
+ } = require('./itinerary-hostconnect-helpers');
9
+
10
+ const extractConfirmBookingReply = replyObj => (
11
+ R.path(['QuoteToBookReply'], replyObj)
12
+ || {}
13
+ );
14
+
15
+ const normalizeConfirmBookingResponse = (replyObj, payload, identifier) => {
16
+ const confirmBookingReply = extractConfirmBookingReply(replyObj);
17
+ const aggregateServiceStatus = getAggregateServiceStatus(confirmBookingReply);
18
+ return {
19
+ confirmBookingReply,
20
+ booking: {
21
+ ref: R.path(['Ref'], confirmBookingReply),
22
+ id: R.path(['BookingId'], confirmBookingReply) || identifier.value,
23
+ status: aggregateServiceStatus
24
+ || R.path(['BookingStatus'], confirmBookingReply)
25
+ || R.path(['Status'], confirmBookingReply)
26
+ || payload.status
27
+ || 'Confirmed',
28
+ serviceLines: mapServiceStatusLines(confirmBookingReply),
29
+ },
30
+ };
31
+ };
32
+
33
+ /**
34
+ * Converts a quote to a confirmed booking via HostConnect QuoteToBookRequest.
35
+ * Inventory is allocated for each service line; returned ServiceStatus values
36
+ * indicate whether allocation succeeded per line.
37
+ */
38
+ const confirmBooking = async ({
39
+ axios,
40
+ token: {
41
+ hostConnectEndpoint,
42
+ hostConnectAgentID,
43
+ hostConnectAgentPassword,
44
+ },
45
+ payload = {},
46
+ callTourplan,
47
+ }) => {
48
+ assert(hostConnectEndpoint, 'Must provide token.hostConnectEndpoint for confirm booking');
49
+ assert(hostConnectAgentID, 'Must provide token.hostConnectAgentID for confirm booking');
50
+ assert(hostConnectAgentPassword, 'Must provide token.hostConnectAgentPassword for confirm booking');
51
+ const identifier = getBookingIdentifier(payload);
52
+ assert(identifier, 'Must provide booking id or reference for confirm booking');
53
+
54
+ const baseRequest = {
55
+ AgentID: hostConnectAgentID,
56
+ Password: hostConnectAgentPassword,
57
+ [identifier.field]: identifier.value,
58
+ };
59
+ const replyObj = await callTourplan({
60
+ model: {
61
+ QuoteToBookRequest: baseRequest,
62
+ },
63
+ endpoint: hostConnectEndpoint,
64
+ axios,
65
+ xmlOptions: hostConnectXmlOptions,
66
+ });
67
+ return normalizeConfirmBookingResponse(replyObj, payload, identifier);
68
+ };
69
+
70
+ module.exports = {
71
+ confirmBooking,
72
+ };
@@ -0,0 +1,52 @@
1
+ const R = require('ramda');
2
+ const { escapeInvalidXmlChars } = require('./utils');
3
+
4
+ const getBookingIdentifier = payload => {
5
+ const bookingId = payload.bookingId || payload.id;
6
+ if (bookingId) {
7
+ return {
8
+ field: 'BookingId',
9
+ value: String(bookingId),
10
+ };
11
+ }
12
+ const bookingRef = payload.ref || payload.reference;
13
+ if (bookingRef) {
14
+ return {
15
+ field: 'Ref',
16
+ value: escapeInvalidXmlChars(String(bookingRef)),
17
+ };
18
+ }
19
+ return null;
20
+ };
21
+
22
+ const toServiceStatusArray = reply => {
23
+ let serviceStatuses = R.pathOr([], ['ServiceStatuses', 'ServiceStatus'], reply);
24
+ if (!Array.isArray(serviceStatuses)) serviceStatuses = [serviceStatuses];
25
+ return serviceStatuses.filter(Boolean);
26
+ };
27
+
28
+ const getAggregateServiceStatus = reply => {
29
+ const statuses = toServiceStatusArray(reply)
30
+ .map(serviceStatus => R.path(['Status'], serviceStatus))
31
+ .filter(Boolean)
32
+ .map(status => String(status).toUpperCase());
33
+ if (!statuses.length) return null;
34
+ const uniqueStatuses = [...new Set(statuses)];
35
+ if (uniqueStatuses.length === 1) return uniqueStatuses[0];
36
+ return 'MIXED';
37
+ };
38
+
39
+ const mapServiceStatusLines = reply => toServiceStatusArray(reply).map(serviceStatus => ({
40
+ ref: R.path(['Ref'], serviceStatus),
41
+ serviceLineId: R.path(['ServiceLineId'], serviceStatus),
42
+ date: R.path(['Date'], serviceStatus),
43
+ sequenceNumber: R.path(['SequenceNumber'], serviceStatus),
44
+ status: R.path(['Status'], serviceStatus),
45
+ }));
46
+
47
+ module.exports = {
48
+ getBookingIdentifier,
49
+ toServiceStatusArray,
50
+ getAggregateServiceStatus,
51
+ mapServiceStatusLines,
52
+ };
@@ -18,7 +18,6 @@ const searchProductsForItinerary = async ({
18
18
  payload: {
19
19
  // single optionId or array of optionIds
20
20
  optionId,
21
- forceRefresh,
22
21
  searchInput,
23
22
  // lastUpdatedFrom is used to get options that were updated after a certain date in Tourplan
24
23
  // example: lastUpdatedFrom: '2024-04-22 05:17:57.427Z'
@@ -26,55 +25,84 @@ const searchProductsForItinerary = async ({
26
25
  },
27
26
  callTourplan,
28
27
  }) => {
29
- /*
30
- Pseudo
31
- 1. getServiceCodes -> [AC, BD]
32
- 2. for each serviceCode getoptions
33
- 3. convert them to ti2 products structure
34
- 4. merge all products from all serviceCodes
35
- */
36
- // getServices
37
- const getServicesModel = {
38
- GetServicesRequest: {
39
- AgentID: hostConnectAgentID,
40
- Password: hostConnectAgentPassword,
41
- },
42
- };
43
- const getServicesReply = await callTourplan({
44
- model: getServicesModel,
45
- endpoint: hostConnectEndpoint,
46
- axios,
47
- xmlOptions: hostConnectXmlOptions,
48
- });
49
- let serviceCodes = R.pathOr([], ['GetServicesReply', 'TPLServices', 'TPLService'], getServicesReply);
50
- if (!Array.isArray(serviceCodes)) serviceCodes = [serviceCodes];
51
- serviceCodes = serviceCodes.map(s => s.Code);
28
+ // Normalise optionId to an array (or empty)
29
+ const optionIds = optionId
30
+ ? (Array.isArray(optionId) ? optionId : [optionId]).filter(Boolean)
31
+ : [];
32
+
52
33
  let options = [];
53
- await Promise.each(serviceCodes, async serviceCode => {
54
- const getOptionsModel = {
55
- OptionInfoRequest: {
56
- Opt: `???${serviceCode}????????????`,
57
- Info: 'G',
34
+
35
+ if (optionIds.length > 0) {
36
+ // Fast path: fetch only the requested option(s) by their exact Opt code,
37
+ // skipping the expensive full-catalog GetServices + wildcard OptionInfo calls.
38
+ await Promise.each(optionIds, async id => {
39
+ const getOptionsModel = {
40
+ OptionInfoRequest: {
41
+ Opt: id,
42
+ Info: 'GR',
43
+ AgentID: hostConnectAgentID,
44
+ Password: hostConnectAgentPassword,
45
+ },
46
+ };
47
+ const getOptionsReply = await callTourplan({
48
+ model: getOptionsModel,
49
+ endpoint: hostConnectEndpoint,
50
+ axios,
51
+ xmlOptions: hostConnectXmlOptions,
52
+ });
53
+ let thisOptions = R.pathOr([], ['OptionInfoReply', 'Option'], getOptionsReply);
54
+ if (!Array.isArray(thisOptions)) thisOptions = [thisOptions];
55
+ options = options.concat(thisOptions);
56
+ });
57
+ } else {
58
+ /*
59
+ Full catalog path:
60
+ 1. getServiceCodes -> [AC, BD]
61
+ 2. for each serviceCode getoptions
62
+ 3. convert them to ti2 products structure
63
+ 4. merge all products from all serviceCodes
64
+ */
65
+ // getServices
66
+ const getServicesModel = {
67
+ GetServicesRequest: {
58
68
  AgentID: hostConnectAgentID,
59
69
  Password: hostConnectAgentPassword,
60
- ...(lastUpdatedFrom ? {
61
- LastUpdateFrom: lastUpdatedFrom,
62
- } : {}),
63
70
  },
64
71
  };
65
- const getOptionsReply = await callTourplan({
66
- model: getOptionsModel,
72
+ const getServicesReply = await callTourplan({
73
+ model: getServicesModel,
67
74
  endpoint: hostConnectEndpoint,
68
75
  axios,
69
76
  xmlOptions: hostConnectXmlOptions,
70
77
  });
71
- let thisOptions = R.pathOr([], ['OptionInfoReply', 'Option'], getOptionsReply);
72
- // due to the new parser, single option will be returned as an object
73
- // instead of an array
74
- if (!Array.isArray(thisOptions)) thisOptions = [thisOptions];
75
- console.log(`got ${thisOptions.length} options for serviceCode ${serviceCode}`);
76
- options = options.concat(thisOptions);
77
- });
78
+ let serviceCodes = R.pathOr([], ['GetServicesReply', 'TPLServices', 'TPLService'], getServicesReply);
79
+ if (!Array.isArray(serviceCodes)) serviceCodes = [serviceCodes];
80
+ serviceCodes = serviceCodes.map(s => s.Code);
81
+ await Promise.each(serviceCodes, async serviceCode => {
82
+ const getOptionsModel = {
83
+ OptionInfoRequest: {
84
+ Opt: `???${serviceCode}????????????`,
85
+ Info: 'G',
86
+ AgentID: hostConnectAgentID,
87
+ Password: hostConnectAgentPassword,
88
+ ...(lastUpdatedFrom ? {
89
+ LastUpdateFrom: lastUpdatedFrom,
90
+ } : {}),
91
+ },
92
+ };
93
+ const getOptionsReply = await callTourplan({
94
+ model: getOptionsModel,
95
+ endpoint: hostConnectEndpoint,
96
+ axios,
97
+ xmlOptions: hostConnectXmlOptions,
98
+ });
99
+ let thisOptions = R.pathOr([], ['OptionInfoReply', 'Option'], getOptionsReply);
100
+ // due to the new parser, single option will be returned as an object
101
+ // instead of an array
102
+ if (!Array.isArray(thisOptions)) thisOptions = [thisOptions];
103
+ options = options.concat(thisOptions);
104
+ });
105
+ }
78
106
  if (!(options && options.length)) {
79
107
  throw new Error('No products found');
80
108
  }
@@ -95,8 +123,27 @@ const searchProductsForItinerary = async ({
95
123
  concurrency: 10,
96
124
  },
97
125
  );
126
+ // Preserve raw OptRates from Tourplan in product search response when available.
127
+ const optionRatesByOptionId = options.reduce((acc, option) => {
128
+ const currentOptionId = R.path(['Opt'], option);
129
+ const currentOptionRates = R.path(['OptRates'], option);
130
+ if (!currentOptionId || !currentOptionRates) return acc;
131
+ return {
132
+ ...acc,
133
+ [currentOptionId]: currentOptionRates,
134
+ };
135
+ }, {});
136
+ const productsWithOptRates = products.map(product => ({
137
+ ...product,
138
+ options: R.pathOr([], ['options'], product).map(currentOption => ({
139
+ ...currentOption,
140
+ ...(R.path([currentOption.optionId], optionRatesByOptionId)
141
+ ? { optRates: R.path([currentOption.optionId], optionRatesByOptionId) }
142
+ : {}),
143
+ })),
144
+ }));
98
145
  return {
99
- products,
146
+ products: productsWithOptRates,
100
147
  productFields: [],
101
148
  ...(searchInput || optionId ? {} : configuration),
102
149
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2-tourplan",
3
- "version": "1.0.123",
3
+ "version": "1.0.125",
4
4
  "description": "Tourplan's TI2 Plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -39,7 +39,7 @@
39
39
  "jsonwebtoken": "^8.5.1",
40
40
  "moment": "^2.29.1",
41
41
  "ramda": "^0.27.1",
42
- "ti2": "^1.0.111",
42
+ "ti2": "^1.0.118",
43
43
  "underscore": "^1.13.4",
44
44
  "xml-js": "^1.6.11",
45
45
  "xml2js": "^0.4.23"