| @@ -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 |