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 CHANGED
@@ -4,11 +4,34 @@
4
4
 
5
5
  ### Requirements
6
6
 
7
- Some environment variables are required for this plugin to run it's tests
8
-
9
- - apiKey
10
- - endpoint
11
- - DTD_DAYS (optional) - Number of days to cache DTD version detection results (default: 7). This helps reduce API calls by caching the correct DTD version for each endpoint.
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
 
@@ -4151,6 +4151,7 @@ Object {
4151
4151
  "description": null,
4152
4152
  "options": Array [
4153
4153
  Object {
4154
+ "chargeUnit": "day",
4154
4155
  "city": "London",
4155
4156
  "comment": "FIT- V- Class (1-5 Pax)",
4156
4157
  "country": "United Kingdom",
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 { translateTPOption, getOptionCurrency } = require('./resolvers/product');
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 [locationsByCode, resolvedServicesByCode, countriesByDestination] = await Promise.all([
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 => translateTPOption({
221
- rootValue: {
222
- optionsGroupedBySupplierId,
223
- },
224
- agentCurrencyCode,
225
- typeDefs: itineraryProductTypeDefs,
226
- query: itineraryProductQuery,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2-tourplan",
3
- "version": "1.0.139",
3
+ "version": "1.0.141",
4
4
  "description": "Tourplan's TI2 Plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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 ['Single', 'Twin', 'Double', 'Quad']
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
- ...['Single', 'Twin', 'Triple', 'Double', 'Quad'].reduce((acc, roomType) => {
137
- const unitAvail = R.path(['OptGeneral', `${roomType}_Avail`], option);
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
+ });
@@ -1,10 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(gh pr diff:*)",
5
- "Bash(gh pr view:*)"
6
- ],
7
- "deny": [],
8
- "ask": []
9
- }
10
- }