Replace the kitchen display's 4s polling with a pushed feed. GET /kitchen/stream holds an SSE connection and emits the board the moment it changes (~1s tick, heartbeat between changes), so fired/bumped/guest orders land on screen near instantly. Bounded to ~25s per connection so PHP-FPM workers recycle — the browser's EventSource reconnects automatically; if EventSource is unavailable it falls back to polling /kitchen/feed. Sends X-Accel-Buffering: no so nginx streams it, and releases the session early (DB sessions don't lock, but be safe). feed()/stream() now share buildTickets(); behaviour of the JSON feed (initial load + fallback) is unchanged. Suite green (12). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d4f4821d96
commit
6c42f18186
@@ -9,7 +9,9 @@ use App\Models\PosSaleLine;
|
||||
use App\Models\PosStation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class KitchenController extends Controller
|
||||
{
|
||||
@@ -24,11 +26,70 @@ class KitchenController extends Controller
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$owner = $this->ownerRef($request);
|
||||
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
|
||||
{
|
||||
$sales = PosSale::owned($owner)
|
||||
->openTickets()
|
||||
->where('kitchen_status', PosSale::KITCHEN_ACTIVE)
|
||||
@@ -36,7 +97,7 @@ class KitchenController extends Controller
|
||||
->orderBy('kitchen_sent_at')
|
||||
->get();
|
||||
|
||||
$tickets = $sales->map(function (PosSale $sale) {
|
||||
return $sales->map(function (PosSale $sale) {
|
||||
$lines = $sale->lines
|
||||
->filter(fn (PosSaleLine $l) => in_array($l->kitchen_state, [
|
||||
PosSaleLine::KITCHEN_QUEUED,
|
||||
@@ -69,8 +130,6 @@ class KitchenController extends Controller
|
||||
])->all(),
|
||||
];
|
||||
})->filter()->values();
|
||||
|
||||
return response()->json(['tickets' => $tickets, 'server_time' => now()->toIso8601String()]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
-2
@@ -444,17 +444,33 @@ Alpine.data('posTicket', (config = {}) => ({
|
||||
Alpine.data('posKitchen', (config = {}) => ({
|
||||
tickets: [],
|
||||
feedUrl: config.feedUrl || '/kitchen/feed',
|
||||
streamUrl: config.streamUrl || '',
|
||||
bumpUrl: config.bumpUrl || '/kitchen/lines/__ID__/bump',
|
||||
csrf: config.csrf || document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
now: Date.now(),
|
||||
loaded: false,
|
||||
activeStation: '',
|
||||
es: null,
|
||||
|
||||
init() {
|
||||
this.load();
|
||||
setInterval(() => this.load(), 4000);
|
||||
this.load(); // fast initial paint
|
||||
this.connect(); // realtime stream (falls back to polling)
|
||||
setInterval(() => { this.now = Date.now(); }, 1000);
|
||||
},
|
||||
connect() {
|
||||
if (! this.streamUrl || typeof window.EventSource === 'undefined') {
|
||||
setInterval(() => this.load(), 4000); // fallback polling
|
||||
return;
|
||||
}
|
||||
this.es = new EventSource(this.streamUrl); // auto-reconnects on close/error
|
||||
this.es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
this.tickets = data.tickets || [];
|
||||
this.loaded = true;
|
||||
} catch (x) { /* ignore malformed frame */ }
|
||||
};
|
||||
},
|
||||
get visibleTickets() {
|
||||
if (! this.activeStation) return this.tickets;
|
||||
const sid = String(this.activeStation);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<x-app-layout title="Kitchen">
|
||||
<div x-data="posKitchen(@js([
|
||||
'feedUrl' => route('pos.kitchen.feed'),
|
||||
'streamUrl' => route('pos.kitchen.stream'),
|
||||
'bumpUrl' => route('pos.kitchen.bump', ['line' => '__ID__']),
|
||||
]))" class="space-y-4">
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
|
||||
Route::get('/kitchen', [KitchenController::class, 'index'])->name('pos.kitchen');
|
||||
Route::get('/kitchen/feed', [KitchenController::class, 'feed'])->name('pos.kitchen.feed');
|
||||
Route::get('/kitchen/stream', [KitchenController::class, 'stream'])->name('pos.kitchen.stream');
|
||||
Route::post('/kitchen/lines/{line}/bump', [KitchenController::class, 'bump'])->name('pos.kitchen.bump');
|
||||
|
||||
// Menu depth — categories, kitchen stations, and modifier groups/options.
|
||||
|
||||
Reference in New Issue
Block a user