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

543 lines 22.6 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\RecurringRuleRepository;
8 use Yatra\Repositories\DepartureRepository;
9 use Yatra\Repositories\AvailabilityRepository;
10 use Yatra\Repositories\TripRepository;
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 RecurringRuleService $recurringRuleService;
25 private AvailabilityRepository $availabilityRepository;
26 private TripRepository $tripRepository;
27 private CalculationService $calculationService;
28
29 public function __construct()
30 {
31 $recurringRuleRepository = new RecurringRuleRepository();
32 $departureRepository = new DepartureRepository();
33 $this->recurringRuleService = new RecurringRuleService($recurringRuleRepository, $departureRepository);
34 $this->availabilityRepository = new AvailabilityRepository();
35 $this->tripRepository = new TripRepository();
36 $this->calculationService = new CalculationService();
37 }
38
39 /**
40 * Resolve availability for a specific trip and date (and optionally time)
41 *
42 * Priority:
43 * 1. Availability Dates (exact date/time row from DB)
44 * 2. Recurring Rules (generated slot when no DB row)
45 * 3. Trip defaults (flexible booking)
46 *
47 * @param int $tripId Trip ID
48 * @param string $date Date in Y-m-d format
49 * @param string|null $departureTime Optional departure time for day tour time slots
50 * @return object Resolved availability data
51 */
52 public function resolveAvailabilityForDate(int $tripId, string $date, ?string $departureTime = null): object
53 {
54 // Get trip data
55 $trip = $this->tripRepository->find($tripId);
56 if (!$trip) {
57 throw new \Exception('Trip not found');
58 }
59
60 // Priority 1: Specific availability rows (sold_out, seats, blocks, price overrides)
61 $availabilityDate = $this->availabilityRepository->findByTripIdAndDateTime($tripId, $date, $departureTime);
62 if ($availabilityDate) {
63 return $this->buildAvailabilityObject($trip, $availabilityDate, 'availability_date');
64 }
65
66 // Priority 2: Recurring rules when no explicit row exists for this date/time
67 $recurringData = $this->checkRecurringRules($tripId, $date);
68 if ($recurringData) {
69 return $this->buildAvailabilityObject($trip, $recurringData, 'recurring_rule');
70 }
71
72 // Priority 3: Trip default (flexible booking / no configured calendar)
73 return $this->buildAvailabilityObject($trip, null, 'trip_default');
74 }
75
76 /**
77 * Get all availability dates for a trip (merged from all sources)
78 *
79 * @param int $tripId Trip ID
80 * @param string $fromDate Start date
81 * @param string $toDate End date
82 * @return array Array of availability objects
83 */
84 public function getAllAvailabilityDates(int $tripId, string $fromDate, string $toDate): array
85 {
86 $trip = $this->tripRepository->find($tripId);
87 if (!$trip) {
88 return [];
89 }
90
91 $allDates = [];
92 $dateMap = [];
93
94 // Step 1: Get specific availability dates
95 $specificDates = $this->availabilityRepository->findByTripIdAndDateRange($tripId, $fromDate, $toDate);
96 foreach ($specificDates as $avail) {
97 // Use composite key (date + time) to support multiple time slots on the same date (day tours)
98 $dateKey = $avail->departure_date;
99 if (!empty($avail->departure_time)) {
100 $dateKey .= '_' . $avail->departure_time;
101 }
102 $dateMap[$dateKey] = $this->buildAvailabilityObject($trip, $avail, 'availability_date');
103 }
104
105 // Step 2: Generate dates from recurring rules
106 $recurringDates = $this->recurringRuleService->generateDatesForTrip($tripId, $fromDate, $toDate);
107 foreach ($recurringDates as $recurringDate) {
108 $dateKey = $recurringDate['date'];
109
110 // Only add if no specific availability date exists (specific dates override rules)
111 if (!isset($dateMap[$dateKey])) {
112 $dateMap[$dateKey] = $this->buildAvailabilityObject($trip, $recurringDate, 'recurring_rule');
113 }
114 }
115
116 // Step 3: Fallback to trip_default if no specific availability configured
117 // This generates availability for flexible booking trips
118 if (empty($dateMap)) {
119 $dateMap = $this->generateDefaultAvailability($trip, $fromDate, $toDate);
120 }
121
122 // Sort by date
123 ksort($dateMap);
124
125 return array_values($dateMap);
126 }
127
128 /**
129 * Get booking mode information for a trip
130 *
131 * Determines whether the trip uses date-specific booking (with configured availability)
132 * or flexible booking (no specific dates configured).
133 *
134 * @param int $tripId Trip ID
135 * @return array Booking mode information with keys:
136 * - 'mode': 'date_specific' or 'flexible'
137 * - 'has_availability': boolean
138 * - 'has_dates': boolean (has specific availability dates)
139 * - 'has_rules': boolean (has recurring rules)
140 */
141 public function getBookingMode(int $tripId): array
142 {
143 // Check for specific availability dates (any date, not range-limited)
144 global $wpdb;
145 $availTable = \Yatra\Database\Tables\TripAvailabilityDatesTable::getTableName();
146 $hasSpecificDates = (bool) $wpdb->get_var(
147 $wpdb->prepare(
148 "SELECT COUNT(*) FROM {$availTable} WHERE trip_id = %d LIMIT 1",
149 $tripId
150 )
151 );
152
153 // Check for recurring rules
154 $recurringTable = \Yatra\Database\Tables\TripAvailabilityRulesTable::getTableName();
155 $hasRecurringRules = (bool) $wpdb->get_var(
156 $wpdb->prepare(
157 "SELECT COUNT(*) FROM {$recurringTable} WHERE trip_id = %d AND status = 'active' LIMIT 1",
158 $tripId
159 )
160 );
161
162 $hasAvailability = $hasSpecificDates || $hasRecurringRules;
163
164 return [
165 'mode' => $hasAvailability ? 'date_specific' : 'flexible',
166 'has_availability' => $hasAvailability,
167 'has_dates' => $hasSpecificDates,
168 'has_rules' => $hasRecurringRules,
169 ];
170 }
171
172 /**
173 * Generate default availability dates for flexible booking trips
174 *
175 * When no specific availability dates or recurring rules are configured,
176 * this generates availability based on trip defaults for the requested date range.
177 *
178 * @param object $trip Trip object
179 * @param string $fromDate Start date
180 * @param string $toDate End date
181 * @return array Array of availability objects keyed by date
182 */
183 private function generateDefaultAvailability(object $trip, string $fromDate, string $toDate): array
184 {
185 $dateMap = [];
186
187 // Respect trip's available_from and available_to if set
188 $tripAvailableFrom = !empty($trip->available_from) ? $trip->available_from : null;
189 $tripAvailableTo = !empty($trip->available_to) ? $trip->available_to : null;
190
191 // Determine actual date range
192 $startDate = $fromDate;
193 $endDate = $toDate;
194
195 if ($tripAvailableFrom && $tripAvailableFrom > $startDate) {
196 $startDate = $tripAvailableFrom;
197 }
198
199 if ($tripAvailableTo && $tripAvailableTo < $endDate) {
200 $endDate = $tripAvailableTo;
201 }
202
203 // Don't generate dates if the range is invalid
204 if ($startDate > $endDate) {
205 return [];
206 }
207
208 // Check if trip has multiple time slots (for day tours)
209 $hasTimeSlots = !empty($trip->has_default_time_slots) && $trip->trip_type === 'single_day';
210 $timeSlots = [];
211
212 if ($hasTimeSlots) {
213 // Parse time slots from JSON
214 $timeSlotsData = $trip->default_time_slots;
215 if (is_string($timeSlotsData)) {
216 $timeSlotsData = json_decode($timeSlotsData, true);
217 }
218 if (is_array($timeSlotsData) && !empty($timeSlotsData)) {
219 $timeSlots = $timeSlotsData;
220 }
221 }
222
223 // Generate daily availability for the range
224 // For flexible booking, we generate dates to show in the calendar
225 $currentDate = new \DateTime($startDate);
226 $finalDate = new \DateTime($endDate);
227
228 while ($currentDate <= $finalDate) {
229 $dateStr = $currentDate->format('Y-m-d');
230
231 if ($hasTimeSlots && !empty($timeSlots)) {
232 // Generate separate availability for each time slot
233 foreach ($timeSlots as $slot) {
234 $timeValue = $slot['time'] ?? null;
235 if (!$timeValue) continue;
236
237 $defaultData = [
238 'date' => $dateStr,
239 'departure_date' => $dateStr,
240 'departure_time' => $timeValue,
241 ];
242
243 $dateKey = $dateStr . '_' . $timeValue;
244 $dateMap[$dateKey] = $this->buildAvailabilityObject($trip, (object) $defaultData, 'trip_default');
245 }
246 } else {
247 // Single availability per date
248 $defaultData = [
249 'date' => $dateStr,
250 'departure_date' => $dateStr,
251 ];
252
253 $dateMap[$dateStr] = $this->buildAvailabilityObject($trip, (object) $defaultData, 'trip_default');
254 }
255
256 // Move to next day
257 $currentDate->modify('+1 day');
258 }
259
260 return $dateMap;
261 }
262
263 /**
264 * Check if recurring rule exists for date
265 *
266 * @param int $tripId Trip ID
267 * @param string $date Date
268 * @return array|null Recurring rule data or null
269 */
270 private function checkRecurringRules(int $tripId, string $date): ?array
271 {
272 $rules = $this->recurringRuleService->generateDatesForTrip($tripId, $date, $date);
273
274 foreach ($rules as $rule) {
275 if ($rule['date'] === $date) {
276 return $rule;
277 }
278 }
279
280 return null;
281 }
282
283 /**
284 * Build unified availability object from different sources
285 *
286 * @param object $trip Trip data
287 * @param mixed $source Source data (recurring rule, availability date, or null)
288 * @param string $sourceType Source type identifier
289 * @return object Unified availability object
290 */
291 private function buildAvailabilityObject(object $trip, $source, string $sourceType): object
292 {
293 $avail = new \stdClass();
294
295 // Get trip's pricing configuration (used as fallback for all sources).
296 // Use {@see TripPricingService::resolvePricingType} so "regular" trips do not inherit stale
297 // JSON category rows into availability objects (keeps effective_price aligned with trip row).
298 $trip_pricing_type = TripPricingService::resolvePricingType($trip);
299 $trip_price_types = $trip_pricing_type === 'traveler_based'
300 ? $this->getTripPriceTypes((int) $trip->id)
301 : [];
302 $trip_original_price = isset($trip->original_price) ? (float) $trip->original_price : null;
303 $trip_discounted_price = isset($trip->discounted_price) && (float) $trip->discounted_price > 0
304 ? (float) $trip->discounted_price
305 : (isset($trip->sale_price) && (float) $trip->sale_price > 0 ? (float) $trip->sale_price : null);
306
307 switch ($sourceType) {
308 case 'recurring_rule':
309 // From recurring rule
310 $avail->id = 'recurring_' . $source['date'] . '_' . ($source['rule_id'] ?? 0);
311 $avail->trip_id = (int) $trip->id;
312 $avail->departure_date = $source['date'];
313 $ruleCap = (int) ($source['max_capacity'] ?? 0);
314 if ($ruleCap <= 0) {
315 $ruleCap = (int) ($trip->max_travelers ?? $trip->max_travellers ?? 0);
316 }
317 if ($ruleCap <= 0) {
318 $ruleCap = 20;
319 }
320 $avail->seats_total = $ruleCap;
321 $avail->seats_available = $ruleCap;
322 $avail->seats_reserved = 0;
323 $avail->status = 'available';
324 $avail->is_recurring = true;
325 $avail->rule_id = $source['rule_id'] ?? null;
326 $avail->source = 'recurring_rule';
327
328 // Pricing: rule base_price → trip original_price fallback
329 $rule_price = isset($source['base_price']) && $source['base_price'] !== null
330 ? (float) $source['base_price'] : null;
331 $avail->original_price = ($rule_price !== null && $rule_price > 0)
332 ? $rule_price : $trip_original_price;
333 // Rules don't have discounted_price — inherit trip's discount
334 $avail->discounted_price = $trip_discounted_price;
335
336 // Inherit trip's pricing_type
337 $avail->pricing_type = $trip_pricing_type;
338
339 // Use trip's price_types (rules don't have their own)
340 $avail->price_types = $trip_price_types;
341
342 // End dates for sidebar / JSON (rules only provide departure day)
343 $durationDays = max(1, (int) ($trip->duration_days ?? 1));
344 $offset = max(0, $durationDays - 1);
345 $dep = $avail->departure_date;
346 if ($dep !== '' && $dep !== null) {
347 $end = date('Y-m-d', strtotime((string) $dep . ' +' . $offset . ' days'));
348 $avail->arrival_date = $end;
349 $avail->return_date = $end;
350 } else {
351 $avail->arrival_date = null;
352 $avail->return_date = null;
353 }
354 break;
355
356 case 'availability_date':
357 // From specific availability date
358 $avail->id = $source->id ?? 0;
359 $avail->trip_id = (int) $trip->id;
360 $avail->departure_date = $source->departure_date ?? '';
361 $arrival = isset($source->arrival_date) ? $source->arrival_date : null;
362 $return = isset($source->return_date) ? $source->return_date : null;
363 $avail->arrival_date = $arrival;
364 $avail->return_date = ($return !== null && $return !== '') ? $return : $arrival;
365 $avail->departure_time = isset($source->departure_time) ? $source->departure_time : null;
366 $avail->arrival_time = isset($source->arrival_time) ? $source->arrival_time : null;
367 $avail->seats_total = (int) ($source->seats_total ?? 0);
368 $avail->seats_available = (int) ($source->seats_available ?? 0);
369 $avail->seats_reserved = (int) ($source->seats_reserved ?? 0);
370 $avail->status = $source->status ?? 'available';
371 $avail->is_recurring = false;
372 $avail->source = 'availability_date';
373
374 // Pricing: availability price → trip price fallback
375 $avail_orig = isset($source->original_price) && $source->original_price !== null
376 ? (float) $source->original_price : null;
377 $avail_disc = isset($source->discounted_price) && $source->discounted_price !== null
378 ? (float) $source->discounted_price : null;
379
380 $avail->original_price = ($avail_orig !== null && $avail_orig > 0)
381 ? $avail_orig : $trip_original_price;
382 $avail->discounted_price = ($avail_disc !== null && $avail_disc > 0)
383 ? $avail_disc : $trip_discounted_price;
384
385 // Inherit trip's pricing_type
386 $avail->pricing_type = $trip_pricing_type;
387
388 // Use availability's price_types if set, otherwise trip's price_types (normalize legacy `price` keys)
389 $avail_price_types = null;
390 if (!empty($source->price_types)) {
391 $avail_price_types = is_string($source->price_types)
392 ? json_decode($source->price_types, true)
393 : $source->price_types;
394 }
395 if (!empty($avail_price_types) && is_array($avail_price_types)) {
396 $avail->price_types = TripPricingService::resolvePriceTypes(
397 (object) ['price_types' => $avail_price_types]
398 );
399 } else {
400 $avail->price_types = $trip_price_types;
401 }
402 break;
403
404 case 'trip_default':
405 // From trip defaults (flexible booking)
406 // Can be used for single date resolution (departure_date = null)
407 // or for generating availability list (departure_date = specific date)
408 $departure_date = null;
409 if (is_object($source) && !empty($source->departure_date)) {
410 $departure_date = $source->departure_date;
411 } elseif (is_array($source) && !empty($source['departure_date'])) {
412 $departure_date = $source['departure_date'];
413 }
414
415 // Get departure time from source or trip default
416 $departure_time_value = null;
417 if (is_object($source) && !empty($source->departure_time)) {
418 $departure_time_value = $source->departure_time;
419 } elseif (is_array($source) && !empty($source['departure_time'])) {
420 $departure_time_value = $source['departure_time'];
421 } else {
422 // Use trip's default departure time
423 $departure_time_value = $trip->departure_time ?? null;
424 }
425
426 $avail->id = $departure_date ? 'default_' . $departure_date : 'default';
427 if ($departure_time_value) {
428 $avail->id .= '_' . str_replace(':', '', $departure_time_value);
429 }
430
431 $avail->trip_id = (int) $trip->id;
432 $avail->departure_date = $departure_date;
433 $avail->arrival_date = null;
434 $avail->return_date = null;
435 $avail->departure_time = $departure_time_value;
436 $avail->arrival_time = null;
437 $avail->seats_total = (int) ($trip->max_travelers ?? 20);
438 $avail->seats_available = (int) ($trip->max_travelers ?? 20);
439 $avail->seats_reserved = 0;
440 $avail->original_price = $trip_original_price;
441 $avail->discounted_price = $trip_discounted_price;
442 $avail->status = 'available';
443 $avail->is_recurring = false;
444 $avail->source = 'trip_default';
445
446 // Use trip's pricing_type and price_types
447 $avail->pricing_type = $trip_pricing_type;
448 $avail->price_types = $trip_price_types;
449 break;
450 }
451
452 // Calculate effective price via centralized TripPricingService
453 $avail->effective_price = $this->calculateEffectivePrice($avail);
454
455 // Pro filter: allows Dynamic Pricing, Itinerary Pricing, etc. to modify per-date availability
456 $avail = (object) apply_filters('yatra_resolve_availability_object', $avail, $trip, $sourceType);
457
458 return $this->normalizeResolvedAvailabilityObject($avail);
459 }
460
461 /**
462 * Ensure optional date fields exist and return_date falls back to arrival (DB / filters may omit keys).
463 */
464 private function normalizeResolvedAvailabilityObject(object $avail): object
465 {
466 foreach (['arrival_date', 'return_date', 'departure_time', 'arrival_time'] as $key) {
467 if (!property_exists($avail, $key)) {
468 $avail->{$key} = null;
469 }
470 }
471
472 $ret = $avail->return_date ?? null;
473 $arr = $avail->arrival_date ?? null;
474 if (($ret === null || $ret === '') && $arr !== null && $arr !== '') {
475 $avail->return_date = $arr;
476 }
477
478 return $avail;
479 }
480
481 /**
482 * Calculate effective price based on pricing type
483 *
484 * Delegates to centralized TripPricingService for consistent pricing resolution.
485 *
486 * @param object $avail Availability object
487 * @return float Effective price
488 */
489 private function calculateEffectivePrice(object $avail): float
490 {
491 if ($avail->pricing_type === 'traveler_based' && !empty($avail->price_types)) {
492 // For traveler-based, return minimum price from categories
493 $min_price = PHP_FLOAT_MAX;
494 foreach ($avail->price_types as $pt) {
495 $price = TripPricingService::resolveCategoryEffectivePrice((array) $pt);
496 if ($price > 0 && $price < $min_price) {
497 $min_price = $price;
498 }
499 }
500 return $min_price < PHP_FLOAT_MAX ? $min_price : 0.0;
501 } else {
502 // For regular pricing: discounted → original
503 if (!empty($avail->discounted_price) && (float) $avail->discounted_price > 0) {
504 return (float) $avail->discounted_price;
505 }
506 return (float) ($avail->original_price ?? 0);
507 }
508 }
509
510 /**
511 * Get trip's price types from trips table JSON field
512 *
513 * @param int $tripId Trip ID
514 * @return array Price types array
515 */
516 private function getTripPriceTypes(int $tripId): array
517 {
518 global $wpdb;
519 $table = \Yatra\Database\Tables\TripsTable::getTableName();
520
521 $json = $wpdb->get_var(
522 $wpdb->prepare(
523 "SELECT price_types FROM {$table} WHERE id = %d",
524 $tripId
525 )
526 );
527
528 if (empty($json)) {
529 return [];
530 }
531
532 $decoded = json_decode($json, true);
533 if (!is_array($decoded)) {
534 return [];
535 }
536
537 // Map legacy `price` keys to original_price so card pricing never resolves to 0
538 $tripStub = (object) ['price_types' => $decoded];
539
540 return TripPricingService::resolvePriceTypes($tripStub);
541 }
542 }
543