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

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

1,753 lines 66.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\Utils\Logger;
9 use Yatra\Repositories\ExportImportRepository;
10
11 /**
12 * Export/Import Service
13 *
14 * Handles background export and import processing using Action Scheduler.
15 * Jobs are queued and processed asynchronously to avoid PHP timeout issues.
16 */
17 class ExportImportService
18 {
19 private ExportImportRepository $repository;
20
21 private const EXPORT_ACTION = 'yatra_process_export_job';
22 private const IMPORT_ACTION = 'yatra_process_import_job';
23 private const JOB_OPTION_PREFIX = 'yatra_job_';
24 private const BATCH_SIZE = 500;
25
26 /**
27 * Unprefixed physical table names (Yatra 3.x uses yatra_new_* — not legacy yatra_trips, etc.).
28 *
29 * @var array<string, string>
30 */
31 private const TABLE_SUFFIX_MAP = [
32 'trips' => 'yatra_new_trips',
33 'bookings' => 'yatra_new_bookings',
34 'customers' => 'yatra_new_customers',
35 'reviews' => 'yatra_new_reviews',
36 'payments' => 'yatra_new_booking_payments',
37 'enquiries' => 'yatra_new_enquiries',
38 'discounts' => 'yatra_new_discounts',
39 'travelers' => 'yatra_new_booking_travellers',
40 'traveler_meta' => 'yatra_new_booking_traveller_meta',
41 'availability' => 'yatra_new_trip_availability_dates',
42 'availability_rules' => 'yatra_new_trip_availability_rules',
43 'departures' => 'yatra_new_trip_departures',
44 'booking_departures' => 'yatra_new_booking_departures',
45 'trip_classifications' => 'yatra_new_trip_classifications',
46 'trip_content' => 'yatra_new_trip_content',
47 'trip_revisions' => 'yatra_new_trip_revisions',
48 'scheduled_payments' => 'yatra_new_scheduled_payments',
49 'payment_tokens' => 'yatra_new_payment_tokens',
50 ];
51
52 /**
53 * Free core map + optional suffixes from Yatra Pro (or other add-ons) via
54 * {@see 'yatra_export_import_table_map'}.
55 *
56 * @return array<string, string> data_type_key => table suffix without wp_prefix
57 */
58 private static function getMergedTableMap(): array
59 {
60 return array_merge(self::TABLE_SUFFIX_MAP, (array) apply_filters('yatra_export_import_table_map', []));
61 }
62
63 public function __construct()
64 {
65 $this->repository = new ExportImportRepository();
66 }
67
68 /**
69 * Get MySQL version
70 */
71 public function getMySQLVersion(): string
72 {
73 return $this->repository->getMySQLVersion();
74 }
75
76 /**
77 * Get job options for user
78 */
79 public function getJobOptionsForUser(int $userId): array
80 {
81 return $this->repository->getJobOptionsForUser($userId);
82 }
83
84 /**
85 * Register Action Scheduler hooks
86 */
87 public static function register(): void
88 {
89 add_action(self::EXPORT_ACTION, [self::class, 'processExportJob'], 10, 1);
90 add_action(self::IMPORT_ACTION, [self::class, 'processImportJob'], 10, 1);
91 }
92
93 /**
94 * Nudge WP-Cron and run Action Scheduler pending actions so queued export/import jobs
95 * actually start during the REST request (same approach as {@see MigrationProgress::kickQueueRunner}).
96 */
97 private static function kickActionSchedulerQueue(): void
98 {
99 if (function_exists('spawn_cron')) {
100 spawn_cron();
101 }
102
103 try {
104 if (class_exists(\ActionScheduler::class)) {
105 $runner = \ActionScheduler::runner();
106 if ($runner !== null && method_exists($runner, 'run')) {
107 $runner->run();
108
109 return;
110 }
111 }
112
113 if (class_exists(\ActionScheduler_QueueRunner::class)) {
114 $runner = \ActionScheduler_QueueRunner::instance();
115 if ($runner !== null && method_exists($runner, 'run')) {
116 $runner->run();
117 }
118 }
119 } catch (\Throwable $e) {
120 Logger::warning('Action Scheduler queue kick failed: ' . $e->getMessage(), [
121 'source' => 'export_import',
122 ]);
123 }
124 }
125
126 /**
127 * Create a new export job
128 *
129 * @param array $dataTypes Data types to export
130 * @param int $userId User who requested the export
131 * @return string Job ID
132 */
133 public static function createExportJob(array $dataTypes, int $userId): string
134 {
135 $dataTypes = self::normalizeExportDataTypes($dataTypes);
136
137 $jobId = 'export_' . uniqid() . '_' . time();
138
139 // Store job metadata in options
140 $jobData = [
141 'id' => $jobId,
142 'type' => 'export',
143 'status' => 'pending',
144 'data_types' => $dataTypes,
145 'user_id' => $userId,
146 'progress' => 0,
147 'total_records' => 0,
148 'processed_records' => 0,
149 'file_path' => '',
150 'file_url' => '',
151 'error' => '',
152 'created_at' => current_time('mysql'),
153 'started_at' => null,
154 'completed_at' => null,
155 ];
156
157 update_option(self::JOB_OPTION_PREFIX . $jobId, $jobData, false);
158
159 // Schedule with Action Scheduler, then kick the runner so work starts in this request
160 // (otherwise many hosts leave jobs pending until WP-Cron, and the UI stays at 0/0).
161 if (function_exists('as_enqueue_async_action')) {
162 as_enqueue_async_action(self::EXPORT_ACTION, [$jobId], 'yatra');
163 self::kickActionSchedulerQueue();
164 $fresh = self::getJobStatus($jobId);
165 if ($fresh && ($fresh['status'] ?? '') === 'pending') {
166 self::processExportJob($jobId);
167 }
168 } else {
169 self::processExportJob($jobId);
170 }
171
172 Logger::info("Export job created: {$jobId}");
173
174 return $jobId;
175 }
176
177 /**
178 * Create a new import job
179 *
180 * @param string $filePath Path to the import file
181 * @param array $dataTypes Data types to import
182 * @param int $userId User who requested the import
183 * @return string Job ID
184 */
185 public static function createImportJob(string $filePath, array $dataTypes, int $userId): string
186 {
187 $importAll = in_array('all', $dataTypes, true);
188 $dataTypes = array_values(array_filter(
189 array_unique($dataTypes),
190 static function ($t) {
191 return is_string($t) && $t !== 'all';
192 }
193 ));
194
195 $jobId = 'import_' . uniqid() . '_' . time();
196
197 $jobData = [
198 'id' => $jobId,
199 'type' => 'import',
200 'status' => 'pending',
201 'data_types' => $dataTypes,
202 'import_all' => $importAll,
203 'user_id' => $userId,
204 'file_path' => $filePath,
205 'progress' => 0,
206 'total_records' => 0,
207 'processed_records' => 0,
208 'error' => '',
209 'created_at' => current_time('mysql'),
210 'started_at' => null,
211 'completed_at' => null,
212 ];
213
214 update_option(self::JOB_OPTION_PREFIX . $jobId, $jobData, false);
215
216 if (function_exists('as_enqueue_async_action')) {
217 as_enqueue_async_action(self::IMPORT_ACTION, [$jobId], 'yatra');
218 self::kickActionSchedulerQueue();
219 $fresh = self::getJobStatus($jobId);
220 if ($fresh && ($fresh['status'] ?? '') === 'pending') {
221 self::processImportJob($jobId);
222 }
223 } else {
224 self::processImportJob($jobId);
225 }
226
227 Logger::info("Import job created: {$jobId}");
228
229 return $jobId;
230 }
231
232 /**
233 * Get job status
234 *
235 * @param string $jobId Job ID
236 * @return array|null Job data or null if not found
237 */
238 public static function getJobStatus(string $jobId): ?array
239 {
240 $jobData = get_option(self::JOB_OPTION_PREFIX . $jobId);
241 return $jobData ?: null;
242 }
243
244 /**
245 * Update job status
246 *
247 * @param string $jobId Job ID
248 * @param array $updates Fields to update
249 */
250 private static function updateJob(string $jobId, array $updates): void
251 {
252 $jobData = get_option(self::JOB_OPTION_PREFIX . $jobId);
253 if ($jobData) {
254 $jobData = array_merge($jobData, $updates);
255 update_option(self::JOB_OPTION_PREFIX . $jobId, $jobData, false);
256 }
257 }
258
259 /**
260 * Process export job (called by Action Scheduler)
261 *
262 * @param string $jobId Job ID
263 */
264 public static function processExportJob(string $jobId): void
265 {
266 $repository = new ExportImportRepository();
267 global $wpdb;
268
269 $jobData = self::getJobStatus($jobId);
270 if (!$jobData) {
271 Logger::error("Export job not found: {$jobId}");
272 return;
273 }
274
275 $status = $jobData['status'] ?? '';
276 if ($status === 'completed' || $status === 'failed') {
277 return;
278 }
279 if ($status === 'running') {
280 return;
281 }
282 if ($status !== 'pending') {
283 return;
284 }
285
286 // Mark as running
287 self::updateJob($jobId, [
288 'status' => 'running',
289 'started_at' => current_time('mysql'),
290 ]);
291
292 try {
293 $dataTypes = $jobData['data_types'] ?? [];
294 $exportData = [
295 'version' => YATRA_VERSION,
296 'export_date' => current_time('mysql'),
297 'job_id' => $jobId,
298 'data' => []
299 ];
300
301 $processedRecords = 0;
302
303 $expandedTypes = self::expandDataTypesForExport($dataTypes);
304
305 $settingsBundleForExport = null;
306 if ($dataTypes === [] || in_array('settings', $dataTypes, true)) {
307 $settingsBundleForExport = self::collectAllYatraOptionsForExport();
308 }
309 $settingsWeight = $settingsBundleForExport !== null ? max(1, count($settingsBundleForExport)) : 0;
310
311 $totalRecords = self::countExportRecords($expandedTypes) + $settingsWeight;
312 self::updateJob($jobId, ['total_records' => $totalRecords]);
313
314 foreach ($expandedTypes as $dataType) {
315 if ($dataType === 'settings') {
316 continue;
317 }
318
319 if ($dataType === 'itinerary') {
320 $daysTable = $wpdb->prefix . 'yatra_new_trip_itinerary_days';
321 $entriesTable = $wpdb->prefix . 'yatra_new_trip_itinerary_day_entry';
322 $allDays = [];
323 $allEntries = [];
324
325 if ($repository->tableExists($daysTable)) {
326 $dayTotal = $repository->getRecordCount($daysTable);
327 for ($offset = 0; $offset < $dayTotal; $offset += self::BATCH_SIZE) {
328 $batch = $repository->getBatchRecords($daysTable, $offset, self::BATCH_SIZE);
329 $allDays = array_merge($allDays, $batch);
330 $processedRecords += count($batch);
331 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
332 self::updateJob($jobId, [
333 'processed_records' => $processedRecords,
334 'progress' => $progress,
335 ]);
336 }
337 }
338
339 if ($repository->tableExists($entriesTable)) {
340 $entryTotal = $repository->getRecordCount($entriesTable);
341 for ($offset = 0; $offset < $entryTotal; $offset += self::BATCH_SIZE) {
342 $batch = $repository->getBatchRecords($entriesTable, $offset, self::BATCH_SIZE);
343 $allEntries = array_merge($allEntries, $batch);
344 $processedRecords += count($batch);
345 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
346 self::updateJob($jobId, [
347 'processed_records' => $processedRecords,
348 'progress' => $progress,
349 ]);
350 }
351 }
352
353 $exportData['data']['itinerary'] = [
354 'days' => $allDays,
355 'entries' => $allEntries,
356 ];
357 continue;
358 }
359
360 $classType = self::classificationTypeForDataType($dataType);
361 if ($classType !== null) {
362 $tableName = $wpdb->prefix . 'yatra_new_classifications';
363 if (!$repository->tableExists($tableName)) {
364 $exportData['data'][$dataType] = [];
365 continue;
366 }
367 $total = $repository->getClassificationCount($tableName, $classType);
368 $records = [];
369 for ($offset = 0; $offset < $total; $offset += self::BATCH_SIZE) {
370 $batch = $repository->getClassificationBatch($tableName, $classType, $offset, self::BATCH_SIZE);
371 $records = array_merge($records, $batch);
372 $processedRecords += count($batch);
373 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
374 self::updateJob($jobId, [
375 'processed_records' => $processedRecords,
376 'progress' => $progress,
377 ]);
378 }
379 $exportData['data'][$dataType] = $records;
380 continue;
381 }
382
383 $tableMap = self::getMergedTableMap();
384 if (!isset($tableMap[$dataType])) {
385 continue;
386 }
387
388 $tableName = $wpdb->prefix . $tableMap[$dataType];
389 if (!$repository->tableExists($tableName)) {
390 $exportData['data'][$dataType] = [];
391 continue;
392 }
393
394 $total = $repository->getRecordCount($tableName);
395 $records = [];
396
397 for ($offset = 0; $offset < $total; $offset += self::BATCH_SIZE) {
398 $batch = $repository->getBatchRecords($tableName, $offset, self::BATCH_SIZE);
399 $records = array_merge($records, $batch);
400 $processedRecords += count($batch);
401
402 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
403 self::updateJob($jobId, [
404 'processed_records' => $processedRecords,
405 'progress' => $progress,
406 ]);
407 }
408
409 $exportData['data'][$dataType] = $records;
410 }
411
412 if ($settingsBundleForExport !== null) {
413 $exportData['data']['settings'] = (array) apply_filters('yatra_export_settings_bundle', $settingsBundleForExport);
414 $processedRecords += $settingsWeight;
415 $progress = $totalRecords > 0 ? min(100, (int) round(($processedRecords / $totalRecords) * 100)) : 100;
416 self::updateJob($jobId, [
417 'processed_records' => $processedRecords,
418 'progress' => $progress,
419 ]);
420 }
421
422 // Write to file
423 $uploadDir = wp_upload_dir();
424 $exportDir = $uploadDir['basedir'] . '/yatra-exports';
425
426 if (!file_exists($exportDir)) {
427 wp_mkdir_p($exportDir);
428 // Add .htaccess to protect directory
429 file_put_contents($exportDir . '/.htaccess', 'deny from all');
430 }
431
432 $filename = 'yatra-export-' . date('Y-m-d-H-i-s') . '-' . substr($jobId, 0, 8) . '.json';
433 $filePath = $exportDir . '/' . $filename;
434 $fileUrl = $uploadDir['baseurl'] . '/yatra-exports/' . $filename;
435
436 file_put_contents($filePath, json_encode($exportData, JSON_PRETTY_PRINT));
437
438 // Mark as completed
439 self::updateJob($jobId, [
440 'status' => 'completed',
441 'progress' => 100,
442 'file_path' => $filePath,
443 'file_url' => $fileUrl,
444 'completed_at' => current_time('mysql'),
445 ]);
446
447 Logger::info("Export job completed: {$jobId}, file: {$filename}");
448
449 } catch (\Exception $e) {
450 self::updateJob($jobId, [
451 'status' => 'failed',
452 'error' => $e->getMessage(),
453 'completed_at' => current_time('mysql'),
454 ]);
455 Logger::error("Export job failed: {$jobId}, error: " . $e->getMessage());
456 }
457 }
458
459 /**
460 * Process import job (called by Action Scheduler)
461 *
462 * @param string $jobId Job ID
463 */
464 public static function processImportJob(string $jobId): void
465 {
466 $repository = new ExportImportRepository();
467 global $wpdb;
468
469 $jobData = self::getJobStatus($jobId);
470 if (!$jobData) {
471 Logger::error("Import job not found: {$jobId}");
472 return;
473 }
474
475 $status = $jobData['status'] ?? '';
476 if ($status === 'completed' || $status === 'failed') {
477 return;
478 }
479 if ($status === 'running') {
480 return;
481 }
482 if ($status !== 'pending') {
483 return;
484 }
485
486 // Mark as running
487 self::updateJob($jobId, [
488 'status' => 'running',
489 'started_at' => current_time('mysql'),
490 'import_stats' => [], // Initialize import statistics
491 ]);
492
493 try {
494 $filePath = $jobData['file_path'] ?? '';
495 $dataTypes = $jobData['data_types'] ?? [];
496
497 if (!file_exists($filePath)) {
498 throw new \Exception('Import file not found');
499 }
500
501 $content = file_get_contents($filePath);
502 $jsonError = null;
503
504 // Add detailed JSON error logging
505 $importData = json_decode($content, true);
506 switch (json_last_error()) {
507 case JSON_ERROR_NONE:
508 break;
509 case JSON_ERROR_DEPTH:
510 $jsonError = 'Maximum stack depth exceeded';
511 break;
512 case JSON_ERROR_STATE_MISMATCH:
513 $jsonError = 'Underflow or the modes mismatch';
514 break;
515 case JSON_ERROR_CTRL_CHAR:
516 $jsonError = 'Unexpected control character found';
517 break;
518 case JSON_ERROR_SYNTAX:
519 $jsonError = 'Syntax error, malformed JSON';
520 break;
521 case JSON_ERROR_UTF8:
522 $jsonError = 'Malformed UTF-8 characters';
523 break;
524 default:
525 $jsonError = 'Unknown JSON error';
526 break;
527 }
528
529 if ($jsonError) {
530 Logger::error("JSON decode error: {$jsonError}, file: {$filePath}");
531 throw new \Exception('Invalid JSON format: ' . $jsonError);
532 }
533
534 if (!$importData) {
535 Logger::error("Empty import data, file: {$filePath}");
536 throw new \Exception('Empty import data');
537 }
538
539 // Handle both formats: direct data array or wrapped in 'data' key
540 if (isset($importData['data'])) {
541 // Standard format with 'data' wrapper
542 $dataContainer = $importData['data'];
543 } else if (is_array($importData) && !empty($importData)) {
544 // Direct data format without wrapper
545 $dataContainer = $importData;
546 } else {
547 Logger::error("No valid data structure found in import file: {$filePath}");
548 throw new \Exception('Invalid import file format: No data structure found');
549 }
550
551 if (!empty($jobData['import_all'])) {
552 $dataTypes = array_keys($dataContainer);
553 }
554
555 $dataTypes = self::sortImportDataTypes($dataTypes);
556
557 $mapper = new ExportImportIdMapper();
558
559 $totalRecords = 0;
560 foreach ($dataTypes as $dt) {
561 if (!isset($dataContainer[$dt])) {
562 continue;
563 }
564 $payload = $dataContainer[$dt];
565 if ($dt === 'itinerary' && is_array($payload) && isset($payload['days'], $payload['entries']) && is_array($payload['days']) && is_array($payload['entries'])) {
566 $totalRecords += count($payload['days']) + count($payload['entries']);
567 } elseif ($dt === 'settings' && is_array($payload)) {
568 $totalRecords += max(1, count($payload));
569 } elseif (is_array($payload)) {
570 $totalRecords += count($payload);
571 }
572 }
573
574 self::updateJob($jobId, ['total_records' => $totalRecords]);
575
576 $processedRecords = 0;
577 $importStats = [];
578
579 foreach ($dataTypes as $dataType) {
580 if ($dataType === 'settings') {
581 if (!isset($dataContainer['settings']) || !is_array($dataContainer['settings'])) {
582 Logger::warning('Skipping settings: not found or invalid in import file');
583 continue;
584 }
585 $settingsRows = $dataContainer['settings'];
586 self::importSettings($settingsRows);
587 $n = max(1, is_array($settingsRows) ? count($settingsRows) : 0);
588 $importStats['settings'] = ['total' => $n, 'imported' => $n, 'failed' => 0];
589 $processedRecords += $n;
590 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
591 self::updateJob($jobId, [
592 'processed_records' => $processedRecords,
593 'progress' => $progress,
594 ]);
595 continue;
596 }
597
598 if ($dataType === 'itinerary') {
599 if (!isset($dataContainer['itinerary']) || !is_array($dataContainer['itinerary'])) {
600 Logger::warning('Skipping itinerary: not found in import file');
601 continue;
602 }
603 $payload = $dataContainer['itinerary'];
604 if (!isset($payload['days'], $payload['entries']) || !is_array($payload['days']) || !is_array($payload['entries'])) {
605 Logger::warning('Skipping itinerary: expected { days, entries } from Yatra 3 export; legacy flat arrays are not supported');
606 continue;
607 }
608 $daysTable = $wpdb->prefix . 'yatra_new_trip_itinerary_days';
609 $entriesTable = $wpdb->prefix . 'yatra_new_trip_itinerary_day_entry';
610 $dayTotal = count($payload['days']);
611 $entryTotal = count($payload['entries']);
612 $importStats['itinerary'] = [
613 'total' => $dayTotal + $entryTotal,
614 'imported' => 0,
615 'failed' => 0,
616 ];
617
618 foreach (array_chunk($payload['days'], self::BATCH_SIZE) as $batch) {
619 foreach ($batch as $record) {
620 $record = (array) $record;
621 $oldDayId = (int) ($record['id'] ?? 0);
622 unset($record['id']);
623 try {
624 if (isset($record['trip_id'])) {
625 $mappedTrip = $mapper->map('trips', $record['trip_id']);
626 $record['trip_id'] = $mappedTrip;
627 }
628 if (empty($record['trip_id'])) {
629 $importStats['itinerary']['failed']++;
630 continue;
631 }
632 $tableColumns = $repository->getTableColumns($daysTable);
633 $filteredRecord = [];
634 foreach ($record as $key => $value) {
635 if (in_array($key, $tableColumns, true)) {
636 $filteredRecord[$key] = $value;
637 }
638 }
639 if ($filteredRecord === []) {
640 $importStats['itinerary']['failed']++;
641 continue;
642 }
643 $newId = $repository->insertRecordReturningId($daysTable, $filteredRecord);
644 if ($newId === null) {
645 $importStats['itinerary']['failed']++;
646 continue;
647 }
648 $processedRecords++;
649 $importStats['itinerary']['imported']++;
650 if ($oldDayId > 0) {
651 $mapper->remember('itinerary_days', $oldDayId, $newId);
652 }
653 } catch (\Exception $e) {
654 Logger::error('Itinerary day import error: ' . $e->getMessage());
655 $importStats['itinerary']['failed']++;
656 }
657 }
658 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
659 self::updateJob($jobId, [
660 'processed_records' => $processedRecords,
661 'progress' => $progress,
662 ]);
663 }
664
665 foreach (array_chunk($payload['entries'], self::BATCH_SIZE) as $batch) {
666 foreach ($batch as $record) {
667 $record = (array) $record;
668 unset($record['id']);
669 try {
670 if (isset($record['day_id'])) {
671 $record['day_id'] = $mapper->map('itinerary_days', $record['day_id']);
672 }
673 if (isset($record['trip_id'])) {
674 $record['trip_id'] = $mapper->map('trips', $record['trip_id']);
675 }
676 if (array_key_exists('item_type_id', $record)) {
677 $record['item_type_id'] = $mapper->mapFkNullable('classifications', $record['item_type_id']);
678 }
679 if (array_key_exists('item_id', $record)) {
680 $record['item_id'] = $mapper->mapFkNullable('classifications', $record['item_id']);
681 }
682 if (empty($record['day_id']) || empty($record['trip_id'])) {
683 $importStats['itinerary']['failed']++;
684 continue;
685 }
686 $tableColumns = $repository->getTableColumns($entriesTable);
687 $filteredRecord = [];
688 foreach ($record as $key => $value) {
689 if (in_array($key, $tableColumns, true)) {
690 $filteredRecord[$key] = $value;
691 }
692 }
693 if ($filteredRecord === []) {
694 $importStats['itinerary']['failed']++;
695 continue;
696 }
697 $newId = $repository->insertRecordReturningId($entriesTable, $filteredRecord);
698 if ($newId === null) {
699 $importStats['itinerary']['failed']++;
700 continue;
701 }
702 $processedRecords++;
703 $importStats['itinerary']['imported']++;
704 } catch (\Exception $e) {
705 Logger::error('Itinerary entry import error: ' . $e->getMessage());
706 $importStats['itinerary']['failed']++;
707 }
708 }
709 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
710 self::updateJob($jobId, [
711 'processed_records' => $processedRecords,
712 'progress' => $progress,
713 ]);
714 }
715
716 Logger::info(
717 "Imported itinerary: {$importStats['itinerary']['imported']} ok, {$importStats['itinerary']['failed']} failed"
718 );
719 continue;
720 }
721
722 if (!isset($dataContainer[$dataType]) || !is_array($dataContainer[$dataType])) {
723 Logger::warning("Skipping data type not found in import file: {$dataType}");
724 continue;
725 }
726
727 $mergedMap = self::getMergedTableMap();
728 $classType = self::classificationTypeForDataType($dataType);
729 if ($classType !== null) {
730 $tableName = $wpdb->prefix . 'yatra_new_classifications';
731 } elseif (isset($mergedMap[$dataType])) {
732 $tableName = $wpdb->prefix . $mergedMap[$dataType];
733 } else {
734 Logger::warning("Skipping unknown import data type: {$dataType}");
735 continue;
736 }
737
738 self::importTableRowsWithMapping(
739 $repository,
740 $mapper,
741 $jobId,
742 $dataType,
743 $tableName,
744 $dataContainer[$dataType],
745 $processedRecords,
746 $totalRecords,
747 $importStats
748 );
749 }
750
751 // Mark as completed with detailed statistics
752 self::updateJob($jobId, [
753 'status' => 'completed',
754 'progress' => 100,
755 'completed_at' => current_time('mysql'),
756 'import_stats' => $importStats,
757 'processed_records' => $processedRecords,
758 'seen_notification' => false, // Flag to track if notification has been seen
759 ]);
760
761 // Clean up import file
762 @unlink($filePath);
763
764 Logger::info("Import job completed: {$jobId}, records: {$processedRecords}, stats: " . json_encode($importStats));
765
766 } catch (\Exception $e) {
767 self::updateJob($jobId, [
768 'status' => 'failed',
769 'error' => $e->getMessage(),
770 'completed_at' => current_time('mysql'),
771 ]);
772 Logger::error("Import job failed: {$jobId}, error: " . $e->getMessage());
773 }
774 }
775
776 /**
777 * @param string[] $dataTypes
778 * @return string[]
779 */
780 private static function normalizeExportDataTypes(array $dataTypes): array
781 {
782 $dataTypes = array_values(array_filter($dataTypes, static function ($t): bool {
783 return is_string($t) && $t !== '';
784 }));
785 if (in_array('all', $dataTypes, true)) {
786 return self::getAllExportableTypeKeys();
787 }
788
789 return array_values(array_unique($dataTypes));
790 }
791
792 /**
793 * @return string[]
794 */
795 private static function getAllExportableTypeKeys(): array
796 {
797 $base = [
798 'settings',
799 'destinations',
800 'activities',
801 'categories',
802 'difficulty_levels',
803 'trips',
804 'itinerary',
805 ];
806 $mergedKeys = array_keys(self::getMergedTableMap());
807 $keys = array_values(array_unique(array_merge($base, $mergedKeys)));
808
809 return array_values(array_unique((array) apply_filters('yatra_export_all_data_types', $keys)));
810 }
811
812 /**
813 * @param array<int, mixed> $records
814 * @param array<string, array{total: int, imported: int, failed: int}> $importStats
815 */
816 private static function importTableRowsWithMapping(
817 ExportImportRepository $repository,
818 ExportImportIdMapper $mapper,
819 string $jobId,
820 string $dataType,
821 string $tableName,
822 array $records,
823 int &$processedRecords,
824 int $totalRecords,
825 array &$importStats
826 ): void {
827 global $wpdb;
828
829 $importStats[$dataType] = [
830 'total' => count($records),
831 'imported' => 0,
832 'failed' => 0,
833 ];
834 Logger::info('Importing ' . $dataType . ': Found ' . count($records) . ' records');
835
836 foreach (array_chunk($records, self::BATCH_SIZE) as $batch) {
837 foreach ($batch as $record) {
838 $record = (array) $record;
839 $oldId = (int) ($record['id'] ?? 0);
840 unset($record['id']);
841
842 try {
843 self::applyForeignKeyRemapping($mapper, $dataType, $record);
844
845 if ($dataType === 'bookings' && isset($record['reference']) && $record['reference'] !== '') {
846 $record['reference'] = self::ensureUniqueBookingReference((string) $record['reference']);
847 }
848
849 if (in_array($dataType, ['dynamic_pricing_rules', 'email_sequences'], true)) {
850 if (array_key_exists('trip_ids', $record) && $record['trip_ids'] !== null && $record['trip_ids'] !== '') {
851 $record['trip_ids'] = self::remapTripIdsTextField($mapper, (string) $record['trip_ids']);
852 }
853 }
854
855 if ($dataType === 'consent_requests' && isset($record['token']) && $record['token'] !== '') {
856 $record['token'] = self::ensureUniqueConsentRequestToken((string) $record['token']);
857 }
858
859 if ($dataType === 'email_templates' && isset($record['template_key']) && $record['template_key'] !== '') {
860 $record['template_key'] = self::ensureUniqueEmailTemplateKey((string) $record['template_key']);
861 }
862
863 if ($dataType === 'discounts') {
864 if (array_key_exists('trip_ids', $record)) {
865 $tripIdsVal = $record['trip_ids'];
866 $record['trip_ids'] = self::remapDiscountTripIdsField(
867 $mapper,
868 $tripIdsVal === null ? null : (string) $tripIdsVal
869 );
870 }
871 if (isset($record['code']) && $record['code'] !== '') {
872 $record['code'] = self::ensureUniqueDiscountCode((string) $record['code']);
873 }
874 }
875
876 if (self::rowHasInvalidRequiredFks($dataType, $record)) {
877 $importStats[$dataType]['failed']++;
878 continue;
879 }
880
881 $tableColumns = $repository->getTableColumns($tableName);
882 $filteredRecord = [];
883 foreach ($record as $key => $value) {
884 if (in_array($key, $tableColumns, true)) {
885 $filteredRecord[$key] = $value;
886 }
887 }
888
889 if ($filteredRecord === []) {
890 $importStats[$dataType]['failed']++;
891 continue;
892 }
893
894 if (isset($filteredRecord['slug'])) {
895 $suffix = str_replace($wpdb->prefix, '', $tableName);
896 $filteredRecord['slug'] = \Yatra\Helpers\SlugHelper::generateUniqueFromDatabase(
897 (string) $filteredRecord['slug'],
898 $suffix,
899 'slug'
900 );
901 }
902
903 $newId = $repository->insertRecordReturningId($tableName, $filteredRecord);
904 if ($newId === null) {
905 $importStats[$dataType]['failed']++;
906 continue;
907 }
908
909 $processedRecords++;
910 $importStats[$dataType]['imported']++;
911
912 $entity = self::entityKeyForDataType($dataType);
913 if ($entity !== null && $oldId > 0) {
914 $mapper->remember($entity, $oldId, $newId);
915 }
916 } catch (\Exception $e) {
917 Logger::error('Error importing ' . $dataType . ': ' . $e->getMessage());
918 $importStats[$dataType]['failed']++;
919 }
920 }
921
922 $progress = $totalRecords > 0 ? round(($processedRecords / $totalRecords) * 100) : 0;
923 self::updateJob($jobId, [
924 'processed_records' => $processedRecords,
925 'progress' => $progress,
926 ]);
927 }
928 }
929
930 private static function entityKeyForDataType(string $dataType): ?string
931 {
932 switch ($dataType) {
933 case 'destinations':
934 case 'activities':
935 case 'categories':
936 case 'difficulty_levels':
937 return 'classifications';
938 case 'trips':
939 return 'trips';
940 case 'customers':
941 return 'customers';
942 case 'bookings':
943 return 'bookings';
944 case 'payments':
945 return 'payments';
946 case 'payment_tokens':
947 return 'payment_tokens';
948 case 'scheduled_payments':
949 return 'scheduled_payments';
950 case 'availability':
951 return 'availability';
952 case 'departures':
953 return 'departures';
954 case 'travelers':
955 return 'travelers';
956 case 'discounts':
957 return 'discounts';
958 case 'additional_service_catalog':
959 return 'services';
960 case 'consent_forms':
961 return 'consent_forms';
962 case 'signed_consents':
963 return 'signed_consents';
964 case 'consent_requests':
965 return 'consent_requests';
966 case 'dynamic_pricing_rules':
967 return 'dynamic_pricing_rules';
968 case 'abandoned_bookings':
969 return 'abandoned_bookings';
970 case 'email_templates':
971 return 'email_templates';
972 case 'email_sequences':
973 return 'email_sequences';
974 case 'email_sequence_steps':
975 return 'email_sequence_steps';
976 default:
977 return null;
978 }
979 }
980
981 private static function rowHasInvalidRequiredFks(string $dataType, array $row): bool
982 {
983 switch ($dataType) {
984 case 'trip_classifications':
985 return empty($row['trip_id'] ?? null) || empty($row['classification_id'] ?? null);
986 case 'trip_content':
987 case 'trip_revisions':
988 case 'availability_rules':
989 case 'availability':
990 case 'departures':
991 case 'trip_additional_services':
992 return empty($row['trip_id'] ?? null);
993 case 'trip_consent_forms':
994 return empty($row['trip_id'] ?? null) || empty($row['form_id'] ?? null);
995 case 'pricing_history':
996 case 'trip_demand_scores':
997 return empty($row['trip_id'] ?? null);
998 case 'consent_requests':
999 return empty($row['form_id'] ?? null) || empty($row['booking_id'] ?? null);
1000 case 'signed_consents':
1001 return empty($row['form_id'] ?? null);
1002 case 'abandoned_bookings':
1003 return empty($row['trip_id'] ?? null);
1004 case 'recovery_email_logs':
1005 return empty($row['abandoned_booking_id'] ?? null);
1006 case 'email_sequence_steps':
1007 return empty($row['sequence_id'] ?? null);
1008 case 'booking_departures':
1009 case 'travelers':
1010 case 'payments':
1011 return empty($row['booking_id'] ?? null);
1012 case 'traveler_meta':
1013 return empty($row['traveller_id'] ?? null);
1014 case 'booking_additional_services':
1015 return empty($row['booking_id'] ?? null) || empty($row['service_id'] ?? null);
1016 case 'payment_tokens':
1017 return empty($row['customer_id'] ?? null);
1018 case 'bookings':
1019 return empty($row['trip_id'] ?? null);
1020 case 'scheduled_payments':
1021 return empty($row['booking_id'] ?? null);
1022 case 'reviews':
1023 return empty($row['trip_id'] ?? null);
1024 default:
1025 return false;
1026 }
1027 }
1028
1029 private static function applyForeignKeyRemapping(ExportImportIdMapper $m, string $dataType, array &$row): void
1030 {
1031 switch ($dataType) {
1032 case 'trips':
1033 if (array_key_exists('difficulty_level', $row)) {
1034 $row['difficulty_level'] = $m->mapFkNullable('classifications', $row['difficulty_level']);
1035 }
1036 break;
1037 case 'trip_classifications':
1038 if (isset($row['trip_id'])) {
1039 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1040 }
1041 if (isset($row['classification_id'])) {
1042 $row['classification_id'] = $m->map('classifications', $row['classification_id']);
1043 }
1044 break;
1045 case 'trip_content':
1046 case 'trip_revisions':
1047 if (isset($row['trip_id'])) {
1048 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1049 }
1050 break;
1051 case 'availability_rules':
1052 case 'availability':
1053 if (isset($row['trip_id'])) {
1054 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1055 }
1056 break;
1057 case 'departures':
1058 if (isset($row['trip_id'])) {
1059 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1060 }
1061 break;
1062 case 'trip_additional_services':
1063 if (isset($row['trip_id'])) {
1064 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1065 }
1066 if (isset($row['service_id'])) {
1067 $row['service_id'] = $m->map('services', $row['service_id']);
1068 }
1069 break;
1070 case 'payment_tokens':
1071 if (isset($row['customer_id'])) {
1072 $row['customer_id'] = $m->map('customers', $row['customer_id']);
1073 }
1074 break;
1075 case 'bookings':
1076 if (isset($row['trip_id'])) {
1077 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1078 }
1079 if (array_key_exists('customer_id', $row)) {
1080 $row['customer_id'] = $m->mapFkNullable('customers', $row['customer_id']);
1081 }
1082 if (array_key_exists('availability_id', $row)) {
1083 $row['availability_id'] = $m->mapFkNullable('availability', $row['availability_id']);
1084 }
1085 break;
1086 case 'booking_departures':
1087 if (isset($row['booking_id'])) {
1088 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1089 }
1090 if (isset($row['departure_id'])) {
1091 $row['departure_id'] = $m->map('departures', $row['departure_id']);
1092 }
1093 break;
1094 case 'booking_additional_services':
1095 if (isset($row['booking_id'])) {
1096 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1097 }
1098 if (isset($row['service_id'])) {
1099 $row['service_id'] = $m->map('services', $row['service_id']);
1100 }
1101 break;
1102 case 'travelers':
1103 if (isset($row['booking_id'])) {
1104 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1105 }
1106 break;
1107 case 'traveler_meta':
1108 if (isset($row['traveller_id'])) {
1109 $row['traveller_id'] = $m->map('travelers', $row['traveller_id']);
1110 }
1111 break;
1112 case 'payments':
1113 if (isset($row['booking_id'])) {
1114 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1115 }
1116 if (array_key_exists('customer_id', $row)) {
1117 $row['customer_id'] = $m->mapFkNullable('customers', $row['customer_id']);
1118 }
1119 break;
1120 case 'scheduled_payments':
1121 if (isset($row['booking_id'])) {
1122 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1123 }
1124 if (array_key_exists('customer_id', $row)) {
1125 $row['customer_id'] = $m->mapFkNullable('customers', $row['customer_id']);
1126 }
1127 if (array_key_exists('payment_token_id', $row)) {
1128 $row['payment_token_id'] = $m->mapFkNullable('payment_tokens', $row['payment_token_id']);
1129 }
1130 break;
1131 case 'google_calendar_events':
1132 if (isset($row['booking_id']) && (int) $row['booking_id'] !== 0) {
1133 $mapped = $m->map('bookings', $row['booking_id']);
1134 $row['booking_id'] = $mapped ?? 0;
1135 }
1136 if (array_key_exists('departure_id', $row)) {
1137 $row['departure_id'] = $m->mapFkNullable('departures', $row['departure_id']);
1138 }
1139 break;
1140 case 'reviews':
1141 if (isset($row['trip_id'])) {
1142 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1143 }
1144 break;
1145 case 'enquiries':
1146 if (array_key_exists('trip_id', $row)) {
1147 $row['trip_id'] = $m->mapFkNullable('trips', $row['trip_id']);
1148 }
1149 break;
1150 case 'destinations':
1151 case 'activities':
1152 case 'categories':
1153 case 'difficulty_levels':
1154 if (array_key_exists('parent_id', $row)) {
1155 $row['parent_id'] = $m->mapFkNullable('classifications', $row['parent_id']);
1156 }
1157 break;
1158 case 'trip_consent_forms':
1159 if (isset($row['trip_id'])) {
1160 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1161 }
1162 if (isset($row['form_id'])) {
1163 $row['form_id'] = $m->map('consent_forms', $row['form_id']);
1164 }
1165 break;
1166 case 'signed_consents':
1167 if (isset($row['form_id'])) {
1168 $row['form_id'] = $m->map('consent_forms', $row['form_id']);
1169 }
1170 if (array_key_exists('booking_id', $row)) {
1171 $row['booking_id'] = $m->mapFkNullable('bookings', $row['booking_id']);
1172 }
1173 break;
1174 case 'consent_requests':
1175 if (isset($row['form_id'])) {
1176 $row['form_id'] = $m->map('consent_forms', $row['form_id']);
1177 }
1178 if (isset($row['booking_id'])) {
1179 $row['booking_id'] = $m->map('bookings', $row['booking_id']);
1180 }
1181 if (array_key_exists('signed_consent_id', $row)) {
1182 $row['signed_consent_id'] = $m->mapFkNullable('signed_consents', $row['signed_consent_id']);
1183 }
1184 break;
1185 case 'pricing_history':
1186 case 'trip_demand_scores':
1187 if (isset($row['trip_id'])) {
1188 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1189 }
1190 break;
1191 case 'abandoned_bookings':
1192 if (isset($row['trip_id'])) {
1193 $row['trip_id'] = $m->map('trips', $row['trip_id']);
1194 }
1195 if (array_key_exists('recovered_booking_id', $row)) {
1196 $row['recovered_booking_id'] = $m->mapFkNullable('bookings', $row['recovered_booking_id']);
1197 }
1198 break;
1199 case 'recovery_email_logs':
1200 if (isset($row['abandoned_booking_id'])) {
1201 $row['abandoned_booking_id'] = $m->map('abandoned_bookings', $row['abandoned_booking_id']);
1202 }
1203 break;
1204 case 'email_sequence_steps':
1205 if (isset($row['sequence_id'])) {
1206 $row['sequence_id'] = $m->map('email_sequences', $row['sequence_id']);
1207 }
1208 if (array_key_exists('template_id', $row)) {
1209 $row['template_id'] = $m->mapFkNullable('email_templates', $row['template_id']);
1210 }
1211 break;
1212 case 'email_queue':
1213 if (array_key_exists('sequence_id', $row)) {
1214 $row['sequence_id'] = $m->mapFkNullable('email_sequences', $row['sequence_id']);
1215 }
1216 if (array_key_exists('step_id', $row)) {
1217 $row['step_id'] = $m->mapFkNullable('email_sequence_steps', $row['step_id']);
1218 }
1219 if (array_key_exists('template_id', $row)) {
1220 $row['template_id'] = $m->mapFkNullable('email_templates', $row['template_id']);
1221 }
1222 break;
1223 case 'email_logs':
1224 if (array_key_exists('template_id', $row)) {
1225 $row['template_id'] = $m->mapFkNullable('email_templates', $row['template_id']);
1226 }
1227 if (array_key_exists('sequence_id', $row)) {
1228 $row['sequence_id'] = $m->mapFkNullable('email_sequences', $row['sequence_id']);
1229 }
1230 break;
1231 default:
1232 break;
1233 }
1234 }
1235
1236 /**
1237 * Remap trip id lists stored as JSON array, comma-separated ids, or a single id (Pro + discounts).
1238 */
1239 private static function remapTripIdsTextField(ExportImportIdMapper $m, string $value): string
1240 {
1241 $trimmed = trim($value);
1242 if ($trimmed === '' || $trimmed === '[]') {
1243 return $value;
1244 }
1245
1246 $decoded = json_decode($trimmed, true);
1247 if (is_array($decoded)) {
1248 $out = [];
1249 foreach ($decoded as $tid) {
1250 $new = $m->map('trips', $tid);
1251 if ($new !== null) {
1252 $out[] = $new;
1253 }
1254 }
1255
1256 return json_encode($out);
1257 }
1258
1259 if (strpos($trimmed, ',') !== false) {
1260 $parts = preg_split('/\s*,\s*/', $trimmed) ?: [];
1261 $out = [];
1262 foreach ($parts as $p) {
1263 if ($p === '') {
1264 continue;
1265 }
1266 $new = $m->map('trips', $p);
1267 if ($new !== null) {
1268 $out[] = (string) $new;
1269 }
1270 }
1271
1272 return implode(',', $out);
1273 }
1274
1275 $single = $m->map('trips', $trimmed);
1276
1277 return $single !== null ? (string) $single : '';
1278 }
1279
1280 private static function ensureUniqueConsentRequestToken(string $token): string
1281 {
1282 global $wpdb;
1283 $table = $wpdb->prefix . 'yatra_consent_requests';
1284 $base = $token;
1285 $candidate = $base;
1286 for ($n = 0; $n < 5000; $n++) {
1287 $exists = (int) $wpdb->get_var(
1288 $wpdb->prepare(
1289 "SELECT COUNT(*) FROM `{$table}` WHERE `token` = %s",
1290 $candidate
1291 )
1292 );
1293 if ($exists === 0) {
1294 return $candidate;
1295 }
1296 $candidate = $base . '-' . wp_generate_password(8, false);
1297 }
1298
1299 return $base . '-' . wp_generate_password(12, false);
1300 }
1301
1302 private static function ensureUniqueEmailTemplateKey(string $key): string
1303 {
1304 global $wpdb;
1305 $table = $wpdb->prefix . 'yatra_email_templates';
1306 $base = $key;
1307 $candidate = $base;
1308 for ($n = 0; $n < 5000; $n++) {
1309 $exists = (int) $wpdb->get_var(
1310 $wpdb->prepare(
1311 "SELECT COUNT(*) FROM `{$table}` WHERE `template_key` = %s",
1312 $candidate
1313 )
1314 );
1315 if ($exists === 0) {
1316 return $candidate;
1317 }
1318 $candidate = $base . '-i' . ($n + 1);
1319 }
1320
1321 return $base . '-' . wp_generate_password(6, false);
1322 }
1323
1324 private static function ensureUniqueDiscountCode(string $code): string
1325 {
1326 global $wpdb;
1327 $table = $wpdb->prefix . 'yatra_new_discounts';
1328 $base = $code;
1329 $candidate = $base;
1330 for ($n = 0; $n < 5000; $n++) {
1331 $exists = (int) $wpdb->get_var(
1332 $wpdb->prepare(
1333 "SELECT COUNT(*) FROM `{$table}` WHERE `code` = %s",
1334 $candidate
1335 )
1336 );
1337 if ($exists === 0) {
1338 return $candidate;
1339 }
1340 $candidate = $base . '-i' . ($n + 1);
1341 }
1342
1343 return $base . '-' . wp_generate_password(6, false);
1344 }
1345
1346 private static function ensureUniqueBookingReference(string $reference): string
1347 {
1348 global $wpdb;
1349 $table = $wpdb->prefix . 'yatra_new_bookings';
1350 $base = $reference;
1351 $candidate = $base;
1352 for ($n = 0; $n < 5000; $n++) {
1353 $exists = (int) $wpdb->get_var(
1354 $wpdb->prepare(
1355 "SELECT COUNT(*) FROM `{$table}` WHERE `reference` = %s",
1356 $candidate
1357 )
1358 );
1359 if ($exists === 0) {
1360 return $candidate;
1361 }
1362 $candidate = $base . '-i' . ($n + 1);
1363 }
1364
1365 return $base . '-' . wp_generate_password(6, false);
1366 }
1367
1368 private static function remapDiscountTripIdsField(ExportImportIdMapper $m, ?string $tripIdsJson): ?string
1369 {
1370 if ($tripIdsJson === null || $tripIdsJson === '') {
1371 return $tripIdsJson;
1372 }
1373 $decoded = json_decode($tripIdsJson, true);
1374 if (!is_array($decoded)) {
1375 return $tripIdsJson;
1376 }
1377
1378 return self::remapTripIdsTextField($m, $tripIdsJson);
1379 }
1380
1381 private static function classificationTypeForDataType(string $dataType): ?string
1382 {
1383 switch ($dataType) {
1384 case 'destinations':
1385 return ClassificationTypes::DESTINATION;
1386 case 'activities':
1387 return ClassificationTypes::ACTIVITY;
1388 case 'categories':
1389 return ClassificationTypes::CATEGORY;
1390 case 'difficulty_levels':
1391 return ClassificationTypes::DIFFICULTY;
1392 default:
1393 return null;
1394 }
1395 }
1396
1397 /**
1398 * @param string[] $dataTypes
1399 * @return string[]
1400 */
1401 private static function expandDataTypesForExport(array $dataTypes): array
1402 {
1403 $out = array_values(array_unique($dataTypes));
1404
1405 if (in_array('trips', $out, true)) {
1406 foreach (['trip_classifications', 'trip_content', 'trip_revisions'] as $extra) {
1407 if (!in_array($extra, $out, true)) {
1408 $out[] = $extra;
1409 }
1410 }
1411 }
1412 if (in_array('travelers', $out, true) && !in_array('traveler_meta', $out, true)) {
1413 $out[] = 'traveler_meta';
1414 }
1415 if (in_array('bookings', $out, true) && !in_array('booking_departures', $out, true)) {
1416 $out[] = 'booking_departures';
1417 }
1418 if (in_array('availability', $out, true) && !in_array('availability_rules', $out, true)) {
1419 $out[] = 'availability_rules';
1420 }
1421 if (in_array('payments', $out, true)) {
1422 foreach (['scheduled_payments', 'payment_tokens'] as $extra) {
1423 if (!in_array($extra, $out, true)) {
1424 $out[] = $extra;
1425 }
1426 }
1427 }
1428
1429 return apply_filters('yatra_export_import_expand_types', $out, $dataTypes);
1430 }
1431
1432 /**
1433 * Best-effort ordering so parents (classifications, trips) import before dependents.
1434 *
1435 * @param string[] $dataTypes
1436 * @return string[]
1437 */
1438 private static function sortImportDataTypes(array $dataTypes): array
1439 {
1440 $order = [
1441 'settings',
1442 'destinations',
1443 'activities',
1444 'categories',
1445 'difficulty_levels',
1446 'additional_service_catalog',
1447 'consent_forms',
1448 'email_templates',
1449 'email_sequences',
1450 'email_sequence_steps',
1451 'trips',
1452 'trip_content',
1453 'trip_classifications',
1454 'trip_revisions',
1455 'itinerary',
1456 'availability_rules',
1457 'availability',
1458 'trip_additional_services',
1459 'trip_consent_forms',
1460 'dynamic_pricing_rules',
1461 'trip_demand_scores',
1462 'pricing_history',
1463 'departures',
1464 'customers',
1465 'payment_tokens',
1466 'bookings',
1467 'booking_departures',
1468 'booking_additional_services',
1469 'signed_consents',
1470 'consent_requests',
1471 'abandoned_bookings',
1472 'recovery_email_logs',
1473 'recovery_statistics',
1474 'email_queue',
1475 'email_logs',
1476 'travelers',
1477 'traveler_meta',
1478 'payments',
1479 'scheduled_payments',
1480 'google_calendar_events',
1481 'reviews',
1482 'enquiries',
1483 'discounts',
1484 ];
1485 $dataTypes = array_values(array_unique($dataTypes));
1486 usort($dataTypes, static function (string $a, string $b) use ($order): int {
1487 $ia = array_search($a, $order, true);
1488 $ib = array_search($b, $order, true);
1489 $ia = $ia === false ? 999 : $ia;
1490 $ib = $ib === false ? 999 : $ib;
1491
1492 return $ia <=> $ib;
1493 });
1494
1495 return $dataTypes;
1496 }
1497
1498 /**
1499 * Count total records to export (must stay aligned with {@see processExportJob()}).
1500 */
1501 private static function countExportRecords(array $dataTypes): int
1502 {
1503 global $wpdb;
1504 $repository = new ExportImportRepository();
1505 $total = 0;
1506
1507 foreach ($dataTypes as $dataType) {
1508 if ($dataType === 'settings') {
1509 continue;
1510 }
1511 if ($dataType === 'itinerary') {
1512 $daysTable = $wpdb->prefix . 'yatra_new_trip_itinerary_days';
1513 $entriesTable = $wpdb->prefix . 'yatra_new_trip_itinerary_day_entry';
1514 if ($repository->tableExists($daysTable)) {
1515 $total += $repository->getRecordCount($daysTable);
1516 }
1517 if ($repository->tableExists($entriesTable)) {
1518 $total += $repository->getRecordCount($entriesTable);
1519 }
1520 continue;
1521 }
1522 $classType = self::classificationTypeForDataType($dataType);
1523 if ($classType !== null) {
1524 $tableName = $wpdb->prefix . 'yatra_new_classifications';
1525 if ($repository->tableExists($tableName)) {
1526 $total += $repository->getClassificationCount($tableName, $classType);
1527 }
1528 continue;
1529 }
1530 $merged = self::getMergedTableMap();
1531 if (!isset($merged[$dataType])) {
1532 continue;
1533 }
1534 $tableName = $wpdb->prefix . $merged[$dataType];
1535 if ($repository->tableExists($tableName)) {
1536 $total += $repository->getRecordCount($tableName);
1537 }
1538 }
1539
1540 return $total;
1541 }
1542
1543 /**
1544 * @return array<string, mixed>
1545 */
1546 private static function collectAllYatraOptionsForExport(): array
1547 {
1548 global $wpdb;
1549
1550 $rows = $wpdb->get_results(
1551 "SELECT option_name, option_value FROM {$wpdb->options}
1552 WHERE option_name LIKE 'yatra_%'
1553 AND option_name NOT LIKE 'yatra_job_%'
1554 AND option_name NOT LIKE 'yatra_migration_%'"
1555 );
1556 if (!is_array($rows)) {
1557 return [];
1558 }
1559
1560 $out = [];
1561 foreach ($rows as $row) {
1562 $name = (string) $row->option_name;
1563 if (strpos($name, 'yatra_transient') === 0) {
1564 continue;
1565 }
1566 $out[$name] = maybe_unserialize($row->option_value);
1567 }
1568
1569 return $out;
1570 }
1571
1572 /**
1573 * Import settings
1574 */
1575 private static function importSettings(array $settings): void
1576 {
1577 foreach ($settings as $key => $value) {
1578 if (!is_string($key) || strpos($key, 'yatra_') !== 0) {
1579 continue;
1580 }
1581 if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $key)) {
1582 continue;
1583 }
1584 update_option($key, $value);
1585 }
1586 }
1587
1588 /**
1589 * Delete a job and its associated files
1590 *
1591 * @param string $jobId Job ID
1592 */
1593 public static function deleteJob(string $jobId): bool
1594 {
1595 $jobData = self::getJobStatus($jobId);
1596
1597 if (!$jobData) {
1598 return false;
1599 }
1600
1601 // Delete export file if exists
1602 if (!empty($jobData['file_path'])) {
1603 if (file_exists($jobData['file_path'])) {
1604 $deleted = unlink($jobData['file_path']);
1605 if (!$deleted) {
1606 Logger::error("Failed to delete export file: {$jobData['file_path']}");
1607 } else {
1608 Logger::info("Successfully deleted export file: {$jobData['file_path']}");
1609 }
1610 } else {
1611 Logger::warning("Export file not found for deletion: {$jobData['file_path']}");
1612 }
1613 }
1614
1615 // Instead of deleting the option, mark it as deleted
1616 // This ensures it won't show up in active jobs but will be cleaned up later
1617 $jobData['status'] = 'deleted';
1618 $jobData['deleted_at'] = current_time('mysql');
1619 update_option(self::JOB_OPTION_PREFIX . $jobId, $jobData, false);
1620
1621 return true;
1622 }
1623
1624 /**
1625 * Get active jobs for a user (pending, running, or recently completed)
1626 *
1627 * @param int $userId User ID
1628 * @return array List of active jobs
1629 */
1630 public static function getActiveJobs(int $userId): array
1631 {
1632 $repository = new ExportImportRepository();
1633
1634 $options = $repository->getAllJobOptions();
1635
1636 $jobs = [];
1637 $cutoff = strtotime('-1 hour'); // Show jobs from last hour
1638
1639 foreach ($options as $option) {
1640 $jobData = maybe_unserialize($option->option_value);
1641
1642 if (!is_array($jobData)) {
1643 continue;
1644 }
1645
1646 // Filter by user
1647 if (($jobData['user_id'] ?? 0) !== $userId) {
1648 continue;
1649 }
1650
1651 // Only include pending/running jobs
1652 // Completed jobs should not be shown again after page refresh
1653 $status = $jobData['status'] ?? '';
1654
1655 if ($status === 'pending' || $status === 'running') {
1656 $jobs[] = $jobData;
1657 }
1658 }
1659
1660 // Sort by created_at descending
1661 usort($jobs, function($a, $b) {
1662 return strtotime($b['created_at'] ?? '0') - strtotime($a['created_at'] ?? '0');
1663 });
1664
1665 return $jobs;
1666 }
1667
1668 /**
1669 * Clean up old completed jobs (older than 24 hours)
1670 */
1671 public static function cleanupOldJobs(): void
1672 {
1673 $repository = new ExportImportRepository();
1674
1675 $options = $repository->getAllJobOptions();
1676
1677 $cutoff = strtotime('-24 hours');
1678
1679 foreach ($options as $option) {
1680 $jobData = maybe_unserialize($option->option_value);
1681
1682 if (!is_array($jobData)) {
1683 continue;
1684 }
1685
1686 $completedAt = $jobData['completed_at'] ?? null;
1687
1688 if ($completedAt && strtotime($completedAt) < $cutoff) {
1689 $jobId = str_replace(self::JOB_OPTION_PREFIX, '', $option->option_name);
1690 self::deleteJob($jobId);
1691 }
1692 }
1693 }
1694
1695 /**
1696 * Export data from a specific table with batch processing
1697 */
1698 private static function exportTableData(string $table_name, int $batch_size): array
1699 {
1700 $repository = new ExportImportRepository();
1701
1702 // Check if table exists
1703 $table_exists = $repository->tableExists($table_name);
1704 if (!$table_exists) {
1705 return [];
1706 }
1707
1708 // Get total records
1709 $total_records = $repository->getRecordCount($table_name);
1710 if ($total_records === 0) {
1711 return [];
1712 }
1713
1714 $data = [];
1715
1716 // Process in batches to avoid memory issues
1717 for ($offset = 0; $offset < $total_records; $offset += $batch_size) {
1718 $batch = $repository->getBatchRecords($table_name, $offset, $batch_size);
1719
1720 if ($batch) {
1721 $data = array_merge($data, $batch);
1722 }
1723 }
1724
1725 return $data;
1726 }
1727
1728 /**
1729 * Get export summary statistics
1730 */
1731 public static function getExportSummary(): array
1732 {
1733 $tables = [];
1734
1735 $summary = [
1736 'total_tables' => 0,
1737 'existing_tables' => 0,
1738 'total_records' => 0,
1739 'tables' => $tables
1740 ];
1741
1742 foreach ($tables as $table) {
1743 $summary['total_tables']++;
1744 if ($table['exists']) {
1745 $summary['existing_tables']++;
1746 $summary['total_records'] += $table['record_count'];
1747 }
1748 }
1749
1750 return $summary;
1751 }
1752 }
1753