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 +330 -52 3.0.3 → 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)
@@ -305,40 +379,163 @@
305 379 : (isset($trip->sale_price) && (float) $trip->sale_price > 0 ? (float) $trip->sale_price : null);
306 380
307 381 switch ($sourceType) {
308 382 case 'recurring_rule':
309 - // From recurring rule
310 - $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 : '');
311 395 $avail->trip_id = (int) $trip->id;
312 - $avail->departure_date = $source['date'];
313 - $ruleCap = (int) ($source['max_capacity'] ?? 0);
314 - if ($ruleCap <= 0) {
315 - $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;
316 407 }
317 - if ($ruleCap <= 0) {
318 - $ruleCap = 20;
408 + if (!$seatsTotal || $seatsTotal <= 0) {
409 + $seatsTotal = (int) ($trip->max_travelers ?? $trip->max_travellers ?? 0);
319 410 }
320 - $avail->seats_total = $ruleCap;
321 - $avail->seats_available = $ruleCap;
322 - $avail->seats_reserved = 0;
323 - $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;
324 455 $avail->is_recurring = true;
325 - $avail->rule_id = $source['rule_id'] ?? null;
456 + $avail->rule_id = $ruleId;
326 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);
327 486
328 487 // Pricing: rule base_price → trip original_price fallback
329 - $rule_price = isset($source['base_price']) && $source['base_price'] !== null
330 - ? (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 + }
331 498 $avail->original_price = ($rule_price !== null && $rule_price > 0)
332 499 ? $rule_price : $trip_original_price;
333 - // Rules don't have discounted_price — inherit trip's discount
334 - $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);
335 518
336 519 // Inherit trip's pricing_type
337 520 $avail->pricing_type = $trip_pricing_type;
338 521
339 - // Use trip's price_types (rules don't have their own)
340 - $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 + }
341 538
342 539 // End dates for sidebar / JSON (rules only provide departure day)
343 540 $durationDays = max(1, (int) ($trip->duration_days ?? 1));
344 541 $offset = max(0, $durationDays - 1);
@@ -367,10 +564,25 @@
367 564 $avail->seats_total = (int) ($source->seats_total ?? 0);
368 565 $avail->seats_available = (int) ($source->seats_available ?? 0);
369 566 $avail->seats_reserved = (int) ($source->seats_reserved ?? 0);
370 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 + }
371 576 $avail->is_recurring = false;
372 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;
373 585
374 586 // Pricing: availability price → trip price fallback
375 587 $avail_orig = isset($source->original_price) && $source->original_price !== null
376 588 ? (float) $source->original_price : null;
@@ -395,11 +607,16 @@
395 607 if (!empty($avail_price_types) && is_array($avail_price_types)) {
396 608 $avail->price_types = TripPricingService::resolvePriceTypes(
397 609 (object) ['price_types' => $avail_price_types]
398 610 );
611 + $avail->pricing_type = 'traveler_based';
399 612 } else {
400 613 $avail->price_types = $trip_price_types;
401 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;
402 619 break;
403 620
404 621 case 'trip_default':
405 622 // From trip defaults (flexible booking)
@@ -439,8 +656,9 @@
439 656 $avail->seats_reserved = 0;
440 657 $avail->original_price = $trip_original_price;
441 658 $avail->discounted_price = $trip_discounted_price;
442 659 $avail->status = 'available';
660 + $avail->is_blocked = false;
443 661 $avail->is_recurring = false;
444 662 $avail->source = 'trip_default';
445 663
446 664 // Use trip's pricing_type and price_types
@@ -475,8 +693,68 @@
475 693 $avail->return_date = $arr;
476 694 }
477 695
478 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;
479 757 }
480 758
481 759 /**
482 760 * Calculate effective price based on pricing type