PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 trunk, at app/Services/AvailabilityService.php

501 lines 18.4 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 * Booked travellers for a date/time, counted from the bookings themselves.
366 *
367 * The `availability_id` join used by getBookingCountsByAvailabilityIds only
368 * counts bookings whose availability_id was set to this exact row — a stored
369 * link that the booking paths don't reliably set, and that rule-generated
370 * dates never have. That made the "Booked" column read 0 for real bookings.
371 *
372 * This counts by the booking's own identity — (trip, date, time) — via the
373 * booking_departures link both checkout and manual booking always create, so
374 * it can't desync. It is the same method the recurring-rule availability path
375 * already uses. Pass the row's own departure_time so a date with several
376 * departures reports each slot separately rather than the day's total.
377 */
378 public function getBookedCountForSlot(int $tripId, string $date, ?string $departureTime = null): int
379 {
380 if ($tripId <= 0 || $date === '') {
381 return 0;
382 }
383
384 // Dates are stored DATE-only; a datetime input would never string-match.
385 if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $date, $m)) {
386 $date = $m[1];
387 }
388
389 $bookingRepository = new \Yatra\Repositories\BookingRepository();
390 return $bookingRepository->countActiveSeatsForSlot($tripId, $date, $departureTime);
391 }
392
393 /**
394 * Update availability status based on booking counts
395 */
396 public function updateAvailabilityStatusBasedOnBookings(int $availabilityId): bool
397 {
398 $availabilityRepository = new \Yatra\Repositories\AvailabilityRepository();
399
400 $bookedCount = $this->getBookedCountByAvailabilityId($availabilityId);
401
402 // Get availability details
403 $availability = $availabilityRepository->find($availabilityId);
404
405 if (!$availability) {
406 return false;
407 }
408
409 $newStatus = ($bookedCount >= $availability->seats_available) ? 'sold_out' : 'available';
410
411 return $availabilityRepository->update($availabilityId, ['status' => $newStatus]);
412 }
413
414 /**
415 * Update booking availability IDs by trip and date
416 */
417 public function updateBookingAvailabilityIds(int $tripId, array $availabilityIdByDate): int
418 {
419 $bookingRepository = new \Yatra\Repositories\BookingRepository();
420
421 $updatedCount = 0;
422
423 foreach ($availabilityIdByDate as $date => $availabilityId) {
424 if ($availabilityId <= 0) {
425 continue;
426 }
427
428 $result = $bookingRepository->updateAvailabilityIdByTripAndDate($tripId, $date, $availabilityId);
429
430 if ($result !== false) {
431 $updatedCount += $result;
432 }
433 }
434
435 return $updatedCount;
436 }
437
438 /**
439 * Get traveler categories by IDs
440 */
441 public function getTravelerCategories(array $categoryIds): array
442 {
443 $travelerCategoryRepository = new \Yatra\Repositories\TravelerCategoryRepository();
444 return $travelerCategoryRepository->getByIds($categoryIds);
445 }
446
447 /**
448 * Get booking by ID
449 */
450 public function getBookingById(int $bookingId): ?\stdClass
451 {
452 $bookingRepository = new \Yatra\Repositories\BookingRepository();
453 return $bookingRepository->find($bookingId);
454 }
455
456 /**
457 * Get trip price types
458 */
459 public function getTripPriceTypes(int $tripId): array
460 {
461 // Table deprecated/removed: return empty so callers fall back gracefully
462 return [];
463 }
464
465 /**
466 * Normalize time string to HH:MM:SS format for MySQL TIME column.
467 * Accepts HH:MM, HH:MM:SS, H:MM, or 12-hour formats (e.g., "9:00 AM").
468 *
469 * @param string $time Time string
470 * @return string|false Normalized time (HH:MM:SS) or false if invalid
471 */
472 private function normalizeTimeFormat(string $time)
473 {
474 $time = trim($time);
475
476 if (empty($time)) {
477 return false;
478 }
479
480 // Try parsing with strtotime (handles "9:00 AM", "14:30", etc.)
481 $timestamp = strtotime($time);
482 if ($timestamp !== false) {
483 return date('H:i:s', $timestamp);
484 }
485
486 // Manual regex for HH:MM or HH:MM:SS
487 if (preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches)) {
488 $hours = (int) $matches[1];
489 $minutes = (int) $matches[2];
490 $seconds = isset($matches[3]) ? (int) $matches[3] : 0;
491
492 if ($hours >= 0 && $hours <= 23 && $minutes >= 0 && $minutes <= 59 && $seconds >= 0 && $seconds <= 59) {
493 return sprintf('%02d:%02d:%02d', $hours, $minutes, $seconds);
494 }
495 }
496
497 return false;
498 }
499 }
500
501