PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 3.0.16 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 All 85 releases
← All changes | app/Controllers/AvailabilityController.php +115 -31 3.0.9 → 3.0.17 View file →
@@ -82,8 +82,32 @@
82 82 'permission_callback' => [$this, 'check_permission'],
83 83 ],
84 84 ]);
85 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 +
86 110 // Single item routes — view cap for read, edit cap for the
87 111 // mutations. Delete uses edit as well — there's no separate
88 112 // "delete availability date" cap in the registry because
89 113 // removing a date is functionally part of trip availability
@@ -166,32 +190,22 @@
166 190 if (!empty($availabilityIdByDate)) {
167 191 $this->service->updateBookingAvailabilityIds((int) $tripId, $availabilityIdByDate);
168 192 }
169 193
170 - // Aggregate bookings count per availability date for this trip
171 - // Use AvailabilityService to get booking counts
172 - $countsByAvailabilityId = [];
173 - $bookingCounts = $this->service->getBookingCountsByAvailabilityIds(array_column($items, 'id'));
174 -
175 - foreach ($bookingCounts as $row) {
176 - $aid = (int) ($row->availability_id ?? 0);
177 - if ($aid > 0) {
178 - $countsByAvailabilityId[$aid] = (int) ($row->booked_count ?? 0);
179 - }
180 - }
181 -
182 - $data = array_map(function ($item) use ($request, $countsByAvailabilityId) {
194 + $data = array_map(function ($item) use ($request) {
183 195 $prepared = $this->prepare_item_for_response($item, $request);
184 196
185 - $availabilityId = (int) ($prepared['id'] ?? 0);
186 - $bookedCount = 0;
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 + );
187 206
188 - if ($availabilityId > 0 && isset($countsByAvailabilityId[$availabilityId])) {
189 - $bookedCount = (int) $countsByAvailabilityId[$availabilityId];
190 - }
191 -
192 207 $seatsTotal = (int) ($prepared['seats_total'] ?? 0);
193 - $seatsReserved = (int) ($prepared['seats_reserved'] ?? 0);
194 208 $available = max(0, $seatsTotal - $bookedCount);
195 209
196 210 $prepared['booked_seats'] = $bookedCount;
197 211 $prepared['total_seats'] = $seatsTotal;
@@ -217,9 +231,9 @@
217 231 'total' => $total,
218 232 'page' => $filters['page'],
219 233 'per_page' => $filters['per_page'],
220 234 ], 200);
221 - } catch (\Exception $e) {
235 + } catch (\Throwable $e) {
222 236 return new WP_Error(
223 237 'availability_fetch_error',
224 238 $e->getMessage(),
225 239 ['status' => 500]
@@ -245,9 +259,9 @@
245 259 'validation_error',
246 260 $e->getMessage(),
247 261 ['status' => 400]
248 262 );
249 - } catch (\Exception $e) {
263 + } catch (\Throwable $e) {
250 264 return new WP_Error(
251 265 'availability_duplicate_error',
252 266 $e->getMessage(),
253 267 ['status' => 500]
@@ -255,8 +269,76 @@
255 269 }
256 270 }
257 271
258 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 (\Throwable $e) {
336 + return new WP_Error('availability_generated_error', $e->getMessage(), ['status' => 500]);
337 + }
338 + }
339 +
340 + /**
259 341 * Get single availability date
260 342 */
261 343 public function get_item(WP_REST_Request $request)
262 344 {
@@ -273,16 +355,18 @@
273 355 }
274 356
275 357 $prepared = $this->prepare_item_for_response($item, $request);
276 358
277 - // Compute live booked seats for this availability_id
359 + // Booked derived from the bookings' (trip, date, time) identity, the
360 + // same way the list does — not the fragile availability_id join.
278 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 + );
279 367
280 - // Use AvailabilityService to get booked count
281 - $bookedCount = $this->service->getBookedCountByAvailabilityId((int) $prepared['id']);
282 -
283 368 $seatsTotal = (int) ($prepared['seats_total'] ?? 0);
284 -
285 369 $available = max(0, $seatsTotal - $bookedCount);
286 370
287 371 $prepared['booked_seats'] = $bookedCount;
288 372 $prepared['seats_available'] = $available;
@@ -288,9 +372,9 @@
288 372 $prepared['seats_available'] = $available;
289 373 }
290 374
291 375 return new WP_REST_Response($prepared, 200);
292 - } catch (\Exception $e) {
376 + } catch (\Throwable $e) {
293 377 return new WP_Error(
294 378 'availability_fetch_error',
295 379 $e->getMessage(),
296 380 ['status' => 500]
@@ -321,9 +405,9 @@
321 405 'validation_error',
322 406 $e->getMessage(),
323 407 ['status' => 400]
324 408 );
325 - } catch (\Exception $e) {
409 + } catch (\Throwable $e) {
326 410 return new WP_Error(
327 411 'availability_create_error',
328 412 $e->getMessage(),
329 413 ['status' => 500]
@@ -355,9 +439,9 @@
355 439 'validation_error',
356 440 $e->getMessage(),
357 441 ['status' => 400]
358 442 );
359 - } catch (\Exception $e) {
443 + } catch (\Throwable $e) {
360 444 return new WP_Error(
361 445 'availability_update_error',
362 446 $e->getMessage(),
363 447 ['status' => 500]
@@ -383,9 +467,9 @@
383 467 'validation_error',
384 468 $e->getMessage(),
385 469 ['status' => 400]
386 470 );
387 - } catch (\Exception $e) {
471 + } catch (\Throwable $e) {
388 472 return new WP_Error(
389 473 'availability_delete_error',
390 474 $e->getMessage(),
391 475 ['status' => 500]