PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
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 2.0.10 All 83 releases
yatra / app / Services / AvailabilityResolutionService.php

AvailabilityResolutionService.php in Yatra – Travel Booking & Tour Operator Software 3.0.13, at app/Services/AvailabilityResolutionService.php

800 lines 38.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 use Yatra\Repositories\AvailabilityRepository;
8 use Yatra\Repositories\TripRepository;
9 use Yatra\Repositories\RecurringAvailabilityRepository;
10 use Yatra\Repositories\BookingRepository;
11
12 /**
13 * Availability Resolution Service
14 *
15 * Centralized service to resolve availability data following priority:
16 * 1. Availability Dates (specific rows — capacity, sold_out, blocked, pricing overrides)
17 * 2. Recurring Rules (pattern-based dates when no specific row exists for that date)
18 * 3. Trip Default (fallback — flexible booking when no dates/rules exist)
19 *
20 * Specific dates must win over recurring rules so admin “sold out” / seat counts are respected.
21 */
22 class AvailabilityResolutionService
23 {
24 private RecurringAvailabilityService $recurringAvailabilityService;
25 private AvailabilityRepository $availabilityRepository;
26 private TripRepository $tripRepository;
27 private BookingRepository $bookingRepository;
28 private CalculationService $calculationService;
29
30 public function __construct()
31 {
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 );
38 $this->availabilityRepository = new AvailabilityRepository();
39 $this->tripRepository = new TripRepository();
40 $this->bookingRepository = new BookingRepository();
41 $this->calculationService = new CalculationService();
42 }
43
44 /**
45 * Resolve availability for a specific trip and date (and optionally time)
46 *
47 * Priority:
48 * 1. Availability Dates (exact date/time row from DB)
49 * 2. Recurring Rules (generated slot when no DB row)
50 * 3. Trip defaults (flexible booking)
51 *
52 * @param int $tripId Trip ID
53 * @param string $date Date in Y-m-d format
54 * @param string|null $departureTime Optional departure time for day tour time slots
55 * @return object Resolved availability data
56 */
57 public function resolveAvailabilityForDate(int $tripId, string $date, ?string $departureTime = null): object
58 {
59 // Get trip data
60 $trip = $this->tripRepository->find($tripId);
61 if (!$trip) {
62 throw new \Exception('Trip not found');
63 }
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
94 // Priority 1: Specific availability rows (sold_out, seats, blocks, price overrides)
95 $availabilityDate = $this->availabilityRepository->findByTripIdAndDateTime($tripId, $date, $departureTime);
96 if ($availabilityDate) {
97 return $this->buildAvailabilityObject($trip, $availabilityDate, 'availability_date');
98 }
99
100 // Priority 2: Recurring rules when no explicit row exists for this date/time
101 $recurring = $this->resolveRecurringAvailabilityForDate($tripId, $date, $departureTime);
102 if ($recurring !== null) {
103 return $this->buildAvailabilityObject($trip, $recurring, 'recurring_rule');
104 }
105
106 // Priority 3: Trip default (flexible booking / no configured calendar)
107 return $this->buildAvailabilityObject($trip, null, 'trip_default');
108 }
109
110 /**
111 * Get all availability dates for a trip (merged from all sources)
112 *
113 * @param int $tripId Trip ID
114 * @param string $fromDate Start date
115 * @param string $toDate End date
116 * @param bool $includeSoldOut Whether sold-out dates stay in the result. Defaults
117 * to true so every existing caller — including Pro's
118 * ChannelManager inventory sync, which must always see
119 * the full picture — is unchanged. Storefront callers
120 * pass the `show_sold_out` setting.
121 * @return array Array of availability objects
122 */
123 public function getAllAvailabilityDates(int $tripId, string $fromDate, string $toDate, bool $includeSoldOut = true): array
124 {
125 $trip = $this->tripRepository->find($tripId);
126 if (!$trip) {
127 return [];
128 }
129
130 $allDates = [];
131 $dateMap = [];
132
133 // Step 1: Get specific availability dates
134 $specificDates = $this->availabilityRepository->findByTripIdAndDateRange($tripId, $fromDate, $toDate);
135 foreach ($specificDates as $avail) {
136 // Use composite key (date + time) to support multiple time slots on the same date (day tours)
137 $dateKey = $avail->departure_date;
138 if (!empty($avail->departure_time)) {
139 $dateKey .= '_' . $avail->departure_time;
140 }
141 $dateMap[$dateKey] = $this->buildAvailabilityObject($trip, $avail, 'availability_date');
142 }
143
144 // Step 2: Generate dates from recurring rules
145 $recurringDates = $this->recurringAvailabilityService->generateDatesForTrip($tripId, $fromDate, $toDate);
146 foreach ($recurringDates as $recurringDate) {
147 $depDate = $recurringDate['departure_date'] ?? $recurringDate['date'] ?? null;
148 if (!$depDate) {
149 continue;
150 }
151 // Mirror the specific-dates composite key so manual rows can override
152 // individual rule time-slots (day tours) deterministically.
153 $dateKey = $depDate;
154 $depTime = $recurringDate['departure_time'] ?? null;
155 if (!empty($depTime)) {
156 $dateKey .= '_' . $depTime;
157 }
158
159 // Only add if no specific availability date exists (specific dates override rules)
160 if (!isset($dateMap[$dateKey])) {
161 $dateMap[$dateKey] = $this->buildAvailabilityObject($trip, $recurringDate, 'recurring_rule');
162 }
163 }
164
165 // Step 3: Fallback to trip_default if no specific availability configured
166 // This generates availability for flexible booking trips
167 if (empty($dateMap)) {
168 $dateMap = $this->generateDefaultAvailability($trip, $fromDate, $toDate);
169 }
170
171 // Step 4: Drop non-bookable dates (blocked/closed/cancelled/unavailable). A
172 // blocked specific row was kept in Step 1 so it overrides its recurring rule
173 // (preventing the rule from resurrecting the date); we remove it here so the
174 // resolved list represents only bookable departures. This feeds the
175 // single-trip count + calendar and the admin date-picker. (sold_out is kept
176 // by default so it can render as "sold out" / drive waitlist.)
177 //
178 // `unavailable` is dropped alongside the rest: the booking guard rejects it
179 // too, so leaving it visible advertised a date that cannot be booked.
180 $nonBookable = ['blocked', 'closed', 'cancelled', 'unavailable'];
181 foreach ($dateMap as $key => $obj) {
182 if (!\is_object($obj)) {
183 continue;
184 }
185 if (\in_array(($obj->status ?? ''), $nonBookable, true) || !empty($obj->is_blocked)) {
186 unset($dateMap[$key]);
187 continue;
188 }
189 // Owner opted to hide sold-out dates entirely rather than badge them.
190 if (!$includeSoldOut && (($obj->status ?? '') === 'sold_out' || !empty($obj->is_sold_out))) {
191 unset($dateMap[$key]);
192 }
193 }
194
195 // Sort by date
196 ksort($dateMap);
197
198 return array_values($dateMap);
199 }
200
201 /**
202 * Get booking mode information for a trip
203 *
204 * Determines whether the trip uses date-specific booking (with configured availability)
205 * or flexible booking (no specific dates configured).
206 *
207 * @param int $tripId Trip ID
208 * @return array Booking mode information with keys:
209 * - 'mode': 'date_specific' or 'flexible'
210 * - 'has_availability': boolean
211 * - 'has_dates': boolean (has specific availability dates)
212 * - 'has_rules': boolean (has recurring rules)
213 */
214 public function getBookingMode(int $tripId): array
215 {
216 // Check for specific availability dates (any date, not range-limited)
217 global $wpdb;
218 $availTable = \Yatra\Database\Tables\TripAvailabilityDatesTable::getTableName();
219 $hasSpecificDates = (bool) $wpdb->get_var(
220 $wpdb->prepare(
221 "SELECT COUNT(*) FROM {$availTable} WHERE trip_id = %d LIMIT 1",
222 $tripId
223 )
224 );
225
226 // Check for recurring rules
227 $recurringTable = \Yatra\Database\Tables\TripAvailabilityRulesTable::getTableName();
228 $hasRecurringRules = (bool) $wpdb->get_var(
229 $wpdb->prepare(
230 "SELECT COUNT(*) FROM {$recurringTable} WHERE trip_id = %d AND status = 'active' LIMIT 1",
231 $tripId
232 )
233 );
234
235 $hasAvailability = $hasSpecificDates || $hasRecurringRules;
236
237 return [
238 'mode' => $hasAvailability ? 'date_specific' : 'flexible',
239 'has_availability' => $hasAvailability,
240 'has_dates' => $hasSpecificDates,
241 'has_rules' => $hasRecurringRules,
242 ];
243 }
244
245 /**
246 * Generate default availability dates for flexible booking trips
247 *
248 * When no specific availability dates or recurring rules are configured,
249 * this generates availability based on trip defaults for the requested date range.
250 *
251 * @param object $trip Trip object
252 * @param string $fromDate Start date
253 * @param string $toDate End date
254 * @return array Array of availability objects keyed by date
255 */
256 private function generateDefaultAvailability(object $trip, string $fromDate, string $toDate): array
257 {
258 $dateMap = [];
259
260 // Respect trip's available_from and available_to if set
261 $tripAvailableFrom = !empty($trip->available_from) ? $trip->available_from : null;
262 $tripAvailableTo = !empty($trip->available_to) ? $trip->available_to : null;
263
264 // Determine actual date range
265 $startDate = $fromDate;
266 $endDate = $toDate;
267
268 if ($tripAvailableFrom && $tripAvailableFrom > $startDate) {
269 $startDate = $tripAvailableFrom;
270 }
271
272 if ($tripAvailableTo && $tripAvailableTo < $endDate) {
273 $endDate = $tripAvailableTo;
274 }
275
276 // Don't generate dates if the range is invalid
277 if ($startDate > $endDate) {
278 return [];
279 }
280
281 // Check if trip has multiple time slots (for day tours)
282 $hasTimeSlots = !empty($trip->has_default_time_slots) && $trip->trip_type === 'single_day';
283 $timeSlots = [];
284
285 if ($hasTimeSlots) {
286 // Parse time slots from JSON
287 $timeSlotsData = $trip->default_time_slots;
288 if (is_string($timeSlotsData)) {
289 $timeSlotsData = json_decode($timeSlotsData, true);
290 }
291 if (is_array($timeSlotsData) && !empty($timeSlotsData)) {
292 $timeSlots = $timeSlotsData;
293 }
294 }
295
296 // Generate daily availability for the range
297 // For flexible booking, we generate dates to show in the calendar
298 $currentDate = new \DateTime($startDate);
299 $finalDate = new \DateTime($endDate);
300
301 while ($currentDate <= $finalDate) {
302 $dateStr = $currentDate->format('Y-m-d');
303
304 if ($hasTimeSlots && !empty($timeSlots)) {
305 // Generate separate availability for each time slot
306 foreach ($timeSlots as $slot) {
307 $timeValue = $slot['time'] ?? null;
308 if (!$timeValue) continue;
309
310 $defaultData = [
311 'date' => $dateStr,
312 'departure_date' => $dateStr,
313 'departure_time' => $timeValue,
314 ];
315
316 $dateKey = $dateStr . '_' . $timeValue;
317 $dateMap[$dateKey] = $this->buildAvailabilityObject($trip, (object) $defaultData, 'trip_default');
318 }
319 } else {
320 // Single availability per date
321 $defaultData = [
322 'date' => $dateStr,
323 'departure_date' => $dateStr,
324 ];
325
326 $dateMap[$dateStr] = $this->buildAvailabilityObject($trip, (object) $defaultData, 'trip_default');
327 }
328
329 // Move to next day
330 $currentDate->modify('+1 day');
331 }
332
333 return $dateMap;
334 }
335
336 /**
337 * Build unified availability object from different sources
338 *
339 * @param object $trip Trip data
340 * @param mixed $source Source data (recurring rule, availability date, or null)
341 * @param string $sourceType Source type identifier
342 * @return object Unified availability object
343 */
344 private function buildAvailabilityObject(object $trip, $source, string $sourceType): object
345 {
346 $avail = new \stdClass();
347
348 // Get trip's pricing configuration (used as fallback for all sources).
349 // Use {@see TripPricingService::resolvePricingType} so "regular" trips do not inherit stale
350 // JSON category rows into availability objects (keeps effective_price aligned with trip row).
351 $trip_pricing_type = TripPricingService::resolvePricingType($trip);
352 $trip_price_types = $trip_pricing_type === 'traveler_based'
353 ? $this->getTripPriceTypes((int) $trip->id)
354 : [];
355 $trip_original_price = isset($trip->original_price) ? (float) $trip->original_price : null;
356 $trip_discounted_price = isset($trip->discounted_price) && (float) $trip->discounted_price > 0
357 ? (float) $trip->discounted_price
358 : (isset($trip->sale_price) && (float) $trip->sale_price > 0 ? (float) $trip->sale_price : null);
359
360 switch ($sourceType) {
361 case 'recurring_rule':
362 // From recurring rule (new engine uses departure_date/departure_time).
363 $depDate = is_array($source)
364 ? ($source['departure_date'] ?? $source['date'] ?? null)
365 : (is_object($source) ? ($source->departure_date ?? $source->date ?? null) : null);
366 $depTime = is_array($source)
367 ? ($source['departure_time'] ?? null)
368 : (is_object($source) ? ($source->departure_time ?? null) : null);
369 $ruleId = is_array($source)
370 ? ($source['rule_id'] ?? null)
371 : (is_object($source) ? ($source->rule_id ?? null) : null);
372
373 $avail->id = 'recurring_' . ($depDate ?: '') . '_' . ($ruleId ?? 0) . ($depTime ? '_' . $depTime : '');
374 $avail->trip_id = (int) $trip->id;
375 $avail->departure_date = (string) ($depDate ?? '');
376 $avail->departure_time = $depTime ?: null;
377 $avail->arrival_time = is_array($source)
378 ? ($source['arrival_time'] ?? null)
379 : (is_object($source) ? ($source->arrival_time ?? null) : null);
380
381 $seatsTotal = null;
382 if (is_array($source)) {
383 $seatsTotal = isset($source['seats_total']) ? (int) $source['seats_total'] : null;
384 } elseif (is_object($source)) {
385 $seatsTotal = isset($source->seats_total) ? (int) $source->seats_total : null;
386 }
387 if (!$seatsTotal || $seatsTotal <= 0) {
388 $seatsTotal = (int) ($trip->max_travelers ?? $trip->max_travellers ?? 0);
389 }
390 if ($seatsTotal <= 0) {
391 $seatsTotal = 20;
392 }
393
394 $avail->seats_total = $seatsTotal;
395 // Live reserved seats from bookings (virtual slots have no numeric availability_id).
396 $reserved = 0;
397 if ($avail->departure_date !== '') {
398 /** @var array{trip_id:int, departure_date:string, departure_time:?string} $args */
399 $args = apply_filters('yatra_virtual_availability_reserved_seats_args', [
400 'trip_id' => (int) $trip->id,
401 'departure_date' => (string) $avail->departure_date,
402 'departure_time' => $avail->departure_time ?: null,
403 ], $trip, $source);
404
405 $tripId = (int) ($args['trip_id'] ?? (int) $trip->id);
406 $depDate = (string) ($args['departure_date'] ?? (string) $avail->departure_date);
407 $depTime = $args['departure_time'] ?? ($avail->departure_time ?: null);
408
409 $reserved = $this->bookingRepository->countActiveSeatsForSlot(
410 $tripId,
411 $depDate,
412 is_string($depTime) ? $depTime : null
413 );
414 }
415 $reserved = (int) apply_filters('yatra_virtual_availability_reserved_seats_count', (int) $reserved, $avail, $trip, $source);
416
417 // Allow modules to override seats_total for rule dates (e.g. seasonal capacity).
418 $seatsTotal = (int) apply_filters('yatra_virtual_availability_seats_total', (int) $seatsTotal, $avail, $trip, $source);
419 $avail->seats_total = max(0, $seatsTotal);
420
421 // Derive seats from reserved + total.
422 $avail->seats_reserved = max(0, (int) $reserved);
423 $avail->seats_available = max(0, (int) $avail->seats_total - (int) $avail->seats_reserved);
424
425 // Let modules override final computed seats_available (e.g. channel allocations).
426 $avail->seats_available = max(0, (int) apply_filters('yatra_virtual_availability_seats_available', (int) $avail->seats_available, $avail, $trip, $source));
427 $avail->status = is_array($source)
428 ? (($source['status'] ?? '') ?: 'available')
429 : (is_object($source) ? (($source->status ?? '') ?: 'available') : 'available');
430 if ($avail->seats_available <= 0) {
431 $avail->status = 'sold_out';
432 }
433 $avail->is_blocked = false;
434 $avail->is_recurring = true;
435 $avail->rule_id = $ruleId;
436 $avail->source = 'recurring_rule';
437 $avail->from_location = is_array($source)
438 ? ($source['from_location'] ?? null)
439 : (is_object($source) ? ($source->from_location ?? null) : null);
440 $avail->to_location = is_array($source)
441 ? ($source['to_location'] ?? null)
442 : (is_object($source) ? ($source->to_location ?? null) : null);
443 $avail->from_latitude = is_array($source)
444 ? ($source['from_latitude'] ?? null)
445 : (is_object($source) ? ($source->from_latitude ?? null) : null);
446 $avail->from_longitude = is_array($source)
447 ? ($source['from_longitude'] ?? null)
448 : (is_object($source) ? ($source->from_longitude ?? null) : null);
449 $avail->to_latitude = is_array($source)
450 ? ($source['to_latitude'] ?? null)
451 : (is_object($source) ? ($source->to_latitude ?? null) : null);
452 $avail->to_longitude = is_array($source)
453 ? ($source['to_longitude'] ?? null)
454 : (is_object($source) ? ($source->to_longitude ?? null) : null);
455 $avail->cutoff_hours = is_array($source)
456 ? ($source['cutoff_hours'] ?? null)
457 : (is_object($source) ? ($source->cutoff_hours ?? null) : null);
458 $alertThreshold = is_array($source)
459 ? (int) ($source['alert_threshold'] ?? 5)
460 : (int) (is_object($source) ? ($source->alert_threshold ?? 5) : 5);
461 $avail->is_sold_out = ($avail->seats_available ?? 0) <= 0;
462 $avail->is_limited = ($avail->seats_available ?? 0) > 0 && ($avail->seats_available ?? 0) <= max(1, $alertThreshold);
463 $avail->is_sold_out = (bool) apply_filters('yatra_virtual_availability_is_sold_out', (bool) $avail->is_sold_out, $avail, $trip, $source);
464 $avail->is_limited = (bool) apply_filters('yatra_virtual_availability_is_limited', (bool) $avail->is_limited, $avail, $trip, $source);
465
466 // Pricing: rule base_price → trip original_price fallback
467 $rule_price = null;
468 if (is_array($source)) {
469 $rule_price = isset($source['original_price']) && $source['original_price'] !== null
470 ? (float) $source['original_price']
471 : (isset($source['base_price']) && $source['base_price'] !== null ? (float) $source['base_price'] : null);
472 } elseif (is_object($source)) {
473 $rule_price = isset($source->original_price) && $source->original_price !== null
474 ? (float) $source->original_price
475 : (isset($source->base_price) && $source->base_price !== null ? (float) $source->base_price : null);
476 }
477 $avail->original_price = ($rule_price !== null && $rule_price > 0)
478 ? $rule_price : $trip_original_price;
479 $rule_discount = null;
480 if (is_array($source)) {
481 $rule_discount = isset($source['discounted_price']) && $source['discounted_price'] !== null
482 ? (float) $source['discounted_price']
483 : null;
484 } elseif (is_object($source)) {
485 $rule_discount = isset($source->discounted_price) && $source->discounted_price !== null
486 ? (float) $source->discounted_price
487 : null;
488 }
489 // Use rule slot discounted_price when present, otherwise inherit trip discount.
490 $avail->discounted_price = ($rule_discount !== null && $rule_discount > 0)
491 ? $rule_discount
492 : $trip_discounted_price;
493 // Convenience for frontend payloads that look for a single price number.
494 $avail->effective_price = ($avail->discounted_price !== null && (float) $avail->discounted_price > 0)
495 ? (float) $avail->discounted_price
496 : (float) ($avail->original_price ?? 0);
497
498 // Inherit trip's pricing_type
499 $avail->pricing_type = $trip_pricing_type;
500
501 // If a rule defines traveler_pricing, expose it as price_types so booking UI
502 // can render category-based pricing for rule-generated availability.
503 $travelerPricing = null;
504 if (is_array($source)) {
505 $travelerPricing = $source['traveler_pricing'] ?? null;
506 } elseif (is_object($source)) {
507 $travelerPricing = $source->traveler_pricing ?? null;
508 }
509 if (is_array($travelerPricing) && !empty($travelerPricing)) {
510 $avail->price_types = TripPricingService::resolvePriceTypes(
511 (object) ['price_types' => $travelerPricing]
512 );
513 $avail->pricing_type = 'traveler_based';
514 } else {
515 $avail->price_types = $trip_price_types;
516 }
517
518 // End dates for sidebar / JSON (rules only provide departure day)
519 $durationDays = max(1, (int) ($trip->duration_days ?? 1));
520 $offset = max(0, $durationDays - 1);
521 $dep = $avail->departure_date;
522 if ($dep !== '' && $dep !== null) {
523 $end = date('Y-m-d', strtotime((string) $dep . ' +' . $offset . ' days'));
524 $avail->arrival_date = $end;
525 $avail->return_date = $end;
526 } else {
527 $avail->arrival_date = null;
528 $avail->return_date = null;
529 }
530 break;
531
532 case 'availability_date':
533 // From specific availability date
534 $avail->id = $source->id ?? 0;
535 $avail->trip_id = (int) $trip->id;
536 $avail->departure_date = $source->departure_date ?? '';
537 $arrival = isset($source->arrival_date) ? $source->arrival_date : null;
538 $return = isset($source->return_date) ? $source->return_date : null;
539 $avail->arrival_date = $arrival;
540 $avail->return_date = ($return !== null && $return !== '') ? $return : $arrival;
541 $avail->departure_time = isset($source->departure_time) ? $source->departure_time : null;
542 $avail->arrival_time = isset($source->arrival_time) ? $source->arrival_time : null;
543 $avail->seats_total = (int) ($source->seats_total ?? 0);
544 $avail->seats_available = (int) ($source->seats_available ?? 0);
545 $avail->seats_reserved = (int) ($source->seats_reserved ?? 0);
546 $avail->status = $source->status ?? 'available';
547 $avail->is_blocked = !empty($source->is_blocked) || (($avail->status ?? '') === 'blocked');
548 // A blocked date is never bookable or waitlistable. Normalize the
549 // status so the list filter drops it and the booking guard rejects
550 // it as 'blocked' even if the row stored a different status (e.g. an
551 // update recalculated it to 'sold_out' alongside is_blocked=1).
552 if ($avail->is_blocked) {
553 $avail->status = 'blocked';
554 }
555 $avail->is_recurring = false;
556 $avail->source = 'availability_date';
557 $avail->from_location = isset($source->from_location) ? $source->from_location : null;
558 $avail->to_location = isset($source->to_location) ? $source->to_location : null;
559 $avail->from_latitude = isset($source->from_latitude) ? $source->from_latitude : null;
560 $avail->from_longitude = isset($source->from_longitude) ? $source->from_longitude : null;
561 $avail->to_latitude = isset($source->to_latitude) ? $source->to_latitude : null;
562 $avail->to_longitude = isset($source->to_longitude) ? $source->to_longitude : null;
563 $avail->cutoff_hours = isset($source->cutoff_hours) ? $source->cutoff_hours : null;
564
565 // Pricing: availability price → trip price fallback
566 $avail_orig = isset($source->original_price) && $source->original_price !== null
567 ? (float) $source->original_price : null;
568 $avail_disc = isset($source->discounted_price) && $source->discounted_price !== null
569 ? (float) $source->discounted_price : null;
570
571 $avail->original_price = ($avail_orig !== null && $avail_orig > 0)
572 ? $avail_orig : $trip_original_price;
573 $avail->discounted_price = ($avail_disc !== null && $avail_disc > 0)
574 ? $avail_disc : $trip_discounted_price;
575
576 // Inherit trip's pricing_type
577 $avail->pricing_type = $trip_pricing_type;
578
579 // Use availability's price_types if set, otherwise trip's price_types (normalize legacy `price` keys)
580 $avail_price_types = null;
581 if (!empty($source->price_types)) {
582 $avail_price_types = is_string($source->price_types)
583 ? json_decode($source->price_types, true)
584 : $source->price_types;
585 }
586 if (!empty($avail_price_types) && is_array($avail_price_types)) {
587 $avail->price_types = TripPricingService::resolvePriceTypes(
588 (object) ['price_types' => $avail_price_types]
589 );
590 $avail->pricing_type = 'traveler_based';
591 } else {
592 $avail->price_types = $trip_price_types;
593 }
594
595 // Standard flags expected by booking UI / cards.
596 $avail->is_sold_out = ($avail->seats_available ?? 0) <= 0 || ($avail->status ?? '') === 'sold_out';
597 $avail->is_limited = ($avail->seats_available ?? 0) > 0 && ($avail->seats_available ?? 0) <= 5;
598 break;
599
600 case 'trip_default':
601 // From trip defaults (flexible booking)
602 // Can be used for single date resolution (departure_date = null)
603 // or for generating availability list (departure_date = specific date)
604 $departure_date = null;
605 if (is_object($source) && !empty($source->departure_date)) {
606 $departure_date = $source->departure_date;
607 } elseif (is_array($source) && !empty($source['departure_date'])) {
608 $departure_date = $source['departure_date'];
609 }
610
611 // Get departure time from source or trip default
612 $departure_time_value = null;
613 if (is_object($source) && !empty($source->departure_time)) {
614 $departure_time_value = $source->departure_time;
615 } elseif (is_array($source) && !empty($source['departure_time'])) {
616 $departure_time_value = $source['departure_time'];
617 } else {
618 // Use trip's default departure time
619 $departure_time_value = $trip->departure_time ?? null;
620 }
621
622 $avail->id = $departure_date ? 'default_' . $departure_date : 'default';
623 if ($departure_time_value) {
624 $avail->id .= '_' . str_replace(':', '', $departure_time_value);
625 }
626
627 $avail->trip_id = (int) $trip->id;
628 $avail->departure_date = $departure_date;
629 $avail->arrival_date = null;
630 $avail->return_date = null;
631 $avail->departure_time = $departure_time_value;
632 $avail->arrival_time = null;
633 $avail->seats_total = (int) ($trip->max_travelers ?? 20);
634 $avail->seats_available = (int) ($trip->max_travelers ?? 20);
635 $avail->seats_reserved = 0;
636 $avail->original_price = $trip_original_price;
637 $avail->discounted_price = $trip_discounted_price;
638 $avail->status = 'available';
639 $avail->is_blocked = false;
640 $avail->is_recurring = false;
641 $avail->source = 'trip_default';
642
643 // Use trip's pricing_type and price_types
644 $avail->pricing_type = $trip_pricing_type;
645 $avail->price_types = $trip_price_types;
646 break;
647 }
648
649 // Calculate effective price via centralized TripPricingService
650 $avail->effective_price = $this->calculateEffectivePrice($avail);
651
652 // Pro filter: allows Dynamic Pricing, Itinerary Pricing, etc. to modify per-date availability
653 $avail = (object) apply_filters('yatra_resolve_availability_object', $avail, $trip, $sourceType);
654
655 return $this->normalizeResolvedAvailabilityObject($avail);
656 }
657
658 /**
659 * Ensure optional date fields exist and return_date falls back to arrival (DB / filters may omit keys).
660 */
661 private function normalizeResolvedAvailabilityObject(object $avail): object
662 {
663 foreach (['arrival_date', 'return_date', 'departure_time', 'arrival_time'] as $key) {
664 if (!property_exists($avail, $key)) {
665 $avail->{$key} = null;
666 }
667 }
668
669 $ret = $avail->return_date ?? null;
670 $arr = $avail->arrival_date ?? null;
671 if (($ret === null || $ret === '') && $arr !== null && $arr !== '') {
672 $avail->return_date = $arr;
673 }
674
675 return $avail;
676 }
677
678 /**
679 * Normalize a time to HH:MM so "8:00", "08:00" and "08:00:00" compare equal.
680 * Returns an empty string for empty input so two blanks still match.
681 *
682 * @param string|null $time
683 * @return string
684 */
685 private function normalizeTimeKey(?string $time): string
686 {
687 $time = trim((string) $time);
688 if ($time === '') {
689 return '';
690 }
691
692 $parts = explode(':', $time);
693 $hour = isset($parts[0]) ? (int) $parts[0] : 0;
694 $minute = isset($parts[1]) ? (int) $parts[1] : 0;
695
696 return sprintf('%02d:%02d', $hour, $minute);
697 }
698
699 /**
700 * Resolve a single day's availability from recurring rules (new rules engine).
701 *
702 * @return array|null A generated availability row (array shape) or null if no rule applies
703 */
704 private function resolveRecurringAvailabilityForDate(int $tripId, string $date, ?string $departureTime = null): ?array
705 {
706 $generated = $this->recurringAvailabilityService->generateDatesForTrip($tripId, $date, $date);
707 if (empty($generated)) {
708 return null;
709 }
710
711 foreach ($generated as $row) {
712 if (!is_array($row)) {
713 continue;
714 }
715 $depDate = $row['departure_date'] ?? null;
716 if ($depDate !== $date) {
717 continue;
718 }
719 $depTime = $row['departure_time'] ?? null;
720 if ($departureTime !== null) {
721 // Compare on HH:MM. Rule time slots store "08:00" while the
722 // departure tables use a SQL TIME column ("08:00:00"), so a strict
723 // match silently missed and the resolver fell through to the trip
724 // default — reporting whole-trip capacity for a slot that sells far
725 // fewer seats, which let the booking guard over-allow.
726 if ($this->normalizeTimeKey($depTime) === $this->normalizeTimeKey($departureTime)) {
727 return $row;
728 }
729 continue;
730 }
731 // No requested time; return first matching occurrence for that date.
732 return $row;
733 }
734
735 return null;
736 }
737
738 /**
739 * Calculate effective price based on pricing type
740 *
741 * Delegates to centralized TripPricingService for consistent pricing resolution.
742 *
743 * @param object $avail Availability object
744 * @return float Effective price
745 */
746 private function calculateEffectivePrice(object $avail): float
747 {
748 if ($avail->pricing_type === 'traveler_based' && !empty($avail->price_types)) {
749 // For traveler-based, return minimum price from categories
750 $min_price = PHP_FLOAT_MAX;
751 foreach ($avail->price_types as $pt) {
752 $price = TripPricingService::resolveCategoryEffectivePrice((array) $pt);
753 if ($price > 0 && $price < $min_price) {
754 $min_price = $price;
755 }
756 }
757 return $min_price < PHP_FLOAT_MAX ? $min_price : 0.0;
758 } else {
759 // For regular pricing: discounted → original
760 if (!empty($avail->discounted_price) && (float) $avail->discounted_price > 0) {
761 return (float) $avail->discounted_price;
762 }
763 return (float) ($avail->original_price ?? 0);
764 }
765 }
766
767 /**
768 * Get trip's price types from trips table JSON field
769 *
770 * @param int $tripId Trip ID
771 * @return array Price types array
772 */
773 private function getTripPriceTypes(int $tripId): array
774 {
775 global $wpdb;
776 $table = \Yatra\Database\Tables\TripsTable::getTableName();
777
778 $json = $wpdb->get_var(
779 $wpdb->prepare(
780 "SELECT price_types FROM {$table} WHERE id = %d",
781 $tripId
782 )
783 );
784
785 if (empty($json)) {
786 return [];
787 }
788
789 $decoded = json_decode($json, true);
790 if (!is_array($decoded)) {
791 return [];
792 }
793
794 // Map legacy `price` keys to original_price so card pricing never resolves to 0
795 $tripStub = (object) ['price_types' => $decoded];
796
797 return TripPricingService::resolvePriceTypes($tripStub);
798 }
799 }
800