PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 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 All 84 releases
yatra / app / Services / AvailabilityResolutionService.php

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

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