Files

290 lines
9.9 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}