PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 / Migrations / MigrationProgress.php

MigrationProgress.php in Yatra – Travel Booking & Tour Operator Software trunk, at app/Migrations/MigrationProgress.php

1,582 lines 54.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Migration Progress - Orchestrates migrations and tracks progress/logs
4 *
5 * @package Yatra\Migration
6 * @since 3.0.0
7 */
8
9 namespace Yatra\Migration;
10
11 use Yatra\Constants\ClassificationTypes;
12 use Yatra\Core\Database;
13 use Yatra\Database\Tables\ClassificationsTable;
14 use Yatra\Database\Tables\DeparturesTable;
15 use Yatra\Database\Tables\DiscountsTable;
16 use Yatra\Database\Tables\TripsTable;
17 use Yatra\Database\Tables\TripClassificationsTable;
18 use Yatra\Utils\Logger;
19 use Yatra\Migration\TripMigration;
20 use Yatra\Migration\BookingMigration;
21 use Yatra\Migration\CustomerMigration;
22 use Yatra\Migration\DestinationMigration;
23 use Yatra\Migration\ActivityMigration;
24 use Yatra\Migration\TripCategoryMigration;
25 use Yatra\Migration\ReviewMigration;
26 use Yatra\Migration\EnquiryMigration;
27 use Yatra\Migration\CouponMigration;
28 use Yatra\Migration\TourDateMigration;
29 use Yatra\Migration\TravelerCategoriesMigration;
30 use Yatra\Migration\AttributeMigration;
31 use Yatra\Migration\SettingsMigration;
32 use Yatra\Migration\ServicesMigration;
33 use Yatra\Migration\ItineraryMigration;
34 use Yatra\Migration\AvailabilityConditionsMigration;
35 use Yatra\Migration\ProMigrationReadiness;
36 use Yatra\Migration\ProFeaturesMigration;
37 use Yatra\Migration\ProReviewCptMigration;
38 use Yatra\Migration\ProDownloadsMigration;
39
40 class MigrationProgress
41 {
42 private $wpdb;
43 private $detector;
44 private $logger;
45 /**
46 * When true, migration routines will reprocess records even if they were previously migrated.
47 *
48 * @var bool
49 */
50 private bool $forceMigration = false;
51 /**
52 * Tracks whether we've already ensured DB schema is up to date for this request.
53 *
54 * @var bool
55 */
56 private bool $schemaEnsured = false;
57 /**
58 * Ordered list of migration data types to maintain consistent processing
59 *
60 * @var string[]
61 */
62 private array $dataTypesOrder = [
63 'settings',
64 'pro_features',
65 'destinations',
66 'activities',
67 'trip_categories',
68 'attributes',
69 'customers',
70 'coupons',
71 'reviews',
72 'enquiries',
73 'trips',
74 // Pro CPT downloads/reviews reference old tour posts → need _migrated_to_trip_id from TripMigration.
75 'pro_reviews_cpt',
76 'pro_downloads',
77 'tour_dates',
78 'bookings',
79 'traveler_categories',
80 'itinerary',
81 'services',
82 'availability_conditions',
83 ];
84
85 public function __construct()
86 {
87 global $wpdb;
88 $this->wpdb = $wpdb;
89 $this->detector = new MigrationDetector();
90 $this->logger = new Logger();
91 }
92
93 /**
94 * Expose wpdb for migration helpers.
95 */
96 public function getWpdb(): \wpdb
97 {
98 return $this->wpdb;
99 }
100
101 /**
102 * Whether force migration is enabled for this run.
103 */
104 public function isForceMigration(): bool
105 {
106 return $this->forceMigration;
107 }
108
109 /**
110 * Get migration status
111 */
112 public function getStatus(): array
113 {
114 return [
115 'has_old_data' => $this->detector->hasOldData(),
116 'old_data' => $this->detector->detectOldData(),
117 'migration_log' => $this->getMigrationLog(),
118 'pro_migration' => ProMigrationReadiness::getState(),
119 ];
120 }
121
122 /**
123 * Whether this site still has legacy Yatra (before 3.0) footprints worth migrating.
124 *
125 * Delegates to {@see MigrationDetector::hasOldData()}: recorded yatra_plugin_version below 3.0.0
126 * (old plugin) and/or structural legacy data (tour CPT, old tables, etc.). Normal 3.x options alone
127 * must not qualify — see MigrationDetector::countOldSettings().
128 */
129 public function hasLegacyEnvironment(): bool
130 {
131 return $this->detector->hasOldData();
132 }
133
134 /**
135 * True when legacy data exists but migration is incomplete or had failures.
136 */
137 public function legacyMigrationNeedsAttention(): bool
138 {
139 if (!$this->hasLegacyEnvironment()) {
140 return false;
141 }
142
143 $oldData = $this->detector->detectOldData();
144 $progress = get_option('yatra_migration_progress', []);
145
146 $anyCountable = false;
147
148 foreach ($this->dataTypesOrder as $type) {
149 $count = (int) ($oldData[$type]['count'] ?? 0);
150 if ($count === 0) {
151 continue;
152 }
153
154 $anyCountable = true;
155
156 $st = $progress[$type] ?? [];
157 $status = $st['status'] ?? '';
158 if ($status !== 'completed') {
159 return true;
160 }
161 if ((int) ($st['failed'] ?? 0) > 0) {
162 return true;
163 }
164 }
165
166 // Legacy environment matched but every per-type count is zero (edge cases / detector drift).
167 if (!$anyCountable) {
168 return true;
169 }
170
171 return false;
172 }
173
174 /**
175 * Migrate specific data type using Action Scheduler for background processing
176 */
177 public function migrate(string $dataType, bool $force = false): array
178 {
179 try {
180 $this->ensureSchemaUpToDate();
181
182 // Check if Action Scheduler is available
183 // Prefer Action Scheduler (bundled with Yatra); fall back to WP-Cron for the same hook.
184 $actionId = null;
185 if (function_exists('as_schedule_single_action')) {
186 $actionArgs = [
187 'data_type' => $dataType,
188 ];
189 if ($force) {
190 $actionArgs['force'] = true;
191 }
192 $actionId = as_schedule_single_action(
193 time(),
194 'yatra_migrate_data_type',
195 $actionArgs,
196 'yatra_migration'
197 );
198 } elseif (function_exists('wp_schedule_single_event')) {
199 wp_schedule_single_event(time(), 'yatra_migrate_data_type', [$dataType, $force]);
200 spawn_cron();
201 } else {
202 throw new \Exception(__('No background scheduler is available (Action Scheduler or WP-Cron).', 'yatra'));
203 }
204
205 $this->kickQueueRunner();
206
207 $pro = ProMigrationReadiness::getState();
208 $payload = [
209 'success' => true,
210 'data_type' => $dataType,
211 'action_id' => $actionId,
212 'message' => "Migration scheduled for {$dataType}. Processing in background...",
213 'pro_migration' => $pro,
214 ];
215 if (!$pro['ready'] && $pro['warning_message'] !== '') {
216 $payload['warnings'] = [$pro['warning_message']];
217 Logger::warning($pro['warning_message'], [
218 'source' => 'migration',
219 'data_type' => $dataType,
220 'pro_migration' => $pro,
221 ]);
222 }
223
224 return $payload;
225
226 } catch (\Exception $e) {
227 return [
228 'success' => false,
229 'data_type' => $dataType,
230 'error' => $e->getMessage(),
231 ];
232 }
233 }
234
235 /**
236 * Process migration for a specific data type (called by Action Scheduler)
237 */
238 public function processMigration(string $dataType, bool $force = false): array
239 {
240 // Ensure schema is ready for this request before touching tables
241 $this->ensureSchemaUpToDate();
242
243 $this->forceMigration = $force;
244 Logger::info("Starting migration for: {$dataType}", ['source' => 'migration']);
245
246 $startTime = microtime(true);
247
248 // Get total count before starting migration
249 $detector = new \Yatra\Migration\MigrationDetector();
250 $oldData = $detector->detectOldData();
251 $total = $oldData[$dataType]['count'] ?? 0;
252
253 Logger::info("Found {$total} items to migrate for {$dataType}", [
254 'source' => 'migration',
255 'data_type' => $dataType,
256 'total' => $total
257 ]);
258
259 // Update progress to 'running' with total count
260 $this->updateProgress($dataType, 'running', 0, 0, 0, $total, current_time('mysql'), null);
261
262 try {
263 switch ($dataType) {
264 case 'pro_features':
265 $result = (new ProFeaturesMigration($this))->run();
266 break;
267 case 'pro_reviews_cpt':
268 $result = (new ProReviewCptMigration($this))->run();
269 break;
270 case 'pro_downloads':
271 $result = (new ProDownloadsMigration($this))->run();
272 break;
273 case 'trips':
274 $result = (new TripMigration($this))->run();
275 break;
276 case 'bookings':
277 $result = (new BookingMigration($this))->run();
278 break;
279 case 'customers':
280 $result = (new CustomerMigration($this))->run();
281 break;
282 case 'destinations':
283 $result = (new DestinationMigration($this))->run();
284 break;
285 case 'activities':
286 $result = (new ActivityMigration($this))->run();
287 break;
288 case 'trip_categories':
289 $result = (new TripCategoryMigration($this))->run();
290 break;
291 case 'attributes':
292 $result = (new AttributeMigration($this))->run();
293 break;
294 case 'reviews':
295 $result = (new ReviewMigration($this))->run();
296 break;
297 case 'enquiries':
298 $result = (new EnquiryMigration($this))->run();
299 break;
300 case 'coupons':
301 $result = (new CouponMigration($this))->run();
302 break;
303 case 'tour_dates':
304 $result = (new TourDateMigration($this))->run();
305 break;
306 case 'traveler_categories':
307 $result = (new TravelerCategoriesMigration($this))->run();
308 break;
309 case 'itinerary':
310 $result = (new ItineraryMigration($this))->run();
311 break;
312 case 'settings':
313 $result = (new SettingsMigration($this))->run();
314 break;
315 case 'services':
316 $result = (new ServicesMigration($this))->run();
317 break;
318 case 'availability_conditions':
319 $result = (new AvailabilityConditionsMigration($this))->run();
320 break;
321 default:
322 throw new \Exception("Unknown data type: {$dataType}");
323 }
324
325 // Taxonomy → trip links must work when migrations run out of order (e.g. trips before destinations).
326 if ($dataType === 'destinations') {
327 $this->repairTripDestinationsForAllLegacyTours();
328 } elseif ($dataType === 'activities') {
329 $this->repairTripActivitiesForAllLegacyTours();
330 } elseif ($dataType === 'trips') {
331 $this->repairTripDestinationsForAllLegacyTours();
332 $this->repairTripActivitiesForAllLegacyTours();
333 }
334
335 $duration = microtime(true) - $startTime;
336
337 // Update progress to 'completed'
338 $total = $result['migrated'] + $result['skipped'] + $result['failed'];
339 $this->updateProgress(
340 $dataType,
341 'completed',
342 $result['migrated'],
343 $result['skipped'],
344 $result['failed'],
345 $total,
346 null,
347 current_time('mysql')
348 );
349
350 // Log migration completion
351 Logger::info("Migration completed for {$dataType}", [
352 'source' => 'migration',
353 'data_type' => $dataType,
354 'migrated' => $result['migrated'],
355 'skipped' => $result['skipped'],
356 'failed' => $result['failed'],
357 'duration' => round($duration, 2)
358 ]);
359
360 if ($result['failed'] > 0) {
361 Logger::warning("{$result['failed']} items failed to migrate for {$dataType}", [
362 'source' => 'migration',
363 'data_type' => $dataType,
364 'failed_count' => $result['failed']
365 ]);
366 }
367
368 // Log migration
369 $this->logMigration($dataType, $result, $duration);
370
371 // If every data type is now complete, clear progress options
372 $this->finalizeProgressIfAllComplete();
373
374 return [
375 'success' => true,
376 'data_type' => $dataType,
377 'migrated' => $result['migrated'],
378 'skipped' => $result['skipped'],
379 'failed' => $result['failed'],
380 'duration' => round($duration, 2),
381 ];
382
383 } catch (\Exception $e) {
384 // Update progress to 'failed'
385 $this->updateProgress($dataType, 'failed', 0, 0, 0, 0, null, current_time('mysql'));
386
387 return [
388 'success' => false,
389 'data_type' => $dataType,
390 'error' => $e->getMessage(),
391 ];
392 }
393 }
394
395 /**
396 * Update migration progress for a specific data type
397 */
398 public function updateProgress(
399 string $dataType,
400 string $status,
401 int $migrated,
402 int $skipped,
403 int $failed,
404 int $total,
405 ?string $startedAt,
406 ?string $completedAt
407 ): void {
408 $progress = get_option('yatra_migration_progress', []);
409
410 if (!isset($progress[$dataType])) {
411 $progress[$dataType] = [];
412 }
413
414 $progress[$dataType]['status'] = $status;
415 $progress[$dataType]['migrated'] = $migrated;
416 $progress[$dataType]['skipped'] = $skipped;
417 $progress[$dataType]['failed'] = $failed;
418 $progress[$dataType]['total'] = $total;
419
420 if ($startedAt !== null) {
421 $progress[$dataType]['started_at'] = $startedAt;
422 }
423
424 if ($completedAt !== null) {
425 $progress[$dataType]['completed_at'] = $completedAt;
426 }
427
428 update_option('yatra_migration_progress', $progress, false);
429 }
430
431 /**
432 * Migrate all data types.
433 *
434 * Queues a full run on Action Scheduler (bundled with Yatra) so work happens outside
435 * the admin HTTP request. Falls back to WP-Cron if AS is unavailable. Immediately runs
436 * the AS queue runner once so progress starts without waiting for another page load.
437 */
438 public function migrateAll(bool $force = false): array
439 {
440 try {
441 $this->ensureSchemaUpToDate();
442 $this->forceMigration = $force;
443
444 if ($this->forceMigration) {
445 $this->resetMigratedData();
446 }
447
448 // Check if migration is already running
449 $progress = $this->getMigrationProgress();
450 if ($progress['any_running'] && !$progress['all_complete']) {
451 return [
452 'success' => false,
453 'error' => 'Migration is already in progress. Please wait for it to complete.',
454 ];
455 }
456
457 // Initialize progress tracking in DB
458 $this->initializeProgress($force);
459
460 $this->scheduleFullMigrationBackgroundRun($force);
461
462 $pro = ProMigrationReadiness::getState();
463 if (!$pro['ready'] && $pro['warning_message'] !== '') {
464 Logger::warning($pro['warning_message'], [
465 'source' => 'migration',
466 'pro_migration' => $pro,
467 ]);
468 }
469
470 $payload = [
471 'success' => true,
472 'message' => 'Migration started successfully.',
473 'started_at' => current_time('mysql'),
474 'background' => true,
475 'pro_migration' => $pro,
476 ];
477 if (!$pro['ready'] && $pro['warning_message'] !== '') {
478 $payload['warnings'] = [$pro['warning_message']];
479 }
480
481 return $payload;
482
483 } catch (\Exception $e) {
484 return [
485 'success' => false,
486 'error' => $e->getMessage(),
487 ];
488 }
489 }
490
491 /**
492 * Queue migrateAllDirect on Action Scheduler (preferred) or WP-Cron, and try to start work immediately.
493 */
494 private function scheduleFullMigrationBackgroundRun(bool $force): void
495 {
496 // Avoid duplicate full runs from a previous attempt
497 if (function_exists('wp_unschedule_hook')) {
498 wp_unschedule_hook('yatra_migration_background_run');
499 } else {
500 wp_clear_scheduled_hook('yatra_migration_background_run', [false]);
501 wp_clear_scheduled_hook('yatra_migration_background_run', [true]);
502 }
503 if (function_exists('as_unschedule_all_actions')) {
504 // Empty group + empty args => cancel_actions_by_hook (clears all arg variants)
505 as_unschedule_all_actions('yatra_migration_background_run');
506 }
507
508 if (function_exists('as_schedule_single_action')) {
509 as_schedule_single_action(
510 time(),
511 'yatra_migration_background_run',
512 [$force],
513 'yatra_migration'
514 );
515 } else {
516 wp_schedule_single_event(time(), 'yatra_migration_background_run', [$force]);
517 }
518
519 spawn_cron();
520 $this->kickQueueRunner();
521 }
522
523 /**
524 * Run Action Scheduler's queue runner (processes pending yatra_migration jobs in this or a follow-up request).
525 */
526 public function kickQueueRunner(): void
527 {
528 try {
529 if (class_exists(\ActionScheduler::class)) {
530 $runner = \ActionScheduler::runner();
531 if ($runner && method_exists($runner, 'run')) {
532 $runner->run();
533
534 return;
535 }
536 }
537
538 if (class_exists(\ActionScheduler_QueueRunner::class)) {
539 $runner = \ActionScheduler_QueueRunner::instance();
540 if ($runner && method_exists($runner, 'run')) {
541 $runner->run();
542 }
543 }
544 } catch (\Throwable $e) {
545 Logger::warning('kickQueueRunner: ' . $e->getMessage(), [
546 'source' => 'migration',
547 ]);
548 }
549 }
550
551 /**
552 * Initialize progress tracking in DB before background run
553 */
554 private function initializeProgress(bool $force = false): void
555 {
556 $this->ensureSchemaUpToDate();
557 $this->forceMigration = $force;
558
559 // Get old data counts
560 $detector = new MigrationDetector();
561 $oldData = $detector->detectOldData();
562
563 // Initialize progress tracking
564 $progress = [];
565 foreach ($this->dataTypesOrder as $dataType) {
566 $count = isset($oldData[$dataType]) ? (int)$oldData[$dataType]['count'] : 0;
567 $progress[$dataType] = [
568 'status' => 'pending',
569 'migrated' => 0,
570 'skipped' => 0,
571 'failed' => 0,
572 'total' => $count,
573 'started_at' => null,
574 'completed_at' => null,
575 ];
576 }
577
578 update_option('yatra_migration_progress', $progress, false);
579 update_option('yatra_migration_started_at', current_time('mysql'));
580 delete_option('yatra_migration_rewrite_flushed_for_started_at');
581 }
582
583 /**
584 * Migrate all data types directly (synchronous, no Action Scheduler).
585 *
586 * Processes each migration sequentially in the current request.
587 * Increases PHP time/memory limits to handle large datasets.
588 */
589 public function migrateAllDirect(bool $force = false): array
590 {
591 // Increase PHP limits for long migration
592 @set_time_limit(3600);
593 @ini_set('memory_limit', '1G');
594
595 // Progress has already been initialized by migrateAll() before scheduling
596 // But we refresh the force variable just in case
597 $this->forceMigration = $force;
598
599 // Process each data type sequentially
600 $results = [];
601 $overallSuccess = true;
602
603 foreach ($this->dataTypesOrder as $dataType) {
604 try {
605 Logger::info("Direct migration: processing {$dataType}", ['source' => 'migration']);
606 $result = $this->processMigration($dataType, $force);
607 $results[$dataType] = $result;
608
609 if (isset($result['success']) && !$result['success']) {
610 $overallSuccess = false;
611 Logger::error("Direct migration: {$dataType} failed", [
612 'source' => 'migration',
613 'error' => $result['error'] ?? 'Unknown'
614 ]);
615 }
616 } catch (\Throwable $e) {
617 $overallSuccess = false;
618 $results[$dataType] = [
619 'success' => false,
620 'data_type' => $dataType,
621 'error' => $e->getMessage(),
622 ];
623 // Update progress to failed for this type
624 $this->updateProgress($dataType, 'failed', 0, 0, 0, 0, null, current_time('mysql'));
625
626 Logger::error("Direct migration: {$dataType} threw exception: {$e->getMessage()}", [
627 'source' => 'migration',
628 'data_type' => $dataType,
629 ]);
630 }
631 }
632
633 // Flush rewrite rules once all types are completed (also retries if finalize was skipped mid-run).
634 $this->finalizeProgressIfAllComplete();
635
636 return [
637 'success' => $overallSuccess,
638 'message' => $overallSuccess
639 ? 'All migrations completed successfully.'
640 : 'Migration completed with some errors. Check individual results.',
641 'mode' => 'direct',
642 'results' => $results,
643 'data_types' => $this->dataTypesOrder,
644 ];
645 }
646
647 /**
648 * Cancel any pending or running migration jobs and mark status as cancelled.
649 */
650 public function cancelMigration(): array
651 {
652 if (function_exists('as_unschedule_all_actions')) {
653 as_unschedule_all_actions('', [], 'yatra_migration');
654 }
655 if (function_exists('wp_unschedule_hook')) {
656 wp_unschedule_hook('yatra_migration_background_run');
657 } else {
658 wp_clear_scheduled_hook('yatra_migration_background_run', [false]);
659 wp_clear_scheduled_hook('yatra_migration_background_run', [true]);
660 }
661
662 $progress = get_option('yatra_migration_progress', []);
663 $updated = false;
664
665 foreach ($progress as $dataType => $status) {
666 if (in_array($status['status'] ?? '', ['pending', 'running'], true)) {
667 $progress[$dataType]['status'] = 'cancelled';
668 $progress[$dataType]['completed_at'] = current_time('mysql');
669 $updated = true;
670 }
671 }
672
673 if ($updated) {
674 update_option('yatra_migration_progress', $progress, false);
675 }
676
677 Logger::info('Migration cancelled', ['source' => 'migration']);
678
679 return [
680 'success' => true,
681 'message' => 'Migration cancelled successfully.',
682 'progress' => $progress,
683 ];
684 }
685
686 /**
687 * Types with total 0 never get a {@see processMigration()} run; mark them completed so
688 * {@see finalizeProgressIfAllComplete()} can detect an all-done run and flush rewrite rules.
689 *
690 * @param array<string, array<string, mixed>> $progress
691 * @return array{0: array<string, array<string, mixed>>, 1: bool}
692 */
693 private function applyZeroCountAutoComplete(array $progress): array
694 {
695 $changed = false;
696 foreach ($progress as $dataType => $status) {
697 $total = isset($status['total']) ? (int) $status['total'] : 0;
698
699 if ($total === 0 && ($status['status'] ?? '') !== 'completed') {
700 $progress[$dataType]['status'] = 'completed';
701 $progress[$dataType]['completed_at'] = $status['completed_at'] ?? current_time('mysql');
702 $changed = true;
703 }
704 }
705
706 return [$progress, $changed];
707 }
708
709 /**
710 * Get migration progress for all data types.
711 */
712 public function getMigrationProgress(): array
713 {
714 $progress = get_option('yatra_migration_progress', []);
715
716 // Attempt to automatically process pending migrations if Action Scheduler queue isn't running
717 $this->maybeKickActionScheduler($progress);
718
719 // Refresh after potential processing
720 $progress = get_option('yatra_migration_progress', []);
721 $startedAt = get_option('yatra_migration_started_at', null);
722 $progressChanged = false;
723
724 // If progress is empty (no migration has been run), set all_complete to false
725 $allComplete = !empty($progress);
726 $anyRunning = false;
727
728 [$progress, $zeroChanged] = $this->applyZeroCountAutoComplete($progress);
729 if ($zeroChanged) {
730 $progressChanged = true;
731 }
732
733 foreach ($progress as $dataType => $status) {
734
735 if (($status['status'] ?? '') === 'pending' || ($status['status'] ?? '') === 'running') {
736 $allComplete = false;
737 }
738
739 if (in_array(($status['status'] ?? ''), ['failed', 'cancelled'], true)) {
740 $allComplete = false;
741 }
742
743 if (($status['status'] ?? '') === 'running') {
744 $anyRunning = true;
745 }
746 }
747
748 if ($progressChanged) {
749 update_option('yatra_migration_progress', $progress, false);
750 }
751
752 if ($allComplete && !empty($progress)) {
753 $this->finalizeProgressIfAllComplete();
754 }
755
756 return [
757 'progress' => $progress,
758 'started_at' => $startedAt,
759 'all_complete' => $allComplete,
760 'any_running' => $anyRunning,
761 'pro_migration' => ProMigrationReadiness::getState(),
762 ];
763 }
764
765 /**
766 * Check if a table exists.
767 */
768 public function tableExists(string $table): bool
769 {
770 $result = $this->wpdb->get_var(
771 $this->wpdb->prepare("SHOW TABLES LIKE %s", $table)
772 );
773 return $result === $table;
774 }
775
776 /**
777 * Generate unique slug by adding suffix if slug already exists
778 */
779 public function generateUniqueSlug(string $baseSlug, string $table): string
780 {
781 $slug = $baseSlug;
782 $suffix = 1;
783
784 // Check if slug exists
785 while ($this->wpdb->get_var($this->wpdb->prepare(
786 "SELECT id FROM {$this->wpdb->prefix}{$table} WHERE slug = %s",
787 $slug
788 ))) {
789 $slug = $baseSlug . '-' . $suffix;
790 $suffix++;
791 }
792
793 return $slug;
794 }
795
796 public function isTripMigrated(int $oldTripId): bool
797 {
798 return (bool) $this->getRawPostMeta($oldTripId, '_migrated_to_trip_id');
799 }
800
801 public function getMigratedTripId(int $oldTripId): ?int
802 {
803 $newId = $this->getRawPostMeta($oldTripId, '_migrated_to_trip_id');
804 return $newId ? (int) $newId : null;
805 }
806
807 /**
808 * Get all post meta for a post using raw SQL.
809 * Old post types (tour, yatra-booking, etc.) are NOT registered in the new plugin,
810 * so we must use raw queries instead of get_post_meta().
811 */
812 public function getPostMeta(int $postId): array
813 {
814 $rows = $this->wpdb->get_results($this->wpdb->prepare(
815 "SELECT meta_key, meta_value FROM {$this->wpdb->postmeta} WHERE post_id = %d",
816 $postId
817 ));
818
819 $result = [];
820 foreach ($rows as $row) {
821 $result[$row->meta_key] = $row->meta_value;
822 }
823
824 return $result;
825 }
826
827 /**
828 * Get a single post meta value using raw SQL.
829 */
830 public function getRawPostMeta(int $postId, string $metaKey): ?string
831 {
832 return $this->wpdb->get_var($this->wpdb->prepare(
833 "SELECT meta_value FROM {$this->wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1",
834 $postId,
835 $metaKey
836 ));
837 }
838
839 /**
840 * Set/update a post meta value using raw SQL.
841 */
842 public function setRawPostMeta(int $postId, string $metaKey, string $metaValue): void
843 {
844 $exists = $this->wpdb->get_var($this->wpdb->prepare(
845 "SELECT meta_id FROM {$this->wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1",
846 $postId,
847 $metaKey
848 ));
849
850 if ($exists) {
851 $this->wpdb->update(
852 $this->wpdb->postmeta,
853 ['meta_value' => $metaValue],
854 ['post_id' => $postId, 'meta_key' => $metaKey]
855 );
856 } else {
857 $this->wpdb->insert(
858 $this->wpdb->postmeta,
859 ['post_id' => $postId, 'meta_key' => $metaKey, 'meta_value' => $metaValue]
860 );
861 }
862 }
863
864 /**
865 * Get a single term meta value using raw SQL.
866 * Old taxonomies (destination, activity, attributes) are NOT registered in the new plugin.
867 */
868 public function getRawTermMeta(int $termId, string $metaKey): ?string
869 {
870 return $this->wpdb->get_var($this->wpdb->prepare(
871 "SELECT meta_value FROM {$this->wpdb->termmeta} WHERE term_id = %d AND meta_key = %s LIMIT 1",
872 $termId,
873 $metaKey
874 ));
875 }
876
877 /**
878 * Latest term meta value when duplicate keys exist (imports / re-saves).
879 */
880 public function getRawTermMetaLatest(int $termId, string $metaKey): ?string
881 {
882 return $this->wpdb->get_var($this->wpdb->prepare(
883 "SELECT meta_value FROM {$this->wpdb->termmeta} WHERE term_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1",
884 $termId,
885 $metaKey
886 ));
887 }
888
889 /**
890 * Set/update a term meta value using raw SQL.
891 */
892 public function setRawTermMeta(int $termId, string $metaKey, string $metaValue): void
893 {
894 $exists = $this->wpdb->get_var($this->wpdb->prepare(
895 "SELECT meta_id FROM {$this->wpdb->termmeta} WHERE term_id = %d AND meta_key = %s LIMIT 1",
896 $termId,
897 $metaKey
898 ));
899
900 if ($exists) {
901 $this->wpdb->update(
902 $this->wpdb->termmeta,
903 ['meta_value' => $metaValue],
904 ['term_id' => $termId, 'meta_key' => $metaKey]
905 );
906 } else {
907 $this->wpdb->insert(
908 $this->wpdb->termmeta,
909 ['term_id' => $termId, 'meta_key' => $metaKey, 'meta_value' => $metaValue]
910 );
911 }
912 }
913
914 /**
915 * Fetch the first non-empty legacy meta value from several possible keys.
916 */
917 public function getLegacyMetaValue(array $meta, array $keys, $default = null)
918 {
919 foreach ($keys as $key) {
920 if (isset($meta[$key])) {
921 $value = $meta[$key];
922 if (is_array($value)) {
923 $value = reset($value);
924 }
925 if ($value !== '' && $value !== null) {
926 return $value;
927 }
928 }
929 }
930
931 return $default;
932 }
933
934 /**
935 * Make sure the core Yatra database schema is up to date before migrations run.
936 */
937 private function ensureSchemaUpToDate(): void
938 {
939 if ($this->schemaEnsured) {
940 return;
941 }
942
943 try {
944 \Yatra\Services\InstallerService::createDatabaseTables();
945 } catch (\Throwable $e) {
946 Logger::error('Failed to ensure Yatra tables exist before migration', [
947 'source' => 'migration',
948 'error' => $e->getMessage(),
949 ]);
950 }
951
952 $this->ensureTripPricingColumns();
953
954 $this->schemaEnsured = true;
955 }
956
957 /**
958 * Some legacy installs may miss newer pricing columns; add them if needed.
959 */
960 private function ensureTripPricingColumns(): void
961 {
962 $table = TripsTable::getTableName();
963 $columns = $this->wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
964
965 if (empty($columns)) {
966 return;
967 }
968
969 if (!in_array('discounted_price', $columns, true)) {
970 $this->wpdb->query(
971 "ALTER TABLE {$table} ADD COLUMN `discounted_price` decimal(10,2) DEFAULT NULL AFTER `original_price`"
972 );
973 }
974 }
975
976 private function logMigration(string $dataType, array $result, float $duration): void
977 {
978 $log = get_option('yatra_migration_log', []);
979
980 $log[] = [
981 'data_type' => $dataType,
982 'migrated' => $result['migrated'],
983 'skipped' => $result['skipped'],
984 'failed' => $result['failed'],
985 'duration' => $duration,
986 'timestamp' => current_time('mysql'),
987 ];
988
989 update_option('yatra_migration_log', $log, false); // autoload=false: migration-time only
990 }
991
992 private function getMigrationLog(): array
993 {
994 return get_option('yatra_migration_log', []);
995 }
996
997 /**
998 * Manually clear migration progress and logs (used by UI dismiss action).
999 */
1000 public function clearMigrationData(): array
1001 {
1002 delete_option('yatra_migration_progress');
1003 delete_option('yatra_migration_started_at');
1004 delete_option('yatra_migration_log');
1005 delete_option('yatra_migration_rewrite_flushed_for_started_at');
1006
1007 return [
1008 'success' => true,
1009 'message' => 'Migration data cleared.',
1010 ];
1011 }
1012
1013 /**
1014 * If all data types are marked completed, preserve the progress data for summary display.
1015 * Note: We no longer delete the progress data so users can see the migration summary.
1016 */
1017 private function finalizeProgressIfAllComplete(): void
1018 {
1019 $progress = get_option('yatra_migration_progress', []);
1020
1021 if (empty($progress)) {
1022 return;
1023 }
1024
1025 [$progress, $zeroChanged] = $this->applyZeroCountAutoComplete($progress);
1026 if ($zeroChanged) {
1027 update_option('yatra_migration_progress', $progress, false);
1028 }
1029
1030 foreach ($progress as $status) {
1031 if (($status['status'] ?? '') !== 'completed') {
1032 return;
1033 }
1034 }
1035
1036 $this->flushRewriteRulesOnceForCurrentMigrationRun();
1037
1038 // Keep the progress data so the UI can display the summary
1039 // Only clear logs to save space
1040 delete_option('yatra_migration_log');
1041 }
1042
1043 /**
1044 * Permalinks and trip/archive routes need a rewrite flush after legacy data moves to 3.x structures.
1045 * Runs once per migration run (keyed by {@see initializeProgress()} started_at).
1046 */
1047 private function flushRewriteRulesOnceForCurrentMigrationRun(): void
1048 {
1049 if (!function_exists('flush_rewrite_rules')) {
1050 return;
1051 }
1052
1053 $startedAt = get_option('yatra_migration_started_at');
1054 if ($startedAt === null || $startedAt === '') {
1055 return;
1056 }
1057
1058 $startedAtStr = is_string($startedAt) ? $startedAt : (string) $startedAt;
1059 $doneFor = get_option('yatra_migration_rewrite_flushed_for_started_at', '');
1060
1061 if ($doneFor === $startedAtStr) {
1062 return;
1063 }
1064
1065 flush_rewrite_rules(true);
1066 update_option('yatra_migration_rewrite_flushed_for_started_at', $startedAtStr, false);
1067
1068 Logger::info('Rewrite rules flushed after Yatra migration completed.', [
1069 'source' => 'migration',
1070 'migration_started_at' => $startedAtStr,
1071 ]);
1072 }
1073
1074 /**
1075 * Migrate coupons from old custom post type
1076 * NOTE: This is a legacy duplicate method. The primary coupon migration
1077 * is handled by CouponMigration class with correct meta key mappings.
1078 * This method is kept for backward compatibility but delegates to CouponMigration.
1079 */
1080 public function migrateCoupons(): array
1081 {
1082 return (new CouponMigration($this))->run();
1083 }
1084
1085 /**
1086 * Migrate tour dates from old table structure.
1087 *
1088 * Old table: wp_yatra_tour_dates (start_date, end_date, max_travellers, pricing, active, note_to_customer, note_to_admin)
1089 * New table: DeparturesTable (trip_id, date, time, max_capacity, booked_count, status, source, price_override, notes)
1090 */
1091 public function migrateTourDates(): array
1092 {
1093 global $wpdb;
1094
1095 $migrated = 0;
1096 $skipped = 0;
1097 $failed = 0;
1098
1099 $oldTable = $wpdb->prefix . 'yatra_tour_dates';
1100 $departuresTable = DeparturesTable::getTableName();
1101
1102 // Check if old table exists
1103 if ($wpdb->get_var("SHOW TABLES LIKE '{$oldTable}'") !== $oldTable) {
1104 return compact('migrated', 'skipped', 'failed');
1105 }
1106
1107 // Get all old tour dates
1108 $oldTourDates = $wpdb->get_results("SELECT * FROM {$oldTable}");
1109 $total = count($oldTourDates);
1110
1111 foreach ($oldTourDates as $oldDate) {
1112 try {
1113 // Get the migrated trip ID
1114 $newTripId = $this->getMigratedTripId($oldDate->tour_id);
1115
1116 if (!$newTripId) {
1117 $failed++;
1118 $this->updateProgress('tour_dates', 'running', $migrated, $skipped, $failed, $total, null, null);
1119 continue;
1120 }
1121
1122 // Check if a departure for this trip+date already exists
1123 $startDate = $oldDate->start_date ?? null;
1124 if (empty($startDate)) {
1125 $skipped++;
1126 $this->updateProgress('tour_dates', 'running', $migrated, $skipped, $failed, $total, null, null);
1127 continue;
1128 }
1129
1130 $exists = $wpdb->get_var($wpdb->prepare(
1131 "SELECT id FROM {$departuresTable} WHERE trip_id = %d AND date = %s",
1132 $newTripId,
1133 $startDate
1134 ));
1135
1136 if ($exists && !$this->isForceMigration()) {
1137 $skipped++;
1138 $this->updateProgress('tour_dates', 'running', $migrated, $skipped, $failed, $total, null, null);
1139 continue;
1140 }
1141
1142 // Parse pricing override
1143 $priceOverride = null;
1144 $priceByTravelerType = null;
1145 if (!empty($oldDate->pricing)) {
1146 $pricing = maybe_unserialize($oldDate->pricing);
1147 if (is_numeric($pricing)) {
1148 $priceOverride = (float) $pricing;
1149 } elseif (is_array($pricing)) {
1150 $priceByTravelerType = json_encode($pricing);
1151 }
1152 }
1153
1154 // Combine notes
1155 $notes = trim(
1156 ($oldDate->note_to_customer ?? '') .
1157 (!empty($oldDate->note_to_admin) ? "\n[Admin] " . $oldDate->note_to_admin : '')
1158 );
1159
1160 $departureData = [
1161 'trip_id' => $newTripId,
1162 'date' => $startDate,
1163 'time' => null,
1164 'max_capacity' => (int) ($oldDate->max_travellers ?? 0),
1165 'booked_count' => 0,
1166 'status' => !empty($oldDate->active) ? 'upcoming' : 'cancelled',
1167 'source' => 'migrated',
1168 'price_override' => $priceOverride,
1169 'price_by_traveler_type' => $priceByTravelerType,
1170 'notes' => !empty($notes) ? $notes : null,
1171 'created_at' => $oldDate->created_at ?? current_time('mysql'),
1172 'updated_at' => $oldDate->updated_at ?? current_time('mysql'),
1173 ];
1174
1175 if ($exists && $this->isForceMigration()) {
1176 $wpdb->update($departuresTable, $departureData, ['id' => $exists]);
1177 $migrated++;
1178 } else {
1179 $inserted = $wpdb->insert($departuresTable, $departureData);
1180 if ($inserted) {
1181 $migrated++;
1182 } else {
1183 $failed++;
1184 Logger::error("Failed to insert departure: {$wpdb->last_error}", [
1185 'source' => 'migration', 'data_type' => 'tour_dates'
1186 ]);
1187 }
1188 }
1189
1190 $this->updateProgress('tour_dates', 'running', $migrated, $skipped, $failed, $total, null, null);
1191
1192 } catch (\Exception $e) {
1193 $failed++;
1194 $this->updateProgress('tour_dates', 'running', $migrated, $skipped, $failed, $total, null, null);
1195 }
1196 }
1197
1198 return compact('migrated', 'skipped', 'failed');
1199 }
1200
1201 /**
1202 * Re-run destination linking for every legacy tour that already has a migrated trip row.
1203 * Needed when "trips" migrated before "destinations" (term meta did not exist yet).
1204 */
1205 public function repairTripDestinationsForAllLegacyTours(): void
1206 {
1207 $ids = $this->wpdb->get_col(
1208 "SELECT ID FROM {$this->wpdb->posts} WHERE post_type = 'tour' AND post_status != 'auto-draft'"
1209 );
1210 $n = 0;
1211 foreach ($ids as $oldId) {
1212 $oldId = (int) $oldId;
1213 $newTripId = $this->getMigratedTripId($oldId);
1214 if (!$newTripId) {
1215 continue;
1216 }
1217 $this->migrateTripDestinations($oldId, $newTripId);
1218 $n++;
1219 }
1220 if ($n > 0) {
1221 Logger::info("Trip–destination link pass: checked {$n} legacy tour(s) with a migrated trip id.", [
1222 'source' => 'migration',
1223 ]);
1224 }
1225 }
1226
1227 /**
1228 * Re-run activity linking for every legacy tour that already has a migrated trip row.
1229 */
1230 public function repairTripActivitiesForAllLegacyTours(): void
1231 {
1232 $ids = $this->wpdb->get_col(
1233 "SELECT ID FROM {$this->wpdb->posts} WHERE post_type = 'tour' AND post_status != 'auto-draft'"
1234 );
1235 $n = 0;
1236 foreach ($ids as $oldId) {
1237 $oldId = (int) $oldId;
1238 $newTripId = $this->getMigratedTripId($oldId);
1239 if (!$newTripId) {
1240 continue;
1241 }
1242 $this->migrateTripActivities($oldId, $newTripId);
1243 $n++;
1244 }
1245 if ($n > 0) {
1246 Logger::info("Trip–activity link pass: checked {$n} legacy tour(s) with a migrated trip id.", [
1247 'source' => 'migration',
1248 ]);
1249 }
1250 }
1251
1252 /**
1253 * Map a legacy taxonomy term to ClassificationsTable.id after taxonomy migration.
1254 *
1255 * Prefer term meta from DestinationMigration/ActivityMigration (handles slug de-duplication e.g. paris vs paris-1).
1256 */
1257 private function resolveLegacyTermToClassificationId(
1258 int $termId,
1259 string $termSlug,
1260 string $termName,
1261 string $legacyTaxonomy,
1262 string $classificationType
1263 ): ?int {
1264 $classificationsTable = ClassificationsTable::getTableName();
1265 $metaKey = '_yatra_migrated_' . $legacyTaxonomy . '_id';
1266
1267 $mapped = $this->getRawTermMetaLatest($termId, $metaKey);
1268 if ($mapped !== null && $mapped !== '') {
1269 $id = (int) trim((string) $mapped);
1270 if ($id > 0) {
1271 $ok = $this->wpdb->get_var($this->wpdb->prepare(
1272 "SELECT id FROM {$classificationsTable} WHERE id = %d AND type = %s",
1273 $id,
1274 $classificationType
1275 ));
1276 if ($ok) {
1277 return (int) $ok;
1278 }
1279 }
1280 }
1281
1282 $slug = trim((string) $termSlug);
1283 if ($slug !== '') {
1284 $bySlug = $this->wpdb->get_var($this->wpdb->prepare(
1285 "SELECT id FROM {$classificationsTable} WHERE slug = %s AND type = %s",
1286 $slug,
1287 $classificationType
1288 ));
1289 if ($bySlug) {
1290 return (int) $bySlug;
1291 }
1292 }
1293
1294 $name = trim((string) $termName);
1295 if ($name !== '' && function_exists('sanitize_title')) {
1296 $fromName = sanitize_title($name);
1297 if ($fromName !== '' && $fromName !== $slug) {
1298 $byNameSlug = $this->wpdb->get_var($this->wpdb->prepare(
1299 "SELECT id FROM {$classificationsTable} WHERE slug = %s AND type = %s",
1300 $fromName,
1301 $classificationType
1302 ));
1303 if ($byNameSlug) {
1304 return (int) $byNameSlug;
1305 }
1306 }
1307 }
1308
1309 if ($name !== '') {
1310 $byName = $this->wpdb->get_var($this->wpdb->prepare(
1311 "SELECT id FROM {$classificationsTable} WHERE type = %s AND name = %s LIMIT 1",
1312 $classificationType,
1313 $name
1314 ));
1315 if ($byName) {
1316 return (int) $byName;
1317 }
1318 }
1319
1320 return null;
1321 }
1322
1323 /**
1324 * Migrate trip destinations relationship.
1325 *
1326 * Uses raw SQL to query wp_term_relationships + wp_term_taxonomy + wp_terms
1327 * because the old 'destination' taxonomy is NOT registered in the new plugin.
1328 * Inserts into ClassificationsTable (type=destination) looked up by slug,
1329 * and links via TripClassificationsTable.
1330 */
1331 public function migrateTripDestinations(int $oldTripId, int $newTripId): void
1332 {
1333 // Raw SQL: get terms assigned to this post via the 'destination' taxonomy
1334 $destinations = $this->wpdb->get_results($this->wpdb->prepare(
1335 "SELECT t.term_id, t.name, t.slug
1336 FROM {$this->wpdb->terms} t
1337 INNER JOIN {$this->wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1338 INNER JOIN {$this->wpdb->term_relationships} tr ON tt.term_taxonomy_id = tr.term_taxonomy_id
1339 WHERE tr.object_id = %d AND tt.taxonomy = 'destination'",
1340 $oldTripId
1341 ));
1342
1343 if (empty($destinations)) {
1344 return;
1345 }
1346
1347 $tripClassificationsTable = TripClassificationsTable::getTableName();
1348
1349 foreach ($destinations as $index => $destination) {
1350 $termId = (int) $destination->term_id;
1351 $newDestinationId = $this->resolveLegacyTermToClassificationId(
1352 $termId,
1353 (string) ($destination->slug ?? ''),
1354 (string) ($destination->name ?? ''),
1355 'destination',
1356 ClassificationTypes::DESTINATION
1357 );
1358
1359 if (!$newDestinationId) {
1360 Logger::warning('Could not map legacy destination term to a classification row; skipping trip link.', [
1361 'source' => 'migration',
1362 'old_trip_id' => $oldTripId,
1363 'new_trip_id' => $newTripId,
1364 'term_id' => $termId,
1365 'term_slug' => $destination->slug ?? '',
1366 'term_name' => $destination->name ?? '',
1367 ]);
1368 continue;
1369 }
1370
1371 $exists = $this->wpdb->get_var($this->wpdb->prepare(
1372 "SELECT id FROM {$tripClassificationsTable}
1373 WHERE trip_id = %d AND classification_id = %d AND classification_type = %s",
1374 $newTripId,
1375 $newDestinationId,
1376 ClassificationTypes::DESTINATION
1377 ));
1378
1379 if (!$exists) {
1380 $inserted = $this->wpdb->insert(
1381 $tripClassificationsTable,
1382 [
1383 'trip_id' => $newTripId,
1384 'classification_id' => $newDestinationId,
1385 'classification_type' => ClassificationTypes::DESTINATION,
1386 'relationship_type' => ($index === 0) ? 'primary' : 'secondary',
1387 'sort_order' => $index,
1388 'is_featured' => ($index === 0) ? 1 : 0,
1389 'is_active' => 1,
1390 'created_at' => current_time('mysql'),
1391 'updated_at' => current_time('mysql'),
1392 ]
1393 );
1394 if ($inserted === false) {
1395 Logger::error('Failed to insert trip–destination classification row: ' . $this->wpdb->last_error, [
1396 'source' => 'migration',
1397 'new_trip_id' => $newTripId,
1398 'classification_id' => $newDestinationId,
1399 ]);
1400 }
1401 }
1402 }
1403 }
1404
1405 /**
1406 * Migrate trip activities relationship.
1407 *
1408 * Uses raw SQL to query wp_term_relationships + wp_term_taxonomy + wp_terms
1409 * because the old 'activity' taxonomy is NOT registered in the new plugin.
1410 * Inserts into ClassificationsTable (type=activity) looked up by slug,
1411 * and links via TripClassificationsTable.
1412 */
1413 public function migrateTripActivities(int $oldTripId, int $newTripId): void
1414 {
1415 // Raw SQL: get terms assigned to this post via the 'activity' taxonomy
1416 $activities = $this->wpdb->get_results($this->wpdb->prepare(
1417 "SELECT t.term_id, t.name, t.slug
1418 FROM {$this->wpdb->terms} t
1419 INNER JOIN {$this->wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
1420 INNER JOIN {$this->wpdb->term_relationships} tr ON tt.term_taxonomy_id = tr.term_taxonomy_id
1421 WHERE tr.object_id = %d AND tt.taxonomy = 'activity'",
1422 $oldTripId
1423 ));
1424
1425 if (empty($activities)) {
1426 return;
1427 }
1428
1429 $tripClassificationsTable = TripClassificationsTable::getTableName();
1430
1431 foreach ($activities as $index => $activity) {
1432 $termId = (int) $activity->term_id;
1433 $newActivityId = $this->resolveLegacyTermToClassificationId(
1434 $termId,
1435 (string) ($activity->slug ?? ''),
1436 (string) ($activity->name ?? ''),
1437 'activity',
1438 ClassificationTypes::ACTIVITY
1439 );
1440
1441 if (!$newActivityId) {
1442 Logger::warning('Could not map legacy activity term to a classification row; skipping trip link.', [
1443 'source' => 'migration',
1444 'old_trip_id' => $oldTripId,
1445 'new_trip_id' => $newTripId,
1446 'term_id' => $termId,
1447 'term_slug' => $activity->slug ?? '',
1448 'term_name' => $activity->name ?? '',
1449 ]);
1450 continue;
1451 }
1452
1453 $exists = $this->wpdb->get_var($this->wpdb->prepare(
1454 "SELECT id FROM {$tripClassificationsTable}
1455 WHERE trip_id = %d AND classification_id = %d AND classification_type = %s",
1456 $newTripId,
1457 $newActivityId,
1458 ClassificationTypes::ACTIVITY
1459 ));
1460
1461 if (!$exists) {
1462 $inserted = $this->wpdb->insert(
1463 $tripClassificationsTable,
1464 [
1465 'trip_id' => $newTripId,
1466 'classification_id' => $newActivityId,
1467 'classification_type' => ClassificationTypes::ACTIVITY,
1468 'relationship_type' => ($index === 0) ? 'primary' : 'secondary',
1469 'sort_order' => $index,
1470 'is_featured' => ($index === 0) ? 1 : 0,
1471 'is_active' => 1,
1472 'created_at' => current_time('mysql'),
1473 'updated_at' => current_time('mysql'),
1474 ]
1475 );
1476 if ($inserted === false) {
1477 Logger::error('Failed to insert trip–activity classification row: ' . $this->wpdb->last_error, [
1478 'source' => 'migration',
1479 'new_trip_id' => $newTripId,
1480 'classification_id' => $newActivityId,
1481 ]);
1482 }
1483 }
1484 }
1485 }
1486
1487 /**
1488 * Reset migrated state so a forced re-run can rebuild everything.
1489 */
1490 private function resetMigratedData(): void
1491 {
1492 global $wpdb;
1493
1494 delete_option('yatra_migration_progress');
1495 delete_option('yatra_migration_started_at');
1496
1497 // For forced runs we only clear the mapping meta so records can reprocess.
1498 $migrationMetaKeys = [
1499 '_migrated_to_trip_id',
1500 '_migrated_to_coupon_id',
1501 '_migrated_to_customer_id',
1502 '_migrated_to_booking_id',
1503 '_migrated_to_payment_id',
1504 ];
1505 foreach ($migrationMetaKeys as $metaKey) {
1506 $wpdb->query(
1507 $wpdb->prepare(
1508 "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
1509 $metaKey
1510 )
1511 );
1512 }
1513
1514 $wpdb->query("DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE '_yatra_migrated_%'");
1515 $wpdb->query("DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE '_yatra_force_migration_%'");
1516 }
1517
1518 /**
1519 * Clear trip classification relationships before re-inserting fresh links.
1520 * Uses the unified TripClassificationsTable (not old separate tables).
1521 */
1522 public function deleteTripRelationships(int $tripId): void
1523 {
1524 $tripClassificationsTable = TripClassificationsTable::getTableName();
1525
1526 // Delete all classification relationships for this trip (destinations, activities, attributes, etc.)
1527 $this->wpdb->delete(
1528 $tripClassificationsTable,
1529 ['trip_id' => $tripId],
1530 ['%d']
1531 );
1532 }
1533
1534 /**
1535 * Truncate table if it exists; fallback to DELETE when TRUNCATE fails.
1536 */
1537 public function truncateTableIfExists(string $table): void
1538 {
1539 global $wpdb;
1540 $exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table));
1541 if ($exists !== $table) {
1542 return;
1543 }
1544
1545 $result = $wpdb->query("TRUNCATE TABLE {$table}");
1546 if ($result === false) {
1547 $wpdb->query("DELETE FROM {$table}");
1548 }
1549 }
1550
1551 /**
1552 * Attempt to manually kick Action Scheduler to process pending migration jobs.
1553 *
1554 * @param array $progress Current migration progress array.
1555 * @param bool $force When true, always attempt to kick the runner regardless of status.
1556 */
1557 private function maybeKickActionScheduler(array $progress, bool $force = false): void
1558 {
1559 // Bail if Action Scheduler core functions are unavailable
1560 if (!class_exists('\ActionScheduler') && !function_exists('as_has_scheduled_action')) {
1561 return;
1562 }
1563
1564 $hasPending = $force;
1565
1566 if (!$hasPending) {
1567 foreach ($progress as $status) {
1568 if (isset($status['status']) && in_array($status['status'], ['pending', 'running'], true)) {
1569 $hasPending = true;
1570 break;
1571 }
1572 }
1573 }
1574
1575 if (!$hasPending) {
1576 return;
1577 }
1578
1579 $this->kickQueueRunner();
1580 }
1581 }
1582