| @@ -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; |
| @@ -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 (\Exception $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; |