| @@ -126,9 +126,29 @@ | ||
| 126 | 126 | */ |
| 127 | 127 | public function create(array $data): Availability |
| 128 | 128 | { |
| 129 | 129 | $this->validate($data); |
| 130 | - | |
| 130 | + | |
| 131 | + // The table carries a UNIQUE KEY on (trip_id, departure_date, departure_time). | |
| 132 | + // Without this check the insert is rejected by the database, the repository | |
| 133 | + // hands back id 0, findModel(0) returns null and the declared return type | |
| 134 | + // raises a TypeError — which is an Error, not an Exception, so it escaped | |
| 135 | + // the controller's catch blocks and surfaced as a fatal 500. Rejecting it | |
| 136 | + // here gives the operator the same clear message duplicate() already gives. | |
| 137 | + $departureTime = !empty($data['departure_time']) | |
| 138 | + ? $this->normalizeTimeFormat((string) $data['departure_time']) | |
| 139 | + : null; | |
| 140 | + if ($departureTime === false) { | |
| 141 | + throw new \InvalidArgumentException('Invalid departure time format. Use HH:MM'); | |
| 142 | + } | |
| 143 | + if ($this->repository->existsForTripDateTime( | |
| 144 | + (int) $data['trip_id'], | |
| 145 | + (string) $data['departure_date'], | |
| 146 | + $departureTime | |
| 147 | + )) { | |
| 148 | + throw new \InvalidArgumentException('Availability date already exists for the selected departure'); | |
| 149 | + } | |
| 150 | + | |
| 131 | 151 | // Set default seats_available if not provided |
| 132 | 152 | if (!isset($data['seats_available'])) { |
| 133 | 153 | $data['seats_available'] = $data['seats_total'] ?? 0; |
| 134 | 154 | } |
| @@ -163,8 +183,30 @@ | ||
| 163 | 183 | |
| 164 | 184 | // Merge with existing data for validation |
| 165 | 185 | $mergedData = array_merge($existing->toArray(), $data); |
| 166 | 186 | $this->validate($mergedData, $id); |
| 187 | + | |
| 188 | + // Moving a date onto a slot another row already holds is rejected by the | |
| 189 | + // same UNIQUE KEY. wpdb::update() then returns false, which this method | |
| 190 | + // used to ignore — it re-read the untouched row and returned 200, so the | |
| 191 | + // operator was told the change saved when nothing had changed. | |
| 192 | + if (array_key_exists('departure_date', $data) || array_key_exists('departure_time', $data)) { | |
| 193 | + $targetDate = (string) ($data['departure_date'] ?? $existing->departure_date); | |
| 194 | + if (array_key_exists('departure_time', $data)) { | |
| 195 | + $targetTime = !empty($data['departure_time']) | |
| 196 | + ? $this->normalizeTimeFormat((string) $data['departure_time']) | |
| 197 | + : null; | |
| 198 | + if ($targetTime === false) { | |
| 199 | + throw new \InvalidArgumentException('Invalid departure time format. Use HH:MM'); | |
| 200 | + } | |
| 201 | + } else { | |
| 202 | + $targetTime = $existing->departure_time; | |
| 203 | + } | |
| 204 | + | |
| 205 | + if ($this->repository->existsForTripDateTime((int) $existing->trip_id, $targetDate, $targetTime, $id)) { | |
| 206 | + throw new \InvalidArgumentException('Availability date already exists for the selected departure'); | |
| 207 | + } | |
| 208 | + } | |
| 167 | 209 | |
| 168 | 210 | // Auto-update status based on availability |
| 169 | 211 | if (isset($data['seats_available']) || isset($data['seats_total'])) { |
| 170 | 212 | $seatsAvailable = (int) ($data['seats_available'] ?? $existing->seats_available); |
| @@ -358,8 +400,37 @@ | ||
| 358 | 400 | public function getBookingCountsByAvailabilityIds(array $availabilityIds): array |
| 359 | 401 | { |
| 360 | 402 | $bookingRepository = new \Yatra\Repositories\BookingRepository(); |
| 361 | 403 | return $bookingRepository->getBookingCountsByAvailabilityIds($availabilityIds); |
| 404 | + } | |
| 405 | + | |
| 406 | + /** | |
| 407 | + * Booked travellers for a date/time, counted from the bookings themselves. | |
| 408 | + * | |
| 409 | + * The `availability_id` join used by getBookingCountsByAvailabilityIds only | |
| 410 | + * counts bookings whose availability_id was set to this exact row — a stored | |
| 411 | + * link that the booking paths don't reliably set, and that rule-generated | |
| 412 | + * dates never have. That made the "Booked" column read 0 for real bookings. | |
| 413 | + * | |
| 414 | + * This counts by the booking's own identity — (trip, date, time) — via the | |
| 415 | + * booking_departures link both checkout and manual booking always create, so | |
| 416 | + * it can't desync. It is the same method the recurring-rule availability path | |
| 417 | + * already uses. Pass the row's own departure_time so a date with several | |
| 418 | + * departures reports each slot separately rather than the day's total. | |
| 419 | + */ | |
| 420 | + public function getBookedCountForSlot(int $tripId, string $date, ?string $departureTime = null): int | |
| 421 | + { | |
| 422 | + if ($tripId <= 0 || $date === '') { | |
| 423 | + return 0; | |
| 424 | + } | |
| 425 | + | |
| 426 | + // Dates are stored DATE-only; a datetime input would never string-match. | |
| 427 | + if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $date, $m)) { | |
| 428 | + $date = $m[1]; | |
| 429 | + } | |
| 430 | + | |
| 431 | + $bookingRepository = new \Yatra\Repositories\BookingRepository(); | |
| 432 | + return $bookingRepository->countActiveSeatsForSlot($tripId, $date, $departureTime); | |
| 362 | 433 | } |
| 363 | 434 | |
| 364 | 435 | /** |
| 365 | 436 | * Update availability status based on booking counts |