= 1 * service_id (opt) shift/event id or 'all' (default) * widget_id (opt) override the API key's widget */ public function getAvailability(\WP_REST_Request $request) { $action = 'bot_availability'; $apiKey = $this->authenticate($request, $action); if ($apiKey instanceof \WP_REST_Response) return $apiKey; $restaurant = Restaurant::where('active', 1)->where('id', $apiKey->restaurant_id)->first(); if (!$restaurant) { return $this->errorResponse($apiKey, $action, $request, 'RESTAURANT_NOT_FOUND', 'Restaurant not found or inactive.', 404); } $widgetId = $this->resolveWidgetId($request, $apiKey); if (!$widgetId) { return $this->errorResponse($apiKey, $action, $request, 'WIDGET_NOT_FOUND', 'No widget_id configured on the API key and none provided in the request.', 400); } $widget = WidgetForm::where('id', $widgetId)->where('restaurant_id', $restaurant->id)->first(); if (!$widget) { return $this->errorResponse($apiKey, $action, $request, 'WIDGET_NOT_FOUND', 'Widget not found for this restaurant.', 404); } // Required: date $date = $this->parseDate($request->get_param('date')); if (!$date) { return $this->errorResponse($apiKey, $action, $request, 'INVALID_DATE', 'Invalid date format. Use YYYY-MM-DD.', 400); } // Required: party_size $party = intval($request->get_param('party_size')); if ($party < 1) { return $this->errorResponse($apiKey, $action, $request, 'VALIDATION_FAILED', 'party_size is required and must be >= 1.', 400); } // Optional: service_id $serviceIdParam = $request->get_param('service_id'); $service = null; if (!empty($serviceIdParam) && $serviceIdParam !== 'all') { $serviceId = intval($serviceIdParam); $service = alexr_get_service($serviceId); if (!$service) { return $this->errorResponse($apiKey, $action, $request, 'SERVICE_NOT_FOUND', 'Service (shift/event) not found.', 404); } } // Optional: area (id o nombre). Filtra los slots a los que caben en esa // area. Sin este parametro cada slot trae igualmente el desglose de // plazas libres por area cuando el servicio lo reparte asi. $area = null; $areaParam = $request->get_param('area'); if ($areaParam !== null && $areaParam !== '') { $area = $this->resolveAreaForAvailability($areaParam, $restaurant->id); if (!$area) { return $this->errorResponse($apiKey, $action, $request, 'AREA_NOT_FOUND', 'area not found for this restaurant.', 404, ['area' => $areaParam]); } } // Closed date short-circuit if ($restaurant->isDateClosed($date)) { $slots = []; $alternatives = $this->findAlternativeDates($restaurant, $widgetId, $date, $party, $service); return $this->successResponse($apiKey, $action, $request, [ 'date' => $date, 'party_size' => $party, 'available' => false, 'reason' => 'DATE_CLOSED', 'slots' => $slots, 'alternative_dates' => $alternatives, ], 200); } $services = $service ? [$service] : $restaurant->getShiftsAndEvents($widgetId); $slots = $this->buildAvailableSlots($services, $restaurant, $date, $party, null, $area ? $area->id : null); $response = [ 'date' => $date, 'party_size' => $party, 'available' => !empty($slots), 'slots' => $slots, ]; if ($area) { $response['area'] = ['id' => intval($area->id), 'name' => $area->name]; } if (empty($slots)) { $response['alternative_dates'] = $this->findAlternativeDates($restaurant, $widgetId, $date, $party, $service); } return $this->successResponse($apiKey, $action, $request, $response, 200); } /** * GET /availability/month * * Query params: * start_date (req) YYYY-MM-DD * end_date (req) YYYY-MM-DD * service_id (opt) shift/event id or 'all' (default) * widget_id (opt) * * Returns the days that have at least one bookable slot inside the range. */ public function getMonthAvailability(\WP_REST_Request $request) { $action = 'bot_availability_month'; $apiKey = $this->authenticate($request, $action); if ($apiKey instanceof \WP_REST_Response) return $apiKey; $restaurant = Restaurant::where('active', 1)->where('id', $apiKey->restaurant_id)->first(); if (!$restaurant) { return $this->errorResponse($apiKey, $action, $request, 'RESTAURANT_NOT_FOUND', 'Restaurant not found or inactive.', 404); } $widgetId = $this->resolveWidgetId($request, $apiKey); if (!$widgetId) { return $this->errorResponse($apiKey, $action, $request, 'WIDGET_NOT_FOUND', 'No widget_id configured on the API key and none provided in the request.', 400); } $startDate = $this->parseDate($request->get_param('start_date')); $endDate = $this->parseDate($request->get_param('end_date')); if (!$startDate || !$endDate) { return $this->errorResponse($apiKey, $action, $request, 'INVALID_DATE', 'start_date and end_date are required (YYYY-MM-DD).', 400); } if ($endDate < $startDate) { return $this->errorResponse($apiKey, $action, $request, 'VALIDATION_FAILED', 'end_date must be >= start_date.', 400); } $serviceIdParam = $request->get_param('service_id'); $serviceId = (!empty($serviceIdParam) && $serviceIdParam !== 'all') ? intval($serviceIdParam) : 'all'; $daysAvailableWithShifts = $restaurant->availableDates_StartEndDates( $startDate, $endDate, $widgetId, $serviceId, true, true ); $daysAvailable = array_keys($daysAvailableWithShifts); sort($daysAvailable); $data = [ 'start_date' => $startDate, 'end_date' => $endDate, 'days_available' => $daysAvailable, 'days_with_services' => $daysAvailableWithShifts, ]; return $this->successResponse($apiKey, $action, $request, $data, 200); } /** * Build the available slots for a given list of services on a date. * * @param array $services Array of Shift|Event * @param Restaurant $restaurant * @param string $date YYYY-MM-DD * @param int $party * @param int|null $excludeBookingId * @return array */ /** * Resolve el parametro `area` (id o nombre) para el filtro de disponibilidad. * * Version simplificada de BotBookingController::resolveArea(): aqui filtrar * es informativo, asi que con un nombre ambiguo se coge la primera * coincidencia en vez de devolver error. * * @return Area|null */ protected function resolveAreaForAvailability($raw, $restaurantId) { if (is_numeric($raw)) { return Area::where('restaurant_id', $restaurantId) ->where('id', intval($raw)) ->where('bookable_online', 1) ->first(); } $name = mb_strtolower(trim(sanitize_text_field((string) $raw))); foreach (Area::where('restaurant_id', $restaurantId)->where('bookable_online', 1)->get() as $area) { if (mb_strtolower(trim($area->name)) === $name) { return $area; } } return null; } protected function buildAvailableSlots($services, $restaurant, $date, $party, $excludeBookingId = null, $areaId = null) { $slots = []; foreach ($services as $service) { if (!($service instanceof Shift) && !($service instanceof Event)) { continue; } // Skip if online bookings are not enabled for this service on this date if (!$restaurant->isOnlineBookingsEnabled($service->id, $date)) { continue; } // Respect service min/max party size if ($party < intval($service->min_covers_reservation) || $party > intval($service->max_covers_reservation)) { continue; } $availableSlots = $service->getAllSlotsAvailable($party, $date, $excludeBookingId); if (empty($availableSlots)) { continue; } $duration = $service->getDuration($party); // Aforo por area. Solo tiene sentido en servicios volume_total con // seleccion de area; en los demas el area no limita plazas, solo // restringe de donde salen las mesas. $areaMode = $service->areaSelectionMode(); // Se pide un area en un servicio que no deja elegirla: sus slots no // son reservables con area, asi que el servicio entero se descarta. // Es coherente con el 400 AREA_NOT_SUPPORTED de POST /bookings. if ($areaId && $areaMode === false) { continue; } $areasPerSlot = []; if ($areaMode === 'covers') { // Una sola consulta para todos los slots del servicio $areasPerSlot = $service->getAreasAvailabilityForSlots($date, $availableSlots, $party); } foreach ($availableSlots as $slotSeconds) { $slot = [ 'time' => $this->formatTime($slotSeconds), 'time_seconds' => intval($slotSeconds), 'service_id' => intval($service->id), 'service_name' => $service->name, 'service_type' => ($service instanceof Event) ? 'event' : 'shift', 'duration_minutes' => intval($duration / 60), ]; if ($areaMode === 'covers') { $areas = isset($areasPerSlot[intval($slotSeconds)]) ? $areasPerSlot[intval($slotSeconds)] : []; // Filtro por area: el slot solo cuenta si caben en ella if ($areaId) { $fits = false; foreach ($areas as $areaInfo) { if ($areaInfo['area_id'] == $areaId && $party <= $areaInfo['free']) { $fits = true; break; } } if (!$fits) continue; $areas = array_values(array_filter($areas, function ($a) use ($areaId) { return $a['area_id'] == $areaId; })); } $slot['areas'] = $areas; } // Servicio por mesas: el area no da plazas, hay que comprobar // que quede alguna mesa libre en ella else if ($areaId && $areaMode === 'tables') { $areaTables = $service->isAvailableBy_SearchTables($date, $slotSeconds, $party, false, $areaId, $excludeBookingId); if (empty($areaTables)) continue; } $slots[] = $slot; } } // Sort slots by time usort($slots, function($a, $b) { return $a['time_seconds'] <=> $b['time_seconds']; }); return $slots; } /** * Find alternative dates around a requested date that have at least one * available slot. Returns a compact summary per date (no full slot list). * * @param Restaurant $restaurant * @param int $widgetId * @param string $date YYYY-MM-DD * @param int $party * @param Shift|Event|null $service Specific service or null for all * @return array */ public function findAlternativeDates($restaurant, $widgetId, $date, $party, $service = null) { $today = evavel_now_timezone_formatted($restaurant->timezone, 'Y-m-d'); $requested = new \DateTime($date); $todayDt = new \DateTime($today); $before = []; $after = []; $services = $service ? [$service] : $restaurant->getShiftsAndEvents($widgetId); for ($i = 1; $i <= self::ALTERNATIVE_SEARCH_WINDOW; $i++) { $d = clone $requested; $d->modify("-$i day"); if ($d < $todayDt) break; $ds = $d->format('Y-m-d'); $count = $this->countAvailableSlotsFor($services, $restaurant, $ds, $party); if ($count > 0) { $before[] = ['date' => $ds, 'slots_count' => $count]; if (count($before) >= self::ALTERNATIVE_DAYS_BEFORE) break; } } for ($i = 1; $i <= self::ALTERNATIVE_SEARCH_WINDOW; $i++) { $d = clone $requested; $d->modify("+$i day"); $ds = $d->format('Y-m-d'); $count = $this->countAvailableSlotsFor($services, $restaurant, $ds, $party); if ($count > 0) { $after[] = ['date' => $ds, 'slots_count' => $count]; if (count($after) >= self::ALTERNATIVE_DAYS_AFTER) break; } } // Order: closest before first (most recent), then after. usort($before, function($a, $b){ return strcmp($b['date'], $a['date']); }); usort($after, function($a, $b){ return strcmp($a['date'], $b['date']); }); return array_merge($before, $after); } public function countAvailableSlotsFor($services, $restaurant, $date, $party) { if ($restaurant->isDateClosed($date)) { return 0; } $count = 0; foreach ($services as $service) { if (!($service instanceof Shift) && !($service instanceof Event)) continue; if (!$restaurant->isOnlineBookingsEnabled($service->id, $date)) continue; if ($party < intval($service->min_covers_reservation) || $party > intval($service->max_covers_reservation)) continue; $slots = $service->getAllSlotsAvailable($party, $date); $count += is_array($slots) ? count($slots) : 0; } return $count; } }