| @@ -28,11 +28,14 @@ | ||
| 28 | 28 | * Get capacity for a specific trip and date based on priority |
| 29 | 29 | * |
| 30 | 30 | * @param int $tripId Trip ID |
| 31 | 31 | * @param string $date Date in YYYY-MM-DD format |
| 32 | + * @param string|null $time Departure time (HH:MM or HH:MM:SS). When the matching | |
| 33 | + * rule defines time slots, this selects the slot whose | |
| 34 | + * seat count applies. Optional for backward compatibility. | |
| 32 | 35 | * @return int Maximum capacity |
| 33 | 36 | */ |
| 34 | - public function getCapacityForDate(int $tripId, string $date): int | |
| 37 | + public function getCapacityForDate(int $tripId, string $date, ?string $time = null): int | |
| 35 | 38 | { |
| 36 | 39 | // Normalize to Y-m-d. The availability-date and recurring-rule columns are |
| 37 | 40 | // DATE type, so a datetime input (e.g. "2026-07-18 12:30:00") breaks the |
| 38 | 41 | // rule's `end_date >= %s` boundary match (the DATE end_date is treated as |
| @@ -52,8 +55,19 @@ | ||
| 52 | 55 | $recurringRules = $this->recurringAvailabilityRepository->findActiveRulesForDate($tripId, $date); |
| 53 | 56 | if (!empty($recurringRules)) { |
| 54 | 57 | // Sort by priority (if applicable) and get the first matching rule |
| 55 | 58 | $matchingRule = reset($recurringRules); |
| 59 | + | |
| 60 | + // A rule may define its capacity per time slot rather than on the rule | |
| 61 | + // itself (e.g. a private tour whose vehicle seats 9 but sells one group | |
| 62 | + // booking per departure). The public availability path already honours | |
| 63 | + // the slot seats, so resolve them here too or departures/capacity would | |
| 64 | + // fall through to the trip default and report the wrong number. | |
| 65 | + $slotSeats = $this->resolveSlotSeats($matchingRule, $time); | |
| 66 | + if ($slotSeats > 0) { | |
| 67 | + return $slotSeats; | |
| 68 | + } | |
| 69 | + | |
| 56 | 70 | $seats = (int) ($matchingRule->seats_total ?? 0); |
| 57 | 71 | if ($seats <= 0 && !empty($matchingRule->capacity_value)) { |
| 58 | 72 | $capType = $matchingRule->capacity_type ?? 'fixed'; |
| 59 | 73 | if ($capType === 'fixed') { |
| @@ -73,6 +87,73 @@ | ||
| 73 | 87 | |
| 74 | 88 | $cap = (int) ($trip->max_travelers ?? $trip->max_travellers ?? 0); |
| 75 | 89 | |
| 76 | 90 | return $cap > 0 ? $cap : 0; |
| 91 | + } | |
| 92 | + | |
| 93 | + /** | |
| 94 | + * Resolve the seat count a rule defines on its time slots. | |
| 95 | + * | |
| 96 | + * Returns 0 when the rule has no slots, or when no slot carries a positive | |
| 97 | + * seat count, so the caller falls back to the existing rule/trip resolution | |
| 98 | + * exactly as before. | |
| 99 | + * | |
| 100 | + * @param object $rule Recurring availability rule | |
| 101 | + * @param string|null $time Departure time to match against (HH:MM or HH:MM:SS) | |
| 102 | + * @return int | |
| 103 | + */ | |
| 104 | + private function resolveSlotSeats(object $rule, ?string $time): int | |
| 105 | + { | |
| 106 | + $slots = $rule->time_slots ?? null; | |
| 107 | + | |
| 108 | + // Most finders hydrate this to an array, but some return the raw column. | |
| 109 | + if (is_string($slots)) { | |
| 110 | + $slots = json_decode($slots, true); | |
| 111 | + } | |
| 112 | + | |
| 113 | + if (empty($slots) || !is_array($slots)) { | |
| 114 | + return 0; | |
| 115 | + } | |
| 116 | + | |
| 117 | + // With a known departure time, only the matching slot's seats apply. | |
| 118 | + if ($time !== null && $time !== '') { | |
| 119 | + $wanted = $this->normalizeTime($time); | |
| 120 | + foreach ($slots as $slot) { | |
| 121 | + if (!is_array($slot) || !isset($slot['departure_time'])) { | |
| 122 | + continue; | |
| 123 | + } | |
| 124 | + if ($this->normalizeTime((string) $slot['departure_time']) === $wanted) { | |
| 125 | + return max(0, (int) ($slot['seats'] ?? 0)); | |
| 126 | + } | |
| 127 | + } | |
| 128 | + | |
| 129 | + return 0; | |
| 130 | + } | |
| 131 | + | |
| 132 | + // Without a time, a single slot is unambiguous; multiple slots make up the | |
| 133 | + // day's total capacity. | |
| 134 | + $total = 0; | |
| 135 | + foreach ($slots as $slot) { | |
| 136 | + if (is_array($slot)) { | |
| 137 | + $total += max(0, (int) ($slot['seats'] ?? 0)); | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + return $total; | |
| 142 | + } | |
| 143 | + | |
| 144 | + /** | |
| 145 | + * Normalize a time value to HH:MM for comparison, so "8:00", "08:00" and | |
| 146 | + * "08:00:00" all match. | |
| 147 | + * | |
| 148 | + * @param string $time | |
| 149 | + * @return string | |
| 150 | + */ | |
| 151 | + private function normalizeTime(string $time): string | |
| 152 | + { | |
| 153 | + $parts = explode(':', trim($time)); | |
| 154 | + $hour = isset($parts[0]) ? (int) $parts[0] : 0; | |
| 155 | + $minute = isset($parts[1]) ? (int) $parts[1] : 0; | |
| 156 | + | |
| 157 | + return sprintf('%02d:%02d', $hour, $minute); | |
| 77 | 158 | } |
| 78 | 159 | } |