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
yatra / app / Services / ExportImportService.php

ExportImportService.php in Yatra – Travel Booking & Tour Operator Software 3.0.17, at app/Services/ExportImportService.php

2,109 lines 83.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
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;
14 use Yatra\Utils\Logger;
15
16 /**
17 * Export/Import Service
18 *
19 * Handles background export and import processing using Action Scheduler.
20 * Jobs are queued and processed asynchronously to avoid PHP timeout issues.
21 */
22 class ExportImportService
23 {
24 private ExportImportRepository $repository;
25
26 private const EXPORT_ACTION = 'yatra_process_export_job';
27 private const IMPORT_ACTION = 'yatra_process_import_job';
28 private const JOB_OPTION_PREFIX = 'yatra_job_';
29 private const BATCH_SIZE = 500;
30
31 /**
32 * Unprefixed physical table names (Yatra 3.x uses yatra_* — not legacy yatra_trips, etc.).
33 *
34 * @var array<string, string>
35 */
36 private const TABLE_SUFFIX_MAP = [
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',
53 ];
54
55 /**
56 * Free core map + optional suffixes from Yatra Pro (or other add-ons) via
57 * {@see 'yatra_export_import_table_map'}.
58 *
59 * @return array<string, string> data_type_key => table suffix without wp_prefix
60 */
61 private static function getMergedTableMap(): array
62 {
63 return array_merge(self::TABLE_SUFFIX_MAP, (array) apply_filters('yatra_export_import_table_map', []));
64 }
65
66 public function __construct()
67 {
68 $this->repository = new ExportImportRepository();
69 }
70
71 /**
72 * Get MySQL version
73 */
74 public function getMySQLVersion(): string
75 {
76 return $this->repository->getMySQLVersion();
77 }
78
79 /**
80 * Get job options for user
81 */
82 public function getJobOptionsForUser(int $userId): array
83 {
84 return $this->repository->getJobOptionsForUser($userId);
85 }
86
87 /**
88 * Register Action Scheduler hooks
89 */
90 public static function register(): void
91 {
92 add_action(self::EXPORT_ACTION, [self::class, 'processExportJob'], 10, 1);
93 add_action(self::IMPORT_ACTION, [self::class, 'processImportJob'], 10, 1);
94 }
95
96 /**
97 * Nudge WP-Cron and run Action Scheduler pending actions so queued export/import jobs
98 * actually start during the REST request (same approach as {@see MigrationProgress::kickQueueRunner}).
99 */
100 private static function kickActionSchedulerQueue(): void
101 {
102 if (function_exists('spawn_cron')) {
103 spawn_cron();
104 }
105
106 try {
107 if (class_exists(\ActionScheduler::class)) {
108 $runner = \ActionScheduler::runner();
109 if ($runner !== null && method_exists($runner, 'run')) {
110 $runner->run();
111
112 return;
113 }
114 }
115
116 if (class_exists(\ActionScheduler_QueueRunner::class)) {
117 $runner = \ActionScheduler_QueueRunner::instance();
118 if ($runner !== null && method_exists($runner, 'run')) {
119 $runner->run();
120 }
121 }
122 } catch (\Throwable $e) {
123 Logger::warning('Action Scheduler queue kick failed: ' . $e->getMessage(), [
124 'source' => 'export_import',
125 ]);
126 }
127 }
128
129 /**
130 * Create a new export job
131 *
132 * @param array $dataTypes Data types to export
133 * @param int $userId User who requested the export
134 * @return string Job ID
135 */
136 public static function createExportJob(array $dataTypes, int $userId): string
137 {
138 $dataTypes = self::normalizeExportDataTypes($dataTypes);
139
140 $jobId = 'export_' . uniqid() . '_' . time();
141
142 // Store job metadata in options
143 $jobData = [
144 'id' => $jobId,
145 'type' => 'export',
146 'status' => 'pending',
147 'data_types' => $dataTypes,
148 'user_id' => $userId,
149 'progress' => 0,
150 'total_records' => 0,
151 'processed_records' => 0,
152 'file_path' => '',
153 'file_url' => '',
154 'error' => '',
155 'created_at' => current_time('mysql'),
156 'started_at' => null,
157 'completed_at' => null,
158 ];
159
160 update_option(self::JOB_OPTION_PREFIX . $jobId, $jobData, false);
161
162 // Schedule with Action Scheduler, then kick the runner so work starts in this request
163 // (otherwise many hosts leave jobs pending until WP-Cron, and the UI stays at 0/0).
164 if (function_exists('as_enqueue_async_action')) {
165 as_enqueue_async_action(self::EXPORT_ACTION, [$jobId], 'yatra');
166 self::kickActionSchedulerQueue();
167 $fresh = self::getJobStatus($jobId);
168 if ($fresh && ($fresh['status'] ?? '') === 'pending') {
169 self::processExportJob($jobId);
170 }
171 } else {
172 self::processExportJob($jobId);
173 }
174
175 Logger::info("Export job created: {$jobId}");
176
177 return $jobId;
178 }
179
180 /**
181 * Create a new import job
182 *
183 * @param string $filePath Path to the import file
184 * @param array $dataTypes Data types to import
185 * @param int $userId User who requested the import
186 * @return string Job ID
187 */
188 public static function createImportJob(string $filePath, array $dataTypes, int $userId): string
189 {
190 $importAll = in_array('all', $dataTypes, true);
191 $dataTypes = array_values(array_filter(
192 array_unique($dataTypes),
193 static function ($t) {
194 return is_string($t) && $t !== 'all';
195 }
196 ));
197
198 $jobId = 'import_' . uniqid() . '_' . time();
199
200 $jobData = [
201 'id' => $jobId,
202 'type' => 'import',
203 'status' => 'pending',
204 'data_types' => $dataTypes,
205 'import_all' => $importAll,
206 'user_id' => $userId,
207 'file_path' => $filePath,
208 'progress' => 0,
209 'total_records' => 0,
210 'processed_records' => 0,
211 'error' => '',
212 'created_at' => current_time('mysql'),
213 'started_at' => null,
214 'completed_at' => null,
215 ];
216
217 update_option(self::JOB_OPTION_PREFIX . $jobId, $jobData, false);
218
219 if (function_exists('as_enqueue_async_action')) {
220 as_enqueue_async_action(self::IMPORT_ACTION, [$jobId], 'yatra');
221 self::kickActionSchedulerQueue();
222 $fresh = self::getJobStatus($jobId);
223 if ($fresh && ($fresh['status'] ?? '') === 'pending') {
224 self::processImportJob($jobId);
225 }
226 } else {
227 self::processImportJob($jobId);
228 }
229
230 Logger::info("Import job created: {$jobId}");
231
232 return $jobId;
233 }
234
235 /**
236 * Get job status
237 *
238 * @param string $jobId Job ID
239 * @return array|null Job data or null if not found
240 */
241 public static function getJobStatus(string $jobId): ?array
242 {
243 $jobData = get_option(self::JOB_OPTION_PREFIX . $jobId);
244 return $jobData ?: null;
245 }
246
247 /**
248 * Update job status
249 *
250 * @param string $jobId Job ID
251 * @param array $updates Fields to update
252 */
253 private static function updateJob(string $jobId, array $updates): void
254 {
255 $jobData = get_option(self::JOB_OPTION_PREFIX . $jobId);
256 if ($jobData) {
257 $jobData = array_merge($jobData, $updates);
258 update_option(self::JOB_OPTION_PREFIX . $jobId, $jobData, false);
259 }
260 }
261
262 /**
263 * Process export job (called by Action Scheduler)
264 *
265 * @param string $jobId Job ID
266 */
267 public static function processExportJob(string $jobId): void
268 {
269 $repository = new ExportImportRepository();
270 global $wpdb;
271
272 $jobData = self::getJobStatus($jobId);
273 if (!$jobData) {
274 Logger::error("Export job not found: {$jobId}");
275 return;
276 }
277
278 $status = $jobData['status'] ?? '';
279 if ($status === 'completed' || $status === 'failed') {
280 return;
281 }
282 if ($status === 'running') {
283 return;
284 }
285 if ($status !== 'pending') {
286 return;
287 }
288
289 // Mark as running
290 self::updateJob($jobId, [
291 'status' => 'running',
292 'started_at' => current_time('mysql'),
293 ]);
294
295 try {
296 $dataTypes = $jobData['data_types'] ?? [];
297 $exportData = [
298 'version' => YATRA_VERSION,
299 'export_date' => current_time('mysql'),
300 'job_id' => $jobId,
301 'data' => []
302 ];
303
304 $processedRecords = 0;
305
306 $expandedTypes = self::expandDataTypesForExport($dataTypes);
307
308 $settingsBundleForExport = null;
309 if ($dataTypes === [] || in_array('settings', $dataTypes, true)) {
310 $settingsBundleForExport = self::collectAllYatraOptionsForExport();
311 }
312 $settingsWeight = $settingsBundleForExport !== null ? max(1, count($settingsBundleForExport)) : 0;
313
314 $totalRecords = self::countExportRecords($expandedTypes) + $settingsWeight;
315 self::updateJob($jobId, ['total_records' => $totalRecords]);
316
317 foreach ($expandedTypes as $dataType) {
318 if ($dataType === 'settings') {
319 continue;
320 }
321
322 if ($dataType === 'itinerary') {
323 $daysTable = TripItineraryDaysTable::getTableName();
324 $entriesTable = TripItineraryDayEntryTable::getTableName();
325 $allDays = [];
326 $allEntries = [];
327
328 if ($repository->tableExists($daysTable)) {
329 $dayTotal = $repository->getRecordCount($daysTable);
330 for ($offset = 0; $offset < $dayTotal; $offset += self::BATCH_SIZE) {
331 $batch = $repository->getBatchRecords($daysTable, $offset, self::BATCH_SIZE);
332 $allDays = array_merge($allDays, $batch);
333 $processedRecords += count($batch);
334 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
335 self::updateJob($jobId, [
336 'processed_records' => $processedRecords,
337 'progress' => $progress,
338 ]);
339 }
340 }
341
342 if ($repository->tableExists($entriesTable)) {
343 $entryTotal = $repository->getRecordCount($entriesTable);
344 for ($offset = 0; $offset < $entryTotal; $offset += self::BATCH_SIZE) {
345 $batch = $repository->getBatchRecords($entriesTable, $offset, self::BATCH_SIZE);
346 $allEntries = array_merge($allEntries, $batch);
347 $processedRecords += count($batch);
348 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
349 self::updateJob($jobId, [
350 'processed_records' => $processedRecords,
351 'progress' => $progress,
352 ]);
353 }
354 }
355
356 $exportData['data']['itinerary'] = [
357 'days' => $allDays,
358 'entries' => $allEntries,
359 ];
360 continue;
361 }
362
363 $classType = self::classificationTypeForDataType($dataType);
364 if ($classType !== null) {
365 $tableName = ClassificationsTable::getTableName();
366 if (!$repository->tableExists($tableName)) {
367 $exportData['data'][$dataType] = [];
368 continue;
369 }
370 $total = $repository->getClassificationCount($tableName, $classType);
371 $records = [];
372 for ($offset = 0; $offset < $total; $offset += self::BATCH_SIZE) {
373 $batch = $repository->getClassificationBatch($tableName, $classType, $offset, self::BATCH_SIZE);
374 $records = array_merge($records, $batch);
375 $processedRecords += count($batch);
376 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
377 self::updateJob($jobId, [
378 'processed_records' => $processedRecords,
379 'progress' => $progress,
380 ]);
381 }
382 $exportData['data'][$dataType] = $records;
383 continue;
384 }
385
386 $tableMap = self::getMergedTableMap();
387 if (!isset($tableMap[$dataType])) {
388 continue;
389 }
390
391 $tableName = $wpdb->prefix . $tableMap[$dataType];
392 if (!$repository->tableExists($tableName)) {
393 $exportData['data'][$dataType] = [];
394 continue;
395 }
396
397 $total = $repository->getRecordCount($tableName);
398 $records = [];
399
400 for ($offset = 0; $offset < $total; $offset += self::BATCH_SIZE) {
401 $batch = $repository->getBatchRecords($tableName, $offset, self::BATCH_SIZE);
402 $records = array_merge($records, $batch);
403 $processedRecords += count($batch);
404
405 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
406 self::updateJob($jobId, [
407 'processed_records' => $processedRecords,
408 'progress' => $progress,
409 ]);
410 }
411
412 $exportData['data'][$dataType] = $records;
413 }
414
415 if ($settingsBundleForExport !== null) {
416 $exportData['data']['settings'] = (array) apply_filters('yatra_export_settings_bundle', $settingsBundleForExport);
417 $processedRecords += $settingsWeight;
418 $progress = $totalRecords > 0 ? min(100, (int) round(($processedRecords / $totalRecords) * 100)) : 100;
419 self::updateJob($jobId, [
420 'processed_records' => $processedRecords,
421 'progress' => $progress,
422 ]);
423 }
424
425 // Write to file
426 $uploadDir = wp_upload_dir();
427 $exportDir = $uploadDir['basedir'] . '/yatra-exports';
428
429 if (!file_exists($exportDir)) {
430 wp_mkdir_p($exportDir);
431 // Add .htaccess to protect directory
432 file_put_contents($exportDir . '/.htaccess', 'deny from all');
433 }
434
435 $filename = 'yatra-export-' . date('Y-m-d-H-i-s') . '-' . substr($jobId, 0, 8) . '.json';
436 $filePath = $exportDir . '/' . $filename;
437 $fileUrl = $uploadDir['baseurl'] . '/yatra-exports/' . $filename;
438
439 file_put_contents($filePath, json_encode($exportData, JSON_PRETTY_PRINT));
440
441 // Mark as completed
442 self::updateJob($jobId, [
443 'status' => 'completed',
444 'progress' => 100,
445 'file_path' => $filePath,
446 'file_url' => $fileUrl,
447 'completed_at' => current_time('mysql'),
448 ]);
449
450 Logger::info("Export job completed: {$jobId}, file: {$filename}");
451
452 } catch (\Exception $e) {
453 self::updateJob($jobId, [
454 'status' => 'failed',
455 'error' => $e->getMessage(),
456 'completed_at' => current_time('mysql'),
457 ]);
458 Logger::error("Export job failed: {$jobId}, error: " . $e->getMessage());
459 }
460 }
461
462 /**
463 * Process import job (called by Action Scheduler)
464 *
465 * @param string $jobId Job ID
466 */
467 public static function processImportJob(string $jobId): void
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
474 $repository = new ExportImportRepository();
475 global $wpdb;
476
477 $jobData = self::getJobStatus($jobId);
478 if (!$jobData) {
479 Logger::error("Import job not found: {$jobId}");
480 return;
481 }
482
483 $status = $jobData['status'] ?? '';
484 if ($status === 'completed' || $status === 'failed') {
485 return;
486 }
487 if ($status === 'running') {
488 return;
489 }
490 if ($status !== 'pending') {
491 return;
492 }
493
494 // Mark as running
495 self::updateJob($jobId, [
496 'status' => 'running',
497 'started_at' => current_time('mysql'),
498 'import_stats' => [], // Initialize import statistics
499 ]);
500
501 try {
502 $filePath = $jobData['file_path'] ?? '';
503 $dataTypes = $jobData['data_types'] ?? [];
504
505 if (!file_exists($filePath)) {
506 throw new \Exception('Import file not found');
507 }
508
509 $content = file_get_contents($filePath);
510 $jsonError = null;
511
512 // Add detailed JSON error logging
513 $importData = json_decode($content, true);
514 switch (json_last_error()) {
515 case JSON_ERROR_NONE:
516 break;
517 case JSON_ERROR_DEPTH:
518 $jsonError = 'Maximum stack depth exceeded';
519 break;
520 case JSON_ERROR_STATE_MISMATCH:
521 $jsonError = 'Underflow or the modes mismatch';
522 break;
523 case JSON_ERROR_CTRL_CHAR:
524 $jsonError = 'Unexpected control character found';
525 break;
526 case JSON_ERROR_SYNTAX:
527 $jsonError = 'Syntax error, malformed JSON';
528 break;
529 case JSON_ERROR_UTF8:
530 $jsonError = 'Malformed UTF-8 characters';
531 break;
532 default:
533 $jsonError = 'Unknown JSON error';
534 break;
535 }
536
537 if ($jsonError) {
538 Logger::error("JSON decode error: {$jsonError}, file: {$filePath}");
539 throw new \Exception('Invalid JSON format: ' . $jsonError);
540 }
541
542 if (!$importData) {
543 Logger::error("Empty import data, file: {$filePath}");
544 throw new \Exception('Empty import data');
545 }
546
547 // Handle both formats: direct data array or wrapped in 'data' key
548 if (isset($importData['data'])) {
549 // Standard format with 'data' wrapper
550 $dataContainer = $importData['data'];
551 } else if (is_array($importData) && !empty($importData)) {
552 // Direct data format without wrapper
553 $dataContainer = $importData;
554 } else {
555 Logger::error("No valid data structure found in import file: {$filePath}");
556 throw new \Exception('Invalid import file format: No data structure found');
557 }
558
559 if (!empty($jobData['import_all'])) {
560 $dataTypes = array_keys($dataContainer);
561 }
562
563 $dataTypes = self::sortImportDataTypes($dataTypes);
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
587 $mapper = new ExportImportIdMapper();
588
589 $totalRecords = 0;
590 foreach ($dataTypes as $dt) {
591 if (!isset($dataContainer[$dt])) {
592 continue;
593 }
594 $payload = $dataContainer[$dt];
595 if ($dt === 'itinerary' && is_array($payload) && isset($payload['days'], $payload['entries']) && is_array($payload['days']) && is_array($payload['entries'])) {
596 $totalRecords += count($payload['days']) + count($payload['entries']);
597 } elseif ($dt === 'settings' && is_array($payload)) {
598 $totalRecords += max(1, count($payload));
599 } elseif (is_array($payload)) {
600 $totalRecords += count($payload);
601 }
602 }
603
604 self::updateJob($jobId, ['total_records' => $totalRecords]);
605
606 $processedRecords = 0;
607 $importStats = [];
608
609 foreach ($dataTypes as $dataType) {
610 if ($dataType === 'settings') {
611 if (!isset($dataContainer['settings']) || !is_array($dataContainer['settings'])) {
612 Logger::warning('Skipping settings: not found or invalid in import file');
613 continue;
614 }
615 $settingsRows = $dataContainer['settings'];
616 self::importSettings($settingsRows);
617 $n = max(1, is_array($settingsRows) ? count($settingsRows) : 0);
618 $importStats['settings'] = ['total' => $n, 'imported' => $n, 'failed' => 0];
619 $processedRecords += $n;
620 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
621 self::updateJob($jobId, [
622 'processed_records' => $processedRecords,
623 'progress' => $progress,
624 ]);
625 continue;
626 }
627
628 if ($dataType === 'itinerary') {
629 if (!isset($dataContainer['itinerary']) || !is_array($dataContainer['itinerary'])) {
630 Logger::warning('Skipping itinerary: not found in import file');
631 continue;
632 }
633 $payload = $dataContainer['itinerary'];
634 if (!isset($payload['days'], $payload['entries']) || !is_array($payload['days']) || !is_array($payload['entries'])) {
635 Logger::warning('Skipping itinerary: expected { days, entries } from Yatra 3 export; legacy flat arrays are not supported');
636 continue;
637 }
638 $daysTable = TripItineraryDaysTable::getTableName();
639 $entriesTable = TripItineraryDayEntryTable::getTableName();
640 $dayTotal = count($payload['days']);
641 $entryTotal = count($payload['entries']);
642 $importStats['itinerary'] = [
643 'total' => $dayTotal + $entryTotal,
644 'imported' => 0,
645 'failed' => 0,
646 'skipped' => 0,
647 ];
648
649 foreach (array_chunk($payload['days'], self::BATCH_SIZE) as $batch) {
650 foreach ($batch as $record) {
651 $record = (array) $record;
652 $oldDayId = (int) ($record['id'] ?? 0);
653 unset($record['id']);
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
668 if (isset($record['trip_id'])) {
669 $mappedTrip = $mapper->map('trips', $record['trip_id']);
670 $record['trip_id'] = $mappedTrip;
671 }
672 if (empty($record['trip_id'])) {
673 $importStats['itinerary']['failed']++;
674 continue;
675 }
676 $tableColumns = $repository->getTableColumns($daysTable);
677 $filteredRecord = [];
678 foreach ($record as $key => $value) {
679 if (in_array($key, $tableColumns, true)) {
680 $filteredRecord[$key] = $value;
681 }
682 }
683 if ($filteredRecord === []) {
684 $importStats['itinerary']['failed']++;
685 continue;
686 }
687 $newId = $repository->insertRecordReturningId($daysTable, $filteredRecord);
688 if ($newId === null) {
689 $importStats['itinerary']['failed']++;
690 continue;
691 }
692 $processedRecords++;
693 $importStats['itinerary']['imported']++;
694 if ($oldDayId > 0) {
695 $mapper->remember('itinerary_days', $oldDayId, $newId);
696 }
697 } catch (\Exception $e) {
698 Logger::error('Itinerary day import error: ' . $e->getMessage());
699 $importStats['itinerary']['failed']++;
700 }
701 }
702 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
703 self::updateJob($jobId, [
704 'processed_records' => $processedRecords,
705 'progress' => $progress,
706 ]);
707 }
708
709 foreach (array_chunk($payload['entries'], self::BATCH_SIZE) as $batch) {
710 foreach ($batch as $record) {
711 $record = (array) $record;
712 unset($record['id']);
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
720 if (isset($record['day_id'])) {
721 $record['day_id'] = $mapper->map('itinerary_days', $record['day_id']);
722 }
723 if (isset($record['trip_id'])) {
724 $record['trip_id'] = $mapper->map('trips', $record['trip_id']);
725 }
726 if (array_key_exists('item_type_id', $record)) {
727 $record['item_type_id'] = $mapper->mapFkNullable('classifications', $record['item_type_id']);
728 }
729 if (array_key_exists('item_id', $record)) {
730 $record['item_id'] = $mapper->mapFkNullable('classifications', $record['item_id']);
731 }
732 if (empty($record['day_id']) || empty($record['trip_id'])) {
733 $importStats['itinerary']['failed']++;
734 continue;
735 }
736 $tableColumns = $repository->getTableColumns($entriesTable);
737 $filteredRecord = [];
738 foreach ($record as $key => $value) {
739 if (in_array($key, $tableColumns, true)) {
740 $filteredRecord[$key] = $value;
741 }
742 }
743 if ($filteredRecord === []) {
744 $importStats['itinerary']['failed']++;
745 continue;
746 }
747 $newId = $repository->insertRecordReturningId($entriesTable, $filteredRecord);
748 if ($newId === null) {
749 $importStats['itinerary']['failed']++;
750 continue;
751 }
752 $processedRecords++;
753 $importStats['itinerary']['imported']++;
754 } catch (\Exception $e) {
755 Logger::error('Itinerary entry import error: ' . $e->getMessage());
756 $importStats['itinerary']['failed']++;
757 }
758 }
759 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
760 self::updateJob($jobId, [
761 'processed_records' => $processedRecords,
762 'progress' => $progress,
763 ]);
764 }
765
766 Logger::info(
767 "Imported itinerary: {$importStats['itinerary']['imported']} ok, {$importStats['itinerary']['failed']} failed"
768 );
769 continue;
770 }
771
772 if (!isset($dataContainer[$dataType]) || !is_array($dataContainer[$dataType])) {
773 Logger::warning("Skipping data type not found in import file: {$dataType}");
774 continue;
775 }
776
777 $mergedMap = self::getMergedTableMap();
778 $classType = self::classificationTypeForDataType($dataType);
779 if ($classType !== null) {
780 $tableName = ClassificationsTable::getTableName();
781 } elseif (isset($mergedMap[$dataType])) {
782 $tableName = $wpdb->prefix . $mergedMap[$dataType];
783 } else {
784 Logger::warning("Skipping unknown import data type: {$dataType}");
785 continue;
786 }
787
788 self::importTableRowsWithMapping(
789 $repository,
790 $mapper,
791 $jobId,
792 $dataType,
793 $tableName,
794 $dataContainer[$dataType],
795 $processedRecords,
796 $totalRecords,
797 $importStats
798 );
799 }
800
801 // Mark as completed with detailed statistics
802 self::updateJob($jobId, [
803 'status' => 'completed',
804 'progress' => 100,
805 'completed_at' => current_time('mysql'),
806 'import_stats' => $importStats,
807 'processed_records' => $processedRecords,
808 'seen_notification' => false, // Flag to track if notification has been seen
809 ]);
810
811 // Clean up import file
812 @unlink($filePath);
813
814 Logger::info("Import job completed: {$jobId}, records: {$processedRecords}, stats: " . json_encode($importStats));
815
816 } catch (\Exception $e) {
817 self::updateJob($jobId, [
818 'status' => 'failed',
819 'error' => $e->getMessage(),
820 'completed_at' => current_time('mysql'),
821 ]);
822 Logger::error("Import job failed: {$jobId}, error: " . $e->getMessage());
823 }
824 }
825
826 /**
827 * @param string[] $dataTypes
828 * @return string[]
829 */
830 private static function normalizeExportDataTypes(array $dataTypes): array
831 {
832 $dataTypes = array_values(array_filter($dataTypes, static function ($t): bool {
833 return is_string($t) && $t !== '';
834 }));
835 if (in_array('all', $dataTypes, true)) {
836 return self::getAllExportableTypeKeys();
837 }
838
839 return array_values(array_unique($dataTypes));
840 }
841
842 /**
843 * @return string[]
844 */
845 private static function getAllExportableTypeKeys(): array
846 {
847 $base = [
848 'settings',
849 'destinations',
850 'activities',
851 'categories',
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',
863 'trips',
864 'itinerary',
865 ];
866 $mergedKeys = array_keys(self::getMergedTableMap());
867 $keys = array_values(array_unique(array_merge($base, $mergedKeys)));
868
869 return array_values(array_unique((array) apply_filters('yatra_export_all_data_types', $keys)));
870 }
871
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 /**
1090 * @param array<int, mixed> $records
1091 * @param array<string, array{total: int, imported: int, failed: int}> $importStats
1092 */
1093 private static function importTableRowsWithMapping(
1094 ExportImportRepository $repository,
1095 ExportImportIdMapper $mapper,
1096 string $jobId,
1097 string $dataType,
1098 string $tableName,
1099 array $records,
1100 int &$processedRecords,
1101 int $totalRecords,
1102 array &$importStats
1103 ): void {
1104 global $wpdb;
1105
1106 $importStats[$dataType] = [
1107 'total' => count($records),
1108 'imported' => 0,
1109 'failed' => 0,
1110 'skipped' => 0,
1111 ];
1112 Logger::info('Importing ' . $dataType . ': Found ' . count($records) . ' records');
1113
1114 foreach (array_chunk($records, self::BATCH_SIZE) as $batch) {
1115 foreach ($batch as $record) {
1116 $record = (array) $record;
1117 $oldId = (int) ($record['id'] ?? 0);
1118 unset($record['id']);
1119
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
1141 self::applyForeignKeyRemapping($mapper, $dataType, $record);
1142
1143 if ($dataType === 'bookings' && isset($record['reference']) && $record['reference'] !== '') {
1144 $record['reference'] = self::ensureUniqueBookingReference((string) $record['reference']);
1145 }
1146
1147 if (in_array($dataType, ['dynamic_pricing_rules', 'email_sequences'], true)) {
1148 if (array_key_exists('trip_ids', $record) && $record['trip_ids'] !== null && $record['trip_ids'] !== '') {
1149 $record['trip_ids'] = self::remapTripIdsTextField($mapper, (string) $record['trip_ids']);
1150 }
1151 }
1152
1153 if ($dataType === 'consent_requests' && isset($record['token']) && $record['token'] !== '') {
1154 $record['token'] = self::ensureUniqueConsentRequestToken((string) $record['token']);
1155 }
1156
1157 if ($dataType === 'email_templates' && isset($record['template_key']) && $record['template_key'] !== '') {
1158 $record['template_key'] = self::ensureUniqueEmailTemplateKey((string) $record['template_key']);
1159 }
1160
1161 if ($dataType === 'discounts') {
1162 if (array_key_exists('trip_ids', $record)) {
1163 $tripIdsVal = $record['trip_ids'];
1164 $record['trip_ids'] = self::remapDiscountTripIdsField(
1165 $mapper,
1166 $tripIdsVal === null ? null : (string) $tripIdsVal
1167 );
1168 }
1169 if (isset($record['code']) && $record['code'] !== '') {
1170 $record['code'] = self::ensureUniqueDiscountCode((string) $record['code']);
1171 }
1172 }
1173
1174 if (self::rowHasInvalidRequiredFks($dataType, $record)) {
1175 $importStats[$dataType]['failed']++;
1176 continue;
1177 }
1178
1179 $tableColumns = $repository->getTableColumns($tableName);
1180 $filteredRecord = [];
1181 foreach ($record as $key => $value) {
1182 if (in_array($key, $tableColumns, true)) {
1183 $filteredRecord[$key] = $value;
1184 }
1185 }
1186
1187 if ($filteredRecord === []) {
1188 $importStats[$dataType]['failed']++;
1189 continue;
1190 }
1191
1192 if (isset($filteredRecord['slug'])) {
1193 $suffix = str_replace($wpdb->prefix, '', $tableName);
1194 $filteredRecord['slug'] = \Yatra\Helpers\SlugHelper::generateUniqueFromDatabase(
1195 (string) $filteredRecord['slug'],
1196 $suffix,
1197 'slug'
1198 );
1199 }
1200
1201 $newId = $repository->insertRecordReturningId($tableName, $filteredRecord);
1202 if ($newId === null) {
1203 $importStats[$dataType]['failed']++;
1204 continue;
1205 }
1206
1207 $processedRecords++;
1208 $importStats[$dataType]['imported']++;
1209
1210 $entity = self::entityKeyForDataType($dataType);
1211 if ($entity !== null && $oldId > 0) {
1212 $mapper->remember($entity, $oldId, $newId);
1213 }
1214 } catch (\Exception $e) {
1215 Logger::error('Error importing ' . $dataType . ': ' . $e->getMessage());
1216 $importStats[$dataType]['failed']++;
1217 }
1218 }
1219
1220 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
1221 self::updateJob($jobId, [
1222 'processed_records' => $processedRecords,
1223 'progress' => $progress,
1224 ]);
1225 }
1226 }
1227
1228 private static function entityKeyForDataType(string $dataType): ?string
1229 {
1230 switch ($dataType) {
1231 case 'destinations':
1232 case 'activities':
1233 case 'categories':
1234 case 'difficulty_levels':
1235 case 'traveler_categories':
1236 case 'attributes':
1237 case 'itinerary_item_types':
1238 case 'itinerary_items':
1239 return 'classifications';
1240 case 'trips':
1241 return 'trips';
1242 case 'customers':
1243 return 'customers';
1244 case 'bookings':
1245 return 'bookings';
1246 case 'payments':
1247 return 'payments';
1248 case 'availability':
1249 return 'availability';
1250 case 'departures':
1251 return 'departures';
1252 case 'travelers':
1253 return 'travelers';
1254 case 'discounts':
1255 return 'discounts';
1256 case 'additional_service_catalog':
1257 return 'services';
1258 case 'consent_forms':
1259 return 'consent_forms';
1260 case 'signed_consents':
1261 return 'signed_consents';
1262 case 'consent_requests':
1263 return 'consent_requests';
1264 case 'dynamic_pricing_rules':
1265 return 'dynamic_pricing_rules';
1266 case 'abandoned_bookings':
1267 return 'abandoned_bookings';
1268 case 'email_templates':
1269 return 'email_templates';
1270 case 'email_sequences':
1271 return 'email_sequences';
1272 case 'email_sequence_steps':
1273 return 'email_sequence_steps';
1274 default:
1275 return null;
1276 }
1277 }
1278
1279 private static function rowHasInvalidRequiredFks(string $dataType, array $row): bool
1280 {
1281 switch ($dataType) {
1282 case 'trip_classifications':
1283 return empty($row['trip_id'] ?? null) || empty($row['classification_id'] ?? null);
1284 case 'trip_content':
1285 case 'trip_revisions':
1286 case 'availability_rules':
1287 case 'availability':
1288 case 'departures':
1289 case 'trip_additional_services':
1290 return empty($row['trip_id'] ?? null);
1291 case 'trip_consent_forms':
1292 return empty($row['trip_id'] ?? null) || empty($row['form_id'] ?? null);
1293 case 'pricing_history':
1294 case 'trip_demand_scores':
1295 return empty($row['trip_id'] ?? null);
1296 case 'consent_requests':
1297 return empty($row['form_id'] ?? null) || empty($row['booking_id'] ?? null);
1298 case 'signed_consents':
1299 return empty($row['form_id'] ?? null);
1300 case 'abandoned_bookings':
1301 return empty($row['trip_id'] ?? null);
1302 case 'recovery_email_logs':
1303 return empty($row['abandoned_booking_id'] ?? null);
1304 case 'email_sequence_steps':
1305 return empty($row['sequence_id'] ?? null);
1306 case 'booking_departures':
1307 case 'travelers':
1308 case 'payments':
1309 return empty($row['booking_id'] ?? null);
1310 case 'traveler_meta':
1311 return empty($row['traveller_id'] ?? null);
1312 case 'booking_additional_services':
1313 return empty($row['booking_id'] ?? null) || empty($row['service_id'] ?? null);
1314 case 'bookings':
1315 return empty($row['trip_id'] ?? null);
1316 case 'reviews':
1317 return empty($row['trip_id'] ?? null);
1318 default:
1319 return false;
1320 }
1321 }
1322
1323 private static function applyForeignKeyRemapping(ExportImportIdMapper $m, string $dataType, array &$row): void
1324 {
1325 switch ($dataType) {
1326 case 'trips':
1327 if (array_key_exists('difficulty_level', $row)) {
1328 $row['difficulty_level'] = $m->mapFkNullable('classifications', $row['difficulty_level']);
1329 }
1330 break;
1331 case 'trip_classifications':
1332 if (isset($row['trip_id'])) {
1333 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1334 }
1335 if (isset($row['classification_id'])) {
1336 $row['classification_id'] = $m->map('classifications', $row['classification_id']);
1337 }
1338 break;
1339 case 'trip_content':
1340 case 'trip_revisions':
1341 if (isset($row['trip_id'])) {
1342 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1343 }
1344 break;
1345 case 'availability_rules':
1346 case 'availability':
1347 if (isset($row['trip_id'])) {
1348 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1349 }
1350 break;
1351 case 'departures':
1352 if (isset($row['trip_id'])) {
1353 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1354 }
1355 break;
1356 case 'trip_additional_services':
1357 if (isset($row['trip_id'])) {
1358 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1359 }
1360 if (isset($row['service_id'])) {
1361 $row['service_id'] = $m->map('services', $row['service_id']);
1362 }
1363 break;
1364 case 'bookings':
1365 if (isset($row['trip_id'])) {
1366 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1367 }
1368 if (array_key_exists('customer_id', $row)) {
1369 $row['customer_id'] = $m->mapFkNullable('customers', $row['customer_id']);
1370 }
1371 if (array_key_exists('availability_id', $row)) {
1372 $row['availability_id'] = $m->mapFkNullable('availability', $row['availability_id']);
1373 }
1374 break;
1375 case 'booking_departures':
1376 if (isset($row['booking_id'])) {
1377 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1378 }
1379 if (isset($row['departure_id'])) {
1380 $row['departure_id'] = $m->map('departures', $row['departure_id']);
1381 }
1382 break;
1383 case 'booking_additional_services':
1384 if (isset($row['booking_id'])) {
1385 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1386 }
1387 if (isset($row['service_id'])) {
1388 $row['service_id'] = $m->map('services', $row['service_id']);
1389 }
1390 break;
1391 case 'travelers':
1392 if (isset($row['booking_id'])) {
1393 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1394 }
1395 break;
1396 case 'traveler_meta':
1397 if (isset($row['traveller_id'])) {
1398 $row['traveller_id'] = $m->map('travelers', $row['traveller_id']);
1399 }
1400 break;
1401 case 'payments':
1402 if (isset($row['booking_id'])) {
1403 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1404 }
1405 if (array_key_exists('customer_id', $row)) {
1406 $row['customer_id'] = $m->mapFkNullable('customers', $row['customer_id']);
1407 }
1408 break;
1409 case 'google_calendar_events':
1410 if (isset($row['booking_id']) && (int) $row['booking_id'] !== 0) {
1411 $mapped = $m->map('bookings', $row['booking_id']);
1412 $row['booking_id'] = $mapped ?? 0;
1413 }
1414 if (array_key_exists('departure_id', $row)) {
1415 $row['departure_id'] = $m->mapFkNullable('departures', $row['departure_id']);
1416 }
1417 break;
1418 case 'reviews':
1419 if (isset($row['trip_id'])) {
1420 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1421 }
1422 break;
1423 case 'enquiries':
1424 if (array_key_exists('trip_id', $row)) {
1425 $row['trip_id'] = $m->mapFkNullable('trips', $row['trip_id']);
1426 }
1427 break;
1428 case 'destinations':
1429 case 'activities':
1430 case 'categories':
1431 case 'difficulty_levels':
1432 if (array_key_exists('parent_id', $row)) {
1433 $row['parent_id'] = $m->mapFkNullable('classifications', $row['parent_id']);
1434 }
1435 break;
1436 case 'trip_consent_forms':
1437 if (isset($row['trip_id'])) {
1438 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1439 }
1440 if (isset($row['form_id'])) {
1441 $row['form_id'] = $m->map('consent_forms', $row['form_id']);
1442 }
1443 break;
1444 case 'signed_consents':
1445 if (isset($row['form_id'])) {
1446 $row['form_id'] = $m->map('consent_forms', $row['form_id']);
1447 }
1448 if (array_key_exists('booking_id', $row)) {
1449 $row['booking_id'] = $m->mapFkNullable('bookings', $row['booking_id']);
1450 }
1451 break;
1452 case 'consent_requests':
1453 if (isset($row['form_id'])) {
1454 $row['form_id'] = $m->map('consent_forms', $row['form_id']);
1455 }
1456 if (isset($row['booking_id'])) {
1457 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1458 }
1459 if (array_key_exists('signed_consent_id', $row)) {
1460 $row['signed_consent_id'] = $m->mapFkNullable('signed_consents', $row['signed_consent_id']);
1461 }
1462 break;
1463 case 'pricing_history':
1464 case 'trip_demand_scores':
1465 if (isset($row['trip_id'])) {
1466 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1467 }
1468 break;
1469 case 'abandoned_bookings':
1470 if (isset($row['trip_id'])) {
1471 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1472 }
1473 if (array_key_exists('recovered_booking_id', $row)) {
1474 $row['recovered_booking_id'] = $m->mapFkNullable('bookings', $row['recovered_booking_id']);
1475 }
1476 break;
1477 case 'recovery_email_logs':
1478 if (isset($row['abandoned_booking_id'])) {
1479 $row['abandoned_booking_id'] = $m->map('abandoned_bookings', $row['abandoned_booking_id']);
1480 }
1481 break;
1482 case 'email_sequence_steps':
1483 if (isset($row['sequence_id'])) {
1484 $row['sequence_id'] = $m->map('email_sequences', $row['sequence_id']);
1485 }
1486 if (array_key_exists('template_id', $row)) {
1487 $row['template_id'] = $m->mapFkNullable('email_templates', $row['template_id']);
1488 }
1489 break;
1490 case 'email_queue':
1491 if (array_key_exists('sequence_id', $row)) {
1492 $row['sequence_id'] = $m->mapFkNullable('email_sequences', $row['sequence_id']);
1493 }
1494 if (array_key_exists('step_id', $row)) {
1495 $row['step_id'] = $m->mapFkNullable('email_sequence_steps', $row['step_id']);
1496 }
1497 if (array_key_exists('template_id', $row)) {
1498 $row['template_id'] = $m->mapFkNullable('email_templates', $row['template_id']);
1499 }
1500 break;
1501 case 'email_logs':
1502 if (array_key_exists('template_id', $row)) {
1503 $row['template_id'] = $m->mapFkNullable('email_templates', $row['template_id']);
1504 }
1505 if (array_key_exists('sequence_id', $row)) {
1506 $row['sequence_id'] = $m->mapFkNullable('email_sequences', $row['sequence_id']);
1507 }
1508 break;
1509 default:
1510 break;
1511 }
1512 }
1513
1514 /**
1515 * Remap trip id lists stored as JSON array, comma-separated ids, or a single id (Pro + discounts).
1516 */
1517 private static function remapTripIdsTextField(ExportImportIdMapper $m, string $value): string
1518 {
1519 $trimmed = trim($value);
1520 if ($trimmed === '' || $trimmed === '[]') {
1521 return $value;
1522 }
1523
1524 $decoded = json_decode($trimmed, true);
1525 if (is_array($decoded)) {
1526 $out = [];
1527 foreach ($decoded as $tid) {
1528 $new = $m->map('trips', $tid);
1529 if ($new !== null) {
1530 $out[] = $new;
1531 }
1532 }
1533
1534 return json_encode($out);
1535 }
1536
1537 if (strpos($trimmed, ',') !== false) {
1538 $parts = preg_split('/\s*,\s*/', $trimmed) ?: [];
1539 $out = [];
1540 foreach ($parts as $p) {
1541 if ($p === '') {
1542 continue;
1543 }
1544 $new = $m->map('trips', $p);
1545 if ($new !== null) {
1546 $out[] = (string) $new;
1547 }
1548 }
1549
1550 return implode(',', $out);
1551 }
1552
1553 $single = $m->map('trips', $trimmed);
1554
1555 return $single !== null ? (string) $single : '';
1556 }
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
1583 private static function ensureUniqueConsentRequestToken(string $token): string
1584 {
1585 global $wpdb;
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
1593 $base = $token;
1594 $candidate = $base;
1595 for ($n = 0; $n < 5000; $n++) {
1596 $exists = (int) $wpdb->get_var(
1597 $wpdb->prepare(
1598 "SELECT COUNT(*) FROM `{$table}` WHERE `token` = %s",
1599 $candidate
1600 )
1601 );
1602 if ($exists === 0) {
1603 return $candidate;
1604 }
1605 $candidate = $base . '-' . wp_generate_password(8, false);
1606 }
1607
1608 return $base . '-' . wp_generate_password(12, false);
1609 }
1610
1611 private static function ensureUniqueEmailTemplateKey(string $key): string
1612 {
1613 global $wpdb;
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
1621 $base = $key;
1622 $candidate = $base;
1623 for ($n = 0; $n < 5000; $n++) {
1624 $exists = (int) $wpdb->get_var(
1625 $wpdb->prepare(
1626 "SELECT COUNT(*) FROM `{$table}` WHERE `template_key` = %s",
1627 $candidate
1628 )
1629 );
1630 if ($exists === 0) {
1631 return $candidate;
1632 }
1633 $candidate = $base . '-i' . ($n + 1);
1634 }
1635
1636 return $base . '-' . wp_generate_password(6, false);
1637 }
1638
1639 private static function ensureUniqueDiscountCode(string $code): string
1640 {
1641 global $wpdb;
1642 $table = DiscountsTable::getTableName();
1643 $base = $code;
1644 $candidate = $base;
1645 for ($n = 0; $n < 5000; $n++) {
1646 $exists = (int) $wpdb->get_var(
1647 $wpdb->prepare(
1648 "SELECT COUNT(*) FROM `{$table}` WHERE `code` = %s",
1649 $candidate
1650 )
1651 );
1652 if ($exists === 0) {
1653 return $candidate;
1654 }
1655 $candidate = $base . '-i' . ($n + 1);
1656 }
1657
1658 return $base . '-' . wp_generate_password(6, false);
1659 }
1660
1661 private static function ensureUniqueBookingReference(string $reference): string
1662 {
1663 global $wpdb;
1664 $table = BookingsTable::getTableName();
1665 $base = $reference;
1666 $candidate = $base;
1667 for ($n = 0; $n < 5000; $n++) {
1668 $exists = (int) $wpdb->get_var(
1669 $wpdb->prepare(
1670 "SELECT COUNT(*) FROM `{$table}` WHERE `reference` = %s",
1671 $candidate
1672 )
1673 );
1674 if ($exists === 0) {
1675 return $candidate;
1676 }
1677 $candidate = $base . '-i' . ($n + 1);
1678 }
1679
1680 return $base . '-' . wp_generate_password(6, false);
1681 }
1682
1683 private static function remapDiscountTripIdsField(ExportImportIdMapper $m, ?string $tripIdsJson): ?string
1684 {
1685 if ($tripIdsJson === null || $tripIdsJson === '') {
1686 return $tripIdsJson;
1687 }
1688 $decoded = json_decode($tripIdsJson, true);
1689 if (!is_array($decoded)) {
1690 return $tripIdsJson;
1691 }
1692
1693 return self::remapTripIdsTextField($m, $tripIdsJson);
1694 }
1695
1696 private static function classificationTypeForDataType(string $dataType): ?string
1697 {
1698 switch ($dataType) {
1699 case 'destinations':
1700 return ClassificationTypes::DESTINATION;
1701 case 'activities':
1702 return ClassificationTypes::ACTIVITY;
1703 case 'categories':
1704 return ClassificationTypes::CATEGORY;
1705 case 'difficulty_levels':
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;
1715 default:
1716 return null;
1717 }
1718 }
1719
1720 /**
1721 * @param string[] $dataTypes
1722 * @return string[]
1723 */
1724 private static function expandDataTypesForExport(array $dataTypes): array
1725 {
1726 $out = array_values(array_unique($dataTypes));
1727
1728 if (in_array('trips', $out, true)) {
1729 foreach (['trip_classifications', 'trip_content', 'trip_revisions'] as $extra) {
1730 if (!in_array($extra, $out, true)) {
1731 $out[] = $extra;
1732 }
1733 }
1734 }
1735 if (in_array('travelers', $out, true) && !in_array('traveler_meta', $out, true)) {
1736 $out[] = 'traveler_meta';
1737 }
1738 if (in_array('bookings', $out, true) && !in_array('booking_departures', $out, true)) {
1739 $out[] = 'booking_departures';
1740 }
1741 if (in_array('availability', $out, true) && !in_array('availability_rules', $out, true)) {
1742 $out[] = 'availability_rules';
1743 }
1744 return apply_filters('yatra_export_import_expand_types', $out, $dataTypes);
1745 }
1746
1747 /**
1748 * Best-effort ordering so parents (classifications, trips) import before dependents.
1749 *
1750 * @param string[] $dataTypes
1751 * @return string[]
1752 */
1753 private static function sortImportDataTypes(array $dataTypes): array
1754 {
1755 $order = [
1756 'settings',
1757 'destinations',
1758 'activities',
1759 'categories',
1760 'difficulty_levels',
1761 'traveler_categories',
1762 'attributes',
1763 'itinerary_item_types',
1764 'itinerary_items',
1765 'additional_service_catalog',
1766 'consent_forms',
1767 'email_templates',
1768 'email_sequences',
1769 'email_sequence_steps',
1770 'trips',
1771 'trip_content',
1772 'trip_classifications',
1773 'trip_revisions',
1774 'itinerary',
1775 'availability_rules',
1776 'availability',
1777 'trip_additional_services',
1778 'trip_consent_forms',
1779 'dynamic_pricing_rules',
1780 'trip_demand_scores',
1781 'pricing_history',
1782 'departures',
1783 'customers',
1784 'bookings',
1785 'booking_departures',
1786 'booking_additional_services',
1787 'signed_consents',
1788 'consent_requests',
1789 'abandoned_bookings',
1790 'recovery_email_logs',
1791 'recovery_statistics',
1792 'email_queue',
1793 'email_logs',
1794 'travelers',
1795 'traveler_meta',
1796 'payments',
1797 'google_calendar_events',
1798 'reviews',
1799 'enquiries',
1800 'discounts',
1801 ];
1802 $dataTypes = array_values(array_unique($dataTypes));
1803 usort($dataTypes, static function (string $a, string $b) use ($order): int {
1804 $ia = array_search($a, $order, true);
1805 $ib = array_search($b, $order, true);
1806 $ia = $ia === false ? 999 : $ia;
1807 $ib = $ib === false ? 999 : $ib;
1808
1809 return $ia <=> $ib;
1810 });
1811
1812 return $dataTypes;
1813 }
1814
1815 /**
1816 * Count total records to export (must stay aligned with {@see processExportJob()}).
1817 */
1818 private static function countExportRecords(array $dataTypes): int
1819 {
1820 global $wpdb;
1821 $repository = new ExportImportRepository();
1822 $total = 0;
1823
1824 foreach ($dataTypes as $dataType) {
1825 if ($dataType === 'settings') {
1826 continue;
1827 }
1828 if ($dataType === 'itinerary') {
1829 $daysTable = TripItineraryDaysTable::getTableName();
1830 $entriesTable = TripItineraryDayEntryTable::getTableName();
1831 if ($repository->tableExists($daysTable)) {
1832 $total += $repository->getRecordCount($daysTable);
1833 }
1834 if ($repository->tableExists($entriesTable)) {
1835 $total += $repository->getRecordCount($entriesTable);
1836 }
1837 continue;
1838 }
1839 $classType = self::classificationTypeForDataType($dataType);
1840 if ($classType !== null) {
1841 $tableName = ClassificationsTable::getTableName();
1842 if ($repository->tableExists($tableName)) {
1843 $total += $repository->getClassificationCount($tableName, $classType);
1844 }
1845 continue;
1846 }
1847 $merged = self::getMergedTableMap();
1848 if (!isset($merged[$dataType])) {
1849 continue;
1850 }
1851 $tableName = $wpdb->prefix . $merged[$dataType];
1852 if ($repository->tableExists($tableName)) {
1853 $total += $repository->getRecordCount($tableName);
1854 }
1855 }
1856
1857 return $total;
1858 }
1859
1860 /**
1861 * @return array<string, mixed>
1862 */
1863 private static function collectAllYatraOptionsForExport(): array
1864 {
1865 global $wpdb;
1866
1867 $rows = $wpdb->get_results(
1868 "SELECT option_name, option_value FROM {$wpdb->options}
1869 WHERE option_name LIKE 'yatra_%'
1870 AND option_name NOT LIKE 'yatra_job_%'
1871 AND option_name NOT LIKE 'yatra_migration_%'"
1872 );
1873 if (!is_array($rows)) {
1874 return [];
1875 }
1876
1877 $out = [];
1878 foreach ($rows as $row) {
1879 $name = (string) $row->option_name;
1880 if (strpos($name, 'yatra_transient') === 0) {
1881 continue;
1882 }
1883 $out[$name] = maybe_unserialize($row->option_value);
1884 }
1885
1886 return $out;
1887 }
1888
1889 /**
1890 * Import settings
1891 */
1892 private static function importSettings(array $settings): void
1893 {
1894 $permalinkChanged = false;
1895
1896 foreach ($settings as $key => $value) {
1897 if (!is_string($key) || strpos($key, 'yatra_') !== 0) {
1898 continue;
1899 }
1900 if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $key)) {
1901 continue;
1902 }
1903
1904 if (!$permalinkChanged && self::settingAffectsPermalinks($key)) {
1905 $existing = get_option($key, null);
1906 if ($existing !== $value) {
1907 $permalinkChanged = true;
1908 }
1909 }
1910
1911 update_option($key, $value);
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);
1942 }
1943
1944 /**
1945 * Delete a job and its associated files
1946 *
1947 * @param string $jobId Job ID
1948 */
1949 public static function deleteJob(string $jobId): bool
1950 {
1951 $jobData = self::getJobStatus($jobId);
1952
1953 if (!$jobData) {
1954 return false;
1955 }
1956
1957 // Delete export file if exists
1958 if (!empty($jobData['file_path'])) {
1959 if (file_exists($jobData['file_path'])) {
1960 $deleted = unlink($jobData['file_path']);
1961 if (!$deleted) {
1962 Logger::error("Failed to delete export file: {$jobData['file_path']}");
1963 } else {
1964 Logger::info("Successfully deleted export file: {$jobData['file_path']}");
1965 }
1966 } else {
1967 Logger::warning("Export file not found for deletion: {$jobData['file_path']}");
1968 }
1969 }
1970
1971 // Instead of deleting the option, mark it as deleted
1972 // This ensures it won't show up in active jobs but will be cleaned up later
1973 $jobData['status'] = 'deleted';
1974 $jobData['deleted_at'] = current_time('mysql');
1975 update_option(self::JOB_OPTION_PREFIX . $jobId, $jobData, false);
1976
1977 return true;
1978 }
1979
1980 /**
1981 * Get active jobs for a user (pending, running, or recently completed)
1982 *
1983 * @param int $userId User ID
1984 * @return array List of active jobs
1985 */
1986 public static function getActiveJobs(int $userId): array
1987 {
1988 $repository = new ExportImportRepository();
1989
1990 $options = $repository->getAllJobOptions();
1991
1992 $jobs = [];
1993 $cutoff = strtotime('-1 hour'); // Show jobs from last hour
1994
1995 foreach ($options as $option) {
1996 $jobData = maybe_unserialize($option->option_value);
1997
1998 if (!is_array($jobData)) {
1999 continue;
2000 }
2001
2002 // Filter by user
2003 if (($jobData['user_id'] ?? 0) !== $userId) {
2004 continue;
2005 }
2006
2007 // Only include pending/running jobs
2008 // Completed jobs should not be shown again after page refresh
2009 $status = $jobData['status'] ?? '';
2010
2011 if ($status === 'pending' || $status === 'running') {
2012 $jobs[] = $jobData;
2013 }
2014 }
2015
2016 // Sort by created_at descending
2017 usort($jobs, function($a, $b) {
2018 return strtotime($b['created_at'] ?? '0') - strtotime($a['created_at'] ?? '0');
2019 });
2020
2021 return $jobs;
2022 }
2023
2024 /**
2025 * Clean up old completed jobs (older than 24 hours)
2026 */
2027 public static function cleanupOldJobs(): void
2028 {
2029 $repository = new ExportImportRepository();
2030
2031 $options = $repository->getAllJobOptions();
2032
2033 $cutoff = strtotime('-24 hours');
2034
2035 foreach ($options as $option) {
2036 $jobData = maybe_unserialize($option->option_value);
2037
2038 if (!is_array($jobData)) {
2039 continue;
2040 }
2041
2042 $completedAt = $jobData['completed_at'] ?? null;
2043
2044 if ($completedAt && strtotime($completedAt) < $cutoff) {
2045 $jobId = str_replace(self::JOB_OPTION_PREFIX, '', $option->option_name);
2046 self::deleteJob($jobId);
2047 }
2048 }
2049 }
2050
2051 /**
2052 * Export data from a specific table with batch processing
2053 */
2054 private static function exportTableData(string $table_name, int $batch_size): array
2055 {
2056 $repository = new ExportImportRepository();
2057
2058 // Check if table exists
2059 $table_exists = $repository->tableExists($table_name);
2060 if (!$table_exists) {
2061 return [];
2062 }
2063
2064 // Get total records
2065 $total_records = $repository->getRecordCount($table_name);
2066 if ($total_records === 0) {
2067 return [];
2068 }
2069
2070 $data = [];
2071
2072 // Process in batches to avoid memory issues
2073 for ($offset = 0; $offset < $total_records; $offset += $batch_size) {
2074 $batch = $repository->getBatchRecords($table_name, $offset, $batch_size);
2075
2076 if ($batch) {
2077 $data = array_merge($data, $batch);
2078 }
2079 }
2080
2081 return $data;
2082 }
2083
2084 /**
2085 * Get export summary statistics
2086 */
2087 public static function getExportSummary(): array
2088 {
2089 $tables = [];
2090
2091 $summary = [
2092 'total_tables' => 0,
2093 'existing_tables' => 0,
2094 'total_records' => 0,
2095 'tables' => $tables
2096 ];
2097
2098 foreach ($tables as $table) {
2099 $summary['total_tables']++;
2100 if ($table['exists']) {
2101 $summary['existing_tables']++;
2102 $summary['total_records'] += $table['record_count'];
2103 }
2104 }
2105
2106 return $summary;
2107 }
2108 }
2109