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 { $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 { if ($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' => $ticket->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 */ 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'); if (! $counter) { return $query; } if ($queue->usesAssignedOnlyRouting()) { return $query->where('assigned_counter_id', $counter->id); } return $query->where(function ($q) use ($counter) { $q->whereNull('assigned_counter_id') ->orWhere('assigned_counter_id', $counter->id); }); } /** * @param array $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|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(), ]); } }