Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

+289
View File
@@ -0,0 +1,289 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\ProfileAuthorizationHelper;
use App\Services\Stats\StatsFilter;
use App\Services\StatsService;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
class StatsController extends Controller
{
public function __construct()
{
$this->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<array{label: string, value: string}>
*/
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;
}
}