| @@ -350,9 +350,9 @@ | ||
| 350 | 350 | } |
| 351 | 351 | |
| 352 | 352 | if (in_array($dayOfWeek, $targetDays, true)) { |
| 353 | 353 | // Check if not excluded |
| 354 | - if (!in_array($dateStr, $excludedDates, true)) { | |
| 354 | + if (!$this->isDateExcluded($dateStr, $excludedDates)) { | |
| 355 | 355 | // Check cutoff |
| 356 | 356 | if ($this->isBookable($current, $rule)) { |
| 357 | 357 | $generatedDates = $this->createAvailabilityFromRule($rule, $dateStr, $dayOfWeek); |
| 358 | 358 | $dates = array_merge($dates, $generatedDates); |
| @@ -408,9 +408,9 @@ | ||
| 408 | 408 | |
| 409 | 409 | // Check if within range |
| 410 | 410 | if ($targetTimestamp >= strtotime($fromDate) && $targetTimestamp <= $end) { |
| 411 | 411 | // Check if not excluded |
| 412 | - if (!in_array($targetDate, $excludedDates, true)) { | |
| 412 | + if (!$this->isDateExcluded($targetDate, $excludedDates)) { | |
| 413 | 413 | // Check cutoff |
| 414 | 414 | if ($this->isBookable($targetTimestamp, $rule)) { |
| 415 | 415 | $generatedDates = $this->createAvailabilityFromRule($rule, $targetDate, $dayOfWeek); |
| 416 | 416 | $dates = array_merge($dates, $generatedDates); |
| @@ -489,9 +489,9 @@ | ||
| 489 | 489 | $dayOfWeek = (int) date('w', $current); |
| 490 | 490 | $month = (int) date('n', $current); // 1-12 |
| 491 | 491 | |
| 492 | 492 | if (empty($selectedMonths) || in_array($month, $selectedMonths, true)) { |
| 493 | - if (!in_array($dateStr, $excludedDates, true)) { | |
| 493 | + if (!$this->isDateExcluded($dateStr, $excludedDates)) { | |
| 494 | 494 | if ($this->isBookable($current, $rule)) { |
| 495 | 495 | $generatedDates = $this->createAvailabilityFromRule($rule, $dateStr, $dayOfWeek); |
| 496 | 496 | $dates = array_merge($dates, $generatedDates); |
| 497 | 497 | } |
| @@ -659,13 +659,94 @@ | ||
| 659 | 659 | |
| 660 | 660 | /** |
| 661 | 661 | * Preview generated dates (for admin UI) |
| 662 | 662 | */ |
| 663 | + /** | |
| 664 | + * Has the operator explicitly closed this date on any of the trip's active | |
| 665 | + * rules — a holiday, or a period such as a business vacation? | |
| 666 | + * | |
| 667 | + * Only answers for dates the operator deliberately listed. A date that is | |
| 668 | + * simply not generated by a rule (a Tuesday on a Mon/Wed/Fri pattern) is | |
| 669 | + * not "excluded" and is left to the rest of the resolution chain. | |
| 670 | + */ | |
| 671 | + public function isDateExcludedForTrip(int $tripId, string $date): bool | |
| 672 | + { | |
| 673 | + if ($tripId < 1 || $date === '') { | |
| 674 | + return false; | |
| 675 | + } | |
| 676 | + | |
| 677 | + foreach ($this->repository->getActiveRulesForDateRange($tripId, $date, $date) as $rule) { | |
| 678 | + if ($this->isDateExcluded($date, $rule->excluded_dates ?? [])) { | |
| 679 | + return true; | |
| 680 | + } | |
| 681 | + } | |
| 682 | + | |
| 683 | + return false; | |
| 684 | + } | |
| 685 | + | |
| 686 | + /** | |
| 687 | + * Is this date excluded by the rule? | |
| 688 | + * | |
| 689 | + * An entry in `excluded_dates` is either a single 'Y-m-d' string (what the | |
| 690 | + * admin has always been able to add one day at a time) or an inclusive | |
| 691 | + * period ['start' => 'Y-m-d', 'end' => 'Y-m-d'] — a business vacation or | |
| 692 | + * seasonal closure, added in one go instead of day by day. | |
| 693 | + * | |
| 694 | + * Both shapes share the one column so existing rules keep working | |
| 695 | + * untouched: a stored list of plain strings behaves exactly as before. | |
| 696 | + * Dates are ISO, so plain string comparison is chronological. | |
| 697 | + * | |
| 698 | + * @param string $date Date being generated, 'Y-m-d'. | |
| 699 | + * @param mixed $excluded Stored exclusions (a list; anything else = none). | |
| 700 | + */ | |
| 701 | + public function isDateExcluded(string $date, $excluded): bool | |
| 702 | + { | |
| 703 | + if (!is_array($excluded)) { | |
| 704 | + return false; | |
| 705 | + } | |
| 706 | + | |
| 707 | + foreach ($excluded as $entry) { | |
| 708 | + if (is_string($entry)) { | |
| 709 | + if ($entry === $date) { | |
| 710 | + return true; | |
| 711 | + } | |
| 712 | + | |
| 713 | + continue; | |
| 714 | + } | |
| 715 | + | |
| 716 | + if (!is_array($entry)) { | |
| 717 | + continue; | |
| 718 | + } | |
| 719 | + | |
| 720 | + $start = isset($entry['start']) ? (string) $entry['start'] : ''; | |
| 721 | + $end = isset($entry['end']) ? (string) $entry['end'] : ''; | |
| 722 | + | |
| 723 | + if ($start === '' || $end === '') { | |
| 724 | + continue; | |
| 725 | + } | |
| 726 | + | |
| 727 | + // Tolerate a reversed period rather than silently ignoring it. | |
| 728 | + if ($start > $end) { | |
| 729 | + [$start, $end] = [$end, $start]; | |
| 730 | + } | |
| 731 | + | |
| 732 | + if ($date >= $start && $date <= $end) { | |
| 733 | + return true; | |
| 734 | + } | |
| 735 | + } | |
| 736 | + | |
| 737 | + return false; | |
| 738 | + } | |
| 739 | + | |
| 663 | 740 | public function previewDates(array $ruleData, int $limit = 20): array |
| 664 | 741 | { |
| 665 | 742 | // Create a temporary rule object |
| 666 | 743 | $rule = (object) $ruleData; |
| 667 | - $rule->excluded_dates = $rule->excluded_dates ?? []; | |
| 744 | + // Normalise once: a malformed value (e.g. a bare string from a | |
| 745 | + // hand-rolled API call) must not reach count() below as a fatal. | |
| 746 | + $rule->excluded_dates = is_array($rule->excluded_dates ?? null) | |
| 747 | + ? $rule->excluded_dates | |
| 748 | + : []; | |
| 668 | 749 | $rule->day_overrides = $rule->day_overrides ?? []; |
| 669 | 750 | $rule->days_of_week_array = isset($rule->days_of_week) |
| 670 | 751 | ? (is_array($rule->days_of_week) ? $rule->days_of_week : array_map('intval', explode(',', (string) $rule->days_of_week))) |
| 671 | 752 | : []; |