PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5
Yatra – Travel Booking & Tour Operator Software v3.0.5
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 2.0.1 2.0.10 2.0.11 All 82 releases
yatra / app / Services / ExportImportService.php

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

1,722 lines 65.0 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 $repository = new ExportImportRepository();
470 global $wpdb;
471
472 $jobData = self::getJobStatus($jobId);
473 if (!$jobData) {
474 Logger::error("Import job not found: {$jobId}");
475 return;
476 }
477
478 $status = $jobData['status'] ?? '';
479 if ($status === 'completed' || $status === 'failed') {
480 return;
481 }
482 if ($status === 'running') {
483 return;
484 }
485 if ($status !== 'pending') {
486 return;
487 }
488
489 // Mark as running
490 self::updateJob($jobId, [
491 'status' => 'running',
492 'started_at' => current_time('mysql'),
493 'import_stats' => [], // Initialize import statistics
494 ]);
495
496 try {
497 $filePath = $jobData['file_path'] ?? '';
498 $dataTypes = $jobData['data_types'] ?? [];
499
500 if (!file_exists($filePath)) {
501 throw new \Exception('Import file not found');
502 }
503
504 $content = file_get_contents($filePath);
505 $jsonError = null;
506
507 // Add detailed JSON error logging
508 $importData = json_decode($content, true);
509 switch (json_last_error()) {
510 case JSON_ERROR_NONE:
511 break;
512 case JSON_ERROR_DEPTH:
513 $jsonError = 'Maximum stack depth exceeded';
514 break;
515 case JSON_ERROR_STATE_MISMATCH:
516 $jsonError = 'Underflow or the modes mismatch';
517 break;
518 case JSON_ERROR_CTRL_CHAR:
519 $jsonError = 'Unexpected control character found';
520 break;
521 case JSON_ERROR_SYNTAX:
522 $jsonError = 'Syntax error, malformed JSON';
523 break;
524 case JSON_ERROR_UTF8:
525 $jsonError = 'Malformed UTF-8 characters';
526 break;
527 default:
528 $jsonError = 'Unknown JSON error';
529 break;
530 }
531
532 if ($jsonError) {
533 Logger::error("JSON decode error: {$jsonError}, file: {$filePath}");
534 throw new \Exception('Invalid JSON format: ' . $jsonError);
535 }
536
537 if (!$importData) {
538 Logger::error("Empty import data, file: {$filePath}");
539 throw new \Exception('Empty import data');
540 }
541
542 // Handle both formats: direct data array or wrapped in 'data' key
543 if (isset($importData['data'])) {
544 // Standard format with 'data' wrapper
545 $dataContainer = $importData['data'];
546 } else if (is_array($importData) && !empty($importData)) {
547 // Direct data format without wrapper
548 $dataContainer = $importData;
549 } else {
550 Logger::error("No valid data structure found in import file: {$filePath}");
551 throw new \Exception('Invalid import file format: No data structure found');
552 }
553
554 if (!empty($jobData['import_all'])) {
555 $dataTypes = array_keys($dataContainer);
556 }
557
558 $dataTypes = self::sortImportDataTypes($dataTypes);
559
560 $mapper = new ExportImportIdMapper();
561
562 $totalRecords = 0;
563 foreach ($dataTypes as $dt) {
564 if (!isset($dataContainer[$dt])) {
565 continue;
566 }
567 $payload = $dataContainer[$dt];
568 if ($dt === 'itinerary' && is_array($payload) && isset($payload['days'], $payload['entries']) && is_array($payload['days']) && is_array($payload['entries'])) {
569 $totalRecords += count($payload['days']) + count($payload['entries']);
570 } elseif ($dt === 'settings' && is_array($payload)) {
571 $totalRecords += max(1, count($payload));
572 } elseif (is_array($payload)) {
573 $totalRecords += count($payload);
574 }
575 }
576
577 self::updateJob($jobId, ['total_records' => $totalRecords]);
578
579 $processedRecords = 0;
580 $importStats = [];
581
582 foreach ($dataTypes as $dataType) {
583 if ($dataType === 'settings') {
584 if (!isset($dataContainer['settings']) || !is_array($dataContainer['settings'])) {
585 Logger::warning('Skipping settings: not found or invalid in import file');
586 continue;
587 }
588 $settingsRows = $dataContainer['settings'];
589 self::importSettings($settingsRows);
590 $n = max(1, is_array($settingsRows) ? count($settingsRows) : 0);
591 $importStats['settings'] = ['total' => $n, 'imported' => $n, 'failed' => 0];
592 $processedRecords += $n;
593 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
594 self::updateJob($jobId, [
595 'processed_records' => $processedRecords,
596 'progress' => $progress,
597 ]);
598 continue;
599 }
600
601 if ($dataType === 'itinerary') {
602 if (!isset($dataContainer['itinerary']) || !is_array($dataContainer['itinerary'])) {
603 Logger::warning('Skipping itinerary: not found in import file');
604 continue;
605 }
606 $payload = $dataContainer['itinerary'];
607 if (!isset($payload['days'], $payload['entries']) || !is_array($payload['days']) || !is_array($payload['entries'])) {
608 Logger::warning('Skipping itinerary: expected { days, entries } from Yatra 3 export; legacy flat arrays are not supported');
609 continue;
610 }
611 $daysTable = TripItineraryDaysTable::getTableName();
612 $entriesTable = TripItineraryDayEntryTable::getTableName();
613 $dayTotal = count($payload['days']);
614 $entryTotal = count($payload['entries']);
615 $importStats['itinerary'] = [
616 'total' => $dayTotal + $entryTotal,
617 'imported' => 0,
618 'failed' => 0,
619 ];
620
621 foreach (array_chunk($payload['days'], self::BATCH_SIZE) as $batch) {
622 foreach ($batch as $record) {
623 $record = (array) $record;
624 $oldDayId = (int) ($record['id'] ?? 0);
625 unset($record['id']);
626 try {
627 if (isset($record['trip_id'])) {
628 $mappedTrip = $mapper->map('trips', $record['trip_id']);
629 $record['trip_id'] = $mappedTrip;
630 }
631 if (empty($record['trip_id'])) {
632 $importStats['itinerary']['failed']++;
633 continue;
634 }
635 $tableColumns = $repository->getTableColumns($daysTable);
636 $filteredRecord = [];
637 foreach ($record as $key => $value) {
638 if (in_array($key, $tableColumns, true)) {
639 $filteredRecord[$key] = $value;
640 }
641 }
642 if ($filteredRecord === []) {
643 $importStats['itinerary']['failed']++;
644 continue;
645 }
646 $newId = $repository->insertRecordReturningId($daysTable, $filteredRecord);
647 if ($newId === null) {
648 $importStats['itinerary']['failed']++;
649 continue;
650 }
651 $processedRecords++;
652 $importStats['itinerary']['imported']++;
653 if ($oldDayId > 0) {
654 $mapper->remember('itinerary_days', $oldDayId, $newId);
655 }
656 } catch (\Exception $e) {
657 Logger::error('Itinerary day import error: ' . $e->getMessage());
658 $importStats['itinerary']['failed']++;
659 }
660 }
661 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
662 self::updateJob($jobId, [
663 'processed_records' => $processedRecords,
664 'progress' => $progress,
665 ]);
666 }
667
668 foreach (array_chunk($payload['entries'], self::BATCH_SIZE) as $batch) {
669 foreach ($batch as $record) {
670 $record = (array) $record;
671 unset($record['id']);
672 try {
673 if (isset($record['day_id'])) {
674 $record['day_id'] = $mapper->map('itinerary_days', $record['day_id']);
675 }
676 if (isset($record['trip_id'])) {
677 $record['trip_id'] = $mapper->map('trips', $record['trip_id']);
678 }
679 if (array_key_exists('item_type_id', $record)) {
680 $record['item_type_id'] = $mapper->mapFkNullable('classifications', $record['item_type_id']);
681 }
682 if (array_key_exists('item_id', $record)) {
683 $record['item_id'] = $mapper->mapFkNullable('classifications', $record['item_id']);
684 }
685 if (empty($record['day_id']) || empty($record['trip_id'])) {
686 $importStats['itinerary']['failed']++;
687 continue;
688 }
689 $tableColumns = $repository->getTableColumns($entriesTable);
690 $filteredRecord = [];
691 foreach ($record as $key => $value) {
692 if (in_array($key, $tableColumns, true)) {
693 $filteredRecord[$key] = $value;
694 }
695 }
696 if ($filteredRecord === []) {
697 $importStats['itinerary']['failed']++;
698 continue;
699 }
700 $newId = $repository->insertRecordReturningId($entriesTable, $filteredRecord);
701 if ($newId === null) {
702 $importStats['itinerary']['failed']++;
703 continue;
704 }
705 $processedRecords++;
706 $importStats['itinerary']['imported']++;
707 } catch (\Exception $e) {
708 Logger::error('Itinerary entry import error: ' . $e->getMessage());
709 $importStats['itinerary']['failed']++;
710 }
711 }
712 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
713 self::updateJob($jobId, [
714 'processed_records' => $processedRecords,
715 'progress' => $progress,
716 ]);
717 }
718
719 Logger::info(
720 "Imported itinerary: {$importStats['itinerary']['imported']} ok, {$importStats['itinerary']['failed']} failed"
721 );
722 continue;
723 }
724
725 if (!isset($dataContainer[$dataType]) || !is_array($dataContainer[$dataType])) {
726 Logger::warning("Skipping data type not found in import file: {$dataType}");
727 continue;
728 }
729
730 $mergedMap = self::getMergedTableMap();
731 $classType = self::classificationTypeForDataType($dataType);
732 if ($classType !== null) {
733 $tableName = ClassificationsTable::getTableName();
734 } elseif (isset($mergedMap[$dataType])) {
735 $tableName = $wpdb->prefix . $mergedMap[$dataType];
736 } else {
737 Logger::warning("Skipping unknown import data type: {$dataType}");
738 continue;
739 }
740
741 self::importTableRowsWithMapping(
742 $repository,
743 $mapper,
744 $jobId,
745 $dataType,
746 $tableName,
747 $dataContainer[$dataType],
748 $processedRecords,
749 $totalRecords,
750 $importStats
751 );
752 }
753
754 // Mark as completed with detailed statistics
755 self::updateJob($jobId, [
756 'status' => 'completed',
757 'progress' => 100,
758 'completed_at' => current_time('mysql'),
759 'import_stats' => $importStats,
760 'processed_records' => $processedRecords,
761 'seen_notification' => false, // Flag to track if notification has been seen
762 ]);
763
764 // Clean up import file
765 @unlink($filePath);
766
767 Logger::info("Import job completed: {$jobId}, records: {$processedRecords}, stats: " . json_encode($importStats));
768
769 } catch (\Exception $e) {
770 self::updateJob($jobId, [
771 'status' => 'failed',
772 'error' => $e->getMessage(),
773 'completed_at' => current_time('mysql'),
774 ]);
775 Logger::error("Import job failed: {$jobId}, error: " . $e->getMessage());
776 }
777 }
778
779 /**
780 * @param string[] $dataTypes
781 * @return string[]
782 */
783 private static function normalizeExportDataTypes(array $dataTypes): array
784 {
785 $dataTypes = array_values(array_filter($dataTypes, static function ($t): bool {
786 return is_string($t) && $t !== '';
787 }));
788 if (in_array('all', $dataTypes, true)) {
789 return self::getAllExportableTypeKeys();
790 }
791
792 return array_values(array_unique($dataTypes));
793 }
794
795 /**
796 * @return string[]
797 */
798 private static function getAllExportableTypeKeys(): array
799 {
800 $base = [
801 'settings',
802 'destinations',
803 'activities',
804 'categories',
805 'difficulty_levels',
806 'trips',
807 'itinerary',
808 ];
809 $mergedKeys = array_keys(self::getMergedTableMap());
810 $keys = array_values(array_unique(array_merge($base, $mergedKeys)));
811
812 return array_values(array_unique((array) apply_filters('yatra_export_all_data_types', $keys)));
813 }
814
815 /**
816 * @param array<int, mixed> $records
817 * @param array<string, array{total: int, imported: int, failed: int}> $importStats
818 */
819 private static function importTableRowsWithMapping(
820 ExportImportRepository $repository,
821 ExportImportIdMapper $mapper,
822 string $jobId,
823 string $dataType,
824 string $tableName,
825 array $records,
826 int &$processedRecords,
827 int $totalRecords,
828 array &$importStats
829 ): void {
830 global $wpdb;
831
832 $importStats[$dataType] = [
833 'total' => count($records),
834 'imported' => 0,
835 'failed' => 0,
836 ];
837 Logger::info('Importing ' . $dataType . ': Found ' . count($records) . ' records');
838
839 foreach (array_chunk($records, self::BATCH_SIZE) as $batch) {
840 foreach ($batch as $record) {
841 $record = (array) $record;
842 $oldId = (int) ($record['id'] ?? 0);
843 unset($record['id']);
844
845 try {
846 self::applyForeignKeyRemapping($mapper, $dataType, $record);
847
848 if ($dataType === 'bookings' && isset($record['reference']) && $record['reference'] !== '') {
849 $record['reference'] = self::ensureUniqueBookingReference((string) $record['reference']);
850 }
851
852 if (in_array($dataType, ['dynamic_pricing_rules', 'email_sequences'], true)) {
853 if (array_key_exists('trip_ids', $record) && $record['trip_ids'] !== null && $record['trip_ids'] !== '') {
854 $record['trip_ids'] = self::remapTripIdsTextField($mapper, (string) $record['trip_ids']);
855 }
856 }
857
858 if ($dataType === 'consent_requests' && isset($record['token']) && $record['token'] !== '') {
859 $record['token'] = self::ensureUniqueConsentRequestToken((string) $record['token']);
860 }
861
862 if ($dataType === 'email_templates' && isset($record['template_key']) && $record['template_key'] !== '') {
863 $record['template_key'] = self::ensureUniqueEmailTemplateKey((string) $record['template_key']);
864 }
865
866 if ($dataType === 'discounts') {
867 if (array_key_exists('trip_ids', $record)) {
868 $tripIdsVal = $record['trip_ids'];
869 $record['trip_ids'] = self::remapDiscountTripIdsField(
870 $mapper,
871 $tripIdsVal === null ? null : (string) $tripIdsVal
872 );
873 }
874 if (isset($record['code']) && $record['code'] !== '') {
875 $record['code'] = self::ensureUniqueDiscountCode((string) $record['code']);
876 }
877 }
878
879 if (self::rowHasInvalidRequiredFks($dataType, $record)) {
880 $importStats[$dataType]['failed']++;
881 continue;
882 }
883
884 $tableColumns = $repository->getTableColumns($tableName);
885 $filteredRecord = [];
886 foreach ($record as $key => $value) {
887 if (in_array($key, $tableColumns, true)) {
888 $filteredRecord[$key] = $value;
889 }
890 }
891
892 if ($filteredRecord === []) {
893 $importStats[$dataType]['failed']++;
894 continue;
895 }
896
897 if (isset($filteredRecord['slug'])) {
898 $suffix = str_replace($wpdb->prefix, '', $tableName);
899 $filteredRecord['slug'] = \Yatra\Helpers\SlugHelper::generateUniqueFromDatabase(
900 (string) $filteredRecord['slug'],
901 $suffix,
902 'slug'
903 );
904 }
905
906 $newId = $repository->insertRecordReturningId($tableName, $filteredRecord);
907 if ($newId === null) {
908 $importStats[$dataType]['failed']++;
909 continue;
910 }
911
912 $processedRecords++;
913 $importStats[$dataType]['imported']++;
914
915 $entity = self::entityKeyForDataType($dataType);
916 if ($entity !== null && $oldId > 0) {
917 $mapper->remember($entity, $oldId, $newId);
918 }
919 } catch (\Exception $e) {
920 Logger::error('Error importing ' . $dataType . ': ' . $e->getMessage());
921 $importStats[$dataType]['failed']++;
922 }
923 }
924
925 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
926 self::updateJob($jobId, [
927 'processed_records' => $processedRecords,
928 'progress' => $progress,
929 ]);
930 }
931 }
932
933 private static function entityKeyForDataType(string $dataType): ?string
934 {
935 switch ($dataType) {
936 case 'destinations':
937 case 'activities':
938 case 'categories':
939 case 'difficulty_levels':
940 return 'classifications';
941 case 'trips':
942 return 'trips';
943 case 'customers':
944 return 'customers';
945 case 'bookings':
946 return 'bookings';
947 case 'payments':
948 return 'payments';
949 case 'availability':
950 return 'availability';
951 case 'departures':
952 return 'departures';
953 case 'travelers':
954 return 'travelers';
955 case 'discounts':
956 return 'discounts';
957 case 'additional_service_catalog':
958 return 'services';
959 case 'consent_forms':
960 return 'consent_forms';
961 case 'signed_consents':
962 return 'signed_consents';
963 case 'consent_requests':
964 return 'consent_requests';
965 case 'dynamic_pricing_rules':
966 return 'dynamic_pricing_rules';
967 case 'abandoned_bookings':
968 return 'abandoned_bookings';
969 case 'email_templates':
970 return 'email_templates';
971 case 'email_sequences':
972 return 'email_sequences';
973 case 'email_sequence_steps':
974 return 'email_sequence_steps';
975 default:
976 return null;
977 }
978 }
979
980 private static function rowHasInvalidRequiredFks(string $dataType, array $row): bool
981 {
982 switch ($dataType) {
983 case 'trip_classifications':
984 return empty($row['trip_id'] ?? null) || empty($row['classification_id'] ?? null);
985 case 'trip_content':
986 case 'trip_revisions':
987 case 'availability_rules':
988 case 'availability':
989 case 'departures':
990 case 'trip_additional_services':
991 return empty($row['trip_id'] ?? null);
992 case 'trip_consent_forms':
993 return empty($row['trip_id'] ?? null) || empty($row['form_id'] ?? null);
994 case 'pricing_history':
995 case 'trip_demand_scores':
996 return empty($row['trip_id'] ?? null);
997 case 'consent_requests':
998 return empty($row['form_id'] ?? null) || empty($row['booking_id'] ?? null);
999 case 'signed_consents':
1000 return empty($row['form_id'] ?? null);
1001 case 'abandoned_bookings':
1002 return empty($row['trip_id'] ?? null);
1003 case 'recovery_email_logs':
1004 return empty($row['abandoned_booking_id'] ?? null);
1005 case 'email_sequence_steps':
1006 return empty($row['sequence_id'] ?? null);
1007 case 'booking_departures':
1008 case 'travelers':
1009 case 'payments':
1010 return empty($row['booking_id'] ?? null);
1011 case 'traveler_meta':
1012 return empty($row['traveller_id'] ?? null);
1013 case 'booking_additional_services':
1014 return empty($row['booking_id'] ?? null) || empty($row['service_id'] ?? null);
1015 case 'bookings':
1016 return empty($row['trip_id'] ?? null);
1017 case 'reviews':
1018 return empty($row['trip_id'] ?? null);
1019 default:
1020 return false;
1021 }
1022 }
1023
1024 private static function applyForeignKeyRemapping(ExportImportIdMapper $m, string $dataType, array &$row): void
1025 {
1026 switch ($dataType) {
1027 case 'trips':
1028 if (array_key_exists('difficulty_level', $row)) {
1029 $row['difficulty_level'] = $m->mapFkNullable('classifications', $row['difficulty_level']);
1030 }
1031 break;
1032 case 'trip_classifications':
1033 if (isset($row['trip_id'])) {
1034 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1035 }
1036 if (isset($row['classification_id'])) {
1037 $row['classification_id'] = $m->map('classifications', $row['classification_id']);
1038 }
1039 break;
1040 case 'trip_content':
1041 case 'trip_revisions':
1042 if (isset($row['trip_id'])) {
1043 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1044 }
1045 break;
1046 case 'availability_rules':
1047 case 'availability':
1048 if (isset($row['trip_id'])) {
1049 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1050 }
1051 break;
1052 case 'departures':
1053 if (isset($row['trip_id'])) {
1054 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1055 }
1056 break;
1057 case 'trip_additional_services':
1058 if (isset($row['trip_id'])) {
1059 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1060 }
1061 if (isset($row['service_id'])) {
1062 $row['service_id'] = $m->map('services', $row['service_id']);
1063 }
1064 break;
1065 case 'bookings':
1066 if (isset($row['trip_id'])) {
1067 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1068 }
1069 if (array_key_exists('customer_id', $row)) {
1070 $row['customer_id'] = $m->mapFkNullable('customers', $row['customer_id']);
1071 }
1072 if (array_key_exists('availability_id', $row)) {
1073 $row['availability_id'] = $m->mapFkNullable('availability', $row['availability_id']);
1074 }
1075 break;
1076 case 'booking_departures':
1077 if (isset($row['booking_id'])) {
1078 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1079 }
1080 if (isset($row['departure_id'])) {
1081 $row['departure_id'] = $m->map('departures', $row['departure_id']);
1082 }
1083 break;
1084 case 'booking_additional_services':
1085 if (isset($row['booking_id'])) {
1086 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1087 }
1088 if (isset($row['service_id'])) {
1089 $row['service_id'] = $m->map('services', $row['service_id']);
1090 }
1091 break;
1092 case 'travelers':
1093 if (isset($row['booking_id'])) {
1094 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1095 }
1096 break;
1097 case 'traveler_meta':
1098 if (isset($row['traveller_id'])) {
1099 $row['traveller_id'] = $m->map('travelers', $row['traveller_id']);
1100 }
1101 break;
1102 case 'payments':
1103 if (isset($row['booking_id'])) {
1104 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1105 }
1106 if (array_key_exists('customer_id', $row)) {
1107 $row['customer_id'] = $m->mapFkNullable('customers', $row['customer_id']);
1108 }
1109 break;
1110 case 'google_calendar_events':
1111 if (isset($row['booking_id']) && (int) $row['booking_id'] !== 0) {
1112 $mapped = $m->map('bookings', $row['booking_id']);
1113 $row['booking_id'] = $mapped ?? 0;
1114 }
1115 if (array_key_exists('departure_id', $row)) {
1116 $row['departure_id'] = $m->mapFkNullable('departures', $row['departure_id']);
1117 }
1118 break;
1119 case 'reviews':
1120 if (isset($row['trip_id'])) {
1121 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1122 }
1123 break;
1124 case 'enquiries':
1125 if (array_key_exists('trip_id', $row)) {
1126 $row['trip_id'] = $m->mapFkNullable('trips', $row['trip_id']);
1127 }
1128 break;
1129 case 'destinations':
1130 case 'activities':
1131 case 'categories':
1132 case 'difficulty_levels':
1133 if (array_key_exists('parent_id', $row)) {
1134 $row['parent_id'] = $m->mapFkNullable('classifications', $row['parent_id']);
1135 }
1136 break;
1137 case 'trip_consent_forms':
1138 if (isset($row['trip_id'])) {
1139 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1140 }
1141 if (isset($row['form_id'])) {
1142 $row['form_id'] = $m->map('consent_forms', $row['form_id']);
1143 }
1144 break;
1145 case 'signed_consents':
1146 if (isset($row['form_id'])) {
1147 $row['form_id'] = $m->map('consent_forms', $row['form_id']);
1148 }
1149 if (array_key_exists('booking_id', $row)) {
1150 $row['booking_id'] = $m->mapFkNullable('bookings', $row['booking_id']);
1151 }
1152 break;
1153 case 'consent_requests':
1154 if (isset($row['form_id'])) {
1155 $row['form_id'] = $m->map('consent_forms', $row['form_id']);
1156 }
1157 if (isset($row['booking_id'])) {
1158 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1159 }
1160 if (array_key_exists('signed_consent_id', $row)) {
1161 $row['signed_consent_id'] = $m->mapFkNullable('signed_consents', $row['signed_consent_id']);
1162 }
1163 break;
1164 case 'pricing_history':
1165 case 'trip_demand_scores':
1166 if (isset($row['trip_id'])) {
1167 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1168 }
1169 break;
1170 case 'abandoned_bookings':
1171 if (isset($row['trip_id'])) {
1172 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1173 }
1174 if (array_key_exists('recovered_booking_id', $row)) {
1175 $row['recovered_booking_id'] = $m->mapFkNullable('bookings', $row['recovered_booking_id']);
1176 }
1177 break;
1178 case 'recovery_email_logs':
1179 if (isset($row['abandoned_booking_id'])) {
1180 $row['abandoned_booking_id'] = $m->map('abandoned_bookings', $row['abandoned_booking_id']);
1181 }
1182 break;
1183 case 'email_sequence_steps':
1184 if (isset($row['sequence_id'])) {
1185 $row['sequence_id'] = $m->map('email_sequences', $row['sequence_id']);
1186 }
1187 if (array_key_exists('template_id', $row)) {
1188 $row['template_id'] = $m->mapFkNullable('email_templates', $row['template_id']);
1189 }
1190 break;
1191 case 'email_queue':
1192 if (array_key_exists('sequence_id', $row)) {
1193 $row['sequence_id'] = $m->mapFkNullable('email_sequences', $row['sequence_id']);
1194 }
1195 if (array_key_exists('step_id', $row)) {
1196 $row['step_id'] = $m->mapFkNullable('email_sequence_steps', $row['step_id']);
1197 }
1198 if (array_key_exists('template_id', $row)) {
1199 $row['template_id'] = $m->mapFkNullable('email_templates', $row['template_id']);
1200 }
1201 break;
1202 case 'email_logs':
1203 if (array_key_exists('template_id', $row)) {
1204 $row['template_id'] = $m->mapFkNullable('email_templates', $row['template_id']);
1205 }
1206 if (array_key_exists('sequence_id', $row)) {
1207 $row['sequence_id'] = $m->mapFkNullable('email_sequences', $row['sequence_id']);
1208 }
1209 break;
1210 default:
1211 break;
1212 }
1213 }
1214
1215 /**
1216 * Remap trip id lists stored as JSON array, comma-separated ids, or a single id (Pro + discounts).
1217 */
1218 private static function remapTripIdsTextField(ExportImportIdMapper $m, string $value): string
1219 {
1220 $trimmed = trim($value);
1221 if ($trimmed === '' || $trimmed === '[]') {
1222 return $value;
1223 }
1224
1225 $decoded = json_decode($trimmed, true);
1226 if (is_array($decoded)) {
1227 $out = [];
1228 foreach ($decoded as $tid) {
1229 $new = $m->map('trips', $tid);
1230 if ($new !== null) {
1231 $out[] = $new;
1232 }
1233 }
1234
1235 return json_encode($out);
1236 }
1237
1238 if (strpos($trimmed, ',') !== false) {
1239 $parts = preg_split('/\s*,\s*/', $trimmed) ?: [];
1240 $out = [];
1241 foreach ($parts as $p) {
1242 if ($p === '') {
1243 continue;
1244 }
1245 $new = $m->map('trips', $p);
1246 if ($new !== null) {
1247 $out[] = (string) $new;
1248 }
1249 }
1250
1251 return implode(',', $out);
1252 }
1253
1254 $single = $m->map('trips', $trimmed);
1255
1256 return $single !== null ? (string) $single : '';
1257 }
1258
1259 private static function ensureUniqueConsentRequestToken(string $token): string
1260 {
1261 global $wpdb;
1262 $table = $wpdb->prefix . 'yatra_consent_requests';
1263 $base = $token;
1264 $candidate = $base;
1265 for ($n = 0; $n < 5000; $n++) {
1266 $exists = (int) $wpdb->get_var(
1267 $wpdb->prepare(
1268 "SELECT COUNT(*) FROM `{$table}` WHERE `token` = %s",
1269 $candidate
1270 )
1271 );
1272 if ($exists === 0) {
1273 return $candidate;
1274 }
1275 $candidate = $base . '-' . wp_generate_password(8, false);
1276 }
1277
1278 return $base . '-' . wp_generate_password(12, false);
1279 }
1280
1281 private static function ensureUniqueEmailTemplateKey(string $key): string
1282 {
1283 global $wpdb;
1284 $table = $wpdb->prefix . 'yatra_email_templates';
1285 $base = $key;
1286 $candidate = $base;
1287 for ($n = 0; $n < 5000; $n++) {
1288 $exists = (int) $wpdb->get_var(
1289 $wpdb->prepare(
1290 "SELECT COUNT(*) FROM `{$table}` WHERE `template_key` = %s",
1291 $candidate
1292 )
1293 );
1294 if ($exists === 0) {
1295 return $candidate;
1296 }
1297 $candidate = $base . '-i' . ($n + 1);
1298 }
1299
1300 return $base . '-' . wp_generate_password(6, false);
1301 }
1302
1303 private static function ensureUniqueDiscountCode(string $code): string
1304 {
1305 global $wpdb;
1306 $table = DiscountsTable::getTableName();
1307 $base = $code;
1308 $candidate = $base;
1309 for ($n = 0; $n < 5000; $n++) {
1310 $exists = (int) $wpdb->get_var(
1311 $wpdb->prepare(
1312 "SELECT COUNT(*) FROM `{$table}` WHERE `code` = %s",
1313 $candidate
1314 )
1315 );
1316 if ($exists === 0) {
1317 return $candidate;
1318 }
1319 $candidate = $base . '-i' . ($n + 1);
1320 }
1321
1322 return $base . '-' . wp_generate_password(6, false);
1323 }
1324
1325 private static function ensureUniqueBookingReference(string $reference): string
1326 {
1327 global $wpdb;
1328 $table = BookingsTable::getTableName();
1329 $base = $reference;
1330 $candidate = $base;
1331 for ($n = 0; $n < 5000; $n++) {
1332 $exists = (int) $wpdb->get_var(
1333 $wpdb->prepare(
1334 "SELECT COUNT(*) FROM `{$table}` WHERE `reference` = %s",
1335 $candidate
1336 )
1337 );
1338 if ($exists === 0) {
1339 return $candidate;
1340 }
1341 $candidate = $base . '-i' . ($n + 1);
1342 }
1343
1344 return $base . '-' . wp_generate_password(6, false);
1345 }
1346
1347 private static function remapDiscountTripIdsField(ExportImportIdMapper $m, ?string $tripIdsJson): ?string
1348 {
1349 if ($tripIdsJson === null || $tripIdsJson === '') {
1350 return $tripIdsJson;
1351 }
1352 $decoded = json_decode($tripIdsJson, true);
1353 if (!is_array($decoded)) {
1354 return $tripIdsJson;
1355 }
1356
1357 return self::remapTripIdsTextField($m, $tripIdsJson);
1358 }
1359
1360 private static function classificationTypeForDataType(string $dataType): ?string
1361 {
1362 switch ($dataType) {
1363 case 'destinations':
1364 return ClassificationTypes::DESTINATION;
1365 case 'activities':
1366 return ClassificationTypes::ACTIVITY;
1367 case 'categories':
1368 return ClassificationTypes::CATEGORY;
1369 case 'difficulty_levels':
1370 return ClassificationTypes::DIFFICULTY;
1371 default:
1372 return null;
1373 }
1374 }
1375
1376 /**
1377 * @param string[] $dataTypes
1378 * @return string[]
1379 */
1380 private static function expandDataTypesForExport(array $dataTypes): array
1381 {
1382 $out = array_values(array_unique($dataTypes));
1383
1384 if (in_array('trips', $out, true)) {
1385 foreach (['trip_classifications', 'trip_content', 'trip_revisions'] as $extra) {
1386 if (!in_array($extra, $out, true)) {
1387 $out[] = $extra;
1388 }
1389 }
1390 }
1391 if (in_array('travelers', $out, true) && !in_array('traveler_meta', $out, true)) {
1392 $out[] = 'traveler_meta';
1393 }
1394 if (in_array('bookings', $out, true) && !in_array('booking_departures', $out, true)) {
1395 $out[] = 'booking_departures';
1396 }
1397 if (in_array('availability', $out, true) && !in_array('availability_rules', $out, true)) {
1398 $out[] = 'availability_rules';
1399 }
1400 return apply_filters('yatra_export_import_expand_types', $out, $dataTypes);
1401 }
1402
1403 /**
1404 * Best-effort ordering so parents (classifications, trips) import before dependents.
1405 *
1406 * @param string[] $dataTypes
1407 * @return string[]
1408 */
1409 private static function sortImportDataTypes(array $dataTypes): array
1410 {
1411 $order = [
1412 'settings',
1413 'destinations',
1414 'activities',
1415 'categories',
1416 'difficulty_levels',
1417 'additional_service_catalog',
1418 'consent_forms',
1419 'email_templates',
1420 'email_sequences',
1421 'email_sequence_steps',
1422 'trips',
1423 'trip_content',
1424 'trip_classifications',
1425 'trip_revisions',
1426 'itinerary',
1427 'availability_rules',
1428 'availability',
1429 'trip_additional_services',
1430 'trip_consent_forms',
1431 'dynamic_pricing_rules',
1432 'trip_demand_scores',
1433 'pricing_history',
1434 'departures',
1435 'customers',
1436 'bookings',
1437 'booking_departures',
1438 'booking_additional_services',
1439 'signed_consents',
1440 'consent_requests',
1441 'abandoned_bookings',
1442 'recovery_email_logs',
1443 'recovery_statistics',
1444 'email_queue',
1445 'email_logs',
1446 'travelers',
1447 'traveler_meta',
1448 'payments',
1449 'google_calendar_events',
1450 'reviews',
1451 'enquiries',
1452 'discounts',
1453 ];
1454 $dataTypes = array_values(array_unique($dataTypes));
1455 usort($dataTypes, static function (string $a, string $b) use ($order): int {
1456 $ia = array_search($a, $order, true);
1457 $ib = array_search($b, $order, true);
1458 $ia = $ia === false ? 999 : $ia;
1459 $ib = $ib === false ? 999 : $ib;
1460
1461 return $ia <=> $ib;
1462 });
1463
1464 return $dataTypes;
1465 }
1466
1467 /**
1468 * Count total records to export (must stay aligned with {@see processExportJob()}).
1469 */
1470 private static function countExportRecords(array $dataTypes): int
1471 {
1472 global $wpdb;
1473 $repository = new ExportImportRepository();
1474 $total = 0;
1475
1476 foreach ($dataTypes as $dataType) {
1477 if ($dataType === 'settings') {
1478 continue;
1479 }
1480 if ($dataType === 'itinerary') {
1481 $daysTable = TripItineraryDaysTable::getTableName();
1482 $entriesTable = TripItineraryDayEntryTable::getTableName();
1483 if ($repository->tableExists($daysTable)) {
1484 $total += $repository->getRecordCount($daysTable);
1485 }
1486 if ($repository->tableExists($entriesTable)) {
1487 $total += $repository->getRecordCount($entriesTable);
1488 }
1489 continue;
1490 }
1491 $classType = self::classificationTypeForDataType($dataType);
1492 if ($classType !== null) {
1493 $tableName = ClassificationsTable::getTableName();
1494 if ($repository->tableExists($tableName)) {
1495 $total += $repository->getClassificationCount($tableName, $classType);
1496 }
1497 continue;
1498 }
1499 $merged = self::getMergedTableMap();
1500 if (!isset($merged[$dataType])) {
1501 continue;
1502 }
1503 $tableName = $wpdb->prefix . $merged[$dataType];
1504 if ($repository->tableExists($tableName)) {
1505 $total += $repository->getRecordCount($tableName);
1506 }
1507 }
1508
1509 return $total;
1510 }
1511
1512 /**
1513 * @return array<string, mixed>
1514 */
1515 private static function collectAllYatraOptionsForExport(): array
1516 {
1517 global $wpdb;
1518
1519 $rows = $wpdb->get_results(
1520 "SELECT option_name, option_value FROM {$wpdb->options}
1521 WHERE option_name LIKE 'yatra_%'
1522 AND option_name NOT LIKE 'yatra_job_%'
1523 AND option_name NOT LIKE 'yatra_migration_%'"
1524 );
1525 if (!is_array($rows)) {
1526 return [];
1527 }
1528
1529 $out = [];
1530 foreach ($rows as $row) {
1531 $name = (string) $row->option_name;
1532 if (strpos($name, 'yatra_transient') === 0) {
1533 continue;
1534 }
1535 $out[$name] = maybe_unserialize($row->option_value);
1536 }
1537
1538 return $out;
1539 }
1540
1541 /**
1542 * Import settings
1543 */
1544 private static function importSettings(array $settings): void
1545 {
1546 foreach ($settings as $key => $value) {
1547 if (!is_string($key) || strpos($key, 'yatra_') !== 0) {
1548 continue;
1549 }
1550 if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $key)) {
1551 continue;
1552 }
1553 update_option($key, $value);
1554 }
1555 }
1556
1557 /**
1558 * Delete a job and its associated files
1559 *
1560 * @param string $jobId Job ID
1561 */
1562 public static function deleteJob(string $jobId): bool
1563 {
1564 $jobData = self::getJobStatus($jobId);
1565
1566 if (!$jobData) {
1567 return false;
1568 }
1569
1570 // Delete export file if exists
1571 if (!empty($jobData['file_path'])) {
1572 if (file_exists($jobData['file_path'])) {
1573 $deleted = unlink($jobData['file_path']);
1574 if (!$deleted) {
1575 Logger::error("Failed to delete export file: {$jobData['file_path']}");
1576 } else {
1577 Logger::info("Successfully deleted export file: {$jobData['file_path']}");
1578 }
1579 } else {
1580 Logger::warning("Export file not found for deletion: {$jobData['file_path']}");
1581 }
1582 }
1583
1584 // Instead of deleting the option, mark it as deleted
1585 // This ensures it won't show up in active jobs but will be cleaned up later
1586 $jobData['status'] = 'deleted';
1587 $jobData['deleted_at'] = current_time('mysql');
1588 update_option(self::JOB_OPTION_PREFIX . $jobId, $jobData, false);
1589
1590 return true;
1591 }
1592
1593 /**
1594 * Get active jobs for a user (pending, running, or recently completed)
1595 *
1596 * @param int $userId User ID
1597 * @return array List of active jobs
1598 */
1599 public static function getActiveJobs(int $userId): array
1600 {
1601 $repository = new ExportImportRepository();
1602
1603 $options = $repository->getAllJobOptions();
1604
1605 $jobs = [];
1606 $cutoff = strtotime('-1 hour'); // Show jobs from last hour
1607
1608 foreach ($options as $option) {
1609 $jobData = maybe_unserialize($option->option_value);
1610
1611 if (!is_array($jobData)) {
1612 continue;
1613 }
1614
1615 // Filter by user
1616 if (($jobData['user_id'] ?? 0) !== $userId) {
1617 continue;
1618 }
1619
1620 // Only include pending/running jobs
1621 // Completed jobs should not be shown again after page refresh
1622 $status = $jobData['status'] ?? '';
1623
1624 if ($status === 'pending' || $status === 'running') {
1625 $jobs[] = $jobData;
1626 }
1627 }
1628
1629 // Sort by created_at descending
1630 usort($jobs, function($a, $b) {
1631 return strtotime($b['created_at'] ?? '0') - strtotime($a['created_at'] ?? '0');
1632 });
1633
1634 return $jobs;
1635 }
1636
1637 /**
1638 * Clean up old completed jobs (older than 24 hours)
1639 */
1640 public static function cleanupOldJobs(): void
1641 {
1642 $repository = new ExportImportRepository();
1643
1644 $options = $repository->getAllJobOptions();
1645
1646 $cutoff = strtotime('-24 hours');
1647
1648 foreach ($options as $option) {
1649 $jobData = maybe_unserialize($option->option_value);
1650
1651 if (!is_array($jobData)) {
1652 continue;
1653 }
1654
1655 $completedAt = $jobData['completed_at'] ?? null;
1656
1657 if ($completedAt && strtotime($completedAt) < $cutoff) {
1658 $jobId = str_replace(self::JOB_OPTION_PREFIX, '', $option->option_name);
1659 self::deleteJob($jobId);
1660 }
1661 }
1662 }
1663
1664 /**
1665 * Export data from a specific table with batch processing
1666 */
1667 private static function exportTableData(string $table_name, int $batch_size): array
1668 {
1669 $repository = new ExportImportRepository();
1670
1671 // Check if table exists
1672 $table_exists = $repository->tableExists($table_name);
1673 if (!$table_exists) {
1674 return [];
1675 }
1676
1677 // Get total records
1678 $total_records = $repository->getRecordCount($table_name);
1679 if ($total_records === 0) {
1680 return [];
1681 }
1682
1683 $data = [];
1684
1685 // Process in batches to avoid memory issues
1686 for ($offset = 0; $offset < $total_records; $offset += $batch_size) {
1687 $batch = $repository->getBatchRecords($table_name, $offset, $batch_size);
1688
1689 if ($batch) {
1690 $data = array_merge($data, $batch);
1691 }
1692 }
1693
1694 return $data;
1695 }
1696
1697 /**
1698 * Get export summary statistics
1699 */
1700 public static function getExportSummary(): array
1701 {
1702 $tables = [];
1703
1704 $summary = [
1705 'total_tables' => 0,
1706 'existing_tables' => 0,
1707 'total_records' => 0,
1708 'tables' => $tables
1709 ];
1710
1711 foreach ($tables as $table) {
1712 $summary['total_tables']++;
1713 if ($table['exists']) {
1714 $summary['existing_tables']++;
1715 $summary['total_records'] += $table['record_count'];
1716 }
1717 }
1718
1719 return $summary;
1720 }
1721 }
1722