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,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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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', []));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user