Files
ladill-queue/app/Services/Qms/QueueEngine.php
T
isaaccladandCursor d2f5ff11d2
Deploy Ladill Queue / deploy (push) Successful in 40s
Fix queue handoff so tickets keep their number across services.
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>
2026-07-14 21:22:01 +00:00

441 lines
16 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;
$ticket = Ticket::create([
'owner_ref' => $ownerRef,
'organization_id' => $queue->organization_id,
'branch_id' => $queue->branch_id,
'service_queue_id' => $queue->id,
'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);
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']);
app(QueueNotificationService::class)->ticketIssued($ticket);
return $ticket;
});
}
public function callNext(ServiceQueue $queue, Counter $counter, ?string $actorRef = null): ?Ticket
{
$ticket = $this->nextWaitingTicket($queue);
if (! $ticket) {
return null;
}
return $this->callTicket($ticket, $counter, $actorRef);
}
public function callTicket(Ticket $ticket, Counter $counter, ?string $actorRef = null): Ticket
{
$ticket->update([
'status' => 'called',
'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,
): Ticket {
return DB::transaction(function () use ($ticket, $toQueue, $counter, $reason, $actorRef) {
$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.');
}
}
$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
{
$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): array
{
return Ticket::query()
->where('service_queue_id', $queue->id)
->where('status', 'waiting')
->orderByRaw($this->priorityOrderSql())
->orderBy('issued_at')
->limit($limit)
->get()
->all();
}
protected function nextWaitingTicket(ServiceQueue $queue): ?Ticket
{
return Ticket::query()
->where('service_queue_id', $queue->id)
->where('status', 'waiting')
->orderByRaw($this->priorityOrderSql())
->orderBy('issued_at')
->first();
}
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(),
]);
}
}