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

539 lines 22.3 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 $trip_pricing_type = $trip->pricing_type ?? 'regular';
297 $trip_price_types = $this->getTripPriceTypes((int) $trip->id);
298 $trip_original_price = isset($trip->original_price) ? (float) $trip->original_price : null;
299 $trip_discounted_price = isset($trip->discounted_price) && (float) $trip->discounted_price > 0
300 ? (float) $trip->discounted_price
301 : (isset($trip->sale_price) && (float) $trip->sale_price > 0 ? (float) $trip->sale_price : null);
302
303 switch ($sourceType) {
304 case 'recurring_rule':
305 // From recurring rule
306 $avail->id = 'recurring_' . $source['date'] . '_' . ($source['rule_id'] ?? 0);
307 $avail->trip_id = (int) $trip->id;
308 $avail->departure_date = $source['date'];
309 $ruleCap = (int) ($source['max_capacity'] ?? 0);
310 if ($ruleCap <= 0) {
311 $ruleCap = (int) ($trip->max_travelers ?? $trip->max_travellers ?? 0);
312 }
313 if ($ruleCap <= 0) {
314 $ruleCap = 20;
315 }
316 $avail->seats_total = $ruleCap;
317 $avail->seats_available = $ruleCap;
318 $avail->seats_reserved = 0;
319 $avail->status = 'available';
320 $avail->is_recurring = true;
321 $avail->rule_id = $source['rule_id'] ?? null;
322 $avail->source = 'recurring_rule';
323
324 // Pricing: rule base_price → trip original_price fallback
325 $rule_price = isset($source['base_price']) && $source['base_price'] !== null
326 ? (float) $source['base_price'] : null;
327 $avail->original_price = ($rule_price !== null && $rule_price > 0)
328 ? $rule_price : $trip_original_price;
329 // Rules don't have discounted_price — inherit trip's discount
330 $avail->discounted_price = $trip_discounted_price;
331
332 // Inherit trip's pricing_type
333 $avail->pricing_type = $trip_pricing_type;
334
335 // Use trip's price_types (rules don't have their own)
336 $avail->price_types = $trip_price_types;
337
338 // End dates for sidebar / JSON (rules only provide departure day)
339 $durationDays = max(1, (int) ($trip->duration_days ?? 1));
340 $offset = max(0, $durationDays - 1);
341 $dep = $avail->departure_date;
342 if ($dep !== '' && $dep !== null) {
343 $end = date('Y-m-d', strtotime((string) $dep . ' +' . $offset . ' days'));
344 $avail->arrival_date = $end;
345 $avail->return_date = $end;
346 } else {
347 $avail->arrival_date = null;
348 $avail->return_date = null;
349 }
350 break;
351
352 case 'availability_date':
353 // From specific availability date
354 $avail->id = $source->id ?? 0;
355 $avail->trip_id = (int) $trip->id;
356 $avail->departure_date = $source->departure_date ?? '';
357 $arrival = isset($source->arrival_date) ? $source->arrival_date : null;
358 $return = isset($source->return_date) ? $source->return_date : null;
359 $avail->arrival_date = $arrival;
360 $avail->return_date = ($return !== null && $return !== '') ? $return : $arrival;
361 $avail->departure_time = isset($source->departure_time) ? $source->departure_time : null;
362 $avail->arrival_time = isset($source->arrival_time) ? $source->arrival_time : null;
363 $avail->seats_total = (int) ($source->seats_total ?? 0);
364 $avail->seats_available = (int) ($source->seats_available ?? 0);
365 $avail->seats_reserved = (int) ($source->seats_reserved ?? 0);
366 $avail->status = $source->status ?? 'available';
367 $avail->is_recurring = false;
368 $avail->source = 'availability_date';
369
370 // Pricing: availability price → trip price fallback
371 $avail_orig = isset($source->original_price) && $source->original_price !== null
372 ? (float) $source->original_price : null;
373 $avail_disc = isset($source->discounted_price) && $source->discounted_price !== null
374 ? (float) $source->discounted_price : null;
375
376 $avail->original_price = ($avail_orig !== null && $avail_orig > 0)
377 ? $avail_orig : $trip_original_price;
378 $avail->discounted_price = ($avail_disc !== null && $avail_disc > 0)
379 ? $avail_disc : $trip_discounted_price;
380
381 // Inherit trip's pricing_type
382 $avail->pricing_type = $trip_pricing_type;
383
384 // Use availability's price_types if set, otherwise trip's price_types (normalize legacy `price` keys)
385 $avail_price_types = null;
386 if (!empty($source->price_types)) {
387 $avail_price_types = is_string($source->price_types)
388 ? json_decode($source->price_types, true)
389 : $source->price_types;
390 }
391 if (!empty($avail_price_types) && is_array($avail_price_types)) {
392 $avail->price_types = TripPricingService::resolvePriceTypes(
393 (object) ['price_types' => $avail_price_types]
394 );
395 } else {
396 $avail->price_types = $trip_price_types;
397 }
398 break;
399
400 case 'trip_default':
401 // From trip defaults (flexible booking)
402 // Can be used for single date resolution (departure_date = null)
403 // or for generating availability list (departure_date = specific date)
404 $departure_date = null;
405 if (is_object($source) && !empty($source->departure_date)) {
406 $departure_date = $source->departure_date;
407 } elseif (is_array($source) && !empty($source['departure_date'])) {
408 $departure_date = $source['departure_date'];
409 }
410
411 // Get departure time from source or trip default
412 $departure_time_value = null;
413 if (is_object($source) && !empty($source->departure_time)) {
414 $departure_time_value = $source->departure_time;
415 } elseif (is_array($source) && !empty($source['departure_time'])) {
416 $departure_time_value = $source['departure_time'];
417 } else {
418 // Use trip's default departure time
419 $departure_time_value = $trip->departure_time ?? null;
420 }
421
422 $avail->id = $departure_date ? 'default_' . $departure_date : 'default';
423 if ($departure_time_value) {
424 $avail->id .= '_' . str_replace(':', '', $departure_time_value);
425 }
426
427 $avail->trip_id = (int) $trip->id;
428 $avail->departure_date = $departure_date;
429 $avail->arrival_date = null;
430 $avail->return_date = null;
431 $avail->departure_time = $departure_time_value;
432 $avail->arrival_time = null;
433 $avail->seats_total = (int) ($trip->max_travelers ?? 20);
434 $avail->seats_available = (int) ($trip->max_travelers ?? 20);
435 $avail->seats_reserved = 0;
436 $avail->original_price = $trip_original_price;
437 $avail->discounted_price = $trip_discounted_price;
438 $avail->status = 'available';
439 $avail->is_recurring = false;
440 $avail->source = 'trip_default';
441
442 // Use trip's pricing_type and price_types
443 $avail->pricing_type = $trip_pricing_type;
444 $avail->price_types = $trip_price_types;
445 break;
446 }
447
448 // Calculate effective price via centralized TripPricingService
449 $avail->effective_price = $this->calculateEffectivePrice($avail);
450
451 // Pro filter: allows Dynamic Pricing, Itinerary Pricing, etc. to modify per-date availability
452 $avail = (object) apply_filters('yatra_resolve_availability_object', $avail, $trip, $sourceType);
453
454 return $this->normalizeResolvedAvailabilityObject($avail);
455 }
456
457 /**
458 * Ensure optional date fields exist and return_date falls back to arrival (DB / filters may omit keys).
459 */
460 private function normalizeResolvedAvailabilityObject(object $avail): object
461 {
462 foreach (['arrival_date', 'return_date', 'departure_time', 'arrival_time'] as $key) {
463 if (!property_exists($avail, $key)) {
464 $avail->{$key} = null;
465 }
466 }
467
468 $ret = $avail->return_date ?? null;
469 $arr = $avail->arrival_date ?? null;
470 if (($ret === null || $ret === '') && $arr !== null && $arr !== '') {
471 $avail->return_date = $arr;
472 }
473
474 return $avail;
475 }
476
477 /**
478 * Calculate effective price based on pricing type
479 *
480 * Delegates to centralized TripPricingService for consistent pricing resolution.
481 *
482 * @param object $avail Availability object
483 * @return float Effective price
484 */
485 private function calculateEffectivePrice(object $avail): float
486 {
487 if ($avail->pricing_type === 'traveler_based' && !empty($avail->price_types)) {
488 // For traveler-based, return minimum price from categories
489 $min_price = PHP_FLOAT_MAX;
490 foreach ($avail->price_types as $pt) {
491 $price = TripPricingService::resolveCategoryEffectivePrice((array) $pt);
492 if ($price > 0 && $price < $min_price) {
493 $min_price = $price;
494 }
495 }
496 return $min_price < PHP_FLOAT_MAX ? $min_price : 0.0;
497 } else {
498 // For regular pricing: discounted → original
499 if (!empty($avail->discounted_price) && (float) $avail->discounted_price > 0) {
500 return (float) $avail->discounted_price;
501 }
502 return (float) ($avail->original_price ?? 0);
503 }
504 }
505
506 /**
507 * Get trip's price types from trips table JSON field
508 *
509 * @param int $tripId Trip ID
510 * @return array Price types array
511 */
512 private function getTripPriceTypes(int $tripId): array
513 {
514 global $wpdb;
515 $table = \Yatra\Database\Tables\TripsTable::getTableName();
516
517 $json = $wpdb->get_var(
518 $wpdb->prepare(
519 "SELECT price_types FROM {$table} WHERE id = %d",
520 $tripId
521 )
522 );
523
524 if (empty($json)) {
525 return [];
526 }
527
528 $decoded = json_decode($json, true);
529 if (!is_array($decoded)) {
530 return [];
531 }
532
533 // Map legacy `price` keys to original_price so card pricing never resolves to 0
534 $tripStub = (object) ['price_types' => $decoded];
535
536 return TripPricingService::resolvePriceTypes($tripStub);
537 }
538 }
539