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 / RecurringAvailabilityService.php

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

693 lines 25.8 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
376 // Legacy rows (migrated from the pre-3.x schema) can land here
377 // with `week_of_month` NULL or missing entirely — the column is
378 // nullable in the DB but {@see self::getNthWeekdayOfMonth()}'s
379 // signature requires `string`, so calling it with NULL was
380 // crashing the trip page on PHP 7.4+. Bail out cleanly instead.
381 $weekOfMonth = $rule->week_of_month ?? null;
382 if (!is_string($weekOfMonth) || $weekOfMonth === '') {
383 return $dates;
384 }
385
386 $dayOfWeek = (int) ($rule->day_of_week ?? 0);
387 $excludedDates = $rule->excluded_dates ?? [];
388 $selectedMonths = !empty($rule->months) ? $rule->months : [];
389
390 // Start from the first day of the starting month
391 $current = strtotime(date('Y-m-01', strtotime($fromDate)));
392 $end = strtotime($toDate);
393
394 while ($current <= $end) {
395 $year = (int) date('Y', $current);
396 $month = (int) date('n', $current);
397
398 // Check if month is allowed (if months filter is set)
399 if (!empty($selectedMonths) && !in_array($month, $selectedMonths, true)) {
400 $current = strtotime('first day of next month', $current);
401 continue;
402 }
403
404 $targetDate = $this->getNthWeekdayOfMonth($year, $month, $weekOfMonth, $dayOfWeek);
405
406 if ($targetDate) {
407 $targetTimestamp = strtotime($targetDate);
408
409 // Check if within range
410 if ($targetTimestamp >= strtotime($fromDate) && $targetTimestamp <= $end) {
411 // Check if not excluded
412 if (!in_array($targetDate, $excludedDates, true)) {
413 // Check cutoff
414 if ($this->isBookable($targetTimestamp, $rule)) {
415 $generatedDates = $this->createAvailabilityFromRule($rule, $targetDate, $dayOfWeek);
416 $dates = array_merge($dates, $generatedDates);
417 }
418 }
419 }
420 }
421
422 // Move to next month
423 $current = strtotime('first day of next month', $current);
424 }
425
426 return $dates;
427 }
428
429 /**
430 * Generate interval recurring dates (every X days).
431 *
432 * Hardened against malformed legacy data (rules carried over from the
433 * pre-3.x schema may land here after the legacy→new heal-step in
434 * {@see \Yatra\Services\InstallerService::maybeNormalizeAvailabilityRulesLegacyData()}):
435 * - `interval_days` defaults to 1 when 0/NULL so we never divide by zero
436 * or loop forever.
437 * - `interval_start_date`/`start_date` may be NULL or unparseable; we
438 * bail out rather than feed `false` into a chain of strtotime() calls,
439 * which on PHP 8.1+ raises a TypeError ($baseTimestamp must be ?int).
440 * - The previous "+N * M days" string was never a valid strtotime
441 * expression (strtotime doesn't multiply); we now compute the skip
442 * arithmetic in PHP and pass a single, well-formed relative format.
443 */
444 private function generateIntervalDates(object $rule, string $fromDate, string $toDate): array
445 {
446 $dates = [];
447
448 $intervalDays = (int) ($rule->interval_days ?? 0);
449 if ($intervalDays <= 0) {
450 $intervalDays = 1;
451 }
452
453 $excludedDates = $rule->excluded_dates ?? [];
454 $selectedMonths = !empty($rule->months) ? $rule->months : [];
455
456 $referenceDate = !empty($rule->interval_start_date)
457 ? $rule->interval_start_date
458 : ($rule->start_date ?? null);
459
460 if (empty($referenceDate)) {
461 return $dates;
462 }
463
464 $reference = strtotime((string) $referenceDate);
465 $from = strtotime($fromDate);
466 $end = strtotime($toDate);
467
468 if ($reference === false || $from === false || $end === false) {
469 return $dates;
470 }
471
472 // Snap reference forward to the first occurrence on/after $from.
473 if ($reference < $from) {
474 $daysDiff = (int) floor(($from - $reference) / 86400);
475 $intervalsToSkip = (int) ceil($daysDiff / $intervalDays);
476 $skipDays = $intervalsToSkip * $intervalDays;
477 $advanced = strtotime("+{$skipDays} days", $reference);
478 if ($advanced === false) {
479 return $dates;
480 }
481 $reference = $advanced;
482 }
483
484 $current = $reference;
485
486 while ($current !== false && $current <= $end) {
487 if ($current >= $from) {
488 $dateStr = date('Y-m-d', $current);
489 $dayOfWeek = (int) date('w', $current);
490 $month = (int) date('n', $current); // 1-12
491
492 if (empty($selectedMonths) || in_array($month, $selectedMonths, true)) {
493 if (!in_array($dateStr, $excludedDates, true)) {
494 if ($this->isBookable($current, $rule)) {
495 $generatedDates = $this->createAvailabilityFromRule($rule, $dateStr, $dayOfWeek);
496 $dates = array_merge($dates, $generatedDates);
497 }
498 }
499 }
500 }
501
502 $current = strtotime("+{$intervalDays} days", $current);
503 }
504
505 return $dates;
506 }
507
508 /**
509 * Get the Nth weekday of a month (e.g., "last Sunday of January 2025")
510 */
511 private function getNthWeekdayOfMonth(int $year, int $month, string $position, int $dayOfWeek): ?string
512 {
513 $dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
514 $dayName = $dayNames[$dayOfWeek];
515
516 switch ($position) {
517 case 'first':
518 $descriptor = "first {$dayName}";
519 break;
520 case 'second':
521 $descriptor = "second {$dayName}";
522 break;
523 case 'third':
524 $descriptor = "third {$dayName}";
525 break;
526 case 'fourth':
527 $descriptor = "fourth {$dayName}";
528 break;
529 case 'last':
530 $descriptor = "last {$dayName}";
531 break;
532 default:
533 return null;
534 }
535
536 $monthName = date('F', mktime(0, 0, 0, $month, 1, $year));
537 $dateStr = "{$descriptor} of {$monthName} {$year}";
538
539 $timestamp = strtotime($dateStr);
540
541 if ($timestamp === false) {
542 return null;
543 }
544
545 // Verify it's in the correct month (edge case for "last" crossing months)
546 if ((int) date('n', $timestamp) !== $month) {
547 return null;
548 }
549
550 return date('Y-m-d', $timestamp);
551 }
552
553 /**
554 * Check if a date is bookable based on cutoff rules
555 */
556 private function isBookable(int $timestamp, object $rule): bool
557 {
558 $cutoffHours = (int) ($rule->cutoff_hours ?? 24);
559 $departureTime = $rule->departure_time ?? '00:00:00';
560
561 $departureTimestamp = strtotime(date('Y-m-d', $timestamp) . ' ' . $departureTime);
562 $cutoffTimestamp = $departureTimestamp - ($cutoffHours * 3600);
563
564 // Check advance booking limit
565 if (!empty($rule->advance_booking_days)) {
566 $maxBookingDate = strtotime('+' . (int) $rule->advance_booking_days . ' days');
567 if ($timestamp > $maxBookingDate) {
568 return false;
569 }
570 }
571
572 return time() < $cutoffTimestamp;
573 }
574
575 /**
576 * Create availability array from rule
577 * For single-day trips with multiple time slots, returns an array of availabilities
578 */
579 private function createAvailabilityFromRule(object $rule, string $date, int $dayOfWeek): array
580 {
581 // Check for day-specific overrides
582 $dayOverrides = $rule->day_overrides[$dayOfWeek] ?? [];
583
584 // Preview flows pass a pseudo-rule that has no persisted id; fall back
585 // to the string "preview" so we still emit a deterministic synthetic
586 // availability id without triggering PHP 8 undefined-property warnings.
587 $ruleId = $rule->id ?? 'preview';
588
589 // If rule has time_slots, create separate availability for each slot
590 if (!empty($rule->time_slots) && is_array($rule->time_slots)) {
591 $availabilities = [];
592 foreach ($rule->time_slots as $index => $slot) {
593 $slotPrice = $slot['price'] ?? $dayOverrides['original_price'] ?? $rule->original_price;
594 $slotSeats = $slot['seats'] ?? $dayOverrides['seats_total'] ?? $rule->seats_total;
595 $slotTravelerPricing = $slot['traveler_pricing'] ?? $rule->traveler_pricing ?? [];
596
597 $availabilities[] = [
598 'id' => 'rule_' . $ruleId . '_' . $date . '_slot_' . $index,
599 'rule_id' => $ruleId,
600 'trip_id' => $rule->trip_id,
601 'departure_date' => $date,
602 'departure_time' => $slot['departure_time'] ?? null,
603 'arrival_time' => $slot['arrival_time'] ?? null,
604 'return_date' => $date, // Same day for day trips
605 'seats_total' => (int) $slotSeats,
606 'seats_available' => (int) $slotSeats,
607 'original_price' => $slotPrice ? (float) $slotPrice : null,
608 'discounted_price' => $slotPrice ? (float) $slotPrice : null,
609 'from_location' => $rule->from_location,
610 'to_location' => $rule->to_location,
611 'from_latitude' => $rule->from_latitude ?? null,
612 'from_longitude' => $rule->from_longitude ?? null,
613 'to_latitude' => $rule->to_latitude ?? null,
614 'to_longitude' => $rule->to_longitude ?? null,
615 'cutoff_hours' => $rule->cutoff_hours,
616 'status' => 'available',
617 'is_recurring' => true,
618 'rule_name' => $rule->name,
619 'slot_index' => $index,
620 'pricing_type' => $rule->pricing_type ?? 'regular',
621 'traveler_pricing' => $slotTravelerPricing,
622 ];
623 }
624 return $availabilities;
625 }
626
627 // Default: single availability per date
628 $originalPrice = $dayOverrides['original_price'] ?? $rule->original_price;
629 $salePrice = $dayOverrides['sale_price'] ?? $rule->sale_price ?? $originalPrice;
630 $seats = $dayOverrides['seats_total'] ?? $rule->seats_total;
631 $travelerPricing = $rule->traveler_pricing ?? [];
632
633 return [[
634 'id' => 'rule_' . $ruleId . '_' . $date,
635 'rule_id' => $ruleId,
636 'trip_id' => $rule->trip_id,
637 'departure_date' => $date,
638 'departure_time' => $rule->departure_time,
639 'arrival_time' => $rule->arrival_time,
640 'return_date' => $date, // Same day for day trips
641 'seats_total' => (int) $seats,
642 'seats_available' => (int) $seats, // Will be adjusted by actual bookings
643 'original_price' => $originalPrice ? (float) $originalPrice : null,
644 'discounted_price' => $salePrice ? (float) $salePrice : null,
645 'from_location' => $rule->from_location,
646 'to_location' => $rule->to_location,
647 'from_latitude' => $rule->from_latitude ?? null,
648 'from_longitude' => $rule->from_longitude ?? null,
649 'to_latitude' => $rule->to_latitude ?? null,
650 'to_longitude' => $rule->to_longitude ?? null,
651 'cutoff_hours' => $rule->cutoff_hours,
652 'status' => 'available',
653 'is_recurring' => true,
654 'rule_name' => $rule->name,
655 'pricing_type' => $rule->pricing_type ?? 'regular',
656 'traveler_pricing' => $travelerPricing,
657 ]];
658 }
659
660 /**
661 * Preview generated dates (for admin UI)
662 */
663 public function previewDates(array $ruleData, int $limit = 20): array
664 {
665 // Create a temporary rule object
666 $rule = (object) $ruleData;
667 $rule->excluded_dates = $rule->excluded_dates ?? [];
668 $rule->day_overrides = $rule->day_overrides ?? [];
669 $rule->days_of_week_array = isset($rule->days_of_week)
670 ? (is_array($rule->days_of_week) ? $rule->days_of_week : array_map('intval', explode(',', (string) $rule->days_of_week)))
671 : [];
672
673 // Parse time_slots if it's a JSON string
674 if (isset($rule->time_slots) && is_string($rule->time_slots)) {
675 $rule->time_slots = json_decode($rule->time_slots, true) ?: [];
676 }
677
678 // Generate for next 365 days or until end_date
679 $startDate = $rule->start_date ?? date('Y-m-d');
680 $fromDate = $startDate >= date('Y-m-d') ? $startDate : date('Y-m-d');
681 $toDate = !empty($rule->end_date) ? $rule->end_date : date('Y-m-d', strtotime('+365 days'));
682
683 $dates = $this->generateDatesFromRule($rule, $fromDate, $toDate);
684
685 return [
686 'total' => count($dates),
687 'dates' => $limit > 0 ? array_slice($dates, 0, $limit) : $dates,
688 'excluded_count' => count($rule->excluded_dates),
689 ];
690 }
691 }
692
693