Visitor management app with SSO, kiosk, badges, reports, and Gitea CI deploy to frontdesk.ladill.com. Co-authored-by: Cursor <cursoragent@cursor.com>
139 lines
5.7 KiB
PHP
139 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Frontdesk;
|
|
|
|
use App\Models\AuditLog;
|
|
use App\Models\Host;
|
|
use App\Models\Organization;
|
|
use App\Models\Visit;
|
|
use App\Models\Visitor;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
|
|
class ReportService
|
|
{
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function summary(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): array
|
|
{
|
|
$visits = $this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId);
|
|
|
|
$checkedIn = (clone $visits)->whereNotNull('checked_in_at');
|
|
$durations = (clone $checkedIn)
|
|
->whereNotNull('checked_out_at')
|
|
->get(['checked_in_at', 'checked_out_at'])
|
|
->map(fn (Visit $v) => $v->checked_in_at->diffInMinutes($v->checked_out_at));
|
|
|
|
return [
|
|
'total_visits' => (clone $visits)->count(),
|
|
'checked_in' => (clone $checkedIn)->count(),
|
|
'checked_out' => (clone $visits)->where('status', Visit::STATUS_CHECKED_OUT)->count(),
|
|
'cancelled' => (clone $visits)->where('status', Visit::STATUS_CANCELLED)->count(),
|
|
'contractors' => (clone $checkedIn)->where('visitor_type', 'contractor')->count(),
|
|
'deliveries' => (clone $checkedIn)->where('visitor_type', 'delivery')->count(),
|
|
'avg_duration_minutes' => $durations->isEmpty() ? 0 : (int) round($durations->avg()),
|
|
'unique_visitors' => (clone $checkedIn)->distinct('visitor_id')->count('visitor_id'),
|
|
];
|
|
}
|
|
|
|
/** @return array<int, array{hour: int, count: int}> */
|
|
public function peakHours(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): array
|
|
{
|
|
$counts = array_fill(0, 24, 0);
|
|
|
|
$this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId)
|
|
->whereNotNull('checked_in_at')
|
|
->pluck('checked_in_at')
|
|
->each(function ($checkedInAt) use (&$counts) {
|
|
$counts[(int) Carbon::parse($checkedInAt)->format('G')]++;
|
|
});
|
|
|
|
$hours = [];
|
|
for ($h = 0; $h < 24; $h++) {
|
|
$hours[] = ['hour' => $h, 'count' => $counts[$h]];
|
|
}
|
|
|
|
return $hours;
|
|
}
|
|
|
|
/** @return Collection<int, object{department: string, count: int}> */
|
|
public function visitsByDepartment(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): Collection
|
|
{
|
|
return $this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId)
|
|
->whereNotNull('frontdesk_visits.checked_in_at')
|
|
->join('frontdesk_hosts', 'frontdesk_visits.host_id', '=', 'frontdesk_hosts.id')
|
|
->selectRaw("coalesce(frontdesk_hosts.department, 'Unassigned') as department, count(*) as total")
|
|
->groupBy('department')
|
|
->orderByDesc('total')
|
|
->get()
|
|
->map(fn ($row) => (object) ['department' => $row->department, 'count' => (int) $row->total]);
|
|
}
|
|
|
|
/** @return Collection<int, Visitor> */
|
|
public function frequentVisitors(string $ownerRef, Organization $organization, int $limit = 10): Collection
|
|
{
|
|
return Visitor::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->where('is_frequent', true)
|
|
->orderByDesc('visit_count')
|
|
->limit($limit)
|
|
->get();
|
|
}
|
|
|
|
/** @return array<string, int> */
|
|
public function securityIncidents(string $ownerRef, Organization $organization, Carbon $from, Carbon $to): array
|
|
{
|
|
$query = AuditLog::owned($ownerRef)
|
|
->where('organization_id', $organization->id)
|
|
->whereBetween('created_at', [$from, $to])
|
|
->whereIn('action', [
|
|
'watchlist.blocked_attempt',
|
|
'watchlist.flagged_checkin',
|
|
'badge.expired',
|
|
]);
|
|
|
|
return [
|
|
'blocked_attempts' => (clone $query)->where('action', 'watchlist.blocked_attempt')->count(),
|
|
'flagged_checkins' => (clone $query)->where('action', 'watchlist.flagged_checkin')->count(),
|
|
'expired_badges' => (clone $query)->where('action', 'badge.expired')->count(),
|
|
];
|
|
}
|
|
|
|
/** @return array<int, array{date: string, count: int}> */
|
|
public function dailyCounts(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): array
|
|
{
|
|
$rows = [];
|
|
|
|
$this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId)
|
|
->whereNotNull('checked_in_at')
|
|
->pluck('checked_in_at')
|
|
->each(function ($checkedInAt) use (&$rows) {
|
|
$key = Carbon::parse($checkedInAt)->toDateString();
|
|
$rows[$key] = ($rows[$key] ?? 0) + 1;
|
|
});
|
|
|
|
$days = [];
|
|
for ($date = $from->copy()->startOfDay(); $date->lte($to); $date->addDay()) {
|
|
$key = $date->toDateString();
|
|
$days[] = ['date' => $key, 'count' => (int) ($rows[$key] ?? 0)];
|
|
}
|
|
|
|
return $days;
|
|
}
|
|
|
|
protected function visitQuery(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId): Builder
|
|
{
|
|
return Visit::query()
|
|
->from('frontdesk_visits')
|
|
->where('frontdesk_visits.owner_ref', $ownerRef)
|
|
->where('frontdesk_visits.organization_id', $organizationId)
|
|
->when($branchId, fn ($q) => $q->where('frontdesk_visits.branch_id', $branchId))
|
|
->where(function ($q) use ($from, $to) {
|
|
$q->whereBetween('frontdesk_visits.checked_in_at', [$from, $to])
|
|
->orWhereBetween('frontdesk_visits.scheduled_at', [$from, $to]);
|
|
});
|
|
}
|
|
}
|