PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.2
Yatra – Travel Booking & Tour Operator Software v3.0.14.2
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.14.2, at app/Controllers/TripAvailabilityController.php

1,016 lines 41.7 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 * GET /trips/{trip_id}/departures
202 */
203 public function get_departures(WP_REST_Request $request): WP_REST_Response
204 {
205 $tripId = (int) $request->get_param('trip_id');
206 $status = $request->get_param('status');
207 $source = $request->get_param('source');
208 $dateFrom = $request->get_param('date_from');
209 $dateTo = $request->get_param('date_to');
210 $includePast = $request->get_param('include_past') !== 'false';
211
212 $filters = [];
213 if ($status) $filters['status'] = $status;
214 if ($source) $filters['source'] = $source;
215 if ($dateFrom && trim($dateFrom) !== '') $filters['date_from'] = $dateFrom;
216 if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo;
217 $filters['include_past'] = $includePast;
218
219 try {
220 $departures = $this->departureService->getByTripId($tripId, $filters);
221
222 // Get trip information
223 $tripRepository = new \Yatra\Repositories\TripRepository();
224 $trip = $tripRepository->find($tripId);
225
226 // Get booking departure repository for booking links
227 $bookingDepartureRepo = new \Yatra\Repositories\BookingDepartureRepository();
228 $travellerRepo = new \Yatra\Repositories\TravellerRepository();
229 $bookingRepo = new \Yatra\Repositories\BookingRepository();
230
231 // Get capacity service to sync capacity from availability
232 $capacityService = new \Yatra\Services\CapacityService();
233 $departureRepo = new \Yatra\Repositories\DepartureRepository();
234
235 return new WP_REST_Response([
236 'success' => true,
237 'data' => array_map(function ($d) use ($trip, $bookingDepartureRepo, $travellerRepo, $bookingRepo, $capacityService, $departureRepo) {
238 // Sync capacity from availability before returning
239 $date = $d->start_date ?: $d->date;
240 $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date, $d->time ?? null);
241 if ($correctCapacity > 0 && $d->max_capacity !== $correctCapacity) {
242 $departureRepo->update($d->id, ['max_capacity' => $correctCapacity]);
243 $d->max_capacity = $correctCapacity;
244 }
245
246 // Promote a departure that has taken place to 'past' so a
247 // completed departure is never shown with (or hidden behind) a
248 // stale 'upcoming'/'full' status — the daily cron may not have
249 // run. Cancelled/trashed departures are left as-is. This keeps
250 // the status badge and the tab counts date-accurate.
251 if (!in_array($d->status, ['cancelled', 'trash', 'past'], true)) {
252 $checkDate = (!empty($d->end_date) && $d->end_date !== '0000-00-00')
253 ? $d->end_date
254 : ((!empty($d->start_date) && $d->start_date !== '0000-00-00') ? $d->start_date : $d->date);
255 if (!empty($checkDate) && $checkDate < date('Y-m-d')) {
256 $departureRepo->update($d->id, ['status' => 'past']);
257 $d->status = 'past';
258 }
259 }
260
261 $departureArray = $d->toArray();
262
263 // Add trip information
264 if ($trip) {
265 $departureArray['trip'] = [
266 'id' => (int) $trip->id,
267 'title' => $trip->title ?? '',
268 'slug' => $trip->slug ?? '',
269 ];
270 }
271
272 // Add booking links and get travelers
273 $bookingIds = $bookingDepartureRepo->getBookingIdsForDeparture($d->id);
274 $departureArray['booking_ids'] = $bookingIds;
275 $departureArray['bookings_count'] = count($bookingIds);
276
277 // Recalculate revenue for departures with bookings
278 if (!empty($bookingIds)) {
279 try {
280 // Call the service method to recalculate revenue
281 $totalRevenue = 0.00;
282 foreach ($bookingIds as $bookingId) {
283 $booking = $bookingRepo->find($bookingId);
284 if ($booking && !empty($booking->total_amount)) {
285 $totalRevenue += (float) $booking->total_amount;
286 }
287 }
288 // Set revenue in the array directly
289 $departureArray['total_revenue'] = $totalRevenue;
290 } catch (\Exception $e) {
291 // If any error occurs, leave the original value
292 }
293 }
294
295 // Get all travelers for this departure
296 $allTravelers = [];
297 foreach ($bookingIds as $bookingId) {
298 $travelers = $travellerRepo->getByBookingId($bookingId);
299 $booking = $bookingRepo->find($bookingId);
300 foreach ($travelers as $traveler) {
301 $fields = $traveler['fields'] ?? [];
302
303 $firstName = $fields['first_name']
304 ?? $traveler['first_name']
305 ?? ($booking->contact_first_name ?? '');
306 $lastName = $fields['last_name']
307 ?? $traveler['last_name']
308 ?? ($booking->contact_last_name ?? '');
309
310 $email = $fields['email']
311 ?? $fields['contact_email']
312 ?? $fields['primary_email']
313 ?? ($booking->contact_email ?? '');
314
315 $phone = $fields['phone']
316 ?? $fields['contact_phone']
317 ?? $fields['mobile_phone']
318 ?? $fields['whatsapp']
319 ?? ($booking->contact_phone ?? '');
320
321 $allTravelers[] = [
322 'id' => (int) $traveler['id'],
323 'booking_id' => $bookingId,
324 'booking_reference' => $booking ? ($booking->reference ?? '') : '',
325 'is_lead' => (bool) ($traveler['is_lead'] ?? false),
326 'first_name' => $firstName,
327 'last_name' => $lastName,
328 'email' => $email,
329 'phone' => $phone,
330 ];
331 }
332 }
333 $departureArray['travelers'] = $allTravelers;
334 $departureArray['travelers_count'] = count($allTravelers);
335
336 // Format time for display (remove seconds if present)
337 if (!empty($departureArray['time'])) {
338 $time = $departureArray['time'];
339 // Convert HH:MM:SS to HH:MM if needed
340 if (strlen($time) > 5 && substr_count($time, ':') === 2) {
341 $departureArray['time'] = substr($time, 0, 5);
342 }
343 }
344
345 // Debug: Log time and revenue values
346 return $departureArray;
347 }, $departures),
348 'meta' => [
349 'total' => count($departures),
350 ],
351 ]);
352 } catch (\Exception $e) {
353 return new WP_REST_Response([
354 'success' => false,
355 'message' => $e->getMessage(),
356 ], 400);
357 }
358 }
359
360 /**
361 * GET /trips/{trip_id}/departures/{id}
362 */
363 public function get_departure(WP_REST_Request $request): WP_REST_Response
364 {
365 $id = (int) $request->get_param('id');
366 $tripId = (int) $request->get_param('trip_id');
367
368 $repo = new DepartureRepository();
369 $departure = $repo->findModel($id);
370
371 if (!$departure) {
372 return new WP_REST_Response([
373 'success' => false,
374 'message' => 'Departure not found',
375 ], 404);
376 }
377
378 // Sync capacity from availability before returning
379 $capacityService = new \Yatra\Services\CapacityService();
380 $date = $departure->start_date ?: $departure->date;
381 $correctCapacity = $capacityService->getCapacityForDate($departure->trip_id, $date, $departure->time ?? null);
382 if ($correctCapacity > 0 && $departure->max_capacity !== $correctCapacity) {
383 $repo->update($departure->id, ['max_capacity' => $correctCapacity]);
384 $departure->max_capacity = $correctCapacity;
385 }
386
387 // Base departure array
388 $departureArray = $departure->toArray();
389
390 // Trip information
391 $tripRepository = new \Yatra\Repositories\TripRepository();
392 $trip = $tripRepository->find($tripId ?: $departure->trip_id);
393 if ($trip) {
394 // Log trip data for debugging
395 \Yatra\Utils\Logger::info("Trip data for departure {$departure->id}: " . json_encode([
396 'duration' => $trip->duration ?? 'NULL',
397 'group_type' => $trip->group_type ?? 'NULL',
398 'difficulty_level' => $trip->difficulty_level ?? 'NULL',
399 'min_travelers' => $trip->min_travelers ?? 'NULL',
400 'max_travelers' => $trip->max_travelers ?? 'NULL',
401 ]));
402
403 // Fetch difficulty level name from difficulty_levels table
404 $difficultyLevelName = '';
405 if (!empty($trip->difficulty_level) && is_numeric($trip->difficulty_level)) {
406 $difficultyRepo = new \Yatra\Repositories\DifficultyLevelRepository();
407 $difficultyLevel = $difficultyRepo->find((int) $trip->difficulty_level);
408 if ($difficultyLevel) {
409 $difficultyLevelName = $difficultyLevel->name ?? '';
410 }
411 }
412
413 // Fetch group type name from traveler_categories table
414 $groupTypeName = '';
415 if (!empty($trip->group_type) && is_numeric($trip->group_type)) {
416 $travelerCategoryRepo = new \Yatra\Repositories\TravelerCategoryRepository();
417 $travelerCategory = $travelerCategoryRepo->find((int) $trip->group_type);
418 if ($travelerCategory) {
419 $groupTypeName = $travelerCategory->name ?? '';
420 }
421 }
422
423 $departureArray['trip'] = [
424 'id' => (int) $trip->id,
425 'title' => $trip->title ?? '',
426 'slug' => $trip->slug ?? '',
427 'summary' => $trip->short_description
428 ?? $trip->excerpt
429 ?? $trip->summary
430 ?? '',
431 'starting_location' => $trip->starting_location ?? '',
432 'ending_location' => $trip->ending_location ?? '',
433 'difficulty_level' => $difficultyLevelName,
434 'group_type' => $groupTypeName,
435 'min_travelers' => $trip->min_travelers ?? null,
436 'max_travelers' => $trip->max_travelers ?? null,
437 'duration' => $trip->duration ?? null,
438 'price' => $trip->price ?? null,
439 'created_at' => $trip->created_at ?? '',
440 ];
441 }
442
443 // Related bookings and travelers (mirror get_departures logic)
444 $bookingDepartureRepo = new \Yatra\Repositories\BookingDepartureRepository();
445 $travellerRepo = new \Yatra\Repositories\TravellerRepository();
446 $bookingRepo = new \Yatra\Repositories\BookingRepository();
447
448 $bookingIds = $bookingDepartureRepo->getBookingIdsForDeparture($departure->id);
449 $departureArray['booking_ids'] = $bookingIds;
450 $departureArray['bookings_count'] = count($bookingIds);
451
452 // Calculate total revenue from bookings (simple sum of total_amount like list endpoint)
453 if (!empty($bookingIds)) {
454 try {
455 $totalRevenue = 0.00;
456 foreach ($bookingIds as $bookingId) {
457 $booking = $bookingRepo->find($bookingId);
458 if ($booking && !empty($booking->total_amount)) {
459 $totalRevenue += (float) $booking->total_amount;
460 }
461 }
462 $departureArray['total_revenue'] = $totalRevenue;
463 } catch (\Exception $e) {
464 // Leave original value on error
465 if (defined('WP_DEBUG') && WP_DEBUG) {
466 }
467 }
468 }
469
470 // Travelers linked to this departure
471 $allTravelers = [];
472 foreach ($bookingIds as $bookingId) {
473 $travelers = $travellerRepo->getByBookingId($bookingId);
474 $booking = $bookingRepo->find($bookingId);
475 foreach ($travelers as $traveler) {
476 $fields = $traveler['fields'] ?? [];
477
478 $firstName = $fields['first_name']
479 ?? $traveler['first_name']
480 ?? ($booking->contact_first_name ?? '');
481 $lastName = $fields['last_name']
482 ?? $traveler['last_name']
483 ?? ($booking->contact_last_name ?? '');
484
485 $email = $fields['email']
486 ?? $fields['contact_email']
487 ?? $fields['primary_email']
488 ?? ($booking->contact_email ?? '');
489
490 $phone = $fields['phone']
491 ?? $fields['contact_phone']
492 ?? $fields['mobile_phone']
493 ?? $fields['whatsapp']
494 ?? ($booking->contact_phone ?? '');
495
496 $allTravelers[] = [
497 'id' => (int) $traveler['id'],
498 'booking_id' => $bookingId,
499 'booking_reference' => $booking ? ($booking->reference ?? '') : '',
500 'is_lead' => (bool) ($traveler['is_lead'] ?? false),
501 'first_name' => $firstName,
502 'last_name' => $lastName,
503 'email' => $email,
504 'phone' => $phone,
505 ];
506 }
507 }
508 $departureArray['travelers'] = $allTravelers;
509 $departureArray['travelers_count'] = count($allTravelers);
510
511 // Format time (HH:MM)
512 if (!empty($departureArray['time'])) {
513 $time = $departureArray['time'];
514 if (strlen($time) > 5 && substr_count($time, ':') === 2) {
515 $departureArray['time'] = substr($time, 0, 5);
516 }
517 }
518
519 return new WP_REST_Response([
520 'success' => true,
521 'data' => $departureArray,
522 ]);
523 }
524
525 /**
526 * POST /trips/{trip_id}/departures
527 */
528 public function create_departure(WP_REST_Request $request): WP_REST_Response
529 {
530 $tripId = (int) $request->get_param('trip_id');
531 $data = $request->get_json_params();
532 $data['trip_id'] = $tripId;
533
534 try {
535 $id = $this->departureService->create($data);
536
537 $repo = new DepartureRepository();
538 $departure = $repo->findModel($id);
539
540 return new WP_REST_Response([
541 'success' => true,
542 'data' => $departure->toArray(),
543 'message' => 'Departure created successfully',
544 ], 201);
545 } catch (\Exception $e) {
546 return new WP_REST_Response([
547 'success' => false,
548 'message' => $e->getMessage(),
549 ], 400);
550 }
551 }
552
553 /**
554 * PUT /trips/{trip_id}/departures/{id}
555 */
556 public function update_departure(WP_REST_Request $request): WP_REST_Response
557 {
558 $id = (int) $request->get_param('id');
559 $data = $request->get_json_params();
560
561 try {
562 $this->departureService->update($id, $data);
563
564 $repo = new DepartureRepository();
565 $departure = $repo->findModel($id);
566
567 return new WP_REST_Response([
568 'success' => true,
569 'data' => $departure->toArray(),
570 'message' => 'Departure updated successfully',
571 ]);
572 } catch (\Exception $e) {
573 return new WP_REST_Response([
574 'success' => false,
575 'message' => $e->getMessage(),
576 ], 400);
577 }
578 }
579
580 /**
581 * DELETE /trips/{trip_id}/departures/{id}
582 */
583 public function delete_departure(WP_REST_Request $request): WP_REST_Response
584 {
585 $id = (int) $request->get_param('id');
586
587 try {
588 $this->departureService->delete($id);
589
590 return new WP_REST_Response([
591 'success' => true,
592 'message' => 'Departure deleted successfully',
593 ]);
594 } catch (\Exception $e) {
595 return new WP_REST_Response([
596 'success' => false,
597 'message' => $e->getMessage(),
598 ], 400);
599 }
600 }
601
602 /**
603 * GET /departures
604 * Get departures from all trips
605 */
606 public function get_all_departures(WP_REST_Request $request): WP_REST_Response
607 {
608 $status = $request->get_param('status');
609 $source = $request->get_param('source');
610 $dateFrom = $request->get_param('date_from');
611 $dateTo = $request->get_param('date_to');
612 $includePast = $request->get_param('include_past') !== 'false';
613
614 $filters = [];
615 if ($status) $filters['status'] = $status;
616 if ($source) $filters['source'] = $source;
617 if ($dateFrom && trim($dateFrom) !== '') $filters['date_from'] = $dateFrom;
618 if ($dateTo && trim($dateTo) !== '') $filters['date_to'] = $dateTo;
619 $filters['include_past'] = $includePast;
620
621 try {
622 // Get all departures (no trip filter)
623 $departures = $this->departureService->getAllDepartures($filters);
624
625 // Get repository for additional data
626 $bookingDepartureRepo = new \Yatra\Repositories\BookingDepartureRepository();
627 $travellerRepo = new \Yatra\Repositories\TravellerRepository();
628 $bookingRepo = new \Yatra\Repositories\BookingRepository();
629 $tripRepository = new \Yatra\Repositories\TripRepository();
630
631 // Get capacity service to sync capacity from availability
632 $capacityService = new \Yatra\Services\CapacityService();
633 $departureRepo = new \Yatra\Repositories\DepartureRepository();
634
635 // Process each departure to add related data
636 $processed = array_map(function ($d) use ($tripRepository, $bookingDepartureRepo, $travellerRepo, $bookingRepo, $capacityService, $departureRepo) {
637 // Sync capacity from availability before returning
638 $date = $d->start_date ?: $d->date;
639 $correctCapacity = $capacityService->getCapacityForDate($d->trip_id, $date, $d->time ?? null);
640 if ($correctCapacity > 0) {
641 if ((int) $d->max_capacity !== $correctCapacity) {
642 $departureRepo->update($d->id, ['max_capacity' => $correctCapacity]);
643 $d->max_capacity = $correctCapacity;
644 }
645 } elseif ((int) $d->max_capacity >= 9999) {
646 // Normalise a legacy "unlimited"/junk capacity sentinel (e.g.
647 // 9999/11111) to the canonical unlimited value 0, so it isn't
648 // shown as a huge literal number here while the dashboard renders
649 // >= 9999 as 0 — the two disagreeing on capacity and occupancy.
650 //
651 // Deliberately heal to 0 (NOT the trip's max_travelers): 0 means
652 // "unlimited" to both the capacity guard (incrementBookedCount)
653 // and Departure::calculateStatus(), so healing can never flip an
654 // already (over-)booked departure to 'full' — which capping to a
655 // smaller number would, and 'full' departures drop out of the
656 // dashboard's upcoming view. This keeps the sentinel's original
657 // "unlimited" meaning while making both surfaces agree.
658 if ((int) $d->max_capacity !== 0) {
659 $departureRepo->update($d->id, ['max_capacity' => 0]);
660 $d->max_capacity = 0;
661 }
662 }
663
664 // Promote a departure that has taken place to 'past' so a completed
665 // departure is never shown with (or hidden behind) a stale
666 // 'upcoming'/'full' status — the daily cron may not have run.
667 // Cancelled/trashed departures are left as-is. Keeps the status
668 // badge and the dashboard/tab counts date-accurate.
669 if (!in_array($d->status, ['cancelled', 'trash', 'past'], true)) {
670 $checkDate = (!empty($d->end_date) && $d->end_date !== '0000-00-00')
671 ? $d->end_date
672 : ((!empty($d->start_date) && $d->start_date !== '0000-00-00') ? $d->start_date : $d->date);
673 if (!empty($checkDate) && $checkDate < date('Y-m-d')) {
674 $departureRepo->update($d->id, ['status' => 'past']);
675 $d->status = 'past';
676 }
677 }
678
679 $departureArray = $d->toArray();
680
681 // Add trip information
682 $trip = $tripRepository->find($d->trip_id);
683 if ($trip) {
684 $departureArray['trip'] = [
685 'id' => (int) $trip->id,
686 'title' => $trip->title ?? '',
687 'slug' => $trip->slug ?? '',
688 ];
689 }
690
691 // Add booking links and get travelers
692 $bookingIds = $bookingDepartureRepo->getBookingIdsForDeparture($d->id);
693 $departureArray['booking_ids'] = $bookingIds;
694 $departureArray['bookings_count'] = count($bookingIds);
695
696 // Recalculate revenue for departures with bookings
697 if (!empty($bookingIds)) {
698 try {
699 $totalRevenue = 0.00;
700 foreach ($bookingIds as $bookingId) {
701 $booking = $bookingRepo->find($bookingId);
702 if ($booking && !empty($booking->total_amount)) {
703 $totalRevenue += (float) $booking->total_amount;
704 }
705 }
706 $departureArray['total_revenue'] = $totalRevenue;
707 } catch (\Exception $e) {
708 }
709 }
710
711 // Get all travelers for this departure
712 $allTravelers = [];
713 foreach ($bookingIds as $bookingId) {
714 $travelers = $travellerRepo->getByBookingId($bookingId);
715 $booking = $bookingRepo->find($bookingId);
716 foreach ($travelers as $traveler) {
717 $fields = $traveler['fields'] ?? [];
718
719 $firstName = $fields['first_name']
720 ?? $traveler['first_name']
721 ?? ($booking->contact_first_name ?? '');
722 $lastName = $fields['last_name']
723 ?? $traveler['last_name']
724 ?? ($booking->contact_last_name ?? '');
725
726 $email = $fields['email']
727 ?? $fields['contact_email']
728 ?? $fields['primary_email']
729 ?? ($booking->contact_email ?? '');
730
731 $phone = $fields['phone']
732 ?? $fields['contact_phone']
733 ?? $fields['mobile_phone']
734 ?? $fields['whatsapp']
735 ?? ($booking->contact_phone ?? '');
736
737 $allTravelers[] = [
738 'id' => (int) $traveler['id'],
739 'booking_id' => $bookingId,
740 'booking_reference' => $booking ? ($booking->reference ?? '') : '',
741 'is_lead' => (bool) ($traveler['is_lead'] ?? false),
742 'first_name' => $firstName,
743 'last_name' => $lastName,
744 'email' => $email,
745 'phone' => $phone,
746 ];
747 }
748 }
749 $departureArray['travelers'] = $allTravelers;
750 $departureArray['travelers_count'] = count($allTravelers);
751
752 // Format time for display (remove seconds if present)
753 if (!empty($departureArray['time'])) {
754 $time = $departureArray['time'];
755 if (strlen($time) > 5 && substr_count($time, ':') === 2) {
756 $departureArray['time'] = substr($time, 0, 5);
757 }
758 }
759
760 return $departureArray;
761 }, $departures);
762
763 return new WP_REST_Response([
764 'success' => true,
765 'data' => $processed,
766 'meta' => [
767 'total' => count($processed),
768 ],
769 ]);
770 } catch (\Exception $e) {
771 return new WP_REST_Response([
772 'success' => false,
773 'message' => $e->getMessage(),
774 ], 400);
775 }
776 }
777
778 /**
779 * GET /trips/{trip_id}/departures/past
780 */
781 public function get_past_departures(WP_REST_Request $request): WP_REST_Response
782 {
783 $tripId = (int) $request->get_param('trip_id');
784
785 try {
786 $departures = $this->departureService->getPastByTripId($tripId);
787
788 return new WP_REST_Response([
789 'success' => true,
790 'data' => array_map(function ($d) {
791 return $d->toArray();
792 }, $departures),
793 ]);
794 } catch (\Exception $e) {
795 return new WP_REST_Response([
796 'success' => false,
797 'message' => $e->getMessage(),
798 ], 400);
799 }
800 }
801
802 /**
803 * GET /trips/{trip_id}/available-dates
804 * Public endpoint for frontend to get available dates
805 */
806 public function get_available_dates(WP_REST_Request $request): WP_REST_Response
807 {
808 $tripId = (int) $request->get_param('trip_id');
809 $fromDate = $request->get_param('from_date') ?: date('Y-m-d');
810 $toDate = $request->get_param('to_date') ?: date('Y-m-d', strtotime('+12 months'));
811
812 try {
813 $dates = $this->departureService->getAvailableDates($tripId, $fromDate, $toDate);
814
815 // Attach the departure times each date actually runs. The list above is
816 // keyed by date and reports `time => null` for rule-generated dates, so a
817 // trip running several departures a day looked like a single slot — and
818 // an operator booking it from the admin had no way to say which departure
819 // the booking was for. Capacity is tracked per departure, so such a
820 // booking reserved no seats at all.
821 //
822 // Added as an extra field rather than by changing the row shape, so every
823 // existing consumer of this endpoint is unaffected.
824 $timesByDate = [];
825 try {
826 $resolver = new \Yatra\Services\AvailabilityResolutionService();
827 foreach ($resolver->getAllAvailabilityDates($tripId, $fromDate, $toDate) as $slot) {
828 $slotDate = (string) ($slot->departure_date ?? $slot->date ?? '');
829 $slotTime = trim((string) ($slot->departure_time ?? ''));
830 if ($slotDate === '' || $slotTime === '') {
831 continue;
832 }
833 $timesByDate[$slotDate][$slotTime] = true;
834 }
835 } catch (\Throwable $e) {
836 $timesByDate = [];
837 }
838
839 foreach ($dates as $key => $row) {
840 $rowDate = is_array($row) ? (string) ($row['date'] ?? '') : (string) ($row->date ?? '');
841 $times = isset($timesByDate[$rowDate]) ? array_keys($timesByDate[$rowDate]) : [];
842 sort($times);
843
844 if (is_array($row)) {
845 $dates[$key]['departure_times'] = $times;
846 } elseif (is_object($row)) {
847 $row->departure_times = $times;
848 }
849 }
850
851 return new WP_REST_Response([
852 'success' => true,
853 'data' => $dates,
854 ]);
855 } catch (\Exception $e) {
856 return new WP_REST_Response([
857 'success' => false,
858 'message' => $e->getMessage(),
859 ], 400);
860 }
861 }
862
863 // =========================================================================
864 // RECURRING RULES ENDPOINTS
865 // =========================================================================
866
867 /**
868 * GET /trips/{trip_id}/recurring-rules
869 */
870 public function get_recurring_rules(WP_REST_Request $request): WP_REST_Response
871 {
872 $tripId = (int) $request->get_param('trip_id');
873 $activeOnly = $request->get_param('active_only') === 'true';
874
875 try {
876 $rules = $this->ruleService->getByTripId($tripId, $activeOnly);
877
878 return new WP_REST_Response([
879 'success' => true,
880 'data' => array_map(function ($r) {
881 return $r->toArray();
882 }, $rules),
883 ]);
884 } catch (\Exception $e) {
885 return new WP_REST_Response([
886 'success' => false,
887 'message' => $e->getMessage(),
888 ], 400);
889 }
890 }
891
892 /**
893 * GET /trips/{trip_id}/recurring-rules/{id}
894 */
895 public function get_recurring_rule(WP_REST_Request $request): WP_REST_Response
896 {
897 $id = (int) $request->get_param('id');
898
899 $repo = new RecurringRuleRepository();
900 $rule = $repo->findModel($id);
901
902 if (!$rule) {
903 return new WP_REST_Response([
904 'success' => false,
905 'message' => 'Recurring rule not found',
906 ], 404);
907 }
908
909 return new WP_REST_Response([
910 'success' => true,
911 'data' => $rule->toArray(),
912 ]);
913 }
914
915 /**
916 * POST /trips/{trip_id}/recurring-rules
917 */
918 public function create_recurring_rule(WP_REST_Request $request): WP_REST_Response
919 {
920 $tripId = (int) $request->get_param('trip_id');
921 $data = $request->get_json_params();
922 $data['trip_id'] = $tripId;
923
924 try {
925 $id = $this->ruleService->create($data);
926
927 $repo = new RecurringRuleRepository();
928 $rule = $repo->findModel($id);
929
930 return new WP_REST_Response([
931 'success' => true,
932 'data' => $rule->toArray(),
933 'message' => 'Recurring rule created successfully',
934 ], 201);
935 } catch (\Exception $e) {
936 return new WP_REST_Response([
937 'success' => false,
938 'message' => $e->getMessage(),
939 ], 400);
940 }
941 }
942
943 /**
944 * PUT /trips/{trip_id}/recurring-rules/{id}
945 */
946 public function update_recurring_rule(WP_REST_Request $request): WP_REST_Response
947 {
948 $id = (int) $request->get_param('id');
949 $data = $request->get_json_params();
950
951 try {
952 $this->ruleService->update($id, $data);
953
954 $repo = new RecurringRuleRepository();
955 $rule = $repo->findModel($id);
956
957 return new WP_REST_Response([
958 'success' => true,
959 'data' => $rule->toArray(),
960 'message' => 'Recurring rule updated successfully',
961 ]);
962 } catch (\Exception $e) {
963 return new WP_REST_Response([
964 'success' => false,
965 'message' => $e->getMessage(),
966 ], 400);
967 }
968 }
969
970 /**
971 * DELETE /trips/{trip_id}/recurring-rules/{id}
972 */
973 public function delete_recurring_rule(WP_REST_Request $request): WP_REST_Response
974 {
975 $id = (int) $request->get_param('id');
976
977 try {
978 $this->ruleService->delete($id);
979
980 return new WP_REST_Response([
981 'success' => true,
982 'message' => 'Recurring rule deleted successfully',
983 ]);
984 } catch (\Exception $e) {
985 return new WP_REST_Response([
986 'success' => false,
987 'message' => $e->getMessage(),
988 ], 400);
989 }
990 }
991
992 /**
993 * GET /trips/{trip_id}/recurring-rules/{id}/preview
994 */
995 public function preview_recurring_rule(WP_REST_Request $request): WP_REST_Response
996 {
997 $id = (int) $request->get_param('id');
998 $count = (int) ($request->get_param('count') ?: 10);
999
1000 try {
1001 $dates = $this->ruleService->getPreviewDates($id, $count);
1002
1003 return new WP_REST_Response([
1004 'success' => true,
1005 'data' => $dates,
1006 ]);
1007 } catch (\Exception $e) {
1008 return new WP_REST_Response([
1009 'success' => false,
1010 'message' => $e->getMessage(),
1011 ], 400);
1012 }
1013 }
1014 }
1015
1016