PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.7
Yatra – Travel Booking & Tour Operator Software v3.0.7
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 2.0.11 All 82 releases
yatra / app / Services / AvailabilityResolutionService.php

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

706 lines 32.7 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 1: Specific availability rows (sold_out, seats, blocks, price overrides)
66 $availabilityDate = $this->availabilityRepository->findByTripIdAndDateTime($tripId, $date, $departureTime);
67 if ($availabilityDate) {
68 return $this->buildAvailabilityObject($trip, $availabilityDate, 'availability_date');
69 }
70
71 // Priority 2: Recurring rules when no explicit row exists for this date/time
72 $recurring = $this->resolveRecurringAvailabilityForDate($tripId, $date, $departureTime);
73 if ($recurring !== null) {
74 return $this->buildAvailabilityObject($trip, $recurring, 'recurring_rule');
75 }
76
77 // Priority 3: Trip default (flexible booking / no configured calendar)
78 return $this->buildAvailabilityObject($trip, null, 'trip_default');
79 }
80
81 /**
82 * Get all availability dates for a trip (merged from all sources)
83 *
84 * @param int $tripId Trip ID
85 * @param string $fromDate Start date
86 * @param string $toDate End date
87 * @return array Array of availability objects
88 */
89 public function getAllAvailabilityDates(int $tripId, string $fromDate, string $toDate): array
90 {
91 $trip = $this->tripRepository->find($tripId);
92 if (!$trip) {
93 return [];
94 }
95
96 $allDates = [];
97 $dateMap = [];
98
99 // Step 1: Get specific availability dates
100 $specificDates = $this->availabilityRepository->findByTripIdAndDateRange($tripId, $fromDate, $toDate);
101 foreach ($specificDates as $avail) {
102 // Use composite key (date + time) to support multiple time slots on the same date (day tours)
103 $dateKey = $avail->departure_date;
104 if (!empty($avail->departure_time)) {
105 $dateKey .= '_' . $avail->departure_time;
106 }
107 $dateMap[$dateKey] = $this->buildAvailabilityObject($trip, $avail, 'availability_date');
108 }
109
110 // Step 2: Generate dates from recurring rules
111 $recurringDates = $this->recurringAvailabilityService->generateDatesForTrip($tripId, $fromDate, $toDate);
112 foreach ($recurringDates as $recurringDate) {
113 $depDate = $recurringDate['departure_date'] ?? $recurringDate['date'] ?? null;
114 if (!$depDate) {
115 continue;
116 }
117 // Mirror the specific-dates composite key so manual rows can override
118 // individual rule time-slots (day tours) deterministically.
119 $dateKey = $depDate;
120 $depTime = $recurringDate['departure_time'] ?? null;
121 if (!empty($depTime)) {
122 $dateKey .= '_' . $depTime;
123 }
124
125 // Only add if no specific availability date exists (specific dates override rules)
126 if (!isset($dateMap[$dateKey])) {
127 $dateMap[$dateKey] = $this->buildAvailabilityObject($trip, $recurringDate, 'recurring_rule');
128 }
129 }
130
131 // Step 3: Fallback to trip_default if no specific availability configured
132 // This generates availability for flexible booking trips
133 if (empty($dateMap)) {
134 $dateMap = $this->generateDefaultAvailability($trip, $fromDate, $toDate);
135 }
136
137 // Sort by date
138 ksort($dateMap);
139
140 return array_values($dateMap);
141 }
142
143 /**
144 * Get booking mode information for a trip
145 *
146 * Determines whether the trip uses date-specific booking (with configured availability)
147 * or flexible booking (no specific dates configured).
148 *
149 * @param int $tripId Trip ID
150 * @return array Booking mode information with keys:
151 * - 'mode': 'date_specific' or 'flexible'
152 * - 'has_availability': boolean
153 * - 'has_dates': boolean (has specific availability dates)
154 * - 'has_rules': boolean (has recurring rules)
155 */
156 public function getBookingMode(int $tripId): array
157 {
158 // Check for specific availability dates (any date, not range-limited)
159 global $wpdb;
160 $availTable = \Yatra\Database\Tables\TripAvailabilityDatesTable::getTableName();
161 $hasSpecificDates = (bool) $wpdb->get_var(
162 $wpdb->prepare(
163 "SELECT COUNT(*) FROM {$availTable} WHERE trip_id = %d LIMIT 1",
164 $tripId
165 )
166 );
167
168 // Check for recurring rules
169 $recurringTable = \Yatra\Database\Tables\TripAvailabilityRulesTable::getTableName();
170 $hasRecurringRules = (bool) $wpdb->get_var(
171 $wpdb->prepare(
172 "SELECT COUNT(*) FROM {$recurringTable} WHERE trip_id = %d AND status = 'active' LIMIT 1",
173 $tripId
174 )
175 );
176
177 $hasAvailability = $hasSpecificDates || $hasRecurringRules;
178
179 return [
180 'mode' => $hasAvailability ? 'date_specific' : 'flexible',
181 'has_availability' => $hasAvailability,
182 'has_dates' => $hasSpecificDates,
183 'has_rules' => $hasRecurringRules,
184 ];
185 }
186
187 /**
188 * Generate default availability dates for flexible booking trips
189 *
190 * When no specific availability dates or recurring rules are configured,
191 * this generates availability based on trip defaults for the requested date range.
192 *
193 * @param object $trip Trip object
194 * @param string $fromDate Start date
195 * @param string $toDate End date
196 * @return array Array of availability objects keyed by date
197 */
198 private function generateDefaultAvailability(object $trip, string $fromDate, string $toDate): array
199 {
200 $dateMap = [];
201
202 // Respect trip's available_from and available_to if set
203 $tripAvailableFrom = !empty($trip->available_from) ? $trip->available_from : null;
204 $tripAvailableTo = !empty($trip->available_to) ? $trip->available_to : null;
205
206 // Determine actual date range
207 $startDate = $fromDate;
208 $endDate = $toDate;
209
210 if ($tripAvailableFrom && $tripAvailableFrom > $startDate) {
211 $startDate = $tripAvailableFrom;
212 }
213
214 if ($tripAvailableTo && $tripAvailableTo < $endDate) {
215 $endDate = $tripAvailableTo;
216 }
217
218 // Don't generate dates if the range is invalid
219 if ($startDate > $endDate) {
220 return [];
221 }
222
223 // Check if trip has multiple time slots (for day tours)
224 $hasTimeSlots = !empty($trip->has_default_time_slots) && $trip->trip_type === 'single_day';
225 $timeSlots = [];
226
227 if ($hasTimeSlots) {
228 // Parse time slots from JSON
229 $timeSlotsData = $trip->default_time_slots;
230 if (is_string($timeSlotsData)) {
231 $timeSlotsData = json_decode($timeSlotsData, true);
232 }
233 if (is_array($timeSlotsData) && !empty($timeSlotsData)) {
234 $timeSlots = $timeSlotsData;
235 }
236 }
237
238 // Generate daily availability for the range
239 // For flexible booking, we generate dates to show in the calendar
240 $currentDate = new \DateTime($startDate);
241 $finalDate = new \DateTime($endDate);
242
243 while ($currentDate <= $finalDate) {
244 $dateStr = $currentDate->format('Y-m-d');
245
246 if ($hasTimeSlots && !empty($timeSlots)) {
247 // Generate separate availability for each time slot
248 foreach ($timeSlots as $slot) {
249 $timeValue = $slot['time'] ?? null;
250 if (!$timeValue) continue;
251
252 $defaultData = [
253 'date' => $dateStr,
254 'departure_date' => $dateStr,
255 'departure_time' => $timeValue,
256 ];
257
258 $dateKey = $dateStr . '_' . $timeValue;
259 $dateMap[$dateKey] = $this->buildAvailabilityObject($trip, (object) $defaultData, 'trip_default');
260 }
261 } else {
262 // Single availability per date
263 $defaultData = [
264 'date' => $dateStr,
265 'departure_date' => $dateStr,
266 ];
267
268 $dateMap[$dateStr] = $this->buildAvailabilityObject($trip, (object) $defaultData, 'trip_default');
269 }
270
271 // Move to next day
272 $currentDate->modify('+1 day');
273 }
274
275 return $dateMap;
276 }
277
278 /**
279 * Build unified availability object from different sources
280 *
281 * @param object $trip Trip data
282 * @param mixed $source Source data (recurring rule, availability date, or null)
283 * @param string $sourceType Source type identifier
284 * @return object Unified availability object
285 */
286 private function buildAvailabilityObject(object $trip, $source, string $sourceType): object
287 {
288 $avail = new \stdClass();
289
290 // Get trip's pricing configuration (used as fallback for all sources).
291 // Use {@see TripPricingService::resolvePricingType} so "regular" trips do not inherit stale
292 // JSON category rows into availability objects (keeps effective_price aligned with trip row).
293 $trip_pricing_type = TripPricingService::resolvePricingType($trip);
294 $trip_price_types = $trip_pricing_type === 'traveler_based'
295 ? $this->getTripPriceTypes((int) $trip->id)
296 : [];
297 $trip_original_price = isset($trip->original_price) ? (float) $trip->original_price : null;
298 $trip_discounted_price = isset($trip->discounted_price) && (float) $trip->discounted_price > 0
299 ? (float) $trip->discounted_price
300 : (isset($trip->sale_price) && (float) $trip->sale_price > 0 ? (float) $trip->sale_price : null);
301
302 switch ($sourceType) {
303 case 'recurring_rule':
304 // From recurring rule (new engine uses departure_date/departure_time).
305 $depDate = is_array($source)
306 ? ($source['departure_date'] ?? $source['date'] ?? null)
307 : (is_object($source) ? ($source->departure_date ?? $source->date ?? null) : null);
308 $depTime = is_array($source)
309 ? ($source['departure_time'] ?? null)
310 : (is_object($source) ? ($source->departure_time ?? null) : null);
311 $ruleId = is_array($source)
312 ? ($source['rule_id'] ?? null)
313 : (is_object($source) ? ($source->rule_id ?? null) : null);
314
315 $avail->id = 'recurring_' . ($depDate ?: '') . '_' . ($ruleId ?? 0) . ($depTime ? '_' . $depTime : '');
316 $avail->trip_id = (int) $trip->id;
317 $avail->departure_date = (string) ($depDate ?? '');
318 $avail->departure_time = $depTime ?: null;
319 $avail->arrival_time = is_array($source)
320 ? ($source['arrival_time'] ?? null)
321 : (is_object($source) ? ($source->arrival_time ?? null) : null);
322
323 $seatsTotal = null;
324 if (is_array($source)) {
325 $seatsTotal = isset($source['seats_total']) ? (int) $source['seats_total'] : null;
326 } elseif (is_object($source)) {
327 $seatsTotal = isset($source->seats_total) ? (int) $source->seats_total : null;
328 }
329 if (!$seatsTotal || $seatsTotal <= 0) {
330 $seatsTotal = (int) ($trip->max_travelers ?? $trip->max_travellers ?? 0);
331 }
332 if ($seatsTotal <= 0) {
333 $seatsTotal = 20;
334 }
335
336 $avail->seats_total = $seatsTotal;
337 // Live reserved seats from bookings (virtual slots have no numeric availability_id).
338 $reserved = 0;
339 if ($avail->departure_date !== '') {
340 /** @var array{trip_id:int, departure_date:string, departure_time:?string} $args */
341 $args = apply_filters('yatra_virtual_availability_reserved_seats_args', [
342 'trip_id' => (int) $trip->id,
343 'departure_date' => (string) $avail->departure_date,
344 'departure_time' => $avail->departure_time ?: null,
345 ], $trip, $source);
346
347 $tripId = (int) ($args['trip_id'] ?? (int) $trip->id);
348 $depDate = (string) ($args['departure_date'] ?? (string) $avail->departure_date);
349 $depTime = $args['departure_time'] ?? ($avail->departure_time ?: null);
350
351 $reserved = $this->bookingRepository->countActiveSeatsForSlot(
352 $tripId,
353 $depDate,
354 is_string($depTime) ? $depTime : null
355 );
356 }
357 $reserved = (int) apply_filters('yatra_virtual_availability_reserved_seats_count', (int) $reserved, $avail, $trip, $source);
358
359 // Allow modules to override seats_total for rule dates (e.g. seasonal capacity).
360 $seatsTotal = (int) apply_filters('yatra_virtual_availability_seats_total', (int) $seatsTotal, $avail, $trip, $source);
361 $avail->seats_total = max(0, $seatsTotal);
362
363 // Derive seats from reserved + total.
364 $avail->seats_reserved = max(0, (int) $reserved);
365 $avail->seats_available = max(0, (int) $avail->seats_total - (int) $avail->seats_reserved);
366
367 // Let modules override final computed seats_available (e.g. channel allocations).
368 $avail->seats_available = max(0, (int) apply_filters('yatra_virtual_availability_seats_available', (int) $avail->seats_available, $avail, $trip, $source));
369 $avail->status = is_array($source)
370 ? (($source['status'] ?? '') ?: 'available')
371 : (is_object($source) ? (($source->status ?? '') ?: 'available') : 'available');
372 if ($avail->seats_available <= 0) {
373 $avail->status = 'sold_out';
374 }
375 $avail->is_recurring = true;
376 $avail->rule_id = $ruleId;
377 $avail->source = 'recurring_rule';
378 $avail->from_location = is_array($source)
379 ? ($source['from_location'] ?? null)
380 : (is_object($source) ? ($source->from_location ?? null) : null);
381 $avail->to_location = is_array($source)
382 ? ($source['to_location'] ?? null)
383 : (is_object($source) ? ($source->to_location ?? null) : null);
384 $avail->from_latitude = is_array($source)
385 ? ($source['from_latitude'] ?? null)
386 : (is_object($source) ? ($source->from_latitude ?? null) : null);
387 $avail->from_longitude = is_array($source)
388 ? ($source['from_longitude'] ?? null)
389 : (is_object($source) ? ($source->from_longitude ?? null) : null);
390 $avail->to_latitude = is_array($source)
391 ? ($source['to_latitude'] ?? null)
392 : (is_object($source) ? ($source->to_latitude ?? null) : null);
393 $avail->to_longitude = is_array($source)
394 ? ($source['to_longitude'] ?? null)
395 : (is_object($source) ? ($source->to_longitude ?? null) : null);
396 $avail->cutoff_hours = is_array($source)
397 ? ($source['cutoff_hours'] ?? null)
398 : (is_object($source) ? ($source->cutoff_hours ?? null) : null);
399 $alertThreshold = is_array($source)
400 ? (int) ($source['alert_threshold'] ?? 5)
401 : (int) (is_object($source) ? ($source->alert_threshold ?? 5) : 5);
402 $avail->is_sold_out = ($avail->seats_available ?? 0) <= 0;
403 $avail->is_limited = ($avail->seats_available ?? 0) > 0 && ($avail->seats_available ?? 0) <= max(1, $alertThreshold);
404 $avail->is_sold_out = (bool) apply_filters('yatra_virtual_availability_is_sold_out', (bool) $avail->is_sold_out, $avail, $trip, $source);
405 $avail->is_limited = (bool) apply_filters('yatra_virtual_availability_is_limited', (bool) $avail->is_limited, $avail, $trip, $source);
406
407 // Pricing: rule base_price → trip original_price fallback
408 $rule_price = null;
409 if (is_array($source)) {
410 $rule_price = isset($source['original_price']) && $source['original_price'] !== null
411 ? (float) $source['original_price']
412 : (isset($source['base_price']) && $source['base_price'] !== null ? (float) $source['base_price'] : null);
413 } elseif (is_object($source)) {
414 $rule_price = isset($source->original_price) && $source->original_price !== null
415 ? (float) $source->original_price
416 : (isset($source->base_price) && $source->base_price !== null ? (float) $source->base_price : null);
417 }
418 $avail->original_price = ($rule_price !== null && $rule_price > 0)
419 ? $rule_price : $trip_original_price;
420 $rule_discount = null;
421 if (is_array($source)) {
422 $rule_discount = isset($source['discounted_price']) && $source['discounted_price'] !== null
423 ? (float) $source['discounted_price']
424 : null;
425 } elseif (is_object($source)) {
426 $rule_discount = isset($source->discounted_price) && $source->discounted_price !== null
427 ? (float) $source->discounted_price
428 : null;
429 }
430 // Use rule slot discounted_price when present, otherwise inherit trip discount.
431 $avail->discounted_price = ($rule_discount !== null && $rule_discount > 0)
432 ? $rule_discount
433 : $trip_discounted_price;
434 // Convenience for frontend payloads that look for a single price number.
435 $avail->effective_price = ($avail->discounted_price !== null && (float) $avail->discounted_price > 0)
436 ? (float) $avail->discounted_price
437 : (float) ($avail->original_price ?? 0);
438
439 // Inherit trip's pricing_type
440 $avail->pricing_type = $trip_pricing_type;
441
442 // If a rule defines traveler_pricing, expose it as price_types so booking UI
443 // can render category-based pricing for rule-generated availability.
444 $travelerPricing = null;
445 if (is_array($source)) {
446 $travelerPricing = $source['traveler_pricing'] ?? null;
447 } elseif (is_object($source)) {
448 $travelerPricing = $source->traveler_pricing ?? null;
449 }
450 if (is_array($travelerPricing) && !empty($travelerPricing)) {
451 $avail->price_types = TripPricingService::resolvePriceTypes(
452 (object) ['price_types' => $travelerPricing]
453 );
454 $avail->pricing_type = 'traveler_based';
455 } else {
456 $avail->price_types = $trip_price_types;
457 }
458
459 // End dates for sidebar / JSON (rules only provide departure day)
460 $durationDays = max(1, (int) ($trip->duration_days ?? 1));
461 $offset = max(0, $durationDays - 1);
462 $dep = $avail->departure_date;
463 if ($dep !== '' && $dep !== null) {
464 $end = date('Y-m-d', strtotime((string) $dep . ' +' . $offset . ' days'));
465 $avail->arrival_date = $end;
466 $avail->return_date = $end;
467 } else {
468 $avail->arrival_date = null;
469 $avail->return_date = null;
470 }
471 break;
472
473 case 'availability_date':
474 // From specific availability date
475 $avail->id = $source->id ?? 0;
476 $avail->trip_id = (int) $trip->id;
477 $avail->departure_date = $source->departure_date ?? '';
478 $arrival = isset($source->arrival_date) ? $source->arrival_date : null;
479 $return = isset($source->return_date) ? $source->return_date : null;
480 $avail->arrival_date = $arrival;
481 $avail->return_date = ($return !== null && $return !== '') ? $return : $arrival;
482 $avail->departure_time = isset($source->departure_time) ? $source->departure_time : null;
483 $avail->arrival_time = isset($source->arrival_time) ? $source->arrival_time : null;
484 $avail->seats_total = (int) ($source->seats_total ?? 0);
485 $avail->seats_available = (int) ($source->seats_available ?? 0);
486 $avail->seats_reserved = (int) ($source->seats_reserved ?? 0);
487 $avail->status = $source->status ?? 'available';
488 $avail->is_recurring = false;
489 $avail->source = 'availability_date';
490 $avail->from_location = isset($source->from_location) ? $source->from_location : null;
491 $avail->to_location = isset($source->to_location) ? $source->to_location : null;
492 $avail->from_latitude = isset($source->from_latitude) ? $source->from_latitude : null;
493 $avail->from_longitude = isset($source->from_longitude) ? $source->from_longitude : null;
494 $avail->to_latitude = isset($source->to_latitude) ? $source->to_latitude : null;
495 $avail->to_longitude = isset($source->to_longitude) ? $source->to_longitude : null;
496 $avail->cutoff_hours = isset($source->cutoff_hours) ? $source->cutoff_hours : null;
497
498 // Pricing: availability price → trip price fallback
499 $avail_orig = isset($source->original_price) && $source->original_price !== null
500 ? (float) $source->original_price : null;
501 $avail_disc = isset($source->discounted_price) && $source->discounted_price !== null
502 ? (float) $source->discounted_price : null;
503
504 $avail->original_price = ($avail_orig !== null && $avail_orig > 0)
505 ? $avail_orig : $trip_original_price;
506 $avail->discounted_price = ($avail_disc !== null && $avail_disc > 0)
507 ? $avail_disc : $trip_discounted_price;
508
509 // Inherit trip's pricing_type
510 $avail->pricing_type = $trip_pricing_type;
511
512 // Use availability's price_types if set, otherwise trip's price_types (normalize legacy `price` keys)
513 $avail_price_types = null;
514 if (!empty($source->price_types)) {
515 $avail_price_types = is_string($source->price_types)
516 ? json_decode($source->price_types, true)
517 : $source->price_types;
518 }
519 if (!empty($avail_price_types) && is_array($avail_price_types)) {
520 $avail->price_types = TripPricingService::resolvePriceTypes(
521 (object) ['price_types' => $avail_price_types]
522 );
523 $avail->pricing_type = 'traveler_based';
524 } else {
525 $avail->price_types = $trip_price_types;
526 }
527
528 // Standard flags expected by booking UI / cards.
529 $avail->is_sold_out = ($avail->seats_available ?? 0) <= 0 || ($avail->status ?? '') === 'sold_out';
530 $avail->is_limited = ($avail->seats_available ?? 0) > 0 && ($avail->seats_available ?? 0) <= 5;
531 break;
532
533 case 'trip_default':
534 // From trip defaults (flexible booking)
535 // Can be used for single date resolution (departure_date = null)
536 // or for generating availability list (departure_date = specific date)
537 $departure_date = null;
538 if (is_object($source) && !empty($source->departure_date)) {
539 $departure_date = $source->departure_date;
540 } elseif (is_array($source) && !empty($source['departure_date'])) {
541 $departure_date = $source['departure_date'];
542 }
543
544 // Get departure time from source or trip default
545 $departure_time_value = null;
546 if (is_object($source) && !empty($source->departure_time)) {
547 $departure_time_value = $source->departure_time;
548 } elseif (is_array($source) && !empty($source['departure_time'])) {
549 $departure_time_value = $source['departure_time'];
550 } else {
551 // Use trip's default departure time
552 $departure_time_value = $trip->departure_time ?? null;
553 }
554
555 $avail->id = $departure_date ? 'default_' . $departure_date : 'default';
556 if ($departure_time_value) {
557 $avail->id .= '_' . str_replace(':', '', $departure_time_value);
558 }
559
560 $avail->trip_id = (int) $trip->id;
561 $avail->departure_date = $departure_date;
562 $avail->arrival_date = null;
563 $avail->return_date = null;
564 $avail->departure_time = $departure_time_value;
565 $avail->arrival_time = null;
566 $avail->seats_total = (int) ($trip->max_travelers ?? 20);
567 $avail->seats_available = (int) ($trip->max_travelers ?? 20);
568 $avail->seats_reserved = 0;
569 $avail->original_price = $trip_original_price;
570 $avail->discounted_price = $trip_discounted_price;
571 $avail->status = 'available';
572 $avail->is_recurring = false;
573 $avail->source = 'trip_default';
574
575 // Use trip's pricing_type and price_types
576 $avail->pricing_type = $trip_pricing_type;
577 $avail->price_types = $trip_price_types;
578 break;
579 }
580
581 // Calculate effective price via centralized TripPricingService
582 $avail->effective_price = $this->calculateEffectivePrice($avail);
583
584 // Pro filter: allows Dynamic Pricing, Itinerary Pricing, etc. to modify per-date availability
585 $avail = (object) apply_filters('yatra_resolve_availability_object', $avail, $trip, $sourceType);
586
587 return $this->normalizeResolvedAvailabilityObject($avail);
588 }
589
590 /**
591 * Ensure optional date fields exist and return_date falls back to arrival (DB / filters may omit keys).
592 */
593 private function normalizeResolvedAvailabilityObject(object $avail): object
594 {
595 foreach (['arrival_date', 'return_date', 'departure_time', 'arrival_time'] as $key) {
596 if (!property_exists($avail, $key)) {
597 $avail->{$key} = null;
598 }
599 }
600
601 $ret = $avail->return_date ?? null;
602 $arr = $avail->arrival_date ?? null;
603 if (($ret === null || $ret === '') && $arr !== null && $arr !== '') {
604 $avail->return_date = $arr;
605 }
606
607 return $avail;
608 }
609
610 /**
611 * Resolve a single day's availability from recurring rules (new rules engine).
612 *
613 * @return array|null A generated availability row (array shape) or null if no rule applies
614 */
615 private function resolveRecurringAvailabilityForDate(int $tripId, string $date, ?string $departureTime = null): ?array
616 {
617 $generated = $this->recurringAvailabilityService->generateDatesForTrip($tripId, $date, $date);
618 if (empty($generated)) {
619 return null;
620 }
621
622 foreach ($generated as $row) {
623 if (!is_array($row)) {
624 continue;
625 }
626 $depDate = $row['departure_date'] ?? null;
627 if ($depDate !== $date) {
628 continue;
629 }
630 $depTime = $row['departure_time'] ?? null;
631 if ($departureTime !== null) {
632 if ($depTime === $departureTime) {
633 return $row;
634 }
635 continue;
636 }
637 // No requested time; return first matching occurrence for that date.
638 return $row;
639 }
640
641 return null;
642 }
643
644 /**
645 * Calculate effective price based on pricing type
646 *
647 * Delegates to centralized TripPricingService for consistent pricing resolution.
648 *
649 * @param object $avail Availability object
650 * @return float Effective price
651 */
652 private function calculateEffectivePrice(object $avail): float
653 {
654 if ($avail->pricing_type === 'traveler_based' && !empty($avail->price_types)) {
655 // For traveler-based, return minimum price from categories
656 $min_price = PHP_FLOAT_MAX;
657 foreach ($avail->price_types as $pt) {
658 $price = TripPricingService::resolveCategoryEffectivePrice((array) $pt);
659 if ($price > 0 && $price < $min_price) {
660 $min_price = $price;
661 }
662 }
663 return $min_price < PHP_FLOAT_MAX ? $min_price : 0.0;
664 } else {
665 // For regular pricing: discounted → original
666 if (!empty($avail->discounted_price) && (float) $avail->discounted_price > 0) {
667 return (float) $avail->discounted_price;
668 }
669 return (float) ($avail->original_price ?? 0);
670 }
671 }
672
673 /**
674 * Get trip's price types from trips table JSON field
675 *
676 * @param int $tripId Trip ID
677 * @return array Price types array
678 */
679 private function getTripPriceTypes(int $tripId): array
680 {
681 global $wpdb;
682 $table = \Yatra\Database\Tables\TripsTable::getTableName();
683
684 $json = $wpdb->get_var(
685 $wpdb->prepare(
686 "SELECT price_types FROM {$table} WHERE id = %d",
687 $tripId
688 )
689 );
690
691 if (empty($json)) {
692 return [];
693 }
694
695 $decoded = json_decode($json, true);
696 if (!is_array($decoded)) {
697 return [];
698 }
699
700 // Map legacy `price` keys to original_price so card pricing never resolves to 0
701 $tripStub = (object) ['price_types' => $decoded];
702
703 return TripPricingService::resolvePriceTypes($tripStub);
704 }
705 }
706