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/Services/AvailabilityResolutionService.php +118 -3 3.0.6 → 3.0.16 View file →
@@ -61,8 +61,37 @@
61 61 if (!$trip) {
62 62 throw new \Exception('Trip not found');
63 63 }
64 64
65 + // Priority 0: A non-bookable specific row (blocked/closed/cancelled/unavailable)
66 + // must win over everything so the booking guard rejects it. The standard lookup
67 + // below hides those rows by design (status IN available/limited), which would let
68 + // the resolver fall through to a recurring rule / trip default = "available"
69 + // and silently allow the booking. We therefore look the row up including any
70 + // status and short-circuit on the guard's reject statuses.
71 + // `sold_out` belongs here for the same reason. The inventory hook marks a
72 + // full date sold_out WITHOUT setting is_blocked, and the lookup below skips
73 + // it too, so the resolver fell through to a rule / trip default reporting
74 + // free seats — the guard then allowed a booking on a sold-out date and the
75 + // waitlist never engaged. Surfacing the real status lets the guard's
76 + // existing sold_out branch decide (reject, or offer the waitlist).
77 + $anyStatusRow = $this->availabilityRepository->findByTripIdAndDateTime($tripId, $date, $departureTime, true);
78 + if ($anyStatusRow && (\in_array(($anyStatusRow->status ?? ''), ['blocked', 'closed', 'cancelled', 'unavailable'], true) || !empty($anyStatusRow->is_blocked))) {
79 + return $this->buildAvailabilityObject($trip, $anyStatusRow, 'availability_date');
80 + }
81 +
82 + // A sold-out row only wins while it genuinely has no seats. Gating on the
83 + // seat count rather than the status alone means a stale `sold_out` row that
84 + // has since freed up (cancellation before the hook recalculated it) keeps
85 + // falling through as it does today, so this can never block a bookable date.
86 + if (
87 + $anyStatusRow
88 + && ($anyStatusRow->status ?? '') === 'sold_out'
89 + && (int) ($anyStatusRow->seats_available ?? 0) <= 0
90 + ) {
91 + return $this->buildAvailabilityObject($trip, $anyStatusRow, 'availability_date');
92 + }
93 +
65 94 // Priority 1: Specific availability rows (sold_out, seats, blocks, price overrides)
66 95 $availabilityDate = $this->availabilityRepository->findByTripIdAndDateTime($tripId, $date, $departureTime);
67 96 if ($availabilityDate) {
68 97 return $this->buildAvailabilityObject($trip, $availabilityDate, 'availability_date');
@@ -73,8 +102,29 @@
73 102 if ($recurring !== null) {
74 103 return $this->buildAvailabilityObject($trip, $recurring, 'recurring_rule');
75 104 }
76 105
106 + // Priority 2.5: A date the operator explicitly excluded on a recurring
107 + // rule — a holiday, or a period such as a business vacation.
108 + //
109 + // Without this the date matches no rule, falls through to the trip
110 + // default below and reports free seats, so the booking guard allows a
111 + // booking on a day the operator has closed. Same failure mode the
112 + // blocked/sold_out short-circuits above already guard against.
113 + //
114 + // Deliberately narrow: it only suppresses the trip-default fallback. An
115 + // explicit availability row or another rule that does generate this date
116 + // has already returned above, so this can never close a date the
117 + // operator opened by other means.
118 + if ($this->recurringAvailabilityService->isDateExcludedForTrip($tripId, $date)) {
119 + $excluded = $this->buildAvailabilityObject($trip, null, 'trip_default');
120 + $excluded->status = 'blocked';
121 + $excluded->seats_available = 0;
122 + $excluded->seats_total = 0;
123 +
124 + return $excluded;
125 + }
126 +
77 127 // Priority 3: Trip default (flexible booking / no configured calendar)
78 128 return $this->buildAvailabilityObject($trip, null, 'trip_default');
79 129 }
80 130
@@ -83,11 +133,16 @@
83 133 *
84 134 * @param int $tripId Trip ID
85 135 * @param string $fromDate Start date
86 136 * @param string $toDate End date
137 + * @param bool $includeSoldOut Whether sold-out dates stay in the result. Defaults
138 + * to true so every existing caller — including Pro's
139 + * ChannelManager inventory sync, which must always see
140 + * the full picture — is unchanged. Storefront callers
141 + * pass the `show_sold_out` setting.
87 142 * @return array Array of availability objects
88 143 */
89 - public function getAllAvailabilityDates(int $tripId, string $fromDate, string $toDate): array
144 + public function getAllAvailabilityDates(int $tripId, string $fromDate, string $toDate, bool $includeSoldOut = true): array
90 145 {
91 146 $trip = $this->tripRepository->find($tripId);
92 147 if (!$trip) {
93 148 return [];
@@ -133,11 +188,35 @@
133 188 if (empty($dateMap)) {
134 189 $dateMap = $this->generateDefaultAvailability($trip, $fromDate, $toDate);
135 190 }
136 191
192 + // Step 4: Drop non-bookable dates (blocked/closed/cancelled/unavailable). A
193 + // blocked specific row was kept in Step 1 so it overrides its recurring rule
194 + // (preventing the rule from resurrecting the date); we remove it here so the
195 + // resolved list represents only bookable departures. This feeds the
196 + // single-trip count + calendar and the admin date-picker. (sold_out is kept
197 + // by default so it can render as "sold out" / drive waitlist.)
198 + //
199 + // `unavailable` is dropped alongside the rest: the booking guard rejects it
200 + // too, so leaving it visible advertised a date that cannot be booked.
201 + $nonBookable = ['blocked', 'closed', 'cancelled', 'unavailable'];
202 + foreach ($dateMap as $key => $obj) {
203 + if (!\is_object($obj)) {
204 + continue;
205 + }
206 + if (\in_array(($obj->status ?? ''), $nonBookable, true) || !empty($obj->is_blocked)) {
207 + unset($dateMap[$key]);
208 + continue;
209 + }
210 + // Owner opted to hide sold-out dates entirely rather than badge them.
211 + if (!$includeSoldOut && (($obj->status ?? '') === 'sold_out' || !empty($obj->is_sold_out))) {
212 + unset($dateMap[$key]);
213 + }
214 + }
215 +
137 216 // Sort by date
138 217 ksort($dateMap);
139 -
218 +
140 219 return array_values($dateMap);
141 220 }
142 221
143 222 /**
@@ -371,8 +450,9 @@
371 450 : (is_object($source) ? (($source->status ?? '') ?: 'available') : 'available');
372 451 if ($avail->seats_available <= 0) {
373 452 $avail->status = 'sold_out';
374 453 }
454 + $avail->is_blocked = false;
375 455 $avail->is_recurring = true;
376 456 $avail->rule_id = $ruleId;
377 457 $avail->source = 'recurring_rule';
378 458 $avail->from_location = is_array($source)
@@ -484,8 +564,16 @@
484 564 $avail->seats_total = (int) ($source->seats_total ?? 0);
485 565 $avail->seats_available = (int) ($source->seats_available ?? 0);
486 566 $avail->seats_reserved = (int) ($source->seats_reserved ?? 0);
487 567 $avail->status = $source->status ?? 'available';
568 + $avail->is_blocked = !empty($source->is_blocked) || (($avail->status ?? '') === 'blocked');
569 + // A blocked date is never bookable or waitlistable. Normalize the
570 + // status so the list filter drops it and the booking guard rejects
571 + // it as 'blocked' even if the row stored a different status (e.g. an
572 + // update recalculated it to 'sold_out' alongside is_blocked=1).
573 + if ($avail->is_blocked) {
574 + $avail->status = 'blocked';
575 + }
488 576 $avail->is_recurring = false;
489 577 $avail->source = 'availability_date';
490 578 $avail->from_location = isset($source->from_location) ? $source->from_location : null;
491 579 $avail->to_location = isset($source->to_location) ? $source->to_location : null;
@@ -568,8 +656,9 @@
568 656 $avail->seats_reserved = 0;
569 657 $avail->original_price = $trip_original_price;
570 658 $avail->discounted_price = $trip_discounted_price;
571 659 $avail->status = 'available';
660 + $avail->is_blocked = false;
572 661 $avail->is_recurring = false;
573 662 $avail->source = 'trip_default';
574 663
575 664 // Use trip's pricing_type and price_types
@@ -607,8 +696,29 @@
607 696 return $avail;
608 697 }
609 698
610 699 /**
700 + * Normalize a time to HH:MM so "8:00", "08:00" and "08:00:00" compare equal.
701 + * Returns an empty string for empty input so two blanks still match.
702 + *
703 + * @param string|null $time
704 + * @return string
705 + */
706 + private function normalizeTimeKey(?string $time): string
707 + {
708 + $time = trim((string) $time);
709 + if ($time === '') {
710 + return '';
711 + }
712 +
713 + $parts = explode(':', $time);
714 + $hour = isset($parts[0]) ? (int) $parts[0] : 0;
715 + $minute = isset($parts[1]) ? (int) $parts[1] : 0;
716 +
717 + return sprintf('%02d:%02d', $hour, $minute);
718 + }
719 +
720 + /**
611 721 * Resolve a single day's availability from recurring rules (new rules engine).
612 722 *
613 723 * @return array|null A generated availability row (array shape) or null if no rule applies
614 724 */
@@ -628,9 +738,14 @@
628 738 continue;
629 739 }
630 740 $depTime = $row['departure_time'] ?? null;
631 741 if ($departureTime !== null) {
632 - if ($depTime === $departureTime) {
742 + // Compare on HH:MM. Rule time slots store "08:00" while the
743 + // departure tables use a SQL TIME column ("08:00:00"), so a strict
744 + // match silently missed and the resolver fell through to the trip
745 + // default — reporting whole-trip capacity for a slot that sells far
746 + // fewer seats, which let the booking guard over-allow.
747 + if ($this->normalizeTimeKey($depTime) === $this->normalizeTimeKey($departureTime)) {
633 748 return $row;
634 749 }
635 750 continue;
636 751 }