PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5
Yatra – Travel Booking & Tour Operator Software v3.0.5
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 / AvailabilityService.php

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

472 lines 17.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Availability Service
4 * Business logic for trip availability dates
5 *
6 * This is a FREE feature - no Pro plugin required
7 *
8 * @package Yatra\Services
9 * @since 3.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace Yatra\Services;
15
16 use Yatra\Models\Availability;
17 use Yatra\Repositories\AvailabilityRepository;
18
19 class AvailabilityService
20 {
21 private AvailabilityRepository $repository;
22
23 public function __construct(AvailabilityRepository $repository)
24 {
25 $this->repository = $repository;
26 }
27
28 /**
29 * Validate availability data
30 */
31 public function validate(array $data, ?int $id = null): void
32 {
33 // Required fields
34 if (empty($data['trip_id'])) {
35 throw new \InvalidArgumentException('Trip ID is required');
36 }
37
38 if (empty($data['departure_date'])) {
39 throw new \InvalidArgumentException('Departure date is required');
40 }
41
42 if (empty($data['seats_total']) || (int) $data['seats_total'] <= 0) {
43 throw new \InvalidArgumentException('Total seats must be greater than 0');
44 }
45
46 // Validate date format
47 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['departure_date'])) {
48 throw new \InvalidArgumentException('Invalid departure date format. Use YYYY-MM-DD');
49 }
50
51 // Validate arrival date if provided
52 if (!empty($data['arrival_date'])) {
53 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['arrival_date'])) {
54 throw new \InvalidArgumentException('Invalid arrival date format. Use YYYY-MM-DD');
55 }
56
57 // Arrival date should be after departure date
58 if (strtotime($data['arrival_date']) < strtotime($data['departure_date'])) {
59 throw new \InvalidArgumentException('Arrival date must be after departure date');
60 }
61 }
62
63 // Validate return date if provided
64 if (!empty($data['return_date'])) {
65 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['return_date'])) {
66 throw new \InvalidArgumentException('Invalid return date format. Use YYYY-MM-DD');
67 }
68
69 $compareDate = !empty($data['arrival_date']) ? $data['arrival_date'] : $data['departure_date'];
70 if (strtotime($data['return_date']) < strtotime($compareDate)) {
71 throw new \InvalidArgumentException('Return date must be after arrival/departure date');
72 }
73 }
74
75 // Validate and normalize time format if provided
76 // Accept HH:MM, HH:MM:SS, or H:MM formats
77 if (!empty($data['departure_time'])) {
78 $data['departure_time'] = $this->normalizeTimeFormat($data['departure_time']);
79 if ($data['departure_time'] === false) {
80 throw new \InvalidArgumentException('Invalid departure time format. Use HH:MM');
81 }
82 }
83
84 if (!empty($data['arrival_time'])) {
85 $data['arrival_time'] = $this->normalizeTimeFormat($data['arrival_time']);
86 if ($data['arrival_time'] === false) {
87 throw new \InvalidArgumentException('Invalid arrival time format. Use HH:MM');
88 }
89 }
90
91 // Validate status
92 $validStatuses = ['available', 'limited', 'sold_out', 'closed', 'cancelled', 'blocked'];
93 if (!empty($data['status']) && !in_array($data['status'], $validStatuses, true)) {
94 throw new \InvalidArgumentException('Invalid status. Must be one of: ' . implode(', ', $validStatuses));
95 }
96
97 // Validate pricing
98 if (isset($data['original_price']) && (float) $data['original_price'] < 0) {
99 throw new \InvalidArgumentException('Original price cannot be negative');
100 }
101
102 if (isset($data['discounted_price']) && (float) $data['discounted_price'] < 0) {
103 throw new \InvalidArgumentException('Discounted price cannot be negative');
104 }
105
106 if (!empty($data['original_price']) && !empty($data['discounted_price'])) {
107 if ((float) $data['discounted_price'] > (float) $data['original_price']) {
108 throw new \InvalidArgumentException('Discounted price cannot be greater than original price');
109 }
110 }
111
112 // Validate seats
113 if (isset($data['seats_available']) && (int) $data['seats_available'] < 0) {
114 throw new \InvalidArgumentException('Available seats cannot be negative');
115 }
116
117 if (isset($data['seats_total']) && isset($data['seats_available'])) {
118 if ((int) $data['seats_available'] > (int) $data['seats_total']) {
119 throw new \InvalidArgumentException('Available seats cannot exceed total seats');
120 }
121 }
122 }
123
124 /**
125 * Create availability date
126 */
127 public function create(array $data): Availability
128 {
129 $this->validate($data);
130
131 // Set default seats_available if not provided
132 if (!isset($data['seats_available'])) {
133 $data['seats_available'] = $data['seats_total'] ?? 0;
134 }
135
136 // Auto-calculate status based on availability
137 if (empty($data['status'])) {
138 $seatsAvailable = (int) ($data['seats_available'] ?? 0);
139 $seatsTotal = (int) ($data['seats_total'] ?? 0);
140
141 if ($seatsAvailable === 0) {
142 $data['status'] = 'sold_out';
143 } elseif ($seatsAvailable <= ($seatsTotal * 0.2)) {
144 $data['status'] = 'limited';
145 } else {
146 $data['status'] = 'available';
147 }
148 }
149
150 $id = $this->repository->create($data);
151 return $this->repository->findModel($id);
152 }
153
154 /**
155 * Update availability date
156 */
157 public function update(int $id, array $data): Availability
158 {
159 $existing = $this->repository->findModel($id);
160 if (!$existing) {
161 throw new \InvalidArgumentException('Availability date not found');
162 }
163
164 // Merge with existing data for validation
165 $mergedData = array_merge($existing->toArray(), $data);
166 $this->validate($mergedData, $id);
167
168 // Auto-update status based on availability
169 if (isset($data['seats_available']) || isset($data['seats_total'])) {
170 $seatsAvailable = (int) ($data['seats_available'] ?? $existing->seats_available);
171 $seatsTotal = (int) ($data['seats_total'] ?? $existing->seats_total);
172
173 if ($seatsAvailable === 0) {
174 $data['status'] = 'sold_out';
175 } elseif ($seatsAvailable <= ($seatsTotal * 0.2)) {
176 $data['status'] = 'limited';
177 } elseif (!isset($data['status'])) {
178 $data['status'] = 'available';
179 }
180 }
181
182 $this->repository->update($id, $data);
183 return $this->repository->findModel($id);
184 }
185
186 public function duplicate(int $id, array $data): Availability
187 {
188 $existing = $this->repository->findModel($id);
189 if (!$existing) {
190 throw new \InvalidArgumentException('Availability date not found');
191 }
192
193 $newDepartureDate = isset($data['departure_date']) ? (string) $data['departure_date'] : '';
194 if (empty($newDepartureDate)) {
195 throw new \InvalidArgumentException('Departure date is required');
196 }
197 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $newDepartureDate)) {
198 throw new \InvalidArgumentException('Invalid departure date format. Use YYYY-MM-DD');
199 }
200
201 $newDepartureTime = null;
202 if (array_key_exists('departure_time', $data)) {
203 $newDepartureTime = !empty($data['departure_time']) ? $this->normalizeTimeFormat((string) $data['departure_time']) : null;
204 if ($newDepartureTime === false) {
205 throw new \InvalidArgumentException('Invalid departure time format. Use HH:MM');
206 }
207 } else {
208 $newDepartureTime = $existing->departure_time;
209 }
210
211 if ($this->repository->existsForTripDateTime($existing->trip_id, $newDepartureDate, $newDepartureTime)) {
212 throw new \InvalidArgumentException('Availability date already exists for the selected departure');
213 }
214
215 $oldDepartureTs = strtotime($existing->departure_date);
216 $newDepartureTs = strtotime($newDepartureDate);
217 if ($oldDepartureTs === false || $newDepartureTs === false) {
218 throw new \InvalidArgumentException('Invalid departure date');
219 }
220
221 $shiftedArrivalDate = null;
222 if (!empty($existing->arrival_date)) {
223 $oldArrivalTs = strtotime($existing->arrival_date);
224 if ($oldArrivalTs !== false) {
225 $diffDays = (int) round(($oldArrivalTs - $oldDepartureTs) / 86400);
226 $shiftedArrivalDate = date('Y-m-d', strtotime('+' . $diffDays . ' days', $newDepartureTs));
227 }
228 }
229
230 $shiftedReturnDate = null;
231 if (!empty($existing->return_date)) {
232 $oldReturnTs = strtotime($existing->return_date);
233 if ($oldReturnTs !== false) {
234 $diffDays = (int) round(($oldReturnTs - $oldDepartureTs) / 86400);
235 $shiftedReturnDate = date('Y-m-d', strtotime('+' . $diffDays . ' days', $newDepartureTs));
236 }
237 }
238
239 $payload = $existing->toArray();
240 unset($payload['id'], $payload['created_at'], $payload['updated_at']);
241 unset($payload['booked_seats'], $payload['total_seats'], $payload['available_seats'], $payload['waitlist_count']);
242
243 $payload['departure_date'] = $newDepartureDate;
244 $payload['departure_time'] = $newDepartureTime;
245 $payload['arrival_date'] = $shiftedArrivalDate;
246 $payload['return_date'] = $shiftedReturnDate;
247
248 $payload['seats_total'] = (int) $existing->seats_total;
249 $payload['seats_available'] = (int) $existing->seats_total;
250 $payload['seats_reserved'] = 0;
251 $payload['seats_waitlist'] = 0;
252
253 if (array_key_exists('seats_total', $data)) {
254 $payload['seats_total'] = (int) $data['seats_total'];
255 $payload['seats_available'] = (int) $data['seats_total'];
256 }
257
258 if (array_key_exists('arrival_time', $data)) {
259 $arrivalTime = !empty($data['arrival_time']) ? $this->normalizeTimeFormat((string) $data['arrival_time']) : null;
260 if ($arrivalTime === false) {
261 throw new \InvalidArgumentException('Invalid arrival time format. Use HH:MM');
262 }
263 $payload['arrival_time'] = $arrivalTime;
264 }
265
266 if (array_key_exists('status', $data)) {
267 $payload['status'] = (string) $data['status'];
268 }
269
270 $this->validate($payload);
271 $newId = $this->repository->create($payload);
272 return $this->repository->findModel($newId);
273 }
274
275 /**
276 * Delete availability date
277 */
278 public function delete(int $id): bool
279 {
280 $existing = $this->repository->findModel($id);
281 if (!$existing) {
282 throw new \InvalidArgumentException('Availability date not found');
283 }
284
285 return $this->repository->delete($id);
286 }
287
288 /**
289 * Get availability dates for a trip
290 */
291 public function getByTripId(int $tripId, array $filters = []): array
292 {
293 return $this->repository->findByTripId($tripId, $filters);
294 }
295
296 /**
297 * Count availability dates for a trip
298 */
299 public function countByTripId(int $tripId, array $filters = []): int
300 {
301 return $this->repository->countByTripId($tripId, $filters);
302 }
303
304 /**
305 * Get availability by trip ID and departure date
306 */
307 public function getByTripAndDate(int $tripId, string $departureDate): ?\stdClass
308 {
309 $repository = new \Yatra\Repositories\AvailabilityRepository();
310 return $repository->findByTripIdAndDate($tripId, $departureDate);
311 }
312
313 /**
314 * Get availability by trip ID, departure date, and optionally time.
315 * Supports day tours with multiple time slots on the same date.
316 *
317 * @param int $tripId Trip ID
318 * @param string $departureDate Departure date (YYYY-MM-DD)
319 * @param string|null $departureTime Departure time (HH:MM:SS or HH:MM)
320 * @return \stdClass|null Availability object or null
321 */
322 public function getByTripAndDateTime(int $tripId, string $departureDate, ?string $departureTime = null): ?\stdClass
323 {
324 $repository = new \Yatra\Repositories\AvailabilityRepository();
325 return $repository->findByTripIdAndDateTime($tripId, $departureDate, $departureTime);
326 }
327
328 /**
329 * Get availability by ID
330 */
331 public function getById(int $availabilityId): ?\stdClass
332 {
333 $repository = new \Yatra\Repositories\AvailabilityRepository();
334 return $repository->find($availabilityId);
335 }
336
337 /**
338 * Check if discount code has been used by customer
339 */
340 public function getDiscountCodeUsage(int $customerId, string $discountCode): int
341 {
342 $bookingRepository = new \Yatra\Repositories\BookingRepository();
343 return $bookingRepository->countDiscountCodeUsage($customerId, $discountCode);
344 }
345
346 /**
347 * Get booked count by availability ID
348 */
349 public function getBookedCountByAvailabilityId(int $availabilityId): int
350 {
351 $bookingRepository = new \Yatra\Repositories\BookingRepository();
352 return $bookingRepository->countBookedTravelersByAvailabilityId($availabilityId);
353 }
354
355 /**
356 * Get booking counts for multiple availability IDs
357 */
358 public function getBookingCountsByAvailabilityIds(array $availabilityIds): array
359 {
360 $bookingRepository = new \Yatra\Repositories\BookingRepository();
361 return $bookingRepository->getBookingCountsByAvailabilityIds($availabilityIds);
362 }
363
364 /**
365 * Update availability status based on booking counts
366 */
367 public function updateAvailabilityStatusBasedOnBookings(int $availabilityId): bool
368 {
369 $availabilityRepository = new \Yatra\Repositories\AvailabilityRepository();
370
371 $bookedCount = $this->getBookedCountByAvailabilityId($availabilityId);
372
373 // Get availability details
374 $availability = $availabilityRepository->find($availabilityId);
375
376 if (!$availability) {
377 return false;
378 }
379
380 $newStatus = ($bookedCount >= $availability->seats_available) ? 'sold_out' : 'available';
381
382 return $availabilityRepository->update($availabilityId, ['status' => $newStatus]);
383 }
384
385 /**
386 * Update booking availability IDs by trip and date
387 */
388 public function updateBookingAvailabilityIds(int $tripId, array $availabilityIdByDate): int
389 {
390 $bookingRepository = new \Yatra\Repositories\BookingRepository();
391
392 $updatedCount = 0;
393
394 foreach ($availabilityIdByDate as $date => $availabilityId) {
395 if ($availabilityId <= 0) {
396 continue;
397 }
398
399 $result = $bookingRepository->updateAvailabilityIdByTripAndDate($tripId, $date, $availabilityId);
400
401 if ($result !== false) {
402 $updatedCount += $result;
403 }
404 }
405
406 return $updatedCount;
407 }
408
409 /**
410 * Get traveler categories by IDs
411 */
412 public function getTravelerCategories(array $categoryIds): array
413 {
414 $travelerCategoryRepository = new \Yatra\Repositories\TravelerCategoryRepository();
415 return $travelerCategoryRepository->getByIds($categoryIds);
416 }
417
418 /**
419 * Get booking by ID
420 */
421 public function getBookingById(int $bookingId): ?\stdClass
422 {
423 $bookingRepository = new \Yatra\Repositories\BookingRepository();
424 return $bookingRepository->find($bookingId);
425 }
426
427 /**
428 * Get trip price types
429 */
430 public function getTripPriceTypes(int $tripId): array
431 {
432 // Table deprecated/removed: return empty so callers fall back gracefully
433 return [];
434 }
435
436 /**
437 * Normalize time string to HH:MM:SS format for MySQL TIME column.
438 * Accepts HH:MM, HH:MM:SS, H:MM, or 12-hour formats (e.g., "9:00 AM").
439 *
440 * @param string $time Time string
441 * @return string|false Normalized time (HH:MM:SS) or false if invalid
442 */
443 private function normalizeTimeFormat(string $time)
444 {
445 $time = trim($time);
446
447 if (empty($time)) {
448 return false;
449 }
450
451 // Try parsing with strtotime (handles "9:00 AM", "14:30", etc.)
452 $timestamp = strtotime($time);
453 if ($timestamp !== false) {
454 return date('H:i:s', $timestamp);
455 }
456
457 // Manual regex for HH:MM or HH:MM:SS
458 if (preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches)) {
459 $hours = (int) $matches[1];
460 $minutes = (int) $matches[2];
461 $seconds = isset($matches[3]) ? (int) $matches[3] : 0;
462
463 if ($hours >= 0 && $hours <= 23 && $minutes >= 0 && $minutes <= 59 && $seconds >= 0 && $seconds <= 59) {
464 return sprintf('%02d:%02d:%02d', $hours, $minutes, $seconds);
465 }
466 }
467
468 return false;
469 }
470 }
471
472