diff --git a/app/Http/Controllers/Api/ConsoleApiController.php b/app/Http/Controllers/Api/ConsoleApiController.php index 67abbdf..603b515 100644 --- a/app/Http/Controllers/Api/ConsoleApiController.php +++ b/app/Http/Controllers/Api/ConsoleApiController.php @@ -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); + } } diff --git a/app/Http/Controllers/Api/TicketController.php b/app/Http/Controllers/Api/TicketController.php index c8e7f18..424294c 100644 --- a/app/Http/Controllers/Api/TicketController.php +++ b/app/Http/Controllers/Api/TicketController.php @@ -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']))]); } } diff --git a/app/Http/Controllers/Qms/ConsoleController.php b/app/Http/Controllers/Qms/ConsoleController.php index a17b318..d2de24e 100644 --- a/app/Http/Controllers/Qms/ConsoleController.php +++ b/app/Http/Controllers/Qms/ConsoleController.php @@ -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."; } } diff --git a/app/Http/Controllers/Qms/TicketController.php b/app/Http/Controllers/Qms/TicketController.php index bb93fc1..85fbf7a 100644 --- a/app/Http/Controllers/Qms/TicketController.php +++ b/app/Http/Controllers/Qms/TicketController.php @@ -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, diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 878b9ca..e39fdaa 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -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'); + } } diff --git a/app/Models/TicketTransfer.php b/app/Models/TicketTransfer.php new file mode 100644 index 0000000..b64a944 --- /dev/null +++ b/app/Models/TicketTransfer.php @@ -0,0 +1,54 @@ + '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'); + } +} diff --git a/app/Services/Qms/QueueEngine.php b/app/Services/Qms/QueueEngine.php index 42a3ba9..efaf745 100644 --- a/app/Services/Qms/QueueEngine.php +++ b/app/Services/Qms/QueueEngine.php @@ -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 diff --git a/app/Services/Qms/QueueNotificationService.php b/app/Services/Qms/QueueNotificationService.php index 928793c..a8c37d8 100644 --- a/app/Services/Qms/QueueNotificationService.php +++ b/app/Services/Qms/QueueNotificationService.php @@ -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}"; diff --git a/config/qms.php b/config/qms.php index f330e6d..7d041fe 100644 --- a/config/qms.php +++ b/config/qms.php @@ -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' => [ diff --git a/resources/views/qms/console/show.blade.php b/resources/views/qms/console/show.blade.php index bb1d0c6..2835218 100644 --- a/resources/views/qms/console/show.blade.php +++ b/resources/views/qms/console/show.blade.php @@ -14,6 +14,13 @@ + @if (session('success')) +

{{ session('success') }}

+ @endif + @if (session('error')) +

{{ session('error') }}

+ @endif + @if ($currentTicket)
@@ -47,26 +54,33 @@ @endforeach
- @if ($allQueues->count() > 1) -
+ @php + $handoffQueues = $allQueues->where('id', '!=', $currentTicket->service_queue_id)->values(); + @endphp + @if ($handoffQueues->isNotEmpty()) + @csrf -
- - +
+

Hand off to another queue

+

Sends this customer to the next service while keeping ticket {{ $currentTicket->ticket_number }}.

-
- - +
+
+ + +
+
+ + +
+
- @endif
diff --git a/resources/views/qms/tickets/show.blade.php b/resources/views/qms/tickets/show.blade.php index 1c36246..28e58ae 100644 --- a/resources/views/qms/tickets/show.blade.php +++ b/resources/views/qms/tickets/show.blade.php @@ -17,5 +17,29 @@ All tickets
+ + @if ($ticket->transfers->isNotEmpty()) +
+

Hand-off history

+

Ticket number stays {{ $ticket->ticket_number }} across each hand-off.

+ +
+ @endif diff --git a/tests/Feature/QmsQueueTest.php b/tests/Feature/QmsQueueTest.php index bbd7b09..c16cdd3 100644 --- a/tests/Feature/QmsQueueTest.php +++ b/tests/Feature/QmsQueueTest.php @@ -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();