PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Controllers / TripAvailabilityController.php

TripAvailabilityController.php in Yatra – Travel Booking & Tour Operator Software 3.0.15, at app/Controllers/TripAvailabilityController.php

1,074 lines 45.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Controllers;
6
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use WP_Error;
10 use Yatra\Services\DepartureService;
11 use Yatra\Services\RecurringRuleService;
12 use Yatra\Repositories\DepartureRepository;
13 use Yatra\Repositories\RecurringRuleRepository;
14
15 /**
16 * Trip Availability Controller
17 * REST API endpoints for managing trip departures and recurring rules
18 */
19 class TripAvailabilityController extends BaseController
20 {
21 private DepartureService $departureService;
22 private RecurringRuleService $ruleService;
23
24 public function __construct()
25 {
26 $departureRepo = new DepartureRepository();
27 $ruleRepo = new RecurringRuleRepository();
28
29 $this->departureService = new DepartureService($departureRepo);
30 $this->ruleService = new RecurringRuleService($ruleRepo, $departureRepo);
31 }
32
33 /**
34 * Register routes
35 */
36 public function register_routes(): void
37 {
38 $namespace = 'yatra/v1';
39
40 // All departures endpoint (without trip ID) — view cap.
41 register_rest_route($namespace, '/departures', [
42 [
43 'methods' => \WP_REST_Server::READABLE,
44 'callback' => [$this, 'get_all_departures'],
45 'permission_callback' => [$this, 'check_view_permission'],
46 ],
47 ]);
48
49 $base = 'trips/(?P<trip_id>[\d]+)/departures';
50
51 // Departures list + create — view cap for read, manage cap
52 // for create (a departure is a scheduled trip instance, not
53 // trip content edit).
54 register_rest_route($namespace, '/' . $base, [
55 [
56 'methods' => \WP_REST_Server::READABLE,
57 'callback' => [$this, 'get_departures'],
58 'permission_callback' => [$this, 'check_view_permission'],
59 ],
60 [
61 'methods' => \WP_REST_Server::CREATABLE,
62 'callback' => [$this, 'create_departure'],
63 'permission_callback' => [$this, 'check_manage_permission'],
64 ],
65 ]);
66
67 // Single departure — view / update / delete. Update is a
68 // manage operation; DELETE is the cancellation cap because
69 // dropping a departure typically means cancelling it.
70 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)', [
71 [
72 'methods' => \WP_REST_Server::READABLE,
73 'callback' => [$this, 'get_departure'],
74 'permission_callback' => [$this, 'check_view_permission'],
75 ],
76 [
77 'methods' => \WP_REST_Server::EDITABLE,
78 'callback' => [$this, 'update_departure'],
79 'permission_callback' => [$this, 'check_manage_permission'],
80 ],
81 [
82 'methods' => \WP_REST_Server::DELETABLE,
83 'callback' => [$this, 'delete_departure'],
84 'permission_callback' => [$this, 'check_cancel_permission'],
85 ],
86 ]);
87
88 // Past departures endpoint — view cap.
89 register_rest_route($namespace, '/' . $base . '/past', [
90 [
91 'methods' => \WP_REST_Server::READABLE,
92 'callback' => [$this, 'get_past_departures'],
93 'permission_callback' => [$this, 'check_view_permission'],
94 ],
95 ]);
96
97 // Available dates endpoint (for frontend booking widget).
98 register_rest_route($namespace, '/trips/(?P<trip_id>[\d]+)/available-dates', [
99 [
100 'methods' => \WP_REST_Server::READABLE,
101 'callback' => [$this, 'get_available_dates'],
102 'permission_callback' => '__return_true', // Public endpoint
103 ],
104 ]);
105
106 // Recurring rules — these are availability templates on the
107 // TRIP, not on individual departures. Gated on the trip-edit
108 // cap (same as the other availability controllers).
109 $rulesBase = 'trips/(?P<trip_id>[\d]+)/recurring-rules';
110 register_rest_route($namespace, '/' . $rulesBase, [
111 [
112 'methods' => \WP_REST_Server::READABLE,
113 'callback' => [$this, 'get_recurring_rules'],
114 'permission_callback' => [$this, 'check_view_permission'],
115 ],
116 [
117 'methods' => \WP_REST_Server::CREATABLE,
118 'callback' => [$this, 'create_recurring_rule'],
119 'permission_callback' => [$this, 'check_trip_edit_permission'],
120 ],
121 ]);
122
123 register_rest_route($namespace, '/' . $rulesBase . '/(?P<id>[\d]+)', [
124 [
125 'methods' => \WP_REST_Server::READABLE,
126 'callback' => [$this, 'get_recurring_rule'],
127 'permission_callback' => [$this, 'check_view_permission'],
128 ],
129 [
130 'methods' => \WP_REST_Server::EDITABLE,
131 'callback' => [$this, 'update_recurring_rule'],
132 'permission_callback' => [$this, 'check_trip_edit_permission'],
133 ],
134 [
135 'methods' => \WP_REST_Server::DELETABLE,
136 'callback' => [$this, 'delete_recurring_rule'],
137 'permission_callback' => [$this, 'check_trip_edit_permission'],
138 ],
139 ]);
140
141 // Preview recurring-rule dates — view cap.
142 register_rest_route($namespace, '/' . $rulesBase . '/(?P<id>[\d]+)/preview', [
143 [
144 'methods' => \WP_REST_Server::READABLE,
145 'callback' => [$this, 'preview_recurring_rule'],
146 'permission_callback' => [$this, 'check_view_permission'],
147 ],
148 ]);
149 }
150
151 /**
152 * Granular cap checks for every Departure endpoint. The previous
153 * implementation gated everything on `manage_options` which
154 * locked Sales Agent / Front Desk / Guide / Accountant / Auditor
155 * out of the departures REST surface despite the role bundles
156 * granting them view / manage / cancel caps. WP admins pass via
157 * the Team module's admin-fallback filter.
158 */
159 public function check_view_permission(?WP_REST_Request $request = null): bool
160 {
161 return current_user_can('yatra_view_departures');
162 }
163
164 public function check_manage_permission(?WP_REST_Request $request = null): bool
165 {
166 // Held by Owner / Manager / Guide. Used for create + update.
167 return current_user_can('yatra_manage_departures');
168 }
169
170 public function check_cancel_permission(?WP_REST_Request $request = null): bool
171 {
172 // Held by Owner / Manager only by default. Cancelling a
173 // departure is a customer-affecting action (refunds, emails)
174 // so it gets the stricter cap than ordinary management.
175 return current_user_can('yatra_cancel_departures');
176 }
177
178 public function check_trip_edit_permission(?WP_REST_Request $request = null): bool
179 {
180 // Recurring rules belong to the parent trip, not to any one
181 // departure. Their lifecycle matches the trip-edit cap.
182 return current_user_can('yatra_edit_trips');
183 }
184
185 /**
186 * @deprecated Kept for any external code referencing the old
187 * method. Routes to view — safer default than the old
188 * `manage_options` shorthand. Admin users still pass via the
189 * admin-fallback layer.
190 */
191 public function check_permission(?WP_REST_Request $request = null): bool
192 {
193 return $this->check_view_permission($request);
194 }
195
196 // =========================================================================
197 // DEPARTURES ENDPOINTS
198 // =========================================================================
199
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 /**
219 * GET /trips/{trip_id}/departures
220 */
221 public function get_departures(WP_REST_Request $request): WP_REST_Response
222 {
223 $tripId = (int) $request->get_param('trip_id');
224 $status = $request->get_param('status');
225 $availability = $request->get_param('availability');
226 $search = $request->get_param('search');
227 $source = $request->get_param('source');
228 $dateFrom = $request->get_param('date_from');
229 $dateTo = $request->get_param('date_to');
230 $includePast = $request->get_param('include_past') !== 'false';
231
232 $filters = [];
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);
238 if ($source) $filters['source'] = $source;
239 if ($dateFrom && trim($dateFrom) !== '') $filters['date_from'] = $dateFrom;
240 if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo;
241 $filters['include_past'] = $includePast;
242
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);
253 $departures = $this->departureService->getByTripId($tripId, $filters);
254
255 // Get trip information
256 $tripRepository = new \Yatra\Repositories\TripRepository();
257 $trip = $tripRepository->find($tripId);
258
259 // Get booking departure repository for booking links
260 $bookingDepartureRepo = new \Yatra\Repositories\BookingDepartureRepository();
261 $travellerRepo = new \Yatra\Repositories\TravellerRepository();
262 $bookingRepo = new \Yatra\Repositories\BookingRepository();
263
264 // Get capacity service to sync capacity from availability
265 $capacityService = new \Yatra\Services\CapacityService();
266 $departureRepo = new \Yatra\Repositories\DepartureRepository();
267
268 return new WP_REST_Response([
269 'success' => true,
270 'data' => array_map(function ($d) use ($trip, $bookingDepartureRepo, $travellerRepo, $bookingRepo, $capacityService, $departureRepo) {
271 // Sync capacity from availability before returning
272 $date = $d->start_date ?: $d->date;
273 $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date, $d->time ?? null);
274 if ($correctCapacity > 0 && $d->max_capacity !== $correctCapacity) {
275 $departureRepo->update($d->id, ['max_capacity' => $correctCapacity]);
276 $d->max_capacity = $correctCapacity;
277 }
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
294 $departureArray = $d->toArray();
295
296 // Add trip information
297 if ($trip) {
298 $departureArray['trip'] = [
299 'id' => (int) $trip->id,
300 'title' => $trip->title ?? '',
301 'slug' => $trip->slug ?? '',
302 ];
303 }
304
305 // Add booking links and get travelers
306 $bookingIds = $bookingDepartureRepo->getBookingIdsForDeparture($d->id);
307 $departureArray['booking_ids'] = $bookingIds;
308 $departureArray['bookings_count'] = count($bookingIds);
309
310 // Recalculate revenue for departures with bookings
311 if (!empty($bookingIds)) {
312 try {
313 // Call the service method to recalculate revenue
314 $totalRevenue = 0.00;
315 foreach ($bookingIds as $bookingId) {
316 $booking = $bookingRepo->find($bookingId);
317 if ($booking && !empty($booking->total_amount)) {
318 $totalRevenue += (float) $booking->total_amount;
319 }
320 }
321 // Set revenue in the array directly
322 $departureArray['total_revenue'] = $totalRevenue;
323 } catch (\Exception $e) {
324 // If any error occurs, leave the original value
325 }
326 }
327
328 // Get all travelers for this departure
329 $allTravelers = [];
330 foreach ($bookingIds as $bookingId) {
331 $travelers = $travellerRepo->getByBookingId($bookingId);
332 $booking = $bookingRepo->find($bookingId);
333 foreach ($travelers as $traveler) {
334 $fields = $traveler['fields'] ?? [];
335
336 $firstName = $fields['first_name']
337 ?? $traveler['first_name']
338 ?? ($booking->contact_first_name ?? '');
339 $lastName = $fields['last_name']
340 ?? $traveler['last_name']
341 ?? ($booking->contact_last_name ?? '');
342
343 $email = $fields['email']
344 ?? $fields['contact_email']
345 ?? $fields['primary_email']
346 ?? ($booking->contact_email ?? '');
347
348 $phone = $fields['phone']
349 ?? $fields['contact_phone']
350 ?? $fields['mobile_phone']
351 ?? $fields['whatsapp']
352 ?? ($booking->contact_phone ?? '');
353
354 $allTravelers[] = [
355 'id' => (int) $traveler['id'],
356 'booking_id' => $bookingId,
357 'booking_reference' => $booking ? ($booking->reference ?? '') : '',
358 'is_lead' => (bool) ($traveler['is_lead'] ?? false),
359 'first_name' => $firstName,
360 'last_name' => $lastName,
361 'email' => $email,
362 'phone' => $phone,
363 ];
364 }
365 }
366 $departureArray['travelers'] = $allTravelers;
367 $departureArray['travelers_count'] = count($allTravelers);
368
369 // Format time for display (remove seconds if present)
370 if (!empty($departureArray['time'])) {
371 $time = $departureArray['time'];
372 // Convert HH:MM:SS to HH:MM if needed
373 if (strlen($time) > 5 && substr_count($time, ':') === 2) {
374 $departureArray['time'] = substr($time, 0, 5);
375 }
376 }
377
378 // Debug: Log time and revenue values
379 return $departureArray;
380 }, $departures),
381 'meta' => [
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,
386 ],
387 ]);
388 } catch (\Exception $e) {
389 return new WP_REST_Response([
390 'success' => false,
391 'message' => $e->getMessage(),
392 ], 400);
393 }
394 }
395
396 /**
397 * GET /trips/{trip_id}/departures/{id}
398 */
399 public function get_departure(WP_REST_Request $request): WP_REST_Response
400 {
401 $id = (int) $request->get_param('id');
402 $tripId = (int) $request->get_param('trip_id');
403
404 $repo = new DepartureRepository();
405 $departure = $repo->findModel($id);
406
407 if (!$departure) {
408 return new WP_REST_Response([
409 'success' => false,
410 'message' => 'Departure not found',
411 ], 404);
412 }
413
414 // Sync capacity from availability before returning
415 $capacityService = new \Yatra\Services\CapacityService();
416 $date = $departure->start_date ?: $departure->date;
417 $correctCapacity = $capacityService->getCapacityForDate($departure->trip_id, $date, $departure->time ?? null);
418 if ($correctCapacity > 0 && $departure->max_capacity !== $correctCapacity) {
419 $repo->update($departure->id, ['max_capacity' => $correctCapacity]);
420 $departure->max_capacity = $correctCapacity;
421 }
422
423 // Base departure array
424 $departureArray = $departure->toArray();
425
426 // Trip information
427 $tripRepository = new \Yatra\Repositories\TripRepository();
428 $trip = $tripRepository->find($tripId ?: $departure->trip_id);
429 if ($trip) {
430 // Log trip data for debugging
431 \Yatra\Utils\Logger::info("Trip data for departure {$departure->id}: " . json_encode([
432 'duration' => $trip->duration ?? 'NULL',
433 'group_type' => $trip->group_type ?? 'NULL',
434 'difficulty_level' => $trip->difficulty_level ?? 'NULL',
435 'min_travelers' => $trip->min_travelers ?? 'NULL',
436 'max_travelers' => $trip->max_travelers ?? 'NULL',
437 ]));
438
439 // Fetch difficulty level name from difficulty_levels table
440 $difficultyLevelName = '';
441 if (!empty($trip->difficulty_level) && is_numeric($trip->difficulty_level)) {
442 $difficultyRepo = new \Yatra\Repositories\DifficultyLevelRepository();
443 $difficultyLevel = $difficultyRepo->find((int) $trip->difficulty_level);
444 if ($difficultyLevel) {
445 $difficultyLevelName = $difficultyLevel->name ?? '';
446 }
447 }
448
449 // Fetch group type name from traveler_categories table
450 $groupTypeName = '';
451 if (!empty($trip->group_type) && is_numeric($trip->group_type)) {
452 $travelerCategoryRepo = new \Yatra\Repositories\TravelerCategoryRepository();
453 $travelerCategory = $travelerCategoryRepo->find((int) $trip->group_type);
454 if ($travelerCategory) {
455 $groupTypeName = $travelerCategory->name ?? '';
456 }
457 }
458
459 $departureArray['trip'] = [
460 'id' => (int) $trip->id,
461 'title' => $trip->title ?? '',
462 'slug' => $trip->slug ?? '',
463 'summary' => $trip->short_description
464 ?? $trip->excerpt
465 ?? $trip->summary
466 ?? '',
467 'starting_location' => $trip->starting_location ?? '',
468 'ending_location' => $trip->ending_location ?? '',
469 'difficulty_level' => $difficultyLevelName,
470 'group_type' => $groupTypeName,
471 'min_travelers' => $trip->min_travelers ?? null,
472 'max_travelers' => $trip->max_travelers ?? null,
473 'duration' => $trip->duration ?? null,
474 'price' => $trip->price ?? null,
475 'created_at' => $trip->created_at ?? '',
476 ];
477 }
478
479 // Related bookings and travelers (mirror get_departures logic)
480 $bookingDepartureRepo = new \Yatra\Repositories\BookingDepartureRepository();
481 $travellerRepo = new \Yatra\Repositories\TravellerRepository();
482 $bookingRepo = new \Yatra\Repositories\BookingRepository();
483
484 $bookingIds = $bookingDepartureRepo->getBookingIdsForDeparture($departure->id);
485 $departureArray['booking_ids'] = $bookingIds;
486 $departureArray['bookings_count'] = count($bookingIds);
487
488 // Calculate total revenue from bookings (simple sum of total_amount like list endpoint)
489 if (!empty($bookingIds)) {
490 try {
491 $totalRevenue = 0.00;
492 foreach ($bookingIds as $bookingId) {
493 $booking = $bookingRepo->find($bookingId);
494 if ($booking && !empty($booking->total_amount)) {
495 $totalRevenue += (float) $booking->total_amount;
496 }
497 }
498 $departureArray['total_revenue'] = $totalRevenue;
499 } catch (\Exception $e) {
500 // Leave original value on error
501 if (defined('WP_DEBUG') && WP_DEBUG) {
502 }
503 }
504 }
505
506 // Travelers linked to this departure
507 $allTravelers = [];
508 foreach ($bookingIds as $bookingId) {
509 $travelers = $travellerRepo->getByBookingId($bookingId);
510 $booking = $bookingRepo->find($bookingId);
511 foreach ($travelers as $traveler) {
512 $fields = $traveler['fields'] ?? [];
513
514 $firstName = $fields['first_name']
515 ?? $traveler['first_name']
516 ?? ($booking->contact_first_name ?? '');
517 $lastName = $fields['last_name']
518 ?? $traveler['last_name']
519 ?? ($booking->contact_last_name ?? '');
520
521 $email = $fields['email']
522 ?? $fields['contact_email']
523 ?? $fields['primary_email']
524 ?? ($booking->contact_email ?? '');
525
526 $phone = $fields['phone']
527 ?? $fields['contact_phone']
528 ?? $fields['mobile_phone']
529 ?? $fields['whatsapp']
530 ?? ($booking->contact_phone ?? '');
531
532 $allTravelers[] = [
533 'id' => (int) $traveler['id'],
534 'booking_id' => $bookingId,
535 'booking_reference' => $booking ? ($booking->reference ?? '') : '',
536 'is_lead' => (bool) ($traveler['is_lead'] ?? false),
537 'first_name' => $firstName,
538 'last_name' => $lastName,
539 'email' => $email,
540 'phone' => $phone,
541 ];
542 }
543 }
544 $departureArray['travelers'] = $allTravelers;
545 $departureArray['travelers_count'] = count($allTravelers);
546
547 // Format time (HH:MM)
548 if (!empty($departureArray['time'])) {
549 $time = $departureArray['time'];
550 if (strlen($time) > 5 && substr_count($time, ':') === 2) {
551 $departureArray['time'] = substr($time, 0, 5);
552 }
553 }
554
555 return new WP_REST_Response([
556 'success' => true,
557 'data' => $departureArray,
558 ]);
559 }
560
561 /**
562 * POST /trips/{trip_id}/departures
563 */
564 public function create_departure(WP_REST_Request $request): WP_REST_Response
565 {
566 $tripId = (int) $request->get_param('trip_id');
567 $data = $request->get_json_params();
568 $data['trip_id'] = $tripId;
569
570 try {
571 $id = $this->departureService->create($data);
572
573 $repo = new DepartureRepository();
574 $departure = $repo->findModel($id);
575
576 return new WP_REST_Response([
577 'success' => true,
578 'data' => $departure->toArray(),
579 'message' => 'Departure created successfully',
580 ], 201);
581 } catch (\Exception $e) {
582 return new WP_REST_Response([
583 'success' => false,
584 'message' => $e->getMessage(),
585 ], 400);
586 }
587 }
588
589 /**
590 * PUT /trips/{trip_id}/departures/{id}
591 */
592 public function update_departure(WP_REST_Request $request): WP_REST_Response
593 {
594 $id = (int) $request->get_param('id');
595 $data = $request->get_json_params();
596
597 try {
598 $this->departureService->update($id, $data);
599
600 $repo = new DepartureRepository();
601 $departure = $repo->findModel($id);
602
603 return new WP_REST_Response([
604 'success' => true,
605 'data' => $departure->toArray(),
606 'message' => 'Departure updated successfully',
607 ]);
608 } catch (\Exception $e) {
609 return new WP_REST_Response([
610 'success' => false,
611 'message' => $e->getMessage(),
612 ], 400);
613 }
614 }
615
616 /**
617 * DELETE /trips/{trip_id}/departures/{id}
618 */
619 public function delete_departure(WP_REST_Request $request): WP_REST_Response
620 {
621 $id = (int) $request->get_param('id');
622
623 try {
624 $this->departureService->delete($id);
625
626 return new WP_REST_Response([
627 'success' => true,
628 'message' => 'Departure deleted successfully',
629 ]);
630 } catch (\Exception $e) {
631 return new WP_REST_Response([
632 'success' => false,
633 'message' => $e->getMessage(),
634 ], 400);
635 }
636 }
637
638 /**
639 * GET /departures
640 * Get departures from all trips
641 */
642 public function get_all_departures(WP_REST_Request $request): WP_REST_Response
643 {
644 $status = $request->get_param('status');
645 $availability = $request->get_param('availability');
646 $search = $request->get_param('search');
647 $source = $request->get_param('source');
648 $dateFrom = $request->get_param('date_from');
649 $dateTo = $request->get_param('date_to');
650 $includePast = $request->get_param('include_past') !== 'false';
651
652 $filters = [];
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);
658 if ($source) $filters['source'] = $source;
659 if ($dateFrom && trim($dateFrom) !== '') $filters['date_from'] = $dateFrom;
660 if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo;
661 $filters['include_past'] = $includePast;
662
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);
673 // Get all departures (no trip filter)
674 $departures = $this->departureService->getAllDepartures($filters);
675
676 // Get repository for additional data
677 $bookingDepartureRepo = new \Yatra\Repositories\BookingDepartureRepository();
678 $travellerRepo = new \Yatra\Repositories\TravellerRepository();
679 $bookingRepo = new \Yatra\Repositories\BookingRepository();
680 $tripRepository = new \Yatra\Repositories\TripRepository();
681
682 // Get capacity service to sync capacity from availability
683 $capacityService = new \Yatra\Services\CapacityService();
684 $departureRepo = new \Yatra\Repositories\DepartureRepository();
685
686 // Process each departure to add related data
687 $processed = array_map(function ($d) use ($tripRepository, $bookingDepartureRepo, $travellerRepo, $bookingRepo, $capacityService, $departureRepo) {
688 // Sync capacity from availability before returning
689 $date = $d->start_date ?: $d->date;
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 }
713 }
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
730 $departureArray = $d->toArray();
731
732 // Add trip information
733 $trip = $tripRepository->find($d->trip_id);
734 if ($trip) {
735 $departureArray['trip'] = [
736 'id' => (int) $trip->id,
737 'title' => $trip->title ?? '',
738 'slug' => $trip->slug ?? '',
739 ];
740 }
741
742 // Add booking links and get travelers
743 $bookingIds = $bookingDepartureRepo->getBookingIdsForDeparture($d->id);
744 $departureArray['booking_ids'] = $bookingIds;
745 $departureArray['bookings_count'] = count($bookingIds);
746
747 // Recalculate revenue for departures with bookings
748 if (!empty($bookingIds)) {
749 try {
750 $totalRevenue = 0.00;
751 foreach ($bookingIds as $bookingId) {
752 $booking = $bookingRepo->find($bookingId);
753 if ($booking && !empty($booking->total_amount)) {
754 $totalRevenue += (float) $booking->total_amount;
755 }
756 }
757 $departureArray['total_revenue'] = $totalRevenue;
758 } catch (\Exception $e) {
759 }
760 }
761
762 // Get all travelers for this departure
763 $allTravelers = [];
764 foreach ($bookingIds as $bookingId) {
765 $travelers = $travellerRepo->getByBookingId($bookingId);
766 $booking = $bookingRepo->find($bookingId);
767 foreach ($travelers as $traveler) {
768 $fields = $traveler['fields'] ?? [];
769
770 $firstName = $fields['first_name']
771 ?? $traveler['first_name']
772 ?? ($booking->contact_first_name ?? '');
773 $lastName = $fields['last_name']
774 ?? $traveler['last_name']
775 ?? ($booking->contact_last_name ?? '');
776
777 $email = $fields['email']
778 ?? $fields['contact_email']
779 ?? $fields['primary_email']
780 ?? ($booking->contact_email ?? '');
781
782 $phone = $fields['phone']
783 ?? $fields['contact_phone']
784 ?? $fields['mobile_phone']
785 ?? $fields['whatsapp']
786 ?? ($booking->contact_phone ?? '');
787
788 $allTravelers[] = [
789 'id' => (int) $traveler['id'],
790 'booking_id' => $bookingId,
791 'booking_reference' => $booking ? ($booking->reference ?? '') : '',
792 'is_lead' => (bool) ($traveler['is_lead'] ?? false),
793 'first_name' => $firstName,
794 'last_name' => $lastName,
795 'email' => $email,
796 'phone' => $phone,
797 ];
798 }
799 }
800 $departureArray['travelers'] = $allTravelers;
801 $departureArray['travelers_count'] = count($allTravelers);
802
803 // Format time for display (remove seconds if present)
804 if (!empty($departureArray['time'])) {
805 $time = $departureArray['time'];
806 if (strlen($time) > 5 && substr_count($time, ':') === 2) {
807 $departureArray['time'] = substr($time, 0, 5);
808 }
809 }
810
811 return $departureArray;
812 }, $departures);
813
814 return new WP_REST_Response([
815 'success' => true,
816 'data' => $processed,
817 'meta' => [
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,
822 ],
823 ]);
824 } catch (\Exception $e) {
825 return new WP_REST_Response([
826 'success' => false,
827 'message' => $e->getMessage(),
828 ], 400);
829 }
830 }
831
832 /**
833 * GET /trips/{trip_id}/departures/past
834 */
835 public function get_past_departures(WP_REST_Request $request): WP_REST_Response
836 {
837 $tripId = (int) $request->get_param('trip_id');
838
839 try {
840 $departures = $this->departureService->getPastByTripId($tripId);
841
842 return new WP_REST_Response([
843 'success' => true,
844 'data' => array_map(function ($d) {
845 return $d->toArray();
846 }, $departures),
847 ]);
848 } catch (\Exception $e) {
849 return new WP_REST_Response([
850 'success' => false,
851 'message' => $e->getMessage(),
852 ], 400);
853 }
854 }
855
856 /**
857 * GET /trips/{trip_id}/available-dates
858 * Public endpoint for frontend to get available dates
859 */
860 public function get_available_dates(WP_REST_Request $request): WP_REST_Response
861 {
862 $tripId = (int) $request->get_param('trip_id');
863 $fromDate = $request->get_param('from_date') ?: date('Y-m-d');
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();
869
870 try {
871 $dates = $this->departureService->getAvailableDates($tripId, $fromDate, $toDate);
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
909 return new WP_REST_Response([
910 'success' => true,
911 'data' => $dates,
912 ]);
913 } catch (\Exception $e) {
914 return new WP_REST_Response([
915 'success' => false,
916 'message' => $e->getMessage(),
917 ], 400);
918 }
919 }
920
921 // =========================================================================
922 // RECURRING RULES ENDPOINTS
923 // =========================================================================
924
925 /**
926 * GET /trips/{trip_id}/recurring-rules
927 */
928 public function get_recurring_rules(WP_REST_Request $request): WP_REST_Response
929 {
930 $tripId = (int) $request->get_param('trip_id');
931 $activeOnly = $request->get_param('active_only') === 'true';
932
933 try {
934 $rules = $this->ruleService->getByTripId($tripId, $activeOnly);
935
936 return new WP_REST_Response([
937 'success' => true,
938 'data' => array_map(function ($r) {
939 return $r->toArray();
940 }, $rules),
941 ]);
942 } catch (\Exception $e) {
943 return new WP_REST_Response([
944 'success' => false,
945 'message' => $e->getMessage(),
946 ], 400);
947 }
948 }
949
950 /**
951 * GET /trips/{trip_id}/recurring-rules/{id}
952 */
953 public function get_recurring_rule(WP_REST_Request $request): WP_REST_Response
954 {
955 $id = (int) $request->get_param('id');
956
957 $repo = new RecurringRuleRepository();
958 $rule = $repo->findModel($id);
959
960 if (!$rule) {
961 return new WP_REST_Response([
962 'success' => false,
963 'message' => 'Recurring rule not found',
964 ], 404);
965 }
966
967 return new WP_REST_Response([
968 'success' => true,
969 'data' => $rule->toArray(),
970 ]);
971 }
972
973 /**
974 * POST /trips/{trip_id}/recurring-rules
975 */
976 public function create_recurring_rule(WP_REST_Request $request): WP_REST_Response
977 {
978 $tripId = (int) $request->get_param('trip_id');
979 $data = $request->get_json_params();
980 $data['trip_id'] = $tripId;
981
982 try {
983 $id = $this->ruleService->create($data);
984
985 $repo = new RecurringRuleRepository();
986 $rule = $repo->findModel($id);
987
988 return new WP_REST_Response([
989 'success' => true,
990 'data' => $rule->toArray(),
991 'message' => 'Recurring rule created successfully',
992 ], 201);
993 } catch (\Exception $e) {
994 return new WP_REST_Response([
995 'success' => false,
996 'message' => $e->getMessage(),
997 ], 400);
998 }
999 }
1000
1001 /**
1002 * PUT /trips/{trip_id}/recurring-rules/{id}
1003 */
1004 public function update_recurring_rule(WP_REST_Request $request): WP_REST_Response
1005 {
1006 $id = (int) $request->get_param('id');
1007 $data = $request->get_json_params();
1008
1009 try {
1010 $this->ruleService->update($id, $data);
1011
1012 $repo = new RecurringRuleRepository();
1013 $rule = $repo->findModel($id);
1014
1015 return new WP_REST_Response([
1016 'success' => true,
1017 'data' => $rule->toArray(),
1018 'message' => 'Recurring rule updated successfully',
1019 ]);
1020 } catch (\Exception $e) {
1021 return new WP_REST_Response([
1022 'success' => false,
1023 'message' => $e->getMessage(),
1024 ], 400);
1025 }
1026 }
1027
1028 /**
1029 * DELETE /trips/{trip_id}/recurring-rules/{id}
1030 */
1031 public function delete_recurring_rule(WP_REST_Request $request): WP_REST_Response
1032 {
1033 $id = (int) $request->get_param('id');
1034
1035 try {
1036 $this->ruleService->delete($id);
1037
1038 return new WP_REST_Response([
1039 'success' => true,
1040 'message' => 'Recurring rule deleted successfully',
1041 ]);
1042 } catch (\Exception $e) {
1043 return new WP_REST_Response([
1044 'success' => false,
1045 'message' => $e->getMessage(),
1046 ], 400);
1047 }
1048 }
1049
1050 /**
1051 * GET /trips/{trip_id}/recurring-rules/{id}/preview
1052 */
1053 public function preview_recurring_rule(WP_REST_Request $request): WP_REST_Response
1054 {
1055 $id = (int) $request->get_param('id');
1056 $count = (int) ($request->get_param('count') ?: 10);
1057
1058 try {
1059 $dates = $this->ruleService->getPreviewDates($id, $count);
1060
1061 return new WP_REST_Response([
1062 'success' => true,
1063 'data' => $dates,
1064 ]);
1065 } catch (\Exception $e) {
1066 return new WP_REST_Response([
1067 'success' => false,
1068 'message' => $e->getMessage(),
1069 ], 400);
1070 }
1071 }
1072 }
1073
1074