settings['offer_menu'] (JSON blob * persisted by make_booking / recompute_offer_payment). */ class KitchenPrepController extends Controller { /** * Statuses considered "firm" for prep counts (the kitchen will surely cook). */ protected $firm_statuses = [ BookingStatus::BOOKED, BookingStatus::CONFIRMED, ]; /** * Additional statuses that are tentative but still expected to come. * Combined with firm = "expected" total. */ protected $tentative_statuses = [ BookingStatus::PENDING, BookingStatus::PENDING_PAYMENT, ]; /** * GET /app/kitchen-prep/:date * * @param Request $request * * @return mixed */ public function index(Request $request) { $user = Eva::make('user'); if (!$user->canManage($request->tenant)) { return $this->response(['success' => false, 'error' => __eva('You cannot access.')]); } $date = $request->date; $tenantId = $request->tenantId(); if (!preg_match('#^\d{4}-\d{2}-\d{2}$#', $date)) { return $this->response(['success' => false, 'error' => __eva('Invalid date.')]); } // Optional shift filter (passed as query param ?shift_id=xx) $shift_id = isset($request->params['shift_id']) ? intval($request->params['shift_id']) : null; $included_statuses = array_merge($this->firm_statuses, $this->tentative_statuses); $query = Booking::where('restaurant_id', $tenantId) ->where('date', $date) ->whereIn('status', $included_statuses) ->orderBy('time', 'asc'); if ($shift_id) { $query->where('shift_event_id', $shift_id); } $bookings = $query->get(); $timeline = []; $aggregate = []; // key: offer_id|section_id|item_id => details + counts $by_dish = []; // same key, but with breakdown of bookings $metrics = [ 'bookings' => 0, 'covers' => 0, 'firm_bookings' => 0, 'firm_covers' => 0, 'tentative_covers' => 0, 'with_menu' => 0, 'allergies' => 0, // count of bookings with at least one allergy-like tag ]; foreach ($bookings as $booking) { $is_firm = in_array($booking->status, $this->firm_statuses, true); $metrics['bookings']++; $metrics['covers'] += intval($booking->party); if ($is_firm) { $metrics['firm_bookings']++; $metrics['firm_covers'] += intval($booking->party); } else { $metrics['tentative_covers'] += intval($booking->party); } $offer_menu = $this->extractOfferMenu($booking); if ($offer_menu) { $metrics['with_menu']++; } $tags = $this->buildTagList($booking); $timeline[] = [ 'id' => $booking->id, 'uuid' => $booking->uuid, 'time' => intval($booking->time), 'duration' => intval($booking->duration), 'date' => $booking->date, 'party' => intval($booking->party), 'status' => $booking->status, 'is_firm' => $is_firm, 'first_name' => $booking->first_name, 'last_name' => $booking->last_name, 'shift_event_id' => $booking->shift_event_id, 'shift_name' => $this->guessServiceName($booking), 'area' => $booking->area_selected_label ?? null, 'table' => $this->buildTablesLabel($booking), 'tags' => $tags, 'private_notes' => $booking->private_notes, 'public_notes' => $booking->notes, 'offer_menu' => $offer_menu, ]; // Aggregate per item if ($offer_menu && !empty($offer_menu['lines'])) { foreach ($offer_menu['lines'] as $line) { $key = $this->lineKey($offer_menu['offer_id'], $line); if (!$key) continue; if (!isset($aggregate[$key])) { $aggregate[$key] = [ 'offer_id' => $offer_menu['offer_id'], 'section_id' => $line['section_id'] ?? null, 'section_type' => $line['section_type'] ?? null, 'item_id' => $line['item_id'] ?? null, 'name' => $line['name'] ?? '', 'unit_price' => floatval($line['unit_price'] ?? 0), 'qty_firm' => 0, 'qty_expected' => 0, ]; $by_dish[$key] = [ 'offer_id' => $offer_menu['offer_id'], 'section_id' => $line['section_id'] ?? null, 'section_type' => $line['section_type'] ?? null, 'item_id' => $line['item_id'] ?? null, 'name' => $line['name'] ?? '', 'unit_price' => floatval($line['unit_price'] ?? 0), 'bookings' => [], ]; } $qty = intval($line['quantity'] ?? 0); $aggregate[$key]['qty_expected'] += $qty; if ($is_firm) { $aggregate[$key]['qty_firm'] += $qty; } $by_dish[$key]['bookings'][] = [ 'booking_id' => $booking->id, 'uuid' => $booking->uuid, 'time' => intval($booking->time), 'name' => trim(($booking->first_name ?? '') . ' ' . ($booking->last_name ?? '')), 'table' => $this->buildTablesLabel($booking), 'status' => $booking->status, 'is_firm' => $is_firm, 'quantity' => $qty, ]; } } // Tag-based allergies count (any tag with name containing allergy/intolerance) foreach ($tags as $tag) { if ($this->isAllergyTag($tag)) { $metrics['allergies']++; break; } } } return $this->response([ 'success' => true, 'date' => $date, 'aggregate' => array_values($aggregate), 'by_dish' => array_values($by_dish), 'timeline' => $timeline, 'metrics' => $metrics, ]); } /** * Decode and normalise the offer_menu blob stored in $booking->settings. * Returns null when the booking has no offer menu attached. */ protected function extractOfferMenu($booking) { $settings = $booking->settings; if (!is_array($settings) || empty($settings['offer_menu'])) return null; $offer_menu = $settings['offer_menu']; $lines = isset($offer_menu['lines']) && is_array($offer_menu['lines']) ? $offer_menu['lines'] : []; if (empty($lines)) return null; return [ 'offer_id' => isset($offer_menu['offer_id']) ? intval($offer_menu['offer_id']) : 0, 'offer_rule_id' => $offer_menu['offer_rule_id'] ?? null, 'subtotal' => floatval($offer_menu['subtotal'] ?? 0), 'lines' => $lines, ]; } /** * Stable key for grouping line items across bookings. */ protected function lineKey($offer_id, $line) { $section_id = $line['section_id'] ?? null; $item_id = $line['item_id'] ?? null; if (!$section_id || !$item_id) return null; return $offer_id . '|' . $section_id . '|' . $item_id; } /** * Build a list of tag names attached to the booking. */ protected function buildTagList($booking) { $tags = []; try { foreach ($booking->tags as $tag) { $tags[] = [ 'id' => $tag->id, 'name' => $tag->name, 'background' => $tag->background ?? null, 'color' => $tag->color ?? null, ]; } } catch (\Exception $e) { // no-op } return $tags; } /** * Guess the shift/event name for a booking. */ protected function guessServiceName($booking) { try { $service = alexr_get_service($booking->shift_event_id); if ($service) return $service->name; } catch (\Exception $e) { // no-op } return null; } /** * Build a comma-separated list of table names for the booking. */ protected function buildTablesLabel($booking) { try { $names = []; foreach ($booking->tables as $table) { $names[] = $table->name; } return implode(', ', $names); } catch (\Exception $e) { return ''; } } /** * Heuristic to detect allergy/dietary tags by name. We don't have an * "allergy" flag on btags so we match common keywords. */ protected function isAllergyTag($tag) { $name = strtolower($tag['name'] ?? ''); if ($name === '') return false; $keywords = ['allergy', 'allergi', 'alergi', 'intoleran', 'gluten', 'celiac', 'lactose', 'vegan', 'vegetarian']; foreach ($keywords as $k) { if (strpos($name, $k) !== false) return true; } return false; } }