ti2-tourplan 1.0.130 → 1.0.131

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/index.test.js CHANGED
@@ -870,6 +870,240 @@ describe('search tests', () => {
870
870
  expect(retVal).toMatchSnapshot();
871
871
  });
872
872
 
873
+ describe('searchProductsForItinerary pickupPoints', () => {
874
+ const singlePickupPoint = {
875
+ Point_ID: '504',
876
+ PointDescription: 'Keio Plaza Hotel Tokyo',
877
+ CanPickup: 'Y',
878
+ CanDropoff: 'Y',
879
+ puTime: '0815',
880
+ doTime: '1800',
881
+ };
882
+ const defaultCallTourplan = mockCallTourplan.getMockImplementation();
883
+ const mockOptionIdProductSearch = ({ pickupPoints } = {}) => {
884
+ mockCallTourplan.mockImplementation(async ({ model }) => {
885
+ if (model.AgentInfoRequest) {
886
+ return { AgentInfoReply: { Currency: 'USD' } };
887
+ }
888
+ if (model.GetLocationsRequest) {
889
+ return { GetLocationsReply: { Locations: { Location: [] } } };
890
+ }
891
+ if (model.GetServicesRequest) {
892
+ return { GetServicesReply: { TPLServices: { TPLService: [] } } };
893
+ }
894
+ if (model.GetSystemSettingsRequest) {
895
+ return { GetSystemSettingsReply: { Countries: { Country: [] } } };
896
+ }
897
+ if (model.OptionInfoRequest) {
898
+ return {
899
+ OptionInfoReply: {
900
+ Option: {
901
+ Opt: 'LONSMPICKUP00001',
902
+ OptGeneral: {
903
+ SupplierId: '1001',
904
+ SupplierName: 'Pickup Supplier',
905
+ Description: 'Pickup Tour',
906
+ SType: 'N',
907
+ AdultsAllowed: 'Y',
908
+ ButtonName: 'Sightseeing',
909
+ ...(pickupPoints !== undefined ? { PickupPoints: pickupPoints } : {}),
910
+ },
911
+ },
912
+ },
913
+ };
914
+ }
915
+ throw new Error(`Unexpected model: ${Object.keys(model)[0]}`);
916
+ });
917
+ };
918
+ const getOptionInfoRequest = () => mockCallTourplan.mock.calls
919
+ .map(([call]) => call.model.OptionInfoRequest)
920
+ .find(Boolean);
921
+
922
+ afterEach(() => {
923
+ mockCallTourplan.mockImplementation(defaultCallTourplan);
924
+ });
925
+
926
+ it('uses GR OptionInfo by default for optionId search', async () => {
927
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
928
+ try {
929
+ mockOptionIdProductSearch();
930
+
931
+ const retVal = await app.searchProductsForItinerary({
932
+ axios,
933
+ token,
934
+ typeDefsAndQueries,
935
+ payload: {
936
+ optionId: 'LONSMPICKUP00001',
937
+ },
938
+ });
939
+
940
+ expect(getOptionInfoRequest().Info).toBe('GR');
941
+ expect(retVal.products[0].options[0].pickupPoints).toBeUndefined();
942
+ } finally {
943
+ warnSpy.mockRestore();
944
+ }
945
+ });
946
+
947
+ it.each([false, 'false'])(
948
+ 'keeps GR OptionInfo when pickupPointsRequired is %p',
949
+ async pickupPointsRequired => {
950
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
951
+ try {
952
+ mockOptionIdProductSearch();
953
+
954
+ const retVal = await app.searchProductsForItinerary({
955
+ axios,
956
+ token,
957
+ typeDefsAndQueries,
958
+ payload: {
959
+ optionId: 'LONSMPICKUP00001',
960
+ pickupPointsRequired,
961
+ },
962
+ });
963
+
964
+ expect(getOptionInfoRequest().Info).toBe('GR');
965
+ expect(retVal.products[0].options[0].pickupPoints).toBeUndefined();
966
+ } finally {
967
+ warnSpy.mockRestore();
968
+ }
969
+ },
970
+ );
971
+
972
+ it('preserves Tourplan pickupPoints on options', async () => {
973
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
974
+ try {
975
+ mockOptionIdProductSearch({
976
+ pickupPoints: { PickupPoint: singlePickupPoint },
977
+ });
978
+
979
+ const retVal = await app.searchProductsForItinerary({
980
+ axios,
981
+ token,
982
+ typeDefsAndQueries,
983
+ payload: {
984
+ optionId: 'LONSMPICKUP00001',
985
+ pickupPointsRequired: true,
986
+ },
987
+ });
988
+
989
+ expect(getOptionInfoRequest().Info).toBe('GRP');
990
+ expect(retVal.products[0].options[0].pickupPoints).toEqual({
991
+ PickupPoint: [singlePickupPoint],
992
+ });
993
+ } finally {
994
+ warnSpy.mockRestore();
995
+ }
996
+ });
997
+
998
+ it('treats string true pickupPointsRequired as enabled', async () => {
999
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
1000
+ try {
1001
+ mockOptionIdProductSearch({
1002
+ pickupPoints: { PickupPoint: singlePickupPoint },
1003
+ });
1004
+
1005
+ const retVal = await app.searchProductsForItinerary({
1006
+ axios,
1007
+ token,
1008
+ typeDefsAndQueries,
1009
+ payload: {
1010
+ optionId: 'LONSMPICKUP00001',
1011
+ pickupPointsRequired: 'true',
1012
+ },
1013
+ });
1014
+
1015
+ expect(getOptionInfoRequest().Info).toBe('GRP');
1016
+ expect(retVal.products[0].options[0].pickupPoints).toEqual({
1017
+ PickupPoint: [singlePickupPoint],
1018
+ });
1019
+ } finally {
1020
+ warnSpy.mockRestore();
1021
+ }
1022
+ });
1023
+
1024
+ it('keeps already-array PickupPoint values', async () => {
1025
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
1026
+ try {
1027
+ const pickupPoints = {
1028
+ PickupPoint: [
1029
+ singlePickupPoint,
1030
+ {
1031
+ Point_ID: '505',
1032
+ PointDescription: 'Tokyo Station',
1033
+ CanPickup: 'N',
1034
+ CanDropoff: 'Y',
1035
+ doTime: '1800',
1036
+ },
1037
+ ],
1038
+ };
1039
+ mockOptionIdProductSearch({ pickupPoints });
1040
+
1041
+ const retVal = await app.searchProductsForItinerary({
1042
+ axios,
1043
+ token,
1044
+ typeDefsAndQueries,
1045
+ payload: {
1046
+ optionId: 'LONSMPICKUP00001',
1047
+ pickupPointsRequired: 1,
1048
+ },
1049
+ });
1050
+
1051
+ expect(getOptionInfoRequest().Info).toBe('GRP');
1052
+ expect(retVal.products[0].options[0].pickupPoints).toEqual(pickupPoints);
1053
+ } finally {
1054
+ warnSpy.mockRestore();
1055
+ }
1056
+ });
1057
+
1058
+ it('omits pickupPoints when Tourplan returns none', async () => {
1059
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
1060
+ try {
1061
+ mockOptionIdProductSearch({
1062
+ pickupPoints: { PickupPoint: [] },
1063
+ });
1064
+
1065
+ const retVal = await app.searchProductsForItinerary({
1066
+ axios,
1067
+ token,
1068
+ typeDefsAndQueries,
1069
+ payload: {
1070
+ optionId: 'LONSMPICKUP00001',
1071
+ pickupPointsRequired: true,
1072
+ },
1073
+ });
1074
+
1075
+ expect(getOptionInfoRequest().Info).toBe('GRP');
1076
+ expect(retVal.products[0].options[0].pickupPoints).toBeUndefined();
1077
+ } finally {
1078
+ warnSpy.mockRestore();
1079
+ }
1080
+ });
1081
+
1082
+ it('omits empty PickupPoints shells', async () => {
1083
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
1084
+ try {
1085
+ mockOptionIdProductSearch({
1086
+ pickupPoints: {},
1087
+ });
1088
+
1089
+ const retVal = await app.searchProductsForItinerary({
1090
+ axios,
1091
+ token,
1092
+ typeDefsAndQueries,
1093
+ payload: {
1094
+ optionId: 'LONSMPICKUP00001',
1095
+ pickupPointsRequired: 'yes',
1096
+ },
1097
+ });
1098
+
1099
+ expect(getOptionInfoRequest().Info).toBe('GRP');
1100
+ expect(retVal.products[0].options[0]).not.toHaveProperty('pickupPoints');
1101
+ } finally {
1102
+ warnSpy.mockRestore();
1103
+ }
1104
+ });
1105
+ });
1106
+
873
1107
  describe('searchProductsForItinerary omitServiceCodes', () => {
874
1108
  const optionGeneral = {
875
1109
  SupplierId: '1001',
@@ -940,6 +1174,11 @@ describe('search tests', () => {
940
1174
  '???BD????????????',
941
1175
  '???TR????????????',
942
1176
  ]);
1177
+ const infos = mockCallTourplan.mock.calls
1178
+ .map(([call]) => call.model.OptionInfoRequest)
1179
+ .filter(Boolean)
1180
+ .map(req => req.Info);
1181
+ expect(infos).toEqual(['G', 'G', 'G']);
943
1182
  expect(debugSpy).not.toHaveBeenCalled();
944
1183
  expect(retVal.products).toHaveLength(1);
945
1184
  expect(retVal.products[0].options.map(o => o.optionId)).toEqual([
@@ -950,6 +1189,83 @@ describe('search tests', () => {
950
1189
  debugSpy.mockRestore();
951
1190
  });
952
1191
 
1192
+ it('requests pickup points in full-catalog OptionInfo when required', async () => {
1193
+ const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
1194
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
1195
+ try {
1196
+ const tourplanPickupPoints = {
1197
+ PickupPoint: {
1198
+ Point_ID: '504',
1199
+ PointDescription: 'Keio Plaza Hotel Tokyo',
1200
+ CanPickup: 'Y',
1201
+ CanDropoff: 'Y',
1202
+ puTime: '0815',
1203
+ doTime: '1800',
1204
+ },
1205
+ };
1206
+ const normalizedPickupPoints = {
1207
+ PickupPoint: [tourplanPickupPoints.PickupPoint],
1208
+ };
1209
+ mockCallTourplan.mockImplementation(async ({ model }) => {
1210
+ if (model.AgentInfoRequest) {
1211
+ return { AgentInfoReply: { Currency: 'USD' } };
1212
+ }
1213
+ if (model.GetLocationsRequest) {
1214
+ return { GetLocationsReply: { Locations: { Location: [] } } };
1215
+ }
1216
+ if (model.GetSystemSettingsRequest) {
1217
+ return { GetSystemSettingsReply: { Countries: { Country: [] } } };
1218
+ }
1219
+ if (model.GetServicesRequest) return getServicesReply;
1220
+ const opt = model.OptionInfoRequest && model.OptionInfoRequest.Opt;
1221
+ if (optionsByOpt[opt]) {
1222
+ return {
1223
+ OptionInfoReply: {
1224
+ Option: {
1225
+ ...optionsByOpt[opt],
1226
+ OptGeneral: {
1227
+ ...optionsByOpt[opt].OptGeneral,
1228
+ PickupPoints: tourplanPickupPoints,
1229
+ },
1230
+ },
1231
+ },
1232
+ };
1233
+ }
1234
+ throw new Error(`Unexpected OptionInfo Opt: ${opt}`);
1235
+ });
1236
+
1237
+ const retVal = await app.searchProductsForItinerary({
1238
+ axios,
1239
+ token,
1240
+ typeDefsAndQueries,
1241
+ payload: {
1242
+ searchInput: '',
1243
+ pickupPointsRequired: true,
1244
+ },
1245
+ });
1246
+
1247
+ const infos = mockCallTourplan.mock.calls
1248
+ .map(([call]) => call.model.OptionInfoRequest)
1249
+ .filter(Boolean)
1250
+ .map(req => req.Info);
1251
+ expect(infos).toEqual(['GP', 'GP', 'GP']);
1252
+ expect(retVal.products[0].options.map(o => o.optionId)).toEqual([
1253
+ 'TESTACOPTION00001',
1254
+ 'TESTBDOPTION00001',
1255
+ 'TESTTROPTION00001',
1256
+ ]);
1257
+ expect(retVal.products[0].options.map(o => o.pickupPoints)).toEqual([
1258
+ normalizedPickupPoints,
1259
+ normalizedPickupPoints,
1260
+ normalizedPickupPoints,
1261
+ ]);
1262
+ expect(warnSpy).not.toHaveBeenCalled();
1263
+ } finally {
1264
+ debugSpy.mockRestore();
1265
+ warnSpy.mockRestore();
1266
+ }
1267
+ });
1268
+
953
1269
  it('fetches all service codes when omitServiceCodes is empty', async () => {
954
1270
  const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
955
1271
  mockFullCatalog();
@@ -7,6 +7,30 @@ const { getCachedLocations } = require('./tp-helpers/locations');
7
7
  const { getCachedServices } = require('./tp-helpers/services');
8
8
  const { getCachedDestinationCountries } = require('./tp-helpers/system-settings');
9
9
  const { enrichOptionWithCodeTables } = require('./tp-helpers/option-enrichment');
10
+ const { asArray } = require('./tp-helpers/values');
11
+
12
+ const isEnabled = value => {
13
+ if (value === true || value === 1) return true;
14
+ const normalized = String(value == null ? '' : value).trim().toLowerCase();
15
+ return normalized === 'true' || normalized === '1' || normalized === 'yes';
16
+ };
17
+
18
+ // Keep Tourplan PascalCase under camelCase pickupPoints (same pattern as optRates).
19
+ // Bokun channel-manager reads container.PickupPoint / point.Point_ID etc.
20
+ const normalizePickupPoints = rawPickupPoints => {
21
+ if (!rawPickupPoints || typeof rawPickupPoints !== 'object') return undefined;
22
+ const points = asArray(R.path(['PickupPoint'], rawPickupPoints))
23
+ .filter(point => point && typeof point === 'object' && !Array.isArray(point));
24
+ if (!points.length) return undefined;
25
+ return {
26
+ ...rawPickupPoints,
27
+ PickupPoint: points,
28
+ };
29
+ };
30
+
31
+ const getOptionInfoTag = ({ includeRates = false, pickupPointsRequired = false } = {}) => (
32
+ `${includeRates ? 'GR' : 'G'}${pickupPointsRequired ? 'P' : ''}`
33
+ );
10
34
 
11
35
  const searchProductsForItinerary = async ({
12
36
  axios,
@@ -30,6 +54,8 @@ const searchProductsForItinerary = async ({
30
54
  // service codes to omit from full catalog search
31
55
  // (e.g. ['AC'], 'AC', or 'AC,SM'); when empty/absent, get all
32
56
  omitServiceCodes,
57
+ // Request Tourplan pickup/dropoff points in OptionInfo when product consumers need them.
58
+ pickupPointsRequired,
33
59
  },
34
60
  callTourplan,
35
61
  cache,
@@ -38,6 +64,7 @@ const searchProductsForItinerary = async ({
38
64
  const optionIds = optionId
39
65
  ? (Array.isArray(optionId) ? optionId : [optionId]).filter(Boolean)
40
66
  : [];
67
+ const shouldRequestPickupPoints = isEnabled(pickupPointsRequired);
41
68
 
42
69
  let options = [];
43
70
  let servicesByCode;
@@ -62,7 +89,10 @@ const searchProductsForItinerary = async ({
62
89
  const getOptionsModel = {
63
90
  OptionInfoRequest: {
64
91
  Opt: id,
65
- Info: 'GR',
92
+ Info: getOptionInfoTag({
93
+ includeRates: true,
94
+ pickupPointsRequired: shouldRequestPickupPoints,
95
+ }),
66
96
  AgentID: hostConnectAgentID,
67
97
  Password: hostConnectAgentPassword,
68
98
  },
@@ -116,7 +146,7 @@ const searchProductsForItinerary = async ({
116
146
  const getOptionsModel = {
117
147
  OptionInfoRequest: {
118
148
  Opt: `???${serviceCode}????????????`,
119
- Info: 'G',
149
+ Info: getOptionInfoTag({ pickupPointsRequired: shouldRequestPickupPoints }),
120
150
  AgentID: hostConnectAgentID,
121
151
  Password: hostConnectAgentPassword,
122
152
  ...(lastUpdatedFrom ? {
@@ -217,10 +247,11 @@ const searchProductsForItinerary = async ({
217
247
  concurrency: 10,
218
248
  },
219
249
  );
220
- // Preserve raw OptRates from Tourplan in product search response when available.
221
- // Also ensure city and currency are always on the option for product cache:
222
- // stock TI2 itinerary-product typeDefs/query omit them, so GraphQL alone will
223
- // not return them even when the code-table and AgentInfo values are available.
250
+ // Preserve raw OptRates / PickupPoints from Tourplan in product search response
251
+ // when available (camelCase keys: optRates, pickupPoints; Tourplan field casing
252
+ // inside). Re-attach after GraphQL because stock TI2 itinerary-product
253
+ // typeDefs/query omit these fields (same for city/country/currency).
254
+ // Also ensure city and currency are always on the option for product cache.
224
255
  const enrichedByOptionId = R.indexBy(R.prop('Opt'), enrichedOptions);
225
256
  const optionRatesByOptionId = options.reduce((acc, option) => {
226
257
  const currentOptionId = R.path(['Opt'], option);
@@ -231,7 +262,7 @@ const searchProductsForItinerary = async ({
231
262
  [currentOptionId]: currentOptionRates,
232
263
  };
233
264
  }, {});
234
- const productsWithOptRates = products.map(product => ({
265
+ const productsWithRawOptionMetadata = products.map(product => ({
235
266
  ...product,
236
267
  options: R.pathOr([], ['options'], product).map(currentOption => {
237
268
  const rawOption = R.path([currentOption.optionId], enrichedByOptionId) || {};
@@ -242,11 +273,16 @@ const searchProductsForItinerary = async ({
242
273
  || R.path(['__destination', 'city'], rawOption);
243
274
  const country = currentOption.country
244
275
  || R.path(['__destination', 'country'], rawOption);
276
+ const pickupPoints = normalizePickupPoints(
277
+ R.path(['OptGeneral', 'PickupPoints'], rawOption)
278
+ || R.path(['PickupPoints'], rawOption),
279
+ );
245
280
  return {
246
281
  ...R.omit(['city', 'country', 'rateContext'], currentOption),
247
282
  ...(city ? { city } : {}),
248
283
  ...(country ? { country } : {}),
249
284
  ...(currency ? { currency } : {}),
285
+ ...(pickupPoints ? { pickupPoints } : {}),
250
286
  ...(R.path([currentOption.optionId], optionRatesByOptionId)
251
287
  ? { optRates: R.path([currentOption.optionId], optionRatesByOptionId) }
252
288
  : {}),
@@ -254,7 +290,7 @@ const searchProductsForItinerary = async ({
254
290
  }),
255
291
  }));
256
292
  return {
257
- products: productsWithOptRates,
293
+ products: productsWithRawOptionMetadata,
258
294
  productFields: [],
259
295
  ...(searchInput || optionId ? {} : configuration),
260
296
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2-tourplan",
3
- "version": "1.0.130",
3
+ "version": "1.0.131",
4
4
  "description": "Tourplan's TI2 Plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {