Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user