Fix queue handoff so tickets keep their number across services.
Deploy Ladill Queue / deploy (push) Successful in 40s

Handoff now frees the counter, ends the service session, and requeues
the same ticket for the next department — critical for hospital flows
like doctor → lab.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-14 21:22:01 +00:00
co-authored by Cursor
parent d938acf909
commit d2f5ff11d2
12 changed files with 333 additions and 74 deletions
@@ -87,7 +87,7 @@ class ConsoleApiController extends Controller
'call_next' => $this->handleCallNext($counter, $validated['queue_uuid'] ?? '', $actor),
'available' => tap($counter)->update(['status' => 'available']),
'offline' => tap($counter)->update(['status' => 'offline']),
default => $this->handleTicketAction($validated, $actor),
default => $this->handleTicketAction($validated, $counter, $actor),
};
if ($result instanceof Ticket) {
@@ -105,7 +105,7 @@ class ConsoleApiController extends Controller
return $this->engine->callNext($queue, $counter, $actor);
}
protected function handleTicketAction(array $validated, string $actor): Ticket|Counter|null
protected function handleTicketAction(array $validated, Counter $counter, string $actor): Ticket|Counter|null
{
$ticketUuid = $validated['ticket_uuid'] ?? '';
abort_if($ticketUuid === '', 422, 'ticket_uuid is required for this action.');
@@ -119,14 +119,28 @@ class ConsoleApiController extends Controller
'skip' => $this->engine->skip($ticket, $actor),
'complete' => $this->engine->completeTicket($ticket, $actor),
'no_show' => $this->engine->markNoShow($ticket, $actor),
'transfer' => $this->engine->transfer(
'transfer' => $this->handleTransfer(
$ticket,
ServiceQueue::where('uuid', $validated['to_queue_uuid'] ?? '')->firstOrFail(),
null,
$counter,
$validated['to_queue_uuid'] ?? '',
$validated['reason'] ?? null,
$actor,
),
default => abort(422, 'Unknown action.'),
};
}
protected function handleTransfer(
Ticket $ticket,
Counter $counter,
string $toQueueUuid,
?string $reason,
string $actor,
): Ticket {
abort_if($toQueueUuid === '', 422, 'to_queue_uuid is required for transfer.');
$toQueue = ServiceQueue::where('uuid', $toQueueUuid)->firstOrFail();
return $this->engine->transfer($ticket, $toQueue, $counter, $reason, $actor);
}
}
+10 -7
View File
@@ -101,12 +101,20 @@ class TicketController extends Controller
$actor = $this->ownerRef($request);
$validated = $request->validate([
'action' => ['required', 'string', 'in:recall,start,hold,skip,complete,no_show,cancel,delay'],
'action' => ['required', 'string', 'in:recall,start,hold,skip,complete,no_show,cancel,delay,transfer'],
'minutes' => ['nullable', 'integer', 'min:5', 'max:120'],
'to_queue_id' => ['nullable', 'uuid'],
'to_queue_id' => ['required_if:action,transfer', 'nullable', 'uuid'],
'reason' => ['nullable', 'string', 'max:500'],
]);
if ($validated['action'] === 'transfer') {
$toQueue = ServiceQueue::where('uuid', $validated['to_queue_id'])->firstOrFail();
$this->authorizeOwner($request, $toQueue);
$result = $this->engine->transfer($ticket, $toQueue, null, $validated['reason'] ?? null, $actor);
return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter']))]);
}
$result = match ($validated['action']) {
'recall' => $this->engine->recall($ticket, $actor),
'start' => $this->engine->startServing($ticket, $actor),
@@ -119,11 +127,6 @@ class TicketController extends Controller
default => $ticket,
};
if ($validated['action'] === 'transfer' && ! empty($validated['to_queue_id'])) {
$toQueue = ServiceQueue::where('uuid', $validated['to_queue_id'])->firstOrFail();
$result = $this->engine->transfer($ticket, $toQueue, null, $validated['reason'] ?? null, $actor);
}
return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter']))]);
}
}
+42 -21
View File
@@ -61,37 +61,45 @@ class ConsoleController extends Controller
'action' => ['required', 'string'],
'queue_id' => ['nullable', 'integer'],
'ticket_id' => ['nullable', 'integer'],
'to_queue_id' => ['nullable', 'integer'],
'to_queue_id' => ['required_if:action,transfer', 'nullable', 'integer'],
'reason' => ['nullable', 'string', 'max:500'],
]);
match ($validated['action']) {
'call_next' => $this->handleCallNext($counter, (int) $validated['queue_id'], $actor),
'recall' => $this->handleTicketAction($validated['ticket_id'], 'recall', $actor),
'start' => $this->handleTicketAction($validated['ticket_id'], 'start', $actor),
'hold' => $this->handleTicketAction($validated['ticket_id'], 'hold', $actor),
'skip' => $this->handleTicketAction($validated['ticket_id'], 'skip', $actor),
'complete' => $this->handleTicketAction($validated['ticket_id'], 'complete', $actor),
'no_show' => $this->handleTicketAction($validated['ticket_id'], 'no_show', $actor),
'transfer' => $this->handleTransfer($validated['ticket_id'], (int) $validated['to_queue_id'], $counter, $validated['reason'] ?? null, $actor),
'available' => $counter->update(['status' => 'available']),
'offline' => $counter->update(['status' => 'offline']),
default => null,
};
try {
$message = match ($validated['action']) {
'call_next' => $this->handleCallNext($counter, (int) $validated['queue_id'], $actor),
'recall' => $this->handleTicketAction($validated['ticket_id'], 'recall', $actor),
'start' => $this->handleTicketAction($validated['ticket_id'], 'start', $actor),
'hold' => $this->handleTicketAction($validated['ticket_id'], 'hold', $actor),
'skip' => $this->handleTicketAction($validated['ticket_id'], 'skip', $actor),
'complete' => $this->handleTicketAction($validated['ticket_id'], 'complete', $actor),
'no_show' => $this->handleTicketAction($validated['ticket_id'], 'no_show', $actor),
'transfer' => $this->handleTransfer($validated['ticket_id'], (int) $validated['to_queue_id'], $counter, $validated['reason'] ?? null, $actor),
'available' => tap(null, fn () => $counter->update(['status' => 'available'])),
'offline' => tap(null, fn () => $counter->update(['status' => 'offline'])),
default => null,
};
} catch (\RuntimeException $e) {
return back()->with('error', $e->getMessage());
}
return back();
return $message
? back()->with('success', $message)
: back();
}
protected function handleCallNext(Counter $counter, int $queueId, string $actor): void
protected function handleCallNext(Counter $counter, int $queueId, string $actor): ?string
{
$queue = ServiceQueue::findOrFail($queueId);
$this->engine->callNext($queue, $counter, $actor);
$ticket = $this->engine->callNext($queue, $counter, $actor);
return $ticket ? "Called {$ticket->ticket_number}." : 'No tickets waiting in that queue.';
}
protected function handleTicketAction(?int $ticketId, string $action, string $actor): void
protected function handleTicketAction(?int $ticketId, string $action, string $actor): ?string
{
if (! $ticketId) {
return;
return null;
}
$ticket = Ticket::findOrFail($ticketId);
match ($action) {
@@ -103,15 +111,28 @@ class ConsoleController extends Controller
'no_show' => $this->engine->markNoShow($ticket, $actor),
default => null,
};
return null;
}
protected function handleTransfer(?int $ticketId, int $toQueueId, Counter $counter, ?string $reason, string $actor): void
protected function handleTransfer(?int $ticketId, int $toQueueId, Counter $counter, ?string $reason, string $actor): ?string
{
if (! $ticketId || ! $toQueueId) {
return;
return null;
}
$ticket = Ticket::findOrFail($ticketId);
$toQueue = ServiceQueue::findOrFail($toQueueId);
if (
$toQueue->organization_id !== $counter->organization_id
|| $toQueue->branch_id !== $counter->branch_id
|| $toQueue->owner_ref !== $counter->owner_ref
) {
throw new \RuntimeException('You can only hand off to queues at this branch.');
}
$this->engine->transfer($ticket, $toQueue, $counter, $reason, $actor);
return "Handed off {$ticket->ticket_number} to {$toQueue->name}. Ticket number unchanged.";
}
}
@@ -92,7 +92,13 @@ class TicketController extends Controller
$this->authorizeAbility($request, 'tickets.view');
$this->authorizeOwner($request, $ticket);
$ticket->load(['serviceQueue', 'counter', 'events']);
$ticket->load([
'serviceQueue',
'counter',
'events',
'transfers.fromQueue',
'transfers.toQueue',
]);
return view('qms.tickets.show', [
'ticket' => $ticket,
+5
View File
@@ -66,4 +66,9 @@ class Ticket extends Model
{
return $this->hasMany(TicketEvent::class, 'ticket_id');
}
public function transfers(): HasMany
{
return $this->hasMany(TicketTransfer::class, 'ticket_id')->orderByDesc('transferred_at');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketTransfer extends Model
{
use BelongsToOwner;
protected $table = 'queue_ticket_transfers';
public $timestamps = false;
protected $fillable = [
'owner_ref',
'ticket_id',
'from_service_queue_id',
'to_service_queue_id',
'from_counter_id',
'reason',
'transferred_by',
'transferred_at',
];
protected function casts(): array
{
return [
'transferred_at' => 'datetime',
];
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
public function fromQueue(): BelongsTo
{
return $this->belongsTo(ServiceQueue::class, 'from_service_queue_id');
}
public function toQueue(): BelongsTo
{
return $this->belongsTo(ServiceQueue::class, 'to_service_queue_id');
}
public function fromCounter(): BelongsTo
{
return $this->belongsTo(Counter::class, 'from_counter_id');
}
}
+79 -25
View File
@@ -230,35 +230,89 @@ class QueueEngine
?string $reason = null,
?string $actorRef = null,
): Ticket {
$fromQueueId = $ticket->service_queue_id;
return DB::transaction(function () use ($ticket, $toQueue, $counter, $reason, $actorRef) {
$ticket = Ticket::query()->lockForUpdate()->findOrFail($ticket->id);
$toQueue = ServiceQueue::query()->lockForUpdate()->findOrFail($toQueue->id);
$ticket->update([
'service_queue_id' => $toQueue->id,
'branch_id' => $toQueue->branch_id,
'status' => 'waiting',
'counter_id' => null,
'called_at' => null,
'serving_started_at' => null,
]);
if ($toQueue->id === $ticket->service_queue_id) {
throw new \RuntimeException('Ticket is already in that queue.');
}
DB::table('queue_ticket_transfers')->insert([
'owner_ref' => $ticket->owner_ref,
'ticket_id' => $ticket->id,
'from_service_queue_id' => $fromQueueId,
'to_service_queue_id' => $toQueue->id,
'from_counter_id' => $counter?->id,
'reason' => $reason,
'transferred_by' => $actorRef,
'transferred_at' => now(),
]);
if ($ticket->organization_id !== $toQueue->organization_id || $ticket->owner_ref !== $toQueue->owner_ref) {
throw new \RuntimeException('Cannot hand off to a queue outside this organization.');
}
$this->recordEvent($ticket, 'transferred', $actorRef, $counter?->id, [
'to_queue_id' => $toQueue->id,
'reason' => $reason,
]);
$this->audit($ticket, 'ticket.transferred', $actorRef, ['to_queue' => $toQueue->name]);
if (! $toQueue->is_active || $toQueue->is_paused) {
throw new \RuntimeException('Target queue is not accepting tickets.');
}
return $ticket->fresh(['serviceQueue']);
if ($toQueue->max_capacity) {
$waiting = Ticket::query()
->where('service_queue_id', $toQueue->id)
->whereIn('status', ['waiting', 'called', 'serving', 'on_hold'])
->count();
if ($waiting >= $toQueue->max_capacity) {
throw new \RuntimeException('Target queue has reached maximum capacity.');
}
}
$fromQueueId = $ticket->service_queue_id;
$fromCounterId = $counter?->id ?? $ticket->counter_id;
$ticketNumber = $ticket->ticket_number;
$waitingCount = Ticket::query()
->where('service_queue_id', $toQueue->id)
->where('status', 'waiting')
->count();
$estimatedWait = $waitingCount * (int) $toQueue->avg_service_seconds;
// Same ticket row and number — only the queue assignment changes.
$ticket->update([
'service_queue_id' => $toQueue->id,
'branch_id' => $toQueue->branch_id,
'status' => 'waiting',
'position' => $waitingCount + 1,
'estimated_wait_seconds' => $estimatedWait,
'counter_id' => null,
'called_at' => null,
'serving_started_at' => null,
'completed_at' => null,
]);
if ($fromCounterId) {
Counter::where('id', $fromCounterId)->update(['status' => 'available']);
}
app(ServiceSessionTracker::class)->endForTicket($ticket);
DB::table('queue_ticket_transfers')->insert([
'owner_ref' => $ticket->owner_ref,
'ticket_id' => $ticket->id,
'from_service_queue_id' => $fromQueueId,
'to_service_queue_id' => $toQueue->id,
'from_counter_id' => $fromCounterId,
'reason' => $reason,
'transferred_by' => $actorRef,
'transferred_at' => now(),
]);
$this->recordEvent($ticket, 'transferred', $actorRef, $fromCounterId, [
'from_queue_id' => $fromQueueId,
'to_queue_id' => $toQueue->id,
'ticket_number' => $ticketNumber,
'reason' => $reason,
]);
$this->audit($ticket, 'ticket.transferred', $actorRef, [
'to_queue' => $toQueue->name,
'ticket_number' => $ticketNumber,
]);
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
app(QueueNotificationService::class)->ticketTransferred($ticket);
$this->notifyWaitingPositions($toQueue);
return $ticket;
});
}
public function pauseQueue(ServiceQueue $queue, ?string $actorRef = null): ServiceQueue
@@ -44,6 +44,11 @@ class QueueNotificationService
$this->notify($ticket, 'missed', $this->message('missed', $ticket));
}
public function ticketTransferred(Ticket $ticket): void
{
$this->notify($ticket, 'transferred', $this->message('transferred', $ticket));
}
public function appointmentReminder(\App\Models\QueueAppointment $appointment): void
{
$message = "Reminder: your appointment at {$appointment->scheduled_at->format('M j, H:i')} is coming up. Ref: {$appointment->reference}";
+1
View File
@@ -162,6 +162,7 @@ return [
'almost_ready' => 'Ticket :ticket — you are :ahead customer(s) away. Please head to the waiting area.',
'completed' => 'Thank you for visiting. Ticket :ticket is complete. Share feedback: :feedback_url',
'missed' => 'Ticket :ticket was marked as missed. Please rejoin the queue if you still need service.',
'transferred' => 'Ticket :ticket has been handed off to :queue. Keep your ticket — your number stays the same.',
],
'report_types' => [
+29 -15
View File
@@ -14,6 +14,13 @@
</div>
</div>
@if (session('success'))
<p class="mt-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{{ session('success') }}</p>
@endif
@if (session('error'))
<p class="mt-4 rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-800">{{ session('error') }}</p>
@endif
@if ($currentTicket)
<section class="mt-6 overflow-hidden rounded-2xl border border-indigo-200 bg-gradient-to-br from-indigo-50 via-white to-white shadow-sm">
<div class="border-b border-indigo-100 px-6 py-4">
@@ -47,26 +54,33 @@
@endforeach
</div>
@if ($allQueues->count() > 1)
<form method="POST" action="{{ route('qms.console.action', $counter) }}" class="mt-4 flex flex-wrap items-end gap-2 border-t border-indigo-100 pt-4">
@php
$handoffQueues = $allQueues->where('id', '!=', $currentTicket->service_queue_id)->values();
@endphp
@if ($handoffQueues->isNotEmpty())
<form method="POST" action="{{ route('qms.console.action', $counter) }}" class="mt-4 border-t border-indigo-100 pt-4">
@csrf
<input type="hidden" name="action" value="transfer">
<input type="hidden" name="ticket_id" value="{{ $currentTicket->id }}">
<div class="min-w-[12rem] flex-1">
<label class="block text-xs font-medium text-slate-500">Transfer to queue</label>
<select name="to_queue_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($allQueues as $q)
@if ($q->id !== $currentTicket->service_queue_id)
<option value="{{ $q->id }}">{{ $q->name }}</option>
@endif
@endforeach
</select>
<div class="mb-3">
<p class="text-sm font-medium text-slate-800">Hand off to another queue</p>
<p class="mt-1 text-xs text-slate-500">Sends this customer to the next service while keeping ticket <span class="font-mono font-semibold text-slate-700">{{ $currentTicket->ticket_number }}</span>.</p>
</div>
<div class="min-w-[12rem] flex-1">
<label class="block text-xs font-medium text-slate-500">Reason (optional)</label>
<input type="text" name="reason" placeholder="e.g. Specialist required" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<div class="flex flex-wrap items-end gap-2">
<div class="min-w-[12rem] flex-1">
<label class="block text-xs font-medium text-slate-500">Next queue</label>
<select name="to_queue_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($handoffQueues as $q)
<option value="{{ $q->id }}">{{ $q->name }} ({{ $q->prefix }})</option>
@endforeach
</select>
</div>
<div class="min-w-[12rem] flex-1">
<label class="block text-xs font-medium text-slate-500">Reason (optional)</label>
<input type="text" name="reason" placeholder="e.g. Lab tests needed" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<button type="submit" class="btn-primary text-sm">Hand off</button>
</div>
<button type="submit" class="btn-secondary text-sm">Transfer ticket</button>
</form>
@endif
</div>
@@ -17,5 +17,29 @@
<a href="{{ route('qms.tickets.index') }}" class="btn-primary">All tickets</a>
</div>
</div>
@if ($ticket->transfers->isNotEmpty())
<div class="mt-4 rounded-2xl bg-white p-6 shadow-sm">
<h2 class="text-sm font-semibold text-slate-900">Hand-off history</h2>
<p class="mt-1 text-xs text-slate-500">Ticket number stays {{ $ticket->ticket_number }} across each hand-off.</p>
<ul class="mt-4 divide-y divide-slate-100">
@foreach ($ticket->transfers as $transfer)
<li class="py-3 text-sm">
<p class="font-medium text-slate-800">
{{ $transfer->fromQueue?->name ?? 'Queue' }}
{{ $transfer->toQueue?->name ?? 'Queue' }}
</p>
<p class="mt-1 text-xs text-slate-500">
{{ $transfer->transferred_at?->format('M j, Y H:i') }}
@if ($transfer->reason)
· {{ $transfer->reason }}
@endif
</p>
</li>
@endforeach
</ul>
</div>
@endif
</div>
</x-app-layout>
+58
View File
@@ -157,6 +157,64 @@ class QmsQueueTest extends TestCase
$this->assertNotNull($session->ended_at);
}
public function test_handoff_keeps_ticket_number_and_frees_counter(): void
{
[$user, $org, $branch, $queue] = $this->setUpOrg();
$lab = ServiceQueue::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Lab',
'prefix' => 'L',
'strategy' => 'fifo',
'is_active' => true,
]);
$engine = app(QueueEngine::class);
$ticket = $engine->issueTicket($queue, $user->public_id, [
'source' => 'reception',
'customer_name' => 'Patient One',
]);
$this->assertSame('A001', $ticket->ticket_number);
$counter = \App\Models\Counter::create([
'owner_ref' => $user->public_id,
'organization_id' => $org->id,
'branch_id' => $branch->id,
'name' => 'Doctor 1',
'status' => 'available',
'is_active' => true,
]);
$engine->callTicket($ticket, $counter, $user->public_id);
$engine->startServing($ticket->fresh(), $user->public_id);
$handedOff = $engine->transfer(
$ticket->fresh(),
$lab,
$counter,
'Lab tests needed',
$user->public_id,
);
$this->assertSame('A001', $handedOff->ticket_number);
$this->assertSame($lab->id, $handedOff->service_queue_id);
$this->assertSame('waiting', $handedOff->status);
$this->assertNull($handedOff->counter_id);
$this->assertNull($handedOff->completed_at);
$this->assertSame('available', $counter->fresh()->status);
$this->assertDatabaseHas('queue_ticket_transfers', [
'ticket_id' => $ticket->id,
'from_service_queue_id' => $queue->id,
'to_service_queue_id' => $lab->id,
'reason' => 'Lab tests needed',
]);
$session = \App\Models\ServiceSession::where('ticket_id', $ticket->id)->first();
$this->assertNotNull($session);
$this->assertNotNull($session->ended_at);
}
public function test_reports_sqlite_compatible(): void
{
[$user, $org, , $queue] = $this->setUpOrg();