Initial Ladill Queue release — enterprise QMS standalone app.
Deploy Ladill Queue / deploy (push) Successful in 56s
Deploy Ladill Queue / deploy (push) Successful in 56s
Phases 1–6: tickets, counters, displays, appointments, workflows, rules, analytics, reports, feedback, admin, device API, and Gitea deploy workflow for queue.ladill.com. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
<?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 {
|
||||
$fromQueueId = $ticket->service_queue_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,
|
||||
]);
|
||||
|
||||
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(),
|
||||
]);
|
||||
|
||||
$this->recordEvent($ticket, 'transferred', $actorRef, $counter?->id, [
|
||||
'to_queue_id' => $toQueue->id,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
$this->audit($ticket, 'ticket.transferred', $actorRef, ['to_queue' => $toQueue->name]);
|
||||
|
||||
return $ticket->fresh(['serviceQueue']);
|
||||
}
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user