diff --git a/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php b/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php index 71f54c1..2a8f322 100644 --- a/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php @@ -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']); diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php new file mode 100644 index 0000000..36a7abe --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php @@ -0,0 +1,29 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php new file mode 100644 index 0000000..c782c67 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php @@ -0,0 +1,39 @@ +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, + ]; + }); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php new file mode 100644 index 0000000..e6d3fb5 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php @@ -0,0 +1,39 @@ +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, + ]; + }); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php new file mode 100644 index 0000000..94ba1be --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php @@ -0,0 +1,38 @@ +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, + ]; + }); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php new file mode 100644 index 0000000..cb7c9c4 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php @@ -0,0 +1,42 @@ +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']); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php new file mode 100644 index 0000000..6112c90 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php @@ -0,0 +1,42 @@ +orderByDesc('id') + ->paginate($p['perPage'], ['*'], 'page', $p['page']); + + return AdminApiList::json($paginator, fn (ReportJob $j) => $this->row($j)); + } + + /** @return array */ + 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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php new file mode 100644 index 0000000..370bcd8 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php @@ -0,0 +1,29 @@ + (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(), + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php new file mode 100644 index 0000000..0e4690e --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php @@ -0,0 +1,39 @@ +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, + ]); + } +} diff --git a/app/Http/Requests/Admin/AdminReportQueryRequest.php b/app/Http/Requests/Admin/AdminReportQueryRequest.php new file mode 100644 index 0000000..f6b4e53 --- /dev/null +++ b/app/Http/Requests/Admin/AdminReportQueryRequest.php @@ -0,0 +1,26 @@ +> */ + 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'], + ]; + } +} diff --git a/app/Http/Requests/Admin/ReportJobStoreRequest.php b/app/Http/Requests/Admin/ReportJobStoreRequest.php new file mode 100644 index 0000000..f7f64a9 --- /dev/null +++ b/app/Http/Requests/Admin/ReportJobStoreRequest.php @@ -0,0 +1,51 @@ +> */ + 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 */ + 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', + ]; + } +} diff --git a/app/Models/ReportJob.php b/app/Models/ReportJob.php new file mode 100644 index 0000000..2314812 --- /dev/null +++ b/app/Models/ReportJob.php @@ -0,0 +1,37 @@ + 'array', + 'finished_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function adminUser(): BelongsTo + { + return $this->belongsTo(AdminUser::class, 'admin_user_id'); + } +} diff --git a/app/Services/Admin/AdminReportJobService.php b/app/Services/Admin/AdminReportJobService.php new file mode 100644 index 0000000..95d73f5 --- /dev/null +++ b/app/Services/Admin/AdminReportJobService.php @@ -0,0 +1,95 @@ +|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> + */ + 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 + */ + 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 []; + } +} diff --git a/app/Services/Admin/AdminReportQueryService.php b/app/Services/Admin/AdminReportQueryService.php new file mode 100644 index 0000000..10394e7 --- /dev/null +++ b/app/Services/Admin/AdminReportQueryService.php @@ -0,0 +1,343 @@ +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> + */ + 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> + */ + 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> + */ + 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> + */ + 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> + */ + 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> + */ + 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> + */ + 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; + } +} diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 8ef95c1..6f6dd5e 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -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']], + ]; } } diff --git a/database/migrations/2026_05_22_100000_add_admin_report_module.php b/database/migrations/2026_05_22_100000_add_admin_report_module.php new file mode 100644 index 0000000..0799573 --- /dev/null +++ b/database/migrations/2026_05_22_100000_add_admin_report_module.php @@ -0,0 +1,175 @@ +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(); + } +}; diff --git a/routes/api.php b/routes/api.php index 7cac1bf..ff1d169 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'; }); }); }); diff --git a/routes/api/v1/admin/report.php b/routes/api/v1/admin/report.php new file mode 100644 index 0000000..8c03554 --- /dev/null +++ b/routes/api/v1/admin/report.php @@ -0,0 +1,32 @@ +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'); +}); diff --git a/tests/Feature/AdminUserPermissionApiTest.php b/tests/Feature/AdminUserPermissionApiTest.php index 921cc33..173340f 100644 --- a/tests/Feature/AdminUserPermissionApiTest.php +++ b/tests/Feature/AdminUserPermissionApiTest.php @@ -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', );