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 -32 3.0.3 → 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,29 +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',
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',
48 53 ];
49 54
50 55 /**
51 56 * Free core map + optional suffixes from Yatra Pro (or other add-ons) via
@@ -314,10 +319,10 @@
314 319 continue;
315 320 }
316 321
317 322 if ($dataType === 'itinerary') {
318 - $daysTable = $wpdb->prefix . 'yatra_new_trip_itinerary_days';
319 - $entriesTable = $wpdb->prefix . 'yatra_new_trip_itinerary_day_entry';
323 + $daysTable = TripItineraryDaysTable::getTableName();
324 + $entriesTable = TripItineraryDayEntryTable::getTableName();
320 325 $allDays = [];
321 326 $allEntries = [];
322 327
323 328 if ($repository->tableExists($daysTable)) {
@@ -356,9 +361,9 @@
356 361 }
357 362
358 363 $classType = self::classificationTypeForDataType($dataType);
359 364 if ($classType !== null) {
360 - $tableName = $wpdb->prefix . 'yatra_new_classifications';
365 + $tableName = ClassificationsTable::getTableName();
361 366 if (!$repository->tableExists($tableName)) {
362 367 $exportData['data'][$dataType] = [];
363 368 continue;
364 369 }
@@ -460,8 +465,13 @@
460 465 * @param string $jobId Job ID
461 466 */
462 467 public static function processImportJob(string $jobId): void
463 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 +
464 474 $repository = new ExportImportRepository();
465 475 global $wpdb;
466 476
467 477 $jobData = self::getJobStatus($jobId);
@@ -551,8 +561,30 @@
551 561 }
552 562
553 563 $dataTypes = self::sortImportDataTypes($dataTypes);
554 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 +
555 587 $mapper = new ExportImportIdMapper();
556 588
557 589 $totalRecords = 0;
558 590 foreach ($dataTypes as $dt) {
@@ -602,17 +634,18 @@
602 634 if (!isset($payload['days'], $payload['entries']) || !is_array($payload['days']) || !is_array($payload['entries'])) {
603 635 Logger::warning('Skipping itinerary: expected { days, entries } from Yatra 3 export; legacy flat arrays are not supported');
604 636 continue;
605 637 }
606 - $daysTable = $wpdb->prefix . 'yatra_new_trip_itinerary_days';
607 - $entriesTable = $wpdb->prefix . 'yatra_new_trip_itinerary_day_entry';
638 + $daysTable = TripItineraryDaysTable::getTableName();
639 + $entriesTable = TripItineraryDayEntryTable::getTableName();
608 640 $dayTotal = count($payload['days']);
609 641 $entryTotal = count($payload['entries']);
610 642 $importStats['itinerary'] = [
611 643 'total' => $dayTotal + $entryTotal,
612 - 'imported' => 0,
613 - 'failed' => 0,
614 - ];
644 + 'imported' => 0,
645 + 'failed' => 0,
646 + 'skipped' => 0,
647 + ];
615 648
616 649 foreach (array_chunk($payload['days'], self::BATCH_SIZE) as $batch) {
617 650 foreach ($batch as $record) {
618 651 $record = (array) $record;
@@ -618,8 +651,21 @@
618 651 $record = (array) $record;
619 652 $oldDayId = (int) ($record['id'] ?? 0);
620 653 unset($record['id']);
621 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 +
622 668 if (isset($record['trip_id'])) {
623 669 $mappedTrip = $mapper->map('trips', $record['trip_id']);
624 670 $record['trip_id'] = $mappedTrip;
625 671 }
@@ -664,8 +710,14 @@
664 710 foreach ($batch as $record) {
665 711 $record = (array) $record;
666 712 unset($record['id']);
667 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 +
668 720 if (isset($record['day_id'])) {
669 721 $record['day_id'] = $mapper->map('itinerary_days', $record['day_id']);
670 722 }
671 723 if (isset($record['trip_id'])) {
@@ -724,9 +776,9 @@
724 776
725 777 $mergedMap = self::getMergedTableMap();
726 778 $classType = self::classificationTypeForDataType($dataType);
727 779 if ($classType !== null) {
728 - $tableName = $wpdb->prefix . 'yatra_new_classifications';
780 + $tableName = ClassificationsTable::getTableName();
729 781 } elseif (isset($mergedMap[$dataType])) {
730 782 $tableName = $wpdb->prefix . $mergedMap[$dataType];
731 783 } else {
732 784 Logger::warning("Skipping unknown import data type: {$dataType}");
@@ -797,8 +849,18 @@
797 849 'destinations',
798 850 'activities',
799 851 'categories',
800 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',
801 863 'trips',
802 864 'itinerary',
803 865 ];
804 866 $mergedKeys = array_keys(self::getMergedTableMap());
@@ -807,8 +869,225 @@
807 869 return array_values(array_unique((array) apply_filters('yatra_export_all_data_types', $keys)));
808 870 }
809 871
810 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 + /**
811 1090 * @param array<int, mixed> $records
812 1091 * @param array<string, array{total: int, imported: int, failed: int}> $importStats
813 1092 */
814 1093 private static function importTableRowsWithMapping(
@@ -827,8 +1106,9 @@
827 1106 $importStats[$dataType] = [
828 1107 'total' => count($records),
829 1108 'imported' => 0,
830 1109 'failed' => 0,
1110 + 'skipped' => 0,
831 1111 ];
832 1112 Logger::info('Importing ' . $dataType . ': Found ' . count($records) . ' records');
833 1113
834 1114 foreach (array_chunk($records, self::BATCH_SIZE) as $batch) {
@@ -837,8 +1117,28 @@
837 1117 $oldId = (int) ($record['id'] ?? 0);
838 1118 unset($record['id']);
839 1119
840 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 +
841 1141 self::applyForeignKeyRemapping($mapper, $dataType, $record);
842 1142
843 1143 if ($dataType === 'bookings' && isset($record['reference']) && $record['reference'] !== '') {
844 1144 $record['reference'] = self::ensureUniqueBookingReference((string) $record['reference']);
@@ -931,8 +1231,12 @@
931 1231 case 'destinations':
932 1232 case 'activities':
933 1233 case 'categories':
934 1234 case 'difficulty_levels':
1235 + case 'traveler_categories':
1236 + case 'attributes':
1237 + case 'itinerary_item_types':
1238 + case 'itinerary_items':
935 1239 return 'classifications';
936 1240 case 'trips':
937 1241 return 'trips';
938 1242 case 'customers':
@@ -1250,12 +1554,43 @@
1250 1554
1251 1555 return $single !== null ? (string) $single : '';
1252 1556 }
1253 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 +
1254 1583 private static function ensureUniqueConsentRequestToken(string $token): string
1255 1584 {
1256 1585 global $wpdb;
1257 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 +
1258 1593 $base = $token;
1259 1594 $candidate = $base;
1260 1595 for ($n = 0; $n < 5000; $n++) {
1261 1596 $exists = (int) $wpdb->get_var(
@@ -1276,8 +1611,14 @@
1276 1611 private static function ensureUniqueEmailTemplateKey(string $key): string
1277 1612 {
1278 1613 global $wpdb;
1279 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 +
1280 1621 $base = $key;
1281 1622 $candidate = $base;
1282 1623 for ($n = 0; $n < 5000; $n++) {
1283 1624 $exists = (int) $wpdb->get_var(
@@ -1297,9 +1638,9 @@
1297 1638
1298 1639 private static function ensureUniqueDiscountCode(string $code): string
1299 1640 {
1300 1641 global $wpdb;
1301 - $table = $wpdb->prefix . 'yatra_new_discounts';
1642 + $table = DiscountsTable::getTableName();
1302 1643 $base = $code;
1303 1644 $candidate = $base;
1304 1645 for ($n = 0; $n < 5000; $n++) {
1305 1646 $exists = (int) $wpdb->get_var(
@@ -1319,9 +1660,9 @@
1319 1660
1320 1661 private static function ensureUniqueBookingReference(string $reference): string
1321 1662 {
1322 1663 global $wpdb;
1323 - $table = $wpdb->prefix . 'yatra_new_bookings';
1664 + $table = BookingsTable::getTableName();
1324 1665 $base = $reference;
1325 1666 $candidate = $base;
1326 1667 for ($n = 0; $n < 5000; $n++) {
1327 1668 $exists = (int) $wpdb->get_var(
@@ -1362,8 +1703,16 @@
1362 1703 case 'categories':
1363 1704 return ClassificationTypes::CATEGORY;
1364 1705 case 'difficulty_levels':
1365 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;
1366 1715 default:
1367 1716 return null;
1368 1717 }
1369 1718 }
@@ -1408,8 +1757,12 @@
1408 1757 'destinations',
1409 1758 'activities',
1410 1759 'categories',
1411 1760 'difficulty_levels',
1761 + 'traveler_categories',
1762 + 'attributes',
1763 + 'itinerary_item_types',
1764 + 'itinerary_items',
1412 1765 'additional_service_catalog',
1413 1766 'consent_forms',
1414 1767 'email_templates',
1415 1768 'email_sequences',
@@ -1472,10 +1825,10 @@
1472 1825 if ($dataType === 'settings') {
1473 1826 continue;
1474 1827 }
1475 1828 if ($dataType === 'itinerary') {
1476 - $daysTable = $wpdb->prefix . 'yatra_new_trip_itinerary_days';
1477 - $entriesTable = $wpdb->prefix . 'yatra_new_trip_itinerary_day_entry';
1829 + $daysTable = TripItineraryDaysTable::getTableName();
1830 + $entriesTable = TripItineraryDayEntryTable::getTableName();
1478 1831 if ($repository->tableExists($daysTable)) {
1479 1832 $total += $repository->getRecordCount($daysTable);
1480 1833 }
1481 1834 if ($repository->tableExists($entriesTable)) {
@@ -1484,9 +1837,9 @@
1484 1837 continue;
1485 1838 }
1486 1839 $classType = self::classificationTypeForDataType($dataType);
1487 1840 if ($classType !== null) {
1488 - $tableName = $wpdb->prefix . 'yatra_new_classifications';
1841 + $tableName = ClassificationsTable::getTableName();
1489 1842 if ($repository->tableExists($tableName)) {
1490 1843 $total += $repository->getClassificationCount($tableName, $classType);
1491 1844 }
1492 1845 continue;
@@ -1537,8 +1890,10 @@
1537 1890 * Import settings
1538 1891 */
1539 1892 private static function importSettings(array $settings): void
1540 1893 {
1894 + $permalinkChanged = false;
1895 +
1541 1896 foreach ($settings as $key => $value) {
1542 1897 if (!is_string($key) || strpos($key, 'yatra_') !== 0) {
1543 1898 continue;
1544 1899 }
@@ -1544,10 +1899,47 @@
1544 1899 }
1545 1900 if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $key)) {
1546 1901 continue;
1547 1902 }
1903 +
1904 + if (!$permalinkChanged && self::settingAffectsPermalinks($key)) {
1905 + $existing = get_option($key, null);
1906 + if ($existing !== $value) {
1907 + $permalinkChanged = true;
1908 + }
1909 + }
1910 +
1548 1911 update_option($key, $value);
1549 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);
1550 1942 }
1551 1943
1552 1944 /**
1553 1945 * Delete a job and its associated files