Add service-point routing so call-next cannot steal assigned tickets.
Deploy Ladill Queue / deploy (push) Successful in 2m17s

Counters gain destination/staff metadata; tickets can be pre-assigned to a
service point with assigned_only queues for healthcare while shared_pool
preserves generic bank/government behavior. Display and announcements expose
ticket, staff, and destination clearly for Care and public boards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 17:21:56 +00:00
co-authored by Cursor
parent 2d1f8c1eff
commit 0854b431bc
17 changed files with 753 additions and 42 deletions
@@ -40,10 +40,10 @@ class ConsoleApiController extends Controller
->latest('called_at')
->first();
$waitingByQueue = collect($counter->serviceQueues)->mapWithKeys(function (ServiceQueue $queue) {
$waitingByQueue = collect($counter->serviceQueues)->mapWithKeys(function (ServiceQueue $queue) use ($counter) {
return [$queue->uuid => array_map(
fn ($t) => TicketPresenter::toArray($t),
$this->engine->waitingTickets($queue, 10),
$this->engine->waitingTickets($queue, 10, $counter),
)];
});
@@ -61,6 +61,8 @@ class ConsoleApiController extends Controller
'counter' => [
'uuid' => $counter->uuid,
'name' => $counter->name,
'destination' => $counter->displayDestination(),
'staff_display_name' => $counter->staff_display_name,
'status' => $counter->status,
'branch' => $counter->branch?->name,
],
@@ -69,6 +71,7 @@ class ConsoleApiController extends Controller
'uuid' => $q->uuid,
'name' => $q->name,
'prefix' => $q->prefix,
'routing_mode' => $q->routingMode(),
]),
'waiting_by_queue' => $waitingByQueue,
'transfer_queues' => $allQueues,
@@ -40,6 +40,9 @@ class CounterController extends Controller
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'destination' => ['nullable', 'string', 'max:255'],
'staff_ref' => ['nullable', 'string', 'max:191'],
'staff_display_name' => ['nullable', 'string', 'max:255'],
'branch_id' => ['nullable', 'integer'],
'branch_name' => ['nullable', 'string', 'max:255'],
'department_id' => ['nullable', 'integer'],
@@ -65,6 +68,15 @@ class CounterController extends Controller
$existing->update([
'name' => $validated['name'],
'code' => $validated['code'] ?? $existing->code,
'destination' => array_key_exists('destination', $validated)
? $validated['destination']
: $existing->destination,
'staff_ref' => array_key_exists('staff_ref', $validated)
? $validated['staff_ref']
: $existing->staff_ref,
'staff_display_name' => array_key_exists('staff_display_name', $validated)
? $validated['staff_display_name']
: $existing->staff_display_name,
'branch_id' => $branch->id,
'department_id' => $validated['department_id'] ?? $existing->department_id,
'is_active' => array_key_exists('is_active', $validated)
@@ -86,6 +98,9 @@ class CounterController extends Controller
'department_id' => $validated['department_id'] ?? null,
'name' => $validated['name'],
'code' => $validated['code'] ?? null,
'destination' => $validated['destination'] ?? null,
'staff_ref' => $validated['staff_ref'] ?? null,
'staff_display_name' => $validated['staff_display_name'] ?? null,
'status' => 'offline',
'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true,
'settings' => $externalKey !== '' ? ['external_key' => $externalKey] : null,
@@ -106,6 +121,9 @@ class CounterController extends Controller
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'destination' => ['nullable', 'string', 'max:255'],
'staff_ref' => ['nullable', 'string', 'max:191'],
'staff_display_name' => ['nullable', 'string', 'max:255'],
'is_active' => ['sometimes', 'boolean'],
'queue_ids' => ['nullable', 'array'],
'queue_ids.*' => ['uuid'],
@@ -116,6 +134,11 @@ class CounterController extends Controller
$counter->update(array_filter([
'name' => $validated['name'] ?? null,
'code' => array_key_exists('code', $validated) ? $validated['code'] : null,
'destination' => array_key_exists('destination', $validated) ? $validated['destination'] : null,
'staff_ref' => array_key_exists('staff_ref', $validated) ? $validated['staff_ref'] : null,
'staff_display_name' => array_key_exists('staff_display_name', $validated)
? $validated['staff_display_name']
: null,
'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : null,
], fn ($v) => $v !== null));
@@ -181,9 +204,13 @@ class CounterController extends Controller
'uuid' => $c->uuid,
'name' => $c->name,
'code' => $c->code,
'destination' => $c->displayDestination(),
'staff_ref' => $c->staff_ref,
'staff_display_name' => $c->staff_display_name,
'status' => $c->status,
'is_active' => $c->is_active,
'branch' => $c->branch?->name,
'department_id' => $c->department_id,
'external_key' => data_get($c->settings, 'external_key'),
'queues' => $c->serviceQueues->map(fn (ServiceQueue $q) => [
'uuid' => $q->uuid,
@@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\Branch;
use App\Models\Department;
use App\Services\Qms\AuditLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DepartmentController extends Controller
{
use ScopesApiToAccount;
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'queues.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$departments = Department::owned($owner)
->whereHas('branch', fn ($q) => $q->where('organization_id', $organization->id))
->with('branch')
->orderBy('name')
->get()
->map(fn (Department $d) => $this->serialize($d));
return response()->json(['data' => $departments]);
}
public function store(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'queues.manage');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['nullable', 'string', 'max:50'],
'branch_id' => ['nullable', 'integer'],
'branch_name' => ['nullable', 'string', 'max:255'],
'external_key' => ['nullable', 'string', 'max:191'],
'is_active' => ['nullable', 'boolean'],
]);
$branch = $this->resolveBranch($organization->id, $owner, $validated);
abort_unless($branch, 422, 'A valid branch_id or branch_name is required.');
$externalKey = isset($validated['external_key']) ? trim((string) $validated['external_key']) : '';
if ($externalKey !== '') {
$existing = Department::owned($owner)
->where('external_key', $externalKey)
->first();
if ($existing) {
$existing->update([
'name' => $validated['name'],
'type' => $validated['type'] ?? $existing->type,
'branch_id' => $branch->id,
'is_active' => array_key_exists('is_active', $validated)
? (bool) $validated['is_active']
: true,
]);
AuditLogger::record($owner, 'department.updated', $organization->id, $owner, Department::class, $existing->id);
return response()->json(['data' => $this->serialize($existing->fresh('branch'))]);
}
}
$byName = Department::owned($owner)
->where('branch_id', $branch->id)
->whereRaw('LOWER(name) = ?', [strtolower($validated['name'])])
->first();
if ($byName) {
$byName->update([
'type' => $validated['type'] ?? $byName->type,
'external_key' => $externalKey !== '' ? $externalKey : $byName->external_key,
'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true,
]);
AuditLogger::record($owner, 'department.updated', $organization->id, $owner, Department::class, $byName->id);
return response()->json(['data' => $this->serialize($byName->fresh('branch'))]);
}
$department = Department::create([
'owner_ref' => $owner,
'branch_id' => $branch->id,
'name' => $validated['name'],
'type' => $validated['type'] ?? 'general',
'external_key' => $externalKey !== '' ? $externalKey : null,
'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true,
]);
AuditLogger::record($owner, 'department.created', $organization->id, $owner, Department::class, $department->id);
return response()->json(['data' => $this->serialize($department->fresh('branch'))], 201);
}
/**
* @param array<string, mixed> $validated
*/
protected function resolveBranch(int $organizationId, string $owner, array $validated): ?Branch
{
if (! empty($validated['branch_id'])) {
return Branch::owned($owner)
->where('organization_id', $organizationId)
->where('id', (int) $validated['branch_id'])
->first();
}
$branchName = trim((string) ($validated['branch_name'] ?? ''));
if ($branchName === '') {
return null;
}
return Branch::owned($owner)
->where('organization_id', $organizationId)
->whereRaw('LOWER(name) = ?', [strtolower($branchName)])
->first();
}
/**
* @return array<string, mixed>
*/
protected function serialize(Department $d): array
{
return [
'id' => $d->id,
'name' => $d->name,
'type' => $d->type,
'external_key' => $d->external_key,
'is_active' => $d->is_active,
'branch' => $d->branch?->name,
'branch_id' => $d->branch_id,
];
}
}
@@ -50,12 +50,14 @@ class ServiceQueueController extends Controller
'max_capacity' => ['nullable', 'integer', 'min:1'],
'is_active' => ['nullable', 'boolean'],
'external_key' => ['nullable', 'string', 'max:191'],
'routing_mode' => ['nullable', 'string', 'in:shared_pool,assigned_only'],
]);
$branch = $this->resolveBranch($organization->id, $owner, $validated);
abort_unless($branch, 422, 'A valid branch_id or branch_name is required.');
$externalKey = isset($validated['external_key']) ? trim((string) $validated['external_key']) : '';
$routingMode = $validated['routing_mode'] ?? null;
if ($externalKey !== '') {
$existing = ServiceQueue::owned($owner)
->where('organization_id', $organization->id)
@@ -63,6 +65,10 @@ class ServiceQueueController extends Controller
->first();
if ($existing) {
$settings = array_merge($existing->settings ?? [], ['external_key' => $externalKey]);
if ($routingMode) {
$settings['routing_mode'] = $routingMode;
}
$existing->update([
'name' => $validated['name'],
'description' => $validated['description'] ?? $existing->description,
@@ -76,7 +82,7 @@ class ServiceQueueController extends Controller
'is_active' => array_key_exists('is_active', $validated)
? (bool) $validated['is_active']
: true,
'settings' => array_merge($existing->settings ?? [], ['external_key' => $externalKey]),
'settings' => $settings,
]);
AuditLogger::record($owner, 'service_queue.updated', $organization->id, $owner, ServiceQueue::class, $existing->id);
@@ -96,6 +102,9 @@ class ServiceQueueController extends Controller
if ($externalKey !== '') {
$settings['external_key'] = $externalKey;
}
if ($routingMode) {
$settings['routing_mode'] = $routingMode;
}
$byName->update([
'prefix' => $validated['prefix'],
'strategy' => $validated['strategy'] ?? $byName->strategy,
@@ -114,6 +123,14 @@ class ServiceQueueController extends Controller
'Queue limit reached for current plan.',
);
$settings = [];
if ($externalKey !== '') {
$settings['external_key'] = $externalKey;
}
if ($routingMode) {
$settings['routing_mode'] = $routingMode;
}
$queue = ServiceQueue::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
@@ -127,7 +144,7 @@ class ServiceQueueController extends Controller
'avg_service_seconds' => $validated['avg_service_seconds'] ?? 300,
'max_capacity' => $validated['max_capacity'] ?? null,
'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : true,
'settings' => $externalKey !== '' ? ['external_key' => $externalKey] : null,
'settings' => $settings !== [] ? $settings : null,
]);
AuditLogger::record($owner, 'service_queue.created', $organization->id, $owner, ServiceQueue::class, $queue->id);
@@ -148,8 +165,16 @@ class ServiceQueueController extends Controller
'is_active' => ['sometimes', 'boolean'],
'avg_service_seconds' => ['nullable', 'integer', 'min:60', 'max:7200'],
'max_capacity' => ['nullable', 'integer', 'min:1'],
'routing_mode' => ['nullable', 'string', 'in:shared_pool,assigned_only'],
]);
$settings = $serviceQueue->settings ?? [];
if (! empty($validated['routing_mode'])) {
$settings['routing_mode'] = $validated['routing_mode'];
unset($validated['routing_mode']);
$validated['settings'] = $settings;
}
$serviceQueue->update($validated);
AuditLogger::record(
@@ -233,6 +258,8 @@ class ServiceQueueController extends Controller
'is_paused' => $q->is_paused,
'branch' => $q->branch?->name,
'external_key' => data_get($q->settings, 'external_key'),
'routing_mode' => $q->routingMode(),
'department_id' => $q->department_id,
];
}
}
+34 -8
View File
@@ -58,17 +58,26 @@ class TicketController extends Controller
'priority' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_priorities')))],
'source' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('qms.ticket_sources')))],
'metadata' => ['nullable', 'array'],
'assigned_counter_id' => ['nullable', 'uuid'],
'assigned_counter_uuid' => ['nullable', 'uuid'],
]);
$queue = ServiceQueue::where('uuid', $validated['service_queue_id'])->firstOrFail();
$this->authorizeOwner($request, $queue);
$ticket = $this->engine->issueTicket($queue, $owner, [
...$validated,
'source' => $validated['source'] ?? 'api',
'actor_ref' => $owner,
'metadata' => $validated['metadata'] ?? null,
]);
try {
$ticket = $this->engine->issueTicket($queue, $owner, [
...$validated,
'source' => $validated['source'] ?? 'api',
'actor_ref' => $owner,
'metadata' => $validated['metadata'] ?? null,
'assigned_counter_uuid' => $validated['assigned_counter_uuid']
?? $validated['assigned_counter_id']
?? null,
]);
} catch (\RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['data' => TicketPresenter::toArray($ticket)], 201);
}
@@ -108,6 +117,7 @@ class TicketController extends Controller
'action' => ['required', 'string', 'in:recall,start,hold,skip,complete,no_show,cancel,delay,transfer'],
'minutes' => ['nullable', 'integer', 'min:5', 'max:120'],
'to_queue_id' => ['required_if:action,transfer', 'nullable', 'uuid'],
'assigned_counter_id' => ['nullable', 'uuid'],
'reason' => ['nullable', 'string', 'max:500'],
]);
@@ -119,9 +129,25 @@ class TicketController extends Controller
);
$toQueue = ServiceQueue::where('uuid', $validated['to_queue_id'])->firstOrFail();
$this->authorizeOwner($request, $toQueue);
$result = $this->engine->transfer($ticket, $toQueue, null, $validated['reason'] ?? null, $actor);
$assignTo = null;
if (! empty($validated['assigned_counter_id'])) {
$assignTo = \App\Models\Counter::where('uuid', $validated['assigned_counter_id'])->firstOrFail();
$this->authorizeOwner($request, $assignTo);
}
try {
$result = $this->engine->transfer(
$ticket,
$toQueue,
null,
$validated['reason'] ?? null,
$actor,
$assignTo,
);
} catch (\RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter']))]);
return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter', 'assignedCounter']))]);
}
$result = match ($validated['action']) {
+10 -1
View File
@@ -19,7 +19,8 @@ class Counter extends Model
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'department_id',
'name', 'code', 'status', 'settings', 'is_active',
'name', 'code', 'destination', 'staff_ref', 'staff_display_name',
'status', 'settings', 'is_active',
];
protected function casts(): array
@@ -30,6 +31,14 @@ class Counter extends Model
];
}
/** Public display label (Room 4, Counter 2, etc.). Falls back to name. */
public function displayDestination(): string
{
$destination = trim((string) ($this->destination ?? ''));
return $destination !== '' ? $destination : (string) $this->name;
}
protected static function booted(): void
{
static::creating(function (self $counter) {
+11 -1
View File
@@ -14,7 +14,7 @@ class Department extends Model
protected $table = 'queue_departments';
protected $fillable = [
'owner_ref', 'branch_id', 'name', 'type', 'is_active',
'owner_ref', 'branch_id', 'name', 'type', 'external_key', 'is_active',
];
protected function casts(): array
@@ -26,4 +26,14 @@ class Department extends Model
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function counters(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Counter::class, 'department_id');
}
public function serviceQueues(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(ServiceQueue::class, 'department_id');
}
}
+16
View File
@@ -67,4 +67,20 @@ class ServiceQueue extends Model
return $this->belongsToMany(Counter::class, 'queue_counter_service_queue', 'service_queue_id', 'counter_id')
->withPivot('priority');
}
/**
* shared_pool any linked service point may pull unassigned waiting tickets.
* assigned_only call-next only returns tickets pre-assigned to that service point.
*/
public function routingMode(): string
{
$mode = (string) data_get($this->settings, 'routing_mode', 'shared_pool');
return in_array($mode, ['shared_pool', 'assigned_only'], true) ? $mode : 'shared_pool';
}
public function usesAssignedOnlyRouting(): bool
{
return $this->routingMode() === 'assigned_only';
}
}
+7
View File
@@ -18,6 +18,7 @@ class Ticket extends Model
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'service_queue_id', 'counter_id',
'assigned_counter_id',
'ticket_number', 'status', 'priority', 'position', 'customer_name', 'customer_phone',
'customer_email', 'source', 'qr_token', 'barcode', 'estimated_wait_seconds',
'issued_at', 'called_at', 'serving_started_at', 'completed_at', 'metadata',
@@ -62,6 +63,12 @@ class Ticket extends Model
return $this->belongsTo(Counter::class, 'counter_id');
}
/** Service point this ticket is routed to (may differ from serving counter until called). */
public function assignedCounter(): BelongsTo
{
return $this->belongsTo(Counter::class, 'assigned_counter_id');
}
public function events(): HasMany
{
return $this->hasMany(TicketEvent::class, 'ticket_id');
+18 -8
View File
@@ -34,25 +34,35 @@ class DisplayService
->orderBy('name')
->get();
// One active ticket per destination (counter). Older called/serving
// One active ticket per destination (service point / counter). Older called/serving
// tickets at the same window must not crowd the public board.
$nowServing = Ticket::query()
->whereIn('service_queue_id', $queueIds)
->whereIn('status', ['called', 'serving'])
->with(['counter', 'serviceQueue'])
->with(['counter', 'assignedCounter', 'serviceQueue'])
->orderByDesc('called_at')
->limit(48)
->get()
->unique(fn (Ticket $t) => $t->counter_id
? 'counter:'.$t->counter_id
: 'queue:'.$t->service_queue_id)
: ($t->assigned_counter_id
? 'assigned:'.$t->assigned_counter_id
: 'queue:'.$t->service_queue_id))
->take(12)
->values()
->map(fn (Ticket $t) => [
'ticket_number' => $t->ticket_number,
'queue_name' => $t->serviceQueue?->name,
'counter' => $t->counter?->name,
])
->map(function (Ticket $t) {
$point = $t->counter ?? $t->assignedCounter;
return [
'ticket_number' => $t->ticket_number,
'queue_name' => $t->serviceQueue?->name,
'department' => $t->serviceQueue?->name,
'counter' => $point?->displayDestination() ?? $point?->name,
'destination' => $point?->displayDestination(),
'staff_display_name' => $point?->staff_display_name,
'announcement_text' => TicketPresenter::announcementText($t, $point),
];
})
->all();
$waiting = Ticket::query()
+87 -14
View File
@@ -56,11 +56,14 @@ class QueueEngine
->count();
$estimatedWait = $waitingCount * (int) $queue->avg_service_seconds;
$assignedCounterId = $this->resolveAssignedCounterId($queue, $attributes);
$ticket = Ticket::create([
'owner_ref' => $ownerRef,
'organization_id' => $queue->organization_id,
'branch_id' => $queue->branch_id,
'service_queue_id' => $queue->id,
'assigned_counter_id' => $assignedCounterId,
'ticket_number' => $number,
'status' => 'waiting',
'priority' => $attributes['priority'] ?? 'walk_in',
@@ -75,7 +78,9 @@ class QueueEngine
'metadata' => $attributes['metadata'] ?? null,
]);
$this->recordEvent($ticket, 'issued', $attributes['actor_ref'] ?? null);
$this->recordEvent($ticket, 'issued', $attributes['actor_ref'] ?? null, null, [
'assigned_counter_id' => $assignedCounterId,
]);
AuditLogger::record(
$ownerRef,
@@ -87,7 +92,7 @@ class QueueEngine
['ticket_number' => $number, 'queue' => $queue->name],
);
$ticket = $ticket->fresh(['serviceQueue', 'branch']);
$ticket = $ticket->fresh(['serviceQueue', 'branch', 'assignedCounter']);
app(QueueNotificationService::class)->ticketIssued($ticket);
return $ticket;
@@ -96,7 +101,7 @@ class QueueEngine
public function callNext(ServiceQueue $queue, Counter $counter, ?string $actorRef = null): ?Ticket
{
$ticket = $this->nextWaitingTicket($queue);
$ticket = $this->nextWaitingTicket($queue, $counter);
if (! $ticket) {
return null;
}
@@ -106,9 +111,14 @@ class QueueEngine
public function callTicket(Ticket $ticket, Counter $counter, ?string $actorRef = null): Ticket
{
if ($ticket->assigned_counter_id && (int) $ticket->assigned_counter_id !== (int) $counter->id) {
throw new \RuntimeException('Ticket is assigned to another service point.');
}
$ticket->update([
'status' => 'called',
'counter_id' => $counter->id,
'assigned_counter_id' => $ticket->assigned_counter_id ?: $counter->id,
'called_at' => now(),
]);
@@ -229,8 +239,9 @@ class QueueEngine
?Counter $counter = null,
?string $reason = null,
?string $actorRef = null,
?Counter $assignToCounter = null,
): Ticket {
return DB::transaction(function () use ($ticket, $toQueue, $counter, $reason, $actorRef) {
return DB::transaction(function () use ($ticket, $toQueue, $counter, $reason, $actorRef, $assignToCounter) {
$ticket = Ticket::query()->lockForUpdate()->findOrFail($ticket->id);
$toQueue = ServiceQueue::query()->lockForUpdate()->findOrFail($toQueue->id);
@@ -256,6 +267,16 @@ class QueueEngine
}
}
$assignedCounterId = null;
if ($assignToCounter) {
if ((int) $assignToCounter->organization_id !== (int) $toQueue->organization_id) {
throw new \RuntimeException('Assigned service point is outside this organization.');
}
$assignedCounterId = $assignToCounter->id;
} elseif ($toQueue->usesAssignedOnlyRouting()) {
throw new \RuntimeException('Target queue requires an assigned service point.');
}
$fromQueueId = $ticket->service_queue_id;
$fromCounterId = $counter?->id ?? $ticket->counter_id;
$ticketNumber = $ticket->ticket_number;
@@ -274,6 +295,7 @@ class QueueEngine
'position' => $waitingCount + 1,
'estimated_wait_seconds' => $estimatedWait,
'counter_id' => null,
'assigned_counter_id' => $assignedCounterId,
'called_at' => null,
'serving_started_at' => null,
'completed_at' => null,
@@ -355,26 +377,77 @@ class QueueEngine
/**
* @return list<Ticket>
*/
public function waitingTickets(ServiceQueue $queue, int $limit = 50): array
public function waitingTickets(ServiceQueue $queue, int $limit = 50, ?Counter $forCounter = null): array
{
return Ticket::query()
->where('service_queue_id', $queue->id)
->where('status', 'waiting')
->orderByRaw($this->priorityOrderSql())
->orderBy('issued_at')
return $this->waitingTicketQuery($queue, $forCounter)
->limit($limit)
->get()
->all();
}
protected function nextWaitingTicket(ServiceQueue $queue): ?Ticket
protected function nextWaitingTicket(ServiceQueue $queue, ?Counter $counter = null): ?Ticket
{
return Ticket::query()
return $this->waitingTicketQuery($queue, $counter)->first();
}
/**
* Tickets visible/callable for a service point:
* - assigned_only: only tickets assigned to this counter
* - shared_pool: unassigned tickets, or tickets assigned to this counter
* - no counter: all waiting (admin/list views)
*/
protected function waitingTicketQuery(ServiceQueue $queue, ?Counter $counter = null)
{
$query = Ticket::query()
->where('service_queue_id', $queue->id)
->where('status', 'waiting')
->orderByRaw($this->priorityOrderSql())
->orderBy('issued_at')
->first();
->orderBy('issued_at');
if (! $counter) {
return $query;
}
if ($queue->usesAssignedOnlyRouting()) {
return $query->where('assigned_counter_id', $counter->id);
}
return $query->where(function ($q) use ($counter) {
$q->whereNull('assigned_counter_id')
->orWhere('assigned_counter_id', $counter->id);
});
}
/**
* @param array<string, mixed> $attributes
*/
protected function resolveAssignedCounterId(ServiceQueue $queue, array $attributes): ?int
{
$counter = null;
if (! empty($attributes['assigned_counter_id'])) {
$value = $attributes['assigned_counter_id'];
$counter = is_numeric($value)
? Counter::query()->find((int) $value)
: Counter::query()->where('uuid', (string) $value)->first();
} elseif (! empty($attributes['assigned_counter_uuid'])) {
$counter = Counter::query()->where('uuid', (string) $attributes['assigned_counter_uuid'])->first();
}
if (! $counter) {
if ($queue->usesAssignedOnlyRouting()) {
throw new \RuntimeException('This queue requires an assigned service point.');
}
return null;
}
if ((int) $counter->organization_id !== (int) $queue->organization_id
|| (string) $counter->owner_ref !== (string) $queue->owner_ref) {
throw new \RuntimeException('Assigned service point is outside this organization.');
}
return (int) $counter->id;
}
protected function priorityOrderSql(): string
+43 -4
View File
@@ -11,6 +11,12 @@ class TicketPresenter
*/
public static function toArray(Ticket $ticket, bool $includeQr = true): array
{
$ticket->loadMissing(['serviceQueue', 'counter', 'assignedCounter']);
$serving = $ticket->counter;
$assigned = $ticket->assignedCounter;
$point = $serving ?? $assigned;
return [
'uuid' => $ticket->uuid,
'ticket_number' => $ticket->ticket_number,
@@ -28,13 +34,46 @@ class TicketPresenter
'name' => $ticket->serviceQueue->name,
'prefix' => $ticket->serviceQueue->prefix,
'color' => $ticket->serviceQueue->color,
'routing_mode' => $ticket->serviceQueue->routingMode(),
] : null,
'counter' => $ticket->counter ? [
'uuid' => $ticket->counter->uuid,
'name' => $ticket->counter->name,
] : null,
'counter' => $serving ? self::serializePoint($serving) : null,
'assigned_counter' => $assigned ? self::serializePoint($assigned) : null,
'destination' => $point?->displayDestination(),
'staff_display_name' => $point?->staff_display_name,
'announcement_text' => self::announcementText($ticket, $point),
'track_url' => $includeQr ? route('qms.mobile.show', $ticket->qr_token) : null,
'barcode' => $ticket->barcode,
];
}
/**
* @return array<string, mixed>
*/
public static function serializePoint(\App\Models\Counter $counter): array
{
return [
'uuid' => $counter->uuid,
'name' => $counter->name,
'destination' => $counter->displayDestination(),
'staff_ref' => $counter->staff_ref,
'staff_display_name' => $counter->staff_display_name,
'code' => $counter->code,
];
}
public static function announcementText(Ticket $ticket, ?\App\Models\Counter $point): ?string
{
if (! $point) {
return $ticket->ticket_number;
}
$parts = array_values(array_filter([
$ticket->ticket_number,
$point->staff_display_name ?: null,
$ticket->serviceQueue?->name,
$point->displayDestination(),
]));
return implode(', ', $parts);
}
}
+16 -1
View File
@@ -31,10 +31,25 @@ class VoiceAnnouncementService
{
$templates = config('qms.announcement_templates');
$template = $templates[$locale] ?? $templates['en'];
$destination = $counter->displayDestination();
$staff = trim((string) ($counter->staff_display_name ?? ''));
$queueName = trim((string) ($ticket->serviceQueue?->name ?? ''));
// Prefer rich healthcare-style copy when staff is known:
// "A005, Dr. Mensah, Consultation Room 4."
if ($staff !== '') {
$parts = array_values(array_filter([
$ticket->ticket_number,
$staff,
$queueName !== '' ? $queueName.' '.$destination : $destination,
]));
return implode(', ', $parts).'.';
}
return str_replace(
[':ticket', ':counter'],
[$ticket->ticket_number, $counter->name],
[$ticket->ticket_number, $destination],
$template,
);
}