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 +92 -10 3.0.9 → 3.0.16 View file →
@@ -61,19 +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) must win
66 - // over everything so the booking guard rejects it. The standard lookup below
67 - // hides those rows by design (status IN available/limited), which would let
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 68 // the resolver fall through to a recurring rule / trip default = "available"
69 69 // and silently allow the booking. We therefore look the row up including any
70 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).
71 77 $anyStatusRow = $this->availabilityRepository->findByTripIdAndDateTime($tripId, $date, $departureTime, true);
72 - if ($anyStatusRow && (\in_array(($anyStatusRow->status ?? ''), ['blocked', 'closed', 'cancelled'], true) || !empty($anyStatusRow->is_blocked))) {
78 + if ($anyStatusRow && (\in_array(($anyStatusRow->status ?? ''), ['blocked', 'closed', 'cancelled', 'unavailable'], true) || !empty($anyStatusRow->is_blocked))) {
73 79 return $this->buildAvailabilityObject($trip, $anyStatusRow, 'availability_date');
74 80 }
75 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 +
76 94 // Priority 1: Specific availability rows (sold_out, seats, blocks, price overrides)
77 95 $availabilityDate = $this->availabilityRepository->findByTripIdAndDateTime($tripId, $date, $departureTime);
78 96 if ($availabilityDate) {
79 97 return $this->buildAvailabilityObject($trip, $availabilityDate, 'availability_date');
@@ -84,8 +102,29 @@
84 102 if ($recurring !== null) {
85 103 return $this->buildAvailabilityObject($trip, $recurring, 'recurring_rule');
86 104 }
87 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 +
88 127 // Priority 3: Trip default (flexible booking / no configured calendar)
89 128 return $this->buildAvailabilityObject($trip, null, 'trip_default');
90 129 }
91 130
@@ -94,11 +133,16 @@
94 133 *
95 134 * @param int $tripId Trip ID
96 135 * @param string $fromDate Start date
97 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.
98 142 * @return array Array of availability objects
99 143 */
100 - public function getAllAvailabilityDates(int $tripId, string $fromDate, string $toDate): array
144 + public function getAllAvailabilityDates(int $tripId, string $fromDate, string $toDate, bool $includeSoldOut = true): array
101 145 {
102 146 $trip = $this->tripRepository->find($tripId);
103 147 if (!$trip) {
104 148 return [];
@@ -144,18 +188,30 @@
144 188 if (empty($dateMap)) {
145 189 $dateMap = $this->generateDefaultAvailability($trip, $fromDate, $toDate);
146 190 }
147 191
148 - // Step 4: Drop non-bookable dates (blocked/closed/cancelled). A blocked
149 - // specific row was kept in Step 1 so it overrides its recurring rule
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
150 194 // (preventing the rule from resurrecting the date); we remove it here so the
151 195 // resolved list represents only bookable departures. This feeds the
152 196 // single-trip count + calendar and the admin date-picker. (sold_out is kept
153 - // so it can render as "sold out" / drive waitlist.)
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'];
154 202 foreach ($dateMap as $key => $obj) {
155 - if (\is_object($obj) && (\in_array(($obj->status ?? ''), ['blocked', 'closed', 'cancelled'], true) || !empty($obj->is_blocked))) {
203 + if (!\is_object($obj)) {
204 + continue;
205 + }
206 + if (\in_array(($obj->status ?? ''), $nonBookable, true) || !empty($obj->is_blocked)) {
156 207 unset($dateMap[$key]);
208 + continue;
157 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 + }
158 214 }
159 215
160 216 // Sort by date
161 217 ksort($dateMap);
@@ -640,8 +696,29 @@
640 696 return $avail;
641 697 }
642 698
643 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 + /**
644 721 * Resolve a single day's availability from recurring rules (new rules engine).
645 722 *
646 723 * @return array|null A generated availability row (array shape) or null if no rule applies
647 724 */
@@ -661,9 +738,14 @@
661 738 continue;
662 739 }
663 740 $depTime = $row['departure_time'] ?? null;
664 741 if ($departureTime !== null) {
665 - 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)) {
666 748 return $row;
667 749 }
668 750 continue;
669 751 }