middleware('auth'); } public function index() { $this->authorizeStatsAccess(); return view('stats.show'); } public function downloadPdf(Request $request) { $this->authorizeStatsAccess(); $filter = StatsFilter::fromArray([ 'periodFrom' => $request->get('fromDate'), 'periodTill' => $request->get('toDate'), 'countryIds' => $this->intArray($request->get('countryIds')), 'divisionIds' => $this->intArray($request->get('divisionIds')), 'cityIds' => $this->intArray($request->get('cityIds')), 'districtIds' => $this->intArray($request->get('districtIds')), 'registeredFrom' => $request->get('registeredFrom'), 'registeredTill' => $request->get('registeredTill'), 'counterparties' => $this->counterparties($request->get('counterparties')), 'allTime' => $request->boolean('allTime'), ]); if ($filter->isEmpty()) { abort(400, 'No filters selected'); } $stats = (new StatsService($filter))->all(); // Read before pullChartImages(), which clears the session keys. $networkSettings = session('stats_network_settings'); $networkLegend = session('stats_network_legend'); $networkSubtext = session('stats_network_subtext'); $charts = $this->pullChartImages($request->boolean('with_charts')); $subtitle = $filter->periodFrom && $filter->periodTill ? $filter->periodFrom->format('d-m-Y') . ' - ' . $filter->periodTill->format('d-m-Y') : __('All time'); // Every applied filter, named, so a printed report is self-describing: // away from the screen there is nothing else to say what it covers. $appliedFilters = $this->describeFilters($filter); $pdf = Pdf::loadView('stats.pdf', [ 'title' => [ 'header' => __('Platform statistics'), 'sub' => $subtitle, ], 'appliedFilters' => $appliedFilters, 'networkSettings' => $networkSettings, 'networkLegend' => $networkLegend, 'networkSubtext' => $networkSubtext, // Counterparty-scoped selections describe trading partners, not // the platform's profiles at large, so the labels change with it. 'partnerScoped' => !empty($filter->counterparties), 'stats' => $stats, 'charts' => $charts, 'decimalFormat' => $request->boolean('decimal'), 'anonymize' => $request->boolean('anonymize'), 'generatedAt' => Carbon::now()->format('d-m-Y H:i'), ])->setPaper('A4', 'portrait') ->setOptions([ 'isHtml5ParserEnabled' => true, 'isRemoteEnabled' => false, 'defaultFont' => 'DejaVu Sans', 'dpi' => 150, 'defaultPaperSize' => 'A4', 'chroot' => public_path(), ]); return $pdf->download('platform-stats-' . now()->format('Y-m-d') . '.pdf'); } /** * Admin and Bank profiles holding the stats permission. */ protected function authorizeStatsAccess(): void { $profile = getActiveProfile(); if (!$profile) { abort(403, __('No active profile')); } ProfileAuthorizationHelper::authorize($profile); if (!($profile instanceof \App\Models\Admin) && !($profile instanceof \App\Models\Bank)) { abort(403, __('Admin or bank access required')); } if (!Gate::allows('access stats') && !Gate::allows('org stats')) { Log::warning('StatsController: permission denied', [ 'profile_type' => get_class($profile), 'profile_id' => $profile->id, 'ip_address' => request()->ip(), ]); abort(403, __('You do not have permission to view statistics.')); } } /** * Read chart PNGs written by the Livewire component, inline them, and clean up. */ private function pullChartImages(bool $withCharts): array { $charts = []; if (!$withCharts || !session()->has('stats_chart_image_ids')) { return $charts; } foreach (session('stats_chart_image_ids', []) as $name => $id) { $path = storage_path('app/temp/' . $id . '.png'); if (!file_exists($path)) { Log::warning('Stats chart image temp file missing', ['path' => $path]); continue; } $charts[$name] = 'data:image/png;base64,' . base64_encode(file_get_contents($path)); unlink($path); } session()->forget('stats_chart_image_ids'); session()->forget('stats_network_settings'); session()->forget('stats_network_legend'); session()->forget('stats_network_subtext'); return $charts; } /** * Human-readable list of the filters behind this report. * * The on-screen page shows the same summary beside every section; a PDF is * read away from it, so the header has to carry the whole picture — * including which profiles were filtered on, by name. * * @return array */ private function describeFilters(StatsFilter $filter): array { $summary = []; $summary[] = [ 'label' => __('Period'), 'value' => $filter->periodFrom && $filter->periodTill ? $filter->periodFrom->format('d-m-Y') . ' – ' . $filter->periodTill->format('d-m-Y') : __('All time'), ]; if ($names = $this->locationNames($filter)) { $summary[] = ['label' => __('Location'), 'value' => $names]; } if ($filter->registeredFrom || $filter->registeredTill) { $from = $filter->registeredFrom?->format('d-m-Y'); $till = $filter->registeredTill?->format('d-m-Y'); $summary[] = [ 'label' => __('Registered'), 'value' => match (true) { $from && $till => $from . ' – ' . $till, (bool) $from => __('from :date', ['date' => $from]), default => __('until :date', ['date' => $till]), }, ]; } if ($filter->notLoggedInSince) { $summary[] = [ 'label' => __('Not logged in since'), 'value' => $filter->notLoggedInSince->format('d-m-Y'), ]; } if ($filter->noTransactionSince) { $summary[] = [ 'label' => __('No transaction since'), 'value' => $filter->noTransactionSince->format('d-m-Y'), ]; } if (!empty($filter->counterparties)) { $summary[] = [ 'label' => __('Transactions with'), // Named even when anonymising. These profiles are the subject // of the filter, not data the report is disclosing: without // them the header cannot say what it covers. Anonymisation // still applies to the most-active table, where the names // would be a disclosure rather than a heading. 'value' => $this->counterpartyNames($filter), ]; } return $summary; } /** Deepest selected location level wins, translated into the active locale. */ private function locationNames(StatsFilter $filter): string { $levels = [ [\App\Models\Locations\District::class, $filter->districtIds], [\App\Models\Locations\City::class, $filter->cityIds], [\App\Models\Locations\Division::class, $filter->divisionIds], [\App\Models\Locations\Country::class, $filter->countryIds], ]; foreach ($levels as [$model, $ids]) { if (empty($ids)) { continue; } return $model::whereIn('id', $ids)->with('translations')->get() ->map(fn($row) => $row->translations->firstWhere('locale', app()->getLocale())?->name ?? $row->translations->first()?->name) ->filter() ->implode(', '); } return ''; } private function counterpartyNames(StatsFilter $filter): string { return collect($filter->counterparties) ->groupBy('type') ->flatMap(fn($group, $type) => $type::whereIn('id', collect($group)->pluck('id'))->pluck('name')) ->implode(', '); } private function intArray($value): array { if (empty($value)) { return []; } $value = is_array($value) ? $value : explode(',', $value); return array_values(array_filter(array_map('intval', $value))); } /** * "Type:id,Type:id" pairs, validated against the known profile classes. */ private function counterparties($value): array { if (empty($value)) { return []; } $pairs = is_array($value) ? $value : explode(',', $value); $out = []; foreach ($pairs as $pair) { [$type, $id] = array_pad(explode(':', (string) $pair, 2), 2, null); if (!$id || !in_array($type, StatsFilter::PROFILE_TYPES, true)) { continue; } $out[] = ['type' => $type, 'id' => (int) $id]; } return $out; } }