PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.3
Yatra – Travel Booking & Tour Operator Software v3.0.3
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.3, at app/Services/ExportImportService.php

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