| @@ -197,8 +197,26 @@ | ||
| 197 | 197 | // DEPARTURES ENDPOINTS |
| 198 | 198 | // ========================================================================= |
| 199 | 199 | |
| 200 | 200 | /** |
| 201 | + * Sanitised pagination for the departure list endpoints. | |
| 202 | + * | |
| 203 | + * Returns [page, per_page]. per_page is 0 when the caller did not ask for | |
| 204 | + * pagination, so the list keeps returning every matching row for | |
| 205 | + * consumers that never sent it (the previous behaviour); page is always | |
| 206 | + * >= 1. Only when per_page > 0 is a LIMIT / OFFSET window applied. | |
| 207 | + * | |
| 208 | + * @return array{0: int, 1: int} | |
| 209 | + */ | |
| 210 | + private function paginationParams(WP_REST_Request $request): array | |
| 211 | + { | |
| 212 | + $perPage = max(0, (int) $request->get_param('per_page')); | |
| 213 | + $page = max(1, (int) $request->get_param('page')); | |
| 214 | + | |
| 215 | + return [$page, $perPage]; | |
| 216 | + } | |
| 217 | + | |
| 218 | + /** | |
| 201 | 219 | * GET /trips/{trip_id}/departures |
| 202 | 220 | */ |
| 203 | 221 | public function get_departures(WP_REST_Request $request): WP_REST_Response |
| 204 | 222 | { |
| @@ -203,15 +221,21 @@ | ||
| 203 | 221 | public function get_departures(WP_REST_Request $request): WP_REST_Response |
| 204 | 222 | { |
| 205 | 223 | $tripId = (int) $request->get_param('trip_id'); |
| 206 | 224 | $status = $request->get_param('status'); |
| 225 | + $availability = $request->get_param('availability'); | |
| 226 | + $search = $request->get_param('search'); | |
| 207 | 227 | $source = $request->get_param('source'); |
| 208 | 228 | $dateFrom = $request->get_param('date_from'); |
| 209 | 229 | $dateTo = $request->get_param('date_to'); |
| 210 | 230 | $includePast = $request->get_param('include_past') !== 'false'; |
| 211 | - | |
| 231 | + | |
| 212 | 232 | $filters = []; |
| 213 | 233 | if ($status) $filters['status'] = $status; |
| 234 | + // Capacity is filtered independently of status (see DepartureRepository::applyAvailabilityClause). | |
| 235 | + if ($availability && in_array($availability, ['available', 'partial', 'full'], true)) $filters['availability'] = $availability; | |
| 236 | + // Free-text search on date / notes (see DepartureRepository::applySearchClause). | |
| 237 | + if (is_string($search) && trim($search) !== '') $filters['search'] = trim($search); | |
| 214 | 238 | if ($source) $filters['source'] = $source; |
| 215 | 239 | if ($dateFrom && trim($dateFrom) !== '') $filters['date_from'] = $dateFrom; |
| 216 | 240 | if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo; |
| 217 | 241 | $filters['include_past'] = $includePast; |
| @@ -216,8 +240,17 @@ | ||
| 216 | 240 | if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo; |
| 217 | 241 | $filters['include_past'] = $includePast; |
| 218 | 242 | |
| 219 | 243 | try { |
| 244 | + // Server-side pagination, only when the caller asks for it. | |
| 245 | + [$page, $perPage] = $this->paginationParams($request); | |
| 246 | + if ($perPage > 0) { | |
| 247 | + $filters['per_page'] = $perPage; | |
| 248 | + $filters['page'] = $page; | |
| 249 | + } | |
| 250 | + // True total for the SAME filters, independent of the page window — | |
| 251 | + // count($departures) was the size of the returned page, not the total. | |
| 252 | + $total = $this->departureService->countByTripId($tripId, $filters); | |
| 220 | 253 | $departures = $this->departureService->getByTripId($tripId, $filters); |
| 221 | 254 | |
| 222 | 255 | // Get trip information |
| 223 | 256 | $tripRepository = new \Yatra\Repositories\TripRepository(); |
| @@ -236,14 +269,29 @@ | ||
| 236 | 269 | 'success' => true, |
| 237 | 270 | 'data' => array_map(function ($d) use ($trip, $bookingDepartureRepo, $travellerRepo, $bookingRepo, $capacityService, $departureRepo) { |
| 238 | 271 | // Sync capacity from availability before returning |
| 239 | 272 | $date = $d->start_date ?: $d->date; |
| 240 | - $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date); | |
| 273 | + $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date, $d->time ?? null); | |
| 241 | 274 | if ($correctCapacity > 0 && $d->max_capacity !== $correctCapacity) { |
| 242 | 275 | $departureRepo->update($d->id, ['max_capacity' => $correctCapacity]); |
| 243 | 276 | $d->max_capacity = $correctCapacity; |
| 244 | 277 | } |
| 245 | - | |
| 278 | + | |
| 279 | + // Promote a departure that has taken place to 'past' so a | |
| 280 | + // completed departure is never shown with (or hidden behind) a | |
| 281 | + // stale 'upcoming'/'full' status — the daily cron may not have | |
| 282 | + // run. Cancelled/trashed departures are left as-is. This keeps | |
| 283 | + // the status badge and the tab counts date-accurate. | |
| 284 | + if (!in_array($d->status, ['cancelled', 'trash', 'past'], true)) { | |
| 285 | + $checkDate = (!empty($d->end_date) && $d->end_date !== '0000-00-00') | |
| 286 | + ? $d->end_date | |
| 287 | + : ((!empty($d->start_date) && $d->start_date !== '0000-00-00') ? $d->start_date : $d->date); | |
| 288 | + if (!empty($checkDate) && $checkDate < date('Y-m-d')) { | |
| 289 | + $departureRepo->update($d->id, ['status' => 'past']); | |
| 290 | + $d->status = 'past'; | |
| 291 | + } | |
| 292 | + } | |
| 293 | + | |
| 246 | 294 | $departureArray = $d->toArray(); |
| 247 | 295 | |
| 248 | 296 | // Add trip information |
| 249 | 297 | if ($trip) { |
| @@ -330,9 +378,12 @@ | ||
| 330 | 378 | // Debug: Log time and revenue values |
| 331 | 379 | return $departureArray; |
| 332 | 380 | }, $departures), |
| 333 | 381 | 'meta' => [ |
| 334 | - 'total' => count($departures), | |
| 382 | + 'total' => $total, | |
| 383 | + 'page' => $page, | |
| 384 | + 'per_page' => $perPage > 0 ? $perPage : $total, | |
| 385 | + 'total_pages' => $perPage > 0 ? max(1, (int) ceil($total / $perPage)) : 1, | |
| 335 | 386 | ], |
| 336 | 387 | ]); |
| 337 | 388 | } catch (\Exception $e) { |
| 338 | 389 | return new WP_REST_Response([ |
| @@ -362,9 +413,9 @@ | ||
| 362 | 413 | |
| 363 | 414 | // Sync capacity from availability before returning |
| 364 | 415 | $capacityService = new \Yatra\Services\CapacityService(); |
| 365 | 416 | $date = $departure->start_date ?: $departure->date; |
| 366 | - $correctCapacity = $capacityService->getCapacityForDate($departure->trip_id, $date); | |
| 417 | + $correctCapacity = $capacityService->getCapacityForDate($departure->trip_id, $date, $departure->time ?? null); | |
| 367 | 418 | if ($correctCapacity > 0 && $departure->max_capacity !== $correctCapacity) { |
| 368 | 419 | $repo->update($departure->id, ['max_capacity' => $correctCapacity]); |
| 369 | 420 | $departure->max_capacity = $correctCapacity; |
| 370 | 421 | } |
| @@ -590,15 +641,21 @@ | ||
| 590 | 641 | */ |
| 591 | 642 | public function get_all_departures(WP_REST_Request $request): WP_REST_Response |
| 592 | 643 | { |
| 593 | 644 | $status = $request->get_param('status'); |
| 645 | + $availability = $request->get_param('availability'); | |
| 646 | + $search = $request->get_param('search'); | |
| 594 | 647 | $source = $request->get_param('source'); |
| 595 | 648 | $dateFrom = $request->get_param('date_from'); |
| 596 | 649 | $dateTo = $request->get_param('date_to'); |
| 597 | 650 | $includePast = $request->get_param('include_past') !== 'false'; |
| 598 | - | |
| 651 | + | |
| 599 | 652 | $filters = []; |
| 600 | 653 | if ($status) $filters['status'] = $status; |
| 654 | + // Capacity is filtered independently of status (see DepartureRepository::applyAvailabilityClause). | |
| 655 | + if ($availability && in_array($availability, ['available', 'partial', 'full'], true)) $filters['availability'] = $availability; | |
| 656 | + // Free-text search on date / notes (see DepartureRepository::applySearchClause). | |
| 657 | + if (is_string($search) && trim($search) !== '') $filters['search'] = trim($search); | |
| 601 | 658 | if ($source) $filters['source'] = $source; |
| 602 | 659 | if ($dateFrom && trim($dateFrom) !== '') $filters['date_from'] = $dateFrom; |
| 603 | 660 | if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo; |
| 604 | 661 | $filters['include_past'] = $includePast; |
| @@ -603,8 +660,17 @@ | ||
| 603 | 660 | if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo; |
| 604 | 661 | $filters['include_past'] = $includePast; |
| 605 | 662 | |
| 606 | 663 | try { |
| 664 | + // Server-side pagination, only when the caller asks for it. | |
| 665 | + [$page, $perPage] = $this->paginationParams($request); | |
| 666 | + if ($perPage > 0) { | |
| 667 | + $filters['per_page'] = $perPage; | |
| 668 | + $filters['page'] = $page; | |
| 669 | + } | |
| 670 | + // True total for the SAME filters, independent of the page window — | |
| 671 | + // count($processed) was the size of the returned page, not the total. | |
| 672 | + $total = $this->departureService->countAllDepartures($filters); | |
| 607 | 673 | // Get all departures (no trip filter) |
| 608 | 674 | $departures = $this->departureService->getAllDepartures($filters); |
| 609 | 675 | |
| 610 | 676 | // Get repository for additional data |
| @@ -620,14 +686,48 @@ | ||
| 620 | 686 | // Process each departure to add related data |
| 621 | 687 | $processed = array_map(function ($d) use ($tripRepository, $bookingDepartureRepo, $travellerRepo, $bookingRepo, $capacityService, $departureRepo) { |
| 622 | 688 | // Sync capacity from availability before returning |
| 623 | 689 | $date = $d->start_date ?: $d->date; |
| 624 | - $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date); | |
| 625 | - if ($correctCapacity > 0 && $d->max_capacity !== $correctCapacity) { | |
| 626 | - $departureRepo->update($d->id, ['max_capacity' => $correctCapacity]); | |
| 627 | - $d->max_capacity = $correctCapacity; | |
| 690 | + $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date, $d->time ?? null); | |
| 691 | + if ($correctCapacity > 0) { | |
| 692 | + if ((int) $d->max_capacity !== $correctCapacity) { | |
| 693 | + $departureRepo->update($d->id, ['max_capacity' => $correctCapacity]); | |
| 694 | + $d->max_capacity = $correctCapacity; | |
| 695 | + } | |
| 696 | + } elseif ((int) $d->max_capacity >= 9999) { | |
| 697 | + // Normalise a legacy "unlimited"/junk capacity sentinel (e.g. | |
| 698 | + // 9999/11111) to the canonical unlimited value 0, so it isn't | |
| 699 | + // shown as a huge literal number here while the dashboard renders | |
| 700 | + // >= 9999 as 0 — the two disagreeing on capacity and occupancy. | |
| 701 | + // | |
| 702 | + // Deliberately heal to 0 (NOT the trip's max_travelers): 0 means | |
| 703 | + // "unlimited" to both the capacity guard (incrementBookedCount) | |
| 704 | + // and Departure::calculateStatus(), so healing can never flip an | |
| 705 | + // already (over-)booked departure to 'full' — which capping to a | |
| 706 | + // smaller number would, and 'full' departures drop out of the | |
| 707 | + // dashboard's upcoming view. This keeps the sentinel's original | |
| 708 | + // "unlimited" meaning while making both surfaces agree. | |
| 709 | + if ((int) $d->max_capacity !== 0) { | |
| 710 | + $departureRepo->update($d->id, ['max_capacity' => 0]); | |
| 711 | + $d->max_capacity = 0; | |
| 712 | + } | |
| 628 | 713 | } |
| 629 | - | |
| 714 | + | |
| 715 | + // Promote a departure that has taken place to 'past' so a completed | |
| 716 | + // departure is never shown with (or hidden behind) a stale | |
| 717 | + // 'upcoming'/'full' status — the daily cron may not have run. | |
| 718 | + // Cancelled/trashed departures are left as-is. Keeps the status | |
| 719 | + // badge and the dashboard/tab counts date-accurate. | |
| 720 | + if (!in_array($d->status, ['cancelled', 'trash', 'past'], true)) { | |
| 721 | + $checkDate = (!empty($d->end_date) && $d->end_date !== '0000-00-00') | |
| 722 | + ? $d->end_date | |
| 723 | + : ((!empty($d->start_date) && $d->start_date !== '0000-00-00') ? $d->start_date : $d->date); | |
| 724 | + if (!empty($checkDate) && $checkDate < date('Y-m-d')) { | |
| 725 | + $departureRepo->update($d->id, ['status' => 'past']); | |
| 726 | + $d->status = 'past'; | |
| 727 | + } | |
| 728 | + } | |
| 729 | + | |
| 630 | 730 | $departureArray = $d->toArray(); |
| 631 | 731 | |
| 632 | 732 | // Add trip information |
| 633 | 733 | $trip = $tripRepository->find($d->trip_id); |
| @@ -714,9 +814,12 @@ | ||
| 714 | 814 | return new WP_REST_Response([ |
| 715 | 815 | 'success' => true, |
| 716 | 816 | 'data' => $processed, |
| 717 | 817 | 'meta' => [ |
| 718 | - 'total' => count($processed), | |
| 818 | + 'total' => $total, | |
| 819 | + 'page' => $page, | |
| 820 | + 'per_page' => $perPage > 0 ? $perPage : $total, | |
| 821 | + 'total_pages' => $perPage > 0 ? max(1, (int) ceil($total / $perPage)) : 1, | |
| 719 | 822 | ], |
| 720 | 823 | ]); |
| 721 | 824 | } catch (\Exception $e) { |
| 722 | 825 | return new WP_REST_Response([ |
| @@ -757,13 +860,53 @@ | ||
| 757 | 860 | public function get_available_dates(WP_REST_Request $request): WP_REST_Response |
| 758 | 861 | { |
| 759 | 862 | $tripId = (int) $request->get_param('trip_id'); |
| 760 | 863 | $fromDate = $request->get_param('from_date') ?: date('Y-m-d'); |
| 761 | - $toDate = $request->get_param('to_date') ?: date('Y-m-d', strtotime('+12 months')); | |
| 864 | + // An explicit to_date always wins; only the default follows the | |
| 865 | + // configurable booking horizon (12 months unless changed). The default | |
| 866 | + // is counted from TODAY — not from from_date — exactly as before, so a | |
| 867 | + // client that sends only from_date gets the same window it always did. | |
| 868 | + $toDate = $request->get_param('to_date') ?: yatra_get_availability_horizon_date(); | |
| 762 | 869 | |
| 763 | 870 | try { |
| 764 | 871 | $dates = $this->departureService->getAvailableDates($tripId, $fromDate, $toDate); |
| 765 | - | |
| 872 | + | |
| 873 | + // Attach the departure times each date actually runs. The list above is | |
| 874 | + // keyed by date and reports `time => null` for rule-generated dates, so a | |
| 875 | + // trip running several departures a day looked like a single slot — and | |
| 876 | + // an operator booking it from the admin had no way to say which departure | |
| 877 | + // the booking was for. Capacity is tracked per departure, so such a | |
| 878 | + // booking reserved no seats at all. | |
| 879 | + // | |
| 880 | + // Added as an extra field rather than by changing the row shape, so every | |
| 881 | + // existing consumer of this endpoint is unaffected. | |
| 882 | + $timesByDate = []; | |
| 883 | + try { | |
| 884 | + $resolver = new \Yatra\Services\AvailabilityResolutionService(); | |
| 885 | + foreach ($resolver->getAllAvailabilityDates($tripId, $fromDate, $toDate) as $slot) { | |
| 886 | + $slotDate = (string) ($slot->departure_date ?? $slot->date ?? ''); | |
| 887 | + $slotTime = trim((string) ($slot->departure_time ?? '')); | |
| 888 | + if ($slotDate === '' || $slotTime === '') { | |
| 889 | + continue; | |
| 890 | + } | |
| 891 | + $timesByDate[$slotDate][$slotTime] = true; | |
| 892 | + } | |
| 893 | + } catch (\Throwable $e) { | |
| 894 | + $timesByDate = []; | |
| 895 | + } | |
| 896 | + | |
| 897 | + foreach ($dates as $key => $row) { | |
| 898 | + $rowDate = is_array($row) ? (string) ($row['date'] ?? '') : (string) ($row->date ?? ''); | |
| 899 | + $times = isset($timesByDate[$rowDate]) ? array_keys($timesByDate[$rowDate]) : []; | |
| 900 | + sort($times); | |
| 901 | + | |
| 902 | + if (is_array($row)) { | |
| 903 | + $dates[$key]['departure_times'] = $times; | |
| 904 | + } elseif (is_object($row)) { | |
| 905 | + $row->departure_times = $times; | |
| 906 | + } | |
| 907 | + } | |
| 908 | + | |
| 766 | 909 | return new WP_REST_Response([ |
| 767 | 910 | 'success' => true, |
| 768 | 911 | 'data' => $dates, |
| 769 | 912 | ]); |