find($bookingId); } if (!is_object($booking)) { return; } $tripId = (int) ($booking->trip_id ?? 0); $availabilityId = isset($booking->availability_id) ? (int) $booking->availability_id : 0; $travelDate = (string) ($booking->travel_date ?? ''); if ($tripId > 0 && $availabilityId <= 0 && $travelDate !== '') { // Try to infer availability_id from resolved snapshot stored in booking meta. // This is more reliable when a trip has multiple departure times on the same date. $metaRaw = $booking->meta ?? null; $meta = []; if (is_string($metaRaw) && $metaRaw !== '') { $decoded = json_decode($metaRaw, true); $meta = is_array($decoded) ? $decoded : []; } elseif (is_array($metaRaw)) { $meta = $metaRaw; } if (!empty($meta) && isset($meta['resolved_availability']) && is_array($meta['resolved_availability'])) { $ra = $meta['resolved_availability']; $metaAvailabilityId = $ra['availability_id'] ?? null; if (is_numeric($metaAvailabilityId) && (int) $metaAvailabilityId > 0) { $availabilityId = (int) $metaAvailabilityId; } else { $metaDepartureTime = isset($ra['departure_time']) ? (string) $ra['departure_time'] : ''; $metaTravelDate = isset($ra['travel_date']) ? (string) $ra['travel_date'] : $travelDate; if ($metaDepartureTime !== '') { $row = (new AvailabilityRepository())->findByTripIdAndDateTime( $tripId, $metaTravelDate, self::normalizeTimeForAvailabilityMatch($metaDepartureTime), true ); if ($row && !empty($row->id)) { $availabilityId = (int) $row->id; } } } } $availabilityId = self::resolveAvailabilityIdForBooking($bookingId, $tripId, $travelDate); if ($availabilityId > 0) { (new BookingRepository())->update($bookingId, ['availability_id' => $availabilityId]); } } if ($tripId <= 0 || $availabilityId <= 0) { return; } self::syncAvailabilityId($availabilityId, $tripId); } catch (\Throwable $e) { return; } } private static function syncAvailabilityId(int $availabilityId, int $tripId): void { $availabilityRepo = new AvailabilityRepository(); $availability = $availabilityRepo->find($availabilityId); if (!$availability) { return; } $departureTime = isset($availability->departure_time) ? (string) $availability->departure_time : ''; $bookingRepo = new BookingRepository(); $bookedCount = $bookingRepo->getTotalTravelersByTripAndAvailability( $tripId, $availabilityId, BookingRepository::getCapacityConsumingBookingStatuses() ); $seatsTotal = (int) ($availability->seats_total ?? 0); $seatsReserved = $bookedCount; $seatsAvailable = max(0, $seatsTotal - $bookedCount); $status = (string) ($availability->status ?? 'available'); if (!in_array($status, ['blocked', 'closed', 'cancelled'], true)) { if ($seatsAvailable === 0) { $status = 'sold_out'; } elseif ($seatsTotal > 0 && $seatsAvailable <= (int) ceil($seatsTotal * 0.2)) { $status = 'limited'; } else { $status = 'available'; } } // Update availability using repository $availabilityRepo->update($availabilityId, [ 'seats_available' => $seatsAvailable, 'seats_reserved' => $seatsReserved, 'status' => $status, ]); self::syncDepartureWithAvailability($tripId, $availability, $bookedCount, $seatsTotal); WaitlistPromotionService::processAvailableSlots($tripId, $availabilityId); } private static function syncDepartureWithAvailability(int $tripId, object $availability, int $bookedCount, int $seatsTotal): void { $departureRepo = new DepartureRepository(); $date = (string) ($availability->departure_date ?? ''); $time = (string) ($availability->departure_time ?? ''); $normalizedTime = ''; if ($time !== '') { $ts = strtotime($time); if ($ts !== false) { $normalizedTime = date('H:i:s', $ts); } } if ($date === '') { return; } // Find a matching departure by trip + date (+ time if provided) $departure = null; $candidateTimes = array_values(array_unique(array_filter([$normalizedTime, $time], function ($t) { return $t !== ''; }))); foreach ($candidateTimes as $candidateTime) { $departure = $departureRepo->findByTripIdAndStartDate($tripId, $date, $candidateTime); if ($departure) { break; } } if (!$departure) { $departure = $departureRepo->findByTripIdAndStartDate($tripId, $date, null); } if (!$departure) { return; } // booked_count is sourced from the actual bookings table — it // is the truth. Reflect it to the departure row WITHOUT // clamping to max_capacity. Clamping previously hid oversells // from operators: if direct + OTA bookings legitimately // exceeded capacity (always an OTA-side acceptance — Yatra's // direct checkout enforces capacity), the departure showed // "full" instead of "oversold by N", and operators had no way // to spot the situation needing reconciliation. Allowing the // overshow makes the problem visible so it can be addressed. $updated = [ 'max_capacity' => $seatsTotal, 'booked_count' => max(0, $bookedCount), ]; $departureRepo->update((int) $departure->id, $updated); } /** * Hook to sync departure capacity when availability is updated */ public static function onAvailabilityUpdate(int $availabilityId): void { $availabilityRepo = new AvailabilityRepository(); $availability = $availabilityRepo->find($availabilityId); if ($availability) { self::syncAvailabilityId($availabilityId, (int) $availability->trip_id); } } /** * Hook to ensure departure capacity always matches availability on save */ public static function onDepartureSave(int $departureId): void { // Prevent infinite loops static $processing = []; if (isset($processing[$departureId])) { return; } $processing[$departureId] = true; $departureRepo = new DepartureRepository(); $departure = $departureRepo->find($departureId); if ($departure) { $date = $departure->start_date ?: $departure->date; // Get capacity from availability $capacityService = new CapacityService(); $correctCapacity = $capacityService->getCapacityForDate($departure->trip_id, $date, $departure->time ?? null); if ($correctCapacity > 0 && $departure->max_capacity !== $correctCapacity) { // Remove hook temporarily to prevent infinite loop remove_action('yatra_departure_saved', [self::class, 'onDepartureSave'], 10); $departureRepo->update($departureId, [ 'max_capacity' => $correctCapacity ]); // Re-add hook add_action('yatra_departure_saved', [self::class, 'onDepartureSave'], 10, 1); } } unset($processing[$departureId]); } /** * Infer yatra_trip_availability_dates.id when the booking row missed availability_id * (common when checkout omits it). Uses booking→departure time to disambiguate multiple slots per day. */ private static function resolveAvailabilityIdForBooking(int $bookingId, int $tripId, string $travelDate): int { $availabilityRepo = new AvailabilityRepository(); $bdRepo = new BookingDepartureRepository(); $depId = $bdRepo->getDepartureIdForBooking($bookingId); if ($depId) { $depRepo = new DepartureRepository(); $dep = $depRepo->find($depId); if (is_object($dep)) { $date = (string) ($dep->start_date ?? $dep->date ?? ''); if ($date === '') { $date = $travelDate; } $timeRaw = isset($dep->time) ? trim((string) $dep->time) : ''; if ($timeRaw !== '' && $timeRaw !== '00:00:00') { $candidates = array_values(array_unique(array_filter([ $timeRaw, self::normalizeTimeForAvailabilityMatch($timeRaw), ]))); foreach ($candidates as $t) { $row = $availabilityRepo->findByTripIdAndDateTime($tripId, $date, $t, true); if ($row && !empty($row->id)) { return (int) $row->id; } } } $row = $availabilityRepo->findByTripIdAndDateTime($tripId, $date, null, true); if ($row && !empty($row->id)) { return (int) $row->id; } } } $records = $availabilityRepo->findByTripAndDate($tripId, $travelDate); if (count($records) === 1 && !empty($records[0]->id)) { return (int) $records[0]->id; } return 0; } private static function normalizeTimeForAvailabilityMatch(string $time): string { $ts = strtotime($time); return $ts !== false ? date('H:i:s', $ts) : $time; } }