PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.4
Yatra – Travel Booking & Tour Operator Software v3.0.4
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.4, at app/Services/RecurringAvailabilityService.php

683 lines 25.4 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 // week_of_month may come from the admin UI as a string (first/second/...)
85 // or from persisted data as an int (1..5). Accept both and normalize for checks.
86 $weekValue = $data['week_of_month'] ?? null;
87 if ($weekValue === null || $weekValue === '') {
88 throw new \InvalidArgumentException('Week of month is required for monthly rules');
89 }
90 if (is_string($weekValue)) {
91 $weekValue = strtolower(trim($weekValue));
92 }
93 if (is_numeric($weekValue)) {
94 $weekInt = (int) $weekValue;
95 $map = [
96 1 => 'first',
97 2 => 'second',
98 3 => 'third',
99 4 => 'fourth',
100 5 => 'last',
101 ];
102 if (isset($map[$weekInt])) {
103 $weekValue = $map[$weekInt];
104 $data['week_of_month'] = $weekValue;
105 }
106 }
107 if (!isset($data['day_of_week']) || $data['day_of_week'] === '') {
108 throw new \InvalidArgumentException('Day of week is required for monthly rules');
109 }
110 $validWeeks = ['first', 'second', 'third', 'fourth', 'last'];
111 if (!is_string($weekValue) || !in_array($weekValue, $validWeeks, true)) {
112 throw new \InvalidArgumentException('Invalid week of month');
113 }
114 break;
115
116 case 'interval':
117 if (empty($data['interval_days']) || (int) $data['interval_days'] < 1) {
118 throw new \InvalidArgumentException('Interval days is required and must be at least 1');
119 }
120 break;
121 }
122
123 // Validate date format
124 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['start_date'])) {
125 throw new \InvalidArgumentException('Invalid start date format. Use YYYY-MM-DD');
126 }
127
128 if (!empty($data['end_date'])) {
129 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['end_date'])) {
130 throw new \InvalidArgumentException('Invalid end date format. Use YYYY-MM-DD');
131 }
132 if (strtotime($data['end_date']) < strtotime($data['start_date'])) {
133 throw new \InvalidArgumentException('End date must be after start date');
134 }
135 }
136
137 // Validate pricing
138 if (isset($data['original_price']) && (float) $data['original_price'] < 0) {
139 throw new \InvalidArgumentException('Price cannot be negative');
140 }
141
142 if (isset($data['seats_total']) && (int) $data['seats_total'] < 1) {
143 throw new \InvalidArgumentException('Seats must be at least 1');
144 }
145 }
146
147 /**
148 * Create a new rule
149 */
150 public function create(array $data): int
151 {
152 $this->validate($data);
153
154 // The `days_of_week` column is JSON. Leave the value as an array so
155 // the repository can JSON-encode it; if a caller passes a legacy CSV
156 // string, normalise it to an array of ints here so the repo always
157 // sees a single shape.
158 $data = $this->normaliseDaysOfWeek($data);
159
160 // Normalise UI strings to match DB column types.
161 if (isset($data['week_of_month']) && is_string($data['week_of_month'])) {
162 $data['week_of_month'] = strtolower(trim($data['week_of_month']));
163 }
164
165 return $this->repository->create($data);
166 }
167
168 /**
169 * Update a rule
170 */
171 public function update(int $id, array $data): bool
172 {
173 $this->validate($data, $id);
174
175 $data = $this->normaliseDaysOfWeek($data);
176
177 if (isset($data['week_of_month']) && is_string($data['week_of_month'])) {
178 $data['week_of_month'] = strtolower(trim($data['week_of_month']));
179 }
180
181 return $this->repository->update($id, $data);
182 }
183
184 /**
185 * Coerce `days_of_week` to an array of ints (0..6) regardless of how the
186 * caller passed it (array of mixed scalars, comma-separated string, JSON
187 * string). The repository is responsible for JSON-encoding it for the
188 * database column.
189 */
190 private function normaliseDaysOfWeek(array $data): array
191 {
192 if (!array_key_exists('days_of_week', $data)) {
193 return $data;
194 }
195
196 $value = $data['days_of_week'];
197
198 if (is_string($value)) {
199 $trimmed = trim($value);
200 if ($trimmed === '') {
201 $value = [];
202 } else {
203 $decoded = json_decode($trimmed, true);
204 $value = is_array($decoded) ? $decoded : explode(',', $trimmed);
205 }
206 }
207
208 if (!is_array($value)) {
209 $value = [];
210 }
211
212 $value = array_values(array_unique(array_map(static fn($d) => (int) $d, $value)));
213 $value = array_values(array_filter($value, static fn(int $d) => $d >= 0 && $d <= 6));
214
215 $data['days_of_week'] = $value;
216
217 return $data;
218 }
219
220 /**
221 * Delete a rule
222 */
223 public function delete(int $id): bool
224 {
225 return $this->repository->delete($id);
226 }
227
228 /**
229 * Get rules by trip ID
230 */
231 public function getByTripId(int $tripId, array $filters = []): array
232 {
233 return $this->repository->findByTripId($tripId, $filters);
234 }
235
236 /**
237 * Count rules by trip ID
238 */
239 public function countByTripId(int $tripId, array $filters = []): int
240 {
241 return $this->repository->countByTripId($tripId, $filters);
242 }
243
244 /**
245 * Get status counts for recurring rules by trip ID
246 */
247 public function getStatusCounts(int $tripId): array
248 {
249 return $this->repository->getStatusCounts(['trip_id' => $tripId]);
250 }
251
252 /**
253 * Find rule by ID
254 */
255 public function find(int $id): ?object
256 {
257 return $this->repository->find($id);
258 }
259
260 /**
261 * Generate availability dates from rules for a trip within a date range
262 */
263 public function generateDatesForTrip(int $tripId, string $fromDate, string $toDate): array
264 {
265 $rules = $this->repository->getActiveRulesForDateRange($tripId, $fromDate, $toDate);
266
267 $allDates = [];
268
269 foreach ($rules as $rule) {
270 $dates = $this->generateDatesFromRule($rule, $fromDate, $toDate);
271 $allDates = array_merge($allDates, $dates);
272 }
273
274 // Sort by date
275 usort($allDates, function ($a, $b) {
276 return strcmp($a['departure_date'], $b['departure_date']);
277 });
278
279 // Remove duplicates (keep first occurrence - higher priority rule)
280 $uniqueDates = [];
281 $seenDates = [];
282
283 foreach ($allDates as $date) {
284 $key = $date['departure_date'] . '_' . ($date['departure_time'] ?? '');
285 if (!isset($seenDates[$key])) {
286 $seenDates[$key] = true;
287 $uniqueDates[] = $date;
288 }
289 }
290
291 return $uniqueDates;
292 }
293
294 /**
295 * Generate dates from a single rule
296 */
297 public function generateDatesFromRule(object $rule, string $fromDate, string $toDate): array
298 {
299 // Clamp dates to rule's active period
300 $ruleStart = $rule->start_date;
301 $ruleEnd = $rule->end_date ?: $toDate;
302
303 $effectiveFrom = max($fromDate, $ruleStart);
304 $effectiveTo = min($toDate, $ruleEnd);
305
306 if ($effectiveFrom > $effectiveTo) {
307 return [];
308 }
309
310 $dates = [];
311
312 switch ($rule->rule_type) {
313 case 'weekly':
314 $dates = $this->generateWeeklyDates($rule, $effectiveFrom, $effectiveTo);
315 break;
316 case 'monthly':
317 $dates = $this->generateMonthlyDates($rule, $effectiveFrom, $effectiveTo);
318 break;
319 case 'interval':
320 $dates = $this->generateIntervalDates($rule, $effectiveFrom, $effectiveTo);
321 break;
322 }
323
324 return $dates;
325 }
326
327 /**
328 * Generate weekly recurring dates
329 */
330 private function generateWeeklyDates(object $rule, string $fromDate, string $toDate): array
331 {
332 $dates = [];
333 $targetDays = $rule->days_of_week_array;
334 $excludedDates = $rule->excluded_dates;
335 $selectedMonths = !empty($rule->months) ? $rule->months : [];
336
337 $current = strtotime($fromDate);
338 $end = strtotime($toDate);
339 $today = strtotime('today');
340
341 while ($current <= $end) {
342 $dayOfWeek = (int) date('w', $current);
343 $dateStr = date('Y-m-d', $current);
344 $month = (int) date('n', $current); // 1-12
345
346 // Check if month is allowed (if months filter is set)
347 if (!empty($selectedMonths) && !in_array($month, $selectedMonths, true)) {
348 $current = strtotime('+1 day', $current);
349 continue;
350 }
351
352 if (in_array($dayOfWeek, $targetDays, true)) {
353 // Check if not excluded
354 if (!in_array($dateStr, $excludedDates, true)) {
355 // Check cutoff
356 if ($this->isBookable($current, $rule)) {
357 $generatedDates = $this->createAvailabilityFromRule($rule, $dateStr, $dayOfWeek);
358 $dates = array_merge($dates, $generatedDates);
359 }
360 }
361 }
362
363 $current = strtotime('+1 day', $current);
364 }
365
366 return $dates;
367 }
368
369 /**
370 * Generate monthly recurring dates (e.g., "last Sunday of each month")
371 */
372 private function generateMonthlyDates(object $rule, string $fromDate, string $toDate): array
373 {
374 $dates = [];
375 $weekOfMonth = $rule->week_of_month;
376 $dayOfWeek = (int) $rule->day_of_week;
377 $excludedDates = $rule->excluded_dates;
378 $selectedMonths = !empty($rule->months) ? $rule->months : [];
379
380 // Start from the first day of the starting month
381 $current = strtotime(date('Y-m-01', strtotime($fromDate)));
382 $end = strtotime($toDate);
383
384 while ($current <= $end) {
385 $year = (int) date('Y', $current);
386 $month = (int) date('n', $current);
387
388 // Check if month is allowed (if months filter is set)
389 if (!empty($selectedMonths) && !in_array($month, $selectedMonths, true)) {
390 $current = strtotime('first day of next month', $current);
391 continue;
392 }
393
394 $targetDate = $this->getNthWeekdayOfMonth($year, $month, $weekOfMonth, $dayOfWeek);
395
396 if ($targetDate) {
397 $targetTimestamp = strtotime($targetDate);
398
399 // Check if within range
400 if ($targetTimestamp >= strtotime($fromDate) && $targetTimestamp <= $end) {
401 // Check if not excluded
402 if (!in_array($targetDate, $excludedDates, true)) {
403 // Check cutoff
404 if ($this->isBookable($targetTimestamp, $rule)) {
405 $generatedDates = $this->createAvailabilityFromRule($rule, $targetDate, $dayOfWeek);
406 $dates = array_merge($dates, $generatedDates);
407 }
408 }
409 }
410 }
411
412 // Move to next month
413 $current = strtotime('first day of next month', $current);
414 }
415
416 return $dates;
417 }
418
419 /**
420 * Generate interval recurring dates (every X days).
421 *
422 * Hardened against malformed legacy data (rules carried over from the
423 * pre-3.x schema may land here after the legacy→new heal-step in
424 * {@see \Yatra\Services\InstallerService::maybeNormalizeAvailabilityRulesLegacyData()}):
425 * - `interval_days` defaults to 1 when 0/NULL so we never divide by zero
426 * or loop forever.
427 * - `interval_start_date`/`start_date` may be NULL or unparseable; we
428 * bail out rather than feed `false` into a chain of strtotime() calls,
429 * which on PHP 8.1+ raises a TypeError ($baseTimestamp must be ?int).
430 * - The previous "+N * M days" string was never a valid strtotime
431 * expression (strtotime doesn't multiply); we now compute the skip
432 * arithmetic in PHP and pass a single, well-formed relative format.
433 */
434 private function generateIntervalDates(object $rule, string $fromDate, string $toDate): array
435 {
436 $dates = [];
437
438 $intervalDays = (int) ($rule->interval_days ?? 0);
439 if ($intervalDays <= 0) {
440 $intervalDays = 1;
441 }
442
443 $excludedDates = $rule->excluded_dates ?? [];
444 $selectedMonths = !empty($rule->months) ? $rule->months : [];
445
446 $referenceDate = !empty($rule->interval_start_date)
447 ? $rule->interval_start_date
448 : ($rule->start_date ?? null);
449
450 if (empty($referenceDate)) {
451 return $dates;
452 }
453
454 $reference = strtotime((string) $referenceDate);
455 $from = strtotime($fromDate);
456 $end = strtotime($toDate);
457
458 if ($reference === false || $from === false || $end === false) {
459 return $dates;
460 }
461
462 // Snap reference forward to the first occurrence on/after $from.
463 if ($reference < $from) {
464 $daysDiff = (int) floor(($from - $reference) / 86400);
465 $intervalsToSkip = (int) ceil($daysDiff / $intervalDays);
466 $skipDays = $intervalsToSkip * $intervalDays;
467 $advanced = strtotime("+{$skipDays} days", $reference);
468 if ($advanced === false) {
469 return $dates;
470 }
471 $reference = $advanced;
472 }
473
474 $current = $reference;
475
476 while ($current !== false && $current <= $end) {
477 if ($current >= $from) {
478 $dateStr = date('Y-m-d', $current);
479 $dayOfWeek = (int) date('w', $current);
480 $month = (int) date('n', $current); // 1-12
481
482 if (empty($selectedMonths) || in_array($month, $selectedMonths, true)) {
483 if (!in_array($dateStr, $excludedDates, true)) {
484 if ($this->isBookable($current, $rule)) {
485 $generatedDates = $this->createAvailabilityFromRule($rule, $dateStr, $dayOfWeek);
486 $dates = array_merge($dates, $generatedDates);
487 }
488 }
489 }
490 }
491
492 $current = strtotime("+{$intervalDays} days", $current);
493 }
494
495 return $dates;
496 }
497
498 /**
499 * Get the Nth weekday of a month (e.g., "last Sunday of January 2025")
500 */
501 private function getNthWeekdayOfMonth(int $year, int $month, string $position, int $dayOfWeek): ?string
502 {
503 $dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
504 $dayName = $dayNames[$dayOfWeek];
505
506 switch ($position) {
507 case 'first':
508 $descriptor = "first {$dayName}";
509 break;
510 case 'second':
511 $descriptor = "second {$dayName}";
512 break;
513 case 'third':
514 $descriptor = "third {$dayName}";
515 break;
516 case 'fourth':
517 $descriptor = "fourth {$dayName}";
518 break;
519 case 'last':
520 $descriptor = "last {$dayName}";
521 break;
522 default:
523 return null;
524 }
525
526 $monthName = date('F', mktime(0, 0, 0, $month, 1, $year));
527 $dateStr = "{$descriptor} of {$monthName} {$year}";
528
529 $timestamp = strtotime($dateStr);
530
531 if ($timestamp === false) {
532 return null;
533 }
534
535 // Verify it's in the correct month (edge case for "last" crossing months)
536 if ((int) date('n', $timestamp) !== $month) {
537 return null;
538 }
539
540 return date('Y-m-d', $timestamp);
541 }
542
543 /**
544 * Check if a date is bookable based on cutoff rules
545 */
546 private function isBookable(int $timestamp, object $rule): bool
547 {
548 $cutoffHours = (int) ($rule->cutoff_hours ?? 24);
549 $departureTime = $rule->departure_time ?? '00:00:00';
550
551 $departureTimestamp = strtotime(date('Y-m-d', $timestamp) . ' ' . $departureTime);
552 $cutoffTimestamp = $departureTimestamp - ($cutoffHours * 3600);
553
554 // Check advance booking limit
555 if (!empty($rule->advance_booking_days)) {
556 $maxBookingDate = strtotime('+' . (int) $rule->advance_booking_days . ' days');
557 if ($timestamp > $maxBookingDate) {
558 return false;
559 }
560 }
561
562 return time() < $cutoffTimestamp;
563 }
564
565 /**
566 * Create availability array from rule
567 * For single-day trips with multiple time slots, returns an array of availabilities
568 */
569 private function createAvailabilityFromRule(object $rule, string $date, int $dayOfWeek): array
570 {
571 // Check for day-specific overrides
572 $dayOverrides = $rule->day_overrides[$dayOfWeek] ?? [];
573
574 // Preview flows pass a pseudo-rule that has no persisted id; fall back
575 // to the string "preview" so we still emit a deterministic synthetic
576 // availability id without triggering PHP 8 undefined-property warnings.
577 $ruleId = $rule->id ?? 'preview';
578
579 // If rule has time_slots, create separate availability for each slot
580 if (!empty($rule->time_slots) && is_array($rule->time_slots)) {
581 $availabilities = [];
582 foreach ($rule->time_slots as $index => $slot) {
583 $slotPrice = $slot['price'] ?? $dayOverrides['original_price'] ?? $rule->original_price;
584 $slotSeats = $slot['seats'] ?? $dayOverrides['seats_total'] ?? $rule->seats_total;
585 $slotTravelerPricing = $slot['traveler_pricing'] ?? $rule->traveler_pricing ?? [];
586
587 $availabilities[] = [
588 'id' => 'rule_' . $ruleId . '_' . $date . '_slot_' . $index,
589 'rule_id' => $ruleId,
590 'trip_id' => $rule->trip_id,
591 'departure_date' => $date,
592 'departure_time' => $slot['departure_time'] ?? null,
593 'arrival_time' => $slot['arrival_time'] ?? null,
594 'return_date' => $date, // Same day for day trips
595 'seats_total' => (int) $slotSeats,
596 'seats_available' => (int) $slotSeats,
597 'original_price' => $slotPrice ? (float) $slotPrice : null,
598 'discounted_price' => $slotPrice ? (float) $slotPrice : null,
599 'from_location' => $rule->from_location,
600 'to_location' => $rule->to_location,
601 'from_latitude' => $rule->from_latitude ?? null,
602 'from_longitude' => $rule->from_longitude ?? null,
603 'to_latitude' => $rule->to_latitude ?? null,
604 'to_longitude' => $rule->to_longitude ?? null,
605 'cutoff_hours' => $rule->cutoff_hours,
606 'status' => 'available',
607 'is_recurring' => true,
608 'rule_name' => $rule->name,
609 'slot_index' => $index,
610 'pricing_type' => $rule->pricing_type ?? 'regular',
611 'traveler_pricing' => $slotTravelerPricing,
612 ];
613 }
614 return $availabilities;
615 }
616
617 // Default: single availability per date
618 $originalPrice = $dayOverrides['original_price'] ?? $rule->original_price;
619 $salePrice = $dayOverrides['sale_price'] ?? $rule->sale_price ?? $originalPrice;
620 $seats = $dayOverrides['seats_total'] ?? $rule->seats_total;
621 $travelerPricing = $rule->traveler_pricing ?? [];
622
623 return [[
624 'id' => 'rule_' . $ruleId . '_' . $date,
625 'rule_id' => $ruleId,
626 'trip_id' => $rule->trip_id,
627 'departure_date' => $date,
628 'departure_time' => $rule->departure_time,
629 'arrival_time' => $rule->arrival_time,
630 'return_date' => $date, // Same day for day trips
631 'seats_total' => (int) $seats,
632 'seats_available' => (int) $seats, // Will be adjusted by actual bookings
633 'original_price' => $originalPrice ? (float) $originalPrice : null,
634 'discounted_price' => $salePrice ? (float) $salePrice : null,
635 'from_location' => $rule->from_location,
636 'to_location' => $rule->to_location,
637 'from_latitude' => $rule->from_latitude ?? null,
638 'from_longitude' => $rule->from_longitude ?? null,
639 'to_latitude' => $rule->to_latitude ?? null,
640 'to_longitude' => $rule->to_longitude ?? null,
641 'cutoff_hours' => $rule->cutoff_hours,
642 'status' => 'available',
643 'is_recurring' => true,
644 'rule_name' => $rule->name,
645 'pricing_type' => $rule->pricing_type ?? 'regular',
646 'traveler_pricing' => $travelerPricing,
647 ]];
648 }
649
650 /**
651 * Preview generated dates (for admin UI)
652 */
653 public function previewDates(array $ruleData, int $limit = 20): array
654 {
655 // Create a temporary rule object
656 $rule = (object) $ruleData;
657 $rule->excluded_dates = $rule->excluded_dates ?? [];
658 $rule->day_overrides = $rule->day_overrides ?? [];
659 $rule->days_of_week_array = isset($rule->days_of_week)
660 ? (is_array($rule->days_of_week) ? $rule->days_of_week : array_map('intval', explode(',', (string) $rule->days_of_week)))
661 : [];
662
663 // Parse time_slots if it's a JSON string
664 if (isset($rule->time_slots) && is_string($rule->time_slots)) {
665 $rule->time_slots = json_decode($rule->time_slots, true) ?: [];
666 }
667
668 // Generate for next 365 days or until end_date
669 $startDate = $rule->start_date ?? date('Y-m-d');
670 $fromDate = $startDate >= date('Y-m-d') ? $startDate : date('Y-m-d');
671 $toDate = !empty($rule->end_date) ? $rule->end_date : date('Y-m-d', strtotime('+365 days'));
672
673 $dates = $this->generateDatesFromRule($rule, $fromDate, $toDate);
674
675 return [
676 'total' => count($dates),
677 'dates' => $limit > 0 ? array_slice($dates, 0, $limit) : $dates,
678 'excluded_count' => count($rule->excluded_dates),
679 ];
680 }
681 }
682
683