Add Frontdesk-style Devices registry for POS hardware.
Deploy Ladill POS / deploy (push) Successful in 42s

Register tills, customer displays, kitchen screens, printers, scanners,
and tablets per branch. Customer displays share the branch display token
and mark online when the public screen polls; stale devices go offline
on a schedule.
This commit is contained in:
isaacclad
2026-07-15 21:49:21 +00:00
parent 46c60259c9
commit c01518b0ee
15 changed files with 877 additions and 3 deletions
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace App\Services\Pos;
use App\Models\PosCustomerDisplay;
use App\Models\PosDevice;
use App\Models\PosLocation;
use Illuminate\Support\Str;
class DeviceService
{
public function __construct(private CustomerDisplayService $displays) {}
public function generateToken(): string
{
return Str::random(48);
}
public function findByToken(string $token): ?PosDevice
{
return PosDevice::query()->where('device_token', $token)->first();
}
public function recordHeartbeat(PosDevice $device): PosDevice
{
$device->update([
'status' => PosDevice::STATUS_ONLINE,
'last_online_at' => now(),
]);
return $device->fresh();
}
/** Mark token-bearing devices online when their public endpoints are hit. */
public function touchByToken(?string $token): void
{
if (! $token) {
return;
}
$device = $this->findByToken($token);
if ($device) {
$this->recordHeartbeat($device);
}
}
public function markStaleDevicesOffline(int $minutes = 10): int
{
return PosDevice::query()
->where('status', PosDevice::STATUS_ONLINE)
->where(function ($q) use ($minutes) {
$q->whereNull('last_online_at')
->orWhere('last_online_at', '<', now()->subMinutes($minutes));
})
->update(['status' => PosDevice::STATUS_OFFLINE]);
}
/** @return array<string, mixed> */
public function defaultConfigForType(string $type): array
{
return match ($type) {
'customer_display' => ['role' => 'customer_facing'],
'kitchen_display' => ['role' => 'kitchen'],
'receipt_printer' => ['paper_mm' => 80, 'driver' => 'browser'],
'register' => ['role' => 'till'],
default => [],
};
}
/**
* Create a device; customer_display types share the branch display token.
*
* @param array{name: string, type: string, location_id?: ?int, owner_ref: string} $data
*/
public function register(array $data): PosDevice
{
$type = $data['type'];
$locationId = isset($data['location_id']) ? (int) $data['location_id'] : null;
$token = null;
if (in_array($type, config('pos.device_token_types', []), true)) {
$token = $this->tokenForNewDevice($type, $locationId, $data['owner_ref']);
}
return PosDevice::create([
'owner_ref' => $data['owner_ref'],
'location_id' => $locationId ?: null,
'name' => $data['name'],
'type' => $type,
'status' => PosDevice::STATUS_OFFLINE,
'device_token' => $token,
'config' => $this->defaultConfigForType($type),
]);
}
public function regenerateToken(PosDevice $device): PosDevice
{
if (! $device->usesToken()) {
return $device;
}
$token = $this->generateToken();
if ($device->type === 'customer_display' && $device->location_id) {
$location = PosLocation::query()->find($device->location_id);
if ($location) {
$display = $this->displays->ensureForLocation($location);
$display->update(['token' => $token]);
}
}
$device->update(['device_token' => $token]);
return $device->fresh();
}
public function openUrl(PosDevice $device): ?string
{
if (! $device->device_token) {
return null;
}
return match ($device->type) {
'customer_display' => route('pos.customer-display.show', ['token' => $device->device_token]),
'kitchen_display' => route('pos.kitchen'),
default => null,
};
}
private function tokenForNewDevice(string $type, ?int $locationId, string $ownerRef): string
{
if ($type === 'customer_display' && $locationId) {
$location = PosLocation::query()
->where('owner_ref', $ownerRef)
->whereKey($locationId)
->first();
if ($location) {
$display = $this->displays->ensureForLocation($location);
// Keep a single active display token per branch; reuse it for the device.
return $display->token;
}
}
return $this->generateToken();
}
}