PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
yatra / app / Services / AvailabilityService.php

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

543 lines 20.7 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 // The table carries a UNIQUE KEY on (trip_id, departure_date, departure_time).
132 // Without this check the insert is rejected by the database, the repository
133 // hands back id 0, findModel(0) returns null and the declared return type
134 // raises a TypeError — which is an Error, not an Exception, so it escaped
135 // the controller's catch blocks and surfaced as a fatal 500. Rejecting it
136 // here gives the operator the same clear message duplicate() already gives.
137 $departureTime = !empty($data['departure_time'])
138 ? $this->normalizeTimeFormat((string) $data['departure_time'])
139 : null;
140 if ($departureTime === false) {
141 throw new \InvalidArgumentException('Invalid departure time format. Use HH:MM');
142 }
143 if ($this->repository->existsForTripDateTime(
144 (int) $data['trip_id'],
145 (string) $data['departure_date'],
146 $departureTime
147 )) {
148 throw new \InvalidArgumentException('Availability date already exists for the selected departure');
149 }
150
151 // Set default seats_available if not provided
152 if (!isset($data['seats_available'])) {
153 $data['seats_available'] = $data['seats_total'] ?? 0;
154 }
155
156 // Auto-calculate status based on availability
157 if (empty($data['status'])) {
158 $seatsAvailable = (int) ($data['seats_available'] ?? 0);
159 $seatsTotal = (int) ($data['seats_total'] ?? 0);
160
161 if ($seatsAvailable === 0) {
162 $data['status'] = 'sold_out';
163 } elseif ($seatsAvailable <= ($seatsTotal * 0.2)) {
164 $data['status'] = 'limited';
165 } else {
166 $data['status'] = 'available';
167 }
168 }
169
170 $id = $this->repository->create($data);
171 return $this->repository->findModel($id);
172 }
173
174 /**
175 * Update availability date
176 */
177 public function update(int $id, array $data): Availability
178 {
179 $existing = $this->repository->findModel($id);
180 if (!$existing) {
181 throw new \InvalidArgumentException('Availability date not found');
182 }
183
184 // Merge with existing data for validation
185 $mergedData = array_merge($existing->toArray(), $data);
186 $this->validate($mergedData, $id);
187
188 // Moving a date onto a slot another row already holds is rejected by the
189 // same UNIQUE KEY. wpdb::update() then returns false, which this method
190 // used to ignore — it re-read the untouched row and returned 200, so the
191 // operator was told the change saved when nothing had changed.
192 if (array_key_exists('departure_date', $data) || array_key_exists('departure_time', $data)) {
193 $targetDate = (string) ($data['departure_date'] ?? $existing->departure_date);
194 if (array_key_exists('departure_time', $data)) {
195 $targetTime = !empty($data['departure_time'])
196 ? $this->normalizeTimeFormat((string) $data['departure_time'])
197 : null;
198 if ($targetTime === false) {
199 throw new \InvalidArgumentException('Invalid departure time format. Use HH:MM');
200 }
201 } else {
202 $targetTime = $existing->departure_time;
203 }
204
205 if ($this->repository->existsForTripDateTime((int) $existing->trip_id, $targetDate, $targetTime, $id)) {
206 throw new \InvalidArgumentException('Availability date already exists for the selected departure');
207 }
208 }
209
210 // Auto-update status based on availability
211 if (isset($data['seats_available']) || isset($data['seats_total'])) {
212 $seatsAvailable = (int) ($data['seats_available'] ?? $existing->seats_available);
213 $seatsTotal = (int) ($data['seats_total'] ?? $existing->seats_total);
214
215 if ($seatsAvailable === 0) {
216 $data['status'] = 'sold_out';
217 } elseif ($seatsAvailable <= ($seatsTotal * 0.2)) {
218 $data['status'] = 'limited';
219 } elseif (!isset($data['status'])) {
220 $data['status'] = 'available';
221 }
222 }
223
224 $this->repository->update($id, $data);
225 return $this->repository->findModel($id);
226 }
227
228 public function duplicate(int $id, array $data): Availability
229 {
230 $existing = $this->repository->findModel($id);
231 if (!$existing) {
232 throw new \InvalidArgumentException('Availability date not found');
233 }
234
235 $newDepartureDate = isset($data['departure_date']) ? (string) $data['departure_date'] : '';
236 if (empty($newDepartureDate)) {
237 throw new \InvalidArgumentException('Departure date is required');
238 }
239 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $newDepartureDate)) {
240 throw new \InvalidArgumentException('Invalid departure date format. Use YYYY-MM-DD');
241 }
242
243 $newDepartureTime = null;
244 if (array_key_exists('departure_time', $data)) {
245 $newDepartureTime = !empty($data['departure_time']) ? $this->normalizeTimeFormat((string) $data['departure_time']) : null;
246 if ($newDepartureTime === false) {
247 throw new \InvalidArgumentException('Invalid departure time format. Use HH:MM');
248 }
249 } else {
250 $newDepartureTime = $existing->departure_time;
251 }
252
253 if ($this->repository->existsForTripDateTime($existing->trip_id, $newDepartureDate, $newDepartureTime)) {
254 throw new \InvalidArgumentException('Availability date already exists for the selected departure');
255 }
256
257 $oldDepartureTs = strtotime($existing->departure_date);
258 $newDepartureTs = strtotime($newDepartureDate);
259 if ($oldDepartureTs === false || $newDepartureTs === false) {
260 throw new \InvalidArgumentException('Invalid departure date');
261 }
262
263 $shiftedArrivalDate = null;
264 if (!empty($existing->arrival_date)) {
265 $oldArrivalTs = strtotime($existing->arrival_date);
266 if ($oldArrivalTs !== false) {
267 $diffDays = (int) round(($oldArrivalTs - $oldDepartureTs) / 86400);
268 $shiftedArrivalDate = date('Y-m-d', strtotime('+' . $diffDays . ' days', $newDepartureTs));
269 }
270 }
271
272 $shiftedReturnDate = null;
273 if (!empty($existing->return_date)) {
274 $oldReturnTs = strtotime($existing->return_date);
275 if ($oldReturnTs !== false) {
276 $diffDays = (int) round(($oldReturnTs - $oldDepartureTs) / 86400);
277 $shiftedReturnDate = date('Y-m-d', strtotime('+' . $diffDays . ' days', $newDepartureTs));
278 }
279 }
280
281 $payload = $existing->toArray();
282 unset($payload['id'], $payload['created_at'], $payload['updated_at']);
283 unset($payload['booked_seats'], $payload['total_seats'], $payload['available_seats'], $payload['waitlist_count']);
284
285 $payload['departure_date'] = $newDepartureDate;
286 $payload['departure_time'] = $newDepartureTime;
287 $payload['arrival_date'] = $shiftedArrivalDate;
288 $payload['return_date'] = $shiftedReturnDate;
289
290 $payload['seats_total'] = (int) $existing->seats_total;
291 $payload['seats_available'] = (int) $existing->seats_total;
292 $payload['seats_reserved'] = 0;
293 $payload['seats_waitlist'] = 0;
294
295 if (array_key_exists('seats_total', $data)) {
296 $payload['seats_total'] = (int) $data['seats_total'];
297 $payload['seats_available'] = (int) $data['seats_total'];
298 }
299
300 if (array_key_exists('arrival_time', $data)) {
301 $arrivalTime = !empty($data['arrival_time']) ? $this->normalizeTimeFormat((string) $data['arrival_time']) : null;
302 if ($arrivalTime === false) {
303 throw new \InvalidArgumentException('Invalid arrival time format. Use HH:MM');
304 }
305 $payload['arrival_time'] = $arrivalTime;
306 }
307
308 if (array_key_exists('status', $data)) {
309 $payload['status'] = (string) $data['status'];
310 }
311
312 $this->validate($payload);
313 $newId = $this->repository->create($payload);
314 return $this->repository->findModel($newId);
315 }
316
317 /**
318 * Delete availability date
319 */
320 public function delete(int $id): bool
321 {
322 $existing = $this->repository->findModel($id);
323 if (!$existing) {
324 throw new \InvalidArgumentException('Availability date not found');
325 }
326
327 return $this->repository->delete($id);
328 }
329
330 /**
331 * Get availability dates for a trip
332 */
333 public function getByTripId(int $tripId, array $filters = []): array
334 {
335 return $this->repository->findByTripId($tripId, $filters);
336 }
337
338 /**
339 * Count availability dates for a trip
340 */
341 public function countByTripId(int $tripId, array $filters = []): int
342 {
343 return $this->repository->countByTripId($tripId, $filters);
344 }
345
346 /**
347 * Get availability by trip ID and departure date
348 */
349 public function getByTripAndDate(int $tripId, string $departureDate): ?\stdClass
350 {
351 $repository = new \Yatra\Repositories\AvailabilityRepository();
352 return $repository->findByTripIdAndDate($tripId, $departureDate);
353 }
354
355 /**
356 * Get availability by trip ID, departure date, and optionally time.
357 * Supports day tours with multiple time slots on the same date.
358 *
359 * @param int $tripId Trip ID
360 * @param string $departureDate Departure date (YYYY-MM-DD)
361 * @param string|null $departureTime Departure time (HH:MM:SS or HH:MM)
362 * @return \stdClass|null Availability object or null
363 */
364 public function getByTripAndDateTime(int $tripId, string $departureDate, ?string $departureTime = null): ?\stdClass
365 {
366 $repository = new \Yatra\Repositories\AvailabilityRepository();
367 return $repository->findByTripIdAndDateTime($tripId, $departureDate, $departureTime);
368 }
369
370 /**
371 * Get availability by ID
372 */
373 public function getById(int $availabilityId): ?\stdClass
374 {
375 $repository = new \Yatra\Repositories\AvailabilityRepository();
376 return $repository->find($availabilityId);
377 }
378
379 /**
380 * Check if discount code has been used by customer
381 */
382 public function getDiscountCodeUsage(int $customerId, string $discountCode): int
383 {
384 $bookingRepository = new \Yatra\Repositories\BookingRepository();
385 return $bookingRepository->countDiscountCodeUsage($customerId, $discountCode);
386 }
387
388 /**
389 * Get booked count by availability ID
390 */
391 public function getBookedCountByAvailabilityId(int $availabilityId): int
392 {
393 $bookingRepository = new \Yatra\Repositories\BookingRepository();
394 return $bookingRepository->countBookedTravelersByAvailabilityId($availabilityId);
395 }
396
397 /**
398 * Get booking counts for multiple availability IDs
399 */
400 public function getBookingCountsByAvailabilityIds(array $availabilityIds): array
401 {
402 $bookingRepository = new \Yatra\Repositories\BookingRepository();
403 return $bookingRepository->getBookingCountsByAvailabilityIds($availabilityIds);
404 }
405
406 /**
407 * Booked travellers for a date/time, counted from the bookings themselves.
408 *
409 * The `availability_id` join used by getBookingCountsByAvailabilityIds only
410 * counts bookings whose availability_id was set to this exact row — a stored
411 * link that the booking paths don't reliably set, and that rule-generated
412 * dates never have. That made the "Booked" column read 0 for real bookings.
413 *
414 * This counts by the booking's own identity — (trip, date, time) — via the
415 * booking_departures link both checkout and manual booking always create, so
416 * it can't desync. It is the same method the recurring-rule availability path
417 * already uses. Pass the row's own departure_time so a date with several
418 * departures reports each slot separately rather than the day's total.
419 */
420 public function getBookedCountForSlot(int $tripId, string $date, ?string $departureTime = null): int
421 {
422 if ($tripId <= 0 || $date === '') {
423 return 0;
424 }
425
426 // Dates are stored DATE-only; a datetime input would never string-match.
427 if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $date, $m)) {
428 $date = $m[1];
429 }
430
431 $bookingRepository = new \Yatra\Repositories\BookingRepository();
432 return $bookingRepository->countActiveSeatsForSlot($tripId, $date, $departureTime);
433 }
434
435 /**
436 * Update availability status based on booking counts
437 */
438 public function updateAvailabilityStatusBasedOnBookings(int $availabilityId): bool
439 {
440 $availabilityRepository = new \Yatra\Repositories\AvailabilityRepository();
441
442 $bookedCount = $this->getBookedCountByAvailabilityId($availabilityId);
443
444 // Get availability details
445 $availability = $availabilityRepository->find($availabilityId);
446
447 if (!$availability) {
448 return false;
449 }
450
451 $newStatus = ($bookedCount >= $availability->seats_available) ? 'sold_out' : 'available';
452
453 return $availabilityRepository->update($availabilityId, ['status' => $newStatus]);
454 }
455
456 /**
457 * Update booking availability IDs by trip and date
458 */
459 public function updateBookingAvailabilityIds(int $tripId, array $availabilityIdByDate): int
460 {
461 $bookingRepository = new \Yatra\Repositories\BookingRepository();
462
463 $updatedCount = 0;
464
465 foreach ($availabilityIdByDate as $date => $availabilityId) {
466 if ($availabilityId <= 0) {
467 continue;
468 }
469
470 $result = $bookingRepository->updateAvailabilityIdByTripAndDate($tripId, $date, $availabilityId);
471
472 if ($result !== false) {
473 $updatedCount += $result;
474 }
475 }
476
477 return $updatedCount;
478 }
479
480 /**
481 * Get traveler categories by IDs
482 */
483 public function getTravelerCategories(array $categoryIds): array
484 {
485 $travelerCategoryRepository = new \Yatra\Repositories\TravelerCategoryRepository();
486 return $travelerCategoryRepository->getByIds($categoryIds);
487 }
488
489 /**
490 * Get booking by ID
491 */
492 public function getBookingById(int $bookingId): ?\stdClass
493 {
494 $bookingRepository = new \Yatra\Repositories\BookingRepository();
495 return $bookingRepository->find($bookingId);
496 }
497
498 /**
499 * Get trip price types
500 */
501 public function getTripPriceTypes(int $tripId): array
502 {
503 // Table deprecated/removed: return empty so callers fall back gracefully
504 return [];
505 }
506
507 /**
508 * Normalize time string to HH:MM:SS format for MySQL TIME column.
509 * Accepts HH:MM, HH:MM:SS, H:MM, or 12-hour formats (e.g., "9:00 AM").
510 *
511 * @param string $time Time string
512 * @return string|false Normalized time (HH:MM:SS) or false if invalid
513 */
514 private function normalizeTimeFormat(string $time)
515 {
516 $time = trim($time);
517
518 if (empty($time)) {
519 return false;
520 }
521
522 // Try parsing with strtotime (handles "9:00 AM", "14:30", etc.)
523 $timestamp = strtotime($time);
524 if ($timestamp !== false) {
525 return date('H:i:s', $timestamp);
526 }
527
528 // Manual regex for HH:MM or HH:MM:SS
529 if (preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches)) {
530 $hours = (int) $matches[1];
531 $minutes = (int) $matches[2];
532 $seconds = isset($matches[3]) ? (int) $matches[3] : 0;
533
534 if ($hours >= 0 && $hours <= 23 && $minutes >= 0 && $minutes <= 59 && $seconds >= 0 && $seconds <= 59) {
535 return sprintf('%02d:%02d:%02d', $hours, $minutes, $seconds);
536 }
537 }
538
539 return false;
540 }
541 }
542
543