Add public token-gated kitchen display for POS devices.
Deploy Ladill POS / deploy (push) Successful in 46s
Deploy Ladill POS / deploy (push) Successful in 46s
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.
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Public;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PosDevice;
|
||||
use App\Models\PosSaleLine;
|
||||
use App\Services\Pos\DeviceService;
|
||||
use App\Services\Pos\KitchenBoardService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Token-gated kitchen display for unattended tablets (no staff login).
|
||||
*/
|
||||
class KitchenDisplayController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private DeviceService $devices,
|
||||
private KitchenBoardService $board,
|
||||
) {}
|
||||
|
||||
public function show(string $token): View
|
||||
{
|
||||
$device = $this->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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Pos;
|
||||
|
||||
use App\Models\PosSale;
|
||||
use App\Models\PosSaleLine;
|
||||
use App\Models\PosStation;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class KitchenBoardService
|
||||
{
|
||||
/** @return array{tickets: Collection, server_time: string} */
|
||||
public function payload(string $ownerRef): array
|
||||
{
|
||||
return [
|
||||
'tickets' => $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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user