PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 2.0.11 All 82 releases
yatra / app / Repositories / DestinationRepository.php

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

521 lines 18.3 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\Constants\ClassificationTypes;
8 use Yatra\Database\Tables\ClassificationsTable;
9 use Yatra\Database\Tables\TripClassificationsTable;
10 use Yatra\Database\Tables\TripsTable;
11 use Yatra\Database\Tables\ReviewsTable;
12 use Yatra\Utils\Cache;
13
14 /**
15 * Destination Repository
16 * Handles database operations for destinations using the new ClassificationsTable
17 */
18 class DestinationRepository extends BaseRepository
19 {
20 /**
21 * Rich text fields specific to destinations
22 */
23 protected array $richTextFields = ['description'];
24
25 /**
26 * Integer fields specific to destinations
27 */
28 protected array $integerFields = ['id', 'created_by', 'updated_by'];
29
30 /**
31 * JSON fields specific to destinations
32 */
33 protected array $jsonFields = ['metadata'];
34
35 /**
36 * Get table name - using the new ClassificationsTable
37 */
38 protected function getTableName(): string
39 {
40 return ClassificationsTable::getTableName();
41 }
42
43 /**
44 * Find by slug - for destinations
45 */
46 public function findBySlug(string $slug): ?\stdClass
47 {
48 $table = esc_sql($this->table);
49 $result = $this->wpdb->get_row(
50 $this->wpdb->prepare(
51 "SELECT * FROM `{$table}` WHERE type = %s AND slug = %s",
52 ClassificationTypes::DESTINATION,
53 $slug
54 )
55 );
56
57 return $result ?: null;
58 }
59
60 /**
61 * Get published destinations
62 */
63 public function getPublished(array $args = []): array
64 {
65 $args['where']['type'] = ClassificationTypes::DESTINATION;
66 $args['where']['status'] = 'publish';
67 return $this->all($args);
68 }
69
70 /**
71 * Get destinations by status
72 */
73 public function getByStatus(string $status, array $args = []): array
74 {
75 $args['where']['type'] = ClassificationTypes::DESTINATION;
76 $args['where']['status'] = $status;
77 return $this->all($args);
78 }
79
80 /**
81 * Wipe listing caches whenever a destination row is created /
82 * updated / deleted so {@see self::getPublishedWithTripCounts()}
83 * (and any other `destination_listing_*` / `trip_listing_*` keys)
84 * never serve stale aggregates after admin edits. The matching
85 * {@see \Yatra\Hooks\CacheHooks::onDestinationUpdated()} listeners
86 * only fire when a `yatra_destination_*` action is dispatched
87 * elsewhere, which today nothing does — so without this override an
88 * admin edit could keep showing old `trips_count` / `starting_price`
89 * for up to {@see Cache::DURATION_DESTINATION_DATA} seconds.
90 */
91 protected function afterWrite(string $operation, int $id, array $context = []): void
92 {
93 Cache::invalidateListingCaches();
94 }
95
96 /**
97 * Get published destinations with attached trip counts.
98 *
99 * This uses the new TripClassificationsTable relation table to count how many
100 * trips are linked to each destination. It returns each destination row
101 * plus a numeric trips_count property.
102 */
103 public function getPublishedWithTripCounts(): array
104 {
105 // The underlying SQL + price walk has been made O(1) in the
106 // number of trips per destination (see
107 // {@see self::computeStartingPriceForTripIds()}), so the
108 // uncached path is now fast on its own. We still cache the full
109 // result for repeat visits, but the cache key sits behind
110 // {@see \Yatra\Utils\Cache::invalidateListingCaches()} (which
111 // matches the `destination_listing_` prefix) and gets wiped
112 // automatically whenever a trip/destination row is written via
113 // {@see \Yatra\Hooks\CacheHooks}. Users always see real data
114 // immediately after admin edits — no manual cache-bust needed.
115 return $this->cacheQueryResult(
116 'destination_listing_with_trip_counts_v2',
117 function (): array {
118 return $this->fetchPublishedWithTripCounts();
119 },
120 Cache::DURATION_DESTINATION_DATA
121 );
122 }
123
124 /**
125 * Uncached worker for {@see self::getPublishedWithTripCounts()}.
126 *
127 * @return array<int, \stdClass>
128 */
129 private function fetchPublishedWithTripCounts(): array
130 {
131 $destTable = esc_sql($this->table);
132 $relTable = TripClassificationsTable::getTableName();
133 $tripsTable = TripsTable::getTableName();
134 $reviewsTable = ReviewsTable::getTableName();
135
136 // COUNT(DISTINCT tc.trip_id) gives real number of trips per destination.
137 // avg_rating is computed from approved reviews across all those trips.
138 // starting_price is computed in PHP using both regular trip prices and
139 // traveler-based pricing from recurring availability rules.
140 $sql = "SELECT d.*,
141 COUNT(DISTINCT tc.trip_id) AS trips_count,
142 COALESCE(AVG(r.rating), 0) AS avg_rating,
143 GROUP_CONCAT(DISTINCT tc.trip_id) AS trip_ids
144 FROM `{$destTable}` d
145 LEFT JOIN `{$relTable}` tc
146 ON tc.classification_id = d.id
147 AND tc.classification_type = %s
148 LEFT JOIN `{$tripsTable}` t
149 ON t.id = tc.trip_id
150 LEFT JOIN `{$reviewsTable}` r
151 ON r.trip_id = t.id AND r.status = 'approved'
152 WHERE d.type = %s AND d.status = 'publish'
153 GROUP BY d.id";
154
155 $rows = $this->wpdb->get_results($this->wpdb->prepare($sql, ClassificationTypes::DESTINATION, ClassificationTypes::DESTINATION)) ?: [];
156
157 if (empty($rows)) {
158 return [];
159 }
160
161 foreach ($rows as $row) {
162 $row->starting_price = $this->computeStartingPriceForTripIds($row->trip_ids ?? '');
163 }
164
165 return $rows;
166 }
167
168 /**
169 * Compute the minimum effective starting price across a set of trip IDs.
170 *
171 * This looks at both regular trip pricing (sale/discounted/original) and
172 * traveler-based pricing defined in recurring availability rules.
173 */
174 private function computeStartingPriceForTripIds(string $tripIdsCsv): float
175 {
176 if (trim($tripIdsCsv) === '') {
177 return 0.0;
178 }
179
180 $tripIds = array_values(array_unique(array_filter(array_map('intval', explode(',', $tripIdsCsv)))));
181 if (empty($tripIds)) {
182 return 0.0;
183 }
184
185 // Batch-load all trip rows in ONE query (was: SELECT-per-trip,
186 // which made every /destinations request issue O(trips_per_dest)
187 // queries — multiplied across all destinations on the page).
188 $tripsTable = TripsTable::getTableName();
189 $placeholders = implode(',', array_fill(0, count($tripIds), '%d'));
190 $rows = $this->wpdb->get_results(
191 $this->wpdb->prepare(
192 "SELECT id, sale_price, discounted_price, original_price FROM `{$tripsTable}` WHERE id IN ({$placeholders})",
193 ...$tripIds
194 )
195 ) ?: [];
196
197 $minPrice = null;
198 $unpricedIds = [];
199
200 foreach ($rows as $row) {
201 $tripEffective = \Yatra\Services\TripPricingService::getEffectivePrice($row);
202 if ($tripEffective > 0) {
203 if ($minPrice === null || $tripEffective < $minPrice) {
204 $minPrice = $tripEffective;
205 }
206 } else {
207 $unpricedIds[] = (int) $row->id;
208 }
209 }
210
211 // Only fall back to RecurringAvailabilityRepository for trips
212 // that have no trip-level price set. Even there, batch the rule
213 // query across all such trip IDs instead of N separate calls.
214 if (!empty($unpricedIds)) {
215 $fallbackMin = $this->minPriceFromRecurringRulesForTripIds($unpricedIds);
216 if ($fallbackMin > 0 && ($minPrice === null || $fallbackMin < $minPrice)) {
217 $minPrice = $fallbackMin;
218 }
219 }
220
221 return $minPrice ?? 0.0;
222 }
223
224 /**
225 * Batched version of the traveler-pricing fallback that used to run
226 * once per trip. Pulls every active rule for the given trip IDs in a
227 * single query and scans them in PHP.
228 *
229 * @param list<int> $tripIds
230 */
231 private function minPriceFromRecurringRulesForTripIds(array $tripIds): float
232 {
233 if ($tripIds === []) {
234 return 0.0;
235 }
236
237 $rulesRepo = new RecurringAvailabilityRepository();
238 if (!method_exists($rulesRepo, 'findByTripIds')) {
239 // Older repository — fall back to per-trip lookup but only
240 // for the (small) set of trips with no trip-level price.
241 $candidates = [];
242 foreach ($tripIds as $tid) {
243 $rules = $rulesRepo->findByTripId($tid, ['status' => 'active']);
244 foreach ($rules as $rule) {
245 $this->collectRulePriceCandidates($rule, $candidates);
246 }
247 }
248 return $candidates === [] ? 0.0 : (float) min($candidates);
249 }
250
251 $rules = $rulesRepo->findByTripIds($tripIds, ['status' => 'active']);
252 $candidates = [];
253 foreach ($rules as $rule) {
254 $this->collectRulePriceCandidates($rule, $candidates);
255 }
256 return $candidates === [] ? 0.0 : (float) min($candidates);
257 }
258
259 /**
260 * @param list<float> $candidates Accumulator passed by reference.
261 */
262 private function collectRulePriceCandidates(object $rule, array &$candidates): void
263 {
264 if (!empty($rule->sale_price) && (float) $rule->sale_price > 0) {
265 $candidates[] = (float) $rule->sale_price;
266 }
267 if (!empty($rule->original_price) && (float) $rule->original_price > 0) {
268 $candidates[] = (float) $rule->original_price;
269 }
270 if (!empty($rule->traveler_pricing) && is_array($rule->traveler_pricing)) {
271 foreach ($rule->traveler_pricing as $pricing) {
272 if (!empty($pricing['effective_price']) && (float) $pricing['effective_price'] > 0) {
273 $candidates[] = (float) $pricing['effective_price'];
274 }
275 }
276 }
277 if (!empty($rule->time_slots) && is_array($rule->time_slots)) {
278 foreach ($rule->time_slots as $slot) {
279 if (empty($slot['traveler_pricing']) || !is_array($slot['traveler_pricing'])) {
280 continue;
281 }
282 foreach ($slot['traveler_pricing'] as $pricing) {
283 if (!empty($pricing['effective_price']) && (float) $pricing['effective_price'] > 0) {
284 $candidates[] = (float) $pricing['effective_price'];
285 }
286 }
287 }
288 }
289 }
290
291 /**
292 * Get an effective base price for a single trip.
293 *
294 * Priority:
295 * 1) Trip-level sale/discounted/original price (if any > 0)
296 * 2) Traveler-based pricing from active recurring availability rules
297 * (minimum effective traveler price or rule-level sale/original).
298 */
299 private function getEffectiveTripBasePrice(int $tripId): float
300 {
301 global $wpdb;
302
303 if ($tripId <= 0) {
304 return 0.0;
305 }
306
307 $tripsTable = TripsTable::getTableName();
308
309 $trip = $wpdb->get_row(
310 $wpdb->prepare(
311 "SELECT id, sale_price, discounted_price, original_price FROM `{$tripsTable}` WHERE id = %d",
312 $tripId
313 )
314 );
315
316 if (!$trip) {
317 return 0.0;
318 }
319
320 // Use centralized TripPricingService for trip-level pricing
321 $tripEffective = \Yatra\Services\TripPricingService::getEffectivePrice($trip);
322 if ($tripEffective > 0) {
323 return $tripEffective;
324 }
325
326 $candidates = [];
327
328 // Fallback 1: look at traveler-based pricing from recurring availability rules
329 $rulesRepo = new RecurringAvailabilityRepository();
330 $rules = $rulesRepo->findByTripId($tripId, ['status' => 'active']);
331
332 foreach ($rules as $rule) {
333 // Rule-level sale/original
334 if (!empty($rule->sale_price) && (float) $rule->sale_price > 0) {
335 $candidates[] = (float) $rule->sale_price;
336 }
337 if (!empty($rule->original_price) && (float) $rule->original_price > 0) {
338 $candidates[] = (float) $rule->original_price;
339 }
340
341 // Traveler pricing on the rule itself
342 if (!empty($rule->traveler_pricing) && is_array($rule->traveler_pricing)) {
343 foreach ($rule->traveler_pricing as $pricing) {
344 if (!empty($pricing['effective_price']) && (float) $pricing['effective_price'] > 0) {
345 $candidates[] = (float) $pricing['effective_price'];
346 }
347 }
348 }
349
350 // Traveler pricing nested in time_slots
351 if (!empty($rule->time_slots) && is_array($rule->time_slots)) {
352 foreach ($rule->time_slots as $slot) {
353 if (empty($slot['traveler_pricing']) || !is_array($slot['traveler_pricing'])) {
354 continue;
355 }
356 foreach ($slot['traveler_pricing'] as $pricing) {
357 if (!empty($pricing['effective_price']) && (float) $pricing['effective_price'] > 0) {
358 $candidates[] = (float) $pricing['effective_price'];
359 }
360 }
361 }
362 }
363 }
364
365 if (empty($candidates)) {
366 return 0.0;
367 }
368
369 return (float) min($candidates);
370 }
371
372 /**
373 * Search destinations
374 */
375 public function search(string $search, array $args = []): array
376 {
377 $table = esc_sql($this->table);
378 $where = $this->buildWhereClause($args);
379 $order = $this->buildOrderClause($args);
380 $limit = $this->buildLimitClause($args);
381
382 $search_where = $this->wpdb->prepare(
383 "WHERE type = %s AND (name LIKE %s OR slug LIKE %s OR description LIKE %s)",
384 ClassificationTypes::DESTINATION,
385 '%' . $this->wpdb->esc_like($search) . '%',
386 '%' . $this->wpdb->esc_like($search) . '%',
387 '%' . $this->wpdb->esc_like($search) . '%'
388 );
389
390 if ($where) {
391 $search_where .= ' AND ' . str_replace('WHERE ', '', $where);
392 }
393
394 $query = "SELECT * FROM `{$table}` {$search_where} {$order} {$limit}";
395 return $this->wpdb->get_results($query) ?: [];
396 }
397
398 /**
399 * Get status counts for destinations
400 */
401 public function getStatusCounts(array $args = []): array
402 {
403 $table = esc_sql($this->table);
404
405 // Get counts for each status - only for destinations
406 $results = $this->wpdb->get_results($this->wpdb->prepare("
407 SELECT status, COUNT(*) as count
408 FROM `{$table}`
409 WHERE type = %s
410 GROUP BY status
411 ", ClassificationTypes::DESTINATION), ARRAY_A) ?: [];
412
413 $counts = [
414 'publish' => 0,
415 'draft' => 0,
416 'trash' => 0,
417 'total' => 0
418 ];
419
420 foreach ($results as $row) {
421 $status = $row['status'];
422 $count = (int) $row['count'];
423
424 // Map old status values to new ones
425 if ($status === 'active') {
426 $status = 'publish';
427 } elseif ($status === 'inactive') {
428 $status = 'trash';
429 }
430
431 if (isset($counts[$status])) {
432 $counts[$status] += $count;
433 $counts['total'] += $count;
434 } else {
435 // Handle any unexpected statuses
436 $counts['total'] += $count;
437 }
438 }
439
440 // Ensure we have entries for all main statuses even if count is 0
441 $counts['publish'] = $counts['publish'] ?? 0;
442 $counts['draft'] = $counts['draft'] ?? 0;
443 $counts['trash'] = $counts['trash'] ?? 0;
444
445
446 return $counts;
447 }
448
449 /**
450 * Override base all() method to ensure type filtering
451 */
452 public function all(array $args = []): array
453 {
454 // IMPORTANT: Always filter by type = 'destination' for destinations
455 $args['where']['type'] = ClassificationTypes::DESTINATION;
456 return parent::all($args);
457 }
458
459 /**
460 * Override base count() method to ensure type filtering
461 */
462 public function count(array $args = []): int
463 {
464 // IMPORTANT: Always filter by type = 'destination' for destinations
465 $args['where']['type'] = ClassificationTypes::DESTINATION;
466 return parent::count($args);
467 }
468
469 /**
470 * Get trip count for a destination
471 *
472 * @param int $destinationId Destination ID
473 * @return int Number of trips with this destination
474 */
475 public function getTripCount(int $destinationId): int
476 {
477 global $wpdb;
478 $tripRepository = new \Yatra\Repositories\TripRepository();
479 $tripsTable = $tripRepository->getTableName();
480
481 // Use TripClassificationsTable for trip-destination relationships
482 $tripDestinationsTable = TripClassificationsTable::getTableName();
483
484 return (int) $wpdb->get_var($wpdb->prepare(
485 "SELECT COUNT(DISTINCT t.id)
486 FROM `{$tripsTable}` t
487 INNER JOIN `{$tripDestinationsTable}` td ON td.trip_id = t.id
488 WHERE td.classification_id = %d
489 AND td.classification_type = %s
490 AND t.status != 'trash'",
491 $destinationId,
492 ClassificationTypes::DESTINATION
493 ));
494 }
495
496 /**
497 * Get trip count for destination (direct field method)
498 *
499 * @param int $destinationId Destination ID
500 * @return int Number of trips with this destination
501 */
502 public function getTripCountDirect(int $destinationId): int
503 {
504 global $wpdb;
505 $tripRepository = new \Yatra\Repositories\TripRepository();
506 $tripTable = $tripRepository->getTableName();
507 $tripClassificationsTable = TripClassificationsTable::getTableName();
508
509 return (int) $wpdb->get_var($wpdb->prepare(
510 "SELECT COUNT(DISTINCT t.id)
511 FROM `{$tripTable}` t
512 INNER JOIN `{$tripClassificationsTable}` tc ON tc.trip_id = t.id
513 WHERE tc.classification_id = %d
514 AND tc.classification_type = %s
515 AND t.status != 'trash'",
516 $destinationId,
517 ClassificationTypes::DESTINATION
518 ));
519 }
520 }
521