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 +337 -55 3.0.2.7 → 3.0.16 View file →
@@ -3,12 +3,12 @@
3 3 declare(strict_types=1);
4 4
5 5 namespace Yatra\Services;
6 6
7 -use Yatra\Repositories\RecurringRuleRepository;
8 -use Yatra\Repositories\DepartureRepository;
9 7 use Yatra\Repositories\AvailabilityRepository;
10 8 use Yatra\Repositories\TripRepository;
9 +use Yatra\Repositories\RecurringAvailabilityRepository;
10 +use Yatra\Repositories\BookingRepository;
11 11
12 12 /**
13 13 * Availability Resolution Service
14 14 *
@@ -20,20 +20,25 @@
20 20 * Specific dates must win over recurring rules so admin “sold out” / seat counts are respected.
21 21 */
22 22 class AvailabilityResolutionService
23 23 {
24 - private RecurringRuleService $recurringRuleService;
24 + private RecurringAvailabilityService $recurringAvailabilityService;
25 25 private AvailabilityRepository $availabilityRepository;
26 26 private TripRepository $tripRepository;
27 + private BookingRepository $bookingRepository;
27 28 private CalculationService $calculationService;
28 29
29 30 public function __construct()
30 31 {
31 - $recurringRuleRepository = new RecurringRuleRepository();
32 - $departureRepository = new DepartureRepository();
33 - $this->recurringRuleService = new RecurringRuleService($recurringRuleRepository, $departureRepository);
32 + // Use the new recurring availability rules engine (wp_yatra_trip_availability_rules).
33 + // The admin Availability Rules UI writes to this schema; the single-trip page must use
34 + // the same engine to keep Preview and frontend availability consistent.
35 + $this->recurringAvailabilityService = new RecurringAvailabilityService(
36 + new RecurringAvailabilityRepository()
37 + );
34 38 $this->availabilityRepository = new AvailabilityRepository();
35 39 $this->tripRepository = new TripRepository();
40 + $this->bookingRepository = new BookingRepository();
36 41 $this->calculationService = new CalculationService();
37 42 }
38 43
39 44 /**
@@ -56,8 +61,37 @@
56 61 if (!$trip) {
57 62 throw new \Exception('Trip not found');
58 63 }
59 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 +
60 94 // Priority 1: Specific availability rows (sold_out, seats, blocks, price overrides)
61 95 $availabilityDate = $this->availabilityRepository->findByTripIdAndDateTime($tripId, $date, $departureTime);
62 96 if ($availabilityDate) {
63 97 return $this->buildAvailabilityObject($trip, $availabilityDate, 'availability_date');
@@ -63,13 +97,34 @@
63 97 return $this->buildAvailabilityObject($trip, $availabilityDate, 'availability_date');
64 98 }
65 99
66 100 // Priority 2: Recurring rules when no explicit row exists for this date/time
67 - $recurringData = $this->checkRecurringRules($tripId, $date);
68 - if ($recurringData) {
69 - return $this->buildAvailabilityObject($trip, $recurringData, 'recurring_rule');
101 + $recurring = $this->resolveRecurringAvailabilityForDate($tripId, $date, $departureTime);
102 + if ($recurring !== null) {
103 + return $this->buildAvailabilityObject($trip, $recurring, 'recurring_rule');
70 104 }
71 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 +
72 127 // Priority 3: Trip default (flexible booking / no configured calendar)
73 128 return $this->buildAvailabilityObject($trip, null, 'trip_default');
74 129 }
75 130
@@ -78,11 +133,16 @@
78 133 *
79 134 * @param int $tripId Trip ID
80 135 * @param string $fromDate Start date
81 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.
82 142 * @return array Array of availability objects
83 143 */
84 - public function getAllAvailabilityDates(int $tripId, string $fromDate, string $toDate): array
144 + public function getAllAvailabilityDates(int $tripId, string $fromDate, string $toDate, bool $includeSoldOut = true): array
85 145 {
86 146 $trip = $this->tripRepository->find($tripId);
87 147 if (!$trip) {
88 148 return [];
@@ -102,11 +162,21 @@
102 162 $dateMap[$dateKey] = $this->buildAvailabilityObject($trip, $avail, 'availability_date');
103 163 }
104 164
105 165 // Step 2: Generate dates from recurring rules
106 - $recurringDates = $this->recurringRuleService->generateDatesForTrip($tripId, $fromDate, $toDate);
166 + $recurringDates = $this->recurringAvailabilityService->generateDatesForTrip($tripId, $fromDate, $toDate);
107 167 foreach ($recurringDates as $recurringDate) {
108 - $dateKey = $recurringDate['date'];
168 + $depDate = $recurringDate['departure_date'] ?? $recurringDate['date'] ?? null;
169 + if (!$depDate) {
170 + continue;
171 + }
172 + // Mirror the specific-dates composite key so manual rows can override
173 + // individual rule time-slots (day tours) deterministically.
174 + $dateKey = $depDate;
175 + $depTime = $recurringDate['departure_time'] ?? null;
176 + if (!empty($depTime)) {
177 + $dateKey .= '_' . $depTime;
178 + }
109 179
110 180 // Only add if no specific availability date exists (specific dates override rules)
111 181 if (!isset($dateMap[$dateKey])) {
112 182 $dateMap[$dateKey] = $this->buildAvailabilityObject($trip, $recurringDate, 'recurring_rule');
@@ -118,11 +188,35 @@
118 188 if (empty($dateMap)) {
119 189 $dateMap = $this->generateDefaultAvailability($trip, $fromDate, $toDate);
120 190 }
121 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 +
122 216 // Sort by date
123 217 ksort($dateMap);
124 -
218 +
125 219 return array_values($dateMap);
126 220 }
127 221
128 222 /**
@@ -260,28 +354,8 @@
260 354 return $dateMap;
261 355 }
262 356
263 357 /**
264 - * Check if recurring rule exists for date
265 - *
266 - * @param int $tripId Trip ID
267 - * @param string $date Date
268 - * @return array|null Recurring rule data or null
269 - */
270 - private function checkRecurringRules(int $tripId, string $date): ?array
271 - {
272 - $rules = $this->recurringRuleService->generateDatesForTrip($tripId, $date, $date);
273 -
274 - foreach ($rules as $rule) {
275 - if ($rule['date'] === $date) {
276 - return $rule;
277 - }
278 - }
279 -
280 - return null;
281 - }
282 -
283 - /**
284 358 * Build unified availability object from different sources
285 359 *
286 360 * @param object $trip Trip data
287 361 * @param mixed $source Source data (recurring rule, availability date, or null)
@@ -291,11 +365,15 @@
291 365 private function buildAvailabilityObject(object $trip, $source, string $sourceType): object
292 366 {
293 367 $avail = new \stdClass();
294 368
295 - // Get trip's pricing configuration (used as fallback for all sources)
296 - $trip_pricing_type = $trip->pricing_type ?? 'regular';
297 - $trip_price_types = $this->getTripPriceTypes((int) $trip->id);
369 + // Get trip's pricing configuration (used as fallback for all sources).
370 + // Use {@see TripPricingService::resolvePricingType} so "regular" trips do not inherit stale
371 + // JSON category rows into availability objects (keeps effective_price aligned with trip row).
372 + $trip_pricing_type = TripPricingService::resolvePricingType($trip);
373 + $trip_price_types = $trip_pricing_type === 'traveler_based'
374 + ? $this->getTripPriceTypes((int) $trip->id)
375 + : [];
298 376 $trip_original_price = isset($trip->original_price) ? (float) $trip->original_price : null;
299 377 $trip_discounted_price = isset($trip->discounted_price) && (float) $trip->discounted_price > 0
300 378 ? (float) $trip->discounted_price
301 379 : (isset($trip->sale_price) && (float) $trip->sale_price > 0 ? (float) $trip->sale_price : null);
@@ -301,40 +379,163 @@
301 379 : (isset($trip->sale_price) && (float) $trip->sale_price > 0 ? (float) $trip->sale_price : null);
302 380
303 381 switch ($sourceType) {
304 382 case 'recurring_rule':
305 - // From recurring rule
306 - $avail->id = 'recurring_' . $source['date'] . '_' . ($source['rule_id'] ?? 0);
383 + // From recurring rule (new engine uses departure_date/departure_time).
384 + $depDate = is_array($source)
385 + ? ($source['departure_date'] ?? $source['date'] ?? null)
386 + : (is_object($source) ? ($source->departure_date ?? $source->date ?? null) : null);
387 + $depTime = is_array($source)
388 + ? ($source['departure_time'] ?? null)
389 + : (is_object($source) ? ($source->departure_time ?? null) : null);
390 + $ruleId = is_array($source)
391 + ? ($source['rule_id'] ?? null)
392 + : (is_object($source) ? ($source->rule_id ?? null) : null);
393 +
394 + $avail->id = 'recurring_' . ($depDate ?: '') . '_' . ($ruleId ?? 0) . ($depTime ? '_' . $depTime : '');
307 395 $avail->trip_id = (int) $trip->id;
308 - $avail->departure_date = $source['date'];
309 - $ruleCap = (int) ($source['max_capacity'] ?? 0);
310 - if ($ruleCap <= 0) {
311 - $ruleCap = (int) ($trip->max_travelers ?? $trip->max_travellers ?? 0);
396 + $avail->departure_date = (string) ($depDate ?? '');
397 + $avail->departure_time = $depTime ?: null;
398 + $avail->arrival_time = is_array($source)
399 + ? ($source['arrival_time'] ?? null)
400 + : (is_object($source) ? ($source->arrival_time ?? null) : null);
401 +
402 + $seatsTotal = null;
403 + if (is_array($source)) {
404 + $seatsTotal = isset($source['seats_total']) ? (int) $source['seats_total'] : null;
405 + } elseif (is_object($source)) {
406 + $seatsTotal = isset($source->seats_total) ? (int) $source->seats_total : null;
312 407 }
313 - if ($ruleCap <= 0) {
314 - $ruleCap = 20;
408 + if (!$seatsTotal || $seatsTotal <= 0) {
409 + $seatsTotal = (int) ($trip->max_travelers ?? $trip->max_travellers ?? 0);
315 410 }
316 - $avail->seats_total = $ruleCap;
317 - $avail->seats_available = $ruleCap;
318 - $avail->seats_reserved = 0;
319 - $avail->status = 'available';
411 + if ($seatsTotal <= 0) {
412 + $seatsTotal = 20;
413 + }
414 +
415 + $avail->seats_total = $seatsTotal;
416 + // Live reserved seats from bookings (virtual slots have no numeric availability_id).
417 + $reserved = 0;
418 + if ($avail->departure_date !== '') {
419 + /** @var array{trip_id:int, departure_date:string, departure_time:?string} $args */
420 + $args = apply_filters('yatra_virtual_availability_reserved_seats_args', [
421 + 'trip_id' => (int) $trip->id,
422 + 'departure_date' => (string) $avail->departure_date,
423 + 'departure_time' => $avail->departure_time ?: null,
424 + ], $trip, $source);
425 +
426 + $tripId = (int) ($args['trip_id'] ?? (int) $trip->id);
427 + $depDate = (string) ($args['departure_date'] ?? (string) $avail->departure_date);
428 + $depTime = $args['departure_time'] ?? ($avail->departure_time ?: null);
429 +
430 + $reserved = $this->bookingRepository->countActiveSeatsForSlot(
431 + $tripId,
432 + $depDate,
433 + is_string($depTime) ? $depTime : null
434 + );
435 + }
436 + $reserved = (int) apply_filters('yatra_virtual_availability_reserved_seats_count', (int) $reserved, $avail, $trip, $source);
437 +
438 + // Allow modules to override seats_total for rule dates (e.g. seasonal capacity).
439 + $seatsTotal = (int) apply_filters('yatra_virtual_availability_seats_total', (int) $seatsTotal, $avail, $trip, $source);
440 + $avail->seats_total = max(0, $seatsTotal);
441 +
442 + // Derive seats from reserved + total.
443 + $avail->seats_reserved = max(0, (int) $reserved);
444 + $avail->seats_available = max(0, (int) $avail->seats_total - (int) $avail->seats_reserved);
445 +
446 + // Let modules override final computed seats_available (e.g. channel allocations).
447 + $avail->seats_available = max(0, (int) apply_filters('yatra_virtual_availability_seats_available', (int) $avail->seats_available, $avail, $trip, $source));
448 + $avail->status = is_array($source)
449 + ? (($source['status'] ?? '') ?: 'available')
450 + : (is_object($source) ? (($source->status ?? '') ?: 'available') : 'available');
451 + if ($avail->seats_available <= 0) {
452 + $avail->status = 'sold_out';
453 + }
454 + $avail->is_blocked = false;
320 455 $avail->is_recurring = true;
321 - $avail->rule_id = $source['rule_id'] ?? null;
456 + $avail->rule_id = $ruleId;
322 457 $avail->source = 'recurring_rule';
458 + $avail->from_location = is_array($source)
459 + ? ($source['from_location'] ?? null)
460 + : (is_object($source) ? ($source->from_location ?? null) : null);
461 + $avail->to_location = is_array($source)
462 + ? ($source['to_location'] ?? null)
463 + : (is_object($source) ? ($source->to_location ?? null) : null);
464 + $avail->from_latitude = is_array($source)
465 + ? ($source['from_latitude'] ?? null)
466 + : (is_object($source) ? ($source->from_latitude ?? null) : null);
467 + $avail->from_longitude = is_array($source)
468 + ? ($source['from_longitude'] ?? null)
469 + : (is_object($source) ? ($source->from_longitude ?? null) : null);
470 + $avail->to_latitude = is_array($source)
471 + ? ($source['to_latitude'] ?? null)
472 + : (is_object($source) ? ($source->to_latitude ?? null) : null);
473 + $avail->to_longitude = is_array($source)
474 + ? ($source['to_longitude'] ?? null)
475 + : (is_object($source) ? ($source->to_longitude ?? null) : null);
476 + $avail->cutoff_hours = is_array($source)
477 + ? ($source['cutoff_hours'] ?? null)
478 + : (is_object($source) ? ($source->cutoff_hours ?? null) : null);
479 + $alertThreshold = is_array($source)
480 + ? (int) ($source['alert_threshold'] ?? 5)
481 + : (int) (is_object($source) ? ($source->alert_threshold ?? 5) : 5);
482 + $avail->is_sold_out = ($avail->seats_available ?? 0) <= 0;
483 + $avail->is_limited = ($avail->seats_available ?? 0) > 0 && ($avail->seats_available ?? 0) <= max(1, $alertThreshold);
484 + $avail->is_sold_out = (bool) apply_filters('yatra_virtual_availability_is_sold_out', (bool) $avail->is_sold_out, $avail, $trip, $source);
485 + $avail->is_limited = (bool) apply_filters('yatra_virtual_availability_is_limited', (bool) $avail->is_limited, $avail, $trip, $source);
323 486
324 487 // Pricing: rule base_price → trip original_price fallback
325 - $rule_price = isset($source['base_price']) && $source['base_price'] !== null
326 - ? (float) $source['base_price'] : null;
488 + $rule_price = null;
489 + if (is_array($source)) {
490 + $rule_price = isset($source['original_price']) && $source['original_price'] !== null
491 + ? (float) $source['original_price']
492 + : (isset($source['base_price']) && $source['base_price'] !== null ? (float) $source['base_price'] : null);
493 + } elseif (is_object($source)) {
494 + $rule_price = isset($source->original_price) && $source->original_price !== null
495 + ? (float) $source->original_price
496 + : (isset($source->base_price) && $source->base_price !== null ? (float) $source->base_price : null);
497 + }
327 498 $avail->original_price = ($rule_price !== null && $rule_price > 0)
328 499 ? $rule_price : $trip_original_price;
329 - // Rules don't have discounted_price — inherit trip's discount
330 - $avail->discounted_price = $trip_discounted_price;
500 + $rule_discount = null;
501 + if (is_array($source)) {
502 + $rule_discount = isset($source['discounted_price']) && $source['discounted_price'] !== null
503 + ? (float) $source['discounted_price']
504 + : null;
505 + } elseif (is_object($source)) {
506 + $rule_discount = isset($source->discounted_price) && $source->discounted_price !== null
507 + ? (float) $source->discounted_price
508 + : null;
509 + }
510 + // Use rule slot discounted_price when present, otherwise inherit trip discount.
511 + $avail->discounted_price = ($rule_discount !== null && $rule_discount > 0)
512 + ? $rule_discount
513 + : $trip_discounted_price;
514 + // Convenience for frontend payloads that look for a single price number.
515 + $avail->effective_price = ($avail->discounted_price !== null && (float) $avail->discounted_price > 0)
516 + ? (float) $avail->discounted_price
517 + : (float) ($avail->original_price ?? 0);
331 518
332 519 // Inherit trip's pricing_type
333 520 $avail->pricing_type = $trip_pricing_type;
334 521
335 - // Use trip's price_types (rules don't have their own)
336 - $avail->price_types = $trip_price_types;
522 + // If a rule defines traveler_pricing, expose it as price_types so booking UI
523 + // can render category-based pricing for rule-generated availability.
524 + $travelerPricing = null;
525 + if (is_array($source)) {
526 + $travelerPricing = $source['traveler_pricing'] ?? null;
527 + } elseif (is_object($source)) {
528 + $travelerPricing = $source->traveler_pricing ?? null;
529 + }
530 + if (is_array($travelerPricing) && !empty($travelerPricing)) {
531 + $avail->price_types = TripPricingService::resolvePriceTypes(
532 + (object) ['price_types' => $travelerPricing]
533 + );
534 + $avail->pricing_type = 'traveler_based';
535 + } else {
536 + $avail->price_types = $trip_price_types;
537 + }
337 538
338 539 // End dates for sidebar / JSON (rules only provide departure day)
339 540 $durationDays = max(1, (int) ($trip->duration_days ?? 1));
340 541 $offset = max(0, $durationDays - 1);
@@ -363,10 +564,25 @@
363 564 $avail->seats_total = (int) ($source->seats_total ?? 0);
364 565 $avail->seats_available = (int) ($source->seats_available ?? 0);
365 566 $avail->seats_reserved = (int) ($source->seats_reserved ?? 0);
366 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 + }
367 576 $avail->is_recurring = false;
368 577 $avail->source = 'availability_date';
578 + $avail->from_location = isset($source->from_location) ? $source->from_location : null;
579 + $avail->to_location = isset($source->to_location) ? $source->to_location : null;
580 + $avail->from_latitude = isset($source->from_latitude) ? $source->from_latitude : null;
581 + $avail->from_longitude = isset($source->from_longitude) ? $source->from_longitude : null;
582 + $avail->to_latitude = isset($source->to_latitude) ? $source->to_latitude : null;
583 + $avail->to_longitude = isset($source->to_longitude) ? $source->to_longitude : null;
584 + $avail->cutoff_hours = isset($source->cutoff_hours) ? $source->cutoff_hours : null;
369 585
370 586 // Pricing: availability price → trip price fallback
371 587 $avail_orig = isset($source->original_price) && $source->original_price !== null
372 588 ? (float) $source->original_price : null;
@@ -391,11 +607,16 @@
391 607 if (!empty($avail_price_types) && is_array($avail_price_types)) {
392 608 $avail->price_types = TripPricingService::resolvePriceTypes(
393 609 (object) ['price_types' => $avail_price_types]
394 610 );
611 + $avail->pricing_type = 'traveler_based';
395 612 } else {
396 613 $avail->price_types = $trip_price_types;
397 614 }
615 +
616 + // Standard flags expected by booking UI / cards.
617 + $avail->is_sold_out = ($avail->seats_available ?? 0) <= 0 || ($avail->status ?? '') === 'sold_out';
618 + $avail->is_limited = ($avail->seats_available ?? 0) > 0 && ($avail->seats_available ?? 0) <= 5;
398 619 break;
399 620
400 621 case 'trip_default':
401 622 // From trip defaults (flexible booking)
@@ -435,8 +656,9 @@
435 656 $avail->seats_reserved = 0;
436 657 $avail->original_price = $trip_original_price;
437 658 $avail->discounted_price = $trip_discounted_price;
438 659 $avail->status = 'available';
660 + $avail->is_blocked = false;
439 661 $avail->is_recurring = false;
440 662 $avail->source = 'trip_default';
441 663
442 664 // Use trip's pricing_type and price_types
@@ -471,8 +693,68 @@
471 693 $avail->return_date = $arr;
472 694 }
473 695
474 696 return $avail;
697 + }
698 +
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 + /**
721 + * Resolve a single day's availability from recurring rules (new rules engine).
722 + *
723 + * @return array|null A generated availability row (array shape) or null if no rule applies
724 + */
725 + private function resolveRecurringAvailabilityForDate(int $tripId, string $date, ?string $departureTime = null): ?array
726 + {
727 + $generated = $this->recurringAvailabilityService->generateDatesForTrip($tripId, $date, $date);
728 + if (empty($generated)) {
729 + return null;
730 + }
731 +
732 + foreach ($generated as $row) {
733 + if (!is_array($row)) {
734 + continue;
735 + }
736 + $depDate = $row['departure_date'] ?? null;
737 + if ($depDate !== $date) {
738 + continue;
739 + }
740 + $depTime = $row['departure_time'] ?? null;
741 + if ($departureTime !== null) {
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)) {
748 + return $row;
749 + }
750 + continue;
751 + }
752 + // No requested time; return first matching occurrence for that date.
753 + return $row;
754 + }
755 +
756 + return null;
475 757 }
476 758
477 759 /**
478 760 * Calculate effective price based on pricing type