ti2-tourplan 1.0.124 → 1.0.126
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/__fixtures__/OptionInfoRequest_4f8b93072db9a52be70dc1f563c4b374de4eb0e6.txt +77 -0
- package/__snapshots__/index.test.js.snap +23 -0
- package/availability/itinerary-availability-helper.js +6 -2
- package/availability/itinerary-availability.js +4 -3
- package/availability/itinerary-availability.validation-flags.test.js +20 -4
- package/itinerary-products-search.js +89 -42
- package/package.json +1 -1
|
@@ -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 {
|
|
@@ -172,7 +172,7 @@ const getAvailabilityOnly = async ({
|
|
|
172
172
|
const model = {
|
|
173
173
|
OptionInfoRequest: {
|
|
174
174
|
Opt: optionId,
|
|
175
|
-
Info: '
|
|
175
|
+
Info: 'GAR',
|
|
176
176
|
AgentID: hostConnectAgentID,
|
|
177
177
|
Password: hostConnectAgentPassword,
|
|
178
178
|
DateFrom: startDate,
|
|
@@ -186,7 +186,11 @@ const getAvailabilityOnly = async ({
|
|
|
186
186
|
axios,
|
|
187
187
|
xmlOptions: hostConnectXmlOptions,
|
|
188
188
|
});
|
|
189
|
-
|
|
189
|
+
const option = R.path(['OptionInfoReply', 'Option'], replyObj);
|
|
190
|
+
return {
|
|
191
|
+
optAvail: R.pathOr([], ['OptAvail'], option),
|
|
192
|
+
optRates: R.path(['OptRates'], option),
|
|
193
|
+
};
|
|
190
194
|
};
|
|
191
195
|
/*
|
|
192
196
|
Get general option information from Tourplan API.
|
|
@@ -152,7 +152,7 @@ const searchAvailabilityForItinerary = async ({
|
|
|
152
152
|
const isAvailabilityOnly = availabilityOnly === true;
|
|
153
153
|
|
|
154
154
|
if (isAvailabilityOnly) {
|
|
155
|
-
const
|
|
155
|
+
const { optAvail, optRates } = await getAvailabilityOnly({
|
|
156
156
|
optionId,
|
|
157
157
|
hostConnectEndpoint,
|
|
158
158
|
hostConnectAgentID,
|
|
@@ -162,8 +162,9 @@ const searchAvailabilityForItinerary = async ({
|
|
|
162
162
|
requestedEndDate,
|
|
163
163
|
callTourplan,
|
|
164
164
|
});
|
|
165
|
-
const optAvailValues = getOptAvailValues(
|
|
165
|
+
const optAvailValues = getOptAvailValues(optAvail);
|
|
166
166
|
const SCheckPass = isOptAvailBookable(optAvailValues);
|
|
167
|
+
const rates = (SCheckPass && optRates) ? [optRates] : [];
|
|
167
168
|
return {
|
|
168
169
|
bookable: SCheckPass,
|
|
169
170
|
type: 'availability',
|
|
@@ -172,7 +173,7 @@ const searchAvailabilityForItinerary = async ({
|
|
|
172
173
|
? 'Availability checked successfully'
|
|
173
174
|
: 'No availability found for the requested range',
|
|
174
175
|
availability: optAvailValues,
|
|
175
|
-
rates
|
|
176
|
+
rates,
|
|
176
177
|
};
|
|
177
178
|
}
|
|
178
179
|
|
|
@@ -24,7 +24,7 @@ jest.mock('./itinerary-availability-helper', () => ({
|
|
|
24
24
|
maxPaxPerCharge: null,
|
|
25
25
|
})),
|
|
26
26
|
getNoRatesAvailableError: jest.fn(async () => 'No rates available'),
|
|
27
|
-
getAvailabilityOnly: jest.fn(async () => '-1 -1 -1'),
|
|
27
|
+
getAvailabilityOnly: jest.fn(async () => ({ optAvail: '-1 -1 -1', optRates: null })),
|
|
28
28
|
getStayResults: jest.fn(async () => ([{
|
|
29
29
|
RateId: 'R1',
|
|
30
30
|
Currency: 'USD',
|
|
@@ -149,7 +149,18 @@ describe('searchAvailabilityForItinerary validation flags', () => {
|
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
it('uses availabilityOnly flow and keeps response shape consistent', async () => {
|
|
152
|
-
|
|
152
|
+
const mockOptRates = {
|
|
153
|
+
Currency: 'JPY',
|
|
154
|
+
OptRate: {
|
|
155
|
+
Date: '2026-06-01',
|
|
156
|
+
RateName: '26年',
|
|
157
|
+
PersonRates: { AdultRate: 2700000, ChildRate: 1700000 },
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce({
|
|
161
|
+
optAvail: '-1 -2 -1',
|
|
162
|
+
optRates: mockOptRates,
|
|
163
|
+
});
|
|
153
164
|
|
|
154
165
|
const result = await searchAvailabilityForItinerary({
|
|
155
166
|
axios: jest.fn(),
|
|
@@ -160,6 +171,7 @@ describe('searchAvailabilityForItinerary validation flags', () => {
|
|
|
160
171
|
});
|
|
161
172
|
|
|
162
173
|
expect(itineraryAvailabilityHelper.getAvailabilityOnly).toHaveBeenCalledTimes(1);
|
|
174
|
+
expect(itineraryAvailabilityHelper.getStayResults).not.toHaveBeenCalled();
|
|
163
175
|
expect(itineraryAvailabilityHelper.getAgentCurrencyCode).not.toHaveBeenCalled();
|
|
164
176
|
expect(result).toEqual({
|
|
165
177
|
bookable: true,
|
|
@@ -167,12 +179,15 @@ describe('searchAvailabilityForItinerary validation flags', () => {
|
|
|
167
179
|
endDate: '2025-04-05',
|
|
168
180
|
message: 'Availability checked successfully',
|
|
169
181
|
availability: [-1, -2, -1],
|
|
170
|
-
rates: [],
|
|
182
|
+
rates: [mockOptRates],
|
|
171
183
|
});
|
|
172
184
|
});
|
|
173
185
|
|
|
174
186
|
it('marks availabilityOnly response as not bookable when all days are unavailable', async () => {
|
|
175
|
-
itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce(
|
|
187
|
+
itineraryAvailabilityHelper.getAvailabilityOnly.mockResolvedValueOnce({
|
|
188
|
+
optAvail: '-1 -1 -1',
|
|
189
|
+
optRates: null,
|
|
190
|
+
});
|
|
176
191
|
|
|
177
192
|
const result = await searchAvailabilityForItinerary({
|
|
178
193
|
axios: jest.fn(),
|
|
@@ -186,5 +201,6 @@ describe('searchAvailabilityForItinerary validation flags', () => {
|
|
|
186
201
|
expect(result.message).toBe('No availability found for the requested range');
|
|
187
202
|
expect(result.availability).toEqual([-1, -1, -1]);
|
|
188
203
|
expect(result.rates).toEqual([]);
|
|
204
|
+
expect(itineraryAvailabilityHelper.getStayResults).not.toHaveBeenCalled();
|
|
189
205
|
});
|
|
190
206
|
});
|
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
66
|
-
model:
|
|
72
|
+
const getServicesReply = await callTourplan({
|
|
73
|
+
model: getServicesModel,
|
|
67
74
|
endpoint: hostConnectEndpoint,
|
|
68
75
|
axios,
|
|
69
76
|
xmlOptions: hostConnectXmlOptions,
|
|
70
77
|
});
|
|
71
|
-
let
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
};
|