Files
ladill-queue/app/Services/Qms/QueueEngine.php
T
isaaccladandCursor d91f632544
Deploy Ladill Queue / deploy (push) Successful in 46s
Fix pharmacy Call next to FIFO shared pool with one patient per counter.
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>
2026-07-17 19:06:30 +00:00

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(),
]);
}
}