ti2-tourplan 1.0.139 → 1.0.141
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 +28 -5
- package/__snapshots__/index.test.js.snap +1 -0
- package/index.test.js +105 -0
- package/itinerary-products-search.js +41 -13
- package/package.json +1 -1
- package/resolvers/product.js +188 -23
- package/resolvers/product.test.js +198 -0
- package/tp-helpers/suppliers.js +72 -0
- package/tp-helpers/suppliers.test.js +53 -0
- package/.claude/settings.local.json +0 -10
package/README.md
CHANGED
|
@@ -4,11 +4,34 @@
|
|
|
4
4
|
|
|
5
5
|
### Requirements
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
Live integration tests use `ti2_tourplan_endpoint`, `ti2_tourplan_username`,
|
|
8
|
+
and `ti2_tourplan_password`; the test suite supplies fixture defaults when
|
|
9
|
+
they are absent. Plugin credentials use the `endpoint`, `username`, and
|
|
10
|
+
`password` token fields. The optional `DTD_DAYS` plugin setting controls how
|
|
11
|
+
many days DTD version detection results are cached per endpoint (default: 7).
|
|
12
|
+
|
|
13
|
+
## Itinerary product catalog fields
|
|
14
|
+
|
|
15
|
+
Product search passes Ti2's itinerary product type definitions and query to
|
|
16
|
+
`translateTPOption`. The query now returns the existing `city`, `country`, and
|
|
17
|
+
`currency` values alongside these HostConnect fields:
|
|
18
|
+
|
|
19
|
+
| Ti2 field | HostConnect source |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `city` | Enriched destination, then `OptGeneral.LocalityDescription` or `Address3` |
|
|
22
|
+
| `country` | Enriched destination country |
|
|
23
|
+
| `currency` | Agent currency, then `OptRates.Currency` |
|
|
24
|
+
| `description` | SupplierInfo notes or description, excluding the supplier name |
|
|
25
|
+
| `optionClass` | `OptGeneral.ClassDescription`, then `OptGeneral.Class` |
|
|
26
|
+
| `chargeUnit` | `OptGeneral.SCU`, lowercased |
|
|
27
|
+
| Room `maxPaxWithInfants` | `OptGeneral.<room>_Max_With_Infants` |
|
|
28
|
+
| `Other` room restriction | `OptGeneral.Other_*` fields, when present |
|
|
29
|
+
|
|
30
|
+
For accommodation and package options, an `Other` unit is included only when
|
|
31
|
+
the source has an `Other_*` value. If those fields are absent, the `Other`
|
|
32
|
+
restriction is omitted. Optional source values omitted by the plugin appear as
|
|
33
|
+
`null` in Ti2's shared query. Adult, Child, and Infant restrictions still
|
|
34
|
+
select only `allowed`, `minAge`, and `maxAge`.
|
|
12
35
|
|
|
13
36
|
## Contributing
|
|
14
37
|
|
package/index.test.js
CHANGED
|
@@ -36,6 +36,11 @@ jest.mock('axios');
|
|
|
36
36
|
|
|
37
37
|
// Default callTourplan mock: resolve HostConnect replies from __fixtures__.
|
|
38
38
|
const defaultCallTourplanImplementation = async ({ model, endpoint }) => {
|
|
39
|
+
// Supplier notes are optional. Product search must not depend on a fixture
|
|
40
|
+
// for the unfiltered SupplierInfo request.
|
|
41
|
+
if (model.SupplierInfoRequest) {
|
|
42
|
+
return { SupplierInfoReply: { Suppliers: { Supplier: [] } } };
|
|
43
|
+
}
|
|
39
44
|
// Create a mock request object similar to what axios would receive
|
|
40
45
|
const requestType = Object.keys(model)[0]; // Gets 'OptionInfoRequest'
|
|
41
46
|
|
|
@@ -1026,6 +1031,91 @@ describe('search tests', () => {
|
|
|
1026
1031
|
expect(retVal).toMatchSnapshot();
|
|
1027
1032
|
});
|
|
1028
1033
|
|
|
1034
|
+
it('keeps class, charge unit, Other, infant cap, and supplier note on the stock schema', async () => {
|
|
1035
|
+
mockCallTourplan.mockImplementation(async ({ model }) => {
|
|
1036
|
+
if (model.AgentInfoRequest) {
|
|
1037
|
+
return { AgentInfoReply: { Currency: 'GBP' } };
|
|
1038
|
+
}
|
|
1039
|
+
if (model.GetLocationsRequest) {
|
|
1040
|
+
return { GetLocationsReply: { Locations: { Location: [] } } };
|
|
1041
|
+
}
|
|
1042
|
+
if (model.GetServicesRequest) {
|
|
1043
|
+
return { GetServicesReply: { TPLServices: { TPLService: [] } } };
|
|
1044
|
+
}
|
|
1045
|
+
if (model.GetSystemSettingsRequest) {
|
|
1046
|
+
return { GetSystemSettingsReply: { Countries: { Country: [] } } };
|
|
1047
|
+
}
|
|
1048
|
+
if (model.SupplierInfoRequest) {
|
|
1049
|
+
return {
|
|
1050
|
+
SupplierInfoReply: {
|
|
1051
|
+
Suppliers: {
|
|
1052
|
+
Supplier: {
|
|
1053
|
+
SupplierId: '7318',
|
|
1054
|
+
Name: 'Example Hotel',
|
|
1055
|
+
SupplierNotes: {
|
|
1056
|
+
SupplierNote: { NoteText: ' Family owned hotel ' },
|
|
1057
|
+
},
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
if (model.OptionInfoRequest) {
|
|
1064
|
+
return {
|
|
1065
|
+
OptionInfoReply: {
|
|
1066
|
+
Option: {
|
|
1067
|
+
Opt: 'LONHOEXAMPLESTD',
|
|
1068
|
+
OptGeneral: {
|
|
1069
|
+
SupplierId: '7318',
|
|
1070
|
+
SupplierName: 'Example Hotel',
|
|
1071
|
+
Description: 'Standard Room',
|
|
1072
|
+
Class: '3*',
|
|
1073
|
+
ClassDescription: '3 Star',
|
|
1074
|
+
SCU: 'Night',
|
|
1075
|
+
SType: 'Y',
|
|
1076
|
+
ButtonName: 'Accommodation',
|
|
1077
|
+
Single_Avail: 'Y',
|
|
1078
|
+
Single_Max: '0',
|
|
1079
|
+
Single_Ad_Max: '2',
|
|
1080
|
+
Single_Max_With_Infants: '1',
|
|
1081
|
+
Other_Avail: 'Y',
|
|
1082
|
+
Other_Max: '4',
|
|
1083
|
+
Other_Ad_Max: '3',
|
|
1084
|
+
Other_Max_With_Infants: '5',
|
|
1085
|
+
},
|
|
1086
|
+
},
|
|
1087
|
+
},
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
return {};
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
const retVal = await app.searchProductsForItinerary({
|
|
1094
|
+
axios,
|
|
1095
|
+
token,
|
|
1096
|
+
typeDefsAndQueries,
|
|
1097
|
+
payload: { optionId: 'LONHOEXAMPLESTD' },
|
|
1098
|
+
});
|
|
1099
|
+
const option = retVal.products[0].options[0];
|
|
1100
|
+
expect(retVal.products[0].description).toBe('Family owned hotel');
|
|
1101
|
+
expect(option.optionClass).toBe('3 Star');
|
|
1102
|
+
expect(option.chargeUnit).toBe('night');
|
|
1103
|
+
expect(option.restrictions.Single.maxPax).toBe(0);
|
|
1104
|
+
expect(option.restrictions.Single.maxPaxWithInfants).toBe(1);
|
|
1105
|
+
expect(option.restrictions.Other).toEqual({
|
|
1106
|
+
allowed: true,
|
|
1107
|
+
maxPax: 4,
|
|
1108
|
+
maxAdults: 3,
|
|
1109
|
+
maxPaxWithInfants: 5,
|
|
1110
|
+
});
|
|
1111
|
+
expect(option.units.map(unit => unit.unitId)).toEqual([
|
|
1112
|
+
'Single', 'Twin', 'Double', 'Triple', 'Quad', 'Other',
|
|
1113
|
+
]);
|
|
1114
|
+
const single = option.units.find(unit => unit.unitId === 'Single');
|
|
1115
|
+
expect(single.restrictions.maxPax).toBe(0);
|
|
1116
|
+
expect(single.restrictions.maxPaxWithInfants).toBe(1);
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1029
1119
|
describe('searchProductsForItinerary pickupPoints', () => {
|
|
1030
1120
|
const singlePickupPoint = {
|
|
1031
1121
|
Point_ID: '504',
|
|
@@ -1050,6 +1140,9 @@ describe('search tests', () => {
|
|
|
1050
1140
|
if (model.GetSystemSettingsRequest) {
|
|
1051
1141
|
return { GetSystemSettingsReply: { Countries: { Country: [] } } };
|
|
1052
1142
|
}
|
|
1143
|
+
if (model.SupplierInfoRequest) {
|
|
1144
|
+
return { SupplierInfoReply: { Suppliers: { Supplier: [] } } };
|
|
1145
|
+
}
|
|
1053
1146
|
if (model.OptionInfoRequest) {
|
|
1054
1147
|
return {
|
|
1055
1148
|
OptionInfoReply: {
|
|
@@ -1303,6 +1396,9 @@ describe('search tests', () => {
|
|
|
1303
1396
|
const mockFullCatalog = ({ throwOnOpt } = {}) => {
|
|
1304
1397
|
mockCallTourplan.mockImplementation(async ({ model }) => {
|
|
1305
1398
|
if (model.GetServicesRequest) return getServicesReply;
|
|
1399
|
+
if (model.SupplierInfoRequest) {
|
|
1400
|
+
return { SupplierInfoReply: { Suppliers: { Supplier: [] } } };
|
|
1401
|
+
}
|
|
1306
1402
|
const opt = model.OptionInfoRequest && model.OptionInfoRequest.Opt;
|
|
1307
1403
|
if (throwOnOpt && throwOnOpt.includes(opt)) {
|
|
1308
1404
|
throw new Error(`${opt} OptionInfo should have been skipped`);
|
|
@@ -1372,6 +1468,9 @@ describe('search tests', () => {
|
|
|
1372
1468
|
if (model.GetSystemSettingsRequest) {
|
|
1373
1469
|
return { GetSystemSettingsReply: { Countries: { Country: [] } } };
|
|
1374
1470
|
}
|
|
1471
|
+
if (model.SupplierInfoRequest) {
|
|
1472
|
+
return { SupplierInfoReply: { Suppliers: { Supplier: [] } } };
|
|
1473
|
+
}
|
|
1375
1474
|
if (model.GetServicesRequest) return getServicesReply;
|
|
1376
1475
|
const opt = model.OptionInfoRequest && model.OptionInfoRequest.Opt;
|
|
1377
1476
|
if (optionsByOpt[opt]) {
|
|
@@ -1617,6 +1716,9 @@ describe('search tests', () => {
|
|
|
1617
1716
|
},
|
|
1618
1717
|
};
|
|
1619
1718
|
}
|
|
1719
|
+
if (model.SupplierInfoRequest) {
|
|
1720
|
+
return { SupplierInfoReply: { Suppliers: { Supplier: [] } } };
|
|
1721
|
+
}
|
|
1620
1722
|
const opt = model.OptionInfoRequest && model.OptionInfoRequest.Opt;
|
|
1621
1723
|
if (opt === '???SM????????????') {
|
|
1622
1724
|
return {
|
|
@@ -1699,6 +1801,9 @@ describe('search tests', () => {
|
|
|
1699
1801
|
},
|
|
1700
1802
|
};
|
|
1701
1803
|
}
|
|
1804
|
+
if (model.SupplierInfoRequest) {
|
|
1805
|
+
return { SupplierInfoReply: { Suppliers: { Supplier: [] } } };
|
|
1806
|
+
}
|
|
1702
1807
|
if (model.OptionInfoRequest) {
|
|
1703
1808
|
return {
|
|
1704
1809
|
OptionInfoReply: {
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
const R = require('ramda');
|
|
2
2
|
const Promise = require('bluebird');
|
|
3
|
-
const {
|
|
3
|
+
const {
|
|
4
|
+
attachOptionCatalogFields,
|
|
5
|
+
translateTPOption,
|
|
6
|
+
getOptionCurrency,
|
|
7
|
+
} = require('./resolvers/product');
|
|
4
8
|
const { getAgentCurrencyCode } = require('./availability/itinerary-availability-helper');
|
|
5
9
|
const { hostConnectXmlOptions } = require('./utils');
|
|
6
10
|
const { getCachedLocations } = require('./tp-helpers/locations');
|
|
7
11
|
const { getCachedServices } = require('./tp-helpers/services');
|
|
8
12
|
const { getCachedDestinationCountries } = require('./tp-helpers/system-settings');
|
|
13
|
+
const { getCachedSuppliers } = require('./tp-helpers/suppliers');
|
|
9
14
|
const { enrichOptionWithCodeTables } = require('./tp-helpers/option-enrichment');
|
|
10
15
|
const { addStructuredOptionMetadata } = require('./option-metadata');
|
|
11
16
|
const {
|
|
@@ -167,7 +172,12 @@ const searchProductsForItinerary = async ({
|
|
|
167
172
|
return {};
|
|
168
173
|
}
|
|
169
174
|
};
|
|
170
|
-
const [
|
|
175
|
+
const [
|
|
176
|
+
locationsByCode,
|
|
177
|
+
resolvedServicesByCode,
|
|
178
|
+
countriesByDestination,
|
|
179
|
+
suppliersById,
|
|
180
|
+
] = await Promise.all([
|
|
171
181
|
loadOptionalTable('locations', () => (
|
|
172
182
|
getCachedLocations({
|
|
173
183
|
callTourplan,
|
|
@@ -200,6 +210,16 @@ const searchProductsForItinerary = async ({
|
|
|
200
210
|
hostConnectAgentPassword,
|
|
201
211
|
})
|
|
202
212
|
)),
|
|
213
|
+
loadOptionalTable('suppliers', () => (
|
|
214
|
+
getCachedSuppliers({
|
|
215
|
+
callTourplan,
|
|
216
|
+
cache,
|
|
217
|
+
axios,
|
|
218
|
+
hostConnectEndpoint,
|
|
219
|
+
hostConnectAgentID,
|
|
220
|
+
hostConnectAgentPassword,
|
|
221
|
+
})
|
|
222
|
+
)),
|
|
203
223
|
]);
|
|
204
224
|
servicesByCode = resolvedServicesByCode;
|
|
205
225
|
|
|
@@ -217,14 +237,21 @@ const searchProductsForItinerary = async ({
|
|
|
217
237
|
), enrichedOptions);
|
|
218
238
|
const products = await Promise.map(
|
|
219
239
|
arrayOfOptionsGroupedBySupplierId,
|
|
220
|
-
optionsGroupedBySupplierId =>
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
240
|
+
optionsGroupedBySupplierId => {
|
|
241
|
+
const supplierId = R.path([0, 'OptGeneral', 'SupplierId'], optionsGroupedBySupplierId);
|
|
242
|
+
const supplierKey = supplierId === undefined || supplierId === null
|
|
243
|
+
? ''
|
|
244
|
+
: String(supplierId).trim();
|
|
245
|
+
return translateTPOption({
|
|
246
|
+
rootValue: {
|
|
247
|
+
optionsGroupedBySupplierId,
|
|
248
|
+
supplierRecord: suppliersById[supplierKey] || {},
|
|
249
|
+
},
|
|
250
|
+
agentCurrencyCode,
|
|
251
|
+
typeDefs: itineraryProductTypeDefs,
|
|
252
|
+
query: itineraryProductQuery,
|
|
253
|
+
});
|
|
254
|
+
},
|
|
228
255
|
{
|
|
229
256
|
concurrency: 10,
|
|
230
257
|
},
|
|
@@ -232,7 +259,8 @@ const searchProductsForItinerary = async ({
|
|
|
232
259
|
// Preserve raw OptRates / PickupPoints from Tourplan in product search response
|
|
233
260
|
// when available (camelCase keys: optRates, pickupPoints; Tourplan field casing
|
|
234
261
|
// inside). Re-attach after GraphQL because stock TI2 itinerary-product
|
|
235
|
-
// typeDefs/query omit these fields (same for city/country/currency
|
|
262
|
+
// typeDefs/query omit these fields (same for city/country/currency,
|
|
263
|
+
// optionClass, chargeUnit, Other, and maxPaxWithInfants).
|
|
236
264
|
// Also ensure city and currency are always on the option for product cache.
|
|
237
265
|
const enrichedByOptionId = R.indexBy(R.prop('Opt'), enrichedOptions);
|
|
238
266
|
const optionRatesByOptionId = options.reduce((acc, option) => {
|
|
@@ -261,7 +289,7 @@ const searchProductsForItinerary = async ({
|
|
|
261
289
|
|| R.path(['PickupPoints'], rawOption),
|
|
262
290
|
)
|
|
263
291
|
: undefined;
|
|
264
|
-
return addStructuredOptionMetadata({
|
|
292
|
+
return addStructuredOptionMetadata(attachOptionCatalogFields({
|
|
265
293
|
...R.omit(['city', 'country', 'rateContext'], currentOption),
|
|
266
294
|
...(city ? { city } : {}),
|
|
267
295
|
...(country ? { country } : {}),
|
|
@@ -270,7 +298,7 @@ const searchProductsForItinerary = async ({
|
|
|
270
298
|
...(R.path([currentOption.optionId], optionRatesByOptionId)
|
|
271
299
|
? { optRates: R.path([currentOption.optionId], optionRatesByOptionId) }
|
|
272
300
|
: {}),
|
|
273
|
-
});
|
|
301
|
+
}, rawOption));
|
|
274
302
|
}),
|
|
275
303
|
}));
|
|
276
304
|
return {
|
package/package.json
CHANGED
package/resolvers/product.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const { makeExecutableSchema } = require('@graphql-tools/schema');
|
|
3
3
|
const R = require('ramda');
|
|
4
4
|
const { graphql } = require('graphql');
|
|
5
|
-
const { firstPresent } = require('../tp-helpers/values');
|
|
5
|
+
const { asArray, firstPresent, trimString } = require('../tp-helpers/values');
|
|
6
6
|
|
|
7
7
|
// Prefer AgentInfo Currency (same source as validateToken); fall back to OptRates.
|
|
8
8
|
const getOptionCurrency = (option, agentCurrencyCode) => firstPresent(
|
|
@@ -16,6 +16,182 @@ const getOptionCity = option => firstPresent(
|
|
|
16
16
|
R.path(['OptGeneral', 'Address3'], option),
|
|
17
17
|
);
|
|
18
18
|
|
|
19
|
+
// Other (HostConnect OT) is included with the other room types only when
|
|
20
|
+
// OptionInfo actually carries Other_* fields. A missing flag is not "unavailable".
|
|
21
|
+
const RESTRICTION_ROOM_TYPES = ['Single', 'Twin', 'Double', 'Triple', 'Quad', 'Other'];
|
|
22
|
+
const ACCOMMODATION_UNIT_TYPES = ['Single', 'Twin', 'Double', 'Triple', 'Quad'];
|
|
23
|
+
const ROOM_SOURCE_SUFFIXES = ['Avail', 'Max', 'Ad_Max', 'Max_With_Infants', 'From', 'To'];
|
|
24
|
+
|
|
25
|
+
const finiteInt = value => {
|
|
26
|
+
if (typeof value === 'boolean') return undefined;
|
|
27
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
28
|
+
const parsed = typeof value === 'number' ? value : parseInt(value, 10);
|
|
29
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const readRoomRaw = (option, roomType, suffix) => (
|
|
33
|
+
R.path(['OptGeneral', `${roomType}_${suffix}`], option)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const readRoomInt = (option, roomType, suffix) => finiteInt(readRoomRaw(option, roomType, suffix));
|
|
37
|
+
|
|
38
|
+
const hasRoomSource = (option, roomType) => ROOM_SOURCE_SUFFIXES.some(suffix => {
|
|
39
|
+
const value = readRoomRaw(option, roomType, suffix);
|
|
40
|
+
return value !== undefined && value !== null && value !== '';
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Y and N are explicit. Anything else, including a missing element, is unknown.
|
|
44
|
+
const roomAllowed = (option, roomType) => {
|
|
45
|
+
const avail = readRoomRaw(option, roomType, 'Avail');
|
|
46
|
+
if (avail === 'Y') return true;
|
|
47
|
+
if (avail === 'N') return false;
|
|
48
|
+
if (roomType === 'Other') return null;
|
|
49
|
+
return false;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Max of 0 is a real cap. Ad_Max is only the fallback when Max is absent.
|
|
53
|
+
const maxPaxForRoom = (option, roomType) => {
|
|
54
|
+
const maxPax = readRoomInt(option, roomType, 'Max');
|
|
55
|
+
if (maxPax !== undefined) return maxPax;
|
|
56
|
+
return readRoomInt(option, roomType, 'Ad_Max');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const roomRestriction = (option, roomType) => {
|
|
60
|
+
const maxPaxWithInfants = readRoomInt(option, roomType, 'Max_With_Infants');
|
|
61
|
+
return {
|
|
62
|
+
allowed: roomAllowed(option, roomType),
|
|
63
|
+
maxPax: maxPaxForRoom(option, roomType),
|
|
64
|
+
maxAdults: readRoomInt(option, roomType, 'Ad_Max'),
|
|
65
|
+
...(maxPaxWithInfants !== undefined && { maxPaxWithInfants }),
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const noteTexts = record => asArray(R.path(['SupplierNotes', 'SupplierNote'], record))
|
|
70
|
+
.map(note => (
|
|
71
|
+
typeof note === 'string' ? trimString(note) : trimString(R.path(['NoteText'], note))
|
|
72
|
+
))
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
|
|
75
|
+
// SupplierInfo notes, not OptGeneral.Description (that text is the option name).
|
|
76
|
+
const supplierDescriptionFromRecord = (record, supplierName) => {
|
|
77
|
+
const source = record || {};
|
|
78
|
+
const name = trimString(supplierName)
|
|
79
|
+
|| trimString(source.Name)
|
|
80
|
+
|| trimString(source.SupplierName);
|
|
81
|
+
const notes = R.uniq([
|
|
82
|
+
trimString(source.Notes),
|
|
83
|
+
trimString(source.Note),
|
|
84
|
+
trimString(source.SupplierNote),
|
|
85
|
+
...noteTexts(source),
|
|
86
|
+
].filter(text => text && text !== name));
|
|
87
|
+
if (notes.length) return notes.join('\n');
|
|
88
|
+
const isOptionGeneral = source.SType !== undefined
|
|
89
|
+
|| source.ButtonName !== undefined
|
|
90
|
+
|| source.Class !== undefined
|
|
91
|
+
|| source.ClassDescription !== undefined;
|
|
92
|
+
const description = trimString(source.Description);
|
|
93
|
+
if (!isOptionGeneral && description && description !== name) return description;
|
|
94
|
+
return null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const optionClassFromGeneral = general => firstPresent(
|
|
98
|
+
trimString(R.path(['ClassDescription'], general)),
|
|
99
|
+
trimString(R.path(['Class'], general)),
|
|
100
|
+
) || null;
|
|
101
|
+
|
|
102
|
+
const chargeUnitFromGeneral = general => {
|
|
103
|
+
const scu = trimString(R.path(['SCU'], general));
|
|
104
|
+
return scu ? scu.toLowerCase() : null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const intOrNull = value => (
|
|
108
|
+
value === undefined || value === null ? null : value
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const accommodationUnit = (option, unitId) => {
|
|
112
|
+
const room = roomRestriction(option, unitId);
|
|
113
|
+
return {
|
|
114
|
+
unitId,
|
|
115
|
+
unitName: unitId,
|
|
116
|
+
restrictions: {
|
|
117
|
+
allowed: room.allowed,
|
|
118
|
+
maxPax: room.maxPax,
|
|
119
|
+
maxAdults: room.maxAdults,
|
|
120
|
+
minAge: readRoomInt(option, unitId, 'From'),
|
|
121
|
+
maxAge: readRoomInt(option, unitId, 'To'),
|
|
122
|
+
...(room.maxPaxWithInfants !== undefined
|
|
123
|
+
&& { maxPaxWithInfants: room.maxPaxWithInfants }),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const accommodationUnitTypes = option => (
|
|
129
|
+
hasRoomSource(option, 'Other')
|
|
130
|
+
? [...ACCOMMODATION_UNIT_TYPES, 'Other']
|
|
131
|
+
: ACCOMMODATION_UNIT_TYPES
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const publicRoomRestriction = room => ({
|
|
135
|
+
allowed: room.allowed,
|
|
136
|
+
maxPax: intOrNull(room.maxPax),
|
|
137
|
+
maxAdults: intOrNull(room.maxAdults),
|
|
138
|
+
...(room.maxPaxWithInfants !== undefined && { maxPaxWithInfants: room.maxPaxWithInfants }),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Stock TI2 product queries omit optionClass, chargeUnit, Other, and maxPaxWithInfants.
|
|
142
|
+
const attachOptionCatalogFields = (currentOption, rawOption) => {
|
|
143
|
+
const general = R.pathOr({}, ['OptGeneral'], rawOption);
|
|
144
|
+
const optionClass = currentOption.optionClass || optionClassFromGeneral(general);
|
|
145
|
+
const chargeUnit = currentOption.chargeUnit || chargeUnitFromGeneral(general);
|
|
146
|
+
const restrictions = { ...(currentOption.restrictions || {}) };
|
|
147
|
+
RESTRICTION_ROOM_TYPES.forEach(roomType => {
|
|
148
|
+
const computed = roomRestriction(rawOption, roomType);
|
|
149
|
+
const existing = restrictions[roomType];
|
|
150
|
+
if (!existing) {
|
|
151
|
+
if (!hasRoomSource(rawOption, roomType)) return;
|
|
152
|
+
restrictions[roomType] = publicRoomRestriction(computed);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (
|
|
156
|
+
computed.maxPaxWithInfants !== undefined
|
|
157
|
+
&& (existing.maxPaxWithInfants === undefined || existing.maxPaxWithInfants === null)
|
|
158
|
+
) {
|
|
159
|
+
restrictions[roomType] = {
|
|
160
|
+
...existing,
|
|
161
|
+
maxPaxWithInfants: computed.maxPaxWithInfants,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
let units = R.pathOr([], ['units'], currentOption).map(unit => {
|
|
166
|
+
const computed = roomRestriction(rawOption, unit.unitId);
|
|
167
|
+
if (computed.maxPaxWithInfants === undefined) return unit;
|
|
168
|
+
const existingCap = R.path(['restrictions', 'maxPaxWithInfants'], unit);
|
|
169
|
+
if (existingCap !== undefined && existingCap !== null) return unit;
|
|
170
|
+
return {
|
|
171
|
+
...unit,
|
|
172
|
+
restrictions: {
|
|
173
|
+
...(unit.restrictions || {}),
|
|
174
|
+
maxPaxWithInfants: computed.maxPaxWithInfants,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
const isAccommodation = ['Y', 'P'].includes(R.path(['OptGeneral', 'SType'], rawOption));
|
|
179
|
+
if (
|
|
180
|
+
isAccommodation
|
|
181
|
+
&& hasRoomSource(rawOption, 'Other')
|
|
182
|
+
&& !units.some(unit => unit.unitId === 'Other')
|
|
183
|
+
) {
|
|
184
|
+
units = units.concat(accommodationUnit(rawOption, 'Other'));
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
...R.omit(['optionClass', 'chargeUnit'], currentOption),
|
|
188
|
+
...(optionClass ? { optionClass } : {}),
|
|
189
|
+
...(chargeUnit ? { chargeUnit } : {}),
|
|
190
|
+
restrictions,
|
|
191
|
+
units,
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
|
|
19
195
|
const typeDefinesField = (typeDefs, typeName, fieldName) => {
|
|
20
196
|
const typeMatch = String(typeDefs || '').match(new RegExp(`type\\s+${typeName}\\s*{([\\s\\S]*?)}`));
|
|
21
197
|
if (!typeMatch) return false;
|
|
@@ -28,6 +204,7 @@ const resolvers = {
|
|
|
28
204
|
productId: rootValue => `${R.path(['supplierData', 'supplierId'], rootValue)}`,
|
|
29
205
|
productName: rootValue => R.path(['supplierData', 'supplierName'], rootValue),
|
|
30
206
|
address: rootValue => R.path(['supplierData', 'supplierAddress'], rootValue),
|
|
207
|
+
description: rootValue => R.path(['supplierData', 'supplierDescription'], rootValue) || null,
|
|
31
208
|
serviceTypes: rootValue => R.path(['supplierData', 'serviceTypes'], rootValue),
|
|
32
209
|
options: R.pathOr([], ['optionsGroupedBySupplierId']),
|
|
33
210
|
},
|
|
@@ -59,6 +236,8 @@ const resolvers = {
|
|
|
59
236
|
option,
|
|
60
237
|
context && context.agentCurrencyCode,
|
|
61
238
|
),
|
|
239
|
+
optionClass: option => optionClassFromGeneral(R.path(['OptGeneral'], option)),
|
|
240
|
+
chargeUnit: option => chargeUnitFromGeneral(R.path(['OptGeneral'], option)),
|
|
62
241
|
units: option => {
|
|
63
242
|
/*
|
|
64
243
|
SType: One character that specifies the service type of the
|
|
@@ -96,19 +275,7 @@ const resolvers = {
|
|
|
96
275
|
} : null].filter(Boolean);
|
|
97
276
|
}
|
|
98
277
|
if (R.path(['OptGeneral', 'SType'], option) === 'Y' || R.path(['OptGeneral', 'SType'], option) === 'P') {
|
|
99
|
-
return
|
|
100
|
-
.map(unitId => ({
|
|
101
|
-
unitId,
|
|
102
|
-
unitName: unitId,
|
|
103
|
-
restrictions: {
|
|
104
|
-
allowed: R.path(['OptGeneral', `${unitId}_Avail`], option) === 'Y',
|
|
105
|
-
maxPax: R.path(['OptGeneral', `${unitId}_Max`], option)
|
|
106
|
-
|| R.path(['OptGeneral', `${unitId}_Ad_Max`], option),
|
|
107
|
-
maxAdults: R.path(['OptGeneral', `${unitId}_Ad_Max`], option),
|
|
108
|
-
minAge: R.path(['OptGeneral', `${unitId}_From`], option),
|
|
109
|
-
maxAge: R.path(['OptGeneral', `${unitId}_To`], option),
|
|
110
|
-
},
|
|
111
|
-
}));
|
|
278
|
+
return accommodationUnitTypes(option).map(unitId => accommodationUnit(option, unitId));
|
|
112
279
|
}
|
|
113
280
|
return [];
|
|
114
281
|
},
|
|
@@ -133,17 +300,11 @@ const resolvers = {
|
|
|
133
300
|
minAge: R.path(['OptGeneral', 'Infant_From'], option),
|
|
134
301
|
maxAge: R.path(['OptGeneral', 'Infant_To'], option),
|
|
135
302
|
},
|
|
136
|
-
...
|
|
137
|
-
|
|
138
|
-
const unitMax = R.path(['OptGeneral', `${roomType}_Max`], option);
|
|
139
|
-
const unitAdMax = R.path(['OptGeneral', `${roomType}_Ad_Max`], option);
|
|
303
|
+
...RESTRICTION_ROOM_TYPES.reduce((acc, roomType) => {
|
|
304
|
+
if (roomType === 'Other' && !hasRoomSource(option, roomType)) return acc;
|
|
140
305
|
return {
|
|
141
306
|
...acc,
|
|
142
|
-
[roomType]:
|
|
143
|
-
allowed: unitAvail === 'Y',
|
|
144
|
-
maxPax: unitMax,
|
|
145
|
-
maxAdults: unitAdMax,
|
|
146
|
-
},
|
|
307
|
+
[roomType]: roomRestriction(option, roomType),
|
|
147
308
|
};
|
|
148
309
|
}, {}),
|
|
149
310
|
}),
|
|
@@ -190,6 +351,7 @@ const resolversForTypeDefs = typeDefs => {
|
|
|
190
351
|
const translateTPOption = async ({
|
|
191
352
|
rootValue: {
|
|
192
353
|
optionsGroupedBySupplierId,
|
|
354
|
+
supplierRecord,
|
|
193
355
|
},
|
|
194
356
|
agentCurrencyCode,
|
|
195
357
|
typeDefs,
|
|
@@ -204,6 +366,7 @@ const translateTPOption = async ({
|
|
|
204
366
|
supplierId: R.path(['SupplierId'], OptGeneral),
|
|
205
367
|
supplierName,
|
|
206
368
|
supplierAddress: `${R.pathOr('', ['Address1'], OptGeneral)}, ${R.pathOr('', ['Address2'], OptGeneral)}, ${R.pathOr('', ['Address3'], OptGeneral)}, ${R.pathOr('', ['Address4'], OptGeneral)}, ${R.pathOr('', ['Address5'], OptGeneral)}`,
|
|
369
|
+
supplierDescription: supplierDescriptionFromRecord(supplierRecord || {}, supplierName),
|
|
207
370
|
serviceTypes: R.uniq(optionsGroupedBySupplierId.map(R.path(['OptGeneral', 'ButtonName']))),
|
|
208
371
|
};
|
|
209
372
|
const schema = makeExecutableSchema({
|
|
@@ -228,4 +391,6 @@ module.exports = {
|
|
|
228
391
|
translateTPOption,
|
|
229
392
|
resolversForTypeDefs,
|
|
230
393
|
getOptionCurrency,
|
|
394
|
+
attachOptionCatalogFields,
|
|
395
|
+
supplierDescriptionFromRecord,
|
|
231
396
|
};
|
|
@@ -6,6 +6,7 @@ const productTypeDefs = `
|
|
|
6
6
|
productId: String
|
|
7
7
|
productName: String
|
|
8
8
|
address: String
|
|
9
|
+
description: String
|
|
9
10
|
serviceTypes: [String]
|
|
10
11
|
options: [ProductOption]
|
|
11
12
|
}
|
|
@@ -19,6 +20,8 @@ const productTypeDefs = `
|
|
|
19
20
|
city: String
|
|
20
21
|
country: String
|
|
21
22
|
currency: String
|
|
23
|
+
optionClass: String
|
|
24
|
+
chargeUnit: String
|
|
22
25
|
units: [Unit]
|
|
23
26
|
restrictions: Restrictions
|
|
24
27
|
extras: [Extra]
|
|
@@ -36,6 +39,7 @@ const productTypeDefs = `
|
|
|
36
39
|
allowed: Boolean
|
|
37
40
|
maxPax: Int
|
|
38
41
|
maxAdults: Int
|
|
42
|
+
maxPaxWithInfants: Int
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
type Restrictions {
|
|
@@ -48,6 +52,7 @@ const productTypeDefs = `
|
|
|
48
52
|
Triple: RoomRestrictions
|
|
49
53
|
Double: RoomRestrictions
|
|
50
54
|
Quad: RoomRestrictions
|
|
55
|
+
Other: RoomRestrictions
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
type PaxRestrictions {
|
|
@@ -60,6 +65,7 @@ const productTypeDefs = `
|
|
|
60
65
|
allowed: Boolean
|
|
61
66
|
maxPax: Int
|
|
62
67
|
maxAdults: Int
|
|
68
|
+
maxPaxWithInfants: Int
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
type Extra {
|
|
@@ -280,4 +286,196 @@ describe('product resolver enriched context', () => {
|
|
|
280
286
|
});
|
|
281
287
|
expect(retVal.options[0].rateContext).toBeUndefined();
|
|
282
288
|
});
|
|
289
|
+
|
|
290
|
+
it('exposes the supplier note, Other room, and infant cap', async () => {
|
|
291
|
+
const retVal = await translateTPOption({
|
|
292
|
+
typeDefs: productTypeDefs,
|
|
293
|
+
query: `{
|
|
294
|
+
productId
|
|
295
|
+
description
|
|
296
|
+
options {
|
|
297
|
+
optionId
|
|
298
|
+
units { unitId restrictions { allowed maxPax maxAdults maxPaxWithInfants } }
|
|
299
|
+
restrictions {
|
|
300
|
+
Other { allowed maxPax maxAdults maxPaxWithInfants }
|
|
301
|
+
Single { allowed maxPax maxAdults maxPaxWithInfants }
|
|
302
|
+
Twin { maxPax }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}`,
|
|
306
|
+
rootValue: {
|
|
307
|
+
supplierRecord: {
|
|
308
|
+
SupplierId: '7318',
|
|
309
|
+
Name: 'Example Hotel',
|
|
310
|
+
SupplierNotes: {
|
|
311
|
+
SupplierNote: { NoteText: ' Family owned hotel ' },
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
optionsGroupedBySupplierId: [{
|
|
315
|
+
Opt: 'LONHOHOTELSSTD',
|
|
316
|
+
OptGeneral: {
|
|
317
|
+
SupplierId: '7318',
|
|
318
|
+
SupplierName: 'Example Hotel',
|
|
319
|
+
Description: 'Standard Room',
|
|
320
|
+
SType: 'Y',
|
|
321
|
+
Single_Avail: 'Y',
|
|
322
|
+
Single_Max: '0',
|
|
323
|
+
Single_Ad_Max: '2',
|
|
324
|
+
Single_Max_With_Infants: '2',
|
|
325
|
+
Other_Avail: 'Y',
|
|
326
|
+
Other_Max: '4',
|
|
327
|
+
Other_Ad_Max: '3',
|
|
328
|
+
Other_Max_With_Infants: '5',
|
|
329
|
+
Twin_Ad_Max: '2',
|
|
330
|
+
},
|
|
331
|
+
RatePolicy: {
|
|
332
|
+
Single_Max: '9',
|
|
333
|
+
Other_Max: '9',
|
|
334
|
+
},
|
|
335
|
+
}],
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(retVal.description).toBe('Family owned hotel');
|
|
340
|
+
expect(retVal.options[0].restrictions.Single).toEqual({
|
|
341
|
+
allowed: true,
|
|
342
|
+
maxPax: 0,
|
|
343
|
+
maxAdults: 2,
|
|
344
|
+
maxPaxWithInfants: 2,
|
|
345
|
+
});
|
|
346
|
+
expect(retVal.options[0].restrictions.Other).toEqual({
|
|
347
|
+
allowed: true,
|
|
348
|
+
maxPax: 4,
|
|
349
|
+
maxAdults: 3,
|
|
350
|
+
maxPaxWithInfants: 5,
|
|
351
|
+
});
|
|
352
|
+
expect(retVal.options[0].restrictions.Twin.maxPax).toBe(2);
|
|
353
|
+
const unitIds = retVal.options[0].units.map(unit => unit.unitId);
|
|
354
|
+
expect(unitIds).toEqual(['Single', 'Twin', 'Double', 'Triple', 'Quad', 'Other']);
|
|
355
|
+
const singleUnit = retVal.options[0].units.find(unit => unit.unitId === 'Single');
|
|
356
|
+
expect(singleUnit.restrictions.maxPax).toBe(0);
|
|
357
|
+
expect(singleUnit.restrictions.maxPaxWithInfants).toBe(2);
|
|
358
|
+
const otherUnit = retVal.options[0].units.find(unit => unit.unitId === 'Other');
|
|
359
|
+
expect(otherUnit.restrictions).toEqual({
|
|
360
|
+
allowed: true,
|
|
361
|
+
maxPax: 4,
|
|
362
|
+
maxAdults: 3,
|
|
363
|
+
maxPaxWithInfants: 5,
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('omits Other when OptionInfo has no Other room fields', async () => {
|
|
368
|
+
const retVal = await translateTPOption({
|
|
369
|
+
typeDefs: productTypeDefs,
|
|
370
|
+
query: `{
|
|
371
|
+
options {
|
|
372
|
+
units { unitId restrictions { allowed maxPax } }
|
|
373
|
+
restrictions { Other { allowed maxPax } Single { allowed } }
|
|
374
|
+
}
|
|
375
|
+
}`,
|
|
376
|
+
rootValue: {
|
|
377
|
+
optionsGroupedBySupplierId: [{
|
|
378
|
+
Opt: 'LONTRDAVIDSHDWBVD',
|
|
379
|
+
OptGeneral: {
|
|
380
|
+
SupplierId: '6489',
|
|
381
|
+
SupplierName: 'Davids of London Ltd',
|
|
382
|
+
Description: 'Half-Day Warner Bros Studios (6-Hours)',
|
|
383
|
+
SType: 'N',
|
|
384
|
+
SCU: 'day',
|
|
385
|
+
},
|
|
386
|
+
}, {
|
|
387
|
+
Opt: 'LONHOHOTELSSTD',
|
|
388
|
+
OptGeneral: {
|
|
389
|
+
SupplierId: '6489',
|
|
390
|
+
SupplierName: 'Davids of London Ltd',
|
|
391
|
+
Description: 'Standard Room',
|
|
392
|
+
SType: 'Y',
|
|
393
|
+
Single_Avail: 'Y',
|
|
394
|
+
Other_Max: '4',
|
|
395
|
+
},
|
|
396
|
+
}],
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
expect(retVal.options[0].restrictions.Other).toBeNull();
|
|
401
|
+
expect(retVal.options[0].units.map(unit => unit.unitId)).not.toContain('Other');
|
|
402
|
+
expect(retVal.options[1].restrictions.Other).toEqual({
|
|
403
|
+
allowed: null,
|
|
404
|
+
maxPax: 4,
|
|
405
|
+
});
|
|
406
|
+
expect(retVal.options[1].units.map(unit => unit.unitId)).toContain('Other');
|
|
407
|
+
const otherUnit = retVal.options[1].units.find(unit => unit.unitId === 'Other');
|
|
408
|
+
expect(otherUnit.restrictions.allowed).toBeNull();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('ignores a supplier note that only repeats the supplier name', async () => {
|
|
412
|
+
const retVal = await translateTPOption({
|
|
413
|
+
typeDefs: productTypeDefs,
|
|
414
|
+
query: '{ description }',
|
|
415
|
+
rootValue: {
|
|
416
|
+
supplierRecord: {
|
|
417
|
+
Name: 'Example Hotel',
|
|
418
|
+
Description: 'Example Hotel',
|
|
419
|
+
SupplierNotes: { SupplierNote: { NoteText: 'Example Hotel' } },
|
|
420
|
+
},
|
|
421
|
+
optionsGroupedBySupplierId: [{
|
|
422
|
+
Opt: 'LONHOHOTELSSTD',
|
|
423
|
+
OptGeneral: {
|
|
424
|
+
SupplierId: '7318',
|
|
425
|
+
SupplierName: 'Example Hotel',
|
|
426
|
+
Description: 'Standard Room',
|
|
427
|
+
SType: 'Y',
|
|
428
|
+
},
|
|
429
|
+
}],
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
expect(retVal.description).toBeNull();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('prefers the class description and stores a lowercased SCU', async () => {
|
|
437
|
+
const retVal = await translateTPOption({
|
|
438
|
+
typeDefs: productTypeDefs,
|
|
439
|
+
query: `{
|
|
440
|
+
options {
|
|
441
|
+
optionId
|
|
442
|
+
optionClass
|
|
443
|
+
chargeUnit
|
|
444
|
+
}
|
|
445
|
+
}`,
|
|
446
|
+
rootValue: {
|
|
447
|
+
optionsGroupedBySupplierId: [{
|
|
448
|
+
Opt: 'TESTVALIDATEMAXPAXPERCHARGE',
|
|
449
|
+
OptGeneral: {
|
|
450
|
+
SupplierId: '7318',
|
|
451
|
+
SupplierName: 'Instyle Chauffeured Limousines Australia',
|
|
452
|
+
Description: 'City Hotel to International Airport Transfer',
|
|
453
|
+
Class: 'PRI',
|
|
454
|
+
ClassDescription: 'Private',
|
|
455
|
+
SCU: 'Day',
|
|
456
|
+
},
|
|
457
|
+
}, {
|
|
458
|
+
Opt: 'LONTRDAVIDSHDWBVD',
|
|
459
|
+
OptGeneral: {
|
|
460
|
+
SupplierId: '6489',
|
|
461
|
+
SupplierName: 'Davids of London Ltd',
|
|
462
|
+
Description: 'Half-Day Warner Bros Studios (6-Hours)',
|
|
463
|
+
Class: 'PRI',
|
|
464
|
+
SCU: '',
|
|
465
|
+
},
|
|
466
|
+
}],
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
expect(retVal.options[0]).toMatchObject({
|
|
471
|
+
optionId: 'TESTVALIDATEMAXPAXPERCHARGE',
|
|
472
|
+
optionClass: 'Private',
|
|
473
|
+
chargeUnit: 'day',
|
|
474
|
+
});
|
|
475
|
+
expect(retVal.options[1]).toMatchObject({
|
|
476
|
+
optionId: 'LONTRDAVIDSHDWBVD',
|
|
477
|
+
optionClass: 'PRI',
|
|
478
|
+
chargeUnit: null,
|
|
479
|
+
});
|
|
480
|
+
});
|
|
283
481
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const R = require('ramda');
|
|
2
|
+
const { hostConnectXmlOptions } = require('../utils');
|
|
3
|
+
const { asArray, trimString } = require('./values');
|
|
4
|
+
|
|
5
|
+
/** Refresh SupplierInfo cache monthly after the first successful fetch. */
|
|
6
|
+
const SUPPLIERS_CACHE_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
7
|
+
|
|
8
|
+
const supplierKey = value => {
|
|
9
|
+
if (value === undefined || value === null) return undefined;
|
|
10
|
+
return trimString(String(value));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalize SupplierInfo rows to { supplierId: supplier, supplierCode: supplier }.
|
|
15
|
+
* Notes live at SupplierNotes/SupplierNote/NoteText, not on OptGeneral.
|
|
16
|
+
*/
|
|
17
|
+
const toSupplierMap = entries => (
|
|
18
|
+
asArray(entries).reduce((acc, entry) => {
|
|
19
|
+
if (!entry || typeof entry !== 'object') return acc;
|
|
20
|
+
const id = supplierKey(entry.SupplierId);
|
|
21
|
+
const code = supplierKey(entry.SupplierCode);
|
|
22
|
+
if (id) acc[id] = entry;
|
|
23
|
+
if (code) acc[code] = entry;
|
|
24
|
+
return acc;
|
|
25
|
+
}, {})
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Fetch SupplierInfo once per agent/endpoint, then serve from cache for a month.
|
|
30
|
+
* An unfiltered request returns the company supplier list, including notes.
|
|
31
|
+
*/
|
|
32
|
+
const getCachedSuppliers = async ({
|
|
33
|
+
callTourplan,
|
|
34
|
+
cache,
|
|
35
|
+
axios,
|
|
36
|
+
hostConnectEndpoint,
|
|
37
|
+
hostConnectAgentID,
|
|
38
|
+
hostConnectAgentPassword,
|
|
39
|
+
}) => {
|
|
40
|
+
const model = {
|
|
41
|
+
SupplierInfoRequest: {
|
|
42
|
+
AgentID: hostConnectAgentID,
|
|
43
|
+
Password: hostConnectAgentPassword,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const fetchSuppliers = async () => {
|
|
47
|
+
const reply = await callTourplan({
|
|
48
|
+
model,
|
|
49
|
+
endpoint: hostConnectEndpoint,
|
|
50
|
+
axios,
|
|
51
|
+
xmlOptions: hostConnectXmlOptions,
|
|
52
|
+
});
|
|
53
|
+
return toSupplierMap(
|
|
54
|
+
R.pathOr([], ['SupplierInfoReply', 'Suppliers', 'Supplier'], reply),
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (cache && cache.getOrExec) {
|
|
59
|
+
return cache.getOrExec({
|
|
60
|
+
fnParams: ['hostconnect:SupplierInfo', hostConnectEndpoint, hostConnectAgentID],
|
|
61
|
+
fn: fetchSuppliers,
|
|
62
|
+
ttl: SUPPLIERS_CACHE_TTL_SECONDS,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return fetchSuppliers();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
SUPPLIERS_CACHE_TTL_SECONDS,
|
|
70
|
+
getCachedSuppliers,
|
|
71
|
+
toSupplierMap,
|
|
72
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* globals describe, it, expect, jest */
|
|
2
|
+
const {
|
|
3
|
+
getCachedSuppliers,
|
|
4
|
+
SUPPLIERS_CACHE_TTL_SECONDS,
|
|
5
|
+
toSupplierMap,
|
|
6
|
+
} = require('./suppliers');
|
|
7
|
+
|
|
8
|
+
describe('tp-helpers/suppliers', () => {
|
|
9
|
+
it('refreshes SupplierInfo cache monthly', () => {
|
|
10
|
+
expect(SUPPLIERS_CACHE_TTL_SECONDS).toBe(60 * 60 * 24 * 30);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('indexes a supplier by id and code', () => {
|
|
14
|
+
const supplier = {
|
|
15
|
+
SupplierId: 7318,
|
|
16
|
+
SupplierCode: 'HOTELS',
|
|
17
|
+
Name: 'Example Hotel',
|
|
18
|
+
SupplierNotes: {
|
|
19
|
+
SupplierNote: { NoteText: 'Family owned hotel' },
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const map = toSupplierMap(supplier);
|
|
23
|
+
expect(map['7318']).toBe(supplier);
|
|
24
|
+
expect(map.HOTELS).toBe(supplier);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('caches SupplierInfo per endpoint and agent', async () => {
|
|
28
|
+
const callTourplan = jest.fn().mockResolvedValue({
|
|
29
|
+
SupplierInfoReply: { Suppliers: { Supplier: [] } },
|
|
30
|
+
});
|
|
31
|
+
const cache = { getOrExec: jest.fn(({ fn }) => fn()) };
|
|
32
|
+
await getCachedSuppliers({
|
|
33
|
+
callTourplan,
|
|
34
|
+
cache,
|
|
35
|
+
axios: {},
|
|
36
|
+
hostConnectEndpoint: 'endpoint',
|
|
37
|
+
hostConnectAgentID: 'agent',
|
|
38
|
+
hostConnectAgentPassword: 'password',
|
|
39
|
+
});
|
|
40
|
+
expect(cache.getOrExec).toHaveBeenCalledWith(expect.objectContaining({
|
|
41
|
+
fnParams: ['hostconnect:SupplierInfo', 'endpoint', 'agent'],
|
|
42
|
+
ttl: SUPPLIERS_CACHE_TTL_SECONDS,
|
|
43
|
+
}));
|
|
44
|
+
expect(callTourplan).toHaveBeenCalledWith(expect.objectContaining({
|
|
45
|
+
model: {
|
|
46
|
+
SupplierInfoRequest: {
|
|
47
|
+
AgentID: 'agent',
|
|
48
|
+
Password: 'password',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
});
|
|
53
|
+
});
|