PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 All 85 releases
← All changes | app/Services/ExportImportService.php +390 -3 3.0.5.1 → 3.0.17 View file →
@@ -465,8 +465,13 @@
465 465 * @param string $jobId Job ID
466 466 */
467 467 public static function processImportJob(string $jobId): void
468 468 {
469 + // Per-job state: without this a second import in the same request would
470 + // inherit the first one's "already here" set.
471 + self::$importPreExisting = [];
472 + self::$importSkippedOldIds = [];
473 +
469 474 $repository = new ExportImportRepository();
470 475 global $wpdb;
471 476
472 477 $jobData = self::getJobStatus($jobId);
@@ -556,8 +561,30 @@
556 561 }
557 562
558 563 $dataTypes = self::sortImportDataTypes($dataTypes);
559 564
565 + /**
566 + * Last chance to create any table the file needs.
567 + *
568 + * Yatra Pro deliberately does not create its tables on activation —
569 + * each module creates its own when it is switched on. That is fine
570 + * for a running site and wrong for a restore: a backup taken from a
571 + * site that used Email Automation, Dynamic Pricing or Consent Forms
572 + * arrives at a fresh install whose tables for those do not exist
573 + * yet, and every one of those rows was counted as a failure and
574 + * dropped. On the migration this was tested against that silently
575 + * lost 2,440 rows — the entire email history, pricing history and
576 + * template set.
577 + *
578 + * Pro listens for this and brings its tables into being so the data
579 + * has somewhere to land; whether the operator later switches those
580 + * modules on is a separate decision from whether their data survived
581 + * the move.
582 + *
583 + * @param list<string> $dataTypes Data types present in the file.
584 + */
585 + do_action('yatra_export_import_prepare_tables', $dataTypes);
586 +
560 587 $mapper = new ExportImportIdMapper();
561 588
562 589 $totalRecords = 0;
563 590 foreach ($dataTypes as $dt) {
@@ -613,11 +640,12 @@
613 640 $dayTotal = count($payload['days']);
614 641 $entryTotal = count($payload['entries']);
615 642 $importStats['itinerary'] = [
616 643 'total' => $dayTotal + $entryTotal,
617 - 'imported' => 0,
618 - 'failed' => 0,
619 - ];
644 + 'imported' => 0,
645 + 'failed' => 0,
646 + 'skipped' => 0,
647 + ];
620 648
621 649 foreach (array_chunk($payload['days'], self::BATCH_SIZE) as $batch) {
622 650 foreach ($batch as $record) {
623 651 $record = (array) $record;
@@ -623,8 +651,21 @@
623 651 $record = (array) $record;
624 652 $oldDayId = (int) ($record['id'] ?? 0);
625 653 unset($record['id']);
626 654 try {
655 + // Itinerary has its own import path, so it needs the
656 + // same recognition the main loop does: a trip already
657 + // here already has its days, and re-inserting them is
658 + // what made every one of these fail on a second run.
659 + $oldTripIdForDay = (int) ($record['trip_id'] ?? 0);
660 + if ($oldTripIdForDay > 0 && !empty(self::$importSkippedOldIds['trips'][$oldTripIdForDay])) {
661 + $importStats['itinerary']['skipped'] = ($importStats['itinerary']['skipped'] ?? 0) + 1;
662 + if ($oldDayId > 0) {
663 + self::$importSkippedOldIds['itinerary_days'][$oldDayId] = true;
664 + }
665 + continue;
666 + }
667 +
627 668 if (isset($record['trip_id'])) {
628 669 $mappedTrip = $mapper->map('trips', $record['trip_id']);
629 670 $record['trip_id'] = $mappedTrip;
630 671 }
@@ -669,8 +710,14 @@
669 710 foreach ($batch as $record) {
670 711 $record = (array) $record;
671 712 unset($record['id']);
672 713 try {
714 + $oldDayRef = (int) ($record['day_id'] ?? 0);
715 + if ($oldDayRef > 0 && !empty(self::$importSkippedOldIds['itinerary_days'][$oldDayRef])) {
716 + $importStats['itinerary']['skipped'] = ($importStats['itinerary']['skipped'] ?? 0) + 1;
717 + continue;
718 + }
719 +
673 720 if (isset($record['day_id'])) {
674 721 $record['day_id'] = $mapper->map('itinerary_days', $record['day_id']);
675 722 }
676 723 if (isset($record['trip_id'])) {
@@ -802,8 +849,18 @@
802 849 'destinations',
803 850 'activities',
804 851 'categories',
805 852 'difficulty_levels',
853 + // The classifications table holds eight kinds of row and only the
854 + // four above were ever exported. Traveller categories, attributes
855 + // and itinerary item types are operator-configured data too, and
856 + // leaving them out silently dropped them from every backup — and
857 + // took the trip links that pointed at them down with it, because
858 + // those could no longer resolve on import.
859 + 'traveler_categories',
860 + 'attributes',
861 + 'itinerary_item_types',
862 + 'itinerary_items',
806 863 'trips',
807 864 'itinerary',
808 865 ];
809 866 $mergedKeys = array_keys(self::getMergedTableMap());
@@ -812,8 +869,225 @@
812 869 return array_values(array_unique((array) apply_filters('yatra_export_all_data_types', $keys)));
813 870 }
814 871
815 872 /**
873 + * Rows found to be already present during the current import, as
874 + * entity => [id => true]. Children of these are skipped: the parent is
875 + * already here, so its children are too.
876 + *
877 + * @var array<string, array<int, bool>>
878 + */
879 + private static $importPreExisting = [];
880 +
881 + /**
882 + * Export-side ids skipped during this import, as entity => [oldId => true].
883 + *
884 + * Needed for depth: when a booking is recognised, its travellers are skipped
885 + * too, and those travellers never get an id mapping — so their meta rows had
886 + * nothing to resolve `traveller_id` against and were rejected. Remembering
887 + * the skip by export id lets the whole subtree be skipped cleanly.
888 + *
889 + * @var array<string, array<int, bool>>
890 + */
891 + private static $importSkippedOldIds = [];
892 +
893 + /**
894 + * Natural-key columns that are themselves foreign keys, so they hold the
895 + * export's id and must be translated to this site's before matching.
896 + *
897 + * @var array<string, array<string, string>> dataType => [column => entity]
898 + */
899 + private const IMPORT_NATURAL_KEY_FKS = [
900 + 'availability' => ['trip_id' => 'trips'],
901 + ];
902 +
903 + /**
904 + * Business identity per data type, used to recognise a row the importer has
905 + * already brought in.
906 + *
907 + * Import inserts every row with a fresh id, so without this a second run
908 + * duplicated everything that had no unique constraint (bookings, trip
909 + * content, availability) and rejected everything that had one — on a real
910 + * site that meant all 69 customers failing because `uk_email` refused them.
911 + * Matching on the natural key instead lets an existing row be reused.
912 + *
913 + * @var array<string, list<string>>
914 + */
915 + private const IMPORT_NATURAL_KEYS = [
916 + 'trips' => ['slug'],
917 + 'customers' => ['email'],
918 + 'bookings' => ['reference'],
919 + 'discounts' => ['code'],
920 + 'email_templates' => ['template_key'],
921 + 'consent_requests' => ['token'],
922 + 'availability' => ['trip_id', 'departure_date', 'departure_time'],
923 +
924 + // The four taxonomies share one table, which already carries a UNIQUE
925 + // KEY on (type, slug). Without an identity here the slug was handed to
926 + // ensureUniqueSlug() first, which renamed "hiking" to "hiking-i1" and so
927 + // re-imported every term as a new one instead of recognising it.
928 + 'destinations' => ['type', 'slug'],
929 + 'activities' => ['type', 'slug'],
930 + 'categories' => ['type', 'slug'],
931 + 'difficulty_levels' => ['type', 'slug'],
932 + 'traveler_categories' => ['type', 'slug'],
933 + 'attributes' => ['type', 'slug'],
934 + 'itinerary_item_types' => ['type', 'slug'],
935 + 'itinerary_items' => ['type', 'slug'],
936 +
937 + // Catalogue rows an operator maintains by name. Nothing about them is
938 + // unique at the database level, so a re-import simply added a second
939 + // "Trip Liability Waiver" beside the first. The name alone would be the
940 + // obvious key, but nothing stops a site holding two services called
941 + // "Transfer" at different prices, and keying on the name would drop one
942 + // of them from a restore. Each key therefore carries enough of the row
943 + // to tell two genuinely different ones apart, while an unchanged row
944 + // still matches itself exactly.
945 + 'additional_service_catalog' => ['name', 'price', 'price_type', 'price_per'],
946 + 'consent_forms' => ['name', 'version'],
947 + 'dynamic_pricing_rules' => ['name', 'rule_type', 'adjustment_type', 'adjustment_value'],
948 +
949 + // email_logs deliberately has no natural key. The obvious candidate,
950 + // (recipient_email, template_key, sent_at), collides on real data — a
951 + // 1,231-row log here held 419 rows that share one with another row,
952 + // because a booking can send the same template to the same address
953 + // within the same second. Keying on it would silently drop those rows
954 + // from a restore, and losing history is worse than repeating it, so a
955 + // re-import appends the log again rather than risk discarding it.
956 + ];
957 +
958 + /**
959 + * Owning parent for rows that carry no identity of their own.
960 + *
961 + * A trip's content, itinerary and departures belong to that trip. When the
962 + * trip is recognised as already present its children are already present
963 + * too, so re-inserting them is what produced duplicate itineraries and
964 + * duplicate departure rows on a second import.
965 + *
966 + * @var array<string, array{0: string, 1: string}> dataType => [entity, fk column]
967 + */
968 + private const IMPORT_PRIMARY_PARENT = [
969 + 'trip_classifications' => [['trips', 'trip_id']],
970 + 'trip_content' => [['trips', 'trip_id']],
971 + 'trip_revisions' => [['trips', 'trip_id']],
972 + 'itinerary' => [['trips', 'trip_id']],
973 + 'availability_rules' => [['trips', 'trip_id']],
974 + 'departures' => [['trips', 'trip_id']],
975 + 'reviews' => [['trips', 'trip_id']],
976 + 'enquiries' => [['trips', 'trip_id']],
977 + 'pricing_history' => [['trips', 'trip_id']],
978 + 'trip_demand_scores' => [['trips', 'trip_id']],
979 + 'trip_additional_services' => [['trips', 'trip_id'], ['services', 'service_id']],
980 + 'trip_consent_forms' => [['trips', 'trip_id'], ['consent_forms', 'form_id']],
981 + // Two parents, and either is enough: a booking_departures row cannot be
982 + // new if the departure it points at is already here. This is what left
983 + // 240 of them failing — departures are skipped as children of a trip, so
984 + // they carry no id mapping and `departure_id` could not resolve.
985 + 'booking_departures' => [['bookings', 'booking_id'], ['departures', 'departure_id']],
986 + 'booking_additional_services' => [['bookings', 'booking_id'], ['services', 'service_id']],
987 + 'travelers' => [['bookings', 'booking_id']],
988 + 'payments' => [['bookings', 'booking_id']],
989 + 'google_calendar_events' => [['bookings', 'booking_id'], ['departures', 'departure_id']],
990 + 'signed_consents' => [['bookings', 'booking_id'], ['consent_forms', 'form_id']],
991 + 'traveler_meta' => [['travelers', 'traveller_id']],
992 + 'recovery_email_logs' => [['abandoned_bookings', 'abandoned_booking_id']],
993 + 'abandoned_bookings' => [['trips', 'trip_id']],
994 + 'email_sequence_steps' => [['email_sequences', 'sequence_id']],
995 + 'email_queue' => [['email_sequences', 'sequence_id']],
996 + ];
997 +
998 + /**
999 + * Id of the row this site already holds for the record being imported, or
1000 + * null when it is genuinely new.
1001 + *
1002 + * Two ways a row can already be here: it carries a business key that
1003 + * matches (a trip slug, a customer email), or it is a child of a parent we
1004 + * just recognised, in which case the parent brought its children with it.
1005 + *
1006 + * Returns [skip, existingId]. existingId is only meaningful for a natural-key
1007 + * match — a child skipped because its parent was already here has no row of
1008 + * its own to map to, and mapping it to the parent's id would corrupt anything
1009 + * referencing the child (traveller meta being the obvious one).
1010 + *
1011 + * @param array<string, mixed> $record
1012 + * @return array{0: bool, 1: int|null}
1013 + */
1014 + private static function findExistingRowId(
1015 + ExportImportRepository $repository,
1016 + string $tableName,
1017 + string $dataType,
1018 + array $record,
1019 + ExportImportIdMapper $mapper
1020 + ): array {
1021 + $naturalKeys = (array) apply_filters('yatra_import_natural_keys', self::IMPORT_NATURAL_KEYS);
1022 +
1023 + if (isset($naturalKeys[$dataType])) {
1024 + $criteria = [];
1025 + foreach ((array) $naturalKeys[$dataType] as $column) {
1026 + if (!array_key_exists($column, $record)) {
1027 + return [false, null];
1028 + }
1029 + $value = $record[$column];
1030 +
1031 + // A null part of a composite key is meaningful — an availability
1032 + // date with no departure time is still that date — so it is
1033 + // matched as IS NULL rather than abandoning the lookup. Only a
1034 + // key that is empty in every part identifies nothing.
1035 + if ($value === null || $value === '') {
1036 + $criteria[$column] = null;
1037 + continue;
1038 + }
1039 + if (!is_scalar($value)) {
1040 + return [false, null];
1041 + }
1042 +
1043 + $keyFks = self::IMPORT_NATURAL_KEY_FKS[$dataType] ?? [];
1044 + if (isset($keyFks[$column])) {
1045 + $mapped = $mapper->map($keyFks[$column], $value);
1046 + if ($mapped === null) {
1047 + // Parent not on this site: nothing here can match.
1048 + return [false, null];
1049 + }
1050 + $value = $mapped;
1051 + }
1052 +
1053 + $criteria[$column] = $value;
1054 + }
1055 +
1056 + if ($criteria === [] || count(array_filter($criteria, static fn($v) => $v !== null)) === 0) {
1057 + return [false, null];
1058 + }
1059 +
1060 + $existingId = $repository->findRowIdBy($tableName, $criteria);
1061 +
1062 + return $existingId !== null ? [true, $existingId] : [false, null];
1063 + }
1064 +
1065 + $parents = (array) apply_filters('yatra_import_primary_parent', self::IMPORT_PRIMARY_PARENT);
1066 + foreach ((array) ($parents[$dataType] ?? []) as $parent) {
1067 + [$parentEntity, $fkColumn] = $parent;
1068 + if (empty($record[$fkColumn])) {
1069 + continue;
1070 + }
1071 + $parentOldId = (int) $record[$fkColumn];
1072 +
1073 + // Matched on the export's id rather than ours: a skipped row has no
1074 + // id here, so anything hanging off it could not be resolved any
1075 + // other way.
1076 + if ($parentOldId > 0 && !empty(self::$importSkippedOldIds[$parentEntity][$parentOldId])) {
1077 + return [true, null];
1078 + }
1079 +
1080 + $mappedParentId = (int) $mapper->map($parentEntity, $record[$fkColumn]);
1081 + if ($mappedParentId > 0 && !empty(self::$importPreExisting[$parentEntity][$mappedParentId])) {
1082 + return [true, null];
1083 + }
1084 + }
1085 +
1086 + return [false, null];
1087 + }
1088 +
1089 + /**
816 1090 * @param array<int, mixed> $records
817 1091 * @param array<string, array{total: int, imported: int, failed: int}> $importStats
818 1092 */
819 1093 private static function importTableRowsWithMapping(
@@ -832,8 +1106,9 @@
832 1106 $importStats[$dataType] = [
833 1107 'total' => count($records),
834 1108 'imported' => 0,
835 1109 'failed' => 0,
1110 + 'skipped' => 0,
836 1111 ];
837 1112 Logger::info('Importing ' . $dataType . ': Found ' . count($records) . ' records');
838 1113
839 1114 foreach (array_chunk($records, self::BATCH_SIZE) as $batch) {
@@ -842,8 +1117,28 @@
842 1117 $oldId = (int) ($record['id'] ?? 0);
843 1118 unset($record['id']);
844 1119
845 1120 try {
1121 + // Recognise a row this site already has. Deliberately before the
1122 + // ensureUnique* helpers below, which would otherwise rename the
1123 + // very value being matched on ("SUMMER10" -> "SUMMER10-i1") and
1124 + // turn every re-import into a fresh duplicate.
1125 + [$alreadyHere, $existingId] = self::findExistingRowId($repository, $tableName, $dataType, $record, $mapper);
1126 + if ($alreadyHere) {
1127 + $importStats[$dataType]['skipped']++;
1128 + $entity = self::entityKeyForDataType($dataType);
1129 + if ($entity !== null && $oldId > 0) {
1130 + self::$importSkippedOldIds[$entity][$oldId] = true;
1131 + }
1132 + if ($entity !== null && $oldId > 0 && $existingId !== null) {
1133 + // Point this export's id at the row already here, so
1134 + // children attach to it instead of dangling.
1135 + $mapper->remember($entity, $oldId, $existingId);
1136 + self::$importPreExisting[$entity][$existingId] = true;
1137 + }
1138 + continue;
1139 + }
1140 +
846 1141 self::applyForeignKeyRemapping($mapper, $dataType, $record);
847 1142
848 1143 if ($dataType === 'bookings' && isset($record['reference']) && $record['reference'] !== '') {
849 1144 $record['reference'] = self::ensureUniqueBookingReference((string) $record['reference']);
@@ -936,8 +1231,12 @@
936 1231 case 'destinations':
937 1232 case 'activities':
938 1233 case 'categories':
939 1234 case 'difficulty_levels':
1235 + case 'traveler_categories':
1236 + case 'attributes':
1237 + case 'itinerary_item_types':
1238 + case 'itinerary_items':
940 1239 return 'classifications';
941 1240 case 'trips':
942 1241 return 'trips';
943 1242 case 'customers':
@@ -1255,12 +1554,43 @@
1255 1554
1256 1555 return $single !== null ? (string) $single : '';
1257 1556 }
1258 1557
1558 + /**
1559 + * Does this table exist?
1560 + *
1561 + * The import de-duplicates keys against tables owned by Yatra Pro modules.
1562 + * On a free-only site — or one where the owning module has never been
1563 + * enabled — those tables are absent, and probing one logs a database error
1564 + * for every record imported. Nothing to collide with in that case, so the
1565 + * callers below keep the incoming value.
1566 + *
1567 + * Cached for the request: each caller runs once per imported row.
1568 + */
1569 + private static function tableExists(string $table): bool
1570 + {
1571 + static $cache = [];
1572 +
1573 + if (!array_key_exists($table, $cache)) {
1574 + global $wpdb;
1575 + $cache[$table] = (bool) $wpdb->get_var(
1576 + $wpdb->prepare('SHOW TABLES LIKE %s', $table)
1577 + );
1578 + }
1579 +
1580 + return $cache[$table];
1581 + }
1582 +
1259 1583 private static function ensureUniqueConsentRequestToken(string $token): string
1260 1584 {
1261 1585 global $wpdb;
1262 1586 $table = $wpdb->prefix . 'yatra_consent_requests';
1587 +
1588 + // Trip Consent is a Pro module; without it there is no table to clash with.
1589 + if (!self::tableExists($table)) {
1590 + return $token;
1591 + }
1592 +
1263 1593 $base = $token;
1264 1594 $candidate = $base;
1265 1595 for ($n = 0; $n < 5000; $n++) {
1266 1596 $exists = (int) $wpdb->get_var(
@@ -1281,8 +1611,14 @@
1281 1611 private static function ensureUniqueEmailTemplateKey(string $key): string
1282 1612 {
1283 1613 global $wpdb;
1284 1614 $table = $wpdb->prefix . 'yatra_email_templates';
1615 +
1616 + // Email Automation is a Pro module; without it there is no table to clash with.
1617 + if (!self::tableExists($table)) {
1618 + return $key;
1619 + }
1620 +
1285 1621 $base = $key;
1286 1622 $candidate = $base;
1287 1623 for ($n = 0; $n < 5000; $n++) {
1288 1624 $exists = (int) $wpdb->get_var(
@@ -1367,8 +1703,16 @@
1367 1703 case 'categories':
1368 1704 return ClassificationTypes::CATEGORY;
1369 1705 case 'difficulty_levels':
1370 1706 return ClassificationTypes::DIFFICULTY;
1707 + case 'traveler_categories':
1708 + return ClassificationTypes::TRAVELER_TYPE;
1709 + case 'attributes':
1710 + return ClassificationTypes::ATTRIBUTE;
1711 + case 'itinerary_item_types':
1712 + return ClassificationTypes::ITEM_TYPE;
1713 + case 'itinerary_items':
1714 + return ClassificationTypes::ITEM;
1371 1715 default:
1372 1716 return null;
1373 1717 }
1374 1718 }
@@ -1413,8 +1757,12 @@
1413 1757 'destinations',
1414 1758 'activities',
1415 1759 'categories',
1416 1760 'difficulty_levels',
1761 + 'traveler_categories',
1762 + 'attributes',
1763 + 'itinerary_item_types',
1764 + 'itinerary_items',
1417 1765 'additional_service_catalog',
1418 1766 'consent_forms',
1419 1767 'email_templates',
1420 1768 'email_sequences',
@@ -1542,8 +1890,10 @@
1542 1890 * Import settings
1543 1891 */
1544 1892 private static function importSettings(array $settings): void
1545 1893 {
1894 + $permalinkChanged = false;
1895 +
1546 1896 foreach ($settings as $key => $value) {
1547 1897 if (!is_string($key) || strpos($key, 'yatra_') !== 0) {
1548 1898 continue;
1549 1899 }
@@ -1549,10 +1899,47 @@
1549 1899 }
1550 1900 if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $key)) {
1551 1901 continue;
1552 1902 }
1903 +
1904 + if (!$permalinkChanged && self::settingAffectsPermalinks($key)) {
1905 + $existing = get_option($key, null);
1906 + if ($existing !== $value) {
1907 + $permalinkChanged = true;
1908 + }
1909 + }
1910 +
1553 1911 update_option($key, $value);
1554 1912 }
1913 +
1914 + // Settings are cached per request, so without this the rest of the
1915 + // import still sees the pre-import values.
1916 + if (class_exists(\Yatra\Services\SettingsService::class)) {
1917 + \Yatra\Services\SettingsService::reload();
1918 + }
1919 +
1920 + // A restored permalink base rewrites the trip and taxonomy URLs, but the
1921 + // rules behind them are only rebuilt on a flush. Without this the site
1922 + // keeps serving the old structure until someone happens to open
1923 + // Settings > Permalinks — until then trip links resolve to nothing and
1924 + // WordPress sends the visitor to the front page.
1925 + if ($permalinkChanged) {
1926 + // Deliberately not flush_rewrite_rules() here: the rules currently in
1927 + // memory were built during init from the pre-import bases, so
1928 + // regenerating now would just write the stale set back. Clearing the
1929 + // cached rules makes WordPress rebuild them on the next request, by
1930 + // which time init has read the imported values.
1931 + delete_option('rewrite_rules');
1932 + Logger::info('Import changed permalink settings; cached rewrite rules cleared for rebuild.');
1933 + }
1934 + }
1935 +
1936 + /**
1937 + * Does this option take part in building URLs?
1938 + */
1939 + private static function settingAffectsPermalinks(string $key): bool
1940 + {
1941 + return (bool) preg_match('/_base$|_prefix$|_segment$|permalink/i', $key);
1555 1942 }
1556 1943
1557 1944 /**
1558 1945 * Delete a job and its associated files