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 +424 -68 3.0.2.6 → 3.0.17 View file →
@@ -4,10 +4,15 @@
4 4
5 5 namespace Yatra\Services;
6 6
7 7 use Yatra\Constants\ClassificationTypes;
8 +use Yatra\Database\Tables\BookingsTable;
9 +use Yatra\Database\Tables\ClassificationsTable;
10 +use Yatra\Database\Tables\DiscountsTable;
11 +use Yatra\Database\Tables\TripItineraryDayEntryTable;
12 +use Yatra\Database\Tables\TripItineraryDaysTable;
13 +use Yatra\Repositories\ExportImportRepository;
8 14 use Yatra\Utils\Logger;
9 -use Yatra\Repositories\ExportImportRepository;
10 15
11 16 /**
12 17 * Export/Import Service
13 18 *
@@ -23,31 +28,29 @@
23 28 private const JOB_OPTION_PREFIX = 'yatra_job_';
24 29 private const BATCH_SIZE = 500;
25 30
26 31 /**
27 - * Unprefixed physical table names (Yatra 3.x uses yatra_new_* — not legacy yatra_trips, etc.).
32 + * Unprefixed physical table names (Yatra 3.x uses yatra_* — not legacy yatra_trips, etc.).
28 33 *
29 34 * @var array<string, string>
30 35 */
31 36 private const TABLE_SUFFIX_MAP = [
32 - 'trips' => 'yatra_new_trips',
33 - 'bookings' => 'yatra_new_bookings',
34 - 'customers' => 'yatra_new_customers',
35 - 'reviews' => 'yatra_new_reviews',
36 - 'payments' => 'yatra_new_booking_payments',
37 - 'enquiries' => 'yatra_new_enquiries',
38 - 'discounts' => 'yatra_new_discounts',
39 - 'travelers' => 'yatra_new_booking_travellers',
40 - 'traveler_meta' => 'yatra_new_booking_traveller_meta',
41 - 'availability' => 'yatra_new_trip_availability_dates',
42 - 'availability_rules' => 'yatra_new_trip_availability_rules',
43 - 'departures' => 'yatra_new_trip_departures',
44 - 'booking_departures' => 'yatra_new_booking_departures',
45 - 'trip_classifications' => 'yatra_new_trip_classifications',
46 - 'trip_content' => 'yatra_new_trip_content',
47 - 'trip_revisions' => 'yatra_new_trip_revisions',
48 - 'scheduled_payments' => 'yatra_new_scheduled_payments',
49 - 'payment_tokens' => 'yatra_new_payment_tokens',
37 + 'trips' => 'yatra_trips',
38 + 'bookings' => 'yatra_bookings',
39 + 'customers' => 'yatra_customers',
40 + 'reviews' => 'yatra_reviews',
41 + 'payments' => 'yatra_booking_payments',
42 + 'enquiries' => 'yatra_enquiries',
43 + 'discounts' => 'yatra_discounts',
44 + 'travelers' => 'yatra_booking_travellers',
45 + 'traveler_meta' => 'yatra_booking_traveller_meta',
46 + 'availability' => 'yatra_trip_availability_dates',
47 + 'availability_rules' => 'yatra_trip_availability_rules',
48 + 'departures' => 'yatra_trip_departures',
49 + 'booking_departures' => 'yatra_booking_departures',
50 + 'trip_classifications' => 'yatra_trip_classifications',
51 + 'trip_content' => 'yatra_trip_content',
52 + 'trip_revisions' => 'yatra_trip_revisions',
50 53 ];
51 54
52 55 /**
53 56 * Free core map + optional suffixes from Yatra Pro (or other add-ons) via
@@ -316,10 +319,10 @@
316 319 continue;
317 320 }
318 321
319 322 if ($dataType === 'itinerary') {
320 - $daysTable = $wpdb->prefix . 'yatra_new_trip_itinerary_days';
321 - $entriesTable = $wpdb->prefix . 'yatra_new_trip_itinerary_day_entry';
323 + $daysTable = TripItineraryDaysTable::getTableName();
324 + $entriesTable = TripItineraryDayEntryTable::getTableName();
322 325 $allDays = [];
323 326 $allEntries = [];
324 327
325 328 if ($repository->tableExists($daysTable)) {
@@ -358,9 +361,9 @@
358 361 }
359 362
360 363 $classType = self::classificationTypeForDataType($dataType);
361 364 if ($classType !== null) {
362 - $tableName = $wpdb->prefix . 'yatra_new_classifications';
365 + $tableName = ClassificationsTable::getTableName();
363 366 if (!$repository->tableExists($tableName)) {
364 367 $exportData['data'][$dataType] = [];
365 368 continue;
366 369 }
@@ -462,8 +465,13 @@
462 465 * @param string $jobId Job ID
463 466 */
464 467 public static function processImportJob(string $jobId): void
465 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 +
466 474 $repository = new ExportImportRepository();
467 475 global $wpdb;
468 476
469 477 $jobData = self::getJobStatus($jobId);
@@ -553,8 +561,30 @@
553 561 }
554 562
555 563 $dataTypes = self::sortImportDataTypes($dataTypes);
556 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 +
557 587 $mapper = new ExportImportIdMapper();
558 588
559 589 $totalRecords = 0;
560 590 foreach ($dataTypes as $dt) {
@@ -604,17 +634,18 @@
604 634 if (!isset($payload['days'], $payload['entries']) || !is_array($payload['days']) || !is_array($payload['entries'])) {
605 635 Logger::warning('Skipping itinerary: expected { days, entries } from Yatra 3 export; legacy flat arrays are not supported');
606 636 continue;
607 637 }
608 - $daysTable = $wpdb->prefix . 'yatra_new_trip_itinerary_days';
609 - $entriesTable = $wpdb->prefix . 'yatra_new_trip_itinerary_day_entry';
638 + $daysTable = TripItineraryDaysTable::getTableName();
639 + $entriesTable = TripItineraryDayEntryTable::getTableName();
610 640 $dayTotal = count($payload['days']);
611 641 $entryTotal = count($payload['entries']);
612 642 $importStats['itinerary'] = [
613 643 'total' => $dayTotal + $entryTotal,
614 - 'imported' => 0,
615 - 'failed' => 0,
616 - ];
644 + 'imported' => 0,
645 + 'failed' => 0,
646 + 'skipped' => 0,
647 + ];
617 648
618 649 foreach (array_chunk($payload['days'], self::BATCH_SIZE) as $batch) {
619 650 foreach ($batch as $record) {
620 651 $record = (array) $record;
@@ -620,8 +651,21 @@
620 651 $record = (array) $record;
621 652 $oldDayId = (int) ($record['id'] ?? 0);
622 653 unset($record['id']);
623 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 +
624 668 if (isset($record['trip_id'])) {
625 669 $mappedTrip = $mapper->map('trips', $record['trip_id']);
626 670 $record['trip_id'] = $mappedTrip;
627 671 }
@@ -666,8 +710,14 @@
666 710 foreach ($batch as $record) {
667 711 $record = (array) $record;
668 712 unset($record['id']);
669 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 +
670 720 if (isset($record['day_id'])) {
671 721 $record['day_id'] = $mapper->map('itinerary_days', $record['day_id']);
672 722 }
673 723 if (isset($record['trip_id'])) {
@@ -726,9 +776,9 @@
726 776
727 777 $mergedMap = self::getMergedTableMap();
728 778 $classType = self::classificationTypeForDataType($dataType);
729 779 if ($classType !== null) {
730 - $tableName = $wpdb->prefix . 'yatra_new_classifications';
780 + $tableName = ClassificationsTable::getTableName();
731 781 } elseif (isset($mergedMap[$dataType])) {
732 782 $tableName = $wpdb->prefix . $mergedMap[$dataType];
733 783 } else {
734 784 Logger::warning("Skipping unknown import data type: {$dataType}");
@@ -799,8 +849,18 @@
799 849 'destinations',
800 850 'activities',
801 851 'categories',
802 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',
803 863 'trips',
804 864 'itinerary',
805 865 ];
806 866 $mergedKeys = array_keys(self::getMergedTableMap());
@@ -809,8 +869,225 @@
809 869 return array_values(array_unique((array) apply_filters('yatra_export_all_data_types', $keys)));
810 870 }
811 871
812 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 + /**
813 1090 * @param array<int, mixed> $records
814 1091 * @param array<string, array{total: int, imported: int, failed: int}> $importStats
815 1092 */
816 1093 private static function importTableRowsWithMapping(
@@ -829,8 +1106,9 @@
829 1106 $importStats[$dataType] = [
830 1107 'total' => count($records),
831 1108 'imported' => 0,
832 1109 'failed' => 0,
1110 + 'skipped' => 0,
833 1111 ];
834 1112 Logger::info('Importing ' . $dataType . ': Found ' . count($records) . ' records');
835 1113
836 1114 foreach (array_chunk($records, self::BATCH_SIZE) as $batch) {
@@ -839,8 +1117,28 @@
839 1117 $oldId = (int) ($record['id'] ?? 0);
840 1118 unset($record['id']);
841 1119
842 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 +
843 1141 self::applyForeignKeyRemapping($mapper, $dataType, $record);
844 1142
845 1143 if ($dataType === 'bookings' && isset($record['reference']) && $record['reference'] !== '') {
846 1144 $record['reference'] = self::ensureUniqueBookingReference((string) $record['reference']);
@@ -933,8 +1231,12 @@
933 1231 case 'destinations':
934 1232 case 'activities':
935 1233 case 'categories':
936 1234 case 'difficulty_levels':
1235 + case 'traveler_categories':
1236 + case 'attributes':
1237 + case 'itinerary_item_types':
1238 + case 'itinerary_items':
937 1239 return 'classifications';
938 1240 case 'trips':
939 1241 return 'trips';
940 1242 case 'customers':
@@ -942,12 +1244,8 @@
942 1244 case 'bookings':
943 1245 return 'bookings';
944 1246 case 'payments':
945 1247 return 'payments';
946 - case 'payment_tokens':
947 - return 'payment_tokens';
948 - case 'scheduled_payments':
949 - return 'scheduled_payments';
950 1248 case 'availability':
951 1249 return 'availability';
952 1250 case 'departures':
953 1251 return 'departures';
@@ -1012,14 +1310,10 @@
1012 1310 case 'traveler_meta':
1013 1311 return empty($row['traveller_id'] ?? null);
1014 1312 case 'booking_additional_services':
1015 1313 return empty($row['booking_id'] ?? null) || empty($row['service_id'] ?? null);
1016 - case 'payment_tokens':
1017 - return empty($row['customer_id'] ?? null);
1018 1314 case 'bookings':
1019 1315 return empty($row['trip_id'] ?? null);
1020 - case 'scheduled_payments':
1021 - return empty($row['booking_id'] ?? null);
1022 1316 case 'reviews':
1023 1317 return empty($row['trip_id'] ?? null);
1024 1318 default:
1025 1319 return false;
@@ -1066,13 +1360,8 @@
1066 1360 if (isset($row['service_id'])) {
1067 1361 $row['service_id'] = $m->map('services', $row['service_id']);
1068 1362 }
1069 1363 break;
1070 - case 'payment_tokens':
1071 - if (isset($row['customer_id'])) {
1072 - $row['customer_id'] = $m->map('customers', $row['customer_id']);
1073 - }
1074 - break;
1075 1364 case 'bookings':
1076 1365 if (isset($row['trip_id'])) {
1077 1366 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1078 1367 }
@@ -1116,19 +1405,8 @@
1116 1405 if (array_key_exists('customer_id', $row)) {
1117 1406 $row['customer_id'] = $m->mapFkNullable('customers', $row['customer_id']);
1118 1407 }
1119 1408 break;
1120 - case 'scheduled_payments':
1121 - if (isset($row['booking_id'])) {
1122 - $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1123 - }
1124 - if (array_key_exists('customer_id', $row)) {
1125 - $row['customer_id'] = $m->mapFkNullable('customers', $row['customer_id']);
1126 - }
1127 - if (array_key_exists('payment_token_id', $row)) {
1128 - $row['payment_token_id'] = $m->mapFkNullable('payment_tokens', $row['payment_token_id']);
1129 - }
1130 - break;
1131 1409 case 'google_calendar_events':
1132 1410 if (isset($row['booking_id']) && (int) $row['booking_id'] !== 0) {
1133 1411 $mapped = $m->map('bookings', $row['booking_id']);
1134 1412 $row['booking_id'] = $mapped ?? 0;
@@ -1276,12 +1554,43 @@
1276 1554
1277 1555 return $single !== null ? (string) $single : '';
1278 1556 }
1279 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 +
1280 1583 private static function ensureUniqueConsentRequestToken(string $token): string
1281 1584 {
1282 1585 global $wpdb;
1283 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 +
1284 1593 $base = $token;
1285 1594 $candidate = $base;
1286 1595 for ($n = 0; $n < 5000; $n++) {
1287 1596 $exists = (int) $wpdb->get_var(
@@ -1302,8 +1611,14 @@
1302 1611 private static function ensureUniqueEmailTemplateKey(string $key): string
1303 1612 {
1304 1613 global $wpdb;
1305 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 +
1306 1621 $base = $key;
1307 1622 $candidate = $base;
1308 1623 for ($n = 0; $n < 5000; $n++) {
1309 1624 $exists = (int) $wpdb->get_var(
@@ -1323,9 +1638,9 @@
1323 1638
1324 1639 private static function ensureUniqueDiscountCode(string $code): string
1325 1640 {
1326 1641 global $wpdb;
1327 - $table = $wpdb->prefix . 'yatra_new_discounts';
1642 + $table = DiscountsTable::getTableName();
1328 1643 $base = $code;
1329 1644 $candidate = $base;
1330 1645 for ($n = 0; $n < 5000; $n++) {
1331 1646 $exists = (int) $wpdb->get_var(
@@ -1345,9 +1660,9 @@
1345 1660
1346 1661 private static function ensureUniqueBookingReference(string $reference): string
1347 1662 {
1348 1663 global $wpdb;
1349 - $table = $wpdb->prefix . 'yatra_new_bookings';
1664 + $table = BookingsTable::getTableName();
1350 1665 $base = $reference;
1351 1666 $candidate = $base;
1352 1667 for ($n = 0; $n < 5000; $n++) {
1353 1668 $exists = (int) $wpdb->get_var(
@@ -1388,8 +1703,16 @@
1388 1703 case 'categories':
1389 1704 return ClassificationTypes::CATEGORY;
1390 1705 case 'difficulty_levels':
1391 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;
1392 1715 default:
1393 1716 return null;
1394 1717 }
1395 1718 }
@@ -1417,16 +1740,8 @@
1417 1740 }
1418 1741 if (in_array('availability', $out, true) && !in_array('availability_rules', $out, true)) {
1419 1742 $out[] = 'availability_rules';
1420 1743 }
1421 - if (in_array('payments', $out, true)) {
1422 - foreach (['scheduled_payments', 'payment_tokens'] as $extra) {
1423 - if (!in_array($extra, $out, true)) {
1424 - $out[] = $extra;
1425 - }
1426 - }
1427 - }
1428 -
1429 1744 return apply_filters('yatra_export_import_expand_types', $out, $dataTypes);
1430 1745 }
1431 1746
1432 1747 /**
@@ -1442,8 +1757,12 @@
1442 1757 'destinations',
1443 1758 'activities',
1444 1759 'categories',
1445 1760 'difficulty_levels',
1761 + 'traveler_categories',
1762 + 'attributes',
1763 + 'itinerary_item_types',
1764 + 'itinerary_items',
1446 1765 'additional_service_catalog',
1447 1766 'consent_forms',
1448 1767 'email_templates',
1449 1768 'email_sequences',
@@ -1461,9 +1780,8 @@
1461 1780 'trip_demand_scores',
1462 1781 'pricing_history',
1463 1782 'departures',
1464 1783 'customers',
1465 - 'payment_tokens',
1466 1784 'bookings',
1467 1785 'booking_departures',
1468 1786 'booking_additional_services',
1469 1787 'signed_consents',
@@ -1475,9 +1793,8 @@
1475 1793 'email_logs',
1476 1794 'travelers',
1477 1795 'traveler_meta',
1478 1796 'payments',
1479 - 'scheduled_payments',
1480 1797 'google_calendar_events',
1481 1798 'reviews',
1482 1799 'enquiries',
1483 1800 'discounts',
@@ -1508,10 +1825,10 @@
1508 1825 if ($dataType === 'settings') {
1509 1826 continue;
1510 1827 }
1511 1828 if ($dataType === 'itinerary') {
1512 - $daysTable = $wpdb->prefix . 'yatra_new_trip_itinerary_days';
1513 - $entriesTable = $wpdb->prefix . 'yatra_new_trip_itinerary_day_entry';
1829 + $daysTable = TripItineraryDaysTable::getTableName();
1830 + $entriesTable = TripItineraryDayEntryTable::getTableName();
1514 1831 if ($repository->tableExists($daysTable)) {
1515 1832 $total += $repository->getRecordCount($daysTable);
1516 1833 }
1517 1834 if ($repository->tableExists($entriesTable)) {
@@ -1520,9 +1837,9 @@
1520 1837 continue;
1521 1838 }
1522 1839 $classType = self::classificationTypeForDataType($dataType);
1523 1840 if ($classType !== null) {
1524 - $tableName = $wpdb->prefix . 'yatra_new_classifications';
1841 + $tableName = ClassificationsTable::getTableName();
1525 1842 if ($repository->tableExists($tableName)) {
1526 1843 $total += $repository->getClassificationCount($tableName, $classType);
1527 1844 }
1528 1845 continue;
@@ -1573,8 +1890,10 @@
1573 1890 * Import settings
1574 1891 */
1575 1892 private static function importSettings(array $settings): void
1576 1893 {
1894 + $permalinkChanged = false;
1895 +
1577 1896 foreach ($settings as $key => $value) {
1578 1897 if (!is_string($key) || strpos($key, 'yatra_') !== 0) {
1579 1898 continue;
1580 1899 }
@@ -1580,10 +1899,47 @@
1580 1899 }
1581 1900 if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $key)) {
1582 1901 continue;
1583 1902 }
1903 +
1904 + if (!$permalinkChanged && self::settingAffectsPermalinks($key)) {
1905 + $existing = get_option($key, null);
1906 + if ($existing !== $value) {
1907 + $permalinkChanged = true;
1908 + }
1909 + }
1910 +
1584 1911 update_option($key, $value);
1585 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);
1586 1942 }
1587 1943
1588 1944 /**
1589 1945 * Delete a job and its associated files