PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.2
Yatra – Travel Booking & Tour Operator Software v3.0.14.2
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Migrations / TripMigration.php

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

1,190 lines 47.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Yatra\Migration;
4
5 use Yatra\Utils\Logger;
6 use Yatra\Migration\MigrationProgress;
7 use Yatra\Database\Tables\TripsTable;
8 use Yatra\Database\Tables\TripContentTable;
9 use Yatra\Database\Tables\TripAvailabilityDatesTable;
10
11 class TripMigration extends BaseMigration
12 {
13 public function __construct(MigrationProgress $service)
14 {
15 parent::__construct($service);
16 }
17
18 public function run(): array
19 {
20 $migrated = 0;
21 $skipped = 0;
22 $failed = 0;
23
24 // Get all tours from old system (including all statuses except trash)
25 $oldTrips = $this->wpdb->get_results(
26 "SELECT * FROM {$this->wpdb->posts}
27 WHERE post_type = 'tour' AND post_status != 'trash'"
28 );
29
30 $total = count($oldTrips);
31
32 // Count existing trips in new system
33 $existingTripsCount = $this->wpdb->get_var(
34 "SELECT COUNT(*) FROM " . TripsTable::getTableName()
35 );
36
37 foreach ($oldTrips as $oldTrip) {
38 try {
39 // Always re-migrate trips on every run - no skip logic
40 $meta = $this->getPostMeta($oldTrip->ID);
41
42 $status = ($oldTrip->post_status === 'publish') ? 'publish' : 'draft';
43
44 $baseSlug = $oldTrip->post_name ?: sanitize_title($oldTrip->post_title);
45 $slug = $baseSlug;
46 $existingTripId = null;
47
48 if ($this->isForceMigration()) {
49 // Force migration: Always insert new (create duplicates)
50 $slug = $this->generateUniqueSlug($baseSlug, 'yatra_trips');
51 } else {
52 // Regular migration: Check if already exists
53 $existingTripId = $this->wpdb->get_var($this->wpdb->prepare(
54 "SELECT id FROM " . TripsTable::getTableName() . " WHERE slug = %s",
55 $baseSlug
56 ));
57
58 if (!$existingTripId) {
59 // Generate unique slug for new insert
60 $slug = $this->generateUniqueSlug($baseSlug, 'yatra_trips');
61 }
62 }
63
64 // Raw SQL: get_post_thumbnail_id internally calls get_post_meta for _thumbnail_id
65 $featuredImageId = $this->getRawPostMeta($oldTrip->ID, '_thumbnail_id');
66 $featuredImageId = $featuredImageId ? (int) $featuredImageId : null;
67 $createdBy = intval($oldTrip->post_author);
68
69 $regularPrice = $this->getLegacyMetaValue($meta, [
70 'yatra_tour_meta_regular_price',
71 'yatra_tour_meta_tour_price',
72 'yatra_tour_meta_price',
73 'yatra_tour_meta_price_per_person',
74 ]);
75
76 $salePrice = $this->getLegacyMetaValue($meta, [
77 'yatra_tour_meta_sales_price',
78 'yatra_tour_meta_sale_price',
79 'yatra_tour_meta_tour_sale_price',
80 ]);
81
82 $maxTravelersMeta = $this->getLegacyMetaValue($meta, [
83 'yatra_tour_meta_tour_group_size',
84 'yatra_tour_meta_group_size',
85 'yatra_tour_maximum_number_of_traveller',
86 'yatra_tour_meta_group_max',
87 ]);
88
89 $regularPriceFloat = $regularPrice !== null ? (float) $regularPrice : 0.0;
90 $salePriceFloat = $salePrice !== null && $salePrice !== ''
91 ? (float) $salePrice
92 : null;
93
94 if ($salePriceFloat !== null && $salePriceFloat <= 0) {
95 $salePriceFloat = null;
96 }
97
98 if ($salePriceFloat !== null && $salePriceFloat >= $regularPriceFloat) {
99 $salePriceFloat = null;
100 }
101
102 $maxTravelers = $maxTravelersMeta !== null && $maxTravelersMeta !== ''
103 ? (int) $maxTravelersMeta
104 : 10;
105
106 $minTravelersMeta = $this->getLegacyMetaValue($meta, [
107 'yatra_tour_minimum_pax',
108 ]);
109 $minTravelers = $minTravelersMeta !== null && $minTravelersMeta !== ''
110 ? max(1, (int) $minTravelersMeta)
111 : 1;
112
113 $durationDays = intval($meta['yatra_tour_meta_tour_duration_days'] ?? 1);
114 $durationNights = intval($meta['yatra_tour_meta_tour_duration_nights'] ?? 0);
115 $tripType = ($durationDays <= 1 && $durationNights < 1) ? 'single_day' : 'multi_day';
116
117 $isFeatured = !empty($meta['yatra_tour_meta_tour_featured']) ? 1 : 0;
118
119 $legacyTourType = $meta['yatra_tour_meta_tour_type'] ?? 'regular';
120 $customFields = [];
121 if (!empty($meta['yatra_tour_meta_disable_booking'])) {
122 $customFields['legacy_disable_booking'] = true;
123 }
124 if ($legacyTourType === 'external') {
125 $extUrl = $meta['yatra_tour_meta_tour_external_url'] ?? '';
126 if ($extUrl !== '') {
127 $customFields['external_booking_url'] = esc_url_raw((string) $extUrl);
128 }
129 $extBtn = $meta['yatra_tour_meta_tour_external_button_text'] ?? '';
130 if ($extBtn !== '') {
131 $customFields['external_booking_button_text'] = sanitize_text_field((string) $extBtn);
132 }
133 }
134
135 $mainBodyHtml = $this->resolveLegacyTourMainBodyHtml($oldTrip, $meta);
136
137 $tripData = [
138 'title' => $oldTrip->post_title,
139 'slug' => $slug,
140 'description' => $mainBodyHtml,
141 'short_description' => $oldTrip->post_excerpt,
142 'trip_details' => $mainBodyHtml,
143 'trip_type' => $tripType,
144 'duration_days' => $durationDays,
145 'duration_nights' => $durationNights,
146 'min_travelers' => $minTravelers,
147 'max_travelers' => $maxTravelers > 0 ? $maxTravelers : 10,
148 'original_price' => $regularPriceFloat,
149 'sale_price' => $salePriceFloat,
150 'discounted_price' => $salePriceFloat,
151 'featured_image' => $featuredImageId ?: null,
152 'is_featured' => $isFeatured,
153 'status' => $status,
154 'created_at' => $oldTrip->post_date,
155 'updated_at' => $oldTrip->post_modified,
156 'created_by' => $createdBy,
157 'updated_by' => $createdBy,
158 ];
159 if (!empty($customFields)) {
160 $tripData['custom_fields'] = wp_json_encode($customFields);
161 }
162
163 if ($existingTripId && !$this->isForceMigration()) {
164 // Regular migration: Update existing trip
165 $updateData = $tripData;
166 unset($updateData['created_at']); // Don't update created_at
167
168 $updated = $this->wpdb->update(
169 TripsTable::getTableName(),
170 $updateData,
171 ['id' => $existingTripId]
172 );
173
174 if ($updated !== false) {
175 $newTripId = $existingTripId;
176 $this->deleteTripRelationships($newTripId);
177 $migrated++;
178 } else {
179 $failed++;
180 Logger::error("Failed to update trip ID {$oldTrip->ID}: {$this->wpdb->last_error}", [
181 'source' => 'migration',
182 'data_type' => 'trips',
183 'trip_id' => $oldTrip->ID,
184 'trip_title' => $oldTrip->post_title,
185 'db_error' => $this->wpdb->last_error
186 ]);
187 $this->updateProgress('trips', 'running', $migrated, $skipped, $failed, $total, null, null);
188 continue;
189 }
190 } else {
191 // Force migration OR new trip: Insert new
192 $mode = $this->isForceMigration() ? 'Force mode' : 'Regular mode (new trip)';
193 $inserted = $this->wpdb->insert(
194 TripsTable::getTableName(),
195 $tripData
196 );
197
198 if ($inserted) {
199 $newTripId = $this->wpdb->insert_id;
200 $migrated++;
201 } else {
202 $failed++;
203 Logger::error("Failed to insert trip ID {$oldTrip->ID} into database", [
204 'source' => 'migration',
205 'data_type' => 'trips',
206 'trip_id' => $oldTrip->ID,
207 'trip_title' => $oldTrip->post_title,
208 'db_error' => $this->wpdb->last_error
209 ]);
210 $this->updateProgress('trips', 'running', $migrated, $skipped, $failed, $total, null, null);
211 continue;
212 }
213 }
214
215 $this->setRawPostMeta($oldTrip->ID, '_migrated_to_trip_id', (string) $newTripId);
216
217 // Migrate trip classifications (destinations, activities, categories, attributes)
218 $this->migrateTripDestinations($oldTrip->ID, $newTripId);
219 $this->migrateTripActivities($oldTrip->ID, $newTripId);
220 $this->migrateTripCategories($oldTrip->ID, $newTripId);
221 $this->migrateTripAttributes($oldTrip->ID, $newTripId);
222
223 // Migrate trip content (gallery, highlights, FAQs)
224 $this->migrateTripGallery($oldTrip->ID, $newTripId, $meta);
225 $this->migrateTripHighlights($oldTrip->ID, $newTripId, $meta);
226 $this->migrateTripFAQs($oldTrip->ID, $newTripId, $meta);
227
228 // Migrate pricing and availability
229 $this->migrateTripPricing($oldTrip->ID, $newTripId, $meta);
230 $this->migrateTripAvailabilityDates($oldTrip->ID, $newTripId, $meta, $tripData);
231
232 $this->updateProgress('trips', 'running', $migrated, $skipped, $failed, $total, null, null);
233
234 usleep(100000);
235 } catch (\Exception $e) {
236 $failed++;
237 Logger::error("Exception migrating trip ID {$oldTrip->ID}: {$e->getMessage()}", [
238 'source' => 'migration',
239 'data_type' => 'trips',
240 'trip_id' => $oldTrip->ID,
241 'trip_title' => $oldTrip->post_title,
242 'error' => $e->getMessage()
243 ]);
244
245 $this->updateProgress('trips', 'running', $migrated, $skipped, $failed, $total, null, null);
246 }
247 }
248
249 return compact('migrated', 'skipped', 'failed');
250 }
251
252 /**
253 * Migrate trip gallery images from old tour system
254 */
255 private function migrateTripGallery(int $oldTourId, int $newTripId, array $meta): void
256 {
257 global $wpdb;
258 $table = TripContentTable::getTableName();
259
260 // Clear existing gallery images for this trip (unless force migration)
261 if (!$this->isForceMigration()) {
262 $deleted = $wpdb->delete($table, ['trip_id' => $newTripId, 'content_type' => 'image'], ['%d', '%s']);
263 if ($deleted) {
264 }
265 }
266
267 // Direct database query to find ALL gallery-related meta
268 $allGalleryMeta = $wpdb->get_results($wpdb->prepare(
269 "SELECT meta_key, meta_value FROM {$wpdb->postmeta}
270 WHERE post_id = %d
271 AND (meta_key LIKE '%%gallery%%' OR meta_key LIKE '%%image%%' OR meta_key LIKE '%%photo%%')
272 ORDER BY meta_key",
273 $oldTourId
274 ));
275
276 if (!empty($allGalleryMeta)) {
277 foreach ($allGalleryMeta as $metaRow) {
278 }
279 } else {
280 }
281
282 $galleryImages = [];
283
284 // The primary gallery meta key from old Yatra system
285 $galleryKeys = [
286 'yatra_tour_meta_gallery', // Primary: comma-separated attachment IDs
287 'yatra_tour_slider_items', // Slider items: comma-separated attachment IDs
288 'yatra_tour_gallery',
289 'yatra_tour_meta_gallery_images',
290 'yatra_gallery',
291 'tour_gallery',
292 'yatra_tour_images',
293 'yatra_tour_meta_images',
294 'yatra_images',
295 'tour_images',
296 'yatra_tour_photos',
297 'yatra_tour_meta_photos',
298 'yatra_photos',
299 'tour_photos',
300 '_yatra_gallery',
301 '_tour_gallery'
302 ];
303
304 foreach ($galleryKeys as $key) {
305 if (isset($meta[$key]) && !empty($meta[$key])) {
306 $galleryData = $meta[$key];
307 // Handle comma-separated attachment IDs (e.g., "0,27,26,25,24,23,22,19,18")
308 if (is_string($galleryData) && strpos($galleryData, ',') !== false) {
309 $attachmentIds = array_filter(array_map('intval', explode(',', $galleryData)));
310 if (!empty($attachmentIds)) {
311 $galleryImages = $attachmentIds;
312 break;
313 }
314 }
315 // Handle single attachment ID
316 elseif (is_numeric($galleryData) && intval($galleryData) > 0) {
317 $galleryImages = [intval($galleryData)];
318 break;
319 }
320 // Handle serialized data
321 elseif (is_string($galleryData)) {
322 $unserialized = maybe_unserialize($galleryData);
323 if (is_array($unserialized) && !empty($unserialized)) {
324 $galleryImages = $unserialized;
325 break;
326 }
327 }
328 // Handle array data
329 elseif (is_array($galleryData) && !empty($galleryData)) {
330 $galleryImages = $galleryData;
331 break;
332 }
333 }
334 }
335
336 // Log what keys were checked
337 // If no gallery meta found, try to get attached images to the tour post
338 if (empty($galleryImages)) {
339 // Raw SQL: get_attached_media won't reliably work for unregistered CPT parents
340 $attachedImages = $this->wpdb->get_results($this->wpdb->prepare(
341 "SELECT ID, post_excerpt FROM {$this->wpdb->posts}
342 WHERE post_parent = %d AND post_type = 'attachment' AND post_mime_type LIKE 'image%%'
343 ORDER BY menu_order ASC",
344 $oldTourId
345 ));
346 if (!empty($attachedImages)) {
347 foreach ($attachedImages as $attachment) {
348 $galleryImages[] = [
349 'id' => $attachment->ID,
350 'url' => wp_get_attachment_url($attachment->ID),
351 'thumbnail_url' => wp_get_attachment_thumb_url($attachment->ID),
352 'alt_text' => $this->getRawPostMeta($attachment->ID, '_wp_attachment_image_alt') ?? '',
353 'caption' => $attachment->post_excerpt
354 ];
355 }
356 } else {
357 }
358 }
359
360 // Migrate gallery images to new format
361 if (empty($galleryImages)) {
362 return;
363 }
364
365 $order = 0;
366 $migrated = 0;
367 $failed = 0;
368
369 foreach ($galleryImages as $index => $image) {
370 $imageUrl = '';
371 $thumbnailUrl = '';
372 $altText = '';
373 $caption = '';
374 $imageId = null;
375
376 // Handle different image data formats
377 if (is_numeric($image)) {
378 // Simple attachment ID (most common format from old Yatra)
379 $imageId = (int) $image;
380
381 // Skip invalid IDs (like 0)
382 if ($imageId <= 0) {
383 continue;
384 }
385
386 $imageUrl = wp_get_attachment_url($imageId);
387 if (!$imageUrl) {
388 continue;
389 }
390
391 $thumbnailUrl = wp_get_attachment_thumb_url($imageId) ?: $imageUrl;
392 $altText = $this->getRawPostMeta($imageId, '_wp_attachment_image_alt') ?? '';
393 $attachment = get_post($imageId);
394 if ($attachment) {
395 $caption = $attachment->post_excerpt ?: '';
396 }
397
398 } elseif (is_string($image)) {
399 // URL string
400 $imageUrl = $image;
401 $thumbnailUrl = $imageUrl;
402 } elseif (is_array($image)) {
403 // Array format
404 $imageId = !empty($image['id']) ? (int) $image['id'] : null;
405 $imageUrl = $image['url'] ?? $image['image_url'] ?? $image['src'] ?? '';
406 $thumbnailUrl = $image['thumbnail_url'] ?? $image['thumb'] ?? $imageUrl;
407 $altText = $image['alt_text'] ?? $image['alt'] ?? '';
408 $caption = $image['caption'] ?? $image['description'] ?? '';
409 }
410
411 if (!empty($imageUrl)) {
412 $imageMetadata = [];
413 if ($imageId) {
414 $imageMetadata['image_id'] = $imageId;
415 }
416 if (!empty($altText)) {
417 $imageMetadata['alt_text'] = $altText;
418 }
419
420 $result = $wpdb->insert(
421 $table,
422 [
423 'trip_id' => $newTripId,
424 'content_type' => 'image',
425 'title' => !empty($altText) ? $altText : (!empty($caption) ? $caption : null),
426 'description' => !empty($caption) ? $caption : null,
427 'content_url' => $imageUrl,
428 'thumbnail_url' => $thumbnailUrl,
429 'metadata' => !empty($imageMetadata) ? json_encode($imageMetadata) : null,
430 'sort_order' => $order,
431 'is_featured' => $order === 0 ? 1 : 0,
432 'created_at' => current_time('mysql')
433 ],
434 ['%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s']
435 );
436
437 if ($result) {
438 $migrated++;
439 $order++;
440 } else {
441 $failed++;
442 }
443 } else {
444 }
445 }
446
447 }
448
449 /**
450 * Migrate trip highlights from old tour system
451 */
452 private function migrateTripHighlights(int $oldTourId, int $newTripId, array $meta): void
453 {
454 global $wpdb;
455 $table = TripContentTable::getTableName();
456
457 // Clear existing highlights for this trip
458 $wpdb->delete($table, ['trip_id' => $newTripId, 'content_type' => 'highlight'], ['%d', '%s']);
459
460 $highlights = [];
461
462 // Try different possible highlights meta keys
463 $highlightsKeys = [
464 'yatra_tour_highlights',
465 'yatra_tour_meta_highlights',
466 'yatra_highlights',
467 'tour_highlights',
468 'yatra_tour_features',
469 'yatra_tour_meta_features',
470 'yatra_features',
471 'tour_features'
472 ];
473
474 foreach ($highlightsKeys as $key) {
475 if (!empty($meta[$key])) {
476 $highlightsData = $meta[$key];
477 if (is_string($highlightsData)) {
478 $highlightsData = maybe_unserialize($highlightsData);
479 }
480
481 if (is_array($highlightsData) && !empty($highlightsData)) {
482 $highlights = $highlightsData;
483 break;
484 }
485 }
486 }
487
488 // Migrate highlights
489 if (!empty($highlights)) {
490 $order = 0;
491 foreach ($highlights as $highlight) {
492 $title = '';
493 $description = '';
494 $icon = '';
495
496 if (is_string($highlight)) {
497 // Simple string - treat as title
498 $title = $highlight;
499 } elseif (is_array($highlight)) {
500 $title = $highlight['title'] ?? $highlight['name'] ?? $highlight['text'] ?? '';
501 $description = $highlight['description'] ?? $highlight['content'] ?? '';
502 $icon = $highlight['icon'] ?? $highlight['icon_class'] ?? '';
503 }
504
505 if (!empty($title)) {
506 $highlightMetadata = [];
507 if (!empty($icon)) {
508 $highlightMetadata['icon'] = $icon;
509 }
510 if (!empty($description)) {
511 $highlightMetadata['description'] = $description;
512 }
513
514 $wpdb->insert(
515 $table,
516 [
517 'trip_id' => $newTripId,
518 'content_type' => 'highlight',
519 'title' => $title,
520 'description' => !empty($description) ? $description : null,
521 'metadata' => !empty($highlightMetadata) ? json_encode($highlightMetadata) : null,
522 'sort_order' => $order,
523 'created_at' => current_time('mysql')
524 ],
525 ['%d', '%s', '%s', '%s', '%s', '%d', '%s']
526 );
527 $order++;
528 }
529 }
530
531 }
532 }
533
534 /**
535 * Migrate trip FAQs from old tour system
536 */
537 private function migrateTripFAQs(int $oldTourId, int $newTripId, array $meta): void
538 {
539 global $wpdb;
540 $table = TripContentTable::getTableName();
541
542 // Clear existing FAQs for this trip (unless force migration)
543 if (!$this->isForceMigration()) {
544 $deleted = $wpdb->delete($table, ['trip_id' => $newTripId, 'content_type' => 'faq'], ['%d', '%s']);
545 if ($deleted) {
546 }
547 }
548
549 $faqs = [];
550
551 // The primary FAQ meta key from old Yatra system
552 $faqKeys = [
553 'faq_repeator', // Primary: serialized array with faq_heading and faq_description
554 'yatra_tour_faqs',
555 'yatra_tour_meta_faqs',
556 'yatra_faqs',
557 'tour_faqs',
558 'yatra_tour_faq',
559 'yatra_tour_meta_faq',
560 'yatra_faq',
561 'tour_faq'
562 ];
563
564 foreach ($faqKeys as $key) {
565 if (isset($meta[$key]) && !empty($meta[$key])) {
566 $faqData = $meta[$key];
567 if (is_string($faqData)) {
568 $faqData = maybe_unserialize($faqData);
569 }
570
571 if (is_array($faqData) && !empty($faqData)) {
572 // Check if this is the faq_repeator format with faq_heading and faq_description
573 if (isset($faqData['faq_heading']) && isset($faqData['faq_description'])) {
574 $headings = $faqData['faq_heading'];
575 $descriptions = $faqData['faq_description'];
576
577 if (is_array($headings) && is_array($descriptions)) {
578 // Convert to standard format
579 $faqs = [];
580 $count = min(count($headings), count($descriptions));
581 for ($i = 0; $i < $count; $i++) {
582 if (!empty($headings[$i]) && !empty($descriptions[$i])) {
583 $faqs[] = [
584 'question' => $headings[$i],
585 'answer' => $descriptions[$i]
586 ];
587 }
588 }
589 break;
590 }
591 } else {
592 // Standard array format
593 $faqs = $faqData;
594 break;
595 }
596 }
597 }
598 }
599
600 // Migrate FAQs
601 if (empty($faqs)) {
602 return;
603 }
604
605 $order = 0;
606 $migrated = 0;
607 $failed = 0;
608
609 foreach ($faqs as $faq) {
610 $question = '';
611 $answer = '';
612
613 if (is_array($faq)) {
614 $question = $faq['question'] ?? $faq['q'] ?? $faq['title'] ?? '';
615 $answer = $faq['answer'] ?? $faq['a'] ?? $faq['content'] ?? $faq['description'] ?? '';
616 }
617
618 if (!empty($question) && !empty($answer)) {
619 $result = $wpdb->insert(
620 $table,
621 [
622 'trip_id' => $newTripId,
623 'content_type' => 'faq',
624 'title' => wp_kses_post($question),
625 'description' => wp_kses_post($answer),
626 'sort_order' => $order,
627 'created_at' => current_time('mysql')
628 ],
629 ['%d', '%s', '%s', '%s', '%d', '%s']
630 );
631
632 if ($result) {
633 $migrated++;
634 $order++;
635 } else {
636 $failed++;
637 }
638 } else {
639 }
640 }
641
642 }
643
644 /**
645 * Migrate trip categories relationship
646 */
647 private function migrateTripCategories(int $oldTripId, int $newTripId): void
648 {
649 global $wpdb;
650
651 // Categories: legacy sites used `trip_category` (3.x) or `tour_category` (some 2.x builds).
652 $categories = $wpdb->get_results($wpdb->prepare(
653 "SELECT t.term_id, t.name, t.slug, tt.taxonomy AS source_taxonomy
654 FROM {$wpdb->terms} t
655 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
656 INNER JOIN {$wpdb->term_relationships} tr ON tt.term_taxonomy_id = tr.term_taxonomy_id
657 WHERE tr.object_id = %d AND tt.taxonomy IN ('trip_category', 'tour_category')",
658 $oldTripId
659 ));
660
661 if (empty($categories)) {
662 return;
663 }
664
665 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
666 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
667
668 foreach ($categories as $index => $category) {
669 // Find the migrated category in ClassificationsTable
670 $newCategoryId = $wpdb->get_var($wpdb->prepare(
671 "SELECT id FROM {$classificationsTable} WHERE slug = %s AND type = %s",
672 $category->slug,
673 \Yatra\Constants\ClassificationTypes::CATEGORY
674 ));
675
676 if (!$newCategoryId) {
677 // Term meta from {@see BaseMigration::migrateTaxonomy}: _yatra_migrated_{taxonomy}_id
678 $sourceTax = isset($category->source_taxonomy) && is_string($category->source_taxonomy)
679 ? $category->source_taxonomy
680 : 'trip_category';
681 $mappedId = $this->getRawTermMeta(
682 (int) $category->term_id,
683 sprintf('_yatra_migrated_%s_id', $sourceTax)
684 );
685 if (!$mappedId && $sourceTax === 'trip_category') {
686 $mappedId = $this->getRawTermMeta((int) $category->term_id, '_yatra_migrated_tour_category_id');
687 } elseif (!$mappedId && $sourceTax === 'tour_category') {
688 $mappedId = $this->getRawTermMeta((int) $category->term_id, '_yatra_migrated_trip_category_id');
689 }
690 if (!$mappedId) {
691 $mappedId = $this->getRawTermMeta((int) $category->term_id, '_yatra_migrated_category_id');
692 }
693 if ($mappedId) {
694 $newCategoryId = (int) $mappedId;
695 }
696 }
697
698 if ($newCategoryId) {
699 // Check if relationship already exists
700 $exists = $wpdb->get_var($wpdb->prepare(
701 "SELECT id FROM {$tripClassificationsTable}
702 WHERE trip_id = %d AND classification_id = %d AND classification_type = %s",
703 $newTripId,
704 $newCategoryId,
705 \Yatra\Constants\ClassificationTypes::CATEGORY
706 ));
707
708 if (!$exists) {
709 $wpdb->insert(
710 $tripClassificationsTable,
711 [
712 'trip_id' => $newTripId,
713 'classification_id' => $newCategoryId,
714 'classification_type' => \Yatra\Constants\ClassificationTypes::CATEGORY,
715 'relationship_type' => 'primary',
716 'sort_order' => $index,
717 'is_active' => 1,
718 'created_at' => current_time('mysql'),
719 'updated_at' => current_time('mysql'),
720 ]
721 );
722 }
723 }
724 }
725 }
726
727 /**
728 * Migrate trip attributes with values
729 */
730 private function migrateTripAttributes(int $oldTripId, int $newTripId): void
731 {
732 global $wpdb;
733
734 // Get custom attributes from post meta
735 $attributesData = $this->getRawPostMeta($oldTripId, 'tour_meta_custom_attributes');
736
737 if (empty($attributesData)) {
738 return;
739 }
740
741 // Unserialize if needed
742 if (is_string($attributesData)) {
743 $attributesData = maybe_unserialize($attributesData);
744 }
745
746 if (!is_array($attributesData) || empty($attributesData)) {
747 return;
748 }
749
750 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
751 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
752
753 $order = 0;
754 foreach ($attributesData as $termId => $attributeValue) {
755 // Get the old attribute term
756 $attribute = $wpdb->get_row($wpdb->prepare(
757 "SELECT t.term_id, t.name, t.slug
758 FROM {$wpdb->terms} t
759 INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
760 WHERE t.term_id = %d AND tt.taxonomy = 'attributes'",
761 $termId
762 ));
763
764 if (!$attribute) {
765 continue;
766 }
767
768 // Find the migrated attribute in ClassificationsTable
769 $newAttributeId = $wpdb->get_var($wpdb->prepare(
770 "SELECT id FROM {$classificationsTable} WHERE slug = %s AND type = %s",
771 $attribute->slug,
772 \Yatra\Constants\ClassificationTypes::ATTRIBUTE
773 ));
774
775 if (!$newAttributeId) {
776 // Try looking up by term meta mapping
777 $mappedId = $this->getRawTermMeta((int) $attribute->term_id, '_yatra_migrated_attributes_id');
778 if ($mappedId) {
779 $newAttributeId = (int) $mappedId;
780 }
781 }
782
783 if ($newAttributeId) {
784 // Check if relationship already exists
785 $exists = $wpdb->get_var($wpdb->prepare(
786 "SELECT id FROM {$tripClassificationsTable}
787 WHERE trip_id = %d AND classification_id = %d AND classification_type = %s",
788 $newTripId,
789 $newAttributeId,
790 \Yatra\Constants\ClassificationTypes::ATTRIBUTE
791 ));
792
793 if (!$exists) {
794 // Store attribute value in metadata JSON
795 $metadata = json_encode(['value' => $attributeValue]);
796
797 $wpdb->insert(
798 $tripClassificationsTable,
799 [
800 'trip_id' => $newTripId,
801 'classification_id' => $newAttributeId,
802 'classification_type' => \Yatra\Constants\ClassificationTypes::ATTRIBUTE,
803 'relationship_type' => 'primary',
804 'sort_order' => $order,
805 'metadata' => $metadata,
806 'is_active' => 1,
807 'created_at' => current_time('mysql'),
808 'updated_at' => current_time('mysql'),
809 ]
810 );
811 $order++;
812 }
813 }
814 }
815 }
816
817 /**
818 * Migrate trip pricing (traveler categories with pricing values)
819 *
820 * Old Plugin Structure:
821 * - Single Pricing: yatra_tour_meta_regular_price, yatra_tour_meta_sales_price
822 * - Multiple Pricing: yatra_multiple_pricing (serialized array)
823 * - Fields: pricing_label, regular_price, sales_price, pricing_per, group_size, minimum_pax, maximum_pax
824 *
825 * New Plugin Structure:
826 * - pricing_type: 'regular' or 'traveler_based'
827 * - price_types: JSON array with category_id, original_price, discounted_price, pricing_mode
828 */
829 private function migrateTripPricing(int $oldTripId, int $newTripId, array $meta): void
830 {
831 global $wpdb;
832
833 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
834 $tripsTable = \Yatra\Database\Tables\TripsTable::getTableName();
835
836 // Get multiple pricing data from old plugin (stored in yatra_multiple_pricing meta key)
837 $multiplePricing = $meta['yatra_multiple_pricing'] ?? null;
838
839 if (is_string($multiplePricing)) {
840 $multiplePricing = maybe_unserialize($multiplePricing);
841 }
842
843 // Check if we have valid multiple pricing data
844 if (empty($multiplePricing) || !is_array($multiplePricing)) {
845 // No multiple pricing, trip will use regular pricing (already migrated in main trip data)
846 return;
847 }
848
849 // Build price_types array for traveler-based pricing
850 $priceTypes = [];
851
852 foreach ($multiplePricing as $pricingId => $pricing) {
853 if (!is_array($pricing)) {
854 continue;
855 }
856
857 // Old plugin uses 'pricing_label' field for category name
858 $label = trim($pricing['pricing_label'] ?? '');
859 if (empty($label)) {
860 continue;
861 }
862
863 // Find the traveler category by label (case-insensitive)
864 $categoryId = $wpdb->get_var($wpdb->prepare(
865 "SELECT id FROM {$classificationsTable} WHERE LOWER(name) = LOWER(%s) AND type = %s",
866 $label,
867 \Yatra\Constants\ClassificationTypes::TRAVELER_TYPE
868 ));
869
870 if (!$categoryId) {
871 // Category doesn't exist in new system, skip this pricing entry
872 continue;
873 }
874
875 // Extract pricing values - old plugin uses 'regular_price' and 'sales_price'
876 $regularPrice = floatval($pricing['regular_price'] ?? 0);
877 $salePrice = floatval($pricing['sales_price'] ?? 0);
878
879 // Get pricing mode from old plugin (person/group)
880 $pricingPer = $pricing['pricing_per'] ?? 'person';
881 // Map old pricing_per to new pricing_mode
882 $pricingMode = ($pricingPer === 'group') ? 'per_group' : 'per_person';
883
884 // Get group size if pricing is per group
885 $groupSize = intval($pricing['group_size'] ?? 1);
886
887 // Get min/max travelers (pax)
888 $minPax = intval($pricing['minimum_pax'] ?? 1);
889 $maxPax = intval($pricing['maximum_pax'] ?? 1);
890
891 // Ensure min is at least 1
892 if ($minPax < 1) {
893 $minPax = 1;
894 }
895
896 // Ensure max is at least equal to min
897 if ($maxPax < $minPax) {
898 $maxPax = $minPax;
899 }
900
901 // Only add if regular price is valid
902 if ($regularPrice > 0) {
903 $priceType = [
904 'category_id' => (int) $categoryId,
905 'label' => $label,
906 'original_price' => $regularPrice,
907 'discounted_price' => ($salePrice > 0 && $salePrice < $regularPrice) ? $salePrice : null,
908 'pricing_mode' => $pricingMode,
909 'min_travelers' => $minPax,
910 'max_travelers' => $maxPax,
911 ];
912
913 // Add group_size if pricing is per group
914 if ($pricingMode === 'per_group' && $groupSize > 0) {
915 $priceType['group_size'] = $groupSize;
916 }
917
918 $priceTypes[] = $priceType;
919 }
920 }
921
922 // If we have valid price types, update the trip
923 if (!empty($priceTypes)) {
924 // Encode price_types as JSON
925 $priceTypesJson = json_encode($priceTypes);
926
927 // Update trip with price_types JSON AND set pricing_type to 'traveler_based'
928 $wpdb->update(
929 $tripsTable,
930 [
931 'price_types' => $priceTypesJson,
932 'pricing_type' => 'traveler_based'
933 ],
934 ['id' => $newTripId],
935 ['%s', '%s'],
936 ['%d']
937 );
938 }
939 }
940
941 /**
942 * Migrate availability date ranges from old tour system
943 */
944 private function migrateTripAvailabilityDates(int $oldTourId, int $newTripId, array $meta, array $tripData): void
945 {
946 global $wpdb;
947 $table = TripAvailabilityDatesTable::getTableName();
948
949 // Clear existing availability dates for this trip (for re-migration)
950 if ($this->isForceMigration()) {
951 // In force mode, don't delete - allow duplicates
952 } else {
953 // In regular mode, clear existing to avoid duplicates
954 $wpdb->delete($table, ['trip_id' => $newTripId], ['%d']);
955 }
956
957 // Try different possible availability date meta keys from old Yatra
958 $availabilityKeys = [
959 'yatra_tour_meta_availability_date_ranges', // Most common format from old system
960 'yatra_tour_availability_date_ranges',
961 'yatra_tour_meta_availability_dates',
962 'yatra_tour_availability_dates',
963 'yatra_availability_dates',
964 'tour_availability_dates',
965 'yatra_tour_fixed_departure_dates',
966 'yatra_tour_meta_fixed_departure',
967 'yatra_fixed_departure_dates',
968 ];
969
970 $availabilityData = null;
971 $foundKey = null;
972
973 // Also try direct database query to ensure we're not missing data
974 $directMetaQuery = $wpdb->get_results($wpdb->prepare(
975 "SELECT meta_key, meta_value FROM {$wpdb->prefix}postmeta WHERE post_id = %d AND meta_key LIKE %s",
976 $oldTourId,
977 '%availability%'
978 ));
979
980 if (!empty($directMetaQuery)) {
981 foreach ($directMetaQuery as $metaRow) {
982 }
983 }
984
985 foreach ($availabilityKeys as $key) {
986 if (isset($meta[$key])) {
987 $availabilityData = $meta[$key];
988 if (is_string($availabilityData)) {
989 $availabilityData = maybe_unserialize($availabilityData);
990 if (is_array($availabilityData) || is_object($availabilityData)) {
991 }
992 }
993
994 // Check if it's an empty array string like '[]'
995 if (is_string($availabilityData) && trim($availabilityData) === '[]') {
996 continue;
997 }
998
999 if (!empty($availabilityData)) {
1000 $foundKey = $key;
1001 break;
1002 }
1003 }
1004 }
1005
1006 if (empty($availabilityData)) {
1007 return;
1008 }
1009
1010 // Parse availability date ranges
1011 $dateRanges = [];
1012
1013 // First, try to decode as JSON if it's a string (common format in database)
1014 if (is_string($availabilityData)) {
1015 $jsonDecoded = json_decode($availabilityData, true);
1016 if (json_last_error() === JSON_ERROR_NONE && is_array($jsonDecoded)) {
1017 $availabilityData = $jsonDecoded;
1018 }
1019 }
1020
1021 if (is_array($availabilityData)) {
1022 // Handle array format
1023 foreach ($availabilityData as $dateRange) {
1024 if (is_array($dateRange) || is_object($dateRange)) {
1025 // Convert object to array if needed
1026 $dateRange = (array) $dateRange;
1027
1028 // Format: ['start' => '2025-12-25', 'end' => '2026-01-18']
1029 $start = $dateRange['start'] ?? $dateRange['start_date'] ?? $dateRange['from'] ?? null;
1030 $end = $dateRange['end'] ?? $dateRange['end_date'] ?? $dateRange['to'] ?? null;
1031
1032 if ($start && $end) {
1033 $dateRanges[] = ['start' => $start, 'end' => $end];
1034 }
1035 } elseif (is_string($dateRange)) {
1036 // Format: "2025-12-25 - 2026-01-18"
1037 $parts = preg_split('/\s*[-–—]\s*/', $dateRange, 2);
1038 if (count($parts) === 2) {
1039 $dateRanges[] = ['start' => trim($parts[0]), 'end' => trim($parts[1])];
1040 }
1041 }
1042 }
1043 } elseif (is_string($availabilityData)) {
1044 // Handle plain string format with multiple ranges separated by comma or newline
1045 $lines = preg_split('/[,\n\r]+/', $availabilityData);
1046 foreach ($lines as $line) {
1047 $line = trim($line);
1048 if (empty($line)) continue;
1049
1050 // Format: "2025-12-25 - 2026-01-18"
1051 $parts = preg_split('/\s*[-–—]\s*/', $line, 2);
1052 if (count($parts) === 2) {
1053 $dateRanges[] = ['start' => trim($parts[0]), 'end' => trim($parts[1])];
1054 }
1055 }
1056 }
1057
1058 if (empty($dateRanges)) {
1059 return;
1060 }
1061
1062 // Create availability dates for each range
1063 $created = 0;
1064 foreach ($dateRanges as $range) {
1065 $startDate = $range['start'];
1066 $endDate = $range['end'];
1067
1068 // Validate dates
1069 if (!strtotime($startDate) || !strtotime($endDate)) {
1070 continue;
1071 }
1072
1073 // Calculate duration
1074 $start = new \DateTime($startDate);
1075 $end = new \DateTime($endDate);
1076 $duration = $start->diff($end)->days;
1077
1078 // Determine if single day or multi-day trip
1079 $isSingleDay = $duration === 0 || ($tripData['duration_days'] ?? 1) === 1;
1080
1081 // Create availability date entry
1082 $availabilityEntry = [
1083 'trip_id' => $newTripId,
1084 'departure_date' => $startDate,
1085 'arrival_date' => $isSingleDay ? $startDate : $endDate,
1086 'return_date' => $endDate,
1087 'departure_time' => null,
1088 'arrival_time' => null,
1089 'seats_total' => $tripData['max_travelers'] ?? 10,
1090 'seats_available' => $tripData['max_travelers'] ?? 10,
1091 'seats_reserved' => 0,
1092 'seats_waitlist' => 0,
1093 'pricing_type' => 'regular',
1094 'original_price' => $tripData['original_price'] ?? null,
1095 'discounted_price' => $tripData['sale_price'] ?? null,
1096 'discount_percentage' => null,
1097 'price_types' => null,
1098 'status' => 'available',
1099 'from_location' => null,
1100 'to_location' => null,
1101 'from_latitude' => null,
1102 'from_longitude' => null,
1103 'to_latitude' => null,
1104 'to_longitude' => null,
1105 'special_notes' => null,
1106 'cutoff_date' => null,
1107 'cutoff_hours' => 24,
1108 'created_at' => current_time('mysql'),
1109 'updated_at' => current_time('mysql'),
1110 ];
1111
1112 $inserted = $wpdb->insert(
1113 $table,
1114 $availabilityEntry,
1115 [
1116 '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d',
1117 '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s',
1118 ]
1119 );
1120
1121 if ($inserted) {
1122 $created++;
1123 } else {
1124 }
1125 }
1126
1127 if ($created > 0) {
1128 }
1129 }
1130
1131 /**
1132 * Resolve main trip HTML for description / trip_details.
1133 *
1134 * Legacy Yatra 2.x rendered the “Overview” tab from the `overview_description` post meta field
1135 * (see `Yatra_Frontend_Tour_Tabs::overview()` in Yatra 2.3.x),
1136 * while the block editor / post_content was often left empty. Migration previously copied only
1137 * post_content, which dropped that body. We prefer meta when present and merge with post_content
1138 * when both differ so unique content in either place is kept.
1139 */
1140 private function resolveLegacyTourMainBodyHtml(object $oldTrip, array $meta): string
1141 {
1142 $overviewKeys = [
1143 'overview_description',
1144 'yatra_tour_meta_overview_description',
1145 'yatra_overview_description',
1146 ];
1147
1148 $fromMeta = '';
1149 foreach ($overviewKeys as $key) {
1150 $fromMeta = $this->legacyMetaTrimmedString($meta, $key);
1151 if ($fromMeta !== '') {
1152 break;
1153 }
1154 }
1155
1156 $fromPost = isset($oldTrip->post_content) ? trim((string) $oldTrip->post_content) : '';
1157
1158 if ($fromMeta !== '' && $fromPost !== '' && $fromMeta !== $fromPost) {
1159 return $fromMeta . "\n\n" . $fromPost;
1160 }
1161
1162 if ($fromMeta !== '') {
1163 return $fromMeta;
1164 }
1165
1166 return $fromPost;
1167 }
1168
1169 /**
1170 * @param array<string, mixed> $meta
1171 */
1172 private function legacyMetaTrimmedString(array $meta, string $key): string
1173 {
1174 if (!array_key_exists($key, $meta) || $meta[$key] === null || $meta[$key] === '') {
1175 return '';
1176 }
1177
1178 $value = $meta[$key];
1179 if (is_string($value)) {
1180 $value = maybe_unserialize($value);
1181 }
1182
1183 if (!is_string($value)) {
1184 return '';
1185 }
1186
1187 return trim($value);
1188 }
1189 }
1190