PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5
Yatra – Travel Booking & Tour Operator Software v3.0.5
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 / Repositories / TripRepository.php

TripRepository.php in Yatra – Travel Booking & Tour Operator Software 3.0.5, at app/Repositories/TripRepository.php

3,613 lines 136.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Repositories;
6
7 use Yatra\Database\Tables\BookingsTable;
8 use Yatra\Database\Tables\ClassificationsTable;
9 use Yatra\Database\Tables\ReviewsTable;
10 use Yatra\Database\Tables\TripClassificationsTable;
11 use Yatra\Database\Tables\TripContentTable;
12 use Yatra\Database\Tables\TripItineraryDayEntryTable;
13 use Yatra\Database\Tables\TripItineraryDaysTable;
14 use Yatra\Database\Tables\TripsTable;
15 use Yatra\Repositories\TripDownloadRepository;
16 use Yatra\Repositories\AttributeRepository;
17 use Yatra\Models\Trip;
18 use Yatra\Utils\Cache;
19 use Yatra\Utils\QueryCache;
20 use Yatra\Database\Tables\TripAvailabilityDatesTable;
21 use Yatra\Database\Tables\TripAvailabilityRulesTable;
22 use Yatra\Constants\ClassificationTypes;
23
24 /**
25 * Trip Repository
26 * Handles database operations for trips with comprehensive field support
27 *
28 * Expert-level repository design:
29 * - Relationship management (destinations, activities)
30 * - JSON field handling
31 * - Soft delete support
32 * - Optimized queries with proper indexing
33 */
34 class TripRepository extends BaseRepository
35 {
36 /**
37 * Get bookings count map for given trip IDs.
38 *
39 * The trips table has a `bookings_count` column but it is not reliably maintained.
40 * For list views, compute counts from the bookings table in one grouped query.
41 *
42 * @param int[] $tripIds
43 * @param string[]|null $excludeStatuses
44 * @return array<int,int> map trip_id => count
45 */
46 public function getBookingsCountMap(array $tripIds, ?array $excludeStatuses = null): array
47 {
48 $tripIds = array_values(array_filter(array_map('intval', $tripIds)));
49 if (empty($tripIds)) {
50 return [];
51 }
52
53 // Default: ignore cancelled/failed bookings in counts (can be overridden)
54 $excludeStatuses = $excludeStatuses ?? apply_filters(
55 'yatra_trip_bookings_count_exclude_statuses',
56 ['cancelled', 'failed'],
57 $tripIds
58 );
59 $excludeStatuses = is_array($excludeStatuses) ? array_values(array_filter(array_map('strval', $excludeStatuses))) : [];
60
61 $bookingsTable = BookingsTable::getTableName();
62
63 $idPlaceholders = implode(',', array_fill(0, count($tripIds), '%d'));
64 $where = "trip_id IN ({$idPlaceholders})";
65 $params = $tripIds;
66
67 if (!empty($excludeStatuses)) {
68 $stPlaceholders = implode(',', array_fill(0, count($excludeStatuses), '%s'));
69 $where .= " AND status NOT IN ({$stPlaceholders})";
70 $params = array_merge($params, $excludeStatuses);
71 }
72
73 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- uses $wpdb->prepare with placeholders
74 $sql = $this->wpdb->prepare(
75 "SELECT trip_id, COUNT(*) AS cnt
76 FROM {$bookingsTable}
77 WHERE {$where}
78 GROUP BY trip_id",
79 $params
80 );
81
82 $rows = $this->wpdb->get_results($sql);
83 $map = [];
84 foreach ((array) $rows as $row) {
85 $tId = (int) ($row->trip_id ?? 0);
86 if ($tId > 0) {
87 $map[$tId] = (int) ($row->cnt ?? 0);
88 }
89 }
90
91 return $map;
92 }
93
94 /**
95 * Cache for table existence checks to avoid repeated SHOW TABLES queries
96 */
97 private static array $tableExistsCache = [];
98
99 /**
100 * Rich text fields specific to trips
101 */
102 protected array $richTextFields = ['description'];
103
104 /**
105 * Integer fields specific to trips
106 */
107 protected array $integerFields = [
108 'id',
109 'created_by',
110 'updated_by',
111 'difficulty_level',
112 'duration_days',
113 'duration_nights',
114 'duration_hours',
115 'booking_window_days',
116 'booking_deadline_hours',
117 'min_travelers',
118 'max_travelers',
119 'group_size',
120 'age_min',
121 'age_max',
122 'version',
123 'views_count',
124 'bookings_count',
125 'reviews_count'
126 ];
127
128 /**
129 * JSON fields specific to trips
130 */
131 protected array $jsonFields = [
132 'included_items',
133 'excluded_items',
134 'frontend_tabs',
135 'testimonial_review_ids',
136 'custom_fields',
137 'price_types',
138 ];
139
140 /**
141 * Publish trips whose scheduled_publish_date has passed
142 */
143 public function publishScheduledTrips(string $now): void
144 {
145 $table = esc_sql($this->table);
146 $affected = $this->wpdb->query(
147 $this->wpdb->prepare(
148 "UPDATE {$table}
149 SET status = 'publish', scheduled_publish_date = NULL, updated_at = %s
150 WHERE scheduled_publish_date IS NOT NULL
151 AND scheduled_publish_date <= %s
152 AND status <> 'publish'",
153 $now,
154 $now
155 )
156 );
157 if ($affected !== false && (int) $affected > 0) {
158 Cache::invalidateAfterBulkTripTableWrites();
159 }
160 }
161
162 /**
163 * Archive trips whose scheduled_unpublish_date has passed
164 */
165 public function archiveScheduledTrips(string $now): void
166 {
167 $table = esc_sql($this->table);
168 $affected = $this->wpdb->query(
169 $this->wpdb->prepare(
170 "UPDATE {$table}
171 SET status = 'archived', scheduled_unpublish_date = NULL, updated_at = %s
172 WHERE scheduled_unpublish_date IS NOT NULL
173 AND scheduled_unpublish_date <= %s
174 AND status = 'publish'",
175 $now,
176 $now
177 )
178 );
179 if ($affected !== false && (int) $affected > 0) {
180 Cache::invalidateAfterBulkTripTableWrites();
181 }
182 }
183
184 /**
185 * Enable trips when seasonal_auto_enable is set and enable date reached
186 */
187 public function enableSeasonalTrips(string $today, string $now): void
188 {
189 $table = esc_sql($this->table);
190 $affected = $this->wpdb->query(
191 $this->wpdb->prepare(
192 "UPDATE {$table}
193 SET status = 'publish', updated_at = %s
194 WHERE seasonal_auto_enable = 1
195 AND seasonal_enable_date IS NOT NULL
196 AND seasonal_enable_date <= %s
197 AND status <> 'publish'",
198 $now,
199 $today
200 )
201 );
202 if ($affected !== false && (int) $affected > 0) {
203 Cache::invalidateAfterBulkTripTableWrites();
204 }
205 }
206
207 /**
208 * Disable trips when seasonal_auto_enable is set and disable date reached
209 */
210 public function disableSeasonalTrips(string $today, string $now): void
211 {
212 $table = esc_sql($this->table);
213 $affected = $this->wpdb->query(
214 $this->wpdb->prepare(
215 "UPDATE {$table}
216 SET status = 'archived', updated_at = %s
217 WHERE seasonal_auto_enable = 1
218 AND seasonal_disable_date IS NOT NULL
219 AND seasonal_disable_date <= %s
220 AND status = 'publish'",
221 $now,
222 $today
223 )
224 );
225 if ($affected !== false && (int) $affected > 0) {
226 Cache::invalidateAfterBulkTripTableWrites();
227 }
228 }
229
230 /**
231 * Get table name
232 */
233 protected function getTableName(): string
234 {
235 return TripsTable::getTableName();
236 }
237
238 /**
239 * Cached single trip row (same key family as {@see CacheService::cacheTrip} consumers).
240 */
241 public function findByIdCached(int $id): ?\stdClass
242 {
243 return $this->cacheQueryResult(
244 Cache::PREFIX_TRIP_DATA . $id,
245 function () use ($id): ?\stdClass {
246 return $this->find($id);
247 },
248 Cache::DURATION_TRIP_DATA
249 );
250 }
251
252 /**
253 * Trip row with relationships loaded — used where {@see TripService::getWithRelationsCached} applied.
254 */
255 public function findWithRelationsCached(int $id): ?\stdClass
256 {
257 $key = Cache::PREFIX_QUERY_RESULT . 'trip_with_relations_' . $id;
258
259 return $this->cacheQueryResult($key, function () use ($id): ?\stdClass {
260 $trip = $this->find($id);
261 if ($trip) {
262 $this->loadTripRelationships($trip);
263 }
264
265 return $trip;
266 }, Cache::DURATION_TRIP_DATA);
267 }
268
269 /**
270 * Create trip row; {@see afterWrite} handles cache; fires action `yatra_trip_created` once per insert.
271 */
272 public function create(array $data): int
273 {
274 $id = parent::create($data);
275 do_action('yatra_trip_created', $id);
276
277 return $id;
278 }
279
280 /**
281 * Override update method to provide proper field formats
282 */
283 public function update(int $id, array $data): bool
284 {
285 $data = $this->sanitizeData($data);
286 $data['updated_at'] = current_time('mysql');
287
288 // Build format array based on field types
289 $formats = [];
290 foreach ($data as $key => $value) {
291 if (in_array($key, $this->integerFields, true)) {
292 $formats[] = '%d';
293 } elseif (in_array($key, ['created_at', 'updated_at'], true)) {
294 $formats[] = '%s';
295 } elseif (in_array($key, ['original_price', 'discounted_price', 'sale_price', 'deposit_amount', 'deposit_percentage', 'avg_rating', 'revenue_total', 'conversion_rate'], true)) {
296 $formats[] = '%f';
297 } elseif (in_array($key, ['transportation_included', 'is_featured', 'seasonal_auto_enable', 'has_default_time_slots'], true)) {
298 $formats[] = '%d'; // boolean as integer
299 } else {
300 $formats[] = '%s'; // default to string
301 }
302 }
303
304 $result = $this->wpdb->update(
305 $this->getTableName(),
306 $data,
307 ['id' => $id],
308 $formats,
309 ['%d']
310 );
311
312 if ($result !== false) {
313 $this->afterWrite('update', $id, []);
314 }
315
316 return $result !== false;
317 }
318
319 /**
320 * Invalidate trip/listing caches. Trip created hook is fired from {@see create()} after relations
321 * are irrelevant for cache; update/delete hooks fire here.
322 *
323 * @param 'create'|'update'|'delete' $operation
324 */
325 protected function afterWrite(string $operation, int $id, array $context = []): void
326 {
327 Cache::invalidateAfterTripWrite($operation, $id);
328
329 if ($operation === 'update') {
330 do_action('yatra_trip_updated', $id);
331 } elseif ($operation === 'delete') {
332 do_action('yatra_trip_deleted', $id);
333 }
334 }
335
336 /**
337 * Find published/active trip by ID
338 *
339 * @param int $id Trip ID
340 * @return \stdClass|null
341 */
342 public function findPublished(int $id): ?\stdClass
343 {
344 $table = esc_sql($this->table);
345 $query = "SELECT * FROM `{$table}` WHERE id = %d AND status IN ('publish', 'published')";
346
347 if ($this->hasSoftDelete()) {
348 $query .= " AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')";
349 }
350
351 $result = $this->wpdb->get_row($this->wpdb->prepare($query, $id));
352
353 return $result ?: null;
354 }
355
356 /**
357 * Get downloads for a trip (normalized for UI)
358 */
359 public function getDownloads(int $tripId): array
360 {
361 $repo = new TripDownloadRepository();
362 // TripDownloadRepository already normalizes the data correctly
363 // No need to normalize again
364 return $repo->getByTripId($tripId);
365 }
366
367 /**
368 * Attach attribute filter map for findWithFilters() (trip ↔ attribute rows in trip_classifications).
369 *
370 * @param array<string,mixed> $filters
371 * @param array<int,mixed> $attributeFilters
372 * @return array<string,mixed>
373 */
374 public function filterByAttributes(array $filters, array $attributeFilters): array
375 {
376 if ($attributeFilters === []) {
377 return $filters;
378 }
379 $filters['attribute_filters'] = $attributeFilters;
380
381 return $filters;
382 }
383
384 /** @var array<string,bool> */
385 private static array $tripColumnExistsCache = [];
386
387 protected function tripTableHasColumn(string $column): bool
388 {
389 if (isset(self::$tripColumnExistsCache[$column])) {
390 return self::$tripColumnExistsCache[$column];
391 }
392 $table = $this->getTableName();
393 $n = (int) $this->wpdb->get_var($this->wpdb->prepare(
394 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = %s',
395 $table,
396 $column
397 ));
398 self::$tripColumnExistsCache[$column] = $n > 0;
399
400 return self::$tripColumnExistsCache[$column];
401 }
402
403 /**
404 * SQL expression for trip "current" list price (matches TripPricingService::resolveRegularCurrentPrice).
405 */
406 protected function sqlTripEffectiveListPrice(): string
407 {
408 return '(CASE '
409 . 'WHEN CAST(t.discounted_price AS DECIMAL(10,2)) > 0 THEN CAST(t.discounted_price AS DECIMAL(10,2)) '
410 . 'WHEN CAST(t.sale_price AS DECIMAL(10,2)) > 0 THEN CAST(t.sale_price AS DECIMAL(10,2)) '
411 . 'ELSE CAST(t.original_price AS DECIMAL(10,2)) END)';
412 }
413
414 /**
415 * Find trips with comprehensive filtering, pagination, and relationships
416 *
417 * @param array $filters Filter criteria
418 * @param int $page Current page
419 * @param int $perPage Items per page
420 * @return array
421 */
422 public function findWithFilters(array $filters = [], int $page = 1, int $perPage = 10): array
423 {
424 // Create cache key based on filters and pagination
425 $cacheKey = Cache::KEY_TRIPS_WITH_FILTERS . '_' . md5(serialize($filters) . "_page_{$page}_per_page_{$perPage}");
426
427 return $this->cacheQueryResult($cacheKey, function() use ($filters, $page, $perPage) {
428 global $wpdb;
429
430 // Table references
431 $trip_table = $this->getTableName();
432
433 // Use new table classes
434 $reviews_table = ReviewsTable::getTableName();
435 $bookings_table = BookingsTable::getTableName();
436 // Build WHERE conditions and parameters
437 $wheres = ["t.status IN ('publish', 'published')", "(t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')"];
438 $params = [];
439 $joins = [];
440 $having_clauses = [];
441 $rating_params = [];
442
443 $classificationsTable = ClassificationsTable::getTableName();
444 $tripClassificationsTable = TripClassificationsTable::getTableName();
445
446 // Keyword search (trip fields + attribute values marked searchable in metadata)
447 if (!empty($filters['search']) && is_string($filters['search'])) {
448 $like = '%' . $wpdb->esc_like($filters['search']) . '%';
449 $searchFlag = AttributeRepository::metadataEnabledSqlOnAlias('yatra_c_srch', 'searchable');
450 $wheres[] = '(t.title LIKE %s OR t.short_description LIKE %s OR t.description LIKE %s OR t.slug LIKE %s OR EXISTS (
451 SELECT 1 FROM `' . esc_sql($tripClassificationsTable) . '` yatra_tc_srch
452 INNER JOIN `' . esc_sql($classificationsTable) . '` yatra_c_srch
453 ON yatra_c_srch.id = yatra_tc_srch.classification_id
454 WHERE yatra_tc_srch.trip_id = t.id
455 AND yatra_tc_srch.classification_type = %s
456 AND yatra_tc_srch.is_active = 1
457 AND yatra_c_srch.type = %s
458 AND yatra_c_srch.status = %s
459 AND (' . $searchFlag . ')
460 AND (
461 LOWER(JSON_UNQUOTE(JSON_EXTRACT(yatra_tc_srch.metadata, \'$.value\'))) LIKE LOWER(%s)
462 OR LOWER(yatra_tc_srch.metadata) LIKE LOWER(%s)
463 )
464 ))';
465 $params[] = $like;
466 $params[] = $like;
467 $params[] = $like;
468 $params[] = $like;
469 $params[] = 'attribute';
470 $params[] = ClassificationTypes::ATTRIBUTE;
471 $params[] = 'publish';
472 $params[] = $like;
473 $params[] = $like;
474 }
475
476 // Trip duration type (DB column)
477 if (!empty($filters['trip_type']) && is_string($filters['trip_type'])) {
478 $allowedTypes = ['single_day', 'multi_day', 'flexible'];
479 if (in_array($filters['trip_type'], $allowedTypes, true)) {
480 $wheres[] = 't.trip_type = %s';
481 $params[] = $filters['trip_type'];
482 }
483 }
484
485 // Featured Priority (column on yatra_trips, indexed by idx_featured_priority).
486 // Admin form's Featured Priority dropdown is the single source of truth.
487 // Legacy `is_featured` shortcode/block flag is normalised upstream into featured_priority='featured'.
488 if (
489 !empty($filters['featured_priority'])
490 && is_string($filters['featured_priority'])
491 && in_array($filters['featured_priority'], ['featured', 'new', 'limited'], true)
492 && $this->tripTableHasColumn('featured_priority')
493 ) {
494 $wheres[] = 't.featured_priority = %s';
495 $params[] = $filters['featured_priority'];
496 }
497
498 // Destination: checkbox classification IDs (OR) or single slug
499 if (!empty($filters['destination_ids']) && is_array($filters['destination_ids'])) {
500 $ids = array_values(array_filter(array_map('intval', $filters['destination_ids']), static fn (int $id): bool => $id > 0));
501 if ($ids !== []) {
502 $placeholders = implode(',', array_fill(0, count($ids), '%d'));
503 // Rely on joined classification row type only (some rows may have inconsistent classification_type).
504 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tcdx INNER JOIN {$classificationsTable} destx ON destx.id = tcdx.classification_id AND destx.type = %s WHERE tcdx.trip_id = t.id AND tcdx.is_active = 1 AND tcdx.classification_id IN ({$placeholders}))";
505 $params[] = ClassificationTypes::DESTINATION;
506 $params = array_merge($params, $ids);
507 }
508 } elseif (!empty($filters['destination'])) {
509 if (is_array($filters['destination'])) {
510 $slugs = array_values(array_filter(array_map('sanitize_title', $filters['destination'])));
511 if ($slugs !== []) {
512 $placeholders = implode(',', array_fill(0, count($slugs), '%s'));
513 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tcdx INNER JOIN {$classificationsTable} destx ON destx.id = tcdx.classification_id AND destx.type = %s WHERE tcdx.trip_id = t.id AND tcdx.is_active = 1 AND destx.slug IN ({$placeholders}))";
514 $params[] = ClassificationTypes::DESTINATION;
515 $params = array_merge($params, $slugs);
516 }
517 } elseif (is_string($filters['destination']) && $filters['destination'] !== '') {
518 $joins[] = "LEFT JOIN {$tripClassificationsTable} tcd ON tcd.trip_id = t.id";
519 $joins[] = "LEFT JOIN {$classificationsTable} dest ON dest.id = tcd.classification_id";
520 $wheres[] = 'dest.type = %s AND dest.slug = %s';
521 $params[] = ClassificationTypes::DESTINATION;
522 $params[] = $filters['destination'];
523 }
524 }
525
526 // Activity: IDs or slug
527 if (!empty($filters['activity_ids']) && is_array($filters['activity_ids'])) {
528 $ids = array_values(array_filter(array_map('intval', $filters['activity_ids']), static fn (int $id): bool => $id > 0));
529 if ($ids !== []) {
530 $placeholders = implode(',', array_fill(0, count($ids), '%d'));
531 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tcax INNER JOIN {$classificationsTable} actx ON actx.id = tcax.classification_id AND actx.type = %s WHERE tcax.trip_id = t.id AND tcax.is_active = 1 AND tcax.classification_id IN ({$placeholders}))";
532 $params[] = ClassificationTypes::ACTIVITY;
533 $params = array_merge($params, $ids);
534 }
535 } elseif (!empty($filters['activity'])) {
536 if (is_array($filters['activity'])) {
537 $slugs = array_values(array_filter(array_map('sanitize_title', $filters['activity'])));
538 if ($slugs !== []) {
539 $placeholders = implode(',', array_fill(0, count($slugs), '%s'));
540 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tcax INNER JOIN {$classificationsTable} actx ON actx.id = tcax.classification_id AND actx.type = %s WHERE tcax.trip_id = t.id AND tcax.is_active = 1 AND actx.slug IN ({$placeholders}))";
541 $params[] = ClassificationTypes::ACTIVITY;
542 $params = array_merge($params, $slugs);
543 }
544 } elseif (is_string($filters['activity']) && $filters['activity'] !== '') {
545 $joins[] = "LEFT JOIN {$tripClassificationsTable} tca ON tca.trip_id = t.id";
546 $joins[] = "LEFT JOIN {$classificationsTable} act ON act.id = tca.classification_id";
547 $wheres[] = 'act.type = %s AND act.slug = %s';
548 $params[] = ClassificationTypes::ACTIVITY;
549 $params[] = $filters['activity'];
550 }
551 }
552
553 // Category: IDs or slug
554 if (!empty($filters['category_ids']) && is_array($filters['category_ids'])) {
555 $ids = array_values(array_filter(array_map('intval', $filters['category_ids']), static fn (int $id): bool => $id > 0));
556 if ($ids !== []) {
557 $placeholders = implode(',', array_fill(0, count($ids), '%d'));
558 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tccx INNER JOIN {$classificationsTable} catx ON catx.id = tccx.classification_id AND catx.type = %s WHERE tccx.trip_id = t.id AND tccx.is_active = 1 AND tccx.classification_id IN ({$placeholders}))";
559 $params[] = ClassificationTypes::CATEGORY;
560 $params = array_merge($params, $ids);
561 }
562 } elseif (!empty($filters['trip_category'])) {
563 if (is_array($filters['trip_category'])) {
564 $slugs = array_values(array_filter(array_map('sanitize_title', $filters['trip_category'])));
565 if ($slugs !== []) {
566 $placeholders = implode(',', array_fill(0, count($slugs), '%s'));
567 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} tccx INNER JOIN {$classificationsTable} catx ON catx.id = tccx.classification_id AND catx.type = %s WHERE tccx.trip_id = t.id AND tccx.is_active = 1 AND catx.slug IN ({$placeholders}))";
568 $params[] = ClassificationTypes::CATEGORY;
569 $params = array_merge($params, $slugs);
570 }
571 } elseif (is_string($filters['trip_category']) && $filters['trip_category'] !== '') {
572 $joins[] = "LEFT JOIN {$tripClassificationsTable} tcc ON tcc.trip_id = t.id";
573 $joins[] = "LEFT JOIN {$classificationsTable} cat ON cat.id = tcc.classification_id";
574 $wheres[] = 'cat.type = %s AND cat.slug = %s';
575 $params[] = ClassificationTypes::CATEGORY;
576 $params[] = $filters['trip_category'];
577 }
578 }
579
580 // Special offers (OR within group)
581 if (!empty($filters['special_offers']) && is_array($filters['special_offers'])) {
582 $offerParts = [];
583 foreach (array_unique($filters['special_offers']) as $offer) {
584 if (!is_string($offer)) {
585 continue;
586 }
587 switch ($offer) {
588 case 'discount':
589 $offerParts[] = '(t.discounted_price IS NOT NULL OR t.sale_price IS NOT NULL)';
590 break;
591 case 'early-bird':
592 if ($this->tripTableHasColumn('early_bird_discount_enabled')) {
593 $offerParts[] = 't.early_bird_discount_enabled = 1';
594 }
595 break;
596 case 'last-minute':
597 if ($this->tripTableHasColumn('last_minute_discount_enabled')) {
598 $offerParts[] = 't.last_minute_discount_enabled = 1';
599 }
600 break;
601 case 'instant-booking':
602 if ($this->tripTableHasColumn('instant_booking')) {
603 $offerParts[] = 't.instant_booking = 1';
604 }
605 break;
606 case 'flexible-dates':
607 if ($this->tripTableHasColumn('flexible_dates')) {
608 $offerParts[] = 't.flexible_dates = 1';
609 }
610 break;
611 case 'deposit-available':
612 if ($this->tripTableHasColumn('deposit_required')) {
613 $offerParts[] = 't.deposit_required = 1';
614 }
615 break;
616 }
617 }
618 if ($offerParts !== []) {
619 $wheres[] = '(' . implode(' OR ', $offerParts) . ')';
620 }
621 }
622
623 // Booking options (OR)
624 if (!empty($filters['booking_options']) && is_array($filters['booking_options'])) {
625 $bookParts = [];
626 foreach (array_unique($filters['booking_options']) as $opt) {
627 if (!is_string($opt)) {
628 continue;
629 }
630 switch ($opt) {
631 case 'instant':
632 if ($this->tripTableHasColumn('instant_booking')) {
633 $bookParts[] = 't.instant_booking = 1';
634 }
635 break;
636 case 'flexible':
637 if ($this->tripTableHasColumn('flexible_dates')) {
638 $bookParts[] = 't.flexible_dates = 1';
639 }
640 break;
641 case 'pay-later':
642 if ($this->tripTableHasColumn('deposit_required')) {
643 $bookParts[] = 't.deposit_required = 1';
644 }
645 break;
646 }
647 }
648 if ($bookParts !== []) {
649 $wheres[] = '(' . implode(' OR ', $bookParts) . ')';
650 }
651 }
652
653 // Age suitability (OR)
654 if (!empty($filters['age_suitability']) && is_array($filters['age_suitability'])) {
655 $ageParts = [];
656 foreach (array_unique($filters['age_suitability']) as $age) {
657 if (!is_string($age)) {
658 continue;
659 }
660 switch ($age) {
661 case 'family-friendly':
662 $ageParts[] = '(t.age_min IS NULL OR t.age_min <= 5)';
663 break;
664 case 'kids-friendly':
665 $ageParts[] = '(t.age_min IS NULL OR t.age_min <= 12)';
666 break;
667 case 'senior-friendly':
668 $ageParts[] = '(t.age_max IS NULL OR t.age_max >= 65)';
669 break;
670 case 'adults-only':
671 $ageParts[] = 't.age_min >= 18';
672 break;
673 }
674 }
675 if ($ageParts !== []) {
676 $wheres[] = '(' . implode(' OR ', $ageParts) . ')';
677 }
678 }
679
680 // Accommodation type
681 if (!empty($filters['accommodation']) && is_array($filters['accommodation']) && $this->tripTableHasColumn('accommodation_type')) {
682 $acc = array_values(array_filter(array_map('sanitize_text_field', $filters['accommodation'])));
683 if ($acc !== []) {
684 $placeholders = implode(',', array_fill(0, count($acc), '%s'));
685 $wheres[] = "t.accommodation_type IN ({$placeholders})";
686 $params = array_merge($params, $acc);
687 }
688 }
689
690 // Included services (loose match on JSON text)
691 if (!empty($filters['included_services']) && is_array($filters['included_services'])) {
692 foreach (array_unique($filters['included_services']) as $svc) {
693 if (!is_string($svc) || $svc === '') {
694 continue;
695 }
696 $like = '%' . $wpdb->esc_like($svc) . '%';
697 $wheres[] = 't.included_items LIKE %s';
698 $params[] = $like;
699 }
700 }
701
702 // Dynamic attribute filters (only attributes allowed in public filters — admin setting "Show in Filters")
703 if (!empty($filters['attribute_filters']) && is_array($filters['attribute_filters'])) {
704 $allowedFilterAttrIds = (new AttributeRepository())->getFilterableAttributeIds();
705 foreach ($filters['attribute_filters'] as $attrId => $rawVal) {
706 $attrId = (int) $attrId;
707 if ($attrId <= 0) {
708 continue;
709 }
710 if (!in_array($attrId, $allowedFilterAttrIds, true)) {
711 continue;
712 }
713 if (is_array($rawVal) && (isset($rawVal['min']) || isset($rawVal['max']))) {
714 if (isset($rawVal['min']) && is_numeric($rawVal['min'])) {
715 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} ta_n WHERE ta_n.trip_id = t.id AND ta_n.classification_type = 'attribute' AND ta_n.classification_id = {$attrId} AND ta_n.is_active = 1 AND CAST(JSON_UNQUOTE(JSON_EXTRACT(ta_n.metadata, '$.value')) AS DECIMAL(14,4)) >= %f)";
716 $params[] = (float) $rawVal['min'];
717 }
718 if (isset($rawVal['max']) && is_numeric($rawVal['max'])) {
719 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} ta_x WHERE ta_x.trip_id = t.id AND ta_x.classification_type = 'attribute' AND ta_x.classification_id = {$attrId} AND ta_x.is_active = 1 AND CAST(JSON_UNQUOTE(JSON_EXTRACT(ta_x.metadata, '$.value')) AS DECIMAL(14,4)) <= %f)";
720 $params[] = (float) $rawVal['max'];
721 }
722 } elseif (is_array($rawVal) && $rawVal !== []) {
723 $vals = array_values(array_filter(array_map('sanitize_text_field', $rawVal)));
724 if ($vals !== []) {
725 $placeholders = implode(',', array_fill(0, count($vals), '%s'));
726 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} ta_m WHERE ta_m.trip_id = t.id AND ta_m.classification_type = 'attribute' AND ta_m.classification_id = {$attrId} AND ta_m.is_active = 1 AND JSON_UNQUOTE(JSON_EXTRACT(ta_m.metadata, '$.value')) IN ({$placeholders}))";
727 $params = array_merge($params, $vals);
728 }
729 } else {
730 $v = sanitize_text_field((string) $rawVal);
731 if ($v !== '') {
732 $wheres[] = "EXISTS (SELECT 1 FROM {$tripClassificationsTable} ta_s WHERE ta_s.trip_id = t.id AND ta_s.classification_type = 'attribute' AND ta_s.classification_id = {$attrId} AND ta_s.is_active = 1 AND JSON_UNQUOTE(JSON_EXTRACT(ta_s.metadata, '$.value')) = %s)";
733 $params[] = $v;
734 }
735 }
736 }
737 }
738
739 // Price range filter.
740 //
741 // The previous version compared a single legacy "effective"
742 // price (discounted → sale → original column) against the
743 // range. That excluded traveler-based trips whose real
744 // displayed price lives in the `price_types` JSON — so eg. a
745 // trip costing €3755 (Adult category) never appeared in search
746 // when the user set max=€3755, because its legacy
747 // original_price was €0 / stale.
748 //
749 // New approach: match a trip if EITHER the legacy effective
750 // price OR ANY per-category price in the JSON falls in the
751 // requested range. Numeric values in the `price_types` JSON
752 // are extracted with a regex on the raw text — works in all
753 // MySQL 5.7+ builds without needing JSON_VALUE / JSON_TABLE
754 // (which are inconsistent across MariaDB / older MySQL).
755 $effPrice = $this->sqlTripEffectiveListPrice();
756 $priceMin = !empty($filters['price_min']) && $filters['price_min'] > 0 ? (float) $filters['price_min'] : null;
757 $priceMax = !empty($filters['price_max']) && $filters['price_max'] > 0 ? (float) $filters['price_max'] : null;
758
759 if ($priceMin !== null || $priceMax !== null) {
760 $legacyCond = [];
761 if ($priceMin !== null) { $legacyCond[] = "{$effPrice} >= %f"; $params[] = $priceMin; }
762 if ($priceMax !== null) { $legacyCond[] = "{$effPrice} <= %f"; $params[] = $priceMax; }
763 $legacyClause = implode(' AND ', $legacyCond);
764
765 // For per-category pricing, find ANY price in `price_types`
766 // JSON that falls in the requested range. This subquery
767 // creates an ad-hoc number sequence (n=0..49 covers up to
768 // 50 categories — far more than any real trip uses) and
769 // uses JSON_EXTRACT to fetch each entry's effective price.
770 $numbers = "(SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15 UNION ALL SELECT 16 UNION ALL SELECT 17 UNION ALL SELECT 18 UNION ALL SELECT 19)";
771 // Each category's effective price = first non-null of
772 // discounted_price → sale_price → original_price → price.
773 $categoryEff = "COALESCE("
774 . "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].discounted_price'))) AS DECIMAL(10,2)), 0),"
775 . "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].sale_price'))) AS DECIMAL(10,2)), 0),"
776 . "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].original_price'))) AS DECIMAL(10,2)), 0),"
777 . "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].price'))) AS DECIMAL(10,2)), 0)"
778 . ")";
779 $catCond = [];
780 if ($priceMin !== null) { $catCond[] = "{$categoryEff} >= %f"; $params[] = $priceMin; }
781 if ($priceMax !== null) { $catCond[] = "{$categoryEff} <= %f"; $params[] = $priceMax; }
782 $catClause = implode(' AND ', $catCond);
783
784 // Guard the per-category branch against malformed JSON.
785 // JSON_EXTRACT throws on invalid JSON, and `price_types`
786 // can be NULL, '', '[]' or a stale string on legacy rows.
787 // JSON_VALID() + IFNULL() lets us bail cleanly for those.
788 $perCategoryExists = "("
789 . "t.price_types IS NOT NULL"
790 . " AND t.price_types <> ''"
791 . " AND JSON_VALID(t.price_types) = 1"
792 . " AND EXISTS ("
793 . "SELECT 1 FROM {$numbers} _n"
794 . " WHERE JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, ']')) IS NOT NULL"
795 . " AND {$catClause}"
796 . ")"
797 . ")";
798
799 // Match if EITHER condition passes — a single legacy-priced
800 // trip OR a per-category trip with any matching tier.
801 $wheres[] = "(({$legacyClause}) OR {$perCategoryExists})";
802 }
803
804 // Duration filter
805 if (!empty($filters['duration_min']) && $filters['duration_min'] > 0) {
806 $wheres[] = "CAST(t.duration_days AS UNSIGNED) >= %d";
807 $params[] = $filters['duration_min'];
808 }
809
810 if (!empty($filters['duration_max']) && $filters['duration_max'] > 0) {
811 $wheres[] = "CAST(t.duration_days AS UNSIGNED) <= %d";
812 $params[] = $filters['duration_max'];
813 }
814
815 // Rating filter
816 if (!empty($filters['rating_min']) && $filters['rating_min'] > 0) {
817 $having_clauses[] = "AVG(r.rating) >= %f";
818 $rating_params[] = $filters['rating_min'];
819 }
820
821 // Difficulty filter
822 if (!empty($filters['difficulty']) && is_array($filters['difficulty'])) {
823 $joins[] = "LEFT JOIN {$classificationsTable} dl ON dl.id = t.difficulty_level";
824 $difficulty_placeholders = implode(',', array_fill(0, count($filters['difficulty']), '%d'));
825 $wheres[] = "dl.type = %s AND dl.id IN ({$difficulty_placeholders})";
826 $params[] = ClassificationTypes::DIFFICULTY;
827 $params = array_merge($params, $filters['difficulty']);
828 }
829
830 // Build SQL components
831 $join_sql = !empty($joins) ? implode(' ', $joins) : '';
832 $where_sql = 'WHERE ' . implode(' AND ', $wheres);
833 $having_sql = !empty($having_clauses) ? ('HAVING ' . implode(' AND ', $having_clauses)) : '';
834
835 // Calculate pagination
836 $offset = ($page - 1) * $perPage;
837
838 // Count total results
839 $count_params = array_merge($params, $rating_params);
840 if (!empty($having_clauses)) {
841 $count_sql = "SELECT COUNT(*) FROM (
842 SELECT t.id
843 FROM {$trip_table} t
844 {$join_sql}
845 LEFT JOIN {$reviews_table} r ON r.trip_id = t.id AND r.status = 'approved'
846 {$where_sql}
847 GROUP BY t.id
848 {$having_sql}
849 ) as filtered_trips";
850 } else {
851 $count_sql = "SELECT COUNT(DISTINCT t.id)
852 FROM {$trip_table} t
853 {$join_sql}
854 {$where_sql}";
855 $count_params = $params;
856 }
857
858 $prepared_count_query = empty($count_params) ? $count_sql :
859 $wpdb->prepare($count_sql, ...$count_params);
860
861 $total = (int) $wpdb->get_var($prepared_count_query);
862 $total_pages = $total > 0 ? (int) ceil($total / $perPage) : 1;
863
864 // Build ORDER BY clause
865 $order_clause = $this->buildTripOrderClause($filters['sort'] ?? '');
866
867 // Check for difficulty table and build difficulty JOIN
868 $difficulty_join = $this->buildDifficultyJoin();
869 $difficulty_select = $difficulty_join ? ', diff.name AS difficulty_name, diff.icon AS difficulty_icon' : '';
870
871
872 // Main query
873 $query_sql = "SELECT t.*,
874 AVG(r.rating) AS average_rating,
875 COUNT(DISTINCT r.id) AS review_count,
876 COUNT(DISTINCT b.id) AS booking_count{$difficulty_select}
877 FROM {$trip_table} t
878 {$join_sql}
879 LEFT JOIN {$reviews_table} r ON r.trip_id = t.id AND r.status = 'approved'
880 LEFT JOIN {$bookings_table} b ON b.trip_id = t.id AND b.status IN ('confirmed', 'completed', 'paid')
881 {$difficulty_join}
882 {$where_sql}
883 GROUP BY t.id
884 {$having_sql}
885 {$order_clause}
886 LIMIT %d OFFSET %d";
887
888 $main_query_params = array_merge($params, $rating_params, [$perPage, $offset]);
889 $prepared_query = $wpdb->prepare($query_sql, ...$main_query_params);
890
891 $trips = $wpdb->get_results($prepared_query) ?: [];
892
893 // Process trips in batch for better performance (eliminates N+1 queries)
894 if (!empty($trips)) {
895 $this->batchEnrichTrips($trips);
896 }
897
898 return [
899 'trips' => $trips,
900 'total' => $total,
901 'pages' => $total_pages,
902 'page' => $page,
903 'per_page' => $perPage
904 ];
905 }, Cache::DURATION_QUERY_RESULT); // Cache for 10 minutes
906 }
907
908 /**
909 * Build ORDER BY clause for trip-specific sorting
910 */
911 protected function buildTripOrderClause(string $sort): string
912 {
913 $effPrice = $this->sqlTripEffectiveListPrice();
914 switch ($sort) {
915 case 'most_popular':
916 return "ORDER BY booking_count DESC, t.created_at DESC";
917 case 'price_low':
918 return "ORDER BY {$effPrice} ASC";
919 case 'price_high':
920 return "ORDER BY {$effPrice} DESC";
921 case 'rating_high':
922 return "ORDER BY average_rating DESC";
923 case 'date_asc':
924 return "ORDER BY t.created_at ASC";
925 case 'duration_short':
926 return "ORDER BY CAST(t.duration_days AS UNSIGNED) ASC, CAST(t.duration_nights AS UNSIGNED) ASC";
927 case 'duration_long':
928 return "ORDER BY CAST(t.duration_days AS UNSIGNED) DESC, CAST(t.duration_nights AS UNSIGNED) DESC";
929 default:
930 return "ORDER BY t.created_at DESC";
931 }
932 }
933
934 /**
935 * Build difficulty JOIN clause if table exists
936 */
937 protected function buildDifficultyJoin(): string
938 {
939 global $wpdb;
940
941 // Use ClassificationsTable for difficulty levels (type = 'difficulty')
942 $difficulty_table = ClassificationsTable::getTableName();
943
944 // Use advanced caching system instead of simple array cache
945 $tableExists = Cache::tableExists($difficulty_table, function() use ($wpdb, $difficulty_table) {
946 return (bool) $wpdb->get_var("SHOW TABLES LIKE '{$difficulty_table}'");
947 });
948
949 if ($tableExists) {
950 // Map yatra_trips.difficulty_level (bigint ID) to yatra_classifications table
951 // The difficulty_level field contains the classification ID
952 return sprintf(
953 "LEFT JOIN {$difficulty_table} diff ON diff.id = t.difficulty_level AND diff.type = '%s'",
954 ClassificationTypes::DIFFICULTY
955 );
956 }
957 return '';
958 }
959
960 /**
961 * Batch enrich multiple trips to eliminate N+1 queries
962 *
963 * @param array $trips Array of trip objects
964 */
965 protected function batchEnrichTrips(array $trips): void
966 {
967 if (empty($trips)) {
968 return;
969 }
970
971 global $wpdb;
972 $trip_ids = array_column($trips, 'id');
973 $trip_ids_placeholder = implode(',', array_fill(0, count($trip_ids), '%d'));
974
975 // Batch load destinations
976 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
977 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
978 $destinations_data = [];
979 $destTableExists = Cache::tableExists($tripClassificationsTable, function() use ($wpdb, $tripClassificationsTable) {
980 return (bool) $wpdb->get_var("SHOW TABLES LIKE '{$tripClassificationsTable}'");
981 });
982
983 if ($destTableExists) {
984 $destinations_raw = $wpdb->get_results($wpdb->prepare(
985 "SELECT tc.trip_id, c.* FROM {$classificationsTable} c
986 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
987 WHERE tc.trip_id IN ({$trip_ids_placeholder}) AND c.type = %s AND c.status = 'publish'
988 ORDER BY tc.trip_id, tc.sort_order ASC, c.name ASC",
989 ClassificationTypes::DESTINATION, ...$trip_ids
990 ));
991
992 foreach ($destinations_raw as $dest) {
993 $trip_id = $dest->trip_id;
994 unset($dest->trip_id);
995 $destinations_data[$trip_id][] = $dest;
996 }
997 }
998
999 // Batch load activities
1000 $activities_data = [];
1001 $actTableExists = Cache::tableExists($tripClassificationsTable, function() use ($wpdb, $tripClassificationsTable) {
1002 return (bool) $wpdb->get_var("SHOW TABLES LIKE '{$tripClassificationsTable}'");
1003 });
1004
1005 if ($actTableExists) {
1006 $activities_raw = $wpdb->get_results($wpdb->prepare(
1007 "SELECT tc.trip_id, c.* FROM {$classificationsTable} c
1008 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
1009 WHERE tc.trip_id IN ({$trip_ids_placeholder}) AND c.type = %s AND c.status = 'publish'
1010 ORDER BY tc.trip_id, tc.sort_order ASC, c.name ASC",
1011 ClassificationTypes::ACTIVITY, ...$trip_ids
1012 ));
1013
1014 foreach ($activities_raw as $act) {
1015 $trip_id = $act->trip_id;
1016 unset($act->trip_id);
1017 $activities_data[$trip_id][] = $act;
1018 }
1019 }
1020
1021 // Batch load categories
1022 // Using TripClassificationsTable for category relationships
1023 $cat_rel_table = $tripClassificationsTable;
1024 $categories_data = [];
1025 $catTableExists = Cache::tableExists($cat_rel_table, function() use ($wpdb, $cat_rel_table) {
1026 return (bool) $wpdb->get_var("SHOW TABLES LIKE '{$cat_rel_table}'");
1027 });
1028
1029 if ($catTableExists) {
1030 $categories_raw = $wpdb->get_results($wpdb->prepare(
1031 "SELECT tc.trip_id, c.* FROM {$classificationsTable} c
1032 INNER JOIN {$cat_rel_table} tc ON c.id = tc.classification_id
1033 WHERE tc.trip_id IN ({$trip_ids_placeholder})
1034 AND c.type = %s AND c.status = 'publish'
1035 ORDER BY tc.trip_id, c.name ASC",
1036 ClassificationTypes::CATEGORY, ...$trip_ids
1037 ));
1038
1039 foreach ($categories_raw as $cat) {
1040 $trip_id = $cat->trip_id;
1041 unset($cat->trip_id);
1042 $categories_data[$trip_id][] = $cat;
1043 }
1044 }
1045
1046 $price_types_by_trip = $this->batchLoadPriceTypesByTripIds($trip_ids);
1047
1048 // Apply enriched data to each trip — use centralized TripPricingService
1049 foreach ($trips as $trip) {
1050 $trip->effective_price_min = \Yatra\Services\TripPricingService::getEffectivePrice($trip);
1051
1052 // Set relationships
1053 $trip->destinations = $destinations_data[$trip->id] ?? [];
1054 $trip->activities = $activities_data[$trip->id] ?? [];
1055 $trip->categories = $categories_data[$trip->id] ?? [];
1056 $trip->price_types = $price_types_by_trip[$trip->id] ?? [];
1057 }
1058 }
1059
1060 /**
1061 * Enrich trip data with additional computed fields (legacy single-trip method)
1062 */
1063 protected function enrichTripData(\stdClass $trip): void
1064 {
1065 global $wpdb;
1066
1067 // Compute effective pricing via centralized TripPricingService
1068 $trip->effective_price_min = \Yatra\Services\TripPricingService::getEffectivePrice($trip);
1069 }
1070
1071 /**
1072 * Load trip relationships (destinations, activities, categories)
1073 */
1074 protected function loadTripRelationships(\stdClass $trip): void
1075 {
1076 global $wpdb;
1077
1078 // Use new Classification tables
1079 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
1080 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1081
1082 // Load destinations
1083 $destinations = $wpdb->get_results($wpdb->prepare(
1084 "SELECT c.* FROM {$classificationsTable} c
1085 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
1086 WHERE tc.trip_id = %d AND c.type = %s AND c.status = 'publish'
1087 ORDER BY tc.sort_order ASC, c.name ASC",
1088 $trip->id, ClassificationTypes::DESTINATION
1089 ));
1090 $trip->destinations = $destinations ?: [];
1091
1092 // Load activities
1093 $activities = $wpdb->get_results($wpdb->prepare(
1094 "SELECT c.* FROM {$classificationsTable} c
1095 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
1096 WHERE tc.trip_id = %d AND c.type = %s AND c.status = 'publish'
1097 ORDER BY tc.sort_order ASC, c.name ASC",
1098 $trip->id, ClassificationTypes::ACTIVITY
1099 ));
1100 $trip->activities = $activities ?: [];
1101
1102 // Load categories
1103 $categories = $wpdb->get_results($wpdb->prepare(
1104 "SELECT c.* FROM {$classificationsTable} c
1105 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
1106 WHERE tc.trip_id = %d AND c.type = %s AND c.status = 'publish'
1107 ORDER BY tc.sort_order ASC, c.name ASC",
1108 $trip->id, ClassificationTypes::CATEGORY
1109 ));
1110 $trip->categories = $categories ?: [];
1111
1112 // Load price types for traveler-based pricing (from trips table JSON)
1113 $trip->price_types = $this->getPriceTypes((int) $trip->id);
1114 }
1115
1116 /**
1117 * Find by slug
1118 */
1119 public function findBySlug(string $slug): ?\stdClass
1120 {
1121 $table = esc_sql($this->table);
1122 $query = "SELECT * FROM `{$table}` WHERE slug = %s";
1123
1124 if ($this->hasSoftDelete()) {
1125 $query .= " AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')";
1126 }
1127
1128 $result = $this->wpdb->get_row(
1129 $this->wpdb->prepare($query, $slug)
1130 );
1131
1132 return $result ?: null;
1133 }
1134
1135 /**
1136 * Find by ID with relationships
1137 */
1138 public function findWithRelations(int $id, bool $includeDeleted = false): ?\stdClass
1139 {
1140 $trip = $this->find($id, $includeDeleted);
1141
1142 if (!$trip) {
1143 return null;
1144 }
1145
1146 // Load destinations
1147 $trip->destinations = $this->getDestinations($id);
1148
1149 // Load activities
1150 $trip->activities = $this->getActivities($id);
1151
1152 // Load trip categories
1153 $trip->trip_category = $this->getTripCategories($id);
1154
1155 // Load price types
1156 $trip->price_types = $this->getPriceTypes($id);
1157
1158 // Load gallery images
1159 $trip->gallery_images = $this->getGalleryImages($id);
1160
1161 // Load downloads
1162 $trip->downloadable_items = $this->getDownloads($id);
1163
1164 // Load highlights
1165 $trip->highlights = $this->getHighlights($id);
1166
1167 // Load landmarks
1168 $trip->landmarks = $this->getLandmarks($id);
1169
1170 // Load FAQs
1171 $trip->faqs = $this->getFaqs($id);
1172
1173 // Load availability dates
1174 $trip->availability_dates = $this->getAvailabilityDates($id);
1175
1176 // Load itinerary days with entries
1177 $trip->itinerary_days = $this->getItineraryDays($id);
1178
1179 do_action('yatra_trip_loaded_with_relations', $trip);
1180
1181 return $trip;
1182 }
1183
1184 /**
1185 * Get destinations for a trip
1186 */
1187 public function getDestinations(int $tripId): array
1188 {
1189 global $wpdb;
1190
1191 // Use new Classification tables
1192 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
1193 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1194
1195 return $wpdb->get_results(
1196 $wpdb->prepare(
1197 "SELECT tc.classification_id as id, tc.sort_order, tc.relationship_type, tc.is_featured, c.name, c.slug
1198 FROM {$tripClassificationsTable} tc
1199 LEFT JOIN {$classificationsTable} c ON c.id = tc.classification_id
1200 WHERE tc.trip_id = %d AND c.type = %s
1201 ORDER BY tc.sort_order ASC, tc.id ASC",
1202 $tripId, ClassificationTypes::DESTINATION
1203 )
1204 ) ?: [];
1205 }
1206
1207 /**
1208 * Get activities for a trip
1209 */
1210 public function getActivities(int $tripId): array
1211 {
1212 global $wpdb;
1213
1214 // Use new Classification tables
1215 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
1216 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1217
1218 $results = $wpdb->get_results(
1219 $wpdb->prepare(
1220 "SELECT tc.*, c.name as activity_name, c.slug as activity_slug
1221 FROM {$tripClassificationsTable} tc
1222 LEFT JOIN {$classificationsTable} c ON c.id = tc.classification_id
1223 WHERE tc.trip_id = %d AND tc.classification_type = %s
1224 ORDER BY tc.sort_order ASC, tc.id ASC",
1225 $tripId, ClassificationTypes::ACTIVITY
1226 )
1227 ) ?: [];
1228
1229 // Filter out relationships where the activity doesn't exist in Classifications table
1230 $validResults = array_filter($results, function($result) {
1231 return !empty($result->activity_name) && !empty($result->activity_slug);
1232 });
1233
1234 return array_values($validResults);
1235 }
1236
1237 /**
1238 * Get trip categories for a trip
1239 */
1240 public function getTripCategories(int $tripId): array
1241 {
1242 global $wpdb;
1243
1244 // Use TripClassificationsTable for trip-category relationships
1245 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
1246 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1247
1248 $sql = $wpdb->prepare(
1249 "SELECT tc.*, c.name as category_name, c.slug as category_slug
1250 FROM {$tripClassificationsTable} tc
1251 LEFT JOIN {$classificationsTable} c ON c.id = tc.classification_id
1252 WHERE tc.trip_id = %d AND tc.classification_type = %s
1253 ORDER BY tc.sort_order ASC, tc.id ASC",
1254 $tripId, ClassificationTypes::CATEGORY
1255 );
1256
1257 $results = $wpdb->get_results($sql) ?: [];
1258
1259 // Filter out relationships where the category doesn't exist in Classifications table
1260 $validResults = array_filter($results, function($result) {
1261 return !empty($result->category_name) && !empty($result->category_slug);
1262 });
1263
1264 return array_values($validResults);
1265 }
1266
1267 /**
1268 * Normalize decoded price_types JSON (trips.price_types column).
1269 *
1270 * @param mixed $json Raw column value or already-decoded array
1271 * @return array<int, array<string, mixed>>
1272 */
1273 protected function parsePriceTypesJson($json): array
1274 {
1275 if ($json === null || $json === '') {
1276 return [];
1277 }
1278
1279 $decoded = is_string($json) ? json_decode($json, true) : $json;
1280 if (!is_array($decoded)) {
1281 return [];
1282 }
1283
1284 return array_values(array_filter(array_map(function ($pt) {
1285 if (!is_array($pt)) {
1286 return null;
1287 }
1288 $normalized = [
1289 'category_id' => isset($pt['category_id']) ? (int) $pt['category_id'] : null,
1290 'original_price' => isset($pt['original_price']) ? (float) $pt['original_price'] : null,
1291 'discounted_price' => isset($pt['discounted_price']) ? (float) $pt['discounted_price'] : null,
1292 'sale_price' => isset($pt['sale_price']) ? (float) $pt['sale_price'] : null,
1293 'label' => $pt['label'] ?? ($pt['title'] ?? null),
1294 'pricing_mode' => $pt['pricing_mode'] ?? 'per_person',
1295 'is_default' => !empty($pt['is_default']),
1296 ];
1297 if (isset($pt['category_label'])) {
1298 $normalized['category_label'] = $pt['category_label'];
1299 }
1300 if (isset($pt['description'])) {
1301 $normalized['description'] = $pt['description'];
1302 }
1303
1304 return $normalized;
1305 }, $decoded)));
1306 }
1307
1308 /**
1309 * Batch-load price_types for many trips (single query).
1310 *
1311 * @param int[] $trip_ids
1312 * @return array<int, array<int, array<string, mixed>>>
1313 */
1314 protected function batchLoadPriceTypesByTripIds(array $trip_ids): array
1315 {
1316 $trip_ids = array_values(array_unique(array_map('intval', array_filter($trip_ids))));
1317 if ($trip_ids === []) {
1318 return [];
1319 }
1320
1321 $table = esc_sql($this->table);
1322 $placeholders = implode(',', array_fill(0, count($trip_ids), '%d'));
1323 $sql = "SELECT id, price_types FROM `{$table}` WHERE id IN ({$placeholders})";
1324 $rows = $this->wpdb->get_results($this->wpdb->prepare($sql, ...$trip_ids)) ?: [];
1325
1326 $out = [];
1327 foreach ($rows as $row) {
1328 $out[(int) $row->id] = $this->parsePriceTypesJson($row->price_types ?? null);
1329 }
1330
1331 return $out;
1332 }
1333
1334 /**
1335 * Get price types for a trip
1336 */
1337 public function getPriceTypes(int $tripId): array
1338 {
1339 $table = esc_sql($this->table);
1340 $json = $this->wpdb->get_var(
1341 $this->wpdb->prepare("SELECT price_types FROM `{$table}` WHERE id = %d", $tripId)
1342 );
1343
1344 return $this->parsePriceTypesJson($json);
1345 }
1346
1347 /**
1348 * Get gallery images for a trip
1349 */
1350 public function getGalleryImages(int $tripId): array
1351 {
1352 global $wpdb;
1353
1354 // Use TripContentTable for gallery images
1355 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
1356
1357 $rows = $wpdb->get_results(
1358 $wpdb->prepare(
1359 "SELECT * FROM {$tripContentTable}
1360 WHERE trip_id = %d AND content_type = 'image'
1361 ORDER BY sort_order ASC, id ASC",
1362 $tripId
1363 )
1364 ) ?: [];
1365
1366 // Normalize to the shape the edit form expects
1367 return array_map(function ($row) {
1368 $metadata = [];
1369 if (!empty($row->metadata)) {
1370 $decoded = json_decode($row->metadata, true);
1371 if (is_array($decoded)) {
1372 $metadata = $decoded;
1373 }
1374 }
1375
1376 $imageId = $metadata['image_id'] ?? ($row->image_id ?? null);
1377 $altText = $metadata['alt_text'] ?? null;
1378 $caption = $metadata['caption'] ?? null;
1379 $dimensions = $metadata['dimensions'] ?? null;
1380
1381 return (object) [
1382 'id' => $imageId ? (int) $imageId : 0,
1383 'image_id' => $imageId ? (int) $imageId : 0,
1384 'url' => $row->content_url ?? '',
1385 'image_url' => $row->content_url ?? '',
1386 'thumbnail_url' => $row->thumbnail_url ?? '',
1387 'alt_text' => $altText ?? '',
1388 'caption' => $caption ?? '',
1389 'width' => is_array($dimensions) && isset($dimensions['width']) ? (int) $dimensions['width'] : null,
1390 'height' => is_array($dimensions) && isset($dimensions['height']) ? (int) $dimensions['height'] : null,
1391 'is_featured' => isset($row->is_featured) ? (bool) $row->is_featured : false,
1392 'order' => isset($row->sort_order) ? (int) $row->sort_order : 0,
1393 ];
1394 }, $rows);
1395 }
1396
1397 /**
1398 * Get highlights for a trip
1399 */
1400 public function getHighlights(int $tripId): array
1401 {
1402 global $wpdb;
1403
1404 // Use TripContentTable for highlights
1405 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
1406
1407 $rows = $wpdb->get_results(
1408 $wpdb->prepare(
1409 "SELECT * FROM {$tripContentTable}
1410 WHERE trip_id = %d AND content_type = 'highlight'
1411 ORDER BY sort_order ASC, id ASC",
1412 $tripId
1413 )
1414 ) ?: [];
1415
1416 // Normalize to UI shape
1417 return array_map(function ($row) {
1418 $metadata = [];
1419 if (!empty($row->metadata)) {
1420 $decoded = json_decode($row->metadata, true);
1421 if (is_array($decoded)) {
1422 $metadata = $decoded;
1423 }
1424 }
1425
1426 $imageId = $metadata['image_id'] ?? ($row->image_id ?? null);
1427 $icon = $metadata['icon'] ?? ($row->icon ?? null);
1428
1429 return (object) [
1430 'text' => $row->title ?? '',
1431 'description' => $row->description ?? '',
1432 'image_id' => $imageId ? (int) $imageId : 0,
1433 'icon' => $icon ?? '',
1434 'is_featured' => isset($row->is_featured) ? (bool) $row->is_featured : false,
1435 'order' => isset($row->sort_order) ? (int) $row->sort_order : 0,
1436 ];
1437 }, $rows);
1438 }
1439
1440 /**
1441 * Get landmarks for a trip
1442 */
1443 public function getLandmarks(int $tripId): array
1444 {
1445 global $wpdb;
1446
1447 // Use TripContentTable for landmarks
1448 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
1449
1450 $rows = $wpdb->get_results(
1451 $wpdb->prepare(
1452 "SELECT * FROM {$tripContentTable}
1453 WHERE trip_id = %d AND content_type = 'landmark'
1454 ORDER BY sort_order ASC, id ASC",
1455 $tripId
1456 )
1457 ) ?: [];
1458
1459 // Convert to simple array of landmark texts (like SingleTripController)
1460 $landmark_texts = [];
1461 foreach ($rows as $landmark) {
1462 if (!empty($landmark->title)) {
1463 $landmark_texts[] = $landmark->title;
1464 } elseif (!empty($landmark->description)) {
1465 $landmark_texts[] = $landmark->description;
1466 }
1467 }
1468
1469 return $landmark_texts;
1470 }
1471
1472 /**
1473 * Get FAQs for a trip
1474 */
1475 public function getFaqs(int $tripId): array
1476 {
1477 global $wpdb;
1478
1479 // Use TripContentTable for FAQs
1480 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
1481
1482 $rows = $wpdb->get_results(
1483 $wpdb->prepare(
1484 "SELECT * FROM {$tripContentTable}
1485 WHERE trip_id = %d AND content_type = 'faq'
1486 ORDER BY sort_order ASC, id ASC",
1487 $tripId
1488 )
1489 ) ?: [];
1490
1491 // Normalize to UI shape
1492 return array_map(function ($row) {
1493 $metadata = [];
1494 if (!empty($row->metadata)) {
1495 $decoded = json_decode($row->metadata, true);
1496 if (is_array($decoded)) {
1497 $metadata = $decoded;
1498 }
1499 }
1500 return (object) [
1501 'question' => $row->title ?? '',
1502 'answer' => $row->description ?? '',
1503 'category' => $metadata['category'] ?? '',
1504 'is_featured' => isset($row->is_featured) ? (bool) $row->is_featured : false,
1505 'order' => isset($row->sort_order) ? (int) $row->sort_order : 0,
1506 ];
1507 }, $rows);
1508 }
1509
1510 /**
1511 * Get availability dates for a trip
1512 */
1513 public function getAvailabilityDates(int $tripId): array
1514 {
1515 global $wpdb;
1516 $table = TripAvailabilityDatesTable::getTableName();
1517
1518 return $wpdb->get_results(
1519 $wpdb->prepare(
1520 "SELECT * FROM `{$table}`
1521 WHERE trip_id = %d
1522 ORDER BY departure_date ASC",
1523 $tripId
1524 )
1525 ) ?: [];
1526 }
1527
1528 /**
1529 * Get itinerary days with entries for a trip
1530 */
1531 public function getItineraryDays(int $tripId): array
1532 {
1533 global $wpdb;
1534
1535 // Use new table names for itinerary
1536 $tableDays = \Yatra\Database\Tables\TripItineraryDaysTable::getTableName();
1537 $tableEntries = \Yatra\Database\Tables\TripItineraryDayEntryTable::getTableName();
1538
1539 // Check if tables exist, return empty array if they don't
1540 $table_exists = $wpdb->get_var($wpdb->prepare(
1541 "SELECT COUNT(*) FROM information_schema.tables
1542 WHERE table_schema = %s AND table_name = %s",
1543 DB_NAME,
1544 $tableDays
1545 ));
1546
1547 if (!$table_exists) {
1548 // Tables don't exist yet, return empty array
1549 return [];
1550 }
1551
1552 // Get all days for this trip
1553 $days = $wpdb->get_results(
1554 $wpdb->prepare(
1555 "SELECT * FROM `{$tableDays}`
1556 WHERE trip_id = %d
1557 ORDER BY `order` ASC, day_number ASC",
1558 $tripId
1559 )
1560 ) ?: [];
1561
1562 // For each day, load its entries
1563 foreach ($days as $day) {
1564 $dayId = (int) $day->id;
1565
1566 // Get entries for this day
1567 $entries = $wpdb->get_results(
1568 $wpdb->prepare(
1569 "SELECT * FROM `{$tableEntries}`
1570 WHERE day_id = %d
1571 ORDER BY `order` ASC",
1572 $dayId
1573 )
1574 ) ?: [];
1575
1576 // Process each entry
1577 foreach ($entries as $entry) {
1578 // Decode included/excluded items JSON stored directly on the entry
1579 $entry->included_items = $this->decodeAmenityItems($entry->included_items ?? null);
1580 $entry->excluded_items = $this->decodeAmenityItems($entry->excluded_items ?? null);
1581
1582 // Images are stored in metadata in the new structure
1583 $entry->images = [];
1584 }
1585
1586 // Attach entries to day
1587 $day->entries = $entries;
1588 }
1589
1590 return $days;
1591 }
1592
1593 /**
1594 * Save destinations for a trip
1595 */
1596 public function saveDestinations(int $tripId, array $destinations): void
1597 {
1598 global $wpdb;
1599
1600 $table = TripClassificationsTable::getTableName();
1601 $classificationsTable = ClassificationsTable::getTableName();
1602
1603 // Delete existing destination relations
1604 $wpdb->delete(
1605 $table,
1606 [
1607 'trip_id' => $tripId,
1608 'classification_type' => ClassificationTypes::DESTINATION,
1609 ],
1610 ['%d', '%s']
1611 );
1612
1613 // Extract destination IDs from destination objects
1614 $destinationIds = [];
1615 foreach ($destinations as $destination) {
1616 if (is_array($destination) && isset($destination['id'])) {
1617 $destinationIds[] = (int) $destination['id'];
1618 } elseif (is_object($destination) && isset($destination->id)) {
1619 $destinationIds[] = (int) $destination->id;
1620 } elseif (is_numeric($destination)) {
1621 $destinationIds[] = (int) $destination;
1622 }
1623 }
1624
1625 // Validate that destinations exist before saving (same as activities)
1626 $validDestinationIds = [];
1627 if (!empty($destinationIds)) {
1628 $placeholders = implode(',', array_fill(0, count($destinationIds), '%d'));
1629 $existingDestinations = $wpdb->get_col(
1630 $wpdb->prepare(
1631 "SELECT id FROM {$classificationsTable}
1632 WHERE id IN ({$placeholders}) AND type = %s",
1633 array_merge($destinationIds, [ClassificationTypes::DESTINATION])
1634 )
1635 );
1636 $validDestinationIds = array_map('intval', $existingDestinations);
1637 }
1638
1639 // Also clean up any existing invalid destination relationships for this trip
1640 $deletedRows = $wpdb->query(
1641 $wpdb->prepare(
1642 "DELETE FROM {$table}
1643 WHERE trip_id = %d AND classification_type = %s
1644 AND classification_id NOT IN (
1645 SELECT id FROM {$classificationsTable} WHERE type = %s
1646 )",
1647 $tripId, ClassificationTypes::DESTINATION, ClassificationTypes::DESTINATION
1648 )
1649 );
1650
1651
1652 // Insert new destination relations
1653 if (!empty($validDestinationIds)) {
1654 foreach ($validDestinationIds as $index => $destinationId) {
1655 $wpdb->insert(
1656 $table,
1657 [
1658 'trip_id' => $tripId,
1659 'classification_id' => $destinationId,
1660 'classification_type' => ClassificationTypes::DESTINATION,
1661 'relationship_type' => $index === 0 ? 'primary' : 'secondary',
1662 'sort_order' => $index,
1663 'is_featured' => $index === 0 ? 1 : 0,
1664 ],
1665 ['%d', '%d', '%s', '%s', '%d', '%d']
1666 );
1667 }
1668 }
1669 }
1670
1671 /**
1672 * Save activities for a trip
1673 */
1674 public function saveActivities(int $tripId, array $activityIds): void
1675 {
1676 global $wpdb;
1677
1678 $table = TripClassificationsTable::getTableName();
1679 $classificationsTable = ClassificationsTable::getTableName();
1680
1681 // Validate that activities exist before saving
1682 $validActivityIds = [];
1683 if (!empty($activityIds)) {
1684 $placeholders = implode(',', array_fill(0, count($activityIds), '%d'));
1685 $existingActivities = $wpdb->get_col(
1686 $wpdb->prepare(
1687 "SELECT id FROM {$classificationsTable}
1688 WHERE id IN ({$placeholders}) AND type = %s",
1689 array_merge($activityIds, [ClassificationTypes::ACTIVITY])
1690 )
1691 );
1692 $validActivityIds = array_map('intval', $existingActivities);
1693 }
1694
1695 // Delete existing activity relations
1696 $wpdb->delete(
1697 $table,
1698 [
1699 'trip_id' => $tripId,
1700 'classification_type' => ClassificationTypes::ACTIVITY,
1701 ],
1702 ['%d', '%s']
1703 );
1704
1705 // Insert new activity relations (only for valid activities)
1706 if (!empty($validActivityIds)) {
1707 foreach ($validActivityIds as $index => $activityId) {
1708 $wpdb->insert(
1709 $table,
1710 [
1711 'trip_id' => $tripId,
1712 'classification_id' => (int) $activityId,
1713 'classification_type' => ClassificationTypes::ACTIVITY,
1714 'relationship_type' => $index === 0 ? 'primary' : 'secondary',
1715 'sort_order' => $index,
1716 'is_featured' => $index === 0 ? 1 : 0,
1717 ],
1718 ['%d', '%d', '%s', '%s', '%d', '%d']
1719 );
1720 }
1721 }
1722 }
1723
1724 /**
1725 * Save trip categories for a trip
1726 */
1727 public function saveTripCategories(int $tripId, array $categoryIds): void
1728 {
1729 global $wpdb;
1730
1731 $table = TripClassificationsTable::getTableName();
1732
1733 // Delete existing category relations
1734 $wpdb->delete(
1735 $table,
1736 [
1737 'trip_id' => $tripId,
1738 'classification_type' => ClassificationTypes::CATEGORY,
1739 ],
1740 ['%d', '%s']
1741 );
1742
1743 // Insert new categories
1744 if (!empty($categoryIds)) {
1745 foreach ($categoryIds as $index => $categoryId) {
1746 $wpdb->insert(
1747 $table,
1748 [
1749 'trip_id' => $tripId,
1750 'classification_id' => (int) $categoryId,
1751 'classification_type' => ClassificationTypes::CATEGORY,
1752 'relationship_type' => $index === 0 ? 'primary' : 'secondary',
1753 'sort_order' => $index,
1754 'is_featured' => $index === 0 ? 1 : 0,
1755 ],
1756 ['%d', '%d', '%s', '%s', '%d', '%d']
1757 );
1758 }
1759 }
1760 }
1761
1762 /**
1763 * Save price types for a trip
1764 */
1765 public function savePriceTypes(int $tripId, array $priceTypes): void
1766 {
1767 if (empty($priceTypes)) {
1768 return;
1769 }
1770
1771 // Ensure at most one default category is set (keep the first truthy one).
1772 $defaultFound = false;
1773 foreach ($priceTypes as &$pt) {
1774 if (!is_array($pt)) {
1775 continue;
1776 }
1777 $isDefault = !empty($pt['is_default']);
1778 if ($isDefault && !$defaultFound) {
1779 $defaultFound = true;
1780 $pt['is_default'] = true;
1781 } else {
1782 $pt['is_default'] = false;
1783 }
1784 }
1785 unset($pt);
1786
1787 // Compute minimal pricing values from provided price types
1788 $minOriginal = PHP_FLOAT_MAX;
1789 $minDiscounted = PHP_FLOAT_MAX;
1790 $minSale = PHP_FLOAT_MAX;
1791
1792 foreach ($priceTypes as $priceType) {
1793 $original = isset($priceType['original_price']) ? (float) $priceType['original_price'] : null;
1794 $discounted = isset($priceType['discounted_price']) ? (float) $priceType['discounted_price'] : null;
1795 $sale = isset($priceType['sale_price']) ? (float) $priceType['sale_price'] : null;
1796
1797 if ($original !== null && $original > 0 && $original < $minOriginal) {
1798 $minOriginal = $original;
1799 }
1800 if ($discounted !== null && $discounted > 0 && $discounted < $minDiscounted) {
1801 $minDiscounted = $discounted;
1802 }
1803 if ($sale !== null && $sale > 0 && $sale < $minSale) {
1804 $minSale = $sale;
1805 }
1806 }
1807
1808 // Normalize infinity values to null
1809 $minOriginal = ($minOriginal === PHP_FLOAT_MAX) ? null : $minOriginal;
1810 $minDiscounted = ($minDiscounted === PHP_FLOAT_MAX) ? null : $minDiscounted;
1811 $minSale = ($minSale === PHP_FLOAT_MAX) ? null : $minSale;
1812
1813 // Determine final prices to store on trips table
1814 $finalOriginal = $minOriginal;
1815 $finalDiscounted = $minDiscounted ?? null;
1816 $finalSale = $minSale ?? null;
1817
1818 // If no discounted/sale but original exists, keep it; else leave unchanged
1819 $data = [];
1820 $format = [];
1821
1822 // Persist full price_types JSON for reference (stored on trips table)
1823 $data['price_types'] = wp_json_encode($priceTypes);
1824 $format[] = '%s';
1825
1826 if ($finalOriginal !== null) {
1827 $data['original_price'] = $finalOriginal;
1828 $format[] = '%f';
1829 }
1830 if ($finalDiscounted !== null) {
1831 $data['discounted_price'] = $finalDiscounted;
1832 $format[] = '%f';
1833 }
1834 if ($finalSale !== null) {
1835 $data['sale_price'] = $finalSale;
1836 $format[] = '%f';
1837 }
1838
1839 if (!empty($data)) {
1840 $this->wpdb->update(
1841 TripsTable::getTableName(),
1842 $data,
1843 ['id' => $tripId],
1844 $format,
1845 ['%d']
1846 );
1847 }
1848 }
1849
1850 /**
1851 * Save highlights for a trip
1852 */
1853 public function saveHighlights(int $tripId, array $highlights): void
1854 {
1855 global $wpdb;
1856
1857 $table = TripContentTable::getTableName();
1858
1859 // Delete existing highlights
1860 $wpdb->delete(
1861 $table,
1862 [
1863 'trip_id' => $tripId,
1864 'content_type' => 'highlight',
1865 ],
1866 ['%d', '%s']
1867 );
1868
1869 if (!empty($highlights)) {
1870 foreach ($highlights as $index => $highlight) {
1871 $highlightText = is_string($highlight) ? $highlight : ($highlight['text'] ?? $highlight['highlight_text'] ?? '');
1872 if (empty($highlightText)) {
1873 continue;
1874 }
1875
1876 $metadata = [];
1877 if (is_array($highlight)) {
1878 if (!empty($highlight['icon'])) {
1879 $metadata['icon'] = $highlight['icon'];
1880 }
1881 if (!empty($highlight['image_id'])) {
1882 $metadata['image_id'] = (int) $highlight['image_id'];
1883 }
1884 }
1885
1886 $wpdb->insert(
1887 $table,
1888 [
1889 'trip_id' => $tripId,
1890 'content_type' => 'highlight',
1891 'title' => sanitize_text_field($highlightText),
1892 'description' => is_array($highlight) && !empty($highlight['description']) ? wp_kses_post($highlight['description']) : null,
1893 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1894 'sort_order' => $index,
1895 'is_featured' => is_array($highlight) && isset($highlight['is_featured']) ? (int) $highlight['is_featured'] : 0,
1896 ],
1897 ['%d', '%s', '%s', '%s', '%s', '%d', '%d']
1898 );
1899 }
1900 }
1901 }
1902
1903 /**
1904 * Save landmarks for a trip
1905 */
1906 public function saveLandmarks(int $tripId, array $landmarks): void
1907 {
1908 global $wpdb;
1909
1910 $table = TripContentTable::getTableName();
1911
1912 // Delete existing landmarks
1913 $wpdb->delete(
1914 $table,
1915 [
1916 'trip_id' => $tripId,
1917 'content_type' => 'landmark',
1918 ],
1919 ['%d', '%s']
1920 );
1921
1922 if (!empty($landmarks)) {
1923 foreach ($landmarks as $index => $landmark) {
1924 $landmarkText = is_string($landmark) ? $landmark : ($landmark['text'] ?? $landmark['landmark_text'] ?? '');
1925 if (empty($landmarkText)) {
1926 continue;
1927 }
1928
1929 $metadata = [];
1930 if (is_array($landmark)) {
1931 if (!empty($landmark['icon'])) {
1932 $metadata['icon'] = $landmark['icon'];
1933 }
1934 if (!empty($landmark['image_id'])) {
1935 $metadata['image_id'] = (int) $landmark['image_id'];
1936 }
1937 }
1938
1939 $wpdb->insert(
1940 $table,
1941 [
1942 'trip_id' => $tripId,
1943 'content_type' => 'landmark',
1944 'title' => sanitize_text_field($landmarkText),
1945 'description' => is_array($landmark) && !empty($landmark['description']) ? wp_kses_post($landmark['description']) : null,
1946 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
1947 'sort_order' => $index,
1948 'is_featured' => is_array($landmark) && isset($landmark['is_featured']) ? (int) $landmark['is_featured'] : 0,
1949 ],
1950 ['%d', '%s', '%s', '%s', '%s', '%d', '%d']
1951 );
1952 }
1953 }
1954 }
1955
1956 /**
1957 * Save gallery images for a trip
1958 */
1959 public function saveGalleryImages(int $tripId, array $galleryImages): void
1960 {
1961 global $wpdb;
1962
1963 $table = TripContentTable::getTableName();
1964
1965 // Delete existing gallery images
1966 $wpdb->delete(
1967 $table,
1968 [
1969 'trip_id' => $tripId,
1970 'content_type' => 'image',
1971 ],
1972 ['%d', '%s']
1973 );
1974
1975 // Insert new
1976 if (!empty($galleryImages)) {
1977 foreach ($galleryImages as $index => $image) {
1978 $imageUrl = is_string($image) ? $image : ($image['url'] ?? $image['image_url'] ?? '');
1979 if (empty($imageUrl)) {
1980 continue;
1981 }
1982
1983 $metadata = [];
1984 if (is_array($image)) {
1985 if (!empty($image['alt_text'])) {
1986 $metadata['alt_text'] = $image['alt_text'];
1987 }
1988 if (!empty($image['caption'])) {
1989 $metadata['caption'] = $image['caption'];
1990 }
1991 }
1992
1993 $wpdb->insert(
1994 $table,
1995 [
1996 'trip_id' => $tripId,
1997 'content_type' => 'image',
1998 'content_url' => esc_url_raw($imageUrl),
1999 'file_path' => is_array($image) ? ($image['file_path'] ?? null) : null,
2000 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
2001 'thumbnail_url' => is_array($image) ? ($image['thumbnail_url'] ?? null) : null,
2002 'sort_order' => $index,
2003 'is_featured' => is_array($image) && isset($image['is_featured']) ? (int) $image['is_featured'] : 0,
2004 ],
2005 ['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d']
2006 );
2007 }
2008 }
2009 }
2010
2011 /**
2012 * Save FAQs for a trip
2013 */
2014 public function saveFaqs(int $tripId, array $faqs): void
2015 {
2016 global $wpdb;
2017
2018 $table = TripContentTable::getTableName();
2019
2020 // Delete existing FAQs
2021 $wpdb->delete(
2022 $table,
2023 [
2024 'trip_id' => $tripId,
2025 'content_type' => 'faq',
2026 ],
2027 ['%d', '%s']
2028 );
2029
2030 // Insert new FAQs
2031 if (!empty($faqs)) {
2032 foreach ($faqs as $index => $faq) {
2033 if (!is_array($faq) || empty($faq['question']) || empty($faq['answer'])) {
2034 continue;
2035 }
2036
2037 $metadata = [];
2038 if (!empty($faq['category'])) {
2039 $metadata['category'] = sanitize_text_field($faq['category']);
2040 }
2041
2042 $wpdb->insert(
2043 $table,
2044 [
2045 'trip_id' => $tripId,
2046 'content_type' => 'faq',
2047 'title' => sanitize_text_field($faq['question']),
2048 'description' => wp_kses_post($faq['answer']),
2049 'metadata' => !empty($metadata) ? wp_json_encode($metadata) : null,
2050 'sort_order' => $index,
2051 'is_featured' => isset($faq['is_featured']) ? (int) $faq['is_featured'] : 0,
2052 ],
2053 ['%d', '%s', '%s', '%s', '%s', '%d', '%d']
2054 );
2055 }
2056 }
2057 }
2058
2059 /**
2060 * Save entries for a specific day using upsert strategy
2061 */
2062 private function saveDayEntries(int $dayId, array $entries, array $existingEntries): void
2063 {
2064 global $wpdb;
2065 $tableEntries = TripItineraryDayEntryTable::getTableName();
2066
2067 // Create lookup map for existing entries
2068 $existingEntryMap = [];
2069 foreach ($existingEntries as $entry) {
2070 $key = $entry->title . '|' . ($entry->order ?? 0);
2071 $existingEntryMap[$key] = $entry;
2072 }
2073
2074 $processedEntryIds = [];
2075 foreach ($entries as $entryIndex => $entry) {
2076 if (!is_array($entry) || empty($entry['title'])) continue;
2077
2078 $entryKey = $entry['title'] . '|' . $entryIndex;
2079 $entryData = [
2080 'title' => sanitize_text_field($entry['title']),
2081 'description' => isset($entry['description']) ? wp_kses_post($entry['description']) : null,
2082 'item_type_id' => isset($entry['item_type_id']) ? (int) $entry['item_type_id'] : null,
2083 'item_id' => isset($entry['item_id']) ? (int) $entry['item_id'] : null,
2084 'item_type' => isset($entry['item_type']) ? sanitize_text_field($entry['item_type']) : null,
2085 'item_name' => isset($entry['item_name']) ? sanitize_text_field($entry['item_name']) : null,
2086 'item_icon' => isset($entry['item_icon']) ? sanitize_text_field($entry['item_icon']) : null,
2087 'time' => isset($entry['time']) ? sanitize_text_field($entry['time']) : null,
2088 'start_time' => isset($entry['start_time']) ? sanitize_text_field($entry['start_time']) : null,
2089 'end_time' => isset($entry['end_time']) ? sanitize_text_field($entry['end_time']) : null,
2090 'time_type' => isset($entry['time_type']) ? sanitize_text_field($entry['time_type']) : 'exact',
2091 'location' => isset($entry['location']) ? sanitize_text_field($entry['location']) : null,
2092 'duration' => isset($entry['duration']) ? sanitize_text_field($entry['duration']) : null,
2093 'cost' => isset($entry['cost']) ? floatval($entry['cost']) : null,
2094 'cost_per_person' => isset($entry['cost_per_person']) ? (int) $entry['cost_per_person'] : 0,
2095 'notes' => isset($entry['notes']) ? wp_kses_post($entry['notes']) : null,
2096 'included_items' => isset($entry['included_items']) ? wp_json_encode($entry['included_items']) : null,
2097 'excluded_items' => isset($entry['excluded_items']) ? wp_json_encode($entry['excluded_items']) : null,
2098 'gallery' => isset($entry['gallery']) ? wp_json_encode($entry['gallery']) : null,
2099 'video_url' => isset($entry['video_url']) ? esc_url_raw($entry['video_url']) : null,
2100 'status' => isset($entry['status']) ? sanitize_text_field($entry['status']) : 'publish',
2101 'order' => $entryIndex,
2102 'updated_at' => current_time('mysql'),
2103 ];
2104
2105 // Update existing entry or insert new
2106 if (isset($existingEntryMap[$entryKey])) {
2107 $existingEntry = $existingEntryMap[$entryKey];
2108 $wpdb->update($tableEntries, $entryData, ['id' => $existingEntry->id]);
2109 $processedEntryIds[] = $existingEntry->id;
2110 } else {
2111 $entryData['day_id'] = $dayId;
2112 $entryData['trip_id'] = $this->getTripIdByDayId($dayId);
2113 $entryData['created_at'] = current_time('mysql');
2114 $wpdb->insert($tableEntries, $entryData);
2115 $processedEntryIds[] = $wpdb->insert_id;
2116 }
2117 }
2118
2119 // Delete entries that are no longer present
2120 if (!empty($processedEntryIds)) {
2121 $placeholders = implode(',', array_fill(0, count($processedEntryIds), '%d'));
2122 $wpdb->query($wpdb->prepare(
2123 "DELETE FROM {$tableEntries} WHERE day_id = %d AND id NOT IN ({$placeholders})",
2124 $dayId,
2125 ...$processedEntryIds
2126 ));
2127 } else {
2128 // If no entries provided, delete all entries for this day
2129 $wpdb->delete($tableEntries, ['day_id' => $dayId], ['%d']);
2130 }
2131 }
2132
2133 /**
2134 * Get trip ID by day ID
2135 */
2136 private function getTripIdByDayId(int $dayId): int
2137 {
2138 global $wpdb;
2139 $tableDays = TripItineraryDaysTable::getTableName();
2140 return (int) $wpdb->get_var($wpdb->prepare(
2141 "SELECT trip_id FROM {$tableDays} WHERE id = %d",
2142 $dayId
2143 ));
2144 }
2145
2146 /**
2147 * Save availability dates for a trip.
2148 *
2149 * Previously a flat DELETE-THEN-INSERT: every row for the trip was nuked
2150 * and the incoming list rewritten. That made the trip-edit save lossy —
2151 * when the Trip form only knew about a subset of fields on each date
2152 * (e.g. it was loaded once, then the operator changed the seats on a
2153 * single date through the Availability tab, then re-saved the Trip with
2154 * the form's stale date payload), every field absent from the incoming
2155 * row was reset to its hard-coded default. That's why operator edits
2156 * to `seats_total` silently reverted to 20 after another trip-save:
2157 *
2158 * 1. Operator opens Trip → form loads `availability_dates` with
2159 * `seats_total = 20`.
2160 * 2. Operator switches to Availability tab → edits Sept 5 to
2161 * `seats_total = 14` via the dedicated specific-date endpoint
2162 * (writes directly to the row, so the row is now 14).
2163 * 3. Operator returns to the Trip form, edits something unrelated
2164 * (title / description), clicks Save. The form re-posts the date
2165 * list it loaded in step 1 — `seats_total = 20`.
2166 * 4. saveAvailabilityDates DELETEs everything, INSERTs the
2167 * form-supplied list → Sept 5 is back to 20 (or the literal `20`
2168 * default if the form didn't include seats_total at all).
2169 *
2170 * Fix: snapshot the existing rows before the DELETE and, when the
2171 * incoming row omits an editable field, fall back to the snapshot's
2172 * value instead of the schema default. The literal `20` default is
2173 * removed in favour of the trip's `max_travelers` (or 1 as a last
2174 * resort) so new dates inserted by truly-new entries don't pretend the
2175 * trip seats 20 people unless the trip actually says so.
2176 *
2177 * Match key for the snapshot: `departure_date` + `departure_time` (both
2178 * normalised), which is the same identity the Availability UI uses.
2179 */
2180 public function saveAvailabilityDates(int $tripId, array $availabilityDates): void
2181 {
2182 global $wpdb;
2183 $table = TripAvailabilityDatesTable::getTableName();
2184
2185 // Snapshot existing rows by date + time so partial incoming payloads
2186 // can be merged with persisted values instead of clobbering them.
2187 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from schema helper.
2188 $existingRows = $wpdb->get_results($wpdb->prepare(
2189 "SELECT * FROM {$table} WHERE trip_id = %d",
2190 $tripId
2191 ));
2192 $snapshot = [];
2193 if (is_array($existingRows)) {
2194 foreach ($existingRows as $row) {
2195 $key = self::availabilityRowKey(
2196 (string) ($row->departure_date ?? ''),
2197 (string) ($row->departure_time ?? '')
2198 );
2199 if ($key !== '') {
2200 $snapshot[$key] = $row;
2201 }
2202 }
2203 }
2204
2205 // Fallback capacity when both the incoming row and the snapshot
2206 // miss seats_total: read the trip's max_travelers once. The
2207 // legacy literal `20` was a phantom default that punished sites
2208 // whose actual trip capacity differs.
2209 $tripFallbackSeats = 0;
2210 $tripRow = $this->find($tripId);
2211 if (is_object($tripRow) && isset($tripRow->max_travelers)) {
2212 $tripFallbackSeats = (int) $tripRow->max_travelers;
2213 }
2214 if ($tripFallbackSeats <= 0) {
2215 $tripFallbackSeats = 1;
2216 }
2217
2218 // Delete existing
2219 $wpdb->delete($table, ['trip_id' => $tripId], ['%d']);
2220
2221 // Insert new
2222 if (!empty($availabilityDates)) {
2223 foreach ($availabilityDates as $date) {
2224 if (is_array($date) && !empty($date['departure_date'])) {
2225 $key = self::availabilityRowKey(
2226 (string) $date['departure_date'],
2227 (string) ($date['departure_time'] ?? '')
2228 );
2229 $existing = $key !== '' && isset($snapshot[$key]) ? $snapshot[$key] : null;
2230
2231 $seatsTotal = isset($date['seats_total'])
2232 ? (int) $date['seats_total']
2233 : ($existing ? (int) ($existing->seats_total ?? $tripFallbackSeats) : $tripFallbackSeats);
2234 $seatsAvailable = isset($date['seats_available'])
2235 ? (int) $date['seats_available']
2236 : ($existing ? (int) ($existing->seats_available ?? $seatsTotal) : $seatsTotal);
2237
2238 $arrivalDate = isset($date['arrival_date'])
2239 ? sanitize_text_field($date['arrival_date'])
2240 : (isset($date['return_date'])
2241 ? sanitize_text_field($date['return_date'])
2242 : ($existing ? $existing->arrival_date : null));
2243 $returnDate = isset($date['return_date'])
2244 ? sanitize_text_field($date['return_date'])
2245 : ($existing ? $existing->return_date : null);
2246 $arrivalTime = isset($date['arrival_time'])
2247 ? sanitize_text_field($date['arrival_time'])
2248 : ($existing ? $existing->arrival_time : null);
2249
2250 $originalPrice = isset($date['original_price'])
2251 ? (float) $date['original_price']
2252 : (isset($date['price_override'])
2253 ? (float) $date['price_override']
2254 : ($existing && $existing->original_price !== null ? (float) $existing->original_price : null));
2255 $discountedPrice = isset($date['discounted_price'])
2256 ? (float) $date['discounted_price']
2257 : ($existing && $existing->discounted_price !== null ? (float) $existing->discounted_price : null);
2258
2259 $fromLocation = isset($date['from_location']) ? sanitize_text_field($date['from_location']) : ($existing ? $existing->from_location : null);
2260 $toLocation = isset($date['to_location']) ? sanitize_text_field($date['to_location']) : ($existing ? $existing->to_location : null);
2261 $fromLat = isset($date['from_latitude']) && is_numeric($date['from_latitude'])
2262 ? (string) $date['from_latitude']
2263 : ($existing ? $existing->from_latitude : null);
2264 $fromLng = isset($date['from_longitude']) && is_numeric($date['from_longitude'])
2265 ? (string) $date['from_longitude']
2266 : ($existing ? $existing->from_longitude : null);
2267 $toLat = isset($date['to_latitude']) && is_numeric($date['to_latitude'])
2268 ? (string) $date['to_latitude']
2269 : ($existing ? $existing->to_latitude : null);
2270 $toLng = isset($date['to_longitude']) && is_numeric($date['to_longitude'])
2271 ? (string) $date['to_longitude']
2272 : ($existing ? $existing->to_longitude : null);
2273
2274 if (isset($date['is_blackout'])) {
2275 $status = $date['is_blackout'] ? 'blocked' : 'available';
2276 } elseif (isset($date['status'])) {
2277 $status = sanitize_text_field($date['status']);
2278 } elseif ($existing) {
2279 $status = (string) ($existing->status ?? 'available');
2280 } else {
2281 $status = 'available';
2282 }
2283
2284 $insertData = [
2285 'trip_id' => $tripId,
2286 'departure_date' => sanitize_text_field($date['departure_date']),
2287 'arrival_date' => $arrivalDate,
2288 'return_date' => $returnDate,
2289 'departure_time' => isset($date['departure_time']) ? sanitize_text_field($date['departure_time']) : ($existing ? $existing->departure_time : null),
2290 'arrival_time' => $arrivalTime,
2291 'seats_total' => $seatsTotal,
2292 'seats_available' => $seatsAvailable,
2293 'original_price' => $originalPrice,
2294 'discounted_price' => $discountedPrice,
2295 'from_location' => $fromLocation,
2296 'to_location' => $toLocation,
2297 'from_latitude' => $fromLat,
2298 'from_longitude' => $fromLng,
2299 'to_latitude' => $toLat,
2300 'to_longitude' => $toLng,
2301 'status' => $status,
2302 ];
2303
2304 $wpdb->insert(
2305 $table,
2306 $insertData,
2307 ['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s']
2308 );
2309 }
2310 }
2311 }
2312 }
2313
2314 /**
2315 * Normalise a (date, time) pair to a single string key used to match
2316 * incoming availability rows against the pre-delete snapshot. Time is
2317 * left as-is for an exact comparison; empty time matches the "no
2318 * specific time" row.
2319 */
2320 private static function availabilityRowKey(string $date, string $time): string
2321 {
2322 $date = trim($date);
2323 if ($date === '') {
2324 return '';
2325 }
2326 return $date . '|' . trim($time);
2327 }
2328
2329 /**
2330 * Save attributes for a trip
2331 */
2332 public function saveAttributes(int $tripId, array $attributes): void
2333 {
2334 $tripAttributeRepository = new \Yatra\Repositories\TripAttributeRepository();
2335 $tripAttributeRepository->saveTripAttributes($tripId, $attributes);
2336 }
2337
2338 /**
2339 * Create trip with relationships
2340 */
2341 public function createWithRelations(array $data, array $relationships = []): int
2342 {
2343 // Extract relationship data
2344 $destinations = $relationships['destinations'] ?? [];
2345 $activities = $relationships['activities'] ?? [];
2346 $tripCategories = $relationships['trip_category'] ?? [];
2347 $priceTypes = $relationships['price_types'] ?? [];
2348 $highlights = $relationships['highlights'] ?? [];
2349 $landmarks = $relationships['landmarks'] ?? [];
2350 $galleryImages = $relationships['gallery_images'] ?? [];
2351 $faqs = $relationships['faqs'] ?? [];
2352 $downloadableItems = $relationships['downloadable_items'] ?? [];
2353 $itineraryDays = $relationships['itinerary_days'] ?? [];
2354 $availabilityDates = $relationships['availability_dates'] ?? [];
2355 $attributes = $relationships['attributes'] ?? [];
2356
2357 // Remove relationship data from main data (these should not be in the main table)
2358 unset(
2359 $data['destinations'],
2360 $data['activities'],
2361 $data['trip_category'],
2362 $data['highlights'],
2363 $data['landmarks'],
2364 $data['gallery_images'],
2365 $data['faqs'],
2366 $data['downloadable_items'],
2367 $data['itinerary_days'],
2368 $data['availability_dates'],
2369 $data['attributes']
2370 );
2371
2372 // Create main trip record
2373 $tripId = $this->create($data);
2374
2375 // Update relationships if provided
2376 if (!empty($destinations)) {
2377 $this->saveDestinations($tripId, $destinations);
2378 }
2379
2380 if (!empty($activities)) {
2381 $this->saveActivities($tripId, $activities);
2382 }
2383
2384 if (!empty($tripCategories)) {
2385 $this->saveTripCategories($tripId, $tripCategories);
2386 }
2387
2388 if (!empty($priceTypes)) {
2389 $this->savePriceTypes($tripId, $priceTypes);
2390 }
2391
2392 if (!empty($highlights)) {
2393 $this->saveHighlights($tripId, $highlights);
2394 }
2395
2396 if (!empty($landmarks)) {
2397 $this->saveLandmarks($tripId, $landmarks);
2398 }
2399
2400 if (!empty($galleryImages)) {
2401 $this->saveGalleryImages($tripId, $galleryImages);
2402 }
2403
2404 if (!empty($faqs)) {
2405 $this->saveFaqs($tripId, $faqs);
2406 }
2407
2408 // Always replace downloads (clear if empty array)
2409 $downloadRepo = new TripDownloadRepository();
2410 $downloadRepo->replaceForTrip($tripId, is_array($downloadableItems) ? $downloadableItems : []);
2411
2412 // ITINERARY SHOULD NEVER BE PROCESSED DURING TRIP CREATION
2413 // Itinerary should be created separately through dedicated itinerary endpoints
2414 // This ensures complete separation of concerns and prevents data loss
2415
2416 if (!empty($availabilityDates)) {
2417 $this->saveAvailabilityDates($tripId, $availabilityDates);
2418 }
2419
2420 if (!empty($attributes)) {
2421 $this->saveAttributes($tripId, $attributes);
2422 }
2423
2424 // Full bust after junction/related tables are written (create() already invalidated listings/stats).
2425 Cache::invalidateAfterTripWrite('update', $tripId);
2426
2427 do_action('yatra_trip_created_with_relations', $tripId, $relationships, $data);
2428
2429 return $tripId;
2430 }
2431
2432 /**
2433 * Update trip with relationships
2434 */
2435 public function updateWithRelations(int $id, array $data, array $relationships = []): bool
2436 {
2437 // Extract relationship data (excluding itinerary - handled separately)
2438 $destinations = $relationships['destinations'] ?? null;
2439 $activities = $relationships['activities'] ?? null;
2440 $tripCategories = $relationships['trip_category'] ?? null;
2441 $priceTypes = $relationships['price_types'] ?? null;
2442 $highlights = $relationships['highlights'] ?? null;
2443 $landmarks = $relationships['landmarks'] ?? null;
2444 $galleryImages = $relationships['gallery_images'] ?? null;
2445 $faqs = $relationships['faqs'] ?? null;
2446 $downloadableItems = $relationships['downloadable_items'] ?? null;
2447 // ITINERARY IS HANDLED SEPARATELY - NEVER PROCESSED HERE
2448 $availabilityDates = $relationships['availability_dates'] ?? null;
2449 $attributes = $relationships['attributes'] ?? null;
2450
2451 // Remove relationship data from main data (excluding itinerary - handled separately)
2452 unset(
2453 $data['destinations'],
2454 $data['activities'],
2455 $data['trip_category'],
2456 $data['price_types'],
2457 $data['highlights'],
2458 $data['landmarks'],
2459 $data['gallery_images'],
2460 $data['faqs'],
2461 // ITINERARY IS HANDLED SEPARATELY - DO NOT UNSET
2462 $data['availability_dates'],
2463 $data['attributes']
2464 );
2465
2466 // Update main trip record
2467 $result = $this->update($id, $data);
2468
2469 // Update relationships if provided
2470 if ($destinations !== null) {
2471 $this->saveDestinations($id, $destinations);
2472 }
2473
2474 if ($activities !== null) {
2475 $this->saveActivities($id, $activities);
2476 }
2477
2478 if ($tripCategories !== null) {
2479 $this->saveTripCategories($id, $tripCategories);
2480 }
2481
2482 if ($priceTypes !== null) {
2483 $this->savePriceTypes($id, $priceTypes);
2484 }
2485
2486 if ($highlights !== null) {
2487 $this->saveHighlights($id, $highlights);
2488 }
2489
2490 if ($landmarks !== null) {
2491 $this->saveLandmarks($id, $landmarks);
2492 }
2493
2494 if ($galleryImages !== null) {
2495 $this->saveGalleryImages($id, $galleryImages);
2496 }
2497
2498 if ($faqs !== null) {
2499 $this->saveFaqs($id, $faqs);
2500 }
2501
2502 if (is_array($downloadableItems)) {
2503 $downloadRepo = new TripDownloadRepository();
2504 $downloadRepo->replaceForTrip($id, $downloadableItems);
2505 }
2506
2507 // ITINERARY SHOULD NEVER BE PROCESSED DURING TRIP UPDATES
2508 // Itinerary updates should be handled separately through dedicated endpoints
2509 // This ensures complete separation of concerns and prevents data loss
2510
2511 if ($availabilityDates !== null) {
2512 $this->saveAvailabilityDates($id, $availabilityDates);
2513 }
2514
2515 if ($attributes !== null) {
2516 $this->saveAttributes($id, $attributes);
2517 }
2518
2519 if ($result) {
2520 // Ensure caches reflect relationship writes (update() runs before saves; this runs after).
2521 Cache::invalidateAfterTripWrite('update', $id);
2522 }
2523
2524 do_action('yatra_trip_updated_with_relations', $id, $relationships, $data);
2525
2526 return $result;
2527 }
2528
2529 /**
2530 * Soft delete a trip
2531 */
2532 public function softDelete(int $id, int $userId): bool
2533 {
2534 return $this->update($id, [
2535 'deleted_at' => current_time('mysql'),
2536 'deleted_by' => $userId,
2537 ]);
2538 }
2539
2540 /**
2541 * Restore a soft-deleted trip
2542 */
2543 public function restore(int $id): bool
2544 {
2545 return $this->update($id, [
2546 'deleted_at' => null,
2547 'deleted_by' => null,
2548 ]);
2549 }
2550
2551 /**
2552 * Get active trips (not deleted, with specified statuses)
2553 *
2554 * @param array $args Query arguments
2555 * @param array $statuses Array of statuses to filter by. Defaults to ['published']
2556 * @return array
2557 */
2558 public function getActive(array $args = [], array $statuses = ['publish']): array
2559 {
2560 $args['where']['deleted_at'] = null;
2561
2562 // If status is already set in where clause, respect it
2563 if (!isset($args['where']['status'])) {
2564 $args['where']['status'] = $statuses;
2565 }
2566
2567 return $this->all($args);
2568 }
2569
2570 /**
2571 * Find trips within a price range, considering all pricing types
2572 *
2573 * @param float $min_price Minimum price
2574 * @param float $max_price Maximum price
2575 * @param array $args Additional query arguments
2576 * @return array Array of trips
2577 */
2578 public function findByPriceRange(float $min_price = 0, float $max_price = 0, array $args = []): array
2579 {
2580 global $wpdb;
2581
2582 // DEBUG: Log method entry
2583 if (defined('WP_DEBUG') && WP_DEBUG) {
2584 }
2585
2586 // Base query to get all active trips
2587 $args['where']['deleted_at'] = null;
2588 if (!isset($args['where']['status'])) {
2589 $args['where']['status'] = ['publish'];
2590 }
2591
2592 // DEBUG: Log query args
2593 if (defined('WP_DEBUG') && WP_DEBUG) {
2594 }
2595
2596 // Get all active trips first
2597 $all_trips = $this->all($args);
2598
2599 if (empty($all_trips)) {
2600 return [];
2601 }
2602
2603 // Get trip IDs
2604 $trip_ids = array_map(function($trip) {
2605 return $trip->id;
2606 }, $all_trips);
2607
2608 // Get trip prices and filter by range (price types table removed)
2609 $filtered_trips = [];
2610
2611 // DEBUG: Log price filtering process
2612 if (defined('WP_DEBUG') && WP_DEBUG) {
2613 }
2614
2615 foreach ($all_trips as $trip) {
2616 $trip_id = $trip->id;
2617 $trip_min_price = PHP_FLOAT_MAX;
2618
2619 // Check trip's own price first
2620 if (!empty($trip->sale_price) && $trip->sale_price > 0) {
2621 $trip_min_price = min($trip_min_price, (float)$trip->sale_price);
2622 }
2623 if (!empty($trip->discounted_price) && $trip->discounted_price > 0) {
2624 $trip_min_price = min($trip_min_price, (float)$trip->discounted_price);
2625 }
2626 if (!empty($trip->original_price) && $trip->original_price > 0) {
2627 $trip_min_price = min($trip_min_price, (float)$trip->original_price);
2628 }
2629
2630 // If no valid price found, skip
2631 if ($trip_min_price === PHP_FLOAT_MAX) {
2632 continue;
2633 }
2634
2635 // Apply price range filter
2636 $passes_filter = ($min_price === 0 || $trip_min_price >= $min_price) &&
2637 ($max_price === 0 || $trip_min_price <= $max_price);
2638
2639 // DEBUG: Log individual trip filtering
2640 if (defined('WP_DEBUG') && WP_DEBUG) {
2641 }
2642
2643 if ($passes_filter) {
2644 $trip->min_price = $trip_min_price;
2645 $filtered_trips[] = $trip;
2646 }
2647 }
2648
2649 return $filtered_trips;
2650 }
2651
2652 /**
2653 * Build where clause (override to handle soft deletes)
2654 */
2655 protected function buildWhereClause(array $args): string
2656 {
2657 $where = parent::buildWhereClause($args);
2658
2659 // Add soft delete filter if not explicitly requested
2660 if (!isset($args['include_deleted']) || !$args['include_deleted']) {
2661 if ($where) {
2662 $where .= ' AND (deleted_at IS NULL OR deleted_at = \'0000-00-00 00:00:00\')';
2663 } else {
2664 $where = 'WHERE (deleted_at IS NULL OR deleted_at = \'0000-00-00 00:00:00\')';
2665 }
2666 }
2667
2668 return $where;
2669 }
2670
2671 /**
2672 * Count trips by status
2673 */
2674 public function countByStatus(string $status): int
2675 {
2676 $table = esc_sql($this->table);
2677 $count = $this->wpdb->get_var(
2678 $this->wpdb->prepare(
2679 "SELECT COUNT(*) FROM `{$table}`
2680 WHERE status = %s
2681 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')",
2682 $status
2683 )
2684 );
2685
2686 return (int) $count;
2687 }
2688
2689 /**
2690 * Search trips by keyword
2691 */
2692 public function search(string $keyword, array $args = []): array
2693 {
2694 $table = esc_sql($this->table);
2695 $where = $this->buildWhereClause($args);
2696 $order = $this->buildOrderClause($args);
2697 $limit = $this->buildLimitClause($args);
2698
2699 $searchTerm = '%' . $this->wpdb->esc_like($keyword) . '%';
2700
2701 // Build search condition
2702 $searchCondition = "(title LIKE %s OR description LIKE %s OR short_description LIKE %s)";
2703
2704 // If we have a WHERE clause, add AND; otherwise start with WHERE
2705 if (!empty($where)) {
2706 $whereClause = "{$where} AND {$searchCondition}";
2707 } else {
2708 $whereClause = "WHERE {$searchCondition}";
2709 }
2710
2711 $query = $this->wpdb->prepare(
2712 "SELECT * FROM `{$table}` {$whereClause} {$order} {$limit}",
2713 $searchTerm,
2714 $searchTerm,
2715 $searchTerm
2716 );
2717
2718 return $this->wpdb->get_results($query) ?: [];
2719 }
2720
2721 /**
2722 * Decode included/excluded items JSON column stored on itinerary entries
2723 */
2724 private function decodeAmenityItems($value): array
2725 {
2726 if (empty($value)) {
2727 return [];
2728 }
2729
2730 if (is_array($value)) {
2731 return $value;
2732 }
2733
2734 if (is_string($value)) {
2735 $decoded = json_decode($value, true);
2736 return is_array($decoded) ? $decoded : [];
2737 }
2738
2739 return [];
2740 }
2741
2742 /**
2743 * Human-readable label for one included_items JSON element (title, name, label, or plain string).
2744 */
2745 private function extractIncludedItemLabel($item): string
2746 {
2747 if (is_string($item)) {
2748 $t = sanitize_text_field($item);
2749
2750 return $t;
2751 }
2752 if (is_object($item)) {
2753 $item = (array) $item;
2754 }
2755 if (!is_array($item)) {
2756 return '';
2757 }
2758 foreach (['title', 'name', 'label', 'text'] as $k) {
2759 if (!empty($item[$k]) && is_scalar($item[$k])) {
2760 $t = sanitize_text_field((string) $item[$k]);
2761
2762 return $t;
2763 }
2764 }
2765
2766 return '';
2767 }
2768
2769 /**
2770 * Get trip title by ID
2771 */
2772 public function getTripTitle(int $tripId): string
2773 {
2774 global $wpdb;
2775 $trips_table = $this->getTableName();
2776
2777 return (string) $wpdb->get_var($wpdb->prepare(
2778 "SELECT title FROM {$trips_table} WHERE id = %d",
2779 $tripId
2780 )) ?: '';
2781 }
2782
2783 /**
2784 * Count all trips
2785 */
2786 public function countAllTrips(): int
2787 {
2788 global $wpdb;
2789 $trips_table = $this->getTableName();
2790
2791 return (int) $wpdb->get_var("SELECT COUNT(*) FROM `{$trips_table}`");
2792 }
2793
2794 /**
2795 * Get trip status counts
2796 */
2797 public function getTripStatusCounts(): array
2798 {
2799 global $wpdb;
2800 $trips_table = $this->getTableName();
2801
2802 return $wpdb->get_results("SELECT status, COUNT(*) as count FROM `{$trips_table}` GROUP BY status");
2803 }
2804
2805 /**
2806 * Get trip with destinations
2807 */
2808 public function getTripWithDestinations(int $tripId): ?\stdClass
2809 {
2810 global $wpdb;
2811 $trips_table = $this->getTableName();
2812
2813 // Use ClassificationsTable for destinations (type = 'destination')
2814 $trip_destinations_table = TripClassificationsTable::getTableName();
2815 $destinations_table = ClassificationsTable::getTableName();
2816
2817 return $wpdb->get_row($wpdb->prepare(
2818 "SELECT t.id, t.title, t.slug, t.status, t.pricing_type, t.original_price, t.sale_price,
2819 td.destination_id, d.name as destination_name, d.slug as destination_slug
2820 FROM {$trips_table} t
2821 LEFT JOIN {$trip_destinations_table} td ON td.trip_id = t.id
2822 LEFT JOIN {$destinations_table} d ON d.id = td.destination_id
2823 WHERE t.id = %d",
2824 $tripId
2825 ));
2826 }
2827
2828 /**
2829 * Get trip destinations
2830 */
2831 public function getTripDestinations(int $tripId): array
2832 {
2833 global $wpdb;
2834
2835 // Use new TripClassificationsTable for trip-destination relationships
2836 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
2837 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
2838
2839 $results = $wpdb->get_results($wpdb->prepare(
2840 "SELECT tc.trip_id, tc.classification_id, c.name, c.slug
2841 FROM {$tripClassificationsTable} tc
2842 LEFT JOIN {$classificationsTable} c ON c.id = tc.classification_id
2843 WHERE tc.trip_id = %d AND tc.classification_type = %s",
2844 $tripId, ClassificationTypes::DESTINATION
2845 ));
2846
2847 // Filter out destinations with missing classification data
2848 return array_filter($results, function($destination) {
2849 return !empty($destination->name) && !empty($destination->slug);
2850 });
2851 }
2852
2853 /**
2854 * Get trip activities
2855 */
2856 public function getTripActivities(int $tripId): array
2857 {
2858 global $wpdb;
2859
2860 // Use new TripClassificationsTable for trip-activity relationships
2861 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
2862 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
2863
2864 return $wpdb->get_results($wpdb->prepare(
2865 "SELECT tc.trip_id, c.id, c.name, c.slug
2866 FROM {$tripClassificationsTable} tc
2867 INNER JOIN {$classificationsTable} c ON c.id = tc.classification_id
2868 WHERE tc.trip_id = %d AND c.type = 'activity'",
2869 $tripId
2870 ));
2871 }
2872
2873 /**
2874 * Get trip with availability
2875 */
2876 public function getTripWithAvailability(int $tripId): ?\stdClass
2877 {
2878 global $wpdb;
2879 $trips_table = $this->getTableName();
2880
2881 // Use TripAvailabilityDatesTable for availability data
2882 $availability_table = \Yatra\Database\Tables\TripAvailabilityDatesTable::getTableName();
2883
2884 return $wpdb->get_row($wpdb->prepare(
2885 "SELECT t.*, a.departure_date, a.seats_total, a.seats_reserved AS seats_booked, a.status as availability_status
2886 FROM {$trips_table} t
2887 LEFT JOIN {$availability_table} a ON a.trip_id = t.id
2888 WHERE t.id = %d",
2889 $tripId
2890 ));
2891 }
2892
2893 /**
2894 * Get price range statistics for published trips.
2895 *
2896 * IMPORTANT: the previous SQL-only implementation only considered
2897 * the legacy `original_price` / `discounted_price` / `sale_price`
2898 * columns. For trips using traveler-based pricing (per-category
2899 * prices stored in the `price_types` JSON column), those legacy
2900 * columns are often empty or stale — so the computed max was lower
2901 * than the actually-displayed price, and the price-range slider
2902 * cut off above the real maximum (eg. trip cost €3755 but slider
2903 * stopped at €3731). Worse, the trip then couldn't be filtered
2904 * into the results because the effective-price WHERE excluded it.
2905 *
2906 * Fix: walk the published trips once in PHP and let
2907 * `TripPricingService` decide each trip's effective price using
2908 * the same logic the listing/single-trip pages use to DISPLAY the
2909 * price. We also track the per-trip MIN/MAX across categories so
2910 * the slider bounds cover every traveler tier (not only the
2911 * default / cheapest).
2912 *
2913 * @return object Object with min_price and max_price properties
2914 */
2915 public function getPriceRangeStats(): object
2916 {
2917 $table = $this->getTableName();
2918 $rows = $this->wpdb->get_results(
2919 "SELECT id, original_price, discounted_price, sale_price, price_types
2920 FROM {$table}
2921 WHERE status IN ('publish', 'published')
2922 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')"
2923 ) ?: [];
2924
2925 $minPrice = null;
2926 $maxPrice = null;
2927
2928 foreach ($rows as $row) {
2929 $tripPrices = $this->collectTripDisplayPrices($row);
2930 foreach ($tripPrices as $price) {
2931 if ($price <= 0) {
2932 continue;
2933 }
2934 if ($minPrice === null || $price < $minPrice) {
2935 $minPrice = $price;
2936 }
2937 if ($maxPrice === null || $price > $maxPrice) {
2938 $maxPrice = $price;
2939 }
2940 }
2941 }
2942
2943 return (object) [
2944 'min_price' => $minPrice,
2945 'max_price' => $maxPrice,
2946 ];
2947 }
2948
2949 /**
2950 * Collect every price a single trip might display to a user.
2951 *
2952 * Returns BOTH the legacy "regular" effective price AND every
2953 * per-category effective price from `price_types`. Used by
2954 * `getPriceRangeStats()` so the price-range slider's bounds cover
2955 * the full range a customer could see — eg. for a traveler-based
2956 * trip with Adult €3755 / Child €1500 / Infant €100, the array
2957 * includes all three so the slider stretches from €100 to €3755.
2958 *
2959 * @param object $row Raw trip row (must include `price_types`,
2960 * `original_price`, `discounted_price`,
2961 * `sale_price`).
2962 * @return float[]
2963 */
2964 protected function collectTripDisplayPrices(object $row): array
2965 {
2966 $prices = [];
2967
2968 // Legacy regular pricing.
2969 $legacyPrice = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($row);
2970 if ($legacyPrice > 0) {
2971 $prices[] = $legacyPrice;
2972 }
2973
2974 // Per-category prices (traveler-based pricing). Decode whether
2975 // `price_types` is stored as a JSON string (raw DB row) or an
2976 // already-decoded array (in-memory trip object).
2977 $priceTypes = $row->price_types ?? null;
2978 if (is_string($priceTypes) && $priceTypes !== '') {
2979 $decoded = json_decode($priceTypes, true);
2980 if (is_array($decoded)) {
2981 $priceTypes = $decoded;
2982 }
2983 }
2984 if (is_array($priceTypes)) {
2985 foreach ($priceTypes as $pt) {
2986 $price = \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt);
2987 if ($price > 0) {
2988 $prices[] = $price;
2989 }
2990 }
2991 }
2992
2993 return $prices;
2994 }
2995
2996 /**
2997 * Count trips by difficulty level
2998 *
2999 * @param int $difficultyLevelId Difficulty level ID
3000 * @return int Number of trips with this difficulty level
3001 */
3002 public function countByDifficultyLevel(int $difficultyLevelId): int
3003 {
3004 $table = $this->getTableName();
3005
3006 // Use ClassificationsTable for difficulty levels (type = 'difficulty')
3007 $difficultyTable = ClassificationsTable::getTableName();
3008
3009 return (int) $this->wpdb->get_var($this->wpdb->prepare(
3010 "SELECT COUNT(*) FROM {$table} t
3011 LEFT JOIN {$difficultyTable} dl ON (t.difficulty_level = dl.id OR t.difficulty_level = dl.slug OR t.difficulty_level = dl.name)
3012 WHERE dl.id = %d AND t.status IN ('publish','published')",
3013 $difficultyLevelId
3014 ));
3015 }
3016
3017 /**
3018 * Check if reviews table exists
3019 *
3020 * @return bool True if reviews table exists
3021 */
3022 public function reviewsTableExists(): bool
3023 {
3024 // Use ReviewsTable for reviews
3025 $reviewsTable = ReviewsTable::getTableName();
3026 return (bool) $this->wpdb->get_var(
3027 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
3028 WHERE TABLE_SCHEMA = DATABASE()
3029 AND TABLE_NAME = '{$reviewsTable}'"
3030 );
3031 }
3032
3033 /**
3034 * Count trips by minimum rating
3035 *
3036 * @param int $minRating Minimum rating
3037 * @return int Number of trips with this rating or above
3038 */
3039 public function countByMinRating(int $minRating): int
3040 {
3041 $table = $this->getTableName();
3042
3043 // Use ReviewsTable for reviews
3044 $reviewsTable = ReviewsTable::getTableName();
3045
3046 return (int) $this->wpdb->get_var($this->wpdb->prepare(
3047 "SELECT COUNT(DISTINCT t.id)
3048 FROM {$reviewsTable} r
3049 INNER JOIN {$table} t ON r.trip_id = t.id
3050 WHERE r.rating >= %d AND t.status IN ('publish','published')",
3051 $minRating
3052 ));
3053 }
3054
3055 /**
3056 * Count trips by category
3057 *
3058 * @param int $categoryId Category ID
3059 * @return int Number of trips in this category
3060 */
3061 public function countByCategory(int $categoryId): int
3062 {
3063 $table = $this->getTableName();
3064
3065 // Use TripClassificationsTable for trip-category relationships
3066 $categoryTable = TripClassificationsTable::getTableName();
3067
3068 $c = ClassificationsTable::getTableName();
3069
3070 return (int) $this->wpdb->get_var($this->wpdb->prepare(
3071 "SELECT COUNT(DISTINCT t.id) FROM {$table} t
3072 INNER JOIN {$categoryTable} ttc ON t.id = ttc.trip_id AND ttc.is_active = 1
3073 INNER JOIN {$c} cls ON cls.id = ttc.classification_id AND cls.type = %s
3074 WHERE ttc.classification_id = %d
3075 AND t.status IN ('publish', 'published')
3076 AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')",
3077 ClassificationTypes::CATEGORY,
3078 $categoryId
3079 ));
3080 }
3081
3082 /**
3083 * Count trips by destination
3084 *
3085 * @param int $destinationId Destination ID
3086 * @return int Number of trips to this destination
3087 */
3088 public function countByDestination(int $destinationId): int
3089 {
3090 $table = $this->getTableName();
3091 $tc = TripClassificationsTable::getTableName();
3092 $c = ClassificationsTable::getTableName();
3093
3094 return (int) $this->wpdb->get_var($this->wpdb->prepare(
3095 "SELECT COUNT(DISTINCT t.id) FROM {$table} t
3096 INNER JOIN {$tc} ttc ON t.id = ttc.trip_id AND ttc.is_active = 1
3097 INNER JOIN {$c} cls ON cls.id = ttc.classification_id AND cls.type = %s
3098 WHERE ttc.classification_id = %d
3099 AND t.status IN ('publish', 'published')
3100 AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')",
3101 ClassificationTypes::DESTINATION,
3102 $destinationId
3103 ));
3104 }
3105
3106 /**
3107 * Count trips by activity
3108 *
3109 * @param int $activityId Activity ID
3110 * @return int Number of trips with this activity
3111 */
3112 public function countByActivity(int $activityId): int
3113 {
3114 $table = $this->getTableName();
3115 $tc = TripClassificationsTable::getTableName();
3116 $c = ClassificationsTable::getTableName();
3117
3118 return (int) $this->wpdb->get_var($this->wpdb->prepare(
3119 "SELECT COUNT(DISTINCT t.id) FROM {$table} t
3120 INNER JOIN {$tc} ttc ON t.id = ttc.trip_id AND ttc.is_active = 1
3121 INNER JOIN {$c} cls ON cls.id = ttc.classification_id AND cls.type = %s
3122 WHERE ttc.classification_id = %d
3123 AND t.status IN ('publish', 'published')
3124 AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')",
3125 ClassificationTypes::ACTIVITY,
3126 $activityId
3127 ));
3128 }
3129
3130 /**
3131 * Get popular trips for cache warming
3132 *
3133 * @param int $limit Number of trips to return
3134 * @return array Array of popular trip IDs
3135 */
3136 public function getPopularTrips(int $limit = 20): array
3137 {
3138 global $wpdb;
3139 $tripsTable = $this->getTableName();
3140
3141 // Using hardcoded table name since there's no dedicated repository for this table
3142 $bookingsTable = BookingsTable::getTableName();
3143
3144 return $wpdb->get_results("
3145 SELECT t.id
3146 FROM {$tripsTable} t
3147 LEFT JOIN {$bookingsTable} b ON b.trip_id = t.id
3148 WHERE t.status = 'publish'
3149 GROUP BY t.id
3150 ORDER BY COUNT(b.id) DESC
3151 LIMIT {$limit}
3152 ") ?: [];
3153 }
3154
3155 /**
3156 * Get price statistics for filter sidebar
3157 */
3158 public function getPriceStats(): ?object
3159 {
3160 global $wpdb;
3161
3162 $table = $this->getTableName();
3163
3164 $result = $wpdb->get_row("
3165 SELECT
3166 MIN(sub.eff_price) as min_price,
3167 MAX(sub.eff_price) as max_price,
3168 AVG(sub.eff_price) as avg_price
3169 FROM (
3170 SELECT (CASE
3171 WHEN CAST(discounted_price AS DECIMAL(10,2)) > 0 THEN CAST(discounted_price AS DECIMAL(10,2))
3172 WHEN CAST(sale_price AS DECIMAL(10,2)) > 0 THEN CAST(sale_price AS DECIMAL(10,2))
3173 ELSE CAST(original_price AS DECIMAL(10,2))
3174 END) AS eff_price
3175 FROM {$table}
3176 WHERE status IN ('publish', 'published')
3177 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')
3178 ) sub
3179 WHERE sub.eff_price > 0
3180 ");
3181
3182 return $result ? (object) [
3183 'min_price' => (float) $result->min_price,
3184 'max_price' => (float) $result->max_price,
3185 'avg_price' => (float) $result->avg_price
3186 ] : null;
3187 }
3188
3189 /**
3190 * Distinct accommodation_type values on published trips with counts.
3191 *
3192 * @return list<object{name: string, trip_count: int}>
3193 */
3194 public function getAccommodationTypes(): array
3195 {
3196 if (!$this->tripTableHasColumn('accommodation_type')) {
3197 return [];
3198 }
3199 $table = $this->getTableName();
3200 $rows = $this->wpdb->get_results(
3201 "SELECT TRIM(accommodation_type) AS name, COUNT(*) AS trip_count
3202 FROM {$table}
3203 WHERE status IN ('publish', 'published')
3204 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')
3205 AND accommodation_type IS NOT NULL AND TRIM(accommodation_type) <> ''
3206 GROUP BY TRIM(accommodation_type)
3207 ORDER BY trip_count DESC, name ASC"
3208 ) ?: [];
3209
3210 $out = [];
3211 foreach ($rows as $r) {
3212 $out[] = (object) [
3213 'name' => (string) $r->name,
3214 'trip_count' => (int) $r->trip_count,
3215 ];
3216 }
3217
3218 return $out;
3219 }
3220
3221 /**
3222 * Included item titles from trip.included_items JSON, aggregated by trip count.
3223 *
3224 * @return list<object{service_name: string, trip_count: int}>
3225 */
3226 public function getIncludedServices(): array
3227 {
3228 if (!$this->tripTableHasColumn('included_items')) {
3229 return [];
3230 }
3231 $table = $this->getTableName();
3232 $jsons = $this->wpdb->get_col(
3233 "SELECT included_items FROM {$table}
3234 WHERE status IN ('publish', 'published')
3235 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')
3236 AND included_items IS NOT NULL
3237 AND included_items <> ''
3238 AND included_items <> '[]'"
3239 ) ?: [];
3240
3241 $counts = [];
3242 foreach ($jsons as $json) {
3243 $decoded = json_decode((string) $json, true);
3244 if (!is_array($decoded)) {
3245 continue;
3246 }
3247 $list = $decoded;
3248 if (isset($decoded['items']) && is_array($decoded['items'])) {
3249 $list = $decoded['items'];
3250 }
3251 foreach ($list as $item) {
3252 $title = $this->extractIncludedItemLabel($item);
3253 if ($title === '') {
3254 continue;
3255 }
3256 $counts[$title] = ($counts[$title] ?? 0) + 1;
3257 }
3258 }
3259 arsort($counts, SORT_NUMERIC);
3260 $out = [];
3261 foreach ($counts as $name => $c) {
3262 $out[] = (object) ['service_name' => $name, 'trip_count' => (int) $c];
3263 }
3264
3265 return $out;
3266 }
3267
3268 /**
3269 * Min/max duration_days among published trips (for search UI). Falls back to 1–30 when empty.
3270 *
3271 * @return array{min: int, max: int}
3272 */
3273 public function getDurationDaysBounds(): array
3274 {
3275 if (!$this->tripTableHasColumn('duration_days')) {
3276 return ['min' => 1, 'max' => 30];
3277 }
3278 $table = $this->getTableName();
3279 $row = $this->wpdb->get_row(
3280 "SELECT
3281 MIN(NULLIF(CAST(duration_days AS UNSIGNED), 0)) AS min_days,
3282 MAX(CAST(duration_days AS UNSIGNED)) AS max_days
3283 FROM {$table}
3284 WHERE status IN ('publish', 'published')
3285 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')"
3286 );
3287 $min = (int) ($row->min_days ?? 1);
3288 $max = (int) ($row->max_days ?? 1);
3289 if ($min < 1) {
3290 $min = 1;
3291 }
3292 if ($max < $min) {
3293 $max = $min;
3294 }
3295 // Sensible upper bound for dual slider UX
3296 if ($max > 365) {
3297 $max = 365;
3298 }
3299
3300 return ['min' => $min, 'max' => $max];
3301 }
3302
3303 /**
3304 * Get duration options (placeholder)
3305 *
3306 * @return array
3307 */
3308 public function getDurationOptions(): array
3309 {
3310 return [];
3311 }
3312
3313 /**
3314 * Get group size options (placeholder)
3315 *
3316 * @return array
3317 */
3318 public function getGroupSizeOptions(): array
3319 {
3320 return [];
3321 }
3322
3323 /**
3324 * Get physical grades (placeholder)
3325 *
3326 * @return array
3327 */
3328 public function getPhysicalGrades(): array
3329 {
3330 return [];
3331 }
3332
3333 /**
3334 * Get trip types
3335 */
3336 public function getTripTypes(): array
3337 {
3338 return [
3339 (object) ['value' => 'single_day', 'label' => __('Single day', 'yatra')],
3340 (object) ['value' => 'multi_day', 'label' => __('Multi-day', 'yatra')],
3341 (object) ['value' => 'flexible', 'label' => __('Flexible', 'yatra')],
3342 ];
3343 }
3344
3345 /**
3346 * Count trips by trip type
3347 */
3348 public function countByTripType(string $tripType): int
3349 {
3350 global $wpdb;
3351 $table = $this->getTableName();
3352
3353 return (int) $wpdb->get_var($wpdb->prepare(
3354 "SELECT COUNT(*) FROM {$table}
3355 WHERE trip_type = %s AND status = 'publish'",
3356 $tripType
3357 ));
3358 }
3359
3360 /**
3361 * Count trips with discounts
3362 */
3363 public function countByDiscount(): int
3364 {
3365 $table = $this->getTableName();
3366 return (int) $this->wpdb->get_var(
3367 "SELECT COUNT(*) FROM {$table}
3368 WHERE status = 'publish' AND (discounted_price IS NOT NULL OR sale_price IS NOT NULL)"
3369 );
3370 }
3371
3372 /**
3373 * Count trips with early bird offers
3374 */
3375 public function countByEarlyBird(): int
3376 {
3377 $table = $this->getTableName();
3378
3379 // Check if column exists
3380 $column_exists = (int) $this->wpdb->get_var(
3381 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
3382 WHERE TABLE_SCHEMA = DATABASE()
3383 AND TABLE_NAME = '{$table}'
3384 AND COLUMN_NAME = 'early_bird_discount_enabled'"
3385 );
3386
3387 if ($column_exists) {
3388 return (int) $this->wpdb->get_var(
3389 "SELECT COUNT(*) FROM {$table}
3390 WHERE status = 'publish' AND early_bird_discount_enabled = 1"
3391 );
3392 }
3393
3394 return 0;
3395 }
3396
3397 /**
3398 * Count trips with last minute deals
3399 */
3400 public function countByLastMinute(): int
3401 {
3402 $table = $this->getTableName();
3403
3404 // Check if column exists
3405 $column_exists = (int) $this->wpdb->get_var(
3406 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
3407 WHERE TABLE_SCHEMA = DATABASE()
3408 AND TABLE_NAME = '{$table}'
3409 AND COLUMN_NAME = 'last_minute_discount_enabled'"
3410 );
3411
3412 if ($column_exists) {
3413 return (int) $this->wpdb->get_var(
3414 "SELECT COUNT(*) FROM {$table}
3415 WHERE status = 'publish' AND last_minute_discount_enabled = 1"
3416 );
3417 }
3418
3419 return 0;
3420 }
3421
3422 /**
3423 * Count trips with instant booking
3424 */
3425 public function countByInstantBooking(): int
3426 {
3427 $table = $this->getTableName();
3428
3429 // Check if column exists
3430 $column_exists = (int) $this->wpdb->get_var(
3431 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
3432 WHERE TABLE_SCHEMA = DATABASE()
3433 AND TABLE_NAME = '{$table}'
3434 AND COLUMN_NAME = 'instant_booking'"
3435 );
3436
3437 if ($column_exists) {
3438 return (int) $this->wpdb->get_var(
3439 "SELECT COUNT(*) FROM {$table}
3440 WHERE status = 'publish' AND instant_booking = 1"
3441 );
3442 }
3443
3444 return 0;
3445 }
3446
3447 /**
3448 * Count trips with flexible dates
3449 */
3450 public function countByFlexibleDates(): int
3451 {
3452 $table = $this->getTableName();
3453
3454 // Check if column exists
3455 $column_exists = (int) $this->wpdb->get_var(
3456 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
3457 WHERE TABLE_SCHEMA = DATABASE()
3458 AND TABLE_NAME = '{$table}'
3459 AND COLUMN_NAME = 'flexible_dates'"
3460 );
3461
3462 if ($column_exists) {
3463 return (int) $this->wpdb->get_var(
3464 "SELECT COUNT(*) FROM {$table}
3465 WHERE status = 'publish' AND flexible_dates = 1"
3466 );
3467 }
3468
3469 return 0;
3470 }
3471
3472 /**
3473 * Count trips requiring deposit
3474 */
3475 public function countByDepositRequired(): int
3476 {
3477 $table = $this->getTableName();
3478
3479 // Check if column exists
3480 $column_exists = (int) $this->wpdb->get_var(
3481 "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
3482 WHERE TABLE_SCHEMA = DATABASE()
3483 AND TABLE_NAME = '{$table}'
3484 AND COLUMN_NAME = 'deposit_required'"
3485 );
3486
3487 if ($column_exists) {
3488 return (int) $this->wpdb->get_var(
3489 "SELECT COUNT(*) FROM {$table}
3490 WHERE status = 'publish' AND deposit_required = 1"
3491 );
3492 }
3493
3494 return 0;
3495 }
3496
3497 /**
3498 * Count family friendly trips
3499 */
3500 public function countByFamilyFriendly(): int
3501 {
3502 $table = $this->getTableName();
3503 return (int) $this->wpdb->get_var(
3504 "SELECT COUNT(*) FROM {$table}
3505 WHERE status = 'publish' AND (age_min IS NULL OR age_min <= 5)"
3506 );
3507 }
3508
3509 /**
3510 * Count kids friendly trips
3511 */
3512 public function countByKidsFriendly(): int
3513 {
3514 $table = $this->getTableName();
3515 return (int) $this->wpdb->get_var(
3516 "SELECT COUNT(*) FROM {$table}
3517 WHERE status = 'publish' AND (age_min IS NULL OR age_min <= 12)"
3518 );
3519 }
3520
3521 /**
3522 * Count senior friendly trips
3523 */
3524 public function countBySeniorFriendly(): int
3525 {
3526 $table = $this->getTableName();
3527 return (int) $this->wpdb->get_var(
3528 "SELECT COUNT(*) FROM {$table}
3529 WHERE status = 'publish' AND (age_max IS NULL OR age_max >= 65)"
3530 );
3531 }
3532
3533 /**
3534 * Count adults only trips
3535 */
3536 public function countByAdultsOnly(): int
3537 {
3538 $table = $this->getTableName();
3539 return (int) $this->wpdb->get_var(
3540 "SELECT COUNT(*) FROM {$table}
3541 WHERE status = 'publish' AND age_min >= 18"
3542 );
3543 }
3544
3545 /**
3546 * Get all destinations for search dropdown
3547 * Returns destinations that have associated trips
3548 *
3549 * @return array Array of destination objects
3550 */
3551 public function getAllDestinationsForSearch(): array
3552 {
3553 global $wpdb;
3554
3555 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
3556 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
3557
3558 // Get destinations - try multiple status values
3559 $destinations = $wpdb->get_results("
3560 SELECT DISTINCT c.* FROM {$classificationsTable} c
3561 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
3562 WHERE c.type = 'destination' AND c.status IN ('publish', 'active', 'draft')
3563 ORDER BY c.name ASC
3564 ");
3565
3566 // If no destinations found, try without status filter
3567 if (empty($destinations)) {
3568 $destinations = $wpdb->get_results("
3569 SELECT DISTINCT c.* FROM {$classificationsTable} c
3570 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
3571 WHERE c.type = 'destination'
3572 ORDER BY c.name ASC
3573 ");
3574 }
3575
3576 return $destinations ?: [];
3577 }
3578
3579 /**
3580 * Get all activities for search dropdown
3581 * Returns activities that have associated trips
3582 *
3583 * @return array Array of activity objects
3584 */
3585 public function getAllActivitiesForSearch(): array
3586 {
3587 global $wpdb;
3588
3589 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
3590 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
3591
3592 // Get activities - try multiple status values
3593 $activities = $wpdb->get_results("
3594 SELECT DISTINCT c.* FROM {$classificationsTable} c
3595 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
3596 WHERE c.type = 'activity' AND c.status IN ('publish', 'active', 'draft')
3597 ORDER BY c.name ASC
3598 ");
3599
3600 // If no activities found, try without status filter
3601 if (empty($activities)) {
3602 $activities = $wpdb->get_results("
3603 SELECT DISTINCT c.* FROM {$classificationsTable} c
3604 INNER JOIN {$tripClassificationsTable} tc ON c.id = tc.classification_id
3605 WHERE c.type = 'activity'
3606 ORDER BY c.name ASC
3607 ");
3608 }
3609
3610 return $activities ?: [];
3611 }
3612 }
3613