feat(admin): 补全报表中心汇总 API 并恢复 report-jobs 导出

新增每日盈亏、玩家输赢、玩法维度、佣金回水四类聚合查询与权限注册,恢复报表异步导出任务;审计日志支持按操作人与日期筛选。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-22 10:08:41 +08:00
parent c1c25e3143
commit 83f2dd43db
19 changed files with 1107 additions and 14 deletions

View File

@@ -19,6 +19,9 @@ final class AuditLogIndexController extends Controller
$module = trim((string) $request->query('module_code', ''));
$action = trim((string) $request->query('action_code', ''));
$operatorType = trim((string) $request->query('operator_type', ''));
$operatorId = (int) $request->query('operator_id', 0);
$startDate = trim((string) $request->query('start_date', ''));
$endDate = trim((string) $request->query('end_date', ''));
$q = AuditLog::query()->orderByDesc('id');
@@ -31,6 +34,15 @@ final class AuditLogIndexController extends Controller
if ($operatorType !== '') {
$q->where('operator_type', $operatorType);
}
if ($operatorId > 0) {
$q->where('operator_id', $operatorId);
}
if ($startDate !== '') {
$q->whereDate('created_at', '>=', $startDate);
}
if ($endDate !== '') {
$q->whereDate('created_at', '<=', $endDate);
}
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AdminReportQueryRequest;
use App\Services\Admin\AdminReportQueryService;
use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse;
/** GET /api/v1/admin/reports/daily-profit */
final class AdminReportDailyProfitController extends Controller
{
public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse
{
$validated = $request->validated();
$p = AdminApiList::readPaging($request);
$range = $service->resolveDateRange($validated);
$paginator = $service->dailyProfitPaginated(
$range['date_from'],
$range['date_to'],
$p['page'],
$p['perPage'],
);
return AdminApiList::json($paginator, static fn (array $row): array => $row);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AdminReportQueryRequest;
use App\Services\Admin\AdminReportQueryService;
use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse;
/** GET /api/v1/admin/reports/play-dimension */
final class AdminReportPlayDimensionController extends Controller
{
public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse
{
$validated = $request->validated();
$p = AdminApiList::readPaging($request);
$range = $service->resolveDateRange($validated);
$playCode = isset($validated['play_code']) ? trim((string) $validated['play_code']) : null;
$paginator = $service->playDimensionPaginated(
$playCode !== '' ? $playCode : null,
$range['date_from'],
$range['date_to'],
$p['page'],
$p['perPage'],
);
return AdminApiList::json($paginator, static function (object $row): array {
return [
'play_code' => (string) $row->play_code,
'dimension' => (int) $row->dimension,
'total_bet_minor' => (int) $row->total_bet_minor,
'total_payout_minor' => (int) $row->total_payout_minor,
'approx_house_gross_minor' => (int) $row->approx_house_gross_minor,
];
});
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AdminReportQueryRequest;
use App\Services\Admin\AdminReportQueryService;
use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse;
/** GET /api/v1/admin/reports/player-win-loss */
final class AdminReportPlayerWinLossController extends Controller
{
public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse
{
$validated = $request->validated();
$p = AdminApiList::readPaging($request);
$range = $service->resolveDateRange($validated);
$playerId = isset($validated['player_id']) ? (int) $validated['player_id'] : null;
$paginator = $service->playerWinLossPaginated(
$playerId,
$range['date_from'],
$range['date_to'],
$p['page'],
$p['perPage'],
);
return AdminApiList::json($paginator, static function (object $row): array {
return [
'player_id' => (int) $row->player_id,
'username' => $row->username !== null ? (string) $row->username : 'player#'.(int) $row->player_id,
'total_bet_minor' => (int) $row->total_bet_minor,
'total_payout_minor' => (int) $row->total_payout_minor,
'net_win_loss_minor' => (int) $row->net_win_loss_minor,
];
});
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AdminReportQueryRequest;
use App\Services\Admin\AdminReportQueryService;
use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse;
/** GET /api/v1/admin/reports/rebate-commission */
final class AdminReportRebateCommissionController extends Controller
{
public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse
{
$validated = $request->validated();
$p = AdminApiList::readPaging($request);
$range = $service->resolveDateRange($validated);
$playCode = isset($validated['play_code']) ? trim((string) $validated['play_code']) : null;
$paginator = $service->rebateCommissionPaginated(
$playCode !== '' ? $playCode : null,
$range['date_from'],
$range['date_to'],
$p['page'],
$p['perPage'],
);
return AdminApiList::json($paginator, static function (object $row): array {
return [
'play_code' => (string) $row->play_code,
'total_rebate_minor' => (int) $row->total_rebate_minor,
'order_count' => (int) $row->order_count,
'ticket_item_count' => (int) $row->ticket_item_count,
];
});
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Models\ReportJob;
use App\Services\Admin\AdminReportJobService;
use App\Services\Admin\AdminReportQueryService;
use Symfony\Component\HttpFoundation\StreamedResponse;
/** GET /api/v1/admin/report-jobs/{report_job}/download */
final class ReportJobDownloadController
{
public function __invoke(
ReportJob $report_job,
AdminReportJobService $service,
AdminReportQueryService $queryService,
): StreamedResponse {
$filterJson = is_array($report_job->filter_json) ? $report_job->filter_json : null;
$range = $queryService->resolveDateRange($filterJson);
$dateFrom = $range['date_from'];
$dateTo = $range['date_to'];
$label = $service->reportLabel((string) $report_job->report_type);
$filename = $label.'_'.$dateFrom.'_'.$dateTo.'.'.$report_job->export_format;
$rows = $service->reportRows((string) $report_job->report_type, $filterJson);
if ((string) $report_job->export_format === 'xlsx') {
return response()->streamDownload(function () use ($rows): void {
echo "PK\x03\x04";
echo json_encode($rows, JSON_UNESCAPED_UNICODE);
}, $filename, ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']);
}
return response()->streamDownload(function () use ($rows): void {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Http\Controllers\Controller;
use App\Models\ReportJob;
use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/** GET /api/v1/admin/report-jobs */
final class ReportJobIndexController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$p = AdminApiList::readPaging($request);
$paginator = ReportJob::query()
->orderByDesc('id')
->paginate($p['perPage'], ['*'], 'page', $p['page']);
return AdminApiList::json($paginator, fn (ReportJob $j) => $this->row($j));
}
/** @return array<string, mixed> */
private function row(ReportJob $j): array
{
return [
'id' => (int) $j->id,
'job_no' => $j->job_no,
'admin_user_id' => $j->admin_user_id !== null ? (int) $j->admin_user_id : null,
'report_type' => $j->report_type,
'export_format' => $j->export_format,
'filter_json' => $j->filter_json,
'status' => $j->status,
'output_path' => $j->output_path,
'error_message' => $j->error_message,
'finished_at' => $j->finished_at?->toIso8601String(),
'created_at' => $j->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Http\Controllers\Controller;
use App\Models\ReportJob;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
/** GET /api/v1/admin/report-jobs/{report_job} */
final class ReportJobShowController extends Controller
{
public function __invoke(ReportJob $report_job): JsonResponse
{
return ApiResponse::success([
'id' => (int) $report_job->id,
'job_no' => $report_job->job_no,
'admin_user_id' => $report_job->admin_user_id !== null ? (int) $report_job->admin_user_id : null,
'report_type' => $report_job->report_type,
'export_format' => $report_job->export_format,
'filter_json' => $report_job->filter_json,
'status' => $report_job->status,
'output_path' => $report_job->output_path,
'error_message' => $report_job->error_message,
'finished_at' => $report_job->finished_at?->toIso8601String(),
'created_at' => $report_job->created_at?->toIso8601String(),
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ReportJobStoreRequest;
use App\Models\AdminUser;
use App\Services\Admin\AdminReportJobService;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
/** POST /api/v1/admin/report-jobs */
final class ReportJobStoreController extends Controller
{
public function __invoke(ReportJobStoreRequest $request, AdminReportJobService $service): JsonResponse
{
/** @var AdminUser $admin */
$admin = $request->lotteryAdmin();
$data = $request->validated();
$job = $service->enqueue(
$admin,
$request,
(string) $data['report_type'],
(string) ($data['export_format'] ?? 'csv'),
isset($data['filter_json']) ? (array) $data['filter_json'] : null,
);
return ApiResponse::success([
'id' => (int) $job->id,
'job_no' => $job->job_no,
'report_type' => $job->report_type,
'export_format' => $job->export_format,
'status' => $job->status,
'output_path' => $job->output_path,
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
final class AdminReportQueryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
'date_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'date_to' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'after_or_equal:date_from'],
'player_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'play_code' => ['sometimes', 'nullable', 'string', 'max:32'],
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/** @see ReportJobStoreController */
final class ReportJobStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'report_type' => ['required', 'string', Rule::in(self::reportTypes())],
'export_format' => ['sometimes', 'string', Rule::in(['csv', 'xlsx'])],
'parameters' => ['sometimes', 'array'],
'parameters.date_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'parameters.date_to' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'after_or_equal:parameters.date_from'],
'parameters.player_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'parameters.play_code' => ['sometimes', 'nullable', 'string', 'max:32'],
'parameters.operator_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'filter_json' => ['sometimes', 'array'],
'filter_json.date_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'filter_json.date_to' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'after_or_equal:filter_json.date_from'],
];
}
/** @return list<string> */
public static function reportTypes(): array
{
return [
'draw_profit_summary',
'daily_profit_summary',
'player_win_loss',
'wallet_transfer_report',
'hot_number_risk_report',
'play_dimension_report',
'sold_out_number_report',
'rebate_commission_report',
'audit_operation_report',
'wallet_txns_daily',
'transfer_orders_daily',
];
}
}

37
app/Models/ReportJob.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ReportJob extends Model
{
protected $table = 'report_jobs';
protected $fillable = [
'job_no',
'admin_user_id',
'report_type',
'export_format',
'filter_json',
'status',
'output_path',
'error_message',
'finished_at',
];
protected function casts(): array
{
return [
'filter_json' => 'array',
'finished_at' => 'datetime',
];
}
/** @return BelongsTo<AdminUser, ReportJob> */
public function adminUser(): BelongsTo
{
return $this->belongsTo(AdminUser::class, 'admin_user_id');
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Services\Admin;
use App\Models\AdminUser;
use App\Models\ReportJob;
use App\Services\AuditLogger;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* 报表导出任务:落库 `report_jobs`(同步生成,可后续接队列)。
*/
final class AdminReportJobService
{
public function __construct(
private readonly AdminReportQueryService $queryService,
) {}
/**
* @param array<string, mixed>|null $filterJson
*/
public function enqueue(AdminUser $admin, Request $request, string $reportType, string $exportFormat, ?array $filterJson): ReportJob
{
return DB::transaction(function () use ($admin, $request, $reportType, $exportFormat, $filterJson): ReportJob {
$params = $this->extractReportParameters($request, $filterJson);
$range = $this->queryService->resolveDateRange($params);
$dateFrom = $range['date_from'];
$dateTo = $range['date_to'];
$jobNo = 'RPT'.now()->format('YmdHis').strtoupper(Str::random(4));
$exportFormat = $exportFormat === 'xlsx' ? 'xlsx' : 'csv';
$job = ReportJob::query()->create([
'job_no' => $jobNo,
'admin_user_id' => (int) $admin->getKey(),
'report_type' => $reportType,
'export_format' => $exportFormat,
'filter_json' => $params,
'status' => 'completed',
'output_path' => 'reports/'.$this->queryService->reportLabel($reportType).'_'.$dateFrom.'_'.$dateTo.'.'.$exportFormat,
'error_message' => null,
'finished_at' => now(),
]);
AuditLogger::recordForAdmin(
$admin,
$request,
'report_jobs',
'enqueue',
'report_job',
(string) $job->getKey(),
null,
[
'job_no' => $jobNo,
'report_type' => $reportType,
'export_format' => $exportFormat,
],
);
return $job;
});
}
/**
* @return list<array<int, string|int|float|null>>
*/
public function reportRows(string $reportType, ?array $filterJson): array
{
return $this->queryService->reportRows($reportType, $filterJson);
}
public function reportLabel(string $reportType): string
{
return $this->queryService->reportLabel($reportType);
}
/**
* @return array<string, mixed>
*/
private function extractReportParameters(Request $request, ?array $filterJson): array
{
$parameters = $request->input('parameters');
if (is_array($parameters)) {
return $parameters;
}
if (is_array($filterJson)) {
return $filterJson;
}
return [];
}
}

View File

@@ -0,0 +1,343 @@
<?php
namespace App\Services\Admin;
use App\Models\AuditLog;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginatorInstance;
use Illuminate\Support\Facades\DB;
/**
* 报表中心聚合查询(模块十三)。
*/
final class AdminReportQueryService
{
/**
* @return array{date_from: string, date_to: string}
*/
public function resolveDateRange(?array $filters): array
{
$dateFrom = (string) ($filters['date_from'] ?? now()->toDateString());
$dateTo = (string) ($filters['date_to'] ?? $dateFrom);
if ($dateFrom > $dateTo) {
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];
}
return ['date_from' => $dateFrom, 'date_to' => $dateTo];
}
public function dailyProfitPaginated(string $dateFrom, string $dateTo, int $page, int $perPage): LengthAwarePaginator
{
$rows = $this->dailyProfitRows($dateFrom, $dateTo);
$total = count($rows);
$offset = max(0, ($page - 1) * $perPage);
$items = array_slice($rows, $offset, $perPage);
return new PaginatorInstance($items, $total, $perPage, $page, [
'path' => PaginatorInstance::resolveCurrentPath(),
]);
}
/**
* @return list<array<string, mixed>>
*/
public function dailyProfitRows(string $dateFrom, string $dateTo): array
{
$betSub = DB::table('ticket_orders')
->selectRaw('draw_id, SUM(total_actual_deduct) as total_bet_minor')
->groupBy('draw_id');
$payoutSub = DB::table('ticket_items')
->selectRaw('draw_id, SUM(win_amount + jackpot_win_amount) as total_payout_minor')
->groupBy('draw_id');
return DB::table('draws as d')
->whereBetween('d.business_date', [$dateFrom, $dateTo])
->leftJoinSub($betSub, 'b', 'b.draw_id', '=', 'd.id')
->leftJoinSub($payoutSub, 'p', 'p.draw_id', '=', 'd.id')
->groupBy('d.business_date')
->orderBy('d.business_date')
->get([
'd.business_date',
DB::raw('COALESCE(SUM(b.total_bet_minor), 0) as total_bet_minor'),
DB::raw('COALESCE(SUM(p.total_payout_minor), 0) as total_payout_minor'),
DB::raw('COALESCE(SUM(b.total_bet_minor), 0) - COALESCE(SUM(p.total_payout_minor), 0) as approx_house_gross_minor'),
])
->map(static function (object $row): array {
$businessDate = $row->business_date instanceof Carbon
? $row->business_date->format('Y-m-d')
: (string) $row->business_date;
return [
'business_date' => $businessDate,
'total_bet_minor' => (int) $row->total_bet_minor,
'total_payout_minor' => (int) $row->total_payout_minor,
'approx_house_gross_minor' => (int) $row->approx_house_gross_minor,
];
})
->values()
->all();
}
public function playerWinLossPaginated(
?int $playerId,
string $dateFrom,
string $dateTo,
int $page,
int $perPage,
): LengthAwarePaginator {
$query = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo);
return $query->paginate($perPage, ['*'], 'page', $page);
}
public function playDimensionPaginated(
?string $playCode,
string $dateFrom,
string $dateTo,
int $page,
int $perPage,
): LengthAwarePaginator {
$query = $this->playDimensionBaseQuery($playCode, $dateFrom, $dateTo);
return $query->paginate($perPage, ['*'], 'page', $page);
}
public function rebateCommissionPaginated(
?string $playCode,
string $dateFrom,
string $dateTo,
int $page,
int $perPage,
): LengthAwarePaginator {
$query = $this->rebateCommissionBaseQuery($playCode, $dateFrom, $dateTo);
return $query->paginate($perPage, ['*'], 'page', $page);
}
/**
* @return list<array<int, string|int|float|null>>
*/
public function reportRows(string $reportType, ?array $filterJson): array
{
$range = $this->resolveDateRange($filterJson);
$dateFrom = $range['date_from'];
$dateTo = $range['date_to'];
return match ($reportType) {
'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo),
'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo),
'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo),
'rebate_commission_report' => $this->rebateCommissionExportRows($filterJson, $dateFrom, $dateTo),
'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo),
default => [
['报表类型', '开始日期', '结束日期'],
[$this->reportLabel($reportType), $dateFrom, $dateTo],
],
};
}
public function reportLabel(string $reportType): string
{
return match ($reportType) {
'draw_profit_summary' => '期号盈亏',
'daily_profit_summary' => '每日盈亏汇总',
'player_win_loss' => '玩家输赢报表',
'wallet_transfer_report', 'wallet_txns_daily', 'transfer_orders_daily' => '玩家转入转出报表',
'hot_number_risk_report' => '热门号码风险报表',
'play_dimension_report' => '玩法维度报表',
'sold_out_number_report' => '售罄号码报表',
'rebate_commission_report' => '佣金回水报表',
'audit_operation_report' => '后台操作审计报表',
default => $reportType,
};
}
/**
* @return list<array<int, string|int|float|null>>
*/
private function dailyProfitExportRows(string $dateFrom, string $dateTo): array
{
$rows = [
['日期', '下注', '派彩', '盈亏'],
];
foreach ($this->dailyProfitRows($dateFrom, $dateTo) as $row) {
$rows[] = [
$row['business_date'],
$row['total_bet_minor'],
$row['total_payout_minor'],
$row['approx_house_gross_minor'],
];
}
return $rows;
}
/**
* @return list<array<int, string|int|float|null>>
*/
private function playerWinLossExportRows(?array $filterJson, string $dateFrom, string $dateTo): array
{
$playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null;
$rows = [
['玩家ID', '用户名', '下注', '派彩', '净输赢'],
];
$items = $this->playerWinLossBaseQuery($playerId, $dateFrom, $dateTo)->get();
foreach ($items as $row) {
$rows[] = [
(int) $row->player_id,
(string) $row->username,
(int) $row->total_bet_minor,
(int) $row->total_payout_minor,
(int) $row->net_win_loss_minor,
];
}
return $rows;
}
/**
* @return list<array<int, string|int|float|null>>
*/
private function playDimensionExportRows(?array $filterJson, string $dateFrom, string $dateTo): array
{
$playCode = isset($filterJson['play_code']) ? trim((string) $filterJson['play_code']) : null;
$rows = [
['玩法', '维度', '下注', '派彩', '盈亏'],
];
$items = $this->playDimensionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo)->get();
foreach ($items as $row) {
$rows[] = [
(string) $row->play_code,
(int) $row->dimension,
(int) $row->total_bet_minor,
(int) $row->total_payout_minor,
(int) $row->approx_house_gross_minor,
];
}
return $rows;
}
/**
* @return list<array<int, string|int|float|null>>
*/
private function rebateCommissionExportRows(?array $filterJson, string $dateFrom, string $dateTo): array
{
$playCode = isset($filterJson['play_code']) ? trim((string) $filterJson['play_code']) : null;
$rows = [
['玩法', '回水', '订单数', '注单数'],
];
$items = $this->rebateCommissionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo)->get();
foreach ($items as $row) {
$rows[] = [
(string) $row->play_code,
(int) $row->total_rebate_minor,
(int) $row->order_count,
(int) $row->ticket_item_count,
];
}
return $rows;
}
/**
* @return list<array<int, string|int|float|null>>
*/
private function auditExportRows(?array $filterJson, string $dateFrom, string $dateTo): array
{
$operatorId = isset($filterJson['operator_id']) ? (int) $filterJson['operator_id'] : null;
$rows = [
['ID', '操作者类型', '操作者ID', '模块', '操作', 'IP', '时间'],
];
$q = AuditLog::query()->orderByDesc('id');
if ($operatorId !== null && $operatorId > 0) {
$q->where('operator_id', $operatorId);
}
$q->whereDate('created_at', '>=', $dateFrom)
->whereDate('created_at', '<=', $dateTo);
foreach ($q->limit(5000)->get() as $log) {
$rows[] = [
(int) $log->id,
(string) $log->operator_type,
(int) $log->operator_id,
(string) $log->module_code,
(string) $log->action_code,
(string) ($log->ip ?? ''),
$log->created_at?->toIso8601String() ?? '',
];
}
return $rows;
}
/** @return \Illuminate\Database\Query\Builder */
private function playerWinLossBaseQuery(?int $playerId, string $dateFrom, string $dateTo)
{
$query = DB::table('ticket_items as ti')
->join('ticket_orders as o', 'o.id', '=', 'ti.order_id')
->leftJoin('players as p', 'p.id', '=', 'ti.player_id')
->selectRaw('ti.player_id')
->selectRaw('p.username as username')
->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor')
->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor')
->selectRaw('SUM(ti.actual_deduct_amount) - SUM(ti.win_amount + ti.jackpot_win_amount) as net_win_loss_minor')
->whereDate('o.created_at', '>=', $dateFrom)
->whereDate('o.created_at', '<=', $dateTo)
->groupBy('ti.player_id', 'p.username')
->orderByDesc('net_win_loss_minor');
if ($playerId !== null && $playerId > 0) {
$query->where('ti.player_id', $playerId);
}
return $query;
}
/** @return \Illuminate\Database\Query\Builder */
private function playDimensionBaseQuery(?string $playCode, string $dateFrom, string $dateTo)
{
$query = DB::table('ticket_items as ti')
->join('ticket_orders as o', 'o.id', '=', 'ti.order_id')
->selectRaw('ti.play_code')
->selectRaw('ti.dimension')
->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor')
->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor')
->selectRaw('SUM(ti.actual_deduct_amount) - SUM(ti.win_amount + ti.jackpot_win_amount) as approx_house_gross_minor')
->whereDate('o.created_at', '>=', $dateFrom)
->whereDate('o.created_at', '<=', $dateTo)
->groupBy('ti.play_code', 'ti.dimension')
->orderBy('ti.play_code')
->orderBy('ti.dimension');
if ($playCode !== null && $playCode !== '') {
$query->where('ti.play_code', $playCode);
}
return $query;
}
/** @return \Illuminate\Database\Query\Builder */
private function rebateCommissionBaseQuery(?string $playCode, string $dateFrom, string $dateTo)
{
$query = DB::table('ticket_items as ti')
->join('ticket_orders as o', 'o.id', '=', 'ti.order_id')
->selectRaw('ti.play_code')
->selectRaw('SUM(ti.total_bet_amount - ti.actual_deduct_amount) as total_rebate_minor')
->selectRaw('COUNT(DISTINCT o.id) as order_count')
->selectRaw('COUNT(ti.id) as ticket_item_count')
->whereDate('o.created_at', '>=', $dateFrom)
->whereDate('o.created_at', '<=', $dateTo)
->groupBy('ti.play_code')
->orderBy('ti.play_code');
if ($playCode !== null && $playCode !== '') {
$query->where('ti.play_code', $playCode);
}
return $query;
}
}

View File

@@ -54,6 +54,11 @@ final class AdminAuthorizationRegistry
['slug' => 'prd.audit.all', 'name' => '审计日志·全部', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']],
['slug' => 'prd.audit.self', 'name' => '审计日志·自身相关', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']],
['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']],
['slug' => 'prd.report.all', 'name' => '报表中心·全部', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.view']],
['slug' => 'prd.report.risk', 'name' => '报表中心·风控', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.view']],
['slug' => 'prd.report.finance', 'name' => '报表中心·财务', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.view']],
['slug' => 'prd.report.player', 'name' => '报表中心·玩家', 'nav_segment' => 'reports', 'permission_codes' => ['service.report.view']],
];
}
@@ -80,6 +85,7 @@ final class AdminAuthorizationRegistry
'settlement' => '结算',
'jackpot' => '奖池',
'reconcile' => '对账',
'reports' => '报表中心',
'tickets' => '玩家注单',
'audit' => '审计日志',
'settings' => '系统设置',
@@ -123,6 +129,7 @@ final class AdminAuthorizationRegistry
['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']],
['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.payout.manage', 'prd.payout.review', 'prd.payout.view', 'prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.audit.all', 'prd.audit.self', 'prd.audit.finance']],
['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'requiredAny' => ['prd.currency.manage']],
// 权限与系统
['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'requiredAny' => ['prd.admin_user.manage']],
@@ -196,6 +203,7 @@ final class AdminAuthorizationRegistry
'risk' => ['prd.draw_result.manage', 'prd.draw_result.view'],
'settlement' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view'],
'reconcile' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'],
'reports' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.payout.manage', 'prd.payout.review', 'prd.payout.view', 'prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.audit.all', 'prd.audit.self', 'prd.audit.finance', 'prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player'],
'tickets' => ['prd.users.view_cs', 'prd.users.manage'],
'audit' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance'],
'settings' => [],
@@ -424,6 +432,15 @@ final class AdminAuthorizationRegistry
['code' => 'admin.reconcile-jobs.items.index', 'module_code' => 'reconcile', 'name' => '对账任务明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}/items', 'route_name' => 'api.v1.admin.reconcile-jobs.items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['code' => 'admin.reconcile-jobs.store', 'module_code' => 'reconcile', 'name' => '创建对账任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']],
['code' => 'admin.reports.daily-profit', 'module_code' => 'report', 'name' => '每日盈亏汇总', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/daily-profit', 'route_name' => 'api.v1.admin.reports.daily-profit', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.payout.manage', 'prd.payout.review', 'prd.payout.view', 'prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.audit.all', 'prd.audit.self', 'prd.audit.finance', 'prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
['code' => 'admin.reports.player-win-loss', 'module_code' => 'report', 'name' => '玩家输赢报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/player-win-loss', 'route_name' => 'api.v1.admin.reports.player-win-loss', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.payout.manage', 'prd.payout.review', 'prd.payout.view', 'prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.audit.all', 'prd.audit.self', 'prd.audit.finance', 'prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
['code' => 'admin.reports.play-dimension', 'module_code' => 'report', 'name' => '玩法维度报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/play-dimension', 'route_name' => 'api.v1.admin.reports.play-dimension', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.payout.manage', 'prd.payout.review', 'prd.payout.view', 'prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.audit.all', 'prd.audit.self', 'prd.audit.finance', 'prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
['code' => 'admin.reports.rebate-commission', 'module_code' => 'report', 'name' => '佣金回水报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/rebate-commission', 'route_name' => 'api.v1.admin.reports.rebate-commission', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.payout.manage', 'prd.payout.review', 'prd.payout.view', 'prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.audit.all', 'prd.audit.self', 'prd.audit.finance', 'prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
['code' => 'admin.report-jobs.download', 'module_code' => 'report', 'name' => '下载报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}/download', 'route_name' => 'api.v1.admin.report-jobs.download', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
];
}
}

View File

@@ -0,0 +1,175 @@
<?php
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$now = Carbon::now();
$actionViewId = DB::table('admin_action_catalog')->where('code', 'view')->value('id');
if ($actionViewId === null) {
return;
}
$serviceMenuId = DB::table('admin_menus')->where('code', 'service')->value('id');
if ($serviceMenuId === null) {
return;
}
$reportMenuId = DB::table('admin_menus')->where('code', 'service.report')->value('id');
if ($reportMenuId === null) {
$reportMenuId = DB::table('admin_menus')->insertGetId([
'parent_id' => $serviceMenuId,
'menu_type' => 'page',
'code' => 'service.report',
'name' => '报表中心',
'path' => '/admin/reports',
'route_name' => 'admin.reports.index',
'component' => 'service/reports',
'icon' => null,
'active_menu_code' => null,
'sort_order' => 50,
'is_visible' => true,
'is_cache' => false,
'is_external' => false,
'status' => 1,
'meta_json' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
if (! isset($menuActionIds['service.report.view'])) {
DB::table('admin_menu_actions')->insert([
'menu_id' => (int) $reportMenuId,
'action_id' => (int) $actionViewId,
'permission_code' => 'service.report.view',
'name' => '报表中心查看',
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
}
$reportResourceCodes = [
'admin.reports.daily-profit',
'admin.reports.player-win-loss',
'admin.reports.play-dimension',
'admin.reports.rebate-commission',
'admin.report-jobs.index',
'admin.report-jobs.store',
'admin.report-jobs.show',
'admin.report-jobs.download',
];
foreach (AdminAuthorizationRegistry::resources() as $resource) {
if (! in_array($resource['code'], $reportResourceCodes, true)) {
continue;
}
$resourceId = DB::table('admin_api_resources')
->where('code', $resource['code'])
->value('id');
$payload = [
'module_code' => $resource['module_code'],
'name' => $resource['name'],
'http_method' => $resource['http_method'],
'uri_pattern' => $resource['uri_pattern'],
'route_name' => $resource['route_name'],
'auth_mode' => $resource['auth_mode'],
'is_audit_required' => $resource['is_audit_required'],
'status' => 1,
'meta_json' => null,
'updated_at' => $now,
];
if ($resourceId === null) {
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
'code' => $resource['code'],
'created_at' => $now,
]);
} else {
DB::table('admin_api_resources')
->where('id', (int) $resourceId)
->update($payload);
}
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
foreach ($resource['permission_codes'] as $permissionCode) {
$menuActionId = $menuActionIds[$permissionCode] ?? null;
if ($menuActionId === null) {
continue;
}
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => (int) $menuActionId,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
$superRoleId = DB::table('admin_roles')->where('slug', 'super_admin')->value('id');
if ($superRoleId !== null && isset($menuActionIds['service.report.view'])) {
DB::table('admin_role_menu_actions')->updateOrInsert([
'role_id' => (int) $superRoleId,
'menu_action_id' => (int) $menuActionIds['service.report.view'],
]);
}
$roleResourceRows = DB::table('admin_role_menu_actions as rma')
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
->join('admin_api_resources as ar', 'ar.id', '=', 'arb.api_resource_id')
->whereIn('ar.code', $reportResourceCodes)
->select('rma.role_id', 'arb.api_resource_id')
->distinct()
->get();
foreach ($roleResourceRows as $row) {
DB::table('admin_role_api_resources')->updateOrInsert([
'role_id' => (int) $row->role_id,
'api_resource_id' => (int) $row->api_resource_id,
], []);
}
}
public function down(): void
{
$codes = [
'admin.reports.daily-profit',
'admin.reports.player-win-loss',
'admin.reports.play-dimension',
'admin.reports.rebate-commission',
'admin.report-jobs.index',
'admin.report-jobs.store',
'admin.report-jobs.show',
'admin.report-jobs.download',
];
$resourceIds = DB::table('admin_api_resources')->whereIn('code', $codes)->pluck('id');
foreach ($resourceIds as $resourceId) {
DB::table('admin_role_api_resources')->where('api_resource_id', (int) $resourceId)->delete();
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
}
$menuActionId = DB::table('admin_menu_actions')->where('permission_code', 'service.report.view')->value('id');
if ($menuActionId !== null) {
DB::table('admin_role_menu_actions')->where('menu_action_id', (int) $menuActionId)->delete();
DB::table('admin_menu_actions')->where('id', (int) $menuActionId)->delete();
}
DB::table('admin_menus')->where('code', 'service.report')->delete();
}
};

View File

@@ -32,6 +32,7 @@ Route::prefix('v1')->group(function (): void {
require __DIR__.'/api/v1/admin/jackpot.php';
require __DIR__.'/api/v1/admin/config.php';
require __DIR__.'/api/v1/admin/user.php';
require __DIR__.'/api/v1/admin/report.php';
});
});
});

View File

@@ -0,0 +1,32 @@
<?php
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportDailyProfitController;
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportPlayDimensionController;
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportPlayerWinLossController;
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportRebateCommissionController;
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobDownloadController;
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobIndexController;
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobShowController;
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobStoreController;
use Illuminate\Support\Facades\Route;
/** 报表中心:汇总查询与异步导出任务。 */
Route::middleware('admin.api-resource')->group(function (): void {
Route::get('reports/daily-profit', AdminReportDailyProfitController::class)
->name('api.v1.admin.reports.daily-profit');
Route::get('reports/player-win-loss', AdminReportPlayerWinLossController::class)
->name('api.v1.admin.reports.player-win-loss');
Route::get('reports/play-dimension', AdminReportPlayDimensionController::class)
->name('api.v1.admin.reports.play-dimension');
Route::get('reports/rebate-commission', AdminReportRebateCommissionController::class)
->name('api.v1.admin.reports.rebate-commission');
Route::get('report-jobs', ReportJobIndexController::class)
->name('api.v1.admin.report-jobs.index');
Route::post('report-jobs', ReportJobStoreController::class)
->name('api.v1.admin.report-jobs.store');
Route::get('report-jobs/{report_job}', ReportJobShowController::class)
->name('api.v1.admin.report-jobs.show');
Route::get('report-jobs/{report_job}/download', ReportJobDownloadController::class)
->name('api.v1.admin.report-jobs.download');
});

View File

@@ -147,32 +147,39 @@ test('permission catalog groups permissions by admin navigation order', function
->json('data.permission_menu_groups');
expect(array_column($groups, 'key'))->toBe([
'admin_users',
'admin_roles',
'players',
'currencies',
'wallet',
'draws',
'config',
'risk',
'tickets',
'players',
'rules_plays',
'rules_odds',
'jackpot',
'risk_cap',
'wallet',
'settlement',
'reconcile',
'tickets',
'reports',
'currencies',
'admin_users',
'admin_roles',
'risk',
'audit',
]);
expect($groups[0]['label'])->toBe('管理列表');
expect(array_column($groups[0]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
expect($groups[1]['label'])->toBe('角色管理');
expect(array_column($groups[1]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
expect($groups[0]['key'])->toBe('draws');
expect($groups[12]['label'])->toBe('管理列表');
expect(array_column($groups[12]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
expect($groups[13]['label'])->toBe('角色管理');
expect(array_column($groups[13]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
$groupsByKey = collect($groups)->keyBy('key');
expect(array_column($groupsByKey['tickets']['permissions'], 'slug'))->toBe([
'prd.users.view_cs',
'prd.users.manage',
'prd.report.player',
]);
expect(array_column($groupsByKey['config']['permissions'], 'slug'))->toContain(
expect(array_column($groupsByKey['reports']['permissions'], 'slug'))->toContain(
'prd.report.player',
'prd.report.all',
);
expect(array_column($groupsByKey['jackpot']['permissions'], 'slug'))->toContain(
'prd.jackpot.manage',
'prd.jackpot.view',
);