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
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace App\Services\Qms;
use App\Models\CustomerFeedback;
use App\Models\QueueAppointment;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use Illuminate\Support\Carbon;
class AnalyticsService
{
public function __construct(
protected DashboardStats $dashboard,
) {}
/**
* @return array<string, mixed>
*/
public function overview(string $ownerRef, int $organizationId, ?int $branchId = null): array
{
$stats = $this->dashboard->forOrganization($ownerRef, $organizationId, $branchId);
$today = Ticket::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereDate('issued_at', today());
$bySource = (clone $today)
->selectRaw('source, COUNT(*) as total')
->groupBy('source')
->pluck('total', 'source')
->all();
$byPriority = (clone $today)
->selectRaw('priority, COUNT(*) as total')
->groupBy('priority')
->pluck('total', 'priority')
->all();
$appointmentsToday = QueueAppointment::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereDate('scheduled_at', today())
->count();
$feedbackAvg = CustomerFeedback::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->whereHas('ticket', fn ($t) => $t->where('branch_id', $branchId)))
->where('created_at', '>=', now()->subDays(30))
->avg('rating');
$queueLoad = ServiceQueue::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where('is_active', true)
->withCount(['tickets as waiting_count' => fn ($q) => $q->where('status', 'waiting')])
->orderByDesc('waiting_count')
->limit(5)
->get()
->map(fn ($q) => ['name' => $q->name, 'waiting' => $q->waiting_count])
->all();
return array_merge($stats, [
'tickets_by_source' => $bySource,
'tickets_by_priority' => $byPriority,
'appointments_today' => $appointmentsToday,
'feedback_avg_30d' => round((float) $feedbackAvg, 1),
'busiest_queues' => $queueLoad,
]);
}
/**
* @return list<array{date: string, issued: int, completed: int}>
*/
public function dailyTrend(string $ownerRef, int $organizationId, int $days = 14, ?int $branchId = null): array
{
$from = now()->subDays($days - 1)->startOfDay();
$issued = Ticket::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where('issued_at', '>=', $from)
->selectRaw('DATE(issued_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
$completed = Ticket::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where('status', 'completed')
->where('completed_at', '>=', $from)
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c', 'd');
$trend = [];
for ($i = 0; $i < $days; $i++) {
$date = $from->copy()->addDays($i)->toDateString();
$trend[] = [
'date' => $date,
'issued' => (int) ($issued[$date] ?? 0),
'completed' => (int) ($completed[$date] ?? 0),
];
}
return $trend;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Services\Qms;
use App\Models\QueueAppointment;
use App\Models\ServiceQueue;
use App\Models\Ticket;
class AppointmentService
{
public function __construct(
protected QueueEngine $engine,
protected QueueNotificationService $notifications,
) {}
public function checkIn(QueueAppointment $appointment, ?string $actorRef = null): Ticket
{
abort_unless($appointment->status === 'scheduled', 422, 'Appointment cannot be checked in.');
$queue = $appointment->serviceQueue ?? $this->defaultQueue($appointment);
abort_unless($queue, 422, 'No queue configured for this appointment.');
$ticket = $this->engine->issueTicket($queue, $appointment->owner_ref, [
'customer_name' => $appointment->customer_name,
'customer_phone' => $appointment->customer_phone,
'customer_email' => $appointment->customer_email,
'priority' => 'appointment',
'source' => 'appointment',
'actor_ref' => $actorRef,
'metadata' => ['appointment_id' => $appointment->id],
]);
$appointment->update([
'status' => 'checked_in',
'checked_in_at' => now(),
'ticket_id' => $ticket->id,
'service_queue_id' => $queue->id,
]);
AuditLogger::record($appointment->owner_ref, 'appointment.checked_in', $appointment->organization_id, $actorRef, QueueAppointment::class, $appointment->id);
$org = $appointment->organization;
if ($org) {
app(StaffNotifier::class)->queueEvent(
$org,
'Appointment checked in',
"{$appointment->customer_name} checked in for {$appointment->scheduled_at->format('H:i')}.",
route('qms.tickets.show', $ticket),
'calendar',
);
}
return $ticket;
}
protected function defaultQueue(QueueAppointment $appointment): ?ServiceQueue
{
return ServiceQueue::owned($appointment->owner_ref)
->where('branch_id', $appointment->branch_id)
->where('is_active', true)
->orderBy('name')
->first();
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Qms;
use App\Models\AuditLog;
use App\Models\Member;
class AuditLogger
{
public static function record(
string $ownerRef,
string $action,
?int $organizationId = null,
?string $actorRef = null,
?string $subjectType = null,
?int $subjectId = null,
?array $metadata = null,
): AuditLog {
return AuditLog::record(
$ownerRef,
$action,
$organizationId,
$actorRef,
$subjectType,
$subjectId,
$metadata,
);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Services\Qms;
use App\Models\Branch;
use App\Models\Ticket;
use App\Support\DatabaseTime;
class DashboardStats
{
/**
* @return array<string, int|float>
*/
public function forOrganization(string $ownerRef, int $organizationId, ?int $branchId = null): array
{
$ticketQuery = Ticket::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId));
$todayQuery = (clone $ticketQuery)->whereDate('issued_at', today());
$waiting = (clone $ticketQuery)->where('status', 'waiting')->count();
$serving = (clone $ticketQuery)->whereIn('status', ['called', 'serving'])->count();
$servedToday = (clone $todayQuery)->where('status', 'completed')->count();
$noShowsToday = (clone $todayQuery)->where('status', 'no_show')->count();
$waitDiff = DatabaseTime::diffSeconds('issued_at', 'called_at');
$avgWait = (clone $todayQuery)
->where('status', 'completed')
->whereNotNull('called_at')
->selectRaw("AVG({$waitDiff}) as avg_wait")
->value('avg_wait');
$activeBranches = Branch::owned($ownerRef)
->where('organization_id', $organizationId)
->where('is_active', true)
->when($branchId, fn ($q) => $q->where('id', $branchId))
->count();
return [
'waiting' => $waiting,
'serving' => $serving,
'served_today' => $servedToday,
'no_shows_today' => $noShowsToday,
'avg_wait_seconds' => (int) round((float) $avgWait),
'active_branches' => $activeBranches,
];
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Services\Qms;
use App\Models\Device;
use App\Models\Ticket;
use Illuminate\Support\Str;
class DeviceService
{
public function findByToken(string $token): ?Device
{
return Device::query()
->where('device_token', $token)
->first();
}
public function recordHeartbeat(Device $device): void
{
$device->update([
'status' => 'online',
'last_online_at' => now(),
]);
}
public function markStaleOffline(int $minutes = 10): int
{
return Device::query()
->where('status', 'online')
->where(function ($q) use ($minutes) {
$q->whereNull('last_online_at')
->orWhere('last_online_at', '<', now()->subMinutes($minutes));
})
->update(['status' => 'offline']);
}
public function generateToken(Device $device): string
{
$token = Str::random(48);
$device->update(['device_token' => $token]);
return $token;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Services\Qms;
use App\Models\DisplayScreen;
use App\Models\ServiceQueue;
use App\Models\Ticket;
class DisplayService
{
public function findByToken(string $token): ?DisplayScreen
{
return DisplayScreen::query()
->where('access_token', $token)
->where('is_active', true)
->first();
}
public function touch(DisplayScreen $screen): void
{
$screen->update(['last_seen_at' => now()]);
}
/**
* @return array<string, mixed>
*/
public function payload(DisplayScreen $screen): array
{
$queueIds = $screen->service_queue_ids ?? [];
$queues = ServiceQueue::query()
->whereIn('id', $queueIds)
->where('is_active', true)
->get();
$nowServing = Ticket::query()
->whereIn('service_queue_id', $queueIds)
->whereIn('status', ['called', 'serving'])
->with(['counter', 'serviceQueue'])
->orderByDesc('called_at')
->limit(5)
->get()
->map(fn (Ticket $t) => [
'ticket_number' => $t->ticket_number,
'queue_name' => $t->serviceQueue?->name,
'counter' => $t->counter?->name,
]);
$waiting = Ticket::query()
->whereIn('service_queue_id', $queueIds)
->where('status', 'waiting')
->count();
$avgWait = (int) Ticket::query()
->whereIn('service_queue_id', $queueIds)
->where('status', 'completed')
->whereDate('issued_at', today())
->whereNotNull('called_at')
->selectRaw('AVG(TIMESTAMPDIFF(SECOND, issued_at, called_at)) as avg_wait')
->value('avg_wait');
return [
'screen' => [
'name' => $screen->name,
'layout' => $screen->layout,
],
'now_serving' => $nowServing,
'waiting_count' => $waiting,
'estimated_wait_minutes' => $avgWait > 0 ? (int) ceil($avgWait / 60) : null,
'queues' => $queues->map(fn (ServiceQueue $q) => [
'name' => $q->name,
'prefix' => $q->prefix,
'is_paused' => $q->is_paused,
]),
'announcements' => app(VoiceAnnouncementService::class)->pendingForBranch((int) $screen->branch_id),
'updated_at' => now()->toIso8601String(),
];
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Services\Qms;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Str;
class OrganizationResolver
{
public function resolveForUser(User $user): ?Organization
{
$ref = $user->ownerRef();
$member = Member::where('user_ref', $ref)->first();
if ($member) {
return Organization::find($member->organization_id);
}
return Organization::owned($ref)->first();
}
public function forUser(User $user): Organization
{
return $this->resolveForUser($user)
?? throw new \RuntimeException('No organization for user.');
}
public function isOnboarded(User $user): bool
{
$organization = $this->resolveForUser($user);
return $organization !== null
&& (bool) data_get($organization->settings, 'onboarded', false);
}
public function memberFor(User $user, ?Organization $organization = null): ?Member
{
$organization ??= $this->resolveForUser($user);
if (! $organization) {
return null;
}
return Member::where('organization_id', $organization->id)
->where('user_ref', $user->ownerRef())
->first();
}
public function ensureOwnerMember(User $user, Organization $organization): Member
{
return Member::firstOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $user->ownerRef(),
],
[
'owner_ref' => $user->ownerRef(),
'role' => 'org_admin',
],
);
}
/**
* @param array<string, mixed> $data
*/
public function completeOnboarding(User $user, array $data): Organization
{
$ref = $user->ownerRef();
$organization = Organization::create([
'owner_ref' => $ref,
'name' => $data['organization_name'],
'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6),
'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'),
'settings' => [
'onboarded' => true,
'industry' => $data['industry'] ?? 'custom',
'appointment_mode' => $data['appointment_mode'] ?? 'hybrid',
],
]);
$this->ensureOwnerMember($user, $organization);
$branch = Branch::create([
'owner_ref' => $ref,
'organization_id' => $organization->id,
'name' => $data['branch_name'],
'address' => $data['branch_address'] ?? null,
'phone' => $data['branch_phone'] ?? null,
'is_active' => true,
]);
Department::create([
'owner_ref' => $ref,
'branch_id' => $branch->id,
'name' => 'General Reception',
'type' => 'reception',
'is_active' => true,
]);
AuditLogger::record($ref, 'organization.created', $organization->id, $ref, Organization::class, $organization->id);
AuditLogger::record($ref, 'branch.created', $organization->id, $ref, Branch::class, $branch->id);
return $organization;
}
public function branchScope(?Member $member): ?int
{
if ($member === null) {
return null;
}
if (in_array($member->role, ['super_admin', 'org_admin', 'supervisor'], true)) {
return null;
}
return $member->branch_id;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Services\Qms;
use App\Models\Organization;
class PlanService
{
public function plan(Organization $organization): string
{
return (string) data_get($organization->settings, 'plan', 'free');
}
public function isPro(Organization $organization): bool
{
return $this->plan($organization) === 'pro';
}
public function maxBranches(Organization $organization): ?int
{
return config('qms.plans.'.$this->plan($organization).'.max_branches');
}
public function maxQueues(Organization $organization): ?int
{
return config('qms.plans.'.$this->plan($organization).'.max_queues');
}
public function canAddBranch(Organization $organization, int $currentCount): bool
{
$max = $this->maxBranches($organization);
return $max === null || $currentCount < $max;
}
public function canAddQueue(Organization $organization, int $currentCount): bool
{
$max = $this->maxQueues($organization);
return $max === null || $currentCount < $max;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Services\Qms;
use App\Models\Member;
class QmsPermissions
{
/** @var array<string, list<string>> */
protected array $roleAbilities = [
'super_admin' => ['*'],
'org_admin' => ['*'],
'branch_manager' => [
'dashboard.view', 'queues.view', 'queues.manage', 'tickets.view', 'tickets.manage',
'counters.view', 'counters.manage', 'console.use', 'displays.view', 'displays.manage',
'appointments.view', 'appointments.manage', 'workflows.view', 'workflows.manage',
'rules.view', 'rules.manage', 'reports.view', 'reports.export', 'feedback.view',
'admin.branches.view', 'admin.branches.manage', 'admin.departments.view', 'admin.departments.manage',
'admin.members.view', 'admin.members.manage', 'settings.view', 'settings.manage', 'audit.view', 'audit.export',
],
'receptionist' => [
'dashboard.view', 'queues.view', 'tickets.view', 'tickets.issue',
'appointments.view', 'appointments.manage',
],
'queue_operator' => [
'dashboard.view', 'queues.view', 'queues.manage', 'tickets.view', 'tickets.manage',
'console.use', 'displays.view',
],
'counter_staff' => [
'dashboard.view', 'queues.view', 'tickets.view', 'console.use',
],
'department_manager' => [
'dashboard.view', 'queues.view', 'queues.manage', 'tickets.view', 'tickets.manage',
'counters.view', 'console.use', 'reports.view',
],
'security' => [
'dashboard.view', 'tickets.view',
],
'supervisor' => [
'dashboard.view', 'queues.view', 'tickets.view', 'tickets.manage',
'counters.view', 'console.use', 'reports.view', 'reports.export',
'feedback.view', 'audit.view', 'audit.export',
],
];
public function can(?Member $member, string $ability): bool
{
if ($member === null) {
return false;
}
$abilities = $this->roleAbilities[$member->role] ?? [];
if (in_array('*', $abilities, true)) {
return true;
}
return in_array($ability, $abilities, true);
}
public function isAdmin(?Member $member): bool
{
return $member !== null && in_array($member->role, ['super_admin', 'org_admin'], true);
}
}
+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(),
]);
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Services\Qms;
use App\Models\Ticket;
use App\Services\Comms\EmailService;
use App\Services\Comms\SmsService;
use Illuminate\Support\Facades\Log;
class QueueNotificationService
{
public function __construct(
protected SmsService $sms,
protected EmailService $email,
) {}
public function ticketIssued(Ticket $ticket): void
{
$this->notify($ticket, 'issued', $this->message('issued', $ticket));
}
public function ticketCalled(Ticket $ticket): void
{
$counter = $ticket->counter?->name ?? 'the counter';
$this->notify($ticket, 'called', $this->message('called', $ticket, [':counter' => $counter]));
}
public function customersAhead(Ticket $ticket, int $ahead): void
{
if ($ahead > 2) {
return;
}
$this->notify($ticket, 'almost_ready', $this->message('almost_ready', $ticket, [':ahead' => (string) $ahead]));
}
public function ticketCompleted(Ticket $ticket): void
{
$feedbackUrl = route('qms.feedback.show', $ticket->qr_token);
$this->notify($ticket, 'completed', $this->message('completed', $ticket, [':feedback_url' => $feedbackUrl]));
}
public function ticketMissed(Ticket $ticket): void
{
$this->notify($ticket, 'missed', $this->message('missed', $ticket));
}
public function appointmentReminder(\App\Models\QueueAppointment $appointment): void
{
$message = "Reminder: your appointment at {$appointment->scheduled_at->format('M j, H:i')} is coming up. Ref: {$appointment->reference}";
$this->sendToContact($appointment->customer_phone, $appointment->customer_email, $message);
}
protected function notify(Ticket $ticket, string $event, string $message): void
{
if (! data_get($ticket->serviceQueue?->settings, 'notifications_enabled', true)) {
return;
}
$this->sendToContact($ticket->customer_phone, $ticket->customer_email, $message);
Log::info('Queue notification sent', [
'event' => $event,
'ticket' => $ticket->ticket_number,
]);
}
protected function sendToContact(?string $phone, ?string $email, string $message): void
{
if ($phone) {
$this->sms->send($phone, $message);
}
if ($email) {
$this->email->send($email, 'Ladill Queue update', $message);
}
}
/**
* @param array<string, string> $replace
*/
protected function message(string $key, Ticket $ticket, array $replace = []): string
{
$template = config("qms.notification_templates.{$key}", 'Queue update for ticket :ticket.');
$replace = array_merge([
':ticket' => $ticket->ticket_number,
':queue' => $ticket->serviceQueue?->name ?? 'Queue',
], $replace);
return str_replace(array_keys($replace), array_values($replace), $template);
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace App\Services\Qms;
use App\Models\QueueRule;
use App\Models\ServiceQueue;
use App\Models\Ticket;
class QueueRuleService
{
/**
* Apply active rules and return possibly modified queue + attributes.
*
* @param array<string, mixed> $attributes
* @return array{0: ServiceQueue, 1: array<string, mixed>}
*/
public function apply(ServiceQueue $queue, array $attributes): array
{
$rules = QueueRule::owned($queue->owner_ref)
->where('service_queue_id', $queue->id)
->where('is_active', true)
->orderBy('sort_order')
->get();
foreach ($rules as $rule) {
match ($rule->rule_type) {
'priority_boost' => $this->applyPriorityBoost($rule, $attributes),
'overflow' => [$queue, $attributes] = $this->applyOverflow($queue, $rule, $attributes),
'routing' => [$queue, $attributes] = $this->applyRouting($queue, $rule, $attributes),
'capacity' => [$queue, $attributes] = $this->applyCapacity($queue, $rule, $attributes),
default => null,
};
}
return [$queue, $attributes];
}
/**
* @param array<string, mixed> $attributes
*/
protected function applyPriorityBoost(QueueRule $rule, array &$attributes): void
{
$boostTo = data_get($rule->actions, 'priority');
$when = data_get($rule->conditions, 'source');
if ($boostTo && (! $when || ($attributes['source'] ?? null) === $when)) {
$attributes['priority'] = $boostTo;
}
}
/**
* @param array<string, mixed> $attributes
* @return array{0: ServiceQueue, 1: array<string, mixed>}
*/
protected function applyOverflow(ServiceQueue $queue, QueueRule $rule, array $attributes): array
{
$threshold = (int) data_get($rule->conditions, 'waiting_threshold', 0);
$overflowQueueId = data_get($rule->actions, 'overflow_queue_id');
if (! $overflowQueueId || $threshold <= 0) {
return [$queue, $attributes];
}
if ($this->waitingCount($queue) >= $threshold) {
$overflow = $this->activeQueue($overflowQueueId);
if ($overflow) {
return [$overflow, $attributes];
}
}
return [$queue, $attributes];
}
/**
* @param array<string, mixed> $attributes
* @return array{0: ServiceQueue, 1: array<string, mixed>}
*/
protected function applyRouting(ServiceQueue $queue, QueueRule $rule, array $attributes): array
{
$targetId = data_get($rule->actions, 'target_queue_id');
if (! $targetId) {
return [$queue, $attributes];
}
$whenSource = data_get($rule->conditions, 'source');
$whenPriority = data_get($rule->conditions, 'priority');
if ($whenSource && ($attributes['source'] ?? null) !== $whenSource) {
return [$queue, $attributes];
}
if ($whenPriority && ($attributes['priority'] ?? null) !== $whenPriority) {
return [$queue, $attributes];
}
$target = $this->activeQueue((int) $targetId);
return $target ? [$target, $attributes] : [$queue, $attributes];
}
/**
* @param array<string, mixed> $attributes
* @return array{0: ServiceQueue, 1: array<string, mixed>}
*/
protected function applyCapacity(ServiceQueue $queue, QueueRule $rule, array $attributes): array
{
$maxWaiting = (int) data_get($rule->conditions, 'max_waiting', 0);
if ($maxWaiting <= 0) {
return [$queue, $attributes];
}
if ($this->waitingCount($queue) < $maxWaiting) {
return [$queue, $attributes];
}
$whenFull = data_get($rule->actions, 'when_full', 'reject');
if ($whenFull === 'reject') {
throw new \RuntimeException('Queue has reached the configured waiting limit.');
}
$overflowId = data_get($rule->actions, 'overflow_queue_id');
if ($overflowId) {
$overflow = $this->activeQueue((int) $overflowId);
if ($overflow) {
return [$overflow, $attributes];
}
}
throw new \RuntimeException('Queue has reached the configured waiting limit.');
}
protected function waitingCount(ServiceQueue $queue): int
{
return Ticket::query()
->where('service_queue_id', $queue->id)
->where('status', 'waiting')
->count();
}
protected function activeQueue(int $queueId): ?ServiceQueue
{
$target = ServiceQueue::find($queueId);
return ($target && $target->is_active && ! $target->is_paused) ? $target : null;
}
}
+166
View File
@@ -0,0 +1,166 @@
<?php
namespace App\Services\Qms;
use App\Models\Branch;
use App\Models\Counter;
use App\Models\CustomerFeedback;
use App\Models\QueueAppointment;
use App\Models\ServiceQueue;
use App\Models\ServiceSession;
use App\Models\Ticket;
use App\Support\DatabaseTime;
use Illuminate\Support\Carbon;
class ReportService
{
/**
* @return array<string, mixed>
*/
public function ticketsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$base = Ticket::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('issued_at', [$from, $to]);
$waitDiff = DatabaseTime::diffSeconds('issued_at', 'called_at');
$serviceDiff = DatabaseTime::diffSeconds('serving_started_at', 'completed_at');
return [
'total_issued' => (clone $base)->count(),
'completed' => (clone $base)->where('status', 'completed')->count(),
'no_show' => (clone $base)->where('status', 'no_show')->count(),
'cancelled' => (clone $base)->where('status', 'cancelled')->count(),
'avg_wait_seconds' => (int) round((float) (clone $base)
->where('status', 'completed')
->whereNotNull('called_at')
->selectRaw("AVG({$waitDiff}) as v")
->value('v')),
'avg_service_seconds' => (int) round((float) (clone $base)
->where('status', 'completed')
->whereNotNull('serving_started_at')
->whereNotNull('completed_at')
->selectRaw("AVG({$serviceDiff}) as v")
->value('v')),
];
}
/**
* @return array<string, mixed>
*/
public function waitTimesReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$hourExpr = DatabaseTime::hourOf('issued_at');
$waitDiff = DatabaseTime::diffSeconds('issued_at', 'called_at');
$rows = Ticket::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('issued_at', [$from, $to])
->where('status', 'completed')
->whereNotNull('called_at')
->selectRaw("{$hourExpr} as hour, AVG({$waitDiff}) as avg_wait")
->groupBy('hour')
->orderBy('hour')
->get();
$peak = $rows->sortByDesc('avg_wait')->first();
return [
'hourly' => $rows->map(fn ($r) => ['hour' => (int) $r->hour, 'avg_wait_seconds' => (int) round((float) $r->avg_wait)])->values()->all(),
'peak_hour' => $peak ? (int) $peak->hour : null,
'peak_avg_wait_seconds' => $peak ? (int) round((float) $peak->avg_wait) : 0,
];
}
/**
* @return array<string, mixed>
*/
public function countersReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$counters = Counter::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->withCount(['tickets as served_count' => function ($q) use ($from, $to) {
$q->where('status', 'completed')
->whereBetween('completed_at', [$from, $to]);
}])
->orderByDesc('served_count')
->get();
return [
'counters' => $counters->map(fn ($c) => [
'name' => $c->name,
'served' => $c->served_count,
])->all(),
];
}
/**
* @return array<string, mixed>
*/
public function appointmentsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$base = QueueAppointment::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('scheduled_at', [$from, $to]);
return [
'total' => (clone $base)->count(),
'checked_in' => (clone $base)->where('status', 'checked_in')->count(),
'completed' => (clone $base)->where('status', 'completed')->count(),
'cancelled' => (clone $base)->where('status', 'cancelled')->count(),
'no_show' => (clone $base)->where('status', 'no_show')->count(),
];
}
/**
* @return array<string, mixed>
*/
public function feedbackReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$base = CustomerFeedback::owned($ownerRef)
->where('organization_id', $organizationId)
->whereBetween('created_at', [$from, $to])
->when($branchId, function ($q) use ($branchId) {
$q->whereHas('ticket', fn ($t) => $t->where('branch_id', $branchId));
});
$count = (clone $base)->count();
$avgRating = (clone $base)->avg('rating');
return [
'responses' => $count,
'avg_rating' => $count ? round((float) $avgRating, 1) : 0,
'avg_service_quality' => round((float) (clone $base)->avg('service_quality'), 1),
'avg_wait_experience' => round((float) (clone $base)->avg('wait_experience'), 1),
'avg_staff_professionalism' => round((float) (clone $base)->avg('staff_professionalism'), 1),
];
}
/**
* @return array<string, mixed>
*/
public function serviceSessionsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$base = ServiceSession::owned($ownerRef)
->whereBetween('started_at', [$from, $to])
->whereHas('ticket', function ($q) use ($organizationId, $branchId) {
$q->where('organization_id', $organizationId);
if ($branchId) {
$q->where('branch_id', $branchId);
}
});
$completed = (clone $base)->whereNotNull('ended_at');
return [
'sessions' => (clone $base)->count(),
'completed_sessions' => (clone $completed)->count(),
'avg_duration_seconds' => (int) round((float) (clone $completed)->avg('duration_seconds')),
'total_service_seconds' => (int) (clone $completed)->sum('duration_seconds'),
];
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Services\Qms;
use App\Models\ServiceSession;
use App\Models\Ticket;
class ServiceSessionTracker
{
public function start(Ticket $ticket, ?string $staffRef = null): ServiceSession
{
return ServiceSession::create([
'owner_ref' => $ticket->owner_ref,
'ticket_id' => $ticket->id,
'counter_id' => $ticket->counter_id,
'staff_ref' => $staffRef,
'started_at' => now(),
]);
}
public function endForTicket(Ticket $ticket): ?ServiceSession
{
$session = ServiceSession::query()
->where('ticket_id', $ticket->id)
->whereNull('ended_at')
->latest('started_at')
->first();
if (! $session) {
return null;
}
$ended = now();
$session->update([
'ended_at' => $ended,
'duration_seconds' => $session->started_at->diffInSeconds($ended),
]);
return $session->fresh();
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services\Qms;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Notifications\Qms\QueueStaffNotification;
use Illuminate\Notifications\Notification;
class StaffNotifier
{
/**
* @param list<string> $roles
*/
public function notifyOrganization(
Organization $organization,
Notification $notification,
array $roles = ['org_admin', 'branch_manager', 'supervisor'],
): void {
$refs = Member::query()
->where('organization_id', $organization->id)
->whereIn('role', $roles)
->pluck('user_ref');
User::query()
->whereIn('public_id', $refs)
->each(fn (User $user) => $user->notify($notification));
}
public function queueEvent(
Organization $organization,
string $title,
string $message,
?string $url = null,
string $icon = 'bell',
): void {
$this->notifyOrganization($organization, new QueueStaffNotification($title, $message, $url, $icon));
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services\Qms;
use App\Models\Ticket;
class TicketPresenter
{
/**
* @return array<string, mixed>
*/
public static function toArray(Ticket $ticket, bool $includeQr = true): array
{
return [
'uuid' => $ticket->uuid,
'ticket_number' => $ticket->ticket_number,
'status' => $ticket->status,
'priority' => $ticket->priority,
'position' => $ticket->position,
'customer_name' => $ticket->customer_name,
'customer_phone' => $ticket->customer_phone,
'source' => $ticket->source,
'estimated_wait_seconds' => $ticket->estimated_wait_seconds,
'issued_at' => $ticket->issued_at?->toIso8601String(),
'called_at' => $ticket->called_at?->toIso8601String(),
'queue' => $ticket->serviceQueue ? [
'uuid' => $ticket->serviceQueue->uuid,
'name' => $ticket->serviceQueue->name,
'prefix' => $ticket->serviceQueue->prefix,
'color' => $ticket->serviceQueue->color,
] : null,
'counter' => $ticket->counter ? [
'uuid' => $ticket->counter->uuid,
'name' => $ticket->counter->name,
] : null,
'track_url' => $includeQr ? route('qms.mobile.show', $ticket->qr_token) : null,
'barcode' => $ticket->barcode,
];
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Services\Qms;
use App\Models\Counter;
use App\Models\Ticket;
use App\Models\VoiceAnnouncement;
use Illuminate\Support\Str;
class VoiceAnnouncementService
{
public function announceCalled(Ticket $ticket, Counter $counter, ?string $locale = null): VoiceAnnouncement
{
$locale ??= data_get($ticket->serviceQueue?->settings, 'announcement_locale', 'en');
$message = $this->buildMessage($ticket, $counter, $locale);
return VoiceAnnouncement::create([
'owner_ref' => $ticket->owner_ref,
'organization_id' => $ticket->organization_id,
'branch_id' => $ticket->branch_id,
'ticket_id' => $ticket->id,
'locale' => $locale,
'message' => $message,
'voice' => data_get($ticket->serviceQueue?->settings, 'voice', 'female'),
'volume' => (int) data_get($ticket->serviceQueue?->settings, 'announcement_volume', 80),
'repeat_count' => (int) data_get($ticket->serviceQueue?->settings, 'announcement_repeat', 1),
'status' => 'pending',
]);
}
protected function buildMessage(Ticket $ticket, Counter $counter, string $locale): string
{
$templates = config('qms.announcement_templates');
$template = $templates[$locale] ?? $templates['en'];
return str_replace(
[':ticket', ':counter'],
[$ticket->ticket_number, $counter->name],
$template,
);
}
/**
* @return list<array<string, mixed>>
*/
public function pendingForBranch(int $branchId, int $limit = 10): array
{
return VoiceAnnouncement::query()
->where('branch_id', $branchId)
->where('status', 'pending')
->orderBy('id')
->limit($limit)
->get()
->map(fn (VoiceAnnouncement $a) => [
'id' => $a->id,
'message' => $a->message,
'locale' => $a->locale,
'voice' => $a->voice,
'volume' => $a->volume,
'repeat_count' => $a->repeat_count,
])
->all();
}
public function markPlayed(VoiceAnnouncement $announcement): void
{
$announcement->update(['status' => 'played', 'played_at' => now()]);
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Services\Qms;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Models\Workflow;
use App\Models\WorkflowStep;
class WorkflowService
{
public function __construct(
protected QueueEngine $engine,
) {}
/**
* @param array<string, mixed> $attributes
*/
public function attachOnIssue(ServiceQueue $queue, array &$attributes): void
{
if (data_get($attributes, 'metadata.workflow_id')) {
return;
}
$workflow = Workflow::query()
->where('owner_ref', $queue->owner_ref)
->where('organization_id', $queue->organization_id)
->where('is_active', true)
->when($queue->branch_id, fn ($q) => $q->where(fn ($inner) => $inner->whereNull('branch_id')->orWhere('branch_id', $queue->branch_id)))
->whereHas('steps', fn ($q) => $q->where('service_queue_id', $queue->id))
->first();
if (! $workflow) {
return;
}
$firstStep = $workflow->steps()
->where('service_queue_id', $queue->id)
->orderBy('sort_order')
->first();
if (! $firstStep) {
return;
}
$metadata = $attributes['metadata'] ?? [];
$metadata['workflow_id'] = $workflow->id;
$metadata['workflow_step'] = $firstStep->sort_order;
$attributes['metadata'] = $metadata;
}
/** Advance completed ticket to the next workflow step queue, if configured. */
public function advanceAfterCompletion(Ticket $ticket): ?Ticket
{
$workflowId = data_get($ticket->metadata, 'workflow_id');
$stepOrder = (int) data_get($ticket->metadata, 'workflow_step', 0);
if (! $workflowId) {
return null;
}
$workflow = Workflow::find($workflowId);
if (! $workflow || ! $workflow->is_active) {
return null;
}
$nextStep = WorkflowStep::where('workflow_id', $workflow->id)
->where('sort_order', '>', $stepOrder)
->orderBy('sort_order')
->first();
if (! $nextStep?->service_queue_id) {
return null;
}
$toQueue = ServiceQueue::find($nextStep->service_queue_id);
if (! $toQueue) {
return null;
}
$transferred = $this->engine->transfer($ticket, $toQueue, null, 'Workflow advance', null);
$transferred->update([
'metadata' => array_merge($transferred->metadata ?? [], [
'workflow_id' => $workflow->id,
'workflow_step' => $nextStep->sort_order,
]),
]);
return $transferred;
}
/**
* @return array<int, array{name: string, queue_id: int|null, sort_order: int}>
*/
public function industryTemplate(string $template): array
{
return config("qms.workflow_templates.{$template}", config('qms.workflow_templates.custom', []));
}
}