PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.3
Yatra – Travel Booking & Tour Operator Software v3.0.3
3.0.16 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 All 84 releases
yatra / app / Migrations / ItineraryMigration.php

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

519 lines 19.4 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\Database\Tables\TripItineraryDaysTable;
6 use Yatra\Database\Tables\TripItineraryDayEntryTable;
7 use Yatra\Utils\Logger;
8
9 /**
10 * Itinerary Migration - Migrate trip itineraries from old Yatra versions
11 *
12 * This class migrates itinerary data from old tour posts to the new itinerary structure.
13 * It handles various old itinerary data formats and converts them to the new
14 * yatra_trip_itinerary_days and yatra_trip_itinerary_entries tables.
15 */
16 class ItineraryMigration extends BaseMigration
17 {
18 public function __construct(MigrationProgress $service)
19 {
20 parent::__construct($service);
21 }
22
23 /**
24 * Run the itinerary migration
25 */
26 public function run(): array
27 {
28 // CRITICAL DEBUG: Log immediately to see if this method is called
29 $migrated = 0;
30 $skipped = 0;
31 $failed = 0;
32
33 try {
34 // Get all old tours that have been migrated to new trips
35 $oldToursWithItinerary = $this->getOldToursWithItinerary();
36 $total = count($oldToursWithItinerary);
37
38 Logger::info("Itinerary Migration: Starting migration", [
39 'total_tours_with_itinerary' => $total,
40 'force_migration' => $this->service->isForceMigration()
41 ]);
42
43 foreach ($oldToursWithItinerary as $oldTour) {
44 try {
45 // Get the new trip ID from migration mapping
46 $newTripId = $this->getMigratedTripId($oldTour->ID);
47
48 if (!$newTripId) {
49 Logger::info("Tour not migrated yet, skipping itinerary", [
50 'old_tour_id' => $oldTour->ID,
51 'tour_title' => $oldTour->post_title
52 ]);
53 $skipped++;
54 continue;
55 }
56
57 // Check if itinerary already exists for this trip (skip for normal migration)
58 if (!$this->service->isForceMigration() && $this->hasItinerary($newTripId)) {
59 Logger::info("Itinerary already exists, skipping", [
60 'trip_id' => $newTripId,
61 'old_tour_id' => $oldTour->ID
62 ]);
63 $skipped++;
64 continue;
65 }
66
67 // During force migration, we delete existing itinerary and recreate
68 if ($this->service->isForceMigration() && $this->hasItinerary($newTripId)) {
69 Logger::info("Force migration: deleting existing itinerary", [
70 'trip_id' => $newTripId,
71 'old_tour_id' => $oldTour->ID
72 ]);
73 $this->deleteTripItinerary($newTripId);
74 }
75
76 // Extract and migrate itinerary data
77 $itineraryData = $this->extractItineraryData($oldTour->ID);
78
79 if (empty($itineraryData)) {
80 Logger::info("No itinerary data found for tour", [
81 'old_tour_id' => $oldTour->ID,
82 'tour_title' => $oldTour->post_title
83 ]);
84 $skipped++;
85 continue;
86 }
87
88 // Create itinerary in new structure
89 $result = $this->createItinerary($newTripId, $itineraryData, $oldTour->ID);
90
91 if ($result) {
92 $migrated++;
93 Logger::info("Itinerary migrated successfully", [
94 'trip_id' => $newTripId,
95 'old_tour_id' => $oldTour->ID,
96 'tour_title' => $oldTour->post_title,
97 'days_count' => count($itineraryData)
98 ]);
99 } else {
100 $failed++;
101 Logger::error("Failed to migrate itinerary", [
102 'trip_id' => $newTripId,
103 'old_tour_id' => $oldTour->ID
104 ]);
105 }
106
107 } catch (\Exception $e) {
108 $failed++;
109 Logger::error("Exception during itinerary migration", [
110 'old_tour_id' => $oldTour->ID,
111 'error' => $e->getMessage(),
112 'trace' => $e->getTraceAsString()
113 ]);
114 }
115 }
116
117 Logger::info("Itinerary Migration: Completed", [
118 'migrated' => $migrated,
119 'skipped' => $skipped,
120 'failed' => $failed,
121 'total' => $total,
122 'force_migration' => $this->service->isForceMigration()
123 ]);
124
125 return compact('migrated', 'skipped', 'failed', 'total');
126
127 } catch (\Exception $e) {
128 Logger::error("Itinerary Migration failed", [
129 'error' => $e->getMessage(),
130 'trace' => $e->getTraceAsString()
131 ]);
132
133 return [
134 'migrated' => $migrated,
135 'skipped' => $skipped,
136 'failed' => $failed + 1,
137 'total' => $total ?? 0
138 ];
139 }
140 }
141
142 /**
143 * Get old tours that might have itinerary data.
144 *
145 * Uses an efficient SQL query with EXISTS to find only tours that
146 * actually have itinerary-related meta keys, instead of loading
147 * all meta for every tour and filtering in PHP.
148 */
149 private function getOldToursWithItinerary(): array
150 {
151 // Known itinerary meta keys from old Yatra versions
152 $itineraryKeys = [
153 'itinerary_repeator',
154 'itinerary_label',
155 'yatra_tour_itinerary',
156 'yatra_tour_meta_itinerary',
157 'yatra_itinerary',
158 'tour_itinerary',
159 'yatra_tour_days',
160 'yatra_tour_meta_days',
161 'yatra_days',
162 'tour_days',
163 'yatra_tour_schedule',
164 'yatra_tour_meta_schedule',
165 'yatra_schedule',
166 'tour_schedule',
167 ];
168
169 $placeholders = implode(',', array_fill(0, count($itineraryKeys), '%s'));
170
171 // Single efficient SQL query: only return tours with itinerary meta
172 $sql = $this->wpdb->prepare(
173 "SELECT DISTINCT p.*
174 FROM {$this->wpdb->posts} p
175 INNER JOIN {$this->wpdb->postmeta} pm ON p.ID = pm.post_id
176 WHERE p.post_type = 'tour'
177 AND p.post_status IN ('publish', 'draft', 'pending', 'private')
178 AND pm.meta_key IN ({$placeholders})
179 AND pm.meta_value != ''
180 AND pm.meta_value IS NOT NULL",
181 ...$itineraryKeys
182 );
183
184 $tours = $this->wpdb->get_results($sql);
185
186 Logger::info("Itinerary Migration: Found tours with itinerary meta", [
187 'total_tours_with_itinerary' => count($tours),
188 ]);
189
190 return $tours;
191 }
192
193 /**
194 * Extract itinerary data from old tour meta
195 */
196 private function extractItineraryData(int $oldTourId): array
197 {
198 $meta = $this->getPostMeta($oldTourId);
199 $itineraryData = [];
200
201 // Try different possible meta keys for itinerary data
202 $itineraryKeys = [
203 'itinerary_repeator', // This is the actual key found in the database
204 'itinerary_label',
205 'yatra_tour_itinerary',
206 'yatra_tour_meta_itinerary',
207 'yatra_itinerary',
208 'tour_itinerary'
209 ];
210
211 foreach ($itineraryKeys as $key) {
212 if (!empty($meta[$key])) {
213 $itineraryData = $this->parseItineraryData($meta[$key]);
214 if (!empty($itineraryData)) {
215 break;
216 }
217 }
218 }
219
220 // If no structured data found, try to parse from content
221 if (empty($itineraryData)) {
222 $tour = get_post($oldTourId);
223 if ($tour) {
224 $itineraryData = $this->parseItineraryFromContent($tour->post_content);
225 }
226 }
227
228 return $itineraryData;
229 }
230
231 /**
232 * Parse itinerary data from meta value
233 */
234 private function parseItineraryData($itineraryData): array
235 {
236 $parsed = [];
237
238 // Handle different data formats
239 if (is_string($itineraryData)) {
240 // Try to unserialize if it's serialized data
241 $unserialized = @unserialize($itineraryData);
242 if ($unserialized !== false) {
243 $itineraryData = $unserialized;
244 } else {
245 // Try to decode JSON
246 $decoded = @json_decode($itineraryData, true);
247 if ($decoded !== null) {
248 $itineraryData = $decoded;
249 }
250 }
251 }
252
253 // Handle the specific itinerary_repeator structure
254 if (is_array($itineraryData) && isset($itineraryData['itinerary_heading'])) {
255 $headings = $itineraryData['itinerary_heading'] ?? [];
256 $titles = $itineraryData['itinerary_title'] ?? [];
257 $details = $itineraryData['itinerary_details'] ?? [];
258
259 $maxDays = max(count($headings), count($titles), count($details));
260
261 for ($i = 0; $i < $maxDays; $i++) {
262 $dayTitle = !empty($titles[$i]) ? $titles[$i] : $headings[$i] ?? '';
263 $dayDescription = $details[$i] ?? '';
264
265 if (!empty($dayTitle) || !empty($dayDescription)) {
266 $parsed[] = [
267 'day_number' => $i + 1,
268 'title' => sanitize_text_field($dayTitle),
269 'description' => wp_kses_post($dayDescription),
270 'entries' => [] // No separate entries in this format
271 ];
272 }
273 }
274 }
275 // Handle generic array format
276 elseif (is_array($itineraryData)) {
277 foreach ($itineraryData as $dayData) {
278 if (is_array($dayData) && !empty($dayData['title'])) {
279 $parsed[] = [
280 'day_number' => intval($dayData['day'] ?? $dayData['day_number'] ?? (count($parsed) + 1)),
281 'title' => sanitize_text_field($dayData['title'] ?? $dayData['day_title'] ?? ''),
282 'description' => wp_kses_post($dayData['description'] ?? $dayData['content'] ?? ''),
283 'entries' => $this->parseDayEntries($dayData['entries'] ?? $dayData['activities'] ?? [])
284 ];
285 }
286 }
287 }
288
289 return $parsed;
290 }
291
292 /**
293 * Parse day entries/activities
294 */
295 private function parseDayEntries($entries): array
296 {
297 $parsedEntries = [];
298
299 if (!is_array($entries)) {
300 return $parsedEntries;
301 }
302
303 foreach ($entries as $entry) {
304 if (is_array($entry) && !empty($entry['title'])) {
305 $parsedEntries[] = [
306 'title' => sanitize_text_field($entry['title'] ?? $entry['activity'] ?? ''),
307 'description' => wp_kses_post($entry['description'] ?? $entry['content'] ?? ''),
308 'time' => sanitize_text_field($entry['time'] ?? $entry['time_of_day'] ?? ''),
309 'start_time' => sanitize_text_field($entry['start_time'] ?? ''),
310 'end_time' => sanitize_text_field($entry['end_time'] ?? ''),
311 'location' => sanitize_text_field($entry['location'] ?? ''),
312 'order' => intval($entry['order'] ?? count($parsedEntries)),
313 'item_type_id' => null, // Will be set to default activity type
314 'item_id' => null
315 ];
316 }
317 }
318
319 return $parsedEntries;
320 }
321
322 /**
323 * Parse itinerary from tour content (fallback method)
324 */
325 private function parseItineraryFromContent(string $content): array
326 {
327 $itinerary = [];
328
329 // Simple regex to extract day-based content
330 // This is a basic implementation - can be enhanced based on actual content structure
331 preg_match_all('/(?:Day\s*(\d+)|(\d+)\.\s*Day)[\s:]*([^\n]*(?:\n(?!Day|\d+\.)[^\n]*)*)/i', $content, $matches, PREG_SET_ORDER);
332
333 foreach ($matches as $match) {
334 $dayNumber = intval($match[1] ?: $match[2]);
335 $dayContent = trim($match[3] ?? '');
336
337 if ($dayNumber > 0 && !empty($dayContent)) {
338 // Extract title from first line
339 $lines = explode("\n", $dayContent);
340 $title = trim($lines[0] ?? "Day $dayNumber");
341 $description = trim(implode("\n", array_slice($lines, 1)));
342
343 $itinerary[] = [
344 'day_number' => $dayNumber,
345 'title' => sanitize_text_field($title),
346 'description' => wp_kses_post($description),
347 'entries' => []
348 ];
349 }
350 }
351
352 return $itinerary;
353 }
354
355 /**
356 * Clean day title and description by removing "Day {index} -" pattern
357 */
358 private function cleanDayData(string $title, string $description): array
359 {
360 // Decode entities so non-breaking spaces or dashed become native characters
361 $title = html_entity_decode(trim($title), ENT_QUOTES, 'UTF-8');
362 $description = html_entity_decode(trim($description), ENT_QUOTES, 'UTF-8');
363
364 // Remove "Day X -", "Day X :", "Day {index} -" pattern from title and description
365 // Matches literal "{index}" as well, just in case the old JS templates saved it to the DB.
366 $pattern = '/^Day[\s\xA0]*(\d+|\{index\})[\s\xA0]*[-:–—]*[\s\xA0]*/iu';
367
368 $cleanTitle = preg_replace($pattern, '', $title);
369 $cleanDescription = preg_replace($pattern, '', $description);
370
371 return [
372 'title' => trim($cleanTitle),
373 'description' => trim($cleanDescription)
374 ];
375 }
376
377 /**
378 * Create itinerary in new structure
379 */
380 private function createItinerary(int $tripId, array $itineraryData, int $oldTourId): bool
381 {
382 try {
383 $tableDays = TripItineraryDaysTable::getTableName();
384 $tableEntries = TripItineraryDayEntryTable::getTableName();
385
386 // Get current user ID for tracking
387 $currentUserId = get_current_user_id();
388 if (!$currentUserId) {
389 $currentUserId = 1; // Fallback to admin
390 }
391
392 foreach ($itineraryData as $dayData) {
393 // Clean day title and description
394 $cleanedData = $this->cleanDayData($dayData['title'], $dayData['description']);
395
396 // Create day
397 $dayResult = $this->wpdb->insert(
398 $tableDays,
399 [
400 'trip_id' => $tripId,
401 'day_number' => $dayData['day_number'],
402 'title' => $cleanedData['title'],
403 'description' => $cleanedData['description'],
404 'order' => $dayData['day_number'] - 1,
405 'created_at' => current_time('mysql'),
406 'updated_at' => current_time('mysql')
407 ],
408 ['%d', '%d', '%s', '%s', '%d', '%s', '%s']
409 );
410
411 if (!$dayResult) {
412 Logger::error("Failed to create itinerary day", [
413 'trip_id' => $tripId,
414 'day_number' => $dayData['day_number'],
415 'wpdb_error' => $this->wpdb->last_error
416 ]);
417 continue;
418 }
419
420 $dayId = $this->wpdb->insert_id;
421
422 // Create entries for this day
423 foreach ($dayData['entries'] as $entryData) {
424 $cleanedEntry = $this->cleanDayData($entryData['title'], $entryData['description']);
425 $entryResult = $this->wpdb->insert(
426 $tableEntries,
427 [
428 'trip_id' => $tripId,
429 'day_id' => $dayId,
430 'title' => $cleanedEntry['title'],
431 'description' => $cleanedEntry['description'],
432 'time' => $entryData['time'],
433 'start_time' => $entryData['start_time'],
434 'end_time' => $entryData['end_time'],
435 'location' => $entryData['location'],
436 'item_type_id' => $entryData['item_type_id'],
437 'item_id' => $entryData['item_id'],
438 'status' => 'publish',
439 'order' => $entryData['order'],
440 'created_at' => current_time('mysql'),
441 'updated_at' => current_time('mysql')
442 ],
443 ['%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%d', '%s', '%s']
444 );
445
446 if (!$entryResult) {
447 Logger::error("Failed to create itinerary entry", [
448 'trip_id' => $tripId,
449 'day_id' => $dayId,
450 'entry_title' => $entryData['title'],
451 'wpdb_error' => $this->wpdb->last_error
452 ]);
453 }
454 }
455 }
456
457 Logger::info("Itinerary created successfully", [
458 'trip_id' => $tripId,
459 'old_tour_id' => $oldTourId,
460 'days_created' => count($itineraryData)
461 ]);
462
463 return true;
464
465 } catch (\Exception $e) {
466 Logger::error("Exception creating itinerary", [
467 'trip_id' => $tripId,
468 'old_tour_id' => $oldTourId,
469 'error' => $e->getMessage()
470 ]);
471 return false;
472 }
473 }
474
475 /**
476 * Check if trip already has itinerary
477 */
478 private function hasItinerary(int $tripId): bool
479 {
480 $tableDays = TripItineraryDaysTable::getTableName();
481
482 $count = (int) $this->wpdb->get_var($this->wpdb->prepare(
483 "SELECT COUNT(*) FROM {$tableDays} WHERE trip_id = %d",
484 $tripId
485 ));
486
487 return $count > 0;
488 }
489
490 /**
491 * Delete existing itinerary for a trip
492 */
493 private function deleteTripItinerary(int $tripId): void
494 {
495 $tableDays = TripItineraryDaysTable::getTableName();
496 $tableEntries = TripItineraryDayEntryTable::getTableName();
497
498 // Delete entries first (foreign key constraint)
499 $this->wpdb->delete($tableEntries, ['trip_id' => $tripId], ['%d']);
500
501 // Delete days
502 $this->wpdb->delete($tableDays, ['trip_id' => $tripId], ['%d']);
503 }
504
505 /**
506 * Get migrated trip ID from old tour ID
507 */
508 protected function getMigratedTripId(int $oldTourId): ?int
509 {
510 $tripId = $this->wpdb->get_var($this->wpdb->prepare(
511 "SELECT meta_value FROM {$this->wpdb->postmeta}
512 WHERE meta_key = '_migrated_to_trip_id' AND post_id = %d",
513 $oldTourId
514 ));
515
516 return $tripId ? (int) $tripId : null;
517 }
518 }
519