Fix queue handoff so tickets keep their number across services.
Deploy Ladill Queue / deploy (push) Successful in 40s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']))]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
|
||||
Reference in New Issue
Block a user