PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.9
Yatra – Travel Booking & Tour Operator Software v3.0.2.9
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 2.0.1 2.0.10 All 83 releases
yatra / app / Services / RecurringAvailabilityService.php

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

586 lines 21.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Recurring Availability Service
4 * Handles business logic for recurring availability rules and date generation
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\Repositories\RecurringAvailabilityRepository;
17
18 class RecurringAvailabilityService
19 {
20 private RecurringAvailabilityRepository $repository;
21
22 public function __construct(RecurringAvailabilityRepository $repository)
23 {
24 $this->repository = $repository;
25 }
26
27 /**
28 * Validate rule data
29 */
30 public function validate(array $data, ?int $id = null): void
31 {
32 // For updates (when $id is provided), allow partial updates
33 $isUpdate = $id !== null;
34
35 // If this is just a status update or other partial update, skip full validation
36 $isPartialUpdate = $isUpdate && count($data) <= 2; // status, or status + one other field
37
38 if ($isPartialUpdate) {
39 // For partial updates, only validate what's provided
40 if (isset($data['status']) && !in_array($data['status'], ['active', 'inactive'], true)) {
41 throw new \InvalidArgumentException('Invalid status. Must be active or inactive');
42 }
43 return; // Skip full validation for partial updates
44 }
45
46 // Full validation for create or complete updates
47 // Required fields
48 if (empty($data['trip_id'])) {
49 throw new \InvalidArgumentException('Trip ID is required');
50 }
51
52 if (empty($data['rule_type'])) {
53 throw new \InvalidArgumentException('Rule type is required');
54 }
55
56 if (empty($data['start_date'])) {
57 throw new \InvalidArgumentException('Start date is required');
58 }
59
60 // Validate rule type
61 $validTypes = ['weekly', 'monthly', 'interval'];
62 if (!in_array($data['rule_type'], $validTypes, true)) {
63 throw new \InvalidArgumentException('Invalid rule type. Must be: ' . implode(', ', $validTypes));
64 }
65
66 // Validate based on rule type
67 switch ($data['rule_type']) {
68 case 'weekly':
69 if (empty($data['days_of_week'])) {
70 throw new \InvalidArgumentException('Days of week is required for weekly rules');
71 }
72 // Validate days are 0-6
73 $days = is_array($data['days_of_week'])
74 ? $data['days_of_week']
75 : explode(',', $data['days_of_week']);
76 foreach ($days as $day) {
77 if ((int) $day < 0 || (int) $day > 6) {
78 throw new \InvalidArgumentException('Days of week must be 0-6 (Sun-Sat)');
79 }
80 }
81 break;
82
83 case 'monthly':
84 if (empty($data['week_of_month'])) {
85 throw new \InvalidArgumentException('Week of month is required for monthly rules');
86 }
87 if (!isset($data['day_of_week']) || $data['day_of_week'] === '') {
88 throw new \InvalidArgumentException('Day of week is required for monthly rules');
89 }
90 $validWeeks = ['first', 'second', 'third', 'fourth', 'last'];
91 if (!in_array($data['week_of_month'], $validWeeks, true)) {
92 throw new \InvalidArgumentException('Invalid week of month');
93 }
94 break;
95
96 case 'interval':
97 if (empty($data['interval_days']) || (int) $data['interval_days'] < 1) {
98 throw new \InvalidArgumentException('Interval days is required and must be at least 1');
99 }
100 break;
101 }
102
103 // Validate date format
104 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['start_date'])) {
105 throw new \InvalidArgumentException('Invalid start date format. Use YYYY-MM-DD');
106 }
107
108 if (!empty($data['end_date'])) {
109 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['end_date'])) {
110 throw new \InvalidArgumentException('Invalid end date format. Use YYYY-MM-DD');
111 }
112 if (strtotime($data['end_date']) < strtotime($data['start_date'])) {
113 throw new \InvalidArgumentException('End date must be after start date');
114 }
115 }
116
117 // Validate pricing
118 if (isset($data['original_price']) && (float) $data['original_price'] < 0) {
119 throw new \InvalidArgumentException('Price cannot be negative');
120 }
121
122 if (isset($data['seats_total']) && (int) $data['seats_total'] < 1) {
123 throw new \InvalidArgumentException('Seats must be at least 1');
124 }
125 }
126
127 /**
128 * Create a new rule
129 */
130 public function create(array $data): int
131 {
132 $this->validate($data);
133
134 // Convert days array to string if needed
135 if (isset($data['days_of_week']) && is_array($data['days_of_week'])) {
136 $data['days_of_week'] = implode(',', $data['days_of_week']);
137 }
138
139 return $this->repository->create($data);
140 }
141
142 /**
143 * Update a rule
144 */
145 public function update(int $id, array $data): bool
146 {
147 $this->validate($data, $id);
148
149 // Convert days array to string if needed
150 if (isset($data['days_of_week']) && is_array($data['days_of_week'])) {
151 $data['days_of_week'] = implode(',', $data['days_of_week']);
152 }
153
154 return $this->repository->update($id, $data);
155 }
156
157 /**
158 * Delete a rule
159 */
160 public function delete(int $id): bool
161 {
162 return $this->repository->delete($id);
163 }
164
165 /**
166 * Get rules by trip ID
167 */
168 public function getByTripId(int $tripId, array $filters = []): array
169 {
170 return $this->repository->findByTripId($tripId, $filters);
171 }
172
173 /**
174 * Count rules by trip ID
175 */
176 public function countByTripId(int $tripId, array $filters = []): int
177 {
178 return $this->repository->countByTripId($tripId, $filters);
179 }
180
181 /**
182 * Get status counts for recurring rules by trip ID
183 */
184 public function getStatusCounts(int $tripId): array
185 {
186 return $this->repository->getStatusCounts(['trip_id' => $tripId]);
187 }
188
189 /**
190 * Find rule by ID
191 */
192 public function find(int $id): ?object
193 {
194 return $this->repository->find($id);
195 }
196
197 /**
198 * Generate availability dates from rules for a trip within a date range
199 */
200 public function generateDatesForTrip(int $tripId, string $fromDate, string $toDate): array
201 {
202 $rules = $this->repository->getActiveRulesForDateRange($tripId, $fromDate, $toDate);
203
204 $allDates = [];
205
206 foreach ($rules as $rule) {
207 $dates = $this->generateDatesFromRule($rule, $fromDate, $toDate);
208 $allDates = array_merge($allDates, $dates);
209 }
210
211 // Sort by date
212 usort($allDates, function ($a, $b) {
213 return strcmp($a['departure_date'], $b['departure_date']);
214 });
215
216 // Remove duplicates (keep first occurrence - higher priority rule)
217 $uniqueDates = [];
218 $seenDates = [];
219
220 foreach ($allDates as $date) {
221 $key = $date['departure_date'] . '_' . ($date['departure_time'] ?? '');
222 if (!isset($seenDates[$key])) {
223 $seenDates[$key] = true;
224 $uniqueDates[] = $date;
225 }
226 }
227
228 return $uniqueDates;
229 }
230
231 /**
232 * Generate dates from a single rule
233 */
234 public function generateDatesFromRule(object $rule, string $fromDate, string $toDate): array
235 {
236 // Clamp dates to rule's active period
237 $ruleStart = $rule->start_date;
238 $ruleEnd = $rule->end_date ?: $toDate;
239
240 $effectiveFrom = max($fromDate, $ruleStart);
241 $effectiveTo = min($toDate, $ruleEnd);
242
243 if ($effectiveFrom > $effectiveTo) {
244 return [];
245 }
246
247 $dates = [];
248
249 switch ($rule->rule_type) {
250 case 'weekly':
251 $dates = $this->generateWeeklyDates($rule, $effectiveFrom, $effectiveTo);
252 break;
253 case 'monthly':
254 $dates = $this->generateMonthlyDates($rule, $effectiveFrom, $effectiveTo);
255 break;
256 case 'interval':
257 $dates = $this->generateIntervalDates($rule, $effectiveFrom, $effectiveTo);
258 break;
259 }
260
261 return $dates;
262 }
263
264 /**
265 * Generate weekly recurring dates
266 */
267 private function generateWeeklyDates(object $rule, string $fromDate, string $toDate): array
268 {
269 $dates = [];
270 $targetDays = $rule->days_of_week_array;
271 $excludedDates = $rule->excluded_dates;
272 $selectedMonths = !empty($rule->months) ? $rule->months : [];
273
274 $current = strtotime($fromDate);
275 $end = strtotime($toDate);
276 $today = strtotime('today');
277
278 while ($current <= $end) {
279 $dayOfWeek = (int) date('w', $current);
280 $dateStr = date('Y-m-d', $current);
281 $month = (int) date('n', $current); // 1-12
282
283 // Check if month is allowed (if months filter is set)
284 if (!empty($selectedMonths) && !in_array($month, $selectedMonths, true)) {
285 $current = strtotime('+1 day', $current);
286 continue;
287 }
288
289 if (in_array($dayOfWeek, $targetDays, true)) {
290 // Check if not excluded
291 if (!in_array($dateStr, $excludedDates, true)) {
292 // Check cutoff
293 if ($this->isBookable($current, $rule)) {
294 $generatedDates = $this->createAvailabilityFromRule($rule, $dateStr, $dayOfWeek);
295 $dates = array_merge($dates, $generatedDates);
296 }
297 }
298 }
299
300 $current = strtotime('+1 day', $current);
301 }
302
303 return $dates;
304 }
305
306 /**
307 * Generate monthly recurring dates (e.g., "last Sunday of each month")
308 */
309 private function generateMonthlyDates(object $rule, string $fromDate, string $toDate): array
310 {
311 $dates = [];
312 $weekOfMonth = $rule->week_of_month;
313 $dayOfWeek = (int) $rule->day_of_week;
314 $excludedDates = $rule->excluded_dates;
315 $selectedMonths = !empty($rule->months) ? $rule->months : [];
316
317 // Start from the first day of the starting month
318 $current = strtotime(date('Y-m-01', strtotime($fromDate)));
319 $end = strtotime($toDate);
320
321 while ($current <= $end) {
322 $year = (int) date('Y', $current);
323 $month = (int) date('n', $current);
324
325 // Check if month is allowed (if months filter is set)
326 if (!empty($selectedMonths) && !in_array($month, $selectedMonths, true)) {
327 $current = strtotime('first day of next month', $current);
328 continue;
329 }
330
331 $targetDate = $this->getNthWeekdayOfMonth($year, $month, $weekOfMonth, $dayOfWeek);
332
333 if ($targetDate) {
334 $targetTimestamp = strtotime($targetDate);
335
336 // Check if within range
337 if ($targetTimestamp >= strtotime($fromDate) && $targetTimestamp <= $end) {
338 // Check if not excluded
339 if (!in_array($targetDate, $excludedDates, true)) {
340 // Check cutoff
341 if ($this->isBookable($targetTimestamp, $rule)) {
342 $generatedDates = $this->createAvailabilityFromRule($rule, $targetDate, $dayOfWeek);
343 $dates = array_merge($dates, $generatedDates);
344 }
345 }
346 }
347 }
348
349 // Move to next month
350 $current = strtotime('first day of next month', $current);
351 }
352
353 return $dates;
354 }
355
356 /**
357 * Generate interval recurring dates (every X days)
358 */
359 private function generateIntervalDates(object $rule, string $fromDate, string $toDate): array
360 {
361 $dates = [];
362 $intervalDays = (int) $rule->interval_days;
363 $excludedDates = $rule->excluded_dates;
364 $selectedMonths = !empty($rule->months) ? $rule->months : [];
365
366 // Start from interval_start_date or rule start_date
367 $referenceDate = $rule->interval_start_date ?? $rule->start_date;
368 $reference = strtotime($referenceDate);
369 $from = strtotime($fromDate);
370 $end = strtotime($toDate);
371
372 // Find first occurrence on or after fromDate
373 if ($reference < $from) {
374 $daysDiff = floor(($from - $reference) / 86400);
375 $intervalsToSkip = ceil($daysDiff / $intervalDays);
376 $reference = strtotime("+{$intervalsToSkip} * {$intervalDays} days", $reference);
377 }
378
379 $current = $reference;
380
381 while ($current <= $end) {
382 if ($current >= $from) {
383 $dateStr = date('Y-m-d', $current);
384 $dayOfWeek = (int) date('w', $current);
385 $month = (int) date('n', $current); // 1-12
386
387 // Check if month is allowed (if months filter is set)
388 if (empty($selectedMonths) || in_array($month, $selectedMonths, true)) {
389 // Check if not excluded
390 if (!in_array($dateStr, $excludedDates, true)) {
391 // Check cutoff
392 if ($this->isBookable($current, $rule)) {
393 $generatedDates = $this->createAvailabilityFromRule($rule, $dateStr, $dayOfWeek);
394 $dates = array_merge($dates, $generatedDates);
395 }
396 }
397 }
398 }
399
400 $current = strtotime("+{$intervalDays} days", $current);
401 }
402
403 return $dates;
404 }
405
406 /**
407 * Get the Nth weekday of a month (e.g., "last Sunday of January 2025")
408 */
409 private function getNthWeekdayOfMonth(int $year, int $month, string $position, int $dayOfWeek): ?string
410 {
411 $dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
412 $dayName = $dayNames[$dayOfWeek];
413
414 switch ($position) {
415 case 'first':
416 $descriptor = "first {$dayName}";
417 break;
418 case 'second':
419 $descriptor = "second {$dayName}";
420 break;
421 case 'third':
422 $descriptor = "third {$dayName}";
423 break;
424 case 'fourth':
425 $descriptor = "fourth {$dayName}";
426 break;
427 case 'last':
428 $descriptor = "last {$dayName}";
429 break;
430 default:
431 return null;
432 }
433
434 $monthName = date('F', mktime(0, 0, 0, $month, 1, $year));
435 $dateStr = "{$descriptor} of {$monthName} {$year}";
436
437 $timestamp = strtotime($dateStr);
438
439 if ($timestamp === false) {
440 return null;
441 }
442
443 // Verify it's in the correct month (edge case for "last" crossing months)
444 if ((int) date('n', $timestamp) !== $month) {
445 return null;
446 }
447
448 return date('Y-m-d', $timestamp);
449 }
450
451 /**
452 * Check if a date is bookable based on cutoff rules
453 */
454 private function isBookable(int $timestamp, object $rule): bool
455 {
456 $cutoffHours = (int) ($rule->cutoff_hours ?? 24);
457 $departureTime = $rule->departure_time ?? '00:00:00';
458
459 $departureTimestamp = strtotime(date('Y-m-d', $timestamp) . ' ' . $departureTime);
460 $cutoffTimestamp = $departureTimestamp - ($cutoffHours * 3600);
461
462 // Check advance booking limit
463 if (!empty($rule->advance_booking_days)) {
464 $maxBookingDate = strtotime('+' . (int) $rule->advance_booking_days . ' days');
465 if ($timestamp > $maxBookingDate) {
466 return false;
467 }
468 }
469
470 return time() < $cutoffTimestamp;
471 }
472
473 /**
474 * Create availability array from rule
475 * For single-day trips with multiple time slots, returns an array of availabilities
476 */
477 private function createAvailabilityFromRule(object $rule, string $date, int $dayOfWeek): array
478 {
479 // Check for day-specific overrides
480 $dayOverrides = $rule->day_overrides[$dayOfWeek] ?? [];
481
482 // If rule has time_slots, create separate availability for each slot
483 if (!empty($rule->time_slots) && is_array($rule->time_slots)) {
484 $availabilities = [];
485 foreach ($rule->time_slots as $index => $slot) {
486 $slotPrice = $slot['price'] ?? $dayOverrides['original_price'] ?? $rule->original_price;
487 $slotSeats = $slot['seats'] ?? $dayOverrides['seats_total'] ?? $rule->seats_total;
488 $slotTravelerPricing = $slot['traveler_pricing'] ?? $rule->traveler_pricing ?? [];
489
490 $availabilities[] = [
491 'id' => 'rule_' . $rule->id . '_' . $date . '_slot_' . $index,
492 'rule_id' => $rule->id,
493 'trip_id' => $rule->trip_id,
494 'departure_date' => $date,
495 'departure_time' => $slot['departure_time'] ?? null,
496 'arrival_time' => $slot['arrival_time'] ?? null,
497 'return_date' => $date, // Same day for day trips
498 'seats_total' => (int) $slotSeats,
499 'seats_available' => (int) $slotSeats,
500 'original_price' => $slotPrice ? (float) $slotPrice : null,
501 'discounted_price' => $slotPrice ? (float) $slotPrice : null,
502 'from_location' => $rule->from_location,
503 'to_location' => $rule->to_location,
504 'from_latitude' => $rule->from_latitude ?? null,
505 'from_longitude' => $rule->from_longitude ?? null,
506 'to_latitude' => $rule->to_latitude ?? null,
507 'to_longitude' => $rule->to_longitude ?? null,
508 'cutoff_hours' => $rule->cutoff_hours,
509 'status' => 'available',
510 'is_recurring' => true,
511 'rule_name' => $rule->name,
512 'slot_index' => $index,
513 'pricing_type' => $rule->pricing_type ?? 'regular',
514 'traveler_pricing' => $slotTravelerPricing,
515 ];
516 }
517 return $availabilities;
518 }
519
520 // Default: single availability per date
521 $originalPrice = $dayOverrides['original_price'] ?? $rule->original_price;
522 $salePrice = $dayOverrides['sale_price'] ?? $rule->sale_price ?? $originalPrice;
523 $seats = $dayOverrides['seats_total'] ?? $rule->seats_total;
524 $travelerPricing = $rule->traveler_pricing ?? [];
525
526 return [[
527 'id' => 'rule_' . $rule->id . '_' . $date,
528 'rule_id' => $rule->id,
529 'trip_id' => $rule->trip_id,
530 'departure_date' => $date,
531 'departure_time' => $rule->departure_time,
532 'arrival_time' => $rule->arrival_time,
533 'return_date' => $date, // Same day for day trips
534 'seats_total' => (int) $seats,
535 'seats_available' => (int) $seats, // Will be adjusted by actual bookings
536 'original_price' => $originalPrice ? (float) $originalPrice : null,
537 'discounted_price' => $salePrice ? (float) $salePrice : null,
538 'from_location' => $rule->from_location,
539 'to_location' => $rule->to_location,
540 'from_latitude' => $rule->from_latitude ?? null,
541 'from_longitude' => $rule->from_longitude ?? null,
542 'to_latitude' => $rule->to_latitude ?? null,
543 'to_longitude' => $rule->to_longitude ?? null,
544 'cutoff_hours' => $rule->cutoff_hours,
545 'status' => 'available',
546 'is_recurring' => true,
547 'rule_name' => $rule->name,
548 'pricing_type' => $rule->pricing_type ?? 'regular',
549 'traveler_pricing' => $travelerPricing,
550 ]];
551 }
552
553 /**
554 * Preview generated dates (for admin UI)
555 */
556 public function previewDates(array $ruleData, int $limit = 20): array
557 {
558 // Create a temporary rule object
559 $rule = (object) $ruleData;
560 $rule->excluded_dates = $rule->excluded_dates ?? [];
561 $rule->day_overrides = $rule->day_overrides ?? [];
562 $rule->days_of_week_array = isset($rule->days_of_week)
563 ? (is_array($rule->days_of_week) ? $rule->days_of_week : array_map('intval', explode(',', (string) $rule->days_of_week)))
564 : [];
565
566 // Parse time_slots if it's a JSON string
567 if (isset($rule->time_slots) && is_string($rule->time_slots)) {
568 $rule->time_slots = json_decode($rule->time_slots, true) ?: [];
569 }
570
571 // Generate for next 365 days or until end_date
572 $startDate = $rule->start_date ?? date('Y-m-d');
573 $fromDate = $startDate >= date('Y-m-d') ? $startDate : date('Y-m-d');
574 $toDate = !empty($rule->end_date) ? $rule->end_date : date('Y-m-d', strtotime('+365 days'));
575
576 $dates = $this->generateDatesFromRule($rule, $fromDate, $toDate);
577
578 return [
579 'total' => count($dates),
580 'dates' => $limit > 0 ? array_slice($dates, 0, $limit) : $dates,
581 'excluded_count' => count($rule->excluded_dates),
582 ];
583 }
584 }
585
586