Initial Ladill Queue release — enterprise QMS standalone app.
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:
isaacclad
2026-06-29 20:19:52 +00:00
co-authored by Cursor
commit cca98eefd2
297 changed files with 27263 additions and 0 deletions
+386
View File
@@ -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(),
]);
}
}