From ede4aaa10ca19043fb8cf3e73a9d44d48d4f8d22 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 15 Jul 2026 22:52:28 +0000 Subject: [PATCH] Add public token-gated kitchen display for POS devices. Kitchen display devices open a full-screen KDS without staff login, with feed/stream/bump over the device token. Shared kitchen board logic is extracted so the signed-in Kitchen page stays in sync. --- .../Controllers/Pos/KitchenController.php | 83 ++----------- .../Public/KitchenDisplayController.php | 116 ++++++++++++++++++ app/Services/Pos/DeviceService.php | 2 +- app/Services/Pos/KitchenBoardService.php | 96 +++++++++++++++ config/pos.php | 2 +- resources/views/pos/kitchen/public.blade.php | 101 +++++++++++++++ routes/web.php | 18 +++ tests/Feature/PosDeviceTest.php | 85 +++++++++++++ 8 files changed, 425 insertions(+), 78 deletions(-) create mode 100644 app/Http/Controllers/Public/KitchenDisplayController.php create mode 100644 app/Services/Pos/KitchenBoardService.php create mode 100644 resources/views/pos/kitchen/public.blade.php diff --git a/app/Http/Controllers/Pos/KitchenController.php b/app/Http/Controllers/Pos/KitchenController.php index 12cb221..12c6faa 100644 --- a/app/Http/Controllers/Pos/KitchenController.php +++ b/app/Http/Controllers/Pos/KitchenController.php @@ -4,12 +4,10 @@ namespace App\Http\Controllers\Pos; use App\Http\Controllers\Controller; use App\Http\Controllers\Pos\Concerns\ScopesToAccount; -use App\Models\PosSale; use App\Models\PosSaleLine; -use App\Models\PosStation; +use App\Services\Pos\KitchenBoardService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Collection; use Illuminate\View\View; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -17,10 +15,12 @@ class KitchenController extends Controller { use ScopesToAccount; + public function __construct(private KitchenBoardService $board) {} + public function index(Request $request): View { return view('pos.kitchen.index', [ - 'stations' => PosStation::owned($this->ownerRef($request))->orderBy('name')->get(['id', 'name']), + 'stations' => $this->board->stations($this->ownerRef($request)), ]); } @@ -30,7 +30,7 @@ class KitchenController extends Controller */ public function feed(Request $request): JsonResponse { - return response()->json($this->payload($this->ownerRef($request))); + return response()->json($this->board->payload($this->ownerRef($request))); } /** @@ -54,7 +54,7 @@ class KitchenController extends Controller $lastHash = null; while (true) { - $payload = $this->payload($owner); + $payload = $this->board->payload($owner); $json = json_encode($payload); $hash = md5((string) $json); @@ -82,80 +82,11 @@ class KitchenController extends Controller 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]); + return response()->json($this->board->bumpLine($line, $this->ownerRef($request))); } } diff --git a/app/Http/Controllers/Public/KitchenDisplayController.php b/app/Http/Controllers/Public/KitchenDisplayController.php new file mode 100644 index 0000000..86817e7 --- /dev/null +++ b/app/Http/Controllers/Public/KitchenDisplayController.php @@ -0,0 +1,116 @@ +findKitchenDevice($token); + $this->devices->recordHeartbeat($device); + + return view('pos.kitchen.public', [ + 'device' => $device, + 'locationName' => $device->location?->name ?? 'Kitchen', + 'stations' => $this->board->stations($device->owner_ref), + 'feedUrl' => route('pos.kitchen-display.feed', ['token' => $token]), + 'streamUrl' => route('pos.kitchen-display.stream', ['token' => $token]), + 'bumpUrl' => route('pos.kitchen-display.bump', ['token' => $token, 'line' => '__ID__']), + ]); + } + + public function feed(string $token): JsonResponse + { + $device = $this->findKitchenDevice($token); + $this->devices->recordHeartbeat($device); + + return response()->json($this->board->payload($device->owner_ref)); + } + + public function stream(Request $request, string $token): StreamedResponse + { + $device = $this->findKitchenDevice($token); + $this->devices->recordHeartbeat($device); + $owner = $device->owner_ref; + $deviceId = $device->id; + $request->session()->save(); + + $response = new StreamedResponse(function () use ($owner, $deviceId) { + while (ob_get_level() > 0) { + @ob_end_flush(); + } + @set_time_limit(0); + + $start = time(); + $lastHash = null; + + while (true) { + if ($deviceId) { + $live = PosDevice::query()->find($deviceId); + if ($live) { + app(DeviceService::class)->recordHeartbeat($live); + } + } + + $payload = $this->board->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"; + } + @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'); + + return $response; + } + + public function bump(string $token, PosSaleLine $line): JsonResponse + { + $device = $this->findKitchenDevice($token); + $this->devices->recordHeartbeat($device); + + return response()->json($this->board->bumpLine($line, $device->owner_ref)); + } + + private function findKitchenDevice(string $token): PosDevice + { + $device = $this->devices->findByToken($token); + abort_unless($device && $device->type === 'kitchen_display', 404); + + return $device->loadMissing('location'); + } +} diff --git a/app/Services/Pos/DeviceService.php b/app/Services/Pos/DeviceService.php index e7f5d01..31e822c 100644 --- a/app/Services/Pos/DeviceService.php +++ b/app/Services/Pos/DeviceService.php @@ -122,7 +122,7 @@ class DeviceService return match ($device->type) { 'customer_display' => route('pos.customer-display.show', ['token' => $device->device_token]), - 'kitchen_display' => route('pos.kitchen'), + 'kitchen_display' => route('pos.kitchen-display.show', ['token' => $device->device_token]), default => null, }; } diff --git a/app/Services/Pos/KitchenBoardService.php b/app/Services/Pos/KitchenBoardService.php new file mode 100644 index 0000000..b9641fc --- /dev/null +++ b/app/Services/Pos/KitchenBoardService.php @@ -0,0 +1,96 @@ + $this->buildTickets($ownerRef), + 'server_time' => now()->toIso8601String(), + ]; + } + + public function stations(string $ownerRef): Collection + { + return PosStation::owned($ownerRef)->orderBy('name')->get(['id', 'name']); + } + + 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). + * + * @return array{state: string} + */ + public function bumpLine(PosSaleLine $line, string $ownerRef): array + { + abort_unless($line->sale && $line->sale->owner_ref === $ownerRef, 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(); + + $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 ['state' => $next]; + } +} diff --git a/config/pos.php b/config/pos.php index 1a260db..e41e728 100644 --- a/config/pos.php +++ b/config/pos.php @@ -74,7 +74,7 @@ return [ 'device_registration_hints' => [ 'register' => 'Tracked for your team. Staff sign in on this machine — no device token is issued.', 'customer_display' => 'Creates (or reuses) the branch customer-display token. Open the URL on a second screen facing the customer.', - 'kitchen_display' => 'Token for kitchen screen provisioning. Kitchen currently opens the signed-in Kitchen page for this account.', + 'kitchen_display' => 'Creates a device token. Open the kitchen URL on a wall tablet — no staff login required. Bump items from the board.', 'receipt_printer' => 'Token for a future print agent. Paper size still comes from branch receipt settings.', 'barcode_scanner' => 'Tracked for inventory. USB scanners work as keyboard wedges on the register today.', 'tablet' => 'Tracked for inventory. Use Customer display or Kitchen display if the tablet should run unattended.', diff --git a/resources/views/pos/kitchen/public.blade.php b/resources/views/pos/kitchen/public.blade.php new file mode 100644 index 0000000..f49ab46 --- /dev/null +++ b/resources/views/pos/kitchen/public.blade.php @@ -0,0 +1,101 @@ + + + + + + + Kitchen · {{ $locationName }} + @include('partials.favicon') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + +
+ +
+ +
+
+

Kitchen display

+

{{ $locationName }}

+

Live tickets — tap an item to advance it. No sign-in required on this screen.

+
+
+ + +
+
+ + @if($stations->isNotEmpty()) +
+ + @foreach($stations as $station) + + @endforeach +
+ @endif + + + +
+ +
+
+ + diff --git a/routes/web.php b/routes/web.php index 3bbf22c..24797ee 100644 --- a/routes/web.php +++ b/routes/web.php @@ -19,6 +19,7 @@ use App\Http\Controllers\Pos\TableController; use App\Http\Controllers\Pos\TicketController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\Public\CustomerDisplayController as PublicCustomerDisplayController; +use App\Http\Controllers\Public\KitchenDisplayController as PublicKitchenDisplayController; use App\Http\Controllers\Public\TableOrderController; use App\Http\Controllers\WalletBalanceController; use Illuminate\Support\Facades\Route; @@ -61,6 +62,23 @@ Route::post('/customer-display/{token}/action', [PublicCustomerDisplayController ->where('token', '[A-Za-z0-9]{32,64}') ->name('pos.customer-display.action'); +// Token-gated kitchen display (unattended tablet; no operator chrome). +Route::get('/kitchen-display/{token}', [PublicKitchenDisplayController::class, 'show']) + ->where('token', '[A-Za-z0-9]{32,64}') + ->name('pos.kitchen-display.show'); +Route::get('/kitchen-display/{token}/feed', [PublicKitchenDisplayController::class, 'feed']) + ->middleware('throttle:120,1') + ->where('token', '[A-Za-z0-9]{32,64}') + ->name('pos.kitchen-display.feed'); +Route::get('/kitchen-display/{token}/stream', [PublicKitchenDisplayController::class, 'stream']) + ->middleware('throttle:60,1') + ->where('token', '[A-Za-z0-9]{32,64}') + ->name('pos.kitchen-display.stream'); +Route::post('/kitchen-display/{token}/lines/{line}/bump', [PublicKitchenDisplayController::class, 'bump']) + ->middleware('throttle:120,1') + ->where('token', '[A-Za-z0-9]{32,64}') + ->name('pos.kitchen-display.bump'); + Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance'); diff --git a/tests/Feature/PosDeviceTest.php b/tests/Feature/PosDeviceTest.php index c5aacf0..f61e2d1 100644 --- a/tests/Feature/PosDeviceTest.php +++ b/tests/Feature/PosDeviceTest.php @@ -154,4 +154,89 @@ class PosDeviceTest extends TestCase $this->assertSame('online', $device->status); $this->assertNotNull($device->last_online_at); } + + public function test_kitchen_display_device_opens_public_url_without_auth(): void + { + $user = $this->user(); + $location = $this->location($user); + $device = app(DeviceService::class)->register([ + 'owner_ref' => $user->public_id, + 'name' => 'Pass tablet', + 'type' => 'kitchen_display', + 'location_id' => $location->id, + ]); + + $this->assertNotEmpty($device->device_token); + + $this->get(route('pos.kitchen-display.show', ['token' => $device->device_token])) + ->assertOk() + ->assertSee('Kitchen display') + ->assertSee('Main register'); + + $this->getJson(route('pos.kitchen-display.feed', ['token' => $device->device_token])) + ->assertOk() + ->assertJsonStructure(['tickets', 'server_time']); + + $device->refresh(); + $this->assertSame('online', $device->status); + } + + public function test_kitchen_display_bump_advances_line_state(): void + { + $user = $this->user(); + $location = $this->location($user); + $device = app(DeviceService::class)->register([ + 'owner_ref' => $user->public_id, + 'name' => 'KDS', + 'type' => 'kitchen_display', + 'location_id' => $location->id, + ]); + + $sale = \App\Models\PosSale::create([ + 'owner_ref' => $user->public_id, + 'location_id' => $location->id, + 'reference' => 'POS-KDS-1', + 'currency' => 'GHS', + 'status' => 'pending', + 'payment_method' => 'cash', + 'order_type' => 'dine_in', + 'kitchen_status' => \App\Models\PosSale::KITCHEN_ACTIVE, + 'kitchen_sent_at' => now(), + 'total_minor' => 1000, + 'subtotal_minor' => 1000, + ]); + $line = \App\Models\PosSaleLine::create([ + 'pos_sale_id' => $sale->id, + 'name' => 'Jollof', + 'unit_price_minor' => 1000, + 'quantity' => 1, + 'line_total_minor' => 1000, + 'position' => 0, + 'kitchen_state' => \App\Models\PosSaleLine::KITCHEN_QUEUED, + ]); + + $this->postJson(route('pos.kitchen-display.bump', [ + 'token' => $device->device_token, + 'line' => $line->id, + ]))->assertOk()->assertJsonPath('state', 'preparing'); + + $this->assertSame('preparing', $line->fresh()->kitchen_state); + } + + public function test_device_service_open_url_for_kitchen_is_public(): void + { + $user = $this->user(); + $location = $this->location($user); + $device = app(DeviceService::class)->register([ + 'owner_ref' => $user->public_id, + 'name' => 'KDS', + 'type' => 'kitchen_display', + 'location_id' => $location->id, + ]); + + $url = app(DeviceService::class)->openUrl($device); + $this->assertNotNull($url); + $this->assertStringContainsString('/kitchen-display/', $url); + $this->assertStringContainsString($device->device_token, $url); + } }