PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | app/Repositories/TripRepository.php +635 -89 3.0.3 → 3.0.16 View file →
@@ -18,8 +18,9 @@
18 18 use Yatra\Utils\Cache;
19 19 use Yatra\Utils\QueryCache;
20 20 use Yatra\Database\Tables\TripAvailabilityDatesTable;
21 21 use Yatra\Database\Tables\TripAvailabilityRulesTable;
22 +use Yatra\Database\Tables\DeparturesTable;
22 23 use Yatra\Constants\ClassificationTypes;
23 24
24 25 /**
25 26 * Trip Repository
@@ -270,8 +271,9 @@
270 271 * Create trip row; {@see afterWrite} handles cache; fires action `yatra_trip_created` once per insert.
271 272 */
272 273 public function create(array $data): int
273 274 {
275 + $data = $this->dropUnsupportedDurationHours($data);
274 276 $id = parent::create($data);
275 277 do_action('yatra_trip_created', $id);
276 278
277 279 return $id;
@@ -277,12 +279,32 @@
277 279 return $id;
278 280 }
279 281
280 282 /**
283 + * Defensive: if the `duration_hours` column is somehow missing (a failed or
284 + * pending column-add migration on a restrictive host), drop it from the
285 + * write set so a normal trip save still succeeds instead of erroring on an
286 + * unknown column. The hour-based feature simply stays off until the column
287 + * exists — it never breaks saving.
288 + *
289 + * @param array<string, mixed> $data
290 + * @return array<string, mixed>
291 + */
292 + protected function dropUnsupportedDurationHours(array $data): array
293 + {
294 + if (array_key_exists('duration_hours', $data) && !$this->tripTableHasColumn('duration_hours')) {
295 + unset($data['duration_hours']);
296 + }
297 +
298 + return $data;
299 + }
300 +
301 + /**
281 302 * Override update method to provide proper field formats
282 303 */
283 304 public function update(int $id, array $data): bool
284 305 {
306 + $data = $this->dropUnsupportedDurationHours($data);
285 307 $data = $this->sanitizeData($data);
286 308 $data['updated_at'] = current_time('mysql');
287 309
288 310 // Build format array based on field types
@@ -293,9 +315,9 @@
293 315 } elseif (in_array($key, ['created_at', 'updated_at'], true)) {
294 316 $formats[] = '%s';
295 317 } elseif (in_array($key, ['original_price', 'discounted_price', 'sale_price', 'deposit_amount', 'deposit_percentage', 'avg_rating', 'revenue_total', 'conversion_rate'], true)) {
296 318 $formats[] = '%f';
297 - } elseif (in_array($key, ['transportation_included', 'is_featured', 'seasonal_auto_enable'], true)) {
319 + } elseif (in_array($key, ['transportation_included', 'is_featured', 'seasonal_auto_enable', 'has_default_time_slots'], true)) {
298 320 $formats[] = '%d'; // boolean as integer
299 321 } else {
300 322 $formats[] = '%s'; // default to string
301 323 }
@@ -400,8 +422,21 @@
400 422 return self::$tripColumnExistsCache[$column];
401 423 }
402 424
403 425 /**
426 + * Public, cached column-existence check for the trips table.
427 + *
428 + * Lets raw SELECTs outside this repository (booking confirmation join,
429 + * similar-trips query) add columns introduced by later upgrades — e.g.
430 + * `duration_hours` — without failing on an install whose ALTER has not
431 + * run yet (see InstallerService::maybeAddTripDurationHoursColumn()).
432 + */
433 + public function hasTripColumn(string $column): bool
434 + {
435 + return $this->tripTableHasColumn($column);
436 + }
437 +
438 + /**
404 439 * SQL expression for trip "current" list price (matches TripPricingService::resolveRegularCurrentPrice).
405 440 */
406 441 protected function sqlTripEffectiveListPrice(): string
407 442 {
@@ -481,8 +516,21 @@
481 516 $params[] = $filters['trip_type'];
482 517 }
483 518 }
484 519
520 + // Featured Priority (column on yatra_trips, indexed by idx_featured_priority).
521 + // Admin form's Featured Priority dropdown is the single source of truth.
522 + // Legacy `is_featured` shortcode/block flag is normalised upstream into featured_priority='featured'.
523 + if (
524 + !empty($filters['featured_priority'])
525 + && is_string($filters['featured_priority'])
526 + && in_array($filters['featured_priority'], ['featured', 'new', 'limited'], true)
527 + && $this->tripTableHasColumn('featured_priority')
528 + ) {
529 + $wheres[] = 't.featured_priority = %s';
530 + $params[] = $filters['featured_priority'];
531 + }
532 +
485 533 // Destination: checkbox classification IDs (OR) or single slug
486 534 if (!empty($filters['destination_ids']) && is_array($filters['destination_ids'])) {
487 535 $ids = array_values(array_filter(array_map('intval', $filters['destination_ids']), static fn (int $id): bool => $id > 0));
488 536 if ($ids !== []) {
@@ -492,13 +540,23 @@
492 540 $params[] = ClassificationTypes::DESTINATION;
493 541 $params = array_merge($params, $ids);
494 542 }
495 543 } elseif (!empty($filters['destination'])) {
496 - $joins[] = "LEFT JOIN {$tripClassificationsTable} tcd ON tcd.trip_id = t.id";
497 - $joins[] = "LEFT JOIN {$classificationsTable} dest ON dest.id = tcd.classification_id";
498 - $wheres[] = 'dest.type = %s AND dest.slug = %s';
499 - $params[] = ClassificationTypes::DESTINATION;
500 - $params[] = $filters['destination'];
544 + if (is_array($filters['destination'])) {
545 + $slugs = array_values(array_filter(array_map('sanitize_title', $filters['destination'])));
546 + if ($slugs !== []) {
547 + $placeholders = implode(',', array_fill(0, count($slugs), '%s'));
548 + $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}))";
549 + $params[] = ClassificationTypes::DESTINATION;
550 + $params = array_merge($params, $slugs);
551 + }
552 + } elseif (is_string($filters['destination']) && $filters['destination'] !== '') {
553 + $joins[] = "LEFT JOIN {$tripClassificationsTable} tcd ON tcd.trip_id = t.id";
554 + $joins[] = "LEFT JOIN {$classificationsTable} dest ON dest.id = tcd.classification_id";
555 + $wheres[] = 'dest.type = %s AND dest.slug = %s';
556 + $params[] = ClassificationTypes::DESTINATION;
557 + $params[] = $filters['destination'];
558 + }
501 559 }
502 560
503 561 // Activity: IDs or slug
504 562 if (!empty($filters['activity_ids']) && is_array($filters['activity_ids'])) {
@@ -509,13 +567,23 @@
509 567 $params[] = ClassificationTypes::ACTIVITY;
510 568 $params = array_merge($params, $ids);
511 569 }
512 570 } elseif (!empty($filters['activity'])) {
513 - $joins[] = "LEFT JOIN {$tripClassificationsTable} tca ON tca.trip_id = t.id";
514 - $joins[] = "LEFT JOIN {$classificationsTable} act ON act.id = tca.classification_id";
515 - $wheres[] = 'act.type = %s AND act.slug = %s';
516 - $params[] = ClassificationTypes::ACTIVITY;
517 - $params[] = $filters['activity'];
571 + if (is_array($filters['activity'])) {
572 + $slugs = array_values(array_filter(array_map('sanitize_title', $filters['activity'])));
573 + if ($slugs !== []) {
574 + $placeholders = implode(',', array_fill(0, count($slugs), '%s'));
575 + $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}))";
576 + $params[] = ClassificationTypes::ACTIVITY;
577 + $params = array_merge($params, $slugs);
578 + }
579 + } elseif (is_string($filters['activity']) && $filters['activity'] !== '') {
580 + $joins[] = "LEFT JOIN {$tripClassificationsTable} tca ON tca.trip_id = t.id";
581 + $joins[] = "LEFT JOIN {$classificationsTable} act ON act.id = tca.classification_id";
582 + $wheres[] = 'act.type = %s AND act.slug = %s';
583 + $params[] = ClassificationTypes::ACTIVITY;
584 + $params[] = $filters['activity'];
585 + }
518 586 }
519 587
520 588 // Category: IDs or slug
521 589 if (!empty($filters['category_ids']) && is_array($filters['category_ids'])) {
@@ -526,13 +594,23 @@
526 594 $params[] = ClassificationTypes::CATEGORY;
527 595 $params = array_merge($params, $ids);
528 596 }
529 597 } elseif (!empty($filters['trip_category'])) {
530 - $joins[] = "LEFT JOIN {$tripClassificationsTable} tcc ON tcc.trip_id = t.id";
531 - $joins[] = "LEFT JOIN {$classificationsTable} cat ON cat.id = tcc.classification_id";
532 - $wheres[] = 'cat.type = %s AND cat.slug = %s';
533 - $params[] = ClassificationTypes::CATEGORY;
534 - $params[] = $filters['trip_category'];
598 + if (is_array($filters['trip_category'])) {
599 + $slugs = array_values(array_filter(array_map('sanitize_title', $filters['trip_category'])));
600 + if ($slugs !== []) {
601 + $placeholders = implode(',', array_fill(0, count($slugs), '%s'));
602 + $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}))";
603 + $params[] = ClassificationTypes::CATEGORY;
604 + $params = array_merge($params, $slugs);
605 + }
606 + } elseif (is_string($filters['trip_category']) && $filters['trip_category'] !== '') {
607 + $joins[] = "LEFT JOIN {$tripClassificationsTable} tcc ON tcc.trip_id = t.id";
608 + $joins[] = "LEFT JOIN {$classificationsTable} cat ON cat.id = tcc.classification_id";
609 + $wheres[] = 'cat.type = %s AND cat.slug = %s';
610 + $params[] = ClassificationTypes::CATEGORY;
611 + $params[] = $filters['trip_category'];
612 + }
535 613 }
536 614
537 615 // Special offers (OR within group)
538 616 if (!empty($filters['special_offers']) && is_array($filters['special_offers'])) {
@@ -692,18 +770,71 @@
692 770 }
693 771 }
694 772 }
695 773
696 - // Price range filter (effective list price, not original-only)
774 + // Price range filter.
775 + //
776 + // The previous version compared a single legacy "effective"
777 + // price (discounted → sale → original column) against the
778 + // range. That excluded traveler-based trips whose real
779 + // displayed price lives in the `price_types` JSON — so eg. a
780 + // trip costing €3755 (Adult category) never appeared in search
781 + // when the user set max=€3755, because its legacy
782 + // original_price was €0 / stale.
783 + //
784 + // New approach: match a trip if EITHER the legacy effective
785 + // price OR ANY per-category price in the JSON falls in the
786 + // requested range. Numeric values in the `price_types` JSON
787 + // are extracted with a regex on the raw text — works in all
788 + // MySQL 5.7+ builds without needing JSON_VALUE / JSON_TABLE
789 + // (which are inconsistent across MariaDB / older MySQL).
697 790 $effPrice = $this->sqlTripEffectiveListPrice();
698 - if (!empty($filters['price_min']) && $filters['price_min'] > 0) {
699 - $wheres[] = "{$effPrice} >= %f";
700 - $params[] = $filters['price_min'];
701 - }
791 + $priceMin = !empty($filters['price_min']) && $filters['price_min'] > 0 ? (float) $filters['price_min'] : null;
792 + $priceMax = !empty($filters['price_max']) && $filters['price_max'] > 0 ? (float) $filters['price_max'] : null;
702 793
703 - if (!empty($filters['price_max']) && $filters['price_max'] > 0) {
704 - $wheres[] = "{$effPrice} <= %f";
705 - $params[] = $filters['price_max'];
794 + if ($priceMin !== null || $priceMax !== null) {
795 + $legacyCond = [];
796 + if ($priceMin !== null) { $legacyCond[] = "{$effPrice} >= %f"; $params[] = $priceMin; }
797 + if ($priceMax !== null) { $legacyCond[] = "{$effPrice} <= %f"; $params[] = $priceMax; }
798 + $legacyClause = implode(' AND ', $legacyCond);
799 +
800 + // For per-category pricing, find ANY price in `price_types`
801 + // JSON that falls in the requested range. This subquery
802 + // creates an ad-hoc number sequence (n=0..49 covers up to
803 + // 50 categories — far more than any real trip uses) and
804 + // uses JSON_EXTRACT to fetch each entry's effective price.
805 + $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)";
806 + // Each category's effective price = first non-null of
807 + // discounted_price → sale_price → original_price → price.
808 + $categoryEff = "COALESCE("
809 + . "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].discounted_price'))) AS DECIMAL(10,2)), 0),"
810 + . "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].sale_price'))) AS DECIMAL(10,2)), 0),"
811 + . "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].original_price'))) AS DECIMAL(10,2)), 0),"
812 + . "NULLIF(CAST(JSON_UNQUOTE(JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, '].price'))) AS DECIMAL(10,2)), 0)"
813 + . ")";
814 + $catCond = [];
815 + if ($priceMin !== null) { $catCond[] = "{$categoryEff} >= %f"; $params[] = $priceMin; }
816 + if ($priceMax !== null) { $catCond[] = "{$categoryEff} <= %f"; $params[] = $priceMax; }
817 + $catClause = implode(' AND ', $catCond);
818 +
819 + // Guard the per-category branch against malformed JSON.
820 + // JSON_EXTRACT throws on invalid JSON, and `price_types`
821 + // can be NULL, '', '[]' or a stale string on legacy rows.
822 + // JSON_VALID() + IFNULL() lets us bail cleanly for those.
823 + $perCategoryExists = "("
824 + . "t.price_types IS NOT NULL"
825 + . " AND t.price_types <> ''"
826 + . " AND JSON_VALID(t.price_types) = 1"
827 + . " AND EXISTS ("
828 + . "SELECT 1 FROM {$numbers} _n"
829 + . " WHERE JSON_EXTRACT(t.price_types, CONCAT('$[', _n.n, ']')) IS NOT NULL"
830 + . " AND {$catClause}"
831 + . ")"
832 + . ")";
833 +
834 + // Match if EITHER condition passes — a single legacy-priced
835 + // trip OR a per-category trip with any matching tier.
836 + $wheres[] = "(({$legacyClause}) OR {$perCategoryExists})";
706 837 }
707 838
708 839 // Duration filter
709 840 if (!empty($filters['duration_min']) && $filters['duration_min'] > 0) {
@@ -714,9 +845,29 @@
714 845 if (!empty($filters['duration_max']) && $filters['duration_max'] > 0) {
715 846 $wheres[] = "CAST(t.duration_days AS UNSIGNED) <= %d";
716 847 $params[] = $filters['duration_max'];
717 848 }
718 -
849 +
850 + // Availability date filter ("available from this date onward"): keep only
851 + // trips that have a departure starting ON OR AFTER the selected date, so
852 + // a customer who picks a date sees every trip they could still travel on
853 + // from then. A departure matches when its effective start date (the
854 + // explicit start_date, else the single `date`) is >= the chosen day and
855 + // it has not been cancelled — the date bound alone excludes past dates
856 + // when the picker's min=today is respected, so no reliance on a possibly
857 + // stale 'past' status. Clause + param are appended together so the WHERE
858 + // ordering stays in sync with $params (see the count/main queries).
859 + if (!empty($filters['available_date'])) {
860 + $departures_table = DeparturesTable::getTableName();
861 + $wheres[] = "EXISTS (
862 + SELECT 1 FROM {$departures_table} yd
863 + WHERE yd.trip_id = t.id
864 + AND COALESCE(NULLIF(yd.start_date, '0000-00-00'), yd.date) >= %s
865 + AND yd.status <> 'cancelled'
866 + )";
867 + $params[] = $filters['available_date'];
868 + }
869 +
719 870 // Rating filter
720 871 if (!empty($filters['rating_min']) && $filters['rating_min'] > 0) {
721 872 $having_clauses[] = "AVG(r.rating) >= %f";
722 873 $rating_params[] = $filters['rating_min'];
@@ -823,8 +974,10 @@
823 974 case 'price_high':
824 975 return "ORDER BY {$effPrice} DESC";
825 976 case 'rating_high':
826 977 return "ORDER BY average_rating DESC";
978 + case 'date_asc':
979 + return "ORDER BY t.created_at ASC";
827 980 case 'duration_short':
828 981 return "ORDER BY CAST(t.duration_days AS UNSIGNED) ASC, CAST(t.duration_nights AS UNSIGNED) ASC";
829 982 case 'duration_long':
830 983 return "ORDER BY CAST(t.duration_days AS UNSIGNED) DESC, CAST(t.duration_nights AS UNSIGNED) DESC";
@@ -1666,8 +1819,28 @@
1666 1819 */
1667 1820 public function savePriceTypes(int $tripId, array $priceTypes): void
1668 1821 {
1669 1822 if (empty($priceTypes)) {
1823 + // Explicitly clear stored per-category pricing so that deleting all
1824 + // rows (even the last one) persists. Previously this early-returned,
1825 + // leaving the old price_types JSON in place — the deletion silently
1826 + // reverted on reload. Only the JSON is always cleared; the derived
1827 + // scalar columns (original/discounted/sale) are reset solely for
1828 + // traveler-based trips, because for regular pricing those columns
1829 + // hold the actual price and must not be wiped.
1830 + $table = TripsTable::getTableName();
1831 + $pricingType = $this->wpdb->get_var(
1832 + $this->wpdb->prepare("SELECT pricing_type FROM `{$table}` WHERE id = %d", $tripId)
1833 + );
1834 +
1835 + $data = ['price_types' => null];
1836 + if ($pricingType === 'traveler_based') {
1837 + $data['original_price'] = null;
1838 + $data['discounted_price'] = null;
1839 + $data['sale_price'] = null;
1840 + }
1841 +
1842 + $this->wpdb->update($table, $data, ['id' => $tripId]);
1670 1843 return;
1671 1844 }
1672 1845
1673 1846 // Ensure at most one default category is set (keep the first truthy one).
@@ -2045,45 +2218,165 @@
2045 2218 ));
2046 2219 }
2047 2220
2048 2221 /**
2049 - * Save availability dates for a trip
2222 + * Save availability dates for a trip.
2223 + *
2224 + * Previously a flat DELETE-THEN-INSERT: every row for the trip was nuked
2225 + * and the incoming list rewritten. That made the trip-edit save lossy —
2226 + * when the Trip form only knew about a subset of fields on each date
2227 + * (e.g. it was loaded once, then the operator changed the seats on a
2228 + * single date through the Availability tab, then re-saved the Trip with
2229 + * the form's stale date payload), every field absent from the incoming
2230 + * row was reset to its hard-coded default. That's why operator edits
2231 + * to `seats_total` silently reverted to 20 after another trip-save:
2232 + *
2233 + * 1. Operator opens Trip → form loads `availability_dates` with
2234 + * `seats_total = 20`.
2235 + * 2. Operator switches to Availability tab → edits Sept 5 to
2236 + * `seats_total = 14` via the dedicated specific-date endpoint
2237 + * (writes directly to the row, so the row is now 14).
2238 + * 3. Operator returns to the Trip form, edits something unrelated
2239 + * (title / description), clicks Save. The form re-posts the date
2240 + * list it loaded in step 1 — `seats_total = 20`.
2241 + * 4. saveAvailabilityDates DELETEs everything, INSERTs the
2242 + * form-supplied list → Sept 5 is back to 20 (or the literal `20`
2243 + * default if the form didn't include seats_total at all).
2244 + *
2245 + * Fix: snapshot the existing rows before the DELETE and, when the
2246 + * incoming row omits an editable field, fall back to the snapshot's
2247 + * value instead of the schema default. The literal `20` default is
2248 + * removed in favour of the trip's `max_travelers` (or 1 as a last
2249 + * resort) so new dates inserted by truly-new entries don't pretend the
2250 + * trip seats 20 people unless the trip actually says so.
2251 + *
2252 + * Match key for the snapshot: `departure_date` + `departure_time` (both
2253 + * normalised), which is the same identity the Availability UI uses.
2050 2254 */
2051 2255 public function saveAvailabilityDates(int $tripId, array $availabilityDates): void
2052 2256 {
2053 2257 global $wpdb;
2054 2258 $table = TripAvailabilityDatesTable::getTableName();
2055 -
2259 +
2260 + // Snapshot existing rows by date + time so partial incoming payloads
2261 + // can be merged with persisted values instead of clobbering them.
2262 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from schema helper.
2263 + $existingRows = $wpdb->get_results($wpdb->prepare(
2264 + "SELECT * FROM {$table} WHERE trip_id = %d",
2265 + $tripId
2266 + ));
2267 + $snapshot = [];
2268 + if (is_array($existingRows)) {
2269 + foreach ($existingRows as $row) {
2270 + $key = self::availabilityRowKey(
2271 + (string) ($row->departure_date ?? ''),
2272 + (string) ($row->departure_time ?? '')
2273 + );
2274 + if ($key !== '') {
2275 + $snapshot[$key] = $row;
2276 + }
2277 + }
2278 + }
2279 +
2280 + // Fallback capacity when both the incoming row and the snapshot
2281 + // miss seats_total: read the trip's max_travelers once. The
2282 + // legacy literal `20` was a phantom default that punished sites
2283 + // whose actual trip capacity differs.
2284 + $tripFallbackSeats = 0;
2285 + $tripRow = $this->find($tripId);
2286 + if (is_object($tripRow) && isset($tripRow->max_travelers)) {
2287 + $tripFallbackSeats = (int) $tripRow->max_travelers;
2288 + }
2289 + if ($tripFallbackSeats <= 0) {
2290 + $tripFallbackSeats = 1;
2291 + }
2292 +
2056 2293 // Delete existing
2057 2294 $wpdb->delete($table, ['trip_id' => $tripId], ['%d']);
2058 -
2295 +
2059 2296 // Insert new
2060 2297 if (!empty($availabilityDates)) {
2061 2298 foreach ($availabilityDates as $date) {
2062 2299 if (is_array($date) && !empty($date['departure_date'])) {
2063 - $seatsTotal = isset($date['seats_total']) ? (int) $date['seats_total'] : 20;
2064 - $seatsAvailable = isset($date['seats_available']) ? (int) $date['seats_available'] : $seatsTotal;
2065 -
2300 + $key = self::availabilityRowKey(
2301 + (string) $date['departure_date'],
2302 + (string) ($date['departure_time'] ?? '')
2303 + );
2304 + $existing = $key !== '' && isset($snapshot[$key]) ? $snapshot[$key] : null;
2305 +
2306 + $seatsTotal = isset($date['seats_total'])
2307 + ? (int) $date['seats_total']
2308 + : ($existing ? (int) ($existing->seats_total ?? $tripFallbackSeats) : $tripFallbackSeats);
2309 + $seatsAvailable = isset($date['seats_available'])
2310 + ? (int) $date['seats_available']
2311 + : ($existing ? (int) ($existing->seats_available ?? $seatsTotal) : $seatsTotal);
2312 +
2313 + $arrivalDate = isset($date['arrival_date'])
2314 + ? sanitize_text_field($date['arrival_date'])
2315 + : (isset($date['return_date'])
2316 + ? sanitize_text_field($date['return_date'])
2317 + : ($existing ? $existing->arrival_date : null));
2318 + $returnDate = isset($date['return_date'])
2319 + ? sanitize_text_field($date['return_date'])
2320 + : ($existing ? $existing->return_date : null);
2321 + $arrivalTime = isset($date['arrival_time'])
2322 + ? sanitize_text_field($date['arrival_time'])
2323 + : ($existing ? $existing->arrival_time : null);
2324 +
2325 + $originalPrice = isset($date['original_price'])
2326 + ? (float) $date['original_price']
2327 + : (isset($date['price_override'])
2328 + ? (float) $date['price_override']
2329 + : ($existing && $existing->original_price !== null ? (float) $existing->original_price : null));
2330 + $discountedPrice = isset($date['discounted_price'])
2331 + ? (float) $date['discounted_price']
2332 + : ($existing && $existing->discounted_price !== null ? (float) $existing->discounted_price : null);
2333 +
2334 + $fromLocation = isset($date['from_location']) ? sanitize_text_field($date['from_location']) : ($existing ? $existing->from_location : null);
2335 + $toLocation = isset($date['to_location']) ? sanitize_text_field($date['to_location']) : ($existing ? $existing->to_location : null);
2336 + $fromLat = isset($date['from_latitude']) && is_numeric($date['from_latitude'])
2337 + ? (string) $date['from_latitude']
2338 + : ($existing ? $existing->from_latitude : null);
2339 + $fromLng = isset($date['from_longitude']) && is_numeric($date['from_longitude'])
2340 + ? (string) $date['from_longitude']
2341 + : ($existing ? $existing->from_longitude : null);
2342 + $toLat = isset($date['to_latitude']) && is_numeric($date['to_latitude'])
2343 + ? (string) $date['to_latitude']
2344 + : ($existing ? $existing->to_latitude : null);
2345 + $toLng = isset($date['to_longitude']) && is_numeric($date['to_longitude'])
2346 + ? (string) $date['to_longitude']
2347 + : ($existing ? $existing->to_longitude : null);
2348 +
2349 + if (isset($date['is_blackout'])) {
2350 + $status = $date['is_blackout'] ? 'blocked' : 'available';
2351 + } elseif (isset($date['status'])) {
2352 + $status = sanitize_text_field($date['status']);
2353 + } elseif ($existing) {
2354 + $status = (string) ($existing->status ?? 'available');
2355 + } else {
2356 + $status = 'available';
2357 + }
2358 +
2066 2359 $insertData = [
2067 2360 'trip_id' => $tripId,
2068 2361 'departure_date' => sanitize_text_field($date['departure_date']),
2069 - 'arrival_date' => isset($date['arrival_date']) ? sanitize_text_field($date['arrival_date']) : ($date['return_date'] ?? null),
2070 - 'return_date' => isset($date['return_date']) ? sanitize_text_field($date['return_date']) : null,
2071 - 'departure_time' => isset($date['departure_time']) ? sanitize_text_field($date['departure_time']) : null,
2072 - 'arrival_time' => isset($date['arrival_time']) ? sanitize_text_field($date['arrival_time']) : null,
2362 + 'arrival_date' => $arrivalDate,
2363 + 'return_date' => $returnDate,
2364 + 'departure_time' => isset($date['departure_time']) ? sanitize_text_field($date['departure_time']) : ($existing ? $existing->departure_time : null),
2365 + 'arrival_time' => $arrivalTime,
2073 2366 'seats_total' => $seatsTotal,
2074 2367 'seats_available' => $seatsAvailable,
2075 - 'original_price' => isset($date['original_price']) ? (float) $date['original_price'] : (isset($date['price_override']) ? (float) $date['price_override'] : null),
2076 - 'discounted_price' => isset($date['discounted_price']) ? (float) $date['discounted_price'] : null,
2077 - 'from_location' => isset($date['from_location']) ? sanitize_text_field($date['from_location']) : null,
2078 - 'to_location' => isset($date['to_location']) ? sanitize_text_field($date['to_location']) : null,
2079 - 'from_latitude' => isset($date['from_latitude']) && is_numeric($date['from_latitude']) ? (string) $date['from_latitude'] : null,
2080 - 'from_longitude' => isset($date['from_longitude']) && is_numeric($date['from_longitude']) ? (string) $date['from_longitude'] : null,
2081 - 'to_latitude' => isset($date['to_latitude']) && is_numeric($date['to_latitude']) ? (string) $date['to_latitude'] : null,
2082 - 'to_longitude' => isset($date['to_longitude']) && is_numeric($date['to_longitude']) ? (string) $date['to_longitude'] : null,
2083 - 'status' => isset($date['is_blackout']) && $date['is_blackout'] ? 'blocked' : (isset($date['status']) ? sanitize_text_field($date['status']) : 'available'),
2368 + 'original_price' => $originalPrice,
2369 + 'discounted_price' => $discountedPrice,
2370 + 'from_location' => $fromLocation,
2371 + 'to_location' => $toLocation,
2372 + 'from_latitude' => $fromLat,
2373 + 'from_longitude' => $fromLng,
2374 + 'to_latitude' => $toLat,
2375 + 'to_longitude' => $toLng,
2376 + 'status' => $status,
2084 2377 ];
2085 -
2378 +
2086 2379 $wpdb->insert(
2087 2380 $table,
2088 2381 $insertData,
2089 2382 ['%d', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s']
@@ -2093,8 +2386,23 @@
2093 2386 }
2094 2387 }
2095 2388
2096 2389 /**
2390 + * Normalise a (date, time) pair to a single string key used to match
2391 + * incoming availability rows against the pre-delete snapshot. Time is
2392 + * left as-is for an exact comparison; empty time matches the "no
2393 + * specific time" row.
2394 + */
2395 + private static function availabilityRowKey(string $date, string $time): string
2396 + {
2397 + $date = trim($date);
2398 + if ($date === '') {
2399 + return '';
2400 + }
2401 + return $date . '|' . trim($time);
2402 + }
2403 +
2404 + /**
2097 2405 * Save attributes for a trip
2098 2406 */
2099 2407 public function saveAttributes(int $tripId, array $attributes): void
2100 2408 {
@@ -2657,34 +2965,229 @@
2657 2965 ));
2658 2966 }
2659 2967
2660 2968 /**
2661 - * Get price range statistics for published trips
2662 - *
2969 + * Get price range statistics for published trips.
2970 + *
2971 + * IMPORTANT: the previous SQL-only implementation only considered
2972 + * the legacy `original_price` / `discounted_price` / `sale_price`
2973 + * columns. For trips using traveler-based pricing (per-category
2974 + * prices stored in the `price_types` JSON column), those legacy
2975 + * columns are often empty or stale — so the computed max was lower
2976 + * than the actually-displayed price, and the price-range slider
2977 + * cut off above the real maximum (eg. trip cost €3755 but slider
2978 + * stopped at €3731). Worse, the trip then couldn't be filtered
2979 + * into the results because the effective-price WHERE excluded it.
2980 + *
2981 + * Fix: walk the published trips once in PHP and let
2982 + * `TripPricingService` decide each trip's effective price using
2983 + * the same logic the listing/single-trip pages use to DISPLAY the
2984 + * price. We also track the per-trip MIN/MAX across categories so
2985 + * the slider bounds cover every traveler tier (not only the
2986 + * default / cheapest).
2987 + *
2663 2988 * @return object Object with min_price and max_price properties
2664 2989 */
2665 2990 public function getPriceRangeStats(): object
2666 2991 {
2667 2992 $table = $this->getTableName();
2668 - return $this->wpdb->get_row(
2669 - "SELECT
2670 - MIN(sub.eff_price) as min_price,
2671 - MAX(sub.eff_price) as max_price
2672 - FROM (
2673 - SELECT (CASE
2674 - WHEN CAST(discounted_price AS DECIMAL(10,2)) > 0 THEN CAST(discounted_price AS DECIMAL(10,2))
2675 - WHEN CAST(sale_price AS DECIMAL(10,2)) > 0 THEN CAST(sale_price AS DECIMAL(10,2))
2676 - ELSE CAST(original_price AS DECIMAL(10,2))
2677 - END) AS eff_price
2678 - FROM {$table}
2679 - WHERE status IN ('publish', 'published')
2680 - AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')
2681 - ) sub
2682 - WHERE sub.eff_price > 0"
2683 - );
2993 + $rows = $this->wpdb->get_results(
2994 + "SELECT id, original_price, discounted_price, sale_price, price_types
2995 + FROM {$table}
2996 + WHERE status IN ('publish', 'published')
2997 + AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')"
2998 + ) ?: [];
2999 +
3000 + $minPrice = null;
3001 + $maxPrice = null;
3002 +
3003 + foreach ($rows as $row) {
3004 + $tripPrices = $this->collectTripDisplayPrices($row);
3005 + foreach ($tripPrices as $price) {
3006 + if ($price <= 0) {
3007 + continue;
3008 + }
3009 + if ($minPrice === null || $price < $minPrice) {
3010 + $minPrice = $price;
3011 + }
3012 + if ($maxPrice === null || $price > $maxPrice) {
3013 + $maxPrice = $price;
3014 + }
3015 + }
3016 + }
3017 +
3018 + // Fold in per-date / per-rule / per-departure price overrides so the
3019 + // slider bounds span every price a customer can actually be charged,
3020 + // not just the trips-table base. See collectPriceOverridePoints().
3021 + foreach ($this->collectPriceOverridePoints() as $price) {
3022 + if ($price <= 0) {
3023 + continue;
3024 + }
3025 + if ($minPrice === null || $price < $minPrice) {
3026 + $minPrice = $price;
3027 + }
3028 + if ($maxPrice === null || $price > $maxPrice) {
3029 + $maxPrice = $price;
3030 + }
3031 + }
3032 +
3033 + return (object) [
3034 + 'min_price' => $minPrice,
3035 + 'max_price' => $maxPrice,
3036 + ];
2684 3037 }
2685 3038
2686 3039 /**
3040 + * Collect every price a single trip might display to a user.
3041 + *
3042 + * Returns BOTH the legacy "regular" effective price AND every
3043 + * per-category effective price from `price_types`. Used by
3044 + * `getPriceRangeStats()` so the price-range slider's bounds cover
3045 + * the full range a customer could see — eg. for a traveler-based
3046 + * trip with Adult €3755 / Child €1500 / Infant €100, the array
3047 + * includes all three so the slider stretches from €100 to €3755.
3048 + *
3049 + * @param object $row Raw trip row (must include `price_types`,
3050 + * `original_price`, `discounted_price`,
3051 + * `sale_price`).
3052 + * @return float[]
3053 + */
3054 + protected function collectTripDisplayPrices(object $row): array
3055 + {
3056 + $prices = [];
3057 +
3058 + // Legacy regular pricing.
3059 + $legacyPrice = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($row);
3060 + if ($legacyPrice > 0) {
3061 + $prices[] = $legacyPrice;
3062 + }
3063 +
3064 + // Per-category prices (traveler-based pricing). Decode whether
3065 + // `price_types` is stored as a JSON string (raw DB row) or an
3066 + // already-decoded array (in-memory trip object).
3067 + $priceTypes = $row->price_types ?? null;
3068 + if (is_string($priceTypes) && $priceTypes !== '') {
3069 + $decoded = json_decode($priceTypes, true);
3070 + if (is_array($decoded)) {
3071 + $priceTypes = $decoded;
3072 + }
3073 + }
3074 + if (is_array($priceTypes)) {
3075 + foreach ($priceTypes as $pt) {
3076 + $price = \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt);
3077 + if ($price > 0) {
3078 + $prices[] = $price;
3079 + }
3080 + }
3081 + }
3082 +
3083 + return $prices;
3084 + }
3085 +
3086 + /**
3087 + * Effective price points that OVERRIDE a trip's base price for specific
3088 + * dates, recurring rules, or individual departures.
3089 + *
3090 + * The price a customer actually sees, filters on, and pays is not always
3091 + * the trips-table base price: it can be overridden per
3092 + * - specific availability date (yatra_trip_availability_dates)
3093 + * - recurring availability rule (yatra_trip_availability_rules)
3094 + * - individual departure (yatra_trip_departures)
3095 + * each carrying its own original/discounted price and per-category
3096 + * (traveler-based) pricing JSON. The price-range slider bounds must span
3097 + * these too — otherwise a trip whose highest bookable price lives only on
3098 + * an override (e.g. a peak-season date priced €3,055 while the base is
3099 + * lower) is unreachable on the filter even though customers can book it.
3100 + *
3101 + * Effective-price semantics mirror the customer-facing display exactly by
3102 + * reusing collectTripDisplayPrices(): the discounted/sale price when set,
3103 + * otherwise the original — the same amount shown and charged. Non-bookable
3104 + * rows (blocked / cancelled / past dates, inactive rules) are excluded so
3105 + * they can't push the slider max beyond any price a customer can reach.
3106 + *
3107 + * @return array<int,float> Effective override prices (each > 0).
3108 + */
3109 + protected function collectPriceOverridePoints(): array
3110 + {
3111 + $prices = [];
3112 + $today = current_time('Y-m-d');
3113 +
3114 + // Only overrides that belong to a genuinely available (published, not
3115 + // soft-deleted) trip may influence the bounds — otherwise a stray
3116 + // override on a draft/trashed trip leaks a phantom max that no
3117 + // customer-visible trip carries. Mirrors the trips-table filter the
3118 + // base bound uses.
3119 + $tripsTable = $this->getTableName();
3120 + $publishedJoin = "INNER JOIN {$tripsTable} t ON t.id = o.trip_id"
3121 + . " AND t.status IN ('publish', 'published')"
3122 + . " AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')";
3123 +
3124 + // 1) Specific availability dates — same column shape as a trip row, so
3125 + // collectTripDisplayPrices() resolves them identically.
3126 + $datesTable = \Yatra\Database\Tables\TripAvailabilityDatesTable::getTableName();
3127 + $dateRows = $this->wpdb->get_results($this->wpdb->prepare(
3128 + "SELECT o.original_price, o.discounted_price, NULL AS sale_price, o.price_types
3129 + FROM {$datesTable} o
3130 + {$publishedJoin}
3131 + WHERE COALESCE(o.is_blocked, 0) = 0
3132 + AND o.status NOT IN ('cancelled', 'blocked', 'closed')
3133 + AND (o.departure_date IS NULL OR o.departure_date >= %s)",
3134 + $today
3135 + )) ?: [];
3136 + foreach ($dateRows as $row) {
3137 + foreach ($this->collectTripDisplayPrices($row) as $p) {
3138 + $prices[] = $p;
3139 + }
3140 + }
3141 +
3142 + // 2) Recurring rules (active only — mirrors the `status = 'active'`
3143 + // filter RecurringAvailabilityRepository uses when generating
3144 + // availability). `traveler_pricing` holds the per-category JSON; a
3145 + // fixed `price_override` is an absolute per-booking price. A
3146 + // percentage override is a relative adjustment to the trip base —
3147 + // already spanned by the base bound — so it adds no new absolute max.
3148 + $rulesTable = \Yatra\Database\Tables\TripAvailabilityRulesTable::getTableName();
3149 + $ruleRows = $this->wpdb->get_results(
3150 + "SELECT o.original_price, o.sale_price, NULL AS discounted_price,
3151 + o.price_override, o.price_type, o.traveler_pricing AS price_types
3152 + FROM {$rulesTable} o
3153 + {$publishedJoin}
3154 + WHERE o.status = 'active'"
3155 + ) ?: [];
3156 + foreach ($ruleRows as $row) {
3157 + foreach ($this->collectTripDisplayPrices($row) as $p) {
3158 + $prices[] = $p;
3159 + }
3160 + if ($row->price_override !== null && (string) $row->price_type !== 'percentage') {
3161 + $po = (float) $row->price_override;
3162 + if ($po > 0) {
3163 + $prices[] = $po;
3164 + }
3165 + }
3166 + }
3167 +
3168 + // 3) Departures — `price_override` is an absolute price;
3169 + // `price_by_traveler_type` is the per-category JSON.
3170 + $depTable = \Yatra\Database\Tables\DeparturesTable::getTableName();
3171 + $depRows = $this->wpdb->get_results($this->wpdb->prepare(
3172 + "SELECT o.price_override AS original_price, NULL AS discounted_price,
3173 + NULL AS sale_price, o.price_by_traveler_type AS price_types
3174 + FROM {$depTable} o
3175 + {$publishedJoin}
3176 + WHERE o.status NOT IN ('cancelled')
3177 + AND (COALESCE(o.start_date, o.`date`) IS NULL OR COALESCE(o.start_date, o.`date`) >= %s)",
3178 + $today
3179 + )) ?: [];
3180 + foreach ($depRows as $row) {
3181 + foreach ($this->collectTripDisplayPrices($row) as $p) {
3182 + $prices[] = $p;
3183 + }
3184 + }
3185 +
3186 + return $prices;
3187 + }
3188 +
3189 + /**
2687 3190 * Count trips by difficulty level
2688 3191 *
2689 3192 * @param int $difficultyLevelId Difficulty level ID
2690 3193 * @return int Number of trips with this difficulty level
@@ -2846,35 +3349,78 @@
2846 3349 * Get price statistics for filter sidebar
2847 3350 */
2848 3351 public function getPriceStats(): ?object
2849 3352 {
2850 - global $wpdb;
2851 -
2852 3353 $table = $this->getTableName();
2853 -
2854 - $result = $wpdb->get_row("
2855 - SELECT
2856 - MIN(sub.eff_price) as min_price,
2857 - MAX(sub.eff_price) as max_price,
2858 - AVG(sub.eff_price) as avg_price
2859 - FROM (
2860 - SELECT (CASE
2861 - WHEN CAST(discounted_price AS DECIMAL(10,2)) > 0 THEN CAST(discounted_price AS DECIMAL(10,2))
2862 - WHEN CAST(sale_price AS DECIMAL(10,2)) > 0 THEN CAST(sale_price AS DECIMAL(10,2))
2863 - ELSE CAST(original_price AS DECIMAL(10,2))
2864 - END) AS eff_price
2865 - FROM {$table}
2866 - WHERE status IN ('publish', 'published')
2867 - AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')
2868 - ) sub
2869 - WHERE sub.eff_price > 0
2870 - ");
2871 -
2872 - return $result ? (object) [
2873 - 'min_price' => (float) $result->min_price,
2874 - 'max_price' => (float) $result->max_price,
2875 - 'avg_price' => (float) $result->avg_price
2876 - ] : null;
3354 +
3355 + // Bounds must cover EVERY price a customer can actually filter on.
3356 + // The search filter matches a trip if its legacy effective price OR
3357 + // ANY per-category price in `price_types` falls in range (see the
3358 + // price clause in the listing query), so the slider's min/max have to
3359 + // span the same set — otherwise a traveler-based trip whose highest
3360 + // tier (eg. Adult €3,055) lives only in `price_types` becomes
3361 + // unreachable when the column-only max stops short (eg. €3,045).
3362 + //
3363 + // Reuse `collectTripDisplayPrices()` — the single source of truth also
3364 + // used by `getPriceRangeStats()` and the listing/single-trip DISPLAY —
3365 + // so bound == display == filter for both regular and traveler-based
3366 + // pricing.
3367 + $rows = $this->wpdb->get_results(
3368 + "SELECT id, original_price, discounted_price, sale_price, price_types
3369 + FROM {$table}
3370 + WHERE status IN ('publish', 'published')
3371 + AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')"
3372 + ) ?: [];
3373 +
3374 + $minPrice = null;
3375 + $maxPrice = null;
3376 + $sum = 0.0;
3377 + $count = 0;
3378 +
3379 + foreach ($rows as $row) {
3380 + foreach ($this->collectTripDisplayPrices($row) as $price) {
3381 + if ($price <= 0) {
3382 + continue;
3383 + }
3384 + if ($minPrice === null || $price < $minPrice) {
3385 + $minPrice = $price;
3386 + }
3387 + if ($maxPrice === null || $price > $maxPrice) {
3388 + $maxPrice = $price;
3389 + }
3390 + $sum += $price;
3391 + $count++;
3392 + }
3393 + }
3394 +
3395 + // Fold in per-date / per-rule / per-departure price overrides so the
3396 + // slider spans every price a customer can actually be charged (bounds
3397 + // only — the average stays a base-trip figure). See
3398 + // collectPriceOverridePoints().
3399 + foreach ($this->collectPriceOverridePoints() as $price) {
3400 + if ($price <= 0) {
3401 + continue;
3402 + }
3403 + if ($minPrice === null || $price < $minPrice) {
3404 + $minPrice = $price;
3405 + }
3406 + if ($maxPrice === null || $price > $maxPrice) {
3407 + $maxPrice = $price;
3408 + }
3409 + }
3410 +
3411 + if ($minPrice === null || $maxPrice === null) {
3412 + return null;
3413 + }
3414 +
3415 + // Widen to whole units so the template's `(int)` cast on the bounds
3416 + // can never truncate a fractional boundary out of range (eg. a
3417 + // €3,055.50 max would otherwise int-cast to 3055 and exclude it).
3418 + return (object) [
3419 + 'min_price' => (float) floor($minPrice),
3420 + 'max_price' => (float) ceil($maxPrice),
3421 + 'avg_price' => $count > 0 ? (float) ($sum / $count) : 0.0,
3422 + ];
2877 3423 }
2878 3424
2879 3425 /**
2880 3426 * Distinct accommodation_type values on published trips with counts.