PosStation::owned($this->ownerRef($request))->orderBy('name')->get(['id', 'name']), ]); } /** * Active kitchen tickets — open sales with fired-but-not-served lines. * Used for the initial load and as a fallback when SSE is unavailable. */ public function feed(Request $request): JsonResponse { return response()->json($this->payload($this->ownerRef($request))); } /** * Realtime feed over Server-Sent Events. Pushes the board whenever it * changes (~1s tick), with a heartbeat. Bounded lifetime so PHP-FPM workers * recycle — the browser's EventSource reconnects automatically. */ public function stream(Request $request): StreamedResponse { $owner = $this->ownerRef($request); // Release the session lock early (DB driver doesn't lock, but be safe). $request->session()->save(); $response = new StreamedResponse(function () use ($owner) { while (ob_get_level() > 0) { @ob_end_flush(); } @set_time_limit(0); $start = time(); $lastHash = null; while (true) { $payload = $this->payload($owner); $json = json_encode($payload); $hash = md5((string) $json); if ($hash !== $lastHash) { echo 'data: '.$json."\n\n"; $lastHash = $hash; } else { echo ": ping\n\n"; // heartbeat } @ob_flush(); @flush(); if (connection_aborted() || (time() - $start) >= 25) { break; } sleep(1); } }); $response->headers->set('Content-Type', 'text/event-stream'); $response->headers->set('Cache-Control', 'no-cache'); $response->headers->set('Connection', 'keep-alive'); $response->headers->set('X-Accel-Buffering', 'no'); // disable nginx buffering return $response; } /** @return array{tickets: \Illuminate\Support\Collection, server_time: string} */ private function payload(string $owner): array { return ['tickets' => $this->buildTickets($owner), 'server_time' => now()->toIso8601String()]; } private function buildTickets(string $owner): Collection { // Payment-agnostic: dine-in tabs (pending) and paid online orders both // sit on the board while the kitchen is still working them. $sales = PosSale::owned($owner) ->where('kitchen_status', PosSale::KITCHEN_ACTIVE) ->with(['table', 'lines' => fn ($q) => $q->orderBy('position')->with('modifiers', 'station')]) ->orderBy('kitchen_sent_at') ->get(); return $sales->map(function (PosSale $sale) { $lines = $sale->lines ->filter(fn (PosSaleLine $l) => in_array($l->kitchen_state, [ PosSaleLine::KITCHEN_QUEUED, PosSaleLine::KITCHEN_PREPARING, PosSaleLine::KITCHEN_READY, ], true)) ->values(); if ($lines->isEmpty()) { return null; } return [ 'id' => $sale->id, 'reference' => $sale->reference, 'order_type' => $sale->order_type, 'table' => $sale->table?->label, 'sent_at' => optional($sale->kitchen_sent_at)->toIso8601String(), 'lines' => $lines->map(fn (PosSaleLine $l) => [ 'id' => $l->id, 'name' => $l->name, 'quantity' => $l->quantity, 'notes' => $l->notes, 'course' => $l->course, 'station' => $l->station?->name, 'station_id' => $l->station_id, 'source' => $l->source, 'modifiers' => $l->modifiers->map(fn ($m) => $m->name)->all(), 'state' => $l->kitchen_state, ])->all(), ]; })->filter()->values(); } /** * Advance one line to the next kitchen state (queued → preparing → ready → served). */ public function bump(Request $request, PosSaleLine $line): JsonResponse { $owner = $this->ownerRef($request); abort_unless($line->sale && $line->sale->owner_ref === $owner, 404); $flow = PosSaleLine::KITCHEN_FLOW; $idx = array_search($line->kitchen_state, $flow, true); $next = $idx === false ? PosSaleLine::KITCHEN_PREPARING : ($flow[$idx + 1] ?? PosSaleLine::KITCHEN_SERVED); $line->forceFill(['kitchen_state' => $next])->save(); // When every fired line is served, the ticket leaves the kitchen board. $sale = $line->sale; $remaining = $sale->lines() ->whereIn('kitchen_state', [PosSaleLine::KITCHEN_QUEUED, PosSaleLine::KITCHEN_PREPARING, PosSaleLine::KITCHEN_READY]) ->count(); if ($remaining === 0) { $sale->forceFill(['kitchen_status' => PosSale::KITCHEN_SERVED])->save(); } return response()->json(['state' => $next]); } }