| @@ -56,9 +56,10 @@ | ||
| 56 | 56 | // Calculate capacity based on the date if not provided |
| 57 | 57 | if (empty($data['max_capacity'])) { |
| 58 | 58 | $data['max_capacity'] = $this->capacityService->getCapacityForDate( |
| 59 | 59 | (int) $data['trip_id'], |
| 60 | - $startDate | |
| 60 | + $startDate, | |
| 61 | + $data['time'] ?? null | |
| 61 | 62 | ); |
| 62 | 63 | |
| 63 | 64 | // If still no capacity, throw an error |
| 64 | 65 | if ($data['max_capacity'] <= 0) { |
| @@ -155,16 +156,53 @@ | ||
| 155 | 156 | throw new \InvalidArgumentException('A departure already exists for this trip and date'); |
| 156 | 157 | } |
| 157 | 158 | } |
| 158 | 159 | |
| 160 | + // Detect a tour (trip) reassignment: the operator is moving this | |
| 161 | + // departure from one trip to another. The DB layer already persists | |
| 162 | + // trip_id, but the move must be validated and kept consistent: | |
| 163 | + // 1. the target trip must exist, | |
| 164 | + // 2. it must not collide with an existing departure on the target | |
| 165 | + // trip for the same date/time, and | |
| 166 | + // 3. the departure's bookings must move to the new trip too, so a | |
| 167 | + // booking never ends up pointing at a departure that belongs to a | |
| 168 | + // different trip (the invariant the booking edit path also keeps). | |
| 169 | + $newTripId = isset($data['trip_id']) ? (int) $data['trip_id'] : 0; | |
| 170 | + $tripReassigned = $newTripId > 0 && $newTripId !== (int) $departure->trip_id; | |
| 171 | + | |
| 172 | + if ($tripReassigned) { | |
| 173 | + $targetTrip = $this->tripRepository->find($newTripId); | |
| 174 | + if (!$targetTrip) { | |
| 175 | + throw new \InvalidArgumentException('Target tour not found'); | |
| 176 | + } | |
| 177 | + | |
| 178 | + $checkDate = $data['start_date'] ?? $data['date'] ?? ($departure->start_date ?: $departure->date); | |
| 179 | + $checkTime = $data['time'] ?? $departure->time; | |
| 180 | + $existing = $this->repository->findByTripIdAndStartDate($newTripId, (string) $checkDate, $checkTime); | |
| 181 | + if ($existing && (int) $existing->id !== $id) { | |
| 182 | + throw new \InvalidArgumentException( | |
| 183 | + 'A departure already exists for the selected tour on this date' | |
| 184 | + ); | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 159 | 188 | // Mark as manually edited by admin |
| 160 | 189 | if (!isset($data['source'])) { |
| 161 | 190 | $data['source'] = 'manual'; // Admin edits mark as manual |
| 162 | 191 | } |
| 163 | - | |
| 192 | + | |
| 164 | 193 | // Update departure |
| 165 | 194 | $result = $this->repository->update($id, $data); |
| 166 | - | |
| 195 | + | |
| 196 | + // Move the departure's bookings onto the new trip so booking.trip_id and | |
| 197 | + // departure.trip_id stay in sync after a manual reassignment. | |
| 198 | + if ($result && $tripReassigned) { | |
| 199 | + $bookingIds = $this->bookingDepartureRepository->getBookingIdsForDeparture($id); | |
| 200 | + foreach ($bookingIds as $bookingId) { | |
| 201 | + $this->bookingRepository->update((int) $bookingId, ['trip_id' => $newTripId]); | |
| 202 | + } | |
| 203 | + } | |
| 204 | + | |
| 167 | 205 | // Trigger hook to sync capacity from availability |
| 168 | 206 | if ($result) { |
| 169 | 207 | do_action('yatra_departure_saved', $id); |
| 170 | 208 | } |
| @@ -184,26 +222,38 @@ | ||
| 184 | 222 | return $result; |
| 185 | 223 | } |
| 186 | 224 | |
| 187 | 225 | /** |
| 188 | - * Delete a departure | |
| 189 | - * Only allowed if source is recurring_generated and booked_count is 0 | |
| 226 | + * Delete a departure. | |
| 227 | + * | |
| 228 | + * Allowed once the departure no longer has any booking attached, whether it | |
| 229 | + * was created automatically or by hand — an operator who adds a departure by | |
| 230 | + * mistake must be able to remove it again. | |
| 190 | 231 | */ |
| 191 | 232 | public function delete(int $id): bool |
| 192 | 233 | { |
| 193 | 234 | $departure = $this->repository->findModel($id); |
| 194 | - | |
| 235 | + | |
| 195 | 236 | if (!$departure) { |
| 196 | 237 | throw new \InvalidArgumentException('Departure not found'); |
| 197 | 238 | } |
| 198 | - | |
| 199 | - // Only allow deletion of recurring_generated departures with no bookings | |
| 200 | - if ($departure->source === 'recurring_generated' && $departure->booked_count === 0) { | |
| 201 | - return $this->repository->delete($id); | |
| 239 | + | |
| 240 | + // Bookings themselves are the source of truth here, not `booked_count`: | |
| 241 | + // that counter can drift upwards (a cancelled or expired booking does not | |
| 242 | + // always decrement it), which would otherwise leave a departure | |
| 243 | + // permanently undeletable long after its last booking went away. | |
| 244 | + // | |
| 245 | + // This is the ONLY thing standing between a departure and deletion. The | |
| 246 | + // previous guard also required source === 'recurring_generated', a value | |
| 247 | + // this plugin never writes (departures are `booking_created` or `manual`, | |
| 248 | + // see Departure::$source), so no departure was ever deletable at all. | |
| 249 | + if (!empty($this->bookingRepository->findByDepartureId($id))) { | |
| 250 | + throw new \InvalidArgumentException( | |
| 251 | + __('Cannot delete departure: it still has bookings attached.', 'yatra') | |
| 252 | + ); | |
| 202 | 253 | } |
| 203 | - | |
| 204 | - // Manual departures or departures with bookings cannot be deleted | |
| 205 | - throw new \InvalidArgumentException('Cannot delete departure: Manual departures or departures with bookings cannot be deleted'); | |
| 254 | + | |
| 255 | + return $this->repository->delete($id); | |
| 206 | 256 | } |
| 207 | 257 | |
| 208 | 258 | /** |
| 209 | 259 | * Increment booked count (when booking is created). |
| @@ -292,8 +342,27 @@ | ||
| 292 | 342 | return $this->repository->findByTripId($tripId, $filters); |
| 293 | 343 | } |
| 294 | 344 | |
| 295 | 345 | /** |
| 346 | + * Count departures across all trips for the same filters as | |
| 347 | + * getAllDepartures() (pagination keys ignored) — the true total behind a | |
| 348 | + * paginated list. | |
| 349 | + */ | |
| 350 | + public function countAllDepartures(array $filters = []): int | |
| 351 | + { | |
| 352 | + return $this->repository->countAll($filters); | |
| 353 | + } | |
| 354 | + | |
| 355 | + /** | |
| 356 | + * Count departures for one trip for the same filters as getByTripId() | |
| 357 | + * (pagination keys ignored). | |
| 358 | + */ | |
| 359 | + public function countByTripId(int $tripId, array $filters = []): int | |
| 360 | + { | |
| 361 | + return $this->repository->countByTripId($tripId, $filters); | |
| 362 | + } | |
| 363 | + | |
| 364 | + /** | |
| 296 | 365 | * Get past departures by trip ID |
| 297 | 366 | */ |
| 298 | 367 | public function getPastByTripId(int $tripId, array $filters = []): array |
| 299 | 368 | { |
| @@ -313,15 +382,18 @@ | ||
| 313 | 382 | * Combines manual departures and dynamically generated recurring rule dates |
| 314 | 383 | * |
| 315 | 384 | * @param int $tripId Trip ID |
| 316 | 385 | * @param string $fromDate Start date (default: today) |
| 317 | - * @param string $toDate End date (default: +12 months) | |
| 386 | + * @param string $toDate End date (default: today + the configurable booking horizon, 12 months unless changed) | |
| 318 | 387 | * @return array Available dates with pricing and capacity info |
| 319 | 388 | */ |
| 320 | 389 | public function getAvailableDates(int $tripId, ?string $fromDate = null, ?string $toDate = null): array |
| 321 | 390 | { |
| 322 | 391 | $fromDate = $fromDate ?? date('Y-m-d'); |
| 323 | - $toDate = $toDate ?? date('Y-m-d', strtotime('+12 months')); | |
| 392 | + // Default counted from today (not $fromDate), exactly as the previous | |
| 393 | + // hard-coded '+12 months' was, so callers passing only a start date | |
| 394 | + // keep the same window. | |
| 395 | + $toDate = $toDate ?? yatra_get_availability_horizon_date(); | |
| 324 | 396 | |
| 325 | 397 | // Get all manual departures |
| 326 | 398 | $manualDepartures = $this->repository->findByTripId($tripId, [ |
| 327 | 399 | 'date_from' => $fromDate, |
| @@ -407,9 +479,9 @@ | ||
| 407 | 479 | */ |
| 408 | 480 | public function findOrCreateForBooking(int $tripId, string $startDate, string $endDate, int $travelersCount = 0, ?int $defaultMaxCapacity = null, ?string $time = null): Departure |
| 409 | 481 | { |
| 410 | 482 | // Get capacity based on priority |
| 411 | - $maxCapacity = $this->capacityService->getCapacityForDate($tripId, $startDate); | |
| 483 | + $maxCapacity = $this->capacityService->getCapacityForDate($tripId, $startDate, $time); | |
| 412 | 484 | |
| 413 | 485 | // If no capacity found from availability or rules, use the provided default |
| 414 | 486 | if ($maxCapacity <= 0 && $defaultMaxCapacity !== null) { |
| 415 | 487 | $maxCapacity = $defaultMaxCapacity; |
| @@ -590,9 +662,14 @@ | ||
| 590 | 662 | * @param string $newStartDate New start date |
| 591 | 663 | * @param string $newEndDate New end date |
| 592 | 664 | * @return array {success: bool, new_departure_id: int, old_departure_id: int|null} |
| 593 | 665 | */ |
| 594 | - public function handleBookingDateChange(int $bookingId, string $newStartDate, string $newEndDate): array | |
| 666 | + /** | |
| 667 | + * @param string|null $newDepartureTime Departure time (HH:MM/HH:MM:SS) when the trip | |
| 668 | + * runs several departures a day. Optional, so | |
| 669 | + * existing callers keep their behaviour. | |
| 670 | + */ | |
| 671 | + public function handleBookingDateChange(int $bookingId, string $newStartDate, string $newEndDate, ?string $newDepartureTime = null): array | |
| 595 | 672 | { |
| 596 | 673 | // Get booking to find trip_id |
| 597 | 674 | $booking = $this->bookingRepository->find($bookingId); |
| 598 | 675 | if (!$booking) { |
| @@ -609,10 +686,19 @@ | ||
| 609 | 686 | if ($trip && !empty($trip->max_travelers)) { |
| 610 | 687 | $maxCapacity = (int) $trip->max_travelers; |
| 611 | 688 | } |
| 612 | 689 | |
| 613 | - // Find or create new departure | |
| 614 | - $newDeparture = $this->findOrCreateForBooking($tripId, $newStartDate, $newEndDate, 0, $maxCapacity); | |
| 690 | + // Find or create new departure. The time matters when a trip runs several | |
| 691 | + // departures a day: without it the booking lands on whichever departure | |
| 692 | + // matches the date alone, so it never occupies the slot it was booked for. | |
| 693 | + $newDeparture = $this->findOrCreateForBooking( | |
| 694 | + $tripId, | |
| 695 | + $newStartDate, | |
| 696 | + $newEndDate, | |
| 697 | + 0, | |
| 698 | + $maxCapacity, | |
| 699 | + ($newDepartureTime !== null && trim($newDepartureTime) !== '') ? trim($newDepartureTime) : null | |
| 700 | + ); | |
| 615 | 701 | |
| 616 | 702 | // Link booking to new departure |
| 617 | 703 | $this->bookingDepartureRepository->updateDepartureForBooking($bookingId, $newDeparture->id); |
| 618 | 704 | |