repository = $repository; } /** * Validate availability data */ public function validate(array $data, ?int $id = null): void { // Required fields if (empty($data['trip_id'])) { throw new \InvalidArgumentException('Trip ID is required'); } if (empty($data['departure_date'])) { throw new \InvalidArgumentException('Departure date is required'); } if (empty($data['seats_total']) || (int) $data['seats_total'] <= 0) { throw new \InvalidArgumentException('Total seats must be greater than 0'); } // Validate date format if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['departure_date'])) { throw new \InvalidArgumentException('Invalid departure date format. Use YYYY-MM-DD'); } // Validate arrival date if provided if (!empty($data['arrival_date'])) { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['arrival_date'])) { throw new \InvalidArgumentException('Invalid arrival date format. Use YYYY-MM-DD'); } // Arrival date should be after departure date if (strtotime($data['arrival_date']) < strtotime($data['departure_date'])) { throw new \InvalidArgumentException('Arrival date must be after departure date'); } } // Validate return date if provided if (!empty($data['return_date'])) { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['return_date'])) { throw new \InvalidArgumentException('Invalid return date format. Use YYYY-MM-DD'); } $compareDate = !empty($data['arrival_date']) ? $data['arrival_date'] : $data['departure_date']; if (strtotime($data['return_date']) < strtotime($compareDate)) { throw new \InvalidArgumentException('Return date must be after arrival/departure date'); } } // Validate and normalize time format if provided // Accept HH:MM, HH:MM:SS, or H:MM formats if (!empty($data['departure_time'])) { $data['departure_time'] = $this->normalizeTimeFormat($data['departure_time']); if ($data['departure_time'] === false) { throw new \InvalidArgumentException('Invalid departure time format. Use HH:MM'); } } if (!empty($data['arrival_time'])) { $data['arrival_time'] = $this->normalizeTimeFormat($data['arrival_time']); if ($data['arrival_time'] === false) { throw new \InvalidArgumentException('Invalid arrival time format. Use HH:MM'); } } // Validate status $validStatuses = ['available', 'limited', 'sold_out', 'closed', 'cancelled', 'blocked']; if (!empty($data['status']) && !in_array($data['status'], $validStatuses, true)) { throw new \InvalidArgumentException('Invalid status. Must be one of: ' . implode(', ', $validStatuses)); } // Validate pricing if (isset($data['original_price']) && (float) $data['original_price'] < 0) { throw new \InvalidArgumentException('Original price cannot be negative'); } if (isset($data['discounted_price']) && (float) $data['discounted_price'] < 0) { throw new \InvalidArgumentException('Discounted price cannot be negative'); } if (!empty($data['original_price']) && !empty($data['discounted_price'])) { if ((float) $data['discounted_price'] > (float) $data['original_price']) { throw new \InvalidArgumentException('Discounted price cannot be greater than original price'); } } // Validate seats if (isset($data['seats_available']) && (int) $data['seats_available'] < 0) { throw new \InvalidArgumentException('Available seats cannot be negative'); } if (isset($data['seats_total']) && isset($data['seats_available'])) { if ((int) $data['seats_available'] > (int) $data['seats_total']) { throw new \InvalidArgumentException('Available seats cannot exceed total seats'); } } } /** * Create availability date */ public function create(array $data): Availability { $this->validate($data); // Set default seats_available if not provided if (!isset($data['seats_available'])) { $data['seats_available'] = $data['seats_total'] ?? 0; } // Auto-calculate status based on availability if (empty($data['status'])) { $seatsAvailable = (int) ($data['seats_available'] ?? 0); $seatsTotal = (int) ($data['seats_total'] ?? 0); if ($seatsAvailable === 0) { $data['status'] = 'sold_out'; } elseif ($seatsAvailable <= ($seatsTotal * 0.2)) { $data['status'] = 'limited'; } else { $data['status'] = 'available'; } } $id = $this->repository->create($data); return $this->repository->findModel($id); } /** * Update availability date */ public function update(int $id, array $data): Availability { $existing = $this->repository->findModel($id); if (!$existing) { throw new \InvalidArgumentException('Availability date not found'); } // Merge with existing data for validation $mergedData = array_merge($existing->toArray(), $data); $this->validate($mergedData, $id); // Auto-update status based on availability if (isset($data['seats_available']) || isset($data['seats_total'])) { $seatsAvailable = (int) ($data['seats_available'] ?? $existing->seats_available); $seatsTotal = (int) ($data['seats_total'] ?? $existing->seats_total); if ($seatsAvailable === 0) { $data['status'] = 'sold_out'; } elseif ($seatsAvailable <= ($seatsTotal * 0.2)) { $data['status'] = 'limited'; } elseif (!isset($data['status'])) { $data['status'] = 'available'; } } $this->repository->update($id, $data); return $this->repository->findModel($id); } public function duplicate(int $id, array $data): Availability { $existing = $this->repository->findModel($id); if (!$existing) { throw new \InvalidArgumentException('Availability date not found'); } $newDepartureDate = isset($data['departure_date']) ? (string) $data['departure_date'] : ''; if (empty($newDepartureDate)) { throw new \InvalidArgumentException('Departure date is required'); } if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $newDepartureDate)) { throw new \InvalidArgumentException('Invalid departure date format. Use YYYY-MM-DD'); } $newDepartureTime = null; if (array_key_exists('departure_time', $data)) { $newDepartureTime = !empty($data['departure_time']) ? $this->normalizeTimeFormat((string) $data['departure_time']) : null; if ($newDepartureTime === false) { throw new \InvalidArgumentException('Invalid departure time format. Use HH:MM'); } } else { $newDepartureTime = $existing->departure_time; } if ($this->repository->existsForTripDateTime($existing->trip_id, $newDepartureDate, $newDepartureTime)) { throw new \InvalidArgumentException('Availability date already exists for the selected departure'); } $oldDepartureTs = strtotime($existing->departure_date); $newDepartureTs = strtotime($newDepartureDate); if ($oldDepartureTs === false || $newDepartureTs === false) { throw new \InvalidArgumentException('Invalid departure date'); } $shiftedArrivalDate = null; if (!empty($existing->arrival_date)) { $oldArrivalTs = strtotime($existing->arrival_date); if ($oldArrivalTs !== false) { $diffDays = (int) round(($oldArrivalTs - $oldDepartureTs) / 86400); $shiftedArrivalDate = date('Y-m-d', strtotime('+' . $diffDays . ' days', $newDepartureTs)); } } $shiftedReturnDate = null; if (!empty($existing->return_date)) { $oldReturnTs = strtotime($existing->return_date); if ($oldReturnTs !== false) { $diffDays = (int) round(($oldReturnTs - $oldDepartureTs) / 86400); $shiftedReturnDate = date('Y-m-d', strtotime('+' . $diffDays . ' days', $newDepartureTs)); } } $payload = $existing->toArray(); unset($payload['id'], $payload['created_at'], $payload['updated_at']); unset($payload['booked_seats'], $payload['total_seats'], $payload['available_seats'], $payload['waitlist_count']); $payload['departure_date'] = $newDepartureDate; $payload['departure_time'] = $newDepartureTime; $payload['arrival_date'] = $shiftedArrivalDate; $payload['return_date'] = $shiftedReturnDate; $payload['seats_total'] = (int) $existing->seats_total; $payload['seats_available'] = (int) $existing->seats_total; $payload['seats_reserved'] = 0; $payload['seats_waitlist'] = 0; if (array_key_exists('seats_total', $data)) { $payload['seats_total'] = (int) $data['seats_total']; $payload['seats_available'] = (int) $data['seats_total']; } if (array_key_exists('arrival_time', $data)) { $arrivalTime = !empty($data['arrival_time']) ? $this->normalizeTimeFormat((string) $data['arrival_time']) : null; if ($arrivalTime === false) { throw new \InvalidArgumentException('Invalid arrival time format. Use HH:MM'); } $payload['arrival_time'] = $arrivalTime; } if (array_key_exists('status', $data)) { $payload['status'] = (string) $data['status']; } $this->validate($payload); $newId = $this->repository->create($payload); return $this->repository->findModel($newId); } /** * Delete availability date */ public function delete(int $id): bool { $existing = $this->repository->findModel($id); if (!$existing) { throw new \InvalidArgumentException('Availability date not found'); } return $this->repository->delete($id); } /** * Get availability dates for a trip */ public function getByTripId(int $tripId, array $filters = []): array { return $this->repository->findByTripId($tripId, $filters); } /** * Count availability dates for a trip */ public function countByTripId(int $tripId, array $filters = []): int { return $this->repository->countByTripId($tripId, $filters); } /** * Get availability by trip ID and departure date */ public function getByTripAndDate(int $tripId, string $departureDate): ?\stdClass { $repository = new \Yatra\Repositories\AvailabilityRepository(); return $repository->findByTripIdAndDate($tripId, $departureDate); } /** * Get availability by trip ID, departure date, and optionally time. * Supports day tours with multiple time slots on the same date. * * @param int $tripId Trip ID * @param string $departureDate Departure date (YYYY-MM-DD) * @param string|null $departureTime Departure time (HH:MM:SS or HH:MM) * @return \stdClass|null Availability object or null */ public function getByTripAndDateTime(int $tripId, string $departureDate, ?string $departureTime = null): ?\stdClass { $repository = new \Yatra\Repositories\AvailabilityRepository(); return $repository->findByTripIdAndDateTime($tripId, $departureDate, $departureTime); } /** * Get availability by ID */ public function getById(int $availabilityId): ?\stdClass { $repository = new \Yatra\Repositories\AvailabilityRepository(); return $repository->find($availabilityId); } /** * Check if discount code has been used by customer */ public function getDiscountCodeUsage(int $customerId, string $discountCode): int { $bookingRepository = new \Yatra\Repositories\BookingRepository(); return $bookingRepository->countDiscountCodeUsage($customerId, $discountCode); } /** * Get booked count by availability ID */ public function getBookedCountByAvailabilityId(int $availabilityId): int { $bookingRepository = new \Yatra\Repositories\BookingRepository(); return $bookingRepository->countBookedTravelersByAvailabilityId($availabilityId); } /** * Get booking counts for multiple availability IDs */ public function getBookingCountsByAvailabilityIds(array $availabilityIds): array { $bookingRepository = new \Yatra\Repositories\BookingRepository(); return $bookingRepository->getBookingCountsByAvailabilityIds($availabilityIds); } /** * Booked travellers for a date/time, counted from the bookings themselves. * * The `availability_id` join used by getBookingCountsByAvailabilityIds only * counts bookings whose availability_id was set to this exact row — a stored * link that the booking paths don't reliably set, and that rule-generated * dates never have. That made the "Booked" column read 0 for real bookings. * * This counts by the booking's own identity — (trip, date, time) — via the * booking_departures link both checkout and manual booking always create, so * it can't desync. It is the same method the recurring-rule availability path * already uses. Pass the row's own departure_time so a date with several * departures reports each slot separately rather than the day's total. */ public function getBookedCountForSlot(int $tripId, string $date, ?string $departureTime = null): int { if ($tripId <= 0 || $date === '') { return 0; } // Dates are stored DATE-only; a datetime input would never string-match. if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $date, $m)) { $date = $m[1]; } $bookingRepository = new \Yatra\Repositories\BookingRepository(); return $bookingRepository->countActiveSeatsForSlot($tripId, $date, $departureTime); } /** * Update availability status based on booking counts */ public function updateAvailabilityStatusBasedOnBookings(int $availabilityId): bool { $availabilityRepository = new \Yatra\Repositories\AvailabilityRepository(); $bookedCount = $this->getBookedCountByAvailabilityId($availabilityId); // Get availability details $availability = $availabilityRepository->find($availabilityId); if (!$availability) { return false; } $newStatus = ($bookedCount >= $availability->seats_available) ? 'sold_out' : 'available'; return $availabilityRepository->update($availabilityId, ['status' => $newStatus]); } /** * Update booking availability IDs by trip and date */ public function updateBookingAvailabilityIds(int $tripId, array $availabilityIdByDate): int { $bookingRepository = new \Yatra\Repositories\BookingRepository(); $updatedCount = 0; foreach ($availabilityIdByDate as $date => $availabilityId) { if ($availabilityId <= 0) { continue; } $result = $bookingRepository->updateAvailabilityIdByTripAndDate($tripId, $date, $availabilityId); if ($result !== false) { $updatedCount += $result; } } return $updatedCount; } /** * Get traveler categories by IDs */ public function getTravelerCategories(array $categoryIds): array { $travelerCategoryRepository = new \Yatra\Repositories\TravelerCategoryRepository(); return $travelerCategoryRepository->getByIds($categoryIds); } /** * Get booking by ID */ public function getBookingById(int $bookingId): ?\stdClass { $bookingRepository = new \Yatra\Repositories\BookingRepository(); return $bookingRepository->find($bookingId); } /** * Get trip price types */ public function getTripPriceTypes(int $tripId): array { // Table deprecated/removed: return empty so callers fall back gracefully return []; } /** * Normalize time string to HH:MM:SS format for MySQL TIME column. * Accepts HH:MM, HH:MM:SS, H:MM, or 12-hour formats (e.g., "9:00 AM"). * * @param string $time Time string * @return string|false Normalized time (HH:MM:SS) or false if invalid */ private function normalizeTimeFormat(string $time) { $time = trim($time); if (empty($time)) { return false; } // Try parsing with strtotime (handles "9:00 AM", "14:30", etc.) $timestamp = strtotime($time); if ($timestamp !== false) { return date('H:i:s', $timestamp); } // Manual regex for HH:MM or HH:MM:SS if (preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches)) { $hours = (int) $matches[1]; $minutes = (int) $matches[2]; $seconds = isset($matches[3]) ? (int) $matches[3] : 0; if ($hours >= 0 && $hours <= 23 && $minutes >= 0 && $minutes <= 59 && $seconds >= 0 && $seconds <= 59) { return sprintf('%02d:%02d:%02d', $hours, $minutes, $seconds); } } return false; } }