Deploy Ladill Queue / deploy (push) Successful in 46s
Pharmacy/lab/billing now use a shared waiting pool claimed in order on Call next. A counter cannot stack another called ticket while one is already called or being served, so pharmacists no longer pull randomly or multiple patients at once. Co-authored-by: Cursor <cursoragent@cursor.com>
539 lines
20 KiB
PHP
539 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Qms;
|
|
|
|
use App\Models\Counter;
|
|
use App\Models\Organization;
|
|
use App\Models\ServiceQueue;
|
|
use App\Models\Ticket;
|
|
use App\Models\TicketEvent;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
|
|
class QueueEngine
|
|
{
|
|
public function issueTicket(
|
|
ServiceQueue $queue,
|
|
string $ownerRef,
|
|
array $attributes = [],
|
|
): Ticket {
|
|
return DB::transaction(function () use ($queue, $ownerRef, $attributes) {
|
|
$queue = ServiceQueue::query()->lockForUpdate()->findOrFail($queue->id);
|
|
|
|
if ($queue->is_paused || ! $queue->is_active) {
|
|
throw new \RuntimeException('Queue is not accepting tickets.');
|
|
}
|
|
|
|
$sourceQueueId = $queue->id;
|
|
[$queue, $attributes] = app(QueueRuleService::class)->apply($queue, $attributes);
|
|
app(WorkflowService::class)->attachOnIssue($queue, $attributes);
|
|
|
|
if ($queue->id !== $sourceQueueId) {
|
|
$queue = ServiceQueue::query()->lockForUpdate()->findOrFail($queue->id);
|
|
}
|
|
|
|
if ($queue->is_paused || ! $queue->is_active) {
|
|
throw new \RuntimeException('Queue is not accepting tickets.');
|
|
}
|
|
|
|
if ($queue->max_capacity) {
|
|
$waiting = Ticket::query()
|
|
->where('service_queue_id', $queue->id)
|
|
->whereIn('status', ['waiting', 'called', 'serving', 'on_hold'])
|
|
->count();
|
|
if ($waiting >= $queue->max_capacity) {
|
|
throw new \RuntimeException('Queue has reached maximum capacity.');
|
|
}
|
|
}
|
|
|
|
$queue->increment('ticket_sequence');
|
|
$queue->refresh();
|
|
|
|
$number = sprintf('%s%03d', $queue->prefix, $queue->ticket_sequence);
|
|
$waitingCount = Ticket::query()
|
|
->where('service_queue_id', $queue->id)
|
|
->where('status', 'waiting')
|
|
->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',
|
|
'position' => $waitingCount + 1,
|
|
'customer_name' => $attributes['customer_name'] ?? null,
|
|
'customer_phone' => $attributes['customer_phone'] ?? null,
|
|
'customer_email' => $attributes['customer_email'] ?? null,
|
|
'source' => $attributes['source'] ?? 'kiosk',
|
|
'barcode' => $attributes['barcode'] ?? Str::upper(Str::random(10)),
|
|
'estimated_wait_seconds' => $estimatedWait,
|
|
'issued_at' => now(),
|
|
'metadata' => $attributes['metadata'] ?? null,
|
|
]);
|
|
|
|
$this->recordEvent($ticket, 'issued', $attributes['actor_ref'] ?? null, null, [
|
|
'assigned_counter_id' => $assignedCounterId,
|
|
]);
|
|
|
|
AuditLogger::record(
|
|
$ownerRef,
|
|
'ticket.issued',
|
|
$queue->organization_id,
|
|
$attributes['actor_ref'] ?? null,
|
|
Ticket::class,
|
|
$ticket->id,
|
|
['ticket_number' => $number, 'queue' => $queue->name],
|
|
);
|
|
|
|
$ticket = $ticket->fresh(['serviceQueue', 'branch', 'assignedCounter']);
|
|
app(QueueNotificationService::class)->ticketIssued($ticket);
|
|
|
|
return $ticket;
|
|
});
|
|
}
|
|
|
|
public function callNext(ServiceQueue $queue, Counter $counter, ?string $actorRef = null): ?Ticket
|
|
{
|
|
// One patient at a time per service point: finish serving before calling another.
|
|
$activeServing = Ticket::query()
|
|
->where('service_queue_id', $queue->id)
|
|
->where('counter_id', $counter->id)
|
|
->where('status', 'serving')
|
|
->exists();
|
|
if ($activeServing) {
|
|
return null;
|
|
}
|
|
|
|
// Already called someone at this counter — re-announce instead of stacking another patient.
|
|
$alreadyCalled = Ticket::query()
|
|
->where('service_queue_id', $queue->id)
|
|
->where('counter_id', $counter->id)
|
|
->where('status', 'called')
|
|
->orderByDesc('called_at')
|
|
->first();
|
|
if ($alreadyCalled) {
|
|
return $this->recall($alreadyCalled, $actorRef);
|
|
}
|
|
|
|
$ticket = $this->nextWaitingTicket($queue, $counter);
|
|
if (! $ticket) {
|
|
return null;
|
|
}
|
|
|
|
return $this->callTicket($ticket, $counter, $actorRef);
|
|
}
|
|
|
|
public function callTicket(Ticket $ticket, Counter $counter, ?string $actorRef = null): Ticket
|
|
{
|
|
$ticket->loadMissing('serviceQueue');
|
|
$queue = $ticket->serviceQueue;
|
|
if ($queue?->usesAssignedOnlyRouting()
|
|
&& $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' => $counter->id,
|
|
'called_at' => now(),
|
|
]);
|
|
|
|
$counter->update(['status' => 'busy']);
|
|
$this->recordEvent($ticket, 'called', $actorRef, $counter->id);
|
|
$this->audit($ticket, 'ticket.called', $actorRef, ['counter_id' => $counter->id]);
|
|
|
|
app(VoiceAnnouncementService::class)->announceCalled($ticket, $counter);
|
|
|
|
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
|
|
app(QueueNotificationService::class)->ticketCalled($ticket);
|
|
$this->notifyWaitingPositions($ticket->serviceQueue);
|
|
|
|
return $ticket;
|
|
}
|
|
|
|
public function recall(Ticket $ticket, ?string $actorRef = null): Ticket
|
|
{
|
|
$ticket->load(['serviceQueue', 'counter']);
|
|
$this->recordEvent($ticket, 'recalled', $actorRef, $ticket->counter_id);
|
|
$this->audit($ticket, 'ticket.recalled', $actorRef);
|
|
|
|
if ($ticket->counter) {
|
|
app(VoiceAnnouncementService::class)->announceCalled($ticket, $ticket->counter);
|
|
}
|
|
|
|
return $ticket->fresh(['serviceQueue', 'counter']);
|
|
}
|
|
|
|
public function startServing(Ticket $ticket, ?string $actorRef = null): Ticket
|
|
{
|
|
$ticket->update([
|
|
'status' => 'serving',
|
|
'serving_started_at' => now(),
|
|
]);
|
|
$this->recordEvent($ticket, 'serving', $actorRef, $ticket->counter_id);
|
|
app(ServiceSessionTracker::class)->start($ticket, $actorRef);
|
|
|
|
return $ticket->fresh();
|
|
}
|
|
|
|
public function hold(Ticket $ticket, ?string $actorRef = null): Ticket
|
|
{
|
|
$ticket->update(['status' => 'on_hold']);
|
|
$this->recordEvent($ticket, 'held', $actorRef, $ticket->counter_id);
|
|
$this->audit($ticket, 'ticket.held', $actorRef);
|
|
|
|
return $ticket->fresh();
|
|
}
|
|
|
|
public function skip(Ticket $ticket, ?string $actorRef = null): Ticket
|
|
{
|
|
$ticket->update([
|
|
'status' => 'waiting',
|
|
'counter_id' => null,
|
|
'called_at' => null,
|
|
]);
|
|
$this->recordEvent($ticket, 'skipped', $actorRef);
|
|
$this->audit($ticket, 'ticket.skipped', $actorRef);
|
|
|
|
return $ticket->fresh();
|
|
}
|
|
|
|
public function completeTicket(Ticket $ticket, ?string $actorRef = null): Ticket
|
|
{
|
|
$ticket->update([
|
|
'status' => 'completed',
|
|
'completed_at' => now(),
|
|
]);
|
|
|
|
if ($ticket->counter_id) {
|
|
Counter::where('id', $ticket->counter_id)->update(['status' => 'available']);
|
|
}
|
|
|
|
$this->recordEvent($ticket, 'completed', $actorRef, $ticket->counter_id);
|
|
$this->audit($ticket, 'ticket.completed', $actorRef);
|
|
app(ServiceSessionTracker::class)->endForTicket($ticket);
|
|
|
|
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
|
|
app(QueueNotificationService::class)->ticketCompleted($ticket);
|
|
app(WorkflowService::class)->advanceAfterCompletion($ticket);
|
|
|
|
return $ticket;
|
|
}
|
|
|
|
public function markNoShow(Ticket $ticket, ?string $actorRef = null): Ticket
|
|
{
|
|
$ticket->update([
|
|
'status' => 'no_show',
|
|
'completed_at' => now(),
|
|
]);
|
|
|
|
if ($ticket->counter_id) {
|
|
Counter::where('id', $ticket->counter_id)->update(['status' => 'available']);
|
|
}
|
|
|
|
$this->recordEvent($ticket, 'no_show', $actorRef, $ticket->counter_id);
|
|
$this->audit($ticket, 'ticket.no_show', $actorRef);
|
|
|
|
$ticket = $ticket->fresh(['serviceQueue', 'counter']);
|
|
app(QueueNotificationService::class)->ticketMissed($ticket);
|
|
|
|
return $ticket;
|
|
}
|
|
|
|
public function cancel(Ticket $ticket, ?string $actorRef = null): Ticket
|
|
{
|
|
$ticket->update(['status' => 'cancelled', 'completed_at' => now()]);
|
|
$this->recordEvent($ticket, 'cancelled', $actorRef, $ticket->counter_id);
|
|
$this->audit($ticket, 'ticket.cancelled', $actorRef);
|
|
|
|
return $ticket->fresh();
|
|
}
|
|
|
|
public function transfer(
|
|
Ticket $ticket,
|
|
ServiceQueue $toQueue,
|
|
?Counter $counter = null,
|
|
?string $reason = null,
|
|
?string $actorRef = null,
|
|
?Counter $assignToCounter = null,
|
|
): Ticket {
|
|
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);
|
|
|
|
if ($toQueue->id === $ticket->service_queue_id) {
|
|
throw new \RuntimeException('Ticket is already in that queue.');
|
|
}
|
|
|
|
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.');
|
|
}
|
|
|
|
if (! $toQueue->is_active || $toQueue->is_paused) {
|
|
throw new \RuntimeException('Target queue is not accepting tickets.');
|
|
}
|
|
|
|
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.');
|
|
}
|
|
}
|
|
|
|
$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;
|
|
|
|
$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,
|
|
'assigned_counter_id' => $assignedCounterId,
|
|
'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
|
|
{
|
|
$queue->update(['is_paused' => true]);
|
|
AuditLogger::record($queue->owner_ref, 'service_queue.paused', $queue->organization_id, $actorRef, ServiceQueue::class, $queue->id);
|
|
|
|
$org = Organization::find($queue->organization_id);
|
|
if ($org) {
|
|
app(StaffNotifier::class)->queueEvent(
|
|
$org,
|
|
'Queue paused',
|
|
"{$queue->name} is no longer accepting tickets.",
|
|
route('qms.queues.show', $queue),
|
|
'pause',
|
|
);
|
|
}
|
|
|
|
return $queue->fresh();
|
|
}
|
|
|
|
public function resumeQueue(ServiceQueue $queue, ?string $actorRef = null): ServiceQueue
|
|
{
|
|
$queue->update(['is_paused' => false]);
|
|
AuditLogger::record($queue->owner_ref, 'service_queue.resumed', $queue->organization_id, $actorRef, ServiceQueue::class, $queue->id);
|
|
|
|
return $queue->fresh();
|
|
}
|
|
|
|
public function delayArrival(Ticket $ticket, int $minutes, ?string $actorRef = null): Ticket
|
|
{
|
|
$metadata = $ticket->metadata ?? [];
|
|
$metadata['delayed_until'] = now()->addMinutes($minutes)->toIso8601String();
|
|
$ticket->update(['metadata' => $metadata]);
|
|
$this->recordEvent($ticket, 'delayed', $actorRef, null, ['minutes' => $minutes]);
|
|
|
|
return $ticket->fresh();
|
|
}
|
|
|
|
/**
|
|
* @return list<Ticket>
|
|
*/
|
|
public function waitingTickets(ServiceQueue $queue, int $limit = 50, ?Counter $forCounter = null): array
|
|
{
|
|
return $this->waitingTicketQuery($queue, $forCounter)
|
|
->limit($limit)
|
|
->get()
|
|
->all();
|
|
}
|
|
|
|
protected function nextWaitingTicket(ServiceQueue $queue, ?Counter $counter = null): ?Ticket
|
|
{
|
|
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')
|
|
->orderBy('id');
|
|
|
|
if (! $counter) {
|
|
return $query;
|
|
}
|
|
|
|
if ($queue->usesAssignedOnlyRouting()) {
|
|
return $query->where('assigned_counter_id', $counter->id);
|
|
}
|
|
|
|
// Shared pool (pharmacy, bank tellers, etc.): FIFO across the whole queue.
|
|
// Prior counter assignment does not block another desk from claiming the next waiting ticket.
|
|
return $query;
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
{
|
|
$priorityOrder = array_keys(config('qms.ticket_priorities'));
|
|
|
|
if (\Illuminate\Support\Facades\DB::connection()->getDriverName() === 'sqlite') {
|
|
$cases = collect($priorityOrder)
|
|
->map(fn ($p, $i) => "WHEN priority = '{$p}' THEN {$i}")
|
|
->implode(' ');
|
|
|
|
return "CASE {$cases} ELSE 99 END";
|
|
}
|
|
|
|
return 'FIELD(priority, '.implode(',', array_map(fn ($p) => "'{$p}'", $priorityOrder)).')';
|
|
}
|
|
|
|
protected function notifyWaitingPositions(?ServiceQueue $queue): void
|
|
{
|
|
if (! $queue) {
|
|
return;
|
|
}
|
|
|
|
$notifications = app(QueueNotificationService::class);
|
|
foreach ($this->waitingTickets($queue, 20) as $position => $waitingTicket) {
|
|
$notifications->customersAhead($waitingTicket, $position);
|
|
}
|
|
}
|
|
|
|
protected function audit(Ticket $ticket, string $action, ?string $actorRef, ?array $metadata = null): void
|
|
{
|
|
AuditLogger::record(
|
|
$ticket->owner_ref,
|
|
$action,
|
|
$ticket->organization_id,
|
|
$actorRef,
|
|
Ticket::class,
|
|
$ticket->id,
|
|
$metadata,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $metadata
|
|
*/
|
|
protected function recordEvent(
|
|
Ticket $ticket,
|
|
string $event,
|
|
?string $actorRef = null,
|
|
?int $counterId = null,
|
|
?array $metadata = null,
|
|
): void {
|
|
TicketEvent::create([
|
|
'owner_ref' => $ticket->owner_ref,
|
|
'ticket_id' => $ticket->id,
|
|
'event' => $event,
|
|
'actor_ref' => $actorRef,
|
|
'counter_id' => $counterId,
|
|
'metadata' => $metadata,
|
|
'created_at' => now(),
|
|
]);
|
|
}
|
|
}
|