Deploy Ladill QR Plus / deploy (push) Successful in 39s
Aggregates scan metrics across all codes with charts, breakdowns, and a sidebar nav item. Co-authored-by: Cursor <cursoragent@cursor.com>
233 lines
7.1 KiB
PHP
233 lines
7.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Qr;
|
|
|
|
use App\Models\QrCode;
|
|
use App\Models\QrScanEvent;
|
|
use App\Models\User;
|
|
use App\Support\Qr\QrTypeCatalog;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class QrAnalyticsService
|
|
{
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function summaryForAccount(User $account): array
|
|
{
|
|
$codeIds = $this->codeIdsFor($account);
|
|
|
|
if ($codeIds === []) {
|
|
return $this->emptySummary();
|
|
}
|
|
|
|
$codes = QrCode::query()->whereIn('id', $codeIds)->get(['id', 'scans_total', 'unique_scans_total', 'last_scanned_at']);
|
|
$now = now();
|
|
$events = QrScanEvent::query()->whereIn('qr_code_id', $codeIds);
|
|
|
|
return [
|
|
'total_scans' => (int) $codes->sum('scans_total'),
|
|
'unique_scans' => (int) $codes->sum('unique_scans_total'),
|
|
'scans_7d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(7))->count(),
|
|
'scans_30d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(30))->count(),
|
|
'last_scanned_at' => $codes->max('last_scanned_at'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, object{date: string, total: int}>
|
|
*/
|
|
public function dailyScansForAccount(User $account, int $days = 30): Collection
|
|
{
|
|
$codeIds = $this->codeIdsFor($account);
|
|
$start = now()->subDays($days - 1)->startOfDay();
|
|
|
|
if ($codeIds === []) {
|
|
return $this->emptyDailySeries($start, $days);
|
|
}
|
|
|
|
$rows = QrScanEvent::query()
|
|
->selectRaw('DATE(scanned_at) as scan_date, COUNT(*) as total')
|
|
->whereIn('qr_code_id', $codeIds)
|
|
->where('scanned_at', '>=', $start)
|
|
->groupBy('scan_date')
|
|
->orderBy('scan_date')
|
|
->get()
|
|
->keyBy('scan_date');
|
|
|
|
return $this->buildDailySeries($start, $days, $rows);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{label: string, total: int}>
|
|
*/
|
|
public function breakdownForAccount(User $account, string $column, int $limit = 5): array
|
|
{
|
|
$codeIds = $this->codeIdsFor($account);
|
|
|
|
if ($codeIds === []) {
|
|
return [];
|
|
}
|
|
|
|
return QrScanEvent::query()
|
|
->select($column, DB::raw('COUNT(*) as total'))
|
|
->whereIn('qr_code_id', $codeIds)
|
|
->whereNotNull($column)
|
|
->groupBy($column)
|
|
->orderByDesc('total')
|
|
->limit($limit)
|
|
->get()
|
|
->map(fn ($row) => [
|
|
'label' => (string) $row->{$column},
|
|
'total' => (int) $row->total,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return \Illuminate\Database\Eloquent\Collection<int, QrScanEvent>
|
|
*/
|
|
public function recentScansForAccount(User $account, int $limit = 20)
|
|
{
|
|
$codeIds = $this->codeIdsFor($account);
|
|
|
|
if ($codeIds === []) {
|
|
return QrScanEvent::query()->whereRaw('0 = 1')->get();
|
|
}
|
|
|
|
return QrScanEvent::query()
|
|
->whereIn('qr_code_id', $codeIds)
|
|
->with('qrCode:id,label,short_code')
|
|
->latest('scanned_at')
|
|
->limit($limit)
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @return \Illuminate\Database\Eloquent\Collection<int, QrCode>
|
|
*/
|
|
public function topCodesForAccount(User $account, int $limit = 10)
|
|
{
|
|
return $account->qrCodes()
|
|
->whereIn('type', QrTypeCatalog::plusTypes())
|
|
->orderByDesc('scans_total')
|
|
->limit($limit)
|
|
->get(['id', 'label', 'short_code', 'type', 'scans_total', 'last_scanned_at']);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function summaryFor(QrCode $qrCode): array
|
|
{
|
|
$now = now();
|
|
$events = QrScanEvent::query()->where('qr_code_id', $qrCode->id);
|
|
|
|
return [
|
|
'total_scans' => (int) $qrCode->scans_total,
|
|
'unique_scans' => (int) $qrCode->unique_scans_total,
|
|
'scans_7d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(7))->count(),
|
|
'scans_30d' => (clone $events)->where('scanned_at', '>=', $now->copy()->subDays(30))->count(),
|
|
'last_scanned_at' => $qrCode->last_scanned_at,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, object{date: string, total: int}>
|
|
*/
|
|
public function dailyScans(QrCode $qrCode, int $days = 30): Collection
|
|
{
|
|
$start = now()->subDays($days - 1)->startOfDay();
|
|
|
|
$rows = QrScanEvent::query()
|
|
->selectRaw('DATE(scanned_at) as scan_date, COUNT(*) as total')
|
|
->where('qr_code_id', $qrCode->id)
|
|
->where('scanned_at', '>=', $start)
|
|
->groupBy('scan_date')
|
|
->orderBy('scan_date')
|
|
->get()
|
|
->keyBy('scan_date');
|
|
|
|
return $this->buildDailySeries($start, $days, $rows);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{label: string, total: int}>
|
|
*/
|
|
public function breakdown(QrCode $qrCode, string $column, int $limit = 5): array
|
|
{
|
|
return QrScanEvent::query()
|
|
->select($column, DB::raw('COUNT(*) as total'))
|
|
->where('qr_code_id', $qrCode->id)
|
|
->whereNotNull($column)
|
|
->groupBy($column)
|
|
->orderByDesc('total')
|
|
->limit($limit)
|
|
->get()
|
|
->map(fn ($row) => [
|
|
'label' => (string) $row->{$column},
|
|
'total' => (int) $row->total,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return \Illuminate\Database\Eloquent\Collection<int, QrScanEvent>
|
|
*/
|
|
public function recentScans(QrCode $qrCode, int $limit = 20)
|
|
{
|
|
return QrScanEvent::query()
|
|
->where('qr_code_id', $qrCode->id)
|
|
->latest('scanned_at')
|
|
->limit($limit)
|
|
->get();
|
|
}
|
|
|
|
/** @return list<int> */
|
|
private function codeIdsFor(User $account): array
|
|
{
|
|
return $account->qrCodes()
|
|
->whereIn('type', QrTypeCatalog::plusTypes())
|
|
->pluck('id')
|
|
->all();
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function emptySummary(): array
|
|
{
|
|
return [
|
|
'total_scans' => 0,
|
|
'unique_scans' => 0,
|
|
'scans_7d' => 0,
|
|
'scans_30d' => 0,
|
|
'last_scanned_at' => null,
|
|
];
|
|
}
|
|
|
|
/** @return Collection<int, object{date: string, total: int}> */
|
|
private function emptyDailySeries(Carbon $start, int $days): Collection
|
|
{
|
|
return $this->buildDailySeries($start, $days, collect());
|
|
}
|
|
|
|
/**
|
|
* @param Collection<string, object{scan_date?: string, total?: int}> $rows
|
|
* @return Collection<int, object{date: string, total: int}>
|
|
*/
|
|
private function buildDailySeries(Carbon $start, int $days, Collection $rows): Collection
|
|
{
|
|
$series = collect();
|
|
for ($i = 0; $i < $days; $i++) {
|
|
$date = $start->copy()->addDays($i)->toDateString();
|
|
$series->push((object) [
|
|
'date' => $date,
|
|
'total' => (int) ($rows->get($date)?->total ?? 0),
|
|
]);
|
|
}
|
|
|
|
return $series;
|
|
}
|
|
}
|