PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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.10, at app/Services/AvailabilityResolutionService.php

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