params); Booking::$addTrackingDataToBooking = true; return $this->index($request); } public function index(Request $request) { //ray($request); $user = Eva::make('user'); if (!$user->canManage($request->tenant)){ return $this->response([ 'success' => false, 'error' => __eva("You cannot access.") ]); } // Date can be: // 2022-09-03 for a specific day // 2022-09 for a month // 2022-01-01-2022-12-01 for a range of dates $date = $request->date; $tenantId = $request->tenantId(); // Removing holded bookings // Using scheduler action now every X minutes //alexr_remove_holded_bookings($tenantId); // If no perPage parameter then request all $perPage = isset($request->params['per_page']) ? $request->params['per_page'] : 100000; $currentPage = isset($request->params['page']) ? $request->params['page'] : 1; $search = isset($request->params['search']) ? $request->params['search'] : null; $orderBy = isset($request->params['order']) ? $request->params['order'] : 'time'; $orderBy = empty($orderBy) ? 'time' : $orderBy; $orderByDirection = isset($request->params['direction']) ? $request->params['direction'] : 'asc'; $orderByDirection = empty($orderByDirection) ? 'asc' : $orderByDirection; // Retrieve all statuses except when it is in mode selected // that is when the user is making the reservation online $year_month = ''; // One day if (preg_match('#^(\d{4}-\d{2})-\d{2}$#', $date, $matches)) { //ray('Queries for one day'); Booking::$addTrackingDataToBooking = true; [$query, $query_count, $year_month] = $this->getQueriesForOneDay($date, $tenantId, $matches); } // One month else if (preg_match('#^(\d{4}-\d{2})$#', $date, $matches)) { [$query, $query_count, $year_month] = $this->getQueriesForOneMonth($date, $tenantId, $matches); // I want all because it is used by the calendar $currentPage = 1; $perPage = 10000; } // Range dates else if (preg_match('#^(\d{4}-\d{2}-\d{2})-(\d{4}-\d{2}-\d{2})$#', $date, $matches)) { [$query, $query_count, $year_month] = $this->getQueriesForRangeDates($tenantId, $matches); } // Es una consulta de las reservas pending else if ($date == 'all') { [$query, $query_count, $year_month] = $this->getQueriesFromToday($tenantId); } // Reservas pending if (isset($request->params['status'])) { $query->where('status', $request->params['status']); $query_count->where('status', $request->params['status']); } // Filter search if ($search) { $this->applySearch($search, $query_count ); $this->applySearch($search, $query); } // El campo name no existe if ($orderBy == 'name') { $orderBy = 'first_name'; } // Do the query //ray('QUERY BOOKINGS'); //Query::setDebug(true); $total_arr = $query_count->count($perPage); //Query::setDebug(false); //ray('END QUERY BOOKINGS'); //ray($total_arr); //$bookings = $query->orderBy('time', 'asc')->get()->toArrayQuery::setDebug(true); //Query::setDebug(true); $bookings = $query->orderBy($orderBy, $orderByDirection) ->page($currentPage, $perPage) ->get() ->toArray(); //ray(count($bookings)); //Query::setDebug(false); // Get Bookings month IDs (helper max bookings per month) $bookings_year_month = $this->getBookingsYearMonth($tenantId, $year_month); return $this->response([ 'success' => true, 'bookings' => $bookings, 'yearMonthEditing' => $year_month, 'total' => $total_arr['count'], 'total_pages' => $total_arr['pages'], 'per_page' => $perPage, 'page' => $currentPage, 'bookingsIdMonth' => $bookings_year_month ]); } protected function getQueriesFromToday($tenantId) { $date_now = evavel_date_now()->format('Y-m-d'); $query = Booking::where('restaurant_id', $tenantId) ->with('customer') ->where('date', '>=', $date_now) ->where('status', '!=', BookingStatus::SELECTED)->where('status', '!=', BookingStatus::DELETED); $query_count = Booking::where('restaurant_id', $tenantId) ->with('customer') ->where('date', '>=', $date_now) ->where('status', '!=', BookingStatus::SELECTED)->where('status', '!=', BookingStatus::DELETED); return [$query, $query_count, $date_now]; } protected function getQueriesForOneDay($date, $tenantId, $matches) { $query = Booking::where('restaurant_id', $tenantId) ->with('customer') ->where('date', $date) ->where('status', '!=', BookingStatus::SELECTED)->where('status', '!=', BookingStatus::DELETED); $query_count = Booking::where('restaurant_id', $tenantId) ->with('customer') ->where('date', $date) ->where('status', '!=', BookingStatus::SELECTED)->where('status', '!=', BookingStatus::DELETED); return [$query, $query_count, $matches[1]]; } protected function getQueriesForOneMonth($date, $tenantId, $matches) { $query = Booking::where('restaurant_id', $tenantId) ->with('customer') ->where('date', 'like', $date) ->where('status', '!=', BookingStatus::SELECTED)->where('status', '!=', BookingStatus::DELETED); $query_count = Booking::where('restaurant_id', $tenantId) ->with('customer') ->where('date', 'like', $date) ->where('status', '!=', BookingStatus::SELECTED)->where('status', '!=', BookingStatus::DELETED); return [$query, $query_count, $matches[1]]; } protected function getQueriesForRangeDates($tenantId, $matches) { $date1 = $matches[1]; $date2 = $matches[2]; $query = Booking::where('restaurant_id', $tenantId) ->with('customer') ->where('date', '>=', $date1) ->where('date', '<=', $date2) ->where('status', '!=', BookingStatus::SELECTED)->where('status', '!=', BookingStatus::DELETED); $query_count = Booking::where('restaurant_id', $tenantId) ->with('customer') ->where('date', '>=', $date1) ->where('date', '<=', $date2) ->where('status', '!=', BookingStatus::SELECTED)->where('status', '!=', BookingStatus::DELETED); // Calculate year month // I need to know the number of dates of the interval // > 10 days means asking for a month // < 10 days means asking for a week // I have to calculate the correct month I want to fetch //$diff_days = Carbon::createFromFormat('Y-m-d', $date2) // ->diff(Carbon::createFromFormat('Y-m-d', $date1))->days; $diff_days = evavel_date_createFromFormat('Y-m-d', $date2) ->diff(evavel_date_createFromFormat('Y-m-d', $date1))->days; // When fetching a month I ask for some days before and after current month // so adding 15 days will make sure it is the corrent month if ($diff_days < 10) { $year_month = substr($date1, 0, 7); } else { /*$year_month = Carbon::createFromFormat('Y-m-d', $date1) ->addDays(15) ->format('Y-m');*/ $year_month = evavel_date_createFromFormat('Y-m-d', $date1) ->addDays(15) ->format('Y-m'); } return [$query, $query_count, $year_month]; } protected function getBookingsYearMonth($tenantId, $year_month) { $bookings_year_month = Booking::where('restaurant_id', $tenantId) ->where('date', 'like', $year_month) ->where('status', '!=', BookingStatus::SELECTED) ->where('status', '!=', BookingStatus::DELETED) ->orderBy('date', 'asc') ->get() ->map(function($booking){ return intval($booking->id); }) ->toArray(); return $bookings_year_month; } // Booking data public function booking(Request $request) { $id = intval($request->bookingId); $booking = Booking::find($id); if ($booking == null){ return $this->response([ 'success' => false, 'error' => __eva("No booking found.") ]); } $user = Eva::make('user'); if (!$user->canManage($booking->restaurant_id)){ return $this->response([ 'success' => false, 'error' => __eva("You cannot access.") ]); } // Include customer's has_whatsapp flag if customer exists $customerHasWhatsapp = true; if ($booking->customer_id) { $customer = \Alexr\Models\Customer::find($booking->customer_id); if ($customer) { $customerHasWhatsapp = (bool) $customer->has_whatsapp; } } return $this->response([ 'booking' => $booking, 'notifications' => $booking->notifications->toArray(), 'payments' => $booking->payments->toArray(), 'actions' => $booking->actions->toArray(), 'customer_has_whatsapp' => $customerHasWhatsapp, ]); } public function bookingUUID(Request $request) { $uuid = $request->bookingUUID; $booking = Booking::where('uuid', $uuid)->first(); if ($booking == null){ return $this->response([ 'success' => false, 'error' => __eva("No booking found.") ]); } $user = Eva::make('user'); if (!$user->canManage($booking->restaurant_id)){ return $this->response([ 'success' => false, 'error' => __eva("You cannot access.") ]); } return $this->response([ 'success' => true, 'booking' => $booking, 'notifications' => $booking->notifications->toArray(), 'payments' => $booking->payments->toArray(), 'actions' => $booking->actions->toArray() ]); } public function create(Request $request) { $user = Eva::make('user'); //ray('CREATE BOOKING by', $user); $restaurant_id = $request->tenant; if (!$user->canCreate('bookings', $request->tenant)) { return $this->response([ 'success' => false, 'error' => __eva("You cannot create bookings.") ]); } //ray('Create new booking'); $booking = new Booking(); $booking->restaurant_id = $restaurant_id; $this->sendEmail = $request->sendEmail == 'yes' ? true : false; $result = $this->updateBookingData($request, $booking, true); evavel_event(new EventBookingCreated($booking, $user)); return $result; } public function update(Request $request) { //ray('UPDATE', $request->query('tables')); $user = Eva::make('user'); //ray('UPDATE BOOKING by', $user); if (!$user->canEdit('bookings', $request->tenant)){ return $this->response([ 'success' => false, 'error' => __eva("You cannot edit this booking [update].") ]); } $booking = Booking::find($request->bookingId); $this->sendEmail = $request->sendEmail == 'yes' ? true : false; //ray('Original tables', $booking->tablesList); //ray('New tables', json_decode($request->query('tables'))); $specific_changes = [ 'tables' => [ 'old' => $booking->tablesList, 'new' => json_decode($request->query('tables')) ], 'tags' => [ 'old' => $booking->tagsList, 'new' => json_decode($request->query('tags')) ] ]; foreach (['tables', 'tags'] as $key) { $old = $specific_changes[$key]['old']; $new = $specific_changes[$key]['new']; // Creamos copias para no modificar los arrays originales $sorted_old = is_array($old) ? $old : []; $sorted_new = is_array($new) ? $new : []; sort($sorted_old); sort($sorted_new); if ($sorted_old == $sorted_new) { unset($specific_changes[$key]); } } $booking_original = ListenBookingEvents::getOriginal($booking); // Booking drag&drop from the timeline view if ($request->editMode == 'update-timeline-view') { $result = $this->updateBookingDataFromTimelineView($request, $booking); } // Full update else { $result = $this->updateBookingData($request, $booking, false); } evavel_event(new EventBookingModified($booking, $user, $booking_original, $specific_changes)); return $result; } protected function updateBookingDataFromTimelineView(Request $request, $booking) { $params = $request->body_params; $tables = json_decode($params['tables']); $booking->time = intval($params['time']); $booking->duration = intval($params['duration']); $booking->save(); $booking->syncTablesAndKeepCurrentSeats($tables); return $this->response([ 'success' => true, 'booking' => $booking ]); } protected function updateBookingData(Request $request, $booking, $is_new = true) { $restaurant_id = $request->tenant; $user = Eva::make('user'); //if (!$user->canManage($booking->restaurant_id)){ // return $this->response([ 'success' => false, 'error' => __eva("You cannot access.") ]); //} $params = $request->body_params; $tables = json_decode($params['tables']); $tags = json_decode($params['tags']); //$date = (new Carbon($params['date']))->format('Y-m-d'); $date = evavel_new_date($params['date'])->format('Y-m-d'); $status = $params['status']; if ($is_new){ // @TODO: a way to define the source of the booking $booking->source = null; if (empty($params['type'])){ $booking->type = 'in-house'; } } // Check service exists $serviceId = intval($params['shift']); $service_name = 'Unknown'; $service = \Alexr\Settings\Shift::where('id', $serviceId)->first(); if (!$service) { $service = \Alexr\Settings\Event::where('id', $serviceId)->first(); } if ($service){ $service_name = $service->name; } $booking->restaurant_id = $request->tenant; $language = isset($params['language']) ? $params['language'] : false; if ($language) { $booking->language = $language; } else if (!$booking->language) { $booking->language = $booking->restaurant->language; } $booking->status = $status; $booking->date = $date; $booking->time = intval($params['slot']); $booking->party = intval($params['party']); if (isset($params['children'])) { $booking->children = max(0, intval($params['children'])); } if (isset($params['babies'])) { $booking->babies = max(0, intval($params['babies'])); } $booking->duration = intval($params['duration']); $booking->shift_event_id = $serviceId; $booking->shift_event_name = $service_name; $booking->notes = $params['notes']; $booking->private_notes = $params['private_notes']; $booking->spend = intval($params['spend']); $booking->type = $params['type']; $booking->blocked_table = isset($params['blocked_table']) ? $params['blocked_table'] : 0; // Area elegida manualmente desde el dashboard (solo turnos volume_total con seleccion de area). // Si el parametro no viene (selector oculto) no se toca: se conserva la que eligio el cliente. if (array_key_exists('area_selected_id', $params)) { $this->applyAreaSelectedFromDashboard($booking, $service, $params['area_selected_id']); } // Some emails have the format of walkin walkin_2023_12_14@fake $email = $params['email']; if (!$email || empty($email)) { $name = $params['first_name']; if (!empty($name)) { $name = Str::slug($name); } else { $name = 'user'; } $email = $name.'_'.time().'@fake'; } $booking->customer_id = intval($params['customer_id']) > 0 ? intval($params['customer_id']) : $this->findCustomerId($request->tenant, $email); $first_name = $params['first_name']; $last_name = $params['last_name']; if (empty($first_name) && empty($last_name)){ $first_name = 'New Client'; if ($status == BookingStatus::WAIT_LIST){ $first_name = 'Wait-List'; } } $booking->first_name = $first_name; $booking->last_name = $last_name; $booking->email = $email; $booking->phone = preg_replace('/\s+/', '', $params['phone'] ?? ''); $booking->country_code = $params['country_code']; $booking->dial_code = $params['dial_code']; $booking->dial_code_country = $params['dial_code_country']; $booking->agree_receive_email = intval($params['agree_receive_email']); $booking->agree_receive_sms = intval($params['agree_receive_sms']); $booking->sms_status = $params['sms_status']; if ($booking->sms_status == 'null') { $booking->sms_status = null; } // Custom fields $custom_fields = null; if (isset($params['custom_fields'])) { try { $custom_fields = json_decode($params['custom_fields'], true); } catch (\Exception $e){ $custom_fields = null; } } $booking->custom_fields = $custom_fields; // Create a new customer $is_new_customer = false; if ($booking->customer_id == null) { $customer = $this->createNewCustomer($booking); if ($customer){ $booking->customer_id = $customer->id; $is_new_customer = true; } } // Sync customer tags si se proporcionaron if (isset($params['customer_tags']) && $booking->customer_id) { $customer_tags = json_decode($params['customer_tags']); if (is_array($customer_tags)) { $customer = $customer ?? Customer::find($booking->customer_id); if ($customer) { $customer->tags()->sync($customer_tags); } } } $booking->save(); $booking->tags()->sync($tags); // Borra las sillas, no me interesa //$booking->tables()->sync($tables); // Borra las mesas que no esten en la lista y añade las nuevas // El resto las mantiene como estan $booking->syncTablesAndKeepCurrentSeats($tables); $spend_has_changed = false; // Recalculate visits to add 1 visit when new customer // Or just recalculate because spend has changed if ($is_new_customer || $spend_has_changed) { $customer->calculateVisits(); } // Send email if requested if ($this->sendEmail){ $this->sendBookingEmail($booking); $this->sendSmsNotification($booking); $this->sendWhatsappNotification($booking); } return $this->response([ 'success' => true, 'booking' => $booking ]); } /** * Guarda el area elegida desde el editor del dashboard en area_selected_id, * que es el campo con el que se cuenta el aforo por area (widget y Bot API). * * Solo se aplica si el servicio esta en modo 'covers' (volume_total con * seleccion de area). Valor vacio o 0 = sin area. */ protected function applyAreaSelectedFromDashboard($booking, $service, $value) { if (!$service || $service->areaSelectionMode() !== 'covers') { return; } $area_id = intval($value); if ($area_id <= 0) { $booking->area_selected_id = null; return; } $area = Area::where('id', $area_id) ->where('restaurant_id', $booking->restaurant_id) ->first(); if ($area) { $booking->area_selected_id = $area->id; } } protected function applySearch($search, &$query) { if ($search) { $search_word = ''.$search; $query->where(function($query) use($search_word){ $query->where('first_name', 'like', $search_word) ->orWhere('last_name', 'like', $search_word) ->orWhere('phone', 'like', $search_word) ->orWhere('email', 'like', $search_word); }); } } protected function findCustomerId($tenantId, $email) { if (empty($email) || !$email) { return null; } $customer = Customer::where('restaurant_id', $tenantId)->where('email', $email)->first(); return $customer ? $customer->id : null; } public function sendBookingEmail($booking) { $booking->sendBookingEmail(); } public function sendSmsNotification($booking) { $booking->sendSmsNotification(); } public function sendWhatsappNotification($booking) { $booking->sendWhatsappNotification(); } public function updateStatus(Request $request) { $user = Eva::make('user'); if (!$user->canEdit('bookings', $request->tenant)){ return $this->response([ 'success' => false, 'error' => __eva("You cannot edit this booking [updateStatus].") ]); } $booking = Booking::find($request->params['bookingId']); if ($booking) { $old_status = $booking->status; $new_status = $request->params['status']; $booking->status = $new_status; $booking->save(); evavel_event(new EventBookingStatusChanged($booking, $old_status, $new_status, $user)); } return $this->response(['success' => true, 'booking' => $booking]); } /** * Update (or create) the offer/menu selection of a booking from the * dashboard. Prices/subtotal are recomputed server-side from the offer * record so client prices cannot be tampered with. * * POST /app/bookings/:bookingId/menu * * Body parameters: * - lines: JSON array of { section_id, item_id, quantity } * - offer_id (optional): when present, switches the booking menu to this * offer. Required when the booking has no menu yet. */ public function updateMenu(Request $request) { $user = Eva::make('user'); if (!$user->canEdit('bookings', $request->tenant)) { return $this->response(['success' => false, 'error' => __eva("You cannot edit this booking [updateMenu].")]); } $booking = Booking::find($request->bookingId); if (!$booking || $booking->restaurant_id != $request->tenant) { return $this->response(['success' => false, 'error' => __eva("Booking not found.")]); } $settings = is_array($booking->settings) ? $booking->settings : []; $offer_menu = (isset($settings['offer_menu']) && is_array($settings['offer_menu'])) ? $settings['offer_menu'] : null; // Determine which offer this update targets. // Priority: explicit offer_id in the request > existing offer_id on the booking. $requested_offer_id = $request->query('offer_id'); $requested_offer_id = $requested_offer_id !== null ? intval($requested_offer_id) : 0; $offer_id = $requested_offer_id > 0 ? $requested_offer_id : ($offer_menu && !empty($offer_menu['offer_id']) ? intval($offer_menu['offer_id']) : 0); if ($offer_id <= 0) { return $this->response(['success' => false, 'error' => __eva("Missing offer.")]); } // Sanity check: the offer must belong to this restaurant. $offer = \Alexr\Settings\Offer::where('restaurant_id', $booking->restaurant_id) ->where('id', $offer_id) ->first(); if (!$offer) { return $this->response(['success' => false, 'error' => __eva("Offer not found.")]); } // Lines from the request: [{section_id, item_id, quantity}, ...] $lines_input = $request->query('lines'); if (is_string($lines_input)) { $decoded = json_decode(stripslashes($lines_input), true); $lines_input = is_array($decoded) ? $decoded : []; } if (!is_array($lines_input)) $lines_input = []; // The shift/event service exposes the offer helpers (ManageOffers trait). $service = alexr_get_service($booking->shift_event_id); if (!$service || !method_exists($service, 'sanitiseMenuLines')) { return $this->response(['success' => false, 'error' => __eva("Service not found for this booking.")]); } // Server is authoritative for prices/subtotal. $clean_lines = $service->sanitiseMenuLines($offer_id, $lines_input, $booking->party); $new_subtotal = $service->recomputeMenuSubtotal($offer_id, $clean_lines, $booking->party); // Initialise a fresh offer_menu record if the booking had none before // (operator adding a menu from scratch — e.g. phone bookings). if (!is_array($offer_menu)) { $offer_menu = []; } // If the operator switched offers, also reset the rule reference so // stale rule_id doesn't pollute the audit trail. if (!empty($offer_menu['offer_id']) && intval($offer_menu['offer_id']) !== $offer_id) { $offer_menu['offer_rule_id'] = null; } $offer_menu['offer_id'] = $offer_id; $old_subtotal = isset($offer_menu['subtotal']) ? floatval($offer_menu['subtotal']) : 0; // Preserve the customer's original subtotal the first time it is edited. if (!isset($offer_menu['original_subtotal'])) { $offer_menu['original_subtotal'] = $old_subtotal; } $offer_menu['lines'] = $clean_lines; $offer_menu['subtotal'] = $new_subtotal; $offer_menu['last_modified_by_id'] = $user->id; $offer_menu['last_modified_by_name'] = $this->menuEditorName($user); $offer_menu['last_modified_at'] = evavel_date_now()->format('Y-m-d H:i:s'); $settings['offer_menu'] = $offer_menu; $booking->settings = $settings; $booking->save(); // Audit entry in the booking history. Action::create([ 'name' => 'Changed menu', 'event_type' => EventMenuModified::class, 'restaurant_id' => $booking->restaurant_id, 'agent_type' => User::class, 'agent_id' => $user->id, 'agent_name' => $this->menuEditorName($user), 'model_type' => Booking::class, 'model_id' => $booking->id, 'original' => ['menu_subtotal' => $old_subtotal], 'changes' => ['menu_subtotal' => $new_subtotal], ]); return $this->response([ 'success' => true, 'offer_menu' => $offer_menu, ]); } protected function menuEditorName($user) { $name = trim($user->first_name . ' ' . $user->last_name); if (strlen($name) < 2 && !empty($user->name)) { $name = $user->name; } return $name; } /** * Provide all the context the dashboard menu-edit modal needs in a single * call: list of enabled offers (with sections), the offer rule that * matches the booking date/time/party (and therefore the default offer to * pre-select), and the existing offer_menu selection on the booking if * any. * * GET /app/bookings/:bookingId/offer-menu-context */ public function offerMenuContext(Request $request) { $user = Eva::make('user'); if (!$user->canManage($request->tenant)) { return $this->response(['success' => false, 'error' => __eva("You cannot access.")]); } $booking = Booking::find($request->bookingId); if (!$booking || $booking->restaurant_id != $request->tenant) { return $this->response(['success' => false, 'error' => __eva("Booking not found.")]); } // Return every offer of the restaurant (enabled or not). The frontend // filters by `enabled` when populating the "assign new menu" dropdown, // but the EXISTING selection on the booking must still be editable — // even when the offer was disabled after the customer ordered it. $offers_payload = []; $offers = \Alexr\Settings\Offer::where('restaurant_id', $booking->restaurant_id) ->orderBy('id', 'asc') ->get(); foreach ($offers as $offer) { $sections = $offer->sections; if (is_string($sections) && $sections !== '') { $decoded = json_decode($sections, true); if (is_array($decoded)) $sections = $decoded; } if (!is_array($sections)) $sections = []; $offers_payload[] = [ 'id' => $offer->id, 'name' => $offer->name, 'enabled' => (bool) $offer->enabled, 'sections' => $sections, ]; } // Matched offer rule by date/time/party from the shift/event service. $matched_rule = null; $matched_offer_id = null; $service = alexr_get_service($booking->shift_event_id); if ($service && method_exists($service, 'getOfferRuleMatched')) { $rule = $service->getOfferRuleMatched($booking->date, $booking->time, $booking->party); if ($rule !== null) { $matched_rule = $rule; $matched_offer_id = isset($rule['offer_id']) ? intval($rule['offer_id']) : null; } } // Existing selection (if any). $settings = is_array($booking->settings) ? $booking->settings : []; $existing = (isset($settings['offer_menu']) && is_array($settings['offer_menu'])) ? $settings['offer_menu'] : null; $restaurant = $booking->restaurant; $currency_symbol = $restaurant ? \Alexr\Enums\CurrencyType::symbolFor($restaurant->currency) : '€'; return $this->response([ 'success' => true, 'offers' => $offers_payload, 'matched_offer_id' => $matched_offer_id, 'matched_rule' => $matched_rule, 'existing_selection' => $existing, 'currency_symbol' => $currency_symbol, 'party' => intval($booking->party), ]); } /** * Admin "request payment / assign pending menu" setup (addon_offers). * * For an existing booking, the operator can either: * - charge a FIXED amount now -> status PENDING_PAYMENT (customer pays via * the booking view), or * - assign an OFFER the customer must choose later -> status PENDING * (offer_menu.menu_status = 'pending'); when payment_mode = percentage the * percentage of the chosen menu is charged at menu-submit time * (see submit_booking_menu / manual_payment). * * The customer notification is sent separately by the dashboard popup through * /app/bookings/{email|sms|whatsapp}/:status/:bookingId. * * POST /app/bookings/:bookingId/offer-payment-setup * payment_mode : 'none' | 'fixed' | 'percentage' * fixed_amount : float (payment_mode = fixed) * price_percentage : float (payment_mode = percentage) * assign_menu : 'yes' | 'no' * offer_id : int (assign_menu = yes) */ public function offerPaymentSetup(Request $request) { $user = Eva::make('user'); if (!$user->canEdit('bookings', $request->tenant)) { return $this->response(['success' => false, 'error' => __eva("You cannot edit this booking.")]); } $booking = Booking::find($request->bookingId); if (!$booking || $booking->restaurant_id != $request->tenant) { return $this->response(['success' => false, 'error' => __eva("Booking not found.")]); } // Feature gate: only with the offers add-on active. $restaurant = $booking->restaurant; $addons = $restaurant ? $restaurant->getPaidAddons() : []; if (!is_array($addons) || !in_array('addon_offers', $addons)) { return $this->response(['success' => false, 'error' => __eva("The offers add-on is not active.")]); } $payment_mode = $request->query('payment_mode'); $payment_mode = in_array($payment_mode, ['none', 'fixed', 'percentage'], true) ? $payment_mode : 'none'; $assign_menu_raw = $request->query('assign_menu'); $assign_menu = ($assign_menu_raw === 'yes' || $assign_menu_raw === '1' || $assign_menu_raw === 1 || $assign_menu_raw === true); $offer_id = intval($request->query('offer_id')); $fixed_amount = floatval($request->query('fixed_amount')); $percentage = floatval($request->query('price_percentage')); // Status the booking should land on once the customer completes (chooses // the menu and/or pays). Default: confirmed (booked). $final_status = $request->query('final_status'); $final_status = in_array($final_status, ['booked', 'pending'], true) ? $final_status : 'booked'; // Validation. if (!$assign_menu && $payment_mode === 'none') { return $this->response(['success' => false, 'error' => __eva("Nothing to set up: choose a payment or assign a menu.")]); } if ($payment_mode === 'percentage' && !$assign_menu) { return $this->response(['success' => false, 'error' => __eva("A menu is required to charge a percentage of the menu.")]); } if ($payment_mode === 'fixed' && $fixed_amount <= 0) { return $this->response(['success' => false, 'error' => __eva("Enter a valid amount.")]); } if ($payment_mode === 'percentage' && ($percentage <= 0 || $percentage > 100)) { return $this->response(['success' => false, 'error' => __eva("Enter a valid percentage (1-100).")]); } $settings = is_array($booking->settings) ? $booking->settings : []; // Operator-chosen completion status (read at menu-submit / payment-success // time). Stored at the top level so it works with or without a menu. $settings['final_status'] = $final_status; // 1) Assign a menu the customer must choose later (menu_status pending). if ($assign_menu) { if ($offer_id <= 0) { return $this->response(['success' => false, 'error' => __eva("Select a menu.")]); } $offer = \Alexr\Settings\Offer::where('restaurant_id', $booking->restaurant_id) ->where('id', $offer_id) ->first(); if (!$offer) { return $this->response(['success' => false, 'error' => __eva("Offer not found.")]); } $offer_menu = [ 'offer_id' => $offer_id, 'offer_rule_id' => null, // manual assignment — no rule 'manual' => true, // shift offer rules must NOT apply 'lines' => [], 'subtotal' => 0, 'menu_status' => 'pending', 'pending_cta_sent' => false, // allow the menu notification to be sent ]; // Percentage of the menu is charged when the customer submits the menu. if ($payment_mode === 'percentage') { $offer_menu['manual_payment'] = [ 'payment_type' => 'percentage_of_menu', 'price_percentage' => $percentage, ]; } $settings['offer_menu'] = $offer_menu; } // Persist settings (final_status, and offer_menu when a menu was assigned). $booking->settings = $settings; // 2) Status + payment. Any requested payment (fixed OR percentage) moves // the booking to PENDING_PAYMENT; only a pure menu assignment (no payment) // stays PENDING. if ($payment_mode === 'fixed') { $booking->amount = intval(round($fixed_amount * 100)); $booking->status = BookingStatus::PENDING_PAYMENT; // Pre-assign a gateway so the booking view can render the pay button. if (empty($booking->gateway)) { $gateway = $this->firstEnabledGateway($booking->restaurant_id); if ($gateway) { $booking->gateway = $gateway; } } } else if ($payment_mode === 'percentage') { // Amount depends on the menu the customer chooses, so it is computed // and charged at menu-submit time (submit_booking_menu). Reset any // stale amount; the gateway is assigned then too. $booking->amount = 0; $booking->status = BookingStatus::PENDING_PAYMENT; } else { // Menu only, no payment. $booking->amount = 0; $booking->status = BookingStatus::PENDING; } $booking->save(); // Which status MESSAGE to send the customer: a fixed payment can be paid // right away (payment request), while percentage/menu-only first need the // customer to open the booking and choose the menu (booking link message). $notify_status = ($payment_mode === 'fixed') ? 'pending_payment' : 'pending'; return $this->response([ 'success' => true, 'new_status' => $booking->status, 'notify_status' => $notify_status, 'booking' => [ 'id' => $booking->id, 'status' => $booking->status, 'amount' => $booking->amount, 'gateway' => $booking->gateway, ], 'offer_menu' => isset($settings['offer_menu']) ? $settings['offer_menu'] : null, ]); } /** * First enabled payment gateway slug for a restaurant, or null. Used to * pre-select a gateway when an operator requests a fixed payment (the * customer never picked one at booking time). */ protected function firstEnabledGateway($restaurant_id) { foreach (\Alexr\Enums\PaymentStatus::listGateways() as $name) { $class = '\\Alexr\\Payments\\Gateway' . ucfirst($name); if (!class_exists($class)) continue; try { $gateway = new $class($restaurant_id); if (!empty($gateway->enabled)) { return $name; } } catch (\Throwable $e) { // Skip a misconfigured gateway and try the next one. } } return null; } public function updateTags(Request $request) { // Verificar permisos del usuario $user = Eva::make('user'); if (!$user->canEdit('bookings', $request->tenant)) { return $this->response([ 'success' => false, 'error' => __eva("You cannot edit this booking [updateTags].") ]); } // Obtener la reserva $booking = Booking::find($request->bookingId); if (!$booking) { return $this->response([ 'success' => false, 'error' => __eva("Booking not found.") ]); } try { // Obtener la lista de tags del request $tagsList = $request->params['tagsList']; // Guardar estado anterior para el evento $old_tags = $booking->tagsList; // Original $booking_original = ListenBookingEvents::getOriginal($booking); // Sincronizar los tags $booking->tags()->sync($tagsList); // Disparar evento de modificación evavel_event(new EventBookingModified( $booking, $user, $booking_original, [ 'tags' => [ 'old' => $old_tags, 'new' => $tagsList ] ] )); return $this->response([ 'success' => true, 'booking' => $booking ]); } catch (\Exception $e) { return $this->response([ 'success' => false, 'error' => __eva("Error updating booking tags: ") . $e->getMessage() ]); } } protected function haveSeatsChanged($old_tables_seats, $new_tables_seats) { // Comparar para ver si han cambiado las sillas // Tengo que quitar las mesas sin sillas para poder comparar /* array [ 304 => [] 355 => [ 0 => "4" 1 => "3" 2 => "6" 3 => "7" ] 362 => [ 0 => "5" 1 => "4" ] ] */ //ray($old_tables_seats); //ray($new_tables_seats); $list_old_seats = []; $list_new_seats = []; foreach ($old_tables_seats as $tableId => $seats) { if (!empty($seats)) { foreach ($seats as $seatId) { $list_old_seats[] = $tableId.'-'.$seatId; } } } foreach ($new_tables_seats as $tableId => $seats) { if (!empty($seats)) { foreach ($seats as $seatId) { $list_new_seats[] = $tableId.'-'.$seatId; } } } //ray('list_old_seats', $list_old_seats); //ray('list_new_seats', $list_new_seats); return count($list_new_seats) != count($list_old_seats); } public function updateTables(Request $request) { //ray('TENANT: ' . $request->tenant); //ray($request->tablesList); //ray($request->tablesSeatsList); //ray($request->params['bookingId']); $user = Eva::make('user'); if (!$user->canEdit('bookings', $request->tenant)){ return $this->response([ 'success' => false, 'error' => __eva("You cannot edit this booking [updateTables].") ]); } $booking = Booking::find($request->params['bookingId']); if ($booking) { $old_tables = $booking->tablesList; $new_tables = $request->tablesList; $old_tables_seats = $booking->tablesSeatsList; $new_tables_seats = $request->tablesSeatsList; $seats_have_changed = $this->haveSeatsChanged($old_tables_seats, $new_tables_seats); // Asegurarse que los seats tienen todas las mesas antes de llamar a sync foreach ($new_tables as $tableId) { if (!isset($new_tables_seats[$tableId])) { $new_tables_seats[$tableId] = []; } } // Ajustar los arrays de las sillas a un string en el pivot $new_tables_seats_adjusted = []; foreach($new_tables_seats as $key => $value_arr) { $new_tables_seats_adjusted[$key] = ['seats' => implode(',', $value_arr)]; } //$booking->tables()->sync($new_tables); $booking->tables()->sync($new_tables_seats_adjusted); // Seats han cambiado if ($seats_have_changed) { //ray('SEATS CHANGED'); evavel_event(new EventBookingSeatsChanged($booking, $old_tables_seats, $new_tables_seats, $user)); } // Mesas que han cambiado else { //ray('TABLES CHANGED'); //ray($booking); //ray($old_tables); //ray($new_tables); //ray($user); evavel_event(new EventBookingTablesChanged($booking, $old_tables, $new_tables, $user)); } } return $this->response(['success' => true, 'booking' => $booking]); } protected function createNewCustomer($booking) { return alexr_create_new_customer($booking); } /** * Get covers allowed for shift and date * * @param Request $request * * @return \WP_REST_Response */ public function covers(Request $request) { $shiftId = $request->shiftId; $date = $request->date; //ray($shiftId.' - '.$date); $shift = Shift::where('id', $shiftId)->first(); if (!$shift) { $shift = Event::where('id', $shiftId)->first(); } if (!$shift) { return $this->response(['covers' => []]); } //$covers_intervals = $shift->mapTimeSlotCovers($date, false); //ray($covers_intervals); $covers_intervals_with_new = $shift->mapTimeSlotCovers_WithNewCovers($date); //ray($covers_intervals_with_new); // Returns array // [ "46800-47700" => 4, "47700-48600" => 4 ] // Simplify to [ 46800 => 4, 47700 => 4 ] $covers_total = []; $covers_new = []; foreach($covers_intervals_with_new as $interval => $data) { $explode = explode('-', $interval); if (is_array($explode) && count($explode) == 2) { $covers_total[$explode[0]] = $data['total']; $covers_new[$explode[0]] = $data['new']; } } // Tengo que transformar los news y acumularlos segun los intervalos del shift, no cada 15 minutos $covers_new_shift = []; //ray($shift->interval); for($time = $shift->first_seating; $time <= $shift->last_seating; $time += $shift->interval) { $time_start = $time; $time_end = $time + $shift->interval; $covers_new_shift[$time] = 0; for ($time2 = $time_start; $time2 < $time_end; $time2 += 900) { if (isset($covers_new[$time2])) { $covers_new_shift[$time] += $covers_new[$time2]; } } //ray('NEW COVERS ENTRE ' . $time_start . ' - ' + $time_end . ' = ' . $covers_new_shift[$time]); } //ray($covers_total); //ray($covers_new); //ray($covers_new_shift); // Bookings per slot (number of reservations, not covers) $bookings_intervals = $shift->mapTimeSlotBookings($date, false); $bookings_total = []; foreach($bookings_intervals as $interval => $count) { $explode = explode('-', $interval); if (is_array($explode) && count($explode) == 2) { $bookings_total[$explode[0]] = $count; } } return $this->response([ 'covers' => $covers_total, 'covers_new' => $covers_new, 'covers_new_shift' => $covers_new_shift, 'bookings' => $bookings_total, ]); } /** * Aforo por area para el editor de reservas del dashboard. * Solo aplica a servicios volume_total con seleccion de area. * * Query: slot, party, exclude (id de la reserva que se esta editando) * * @param Request $request * * @return \WP_REST_Response */ public function areasCovers(Request $request) { $shiftId = intval($request->shiftId); $date = $request->date; $slot = intval($request->slot); $party = max(1, intval($request->party)); $exclude = intval($request->exclude); $shift = Shift::where('id', $shiftId)->first(); if (!$shift) { $shift = Event::where('id', $shiftId)->first(); } if (!$shift || $shift->restaurant_id != $request->tenant || $shift->areaSelectionMode() !== 'covers') { return $this->response(['enabled' => false, 'areas' => []]); } $list = $shift->getMaxCoversPerArea($date, $slot, $party, $exclude > 0 ? $exclude : null); $names = []; $areas = Area::where('restaurant_id', $shift->restaurant_id)->where('bookable_online', 1)->get(); foreach ($areas as $area) { $names[$area->id] = $area->name; } $result = []; foreach ($list as $item) { $max = intval($item['max_covers']); $occupied = intval($item['occupied']); $result[] = [ 'area_id' => intval($item['area_id']), 'area_name' => isset($names[$item['area_id']]) ? $names[$item['area_id']] : '', 'max' => $max, 'occupied' => $occupied, 'free' => max(0, $max - $occupied), ]; } return $this->response([ 'enabled' => true, 'areas' => $result, ]); } /** * Search for reserved tables from a selected date, shift, slot, duration * Useful for the reservation modal * * @param Request $request * * @return \WP_REST_Response */ public function tables(Request $request) { $shiftId = intval($request->shiftId); $date = $request->date; $slot = intval($request->slot); $duration = intval($request->duration); $shift = Shift::where('id', $shiftId)->first(); if (!$shift) { $shift = Event::where('id', $shiftId)->first(); } if (!$shift) { return $this->response(['tables' => []]); } //ray([ 'shiftId' => $shiftId, 'date' => $date, 'slot' => $slot, 'duration' => $duration ]); $tables = $shift->getListTablesIdOccupied($date, $slot, $duration); $restaurant = Restaurant::find($shift->restaurant_id); $tables_blocked = []; if ($restaurant){ $tables_id_blocked_start = $restaurant->getBlockedTables($date, $slot); $tables_id_blocked_end = $restaurant->getBlockedTables($date, $slot+$duration); $tables_blocked = array_merge($tables_id_blocked_start, $tables_id_blocked_end); $tables_blocked = array_unique($tables_blocked); } //ray($tables_blocked); return $this->response([ //'tables' => [91,92,93,94,95,96,97,98,99], 'tables' => array_values($tables), // tables reserved 'tables_blocked' => array_values($tables_blocked) ]); } // Metrics }