PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 2.0.11 All 82 releases
yatra / app / Controllers / AvailabilityController.php

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

545 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Availability REST API Controller
4 * API endpoints for trip availability dates management
5 *
6 * This is a FREE feature - no Pro plugin required
7 *
8 * @package Yatra\Controllers
9 * @since 3.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace Yatra\Controllers;
15
16 use WP_REST_Request;
17 use WP_REST_Response;
18 use WP_Error;
19 use Yatra\Database\Tables\BookingsTable;
20 use Yatra\Services\AvailabilityService;
21 use Yatra\Repositories\AvailabilityRepository;
22
23 class AvailabilityController extends BaseController
24 {
25 private AvailabilityService $service;
26
27 public function __construct()
28 {
29 $this->service = new AvailabilityService(new AvailabilityRepository());
30 }
31
32 /**
33 * Register routes
34 */
35 public function register_routes(): void
36 {
37 $namespace = 'yatra/v1';
38 $base = 'availability';
39
40 // Collection routes — view cap for reads, edit cap for writes.
41 register_rest_route($namespace, '/' . $base, [
42 [
43 'methods' => \WP_REST_Server::READABLE,
44 'callback' => [$this, 'get_items'],
45 'permission_callback' => [$this, 'check_view_permission'],
46 'args' => [
47 'trip_id' => [
48 'required' => true,
49 'type' => 'integer',
50 'validate_callback' => function ($param) {
51 return is_numeric($param) && $param > 0;
52 },
53 ],
54 'status' => [
55 'type' => 'string',
56 'default' => 'all',
57 ],
58 'month' => [
59 'type' => 'string',
60 'default' => 'all',
61 ],
62 'search' => [
63 'type' => 'string',
64 'default' => '',
65 ],
66 'page' => [
67 'type' => 'integer',
68 'default' => 1,
69 'minimum' => 1,
70 ],
71 'per_page' => [
72 'type' => 'integer',
73 'default' => 50,
74 'minimum' => 1,
75 'maximum' => 100,
76 ],
77 ],
78 ],
79 [
80 'methods' => \WP_REST_Server::CREATABLE,
81 'callback' => [$this, 'create_item'],
82 'permission_callback' => [$this, 'check_permission'],
83 ],
84 ]);
85
86 // Read-only list of dates a trip's recurring rules generate, for the
87 // admin calendar. The main /availability list reads only the stored
88 // availability_dates table, so a trip configured purely with recurring
89 // rules showed an empty calendar. These are virtual (governed by the
90 // rule, not individually editable), so the calendar renders them
91 // read-only — hence a separate endpoint rather than mixing them into
92 // the paginated, action-bearing /availability list.
93 register_rest_route($namespace, '/' . $base . '/generated', [
94 [
95 'methods' => \WP_REST_Server::READABLE,
96 'callback' => [$this, 'get_generated_dates'],
97 'permission_callback' => [$this, 'check_view_permission'],
98 'args' => [
99 'trip_id' => [
100 'required' => true,
101 'type' => 'integer',
102 'validate_callback' => function ($param) {
103 return is_numeric($param) && $param > 0;
104 },
105 ],
106 ],
107 ],
108 ]);
109
110 // Single item routes — view cap for read, edit cap for the
111 // mutations. Delete uses edit as well — there's no separate
112 // "delete availability date" cap in the registry because
113 // removing a date is functionally part of trip availability
114 // editing, not a destructive operation in its own right.
115 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)', [
116 [
117 'methods' => \WP_REST_Server::READABLE,
118 'callback' => [$this, 'get_item'],
119 'permission_callback' => [$this, 'check_view_permission'],
120 ],
121 [
122 'methods' => \WP_REST_Server::EDITABLE,
123 'callback' => [$this, 'update_item'],
124 'permission_callback' => [$this, 'check_permission'],
125 ],
126 [
127 'methods' => \WP_REST_Server::DELETABLE,
128 'callback' => [$this, 'delete_item'],
129 'permission_callback' => [$this, 'check_permission'],
130 ],
131 ]);
132
133 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/duplicate', [
134 [
135 'methods' => \WP_REST_Server::CREATABLE,
136 'callback' => [$this, 'duplicate_item'],
137 'permission_callback' => [$this, 'check_permission'],
138 ],
139 ]);
140 }
141
142 /**
143 * Get all availability dates for a trip
144 */
145 public function get_items(WP_REST_Request $request)
146 {
147 try {
148 $tripId = (int) $request->get_param('trip_id');
149
150 if ($tripId <= 0) {
151 return new WP_Error(
152 'invalid_trip_id',
153 'Valid trip_id is required',
154 ['status' => 400]
155 );
156 }
157
158 $filters = [
159 'status' => $request->get_param('status') ?? 'all',
160 'month' => $request->get_param('month') ?? 'all',
161 'search' => $request->get_param('search') ?? '',
162 'page' => (int) ($request->get_param('page') ?? 1),
163 'per_page' => (int) ($request->get_param('per_page') ?? 50),
164 ];
165
166 $items = $this->service->getByTripId($tripId, $filters);
167 $total = $this->service->countByTripId($tripId, $filters);
168
169 global $wpdb;
170 $bookingsTable = BookingsTable::getTableName();
171
172 $availabilityIdByDate = [];
173 $dateCounts = [];
174 foreach ($items as $item) {
175 $date = (string) ($item->departure_date ?? '');
176 if ($date === '') {
177 continue;
178 }
179 $dateCounts[$date] = ($dateCounts[$date] ?? 0) + 1;
180 $availabilityIdByDate[$date] = (int) ($item->id ?? 0);
181 }
182
183 foreach ($dateCounts as $date => $count) {
184 if ($count !== 1) {
185 unset($availabilityIdByDate[$date]);
186 }
187 }
188
189 // Use AvailabilityService to update booking availability IDs
190 if (!empty($availabilityIdByDate)) {
191 $this->service->updateBookingAvailabilityIds((int) $tripId, $availabilityIdByDate);
192 }
193
194 $data = array_map(function ($item) use ($request) {
195 $prepared = $this->prepare_item_for_response($item, $request);
196
197 // Booked is derived from the bookings' own (trip, date, time)
198 // identity, not the fragile availability_id join — see
199 // AvailabilityService::getBookedCountForSlot. Each row passes its
200 // own departure_time so multi-departure dates report per slot.
201 $bookedCount = $this->service->getBookedCountForSlot(
202 (int) ($prepared['trip_id'] ?? 0),
203 (string) ($prepared['departure_date'] ?? ''),
204 !empty($prepared['departure_time']) ? (string) $prepared['departure_time'] : null
205 );
206
207 $seatsTotal = (int) ($prepared['seats_total'] ?? 0);
208 $available = max(0, $seatsTotal - $bookedCount);
209
210 $prepared['booked_seats'] = $bookedCount;
211 $prepared['total_seats'] = $seatsTotal;
212 $prepared['available_seats'] = $available;
213 $prepared['seats_available'] = $available;
214
215 // Preserve original database status - don't override calculated status
216 // The status should reflect what's actually stored in the database
217 $original_status = $prepared['status'] ?? 'available';
218
219 // Only update status if seats are actually sold out (0 available)
220 if ($available === 0 && $original_status !== 'blocked' && $original_status !== 'closed' && $original_status !== 'cancelled') {
221 $prepared['status'] = 'sold_out';
222 }
223 // For all other cases, preserve the original database status
224 // This allows 'available', 'limited', 'blocked', 'closed', 'cancelled' to show correctly
225
226 return $prepared;
227 }, $items);
228
229 return new WP_REST_Response([
230 'dates' => $data,
231 'total' => $total,
232 'page' => $filters['page'],
233 'per_page' => $filters['per_page'],
234 ], 200);
235 } catch (\Exception $e) {
236 return new WP_Error(
237 'availability_fetch_error',
238 $e->getMessage(),
239 ['status' => 500]
240 );
241 }
242 }
243
244 public function duplicate_item(WP_REST_Request $request)
245 {
246 try {
247 $id = (int) $request->get_param('id');
248 $data = $request->get_json_params();
249
250 if (empty($data)) {
251 $data = $request->get_body_params();
252 }
253
254 $item = $this->service->duplicate($id, is_array($data) ? $data : []);
255
256 return new WP_REST_Response($this->prepare_item_for_response($item, $request), 201);
257 } catch (\InvalidArgumentException $e) {
258 return new WP_Error(
259 'validation_error',
260 $e->getMessage(),
261 ['status' => 400]
262 );
263 } catch (\Exception $e) {
264 return new WP_Error(
265 'availability_duplicate_error',
266 $e->getMessage(),
267 ['status' => 500]
268 );
269 }
270 }
271
272 /**
273 * Read-only dates generated by a trip's recurring rules, for the admin
274 * calendar. Resolves through AvailabilityResolutionService so Booked /
275 * Available reflect real bookings (same (trip, date, time) count the storefront
276 * uses), and returns ONLY rule-generated dates — specific availability rows
277 * already come from the main list, and trip-default (flexible) dates are left
278 * out so this overlay is scoped to the recurring-rules gap it exists to fill.
279 */
280 public function get_generated_dates(WP_REST_Request $request)
281 {
282 try {
283 $tripId = (int) $request->get_param('trip_id');
284
285 // Fall back to sane defaults for missing OR malformed dates rather than
286 // passing junk into the resolver (a bad date string errored the query).
287 $normalizeDate = static function ($value, string $fallback): string {
288 $value = sanitize_text_field((string) $value);
289 if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
290 $ts = strtotime($value);
291 if ($ts !== false && date('Y-m-d', $ts) === $value) {
292 return $value;
293 }
294 }
295 return $fallback;
296 };
297 $fromDate = $normalizeDate($request->get_param('from_date'), date('Y-m-d'));
298 $toDate = $normalizeDate($request->get_param('to_date'), date('Y-m-d', strtotime('+12 months')));
299
300 $resolver = new \Yatra\Services\AvailabilityResolutionService();
301 $resolved = $resolver->getAllAvailabilityDates($tripId, $fromDate, $toDate);
302
303 $dates = [];
304 foreach ($resolved as $slot) {
305 if (($slot->source ?? '') !== 'recurring_rule') {
306 continue;
307 }
308 $total = (int) ($slot->seats_total ?? 0);
309 $available = (int) ($slot->seats_available ?? 0);
310 $dates[] = [
311 'id' => (string) ($slot->id ?? ''),
312 'trip_id' => $tripId,
313 'departure_date' => (string) ($slot->departure_date ?? ''),
314 'departure_time' => $slot->departure_time ?? null,
315 'arrival_date' => $slot->arrival_date ?? ($slot->departure_date ?? ''),
316 'arrival_time' => $slot->arrival_time ?? null,
317 'total_seats' => $total,
318 'seats_total' => $total,
319 'available_seats' => $available,
320 'seats_available' => $available,
321 'booked_seats' => max(0, $total - $available),
322 'waitlist_count' => 0,
323 'status' => (string) ($slot->status ?? 'available'),
324 'is_blocked' => !empty($slot->is_blocked),
325 'original_price' => $slot->original_price ?? null,
326 'discounted_price' => $slot->discounted_price ?? null,
327 // Marks this as a read-only, rule-generated entry so the
328 // calendar shows it without edit/delete affordances.
329 'is_virtual' => true,
330 'source' => 'rule',
331 ];
332 }
333
334 return new WP_REST_Response(['dates' => $dates, 'total' => count($dates)], 200);
335 } catch (\Exception $e) {
336 return new WP_Error('availability_generated_error', $e->getMessage(), ['status' => 500]);
337 }
338 }
339
340 /**
341 * Get single availability date
342 */
343 public function get_item(WP_REST_Request $request)
344 {
345 try {
346 $id = (int) $request->get_param('id');
347 $item = $this->service->getById($id);
348
349 if (!$item) {
350 return new WP_Error(
351 'availability_not_found',
352 'Availability date not found',
353 ['status' => 404]
354 );
355 }
356
357 $prepared = $this->prepare_item_for_response($item, $request);
358
359 // Booked derived from the bookings' (trip, date, time) identity, the
360 // same way the list does — not the fragile availability_id join.
361 if (!empty($prepared['id'])) {
362 $bookedCount = $this->service->getBookedCountForSlot(
363 (int) ($prepared['trip_id'] ?? 0),
364 (string) ($prepared['departure_date'] ?? ''),
365 !empty($prepared['departure_time']) ? (string) $prepared['departure_time'] : null
366 );
367
368 $seatsTotal = (int) ($prepared['seats_total'] ?? 0);
369 $available = max(0, $seatsTotal - $bookedCount);
370
371 $prepared['booked_seats'] = $bookedCount;
372 $prepared['seats_available'] = $available;
373 }
374
375 return new WP_REST_Response($prepared, 200);
376 } catch (\Exception $e) {
377 return new WP_Error(
378 'availability_fetch_error',
379 $e->getMessage(),
380 ['status' => 500]
381 );
382 }
383 }
384
385 /**
386 * Create availability date
387 */
388 public function create_item(WP_REST_Request $request)
389 {
390 try {
391 $data = $request->get_json_params();
392
393 if (empty($data)) {
394 $data = $request->get_body_params();
395 }
396
397 $item = $this->service->create($data);
398
399 // Trigger hook to sync departure capacity
400 do_action('yatra_availability_updated', $item->id);
401
402 return new WP_REST_Response($this->prepare_item_for_response($item, $request), 201);
403 } catch (\InvalidArgumentException $e) {
404 return new WP_Error(
405 'validation_error',
406 $e->getMessage(),
407 ['status' => 400]
408 );
409 } catch (\Exception $e) {
410 return new WP_Error(
411 'availability_create_error',
412 $e->getMessage(),
413 ['status' => 500]
414 );
415 }
416 }
417
418 /**
419 * Update availability date
420 */
421 public function update_item(WP_REST_Request $request)
422 {
423 try {
424 $id = (int) $request->get_param('id');
425 $data = $request->get_json_params();
426
427 if (empty($data)) {
428 $data = $request->get_body_params();
429 }
430
431 $item = $this->service->update($id, $data);
432
433 // Trigger hook to sync departure capacity
434 do_action('yatra_availability_updated', $id);
435
436 return new WP_REST_Response($this->prepare_item_for_response($item, $request), 200);
437 } catch (\InvalidArgumentException $e) {
438 return new WP_Error(
439 'validation_error',
440 $e->getMessage(),
441 ['status' => 400]
442 );
443 } catch (\Exception $e) {
444 return new WP_Error(
445 'availability_update_error',
446 $e->getMessage(),
447 ['status' => 500]
448 );
449 }
450 }
451
452 /**
453 * Delete availability date
454 */
455 public function delete_item(WP_REST_Request $request)
456 {
457 try {
458 $id = (int) $request->get_param('id');
459 $this->service->delete($id);
460
461 return new WP_REST_Response([
462 'message' => 'Availability date deleted successfully',
463 'id' => $id,
464 ], 200);
465 } catch (\InvalidArgumentException $e) {
466 return new WP_Error(
467 'validation_error',
468 $e->getMessage(),
469 ['status' => 400]
470 );
471 } catch (\Exception $e) {
472 return new WP_Error(
473 'availability_delete_error',
474 $e->getMessage(),
475 ['status' => 500]
476 );
477 }
478 }
479
480 /**
481 * Prepare item for response
482 */
483 protected function prepare_item_for_response($item, WP_REST_Request $request): array
484 {
485 $data = (array) $item;
486
487 // Format prices as strings for frontend
488 if (isset($data['original_price'])) {
489 $data['original_price'] = $data['original_price'] !== null ? number_format((float) $data['original_price'], 2, '.', '') : null;
490 }
491 if (isset($data['discounted_price'])) {
492 $data['discounted_price'] = $data['discounted_price'] !== null ? number_format((float) $data['discounted_price'], 2, '.', '') : null;
493 }
494 if (isset($data['discount_percentage'])) {
495 $data['discount_percentage'] = $data['discount_percentage'] !== null ? number_format((float) $data['discount_percentage'], 2, '.', '') : null;
496 }
497
498 // Ensure pricing_type has a default value
499 if (!isset($data['pricing_type']) || empty($data['pricing_type'])) {
500 $data['pricing_type'] = 'regular';
501 }
502
503 // Decode price_types JSON string from DB and ensure it's an array
504 if (isset($data['price_types']) && is_string($data['price_types'])) {
505 $decoded = json_decode($data['price_types'], true);
506 $data['price_types'] = is_array($decoded) ? $decoded : [];
507 } elseif (!isset($data['price_types']) || !is_array($data['price_types'])) {
508 $data['price_types'] = [];
509 }
510
511 // Ensure status matches frontend expectations
512 if ($data['status'] === 'blocked' || !empty($data['is_blocked'])) {
513 $data['status'] = 'blocked';
514 $data['is_blocked'] = true;
515 }
516
517 return $data;
518 }
519
520 /**
521 * Check permission
522 */
523 /**
524 * Write permission — trip-edits cap. Adding, updating, deleting,
525 * and duplicating availability dates all mutate trip data, so the
526 * registered `yatra_edit_trips` cap is the right gate. WP admins
527 * pass via the Team module's admin-fallback filter.
528 */
529 public function check_permission(?WP_REST_Request $request = null): bool
530 {
531 return current_user_can('yatra_edit_trips');
532 }
533
534 /**
535 * Read permission — view-trips cap. Listing availability dates is
536 * a read-only operation against trip data; Sales Agent / Front
537 * Desk / Guide / Accountant / Auditor roles all hold this.
538 */
539 public function check_view_permission(?WP_REST_Request $request = null): bool
540 {
541 return current_user_can('yatra_view_trips');
542 }
543 }
544
545