diff --git a/app/Http/Requests/Admin/ReportJobStoreRequest.php b/app/Http/Requests/Admin/ReportJobStoreRequest.php index f7f64a9..dcd2f6b 100644 --- a/app/Http/Requests/Admin/ReportJobStoreRequest.php +++ b/app/Http/Requests/Admin/ReportJobStoreRequest.php @@ -25,9 +25,15 @@ final class ReportJobStoreRequest extends FormRequest 'parameters.player_id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'parameters.play_code' => ['sometimes', 'nullable', 'string', 'max:32'], 'parameters.operator_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'parameters.draw_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'parameters.draw_no' => ['sometimes', 'nullable', 'string', 'max:64'], + 'parameters.normalized_number' => ['sometimes', 'nullable', 'regex:/^[0-9]{4}$/'], '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'], + 'filter_json.draw_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'filter_json.draw_no' => ['sometimes', 'nullable', 'string', 'max:64'], + 'filter_json.normalized_number' => ['sometimes', 'nullable', 'regex:/^[0-9]{4}$/'], ]; } diff --git a/app/Models/WalletTxn.php b/app/Models/WalletTxn.php index e73d937..8e54d0d 100644 --- a/app/Models/WalletTxn.php +++ b/app/Models/WalletTxn.php @@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; /** 彩票钱包流水 {@see wallet_txns} */ final class WalletTxn extends Model { + protected $table = 'wallet_txns'; + protected $fillable = [ 'txn_no', 'player_id', diff --git a/app/Services/Admin/AdminReportJobService.php b/app/Services/Admin/AdminReportJobService.php index 95d73f5..d04359c 100644 --- a/app/Services/Admin/AdminReportJobService.php +++ b/app/Services/Admin/AdminReportJobService.php @@ -32,6 +32,8 @@ final class AdminReportJobService $jobNo = 'RPT'.now()->format('YmdHis').strtoupper(Str::random(4)); $exportFormat = $exportFormat === 'xlsx' ? 'xlsx' : 'csv'; + $pathSuffix = $this->queryService->resolveOutputPathSuffix($reportType, $params, $dateFrom, $dateTo); + $job = ReportJob::query()->create([ 'job_no' => $jobNo, 'admin_user_id' => (int) $admin->getKey(), @@ -39,7 +41,7 @@ final class AdminReportJobService 'export_format' => $exportFormat, 'filter_json' => $params, 'status' => 'completed', - 'output_path' => 'reports/'.$this->queryService->reportLabel($reportType).'_'.$dateFrom.'_'.$dateTo.'.'.$exportFormat, + 'output_path' => 'reports/'.$this->queryService->reportLabel($reportType).'_'.$pathSuffix.'.'.$exportFormat, 'error_message' => null, 'finished_at' => now(), ]); diff --git a/app/Services/Admin/AdminReportQueryService.php b/app/Services/Admin/AdminReportQueryService.php index 8fb451f..4f2e3bb 100644 --- a/app/Services/Admin/AdminReportQueryService.php +++ b/app/Services/Admin/AdminReportQueryService.php @@ -3,6 +3,14 @@ namespace App\Services\Admin; use App\Models\AuditLog; +use App\Models\Draw; +use App\Models\RiskPool; +use App\Models\RiskPoolLockLog; +use App\Models\SettlementBatch; +use App\Models\TicketItem; +use App\Models\TicketOrder; +use App\Models\TransferOrder; +use App\Models\WalletTxn; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator as PaginatorInstance; @@ -351,11 +359,16 @@ final class AdminReportQueryService $dateTo = $range['date_to']; return match ($reportType) { + 'draw_profit_summary' => $this->drawProfitExportRows($filterJson), '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), + 'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo), + 'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo), + 'hot_number_risk_report' => $this->hotNumberRiskExportRows($filterJson), + 'sold_out_number_report' => $this->soldOutNumberExportRows($filterJson), default => [ ['报表类型', '开始日期', '结束日期'], [$this->reportLabel($reportType), $dateFrom, $dateTo], @@ -363,6 +376,24 @@ final class AdminReportQueryService }; } + public function resolveOutputPathSuffix(string $reportType, ?array $filterJson, string $dateFrom, string $dateTo): string + { + if (in_array($reportType, ['draw_profit_summary', 'hot_number_risk_report', 'sold_out_number_report'], true)) { + $draw = $this->resolveDrawForReport($filterJson); + if ($draw !== null) { + $suffix = (string) $draw->draw_no; + $number = $this->normalizedNumberFromFilters($filterJson); + if ($reportType === 'hot_number_risk_report' && $number !== null) { + return $suffix.'_'.$number; + } + + return $suffix; + } + } + + return $dateFrom.'_'.$dateTo; + } + public function reportLabel(string $reportType): string { return match ($reportType) { @@ -566,6 +597,359 @@ final class AdminReportQueryService return $query; } + /** + * @return list> + */ + private function drawProfitExportRows(?array $filterJson): array + { + $draw = $this->resolveDrawForReport($filterJson); + if ($draw === null) { + return [['提示', '请提供 draw_id 或 draw_no']]; + } + + $drawId = (int) $draw->id; + $totalBetMinor = (int) TicketOrder::query()->where('draw_id', $drawId)->sum('total_actual_deduct'); + $orderCount = (int) TicketOrder::query()->where('draw_id', $drawId)->count(); + $itemCount = (int) TicketItem::query()->where('draw_id', $drawId)->count(); + $currencyCode = (string) (TicketOrder::query()->where('draw_id', $drawId)->value('currency_code') ?? ''); + $totalWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('win_amount'); + $totalJackpotWinMinor = (int) TicketItem::query()->where('draw_id', $drawId)->sum('jackpot_win_amount'); + $totalPayoutMinor = $totalWinMinor + $totalJackpotWinMinor; + $approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor; + + $rows = [ + [ + '行类型', '期号', '状态', '币种', '订单数', '注单数', '下注', '派彩', '平台盈亏', + '结算批次ID', '结算状态', '批次数', '中奖数', '批次派彩', '批次Jackpot', '完成时间', + ], + [ + 'summary', + $draw->draw_no, + $draw->status, + $currencyCode !== '' ? $currencyCode : null, + $orderCount, + $itemCount, + $totalBetMinor, + $totalPayoutMinor, + $approxHouseGrossMinor, + null, + null, + null, + null, + null, + null, + null, + ], + ]; + + $batches = SettlementBatch::query() + ->where('draw_id', $drawId) + ->orderByDesc('id') + ->limit(100) + ->get(); + + foreach ($batches as $batch) { + $rows[] = [ + 'settlement_batch', + $draw->draw_no, + $draw->status, + $currencyCode !== '' ? $currencyCode : null, + null, + null, + null, + null, + null, + (int) $batch->id, + $batch->status, + (int) $batch->total_ticket_count, + (int) $batch->total_win_count, + (int) $batch->total_payout_amount, + (int) $batch->total_jackpot_payout_amount, + $batch->finished_at?->toIso8601String(), + ]; + } + + return $rows; + } + + /** + * @return list> + */ + private function soldOutNumberExportRows(?array $filterJson): array + { + $draw = $this->resolveDrawForReport($filterJson); + if ($draw === null) { + return [['提示', '请提供 draw_id 或 draw_no']]; + } + + $rows = [ + ['期号', '号码', '封顶', '已占用', '剩余', '售罄', '使用率%'], + ]; + + $pools = RiskPool::query() + ->where('draw_id', $draw->id) + ->where('sold_out_status', 1) + ->orderBy('normalized_number') + ->limit(10000) + ->get(); + + foreach ($pools as $pool) { + $cap = (int) $pool->total_cap_amount; + $locked = (int) $pool->locked_amount; + $rows[] = [ + $draw->draw_no, + $pool->normalized_number, + $cap, + $locked, + (int) $pool->remaining_amount, + '是', + $this->riskUsageRatioPercent($cap, $locked), + ]; + } + + return $rows; + } + + /** + * @return list> + */ + private function hotNumberRiskExportRows(?array $filterJson): array + { + $draw = $this->resolveDrawForReport($filterJson); + if ($draw === null) { + return [['提示', '请提供 draw_id 或 draw_no']]; + } + + $number = $this->normalizedNumberFromFilters($filterJson); + $rows = [ + [ + '行类型', '期号', '号码', '封顶', '已占用', '剩余', '售罄', '使用率%', + '日志ID', '动作', '金额', '玩法', '注单号', '玩家ID', '原因', '时间', + ], + ]; + + if ($number !== null) { + $pool = RiskPool::query() + ->where('draw_id', $draw->id) + ->where('normalized_number', $number) + ->first(); + + if ($pool === null) { + $rows[] = ['pool', $draw->draw_no, $number, null, null, null, null, null, null, null, null, null, null, null, null, null]; + + return $rows; + } + + $cap = (int) $pool->total_cap_amount; + $locked = (int) $pool->locked_amount; + $rows[] = [ + 'pool', + $draw->draw_no, + $pool->normalized_number, + $cap, + $locked, + (int) $pool->remaining_amount, + (int) $pool->sold_out_status === 1 ? '是' : '否', + $this->riskUsageRatioPercent($cap, $locked), + null, + null, + null, + null, + null, + null, + null, + null, + ]; + + $logs = RiskPoolLockLog::query() + ->where('draw_id', $draw->id) + ->where('normalized_number', $number) + ->with(['ticketItem:id,ticket_no,play_code,player_id']) + ->orderByDesc('id') + ->limit(5000) + ->get(); + + foreach ($logs as $log) { + $rows[] = [ + 'lock_log', + $draw->draw_no, + $number, + null, + null, + null, + null, + null, + (int) $log->id, + $log->action_type, + (int) $log->amount, + $log->ticketItem?->play_code, + $log->ticketItem?->ticket_no, + $log->ticketItem?->player_id, + $log->source_reason, + $log->created_at?->toIso8601String(), + ]; + } + + return $rows; + } + + $pools = RiskPool::query() + ->where('draw_id', $draw->id) + ->orderByDesc('locked_amount') + ->limit(10000) + ->get(); + + foreach ($pools as $pool) { + $cap = (int) $pool->total_cap_amount; + $locked = (int) $pool->locked_amount; + $rows[] = [ + 'pool', + $draw->draw_no, + $pool->normalized_number, + $cap, + $locked, + (int) $pool->remaining_amount, + (int) $pool->sold_out_status === 1 ? '是' : '否', + $this->riskUsageRatioPercent($cap, $locked), + null, + null, + null, + null, + null, + null, + null, + null, + ]; + } + + return $rows; + } + + /** + * @return list> + */ + private function transferOrdersExportRows(?array $filterJson, string $dateFrom, string $dateTo): array + { + $rows = [ + ['转账单号', '玩家ID', '用户名', '昵称', '方向', '币种', '金额', '状态', '外部单号', '失败原因', '创建时间', '完成时间'], + ]; + + $query = TransferOrder::query() + ->with(['player:id,username,nickname']) + ->orderByDesc('id'); + + $playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null; + if ($playerId !== null && $playerId > 0) { + $query->where('player_id', $playerId); + } + + $query->where('created_at', '>=', $dateFrom.' 00:00:00') + ->where('created_at', '<=', $dateTo.' 23:59:59'); + + foreach ($query->limit(10000)->get() as $order) { + $player = $order->player; + $rows[] = [ + $order->transfer_no, + (int) $order->player_id, + $player?->username, + $player?->nickname, + $order->direction, + $order->currency_code, + (int) $order->amount, + $order->status, + $order->external_ref_no, + $order->fail_reason, + $order->created_at?->toIso8601String(), + $order->finished_at?->toIso8601String(), + ]; + } + + return $rows; + } + + /** + * @return list> + */ + private function walletTxnsExportRows(?array $filterJson, string $dateFrom, string $dateTo): array + { + $rows = [ + ['流水号', '玩家ID', '用户名', '业务类型', '业务单号', '方向', '金额', '变动前余额', '变动后余额', '状态', '外部单号', '备注', '创建时间'], + ]; + + $query = WalletTxn::query() + ->with(['player:id,username']) + ->orderByDesc('id'); + + $playerId = isset($filterJson['player_id']) ? (int) $filterJson['player_id'] : null; + if ($playerId !== null && $playerId > 0) { + $query->where('player_id', $playerId); + } + + $query->where('created_at', '>=', $dateFrom.' 00:00:00') + ->where('created_at', '<=', $dateTo.' 23:59:59'); + + foreach ($query->limit(10000)->get() as $txn) { + $rows[] = [ + $txn->txn_no, + (int) $txn->player_id, + $txn->player?->username, + $txn->biz_type, + $txn->biz_no, + (int) $txn->direction, + (int) $txn->amount, + (int) $txn->balance_before, + (int) $txn->balance_after, + $txn->status, + $txn->external_ref_no, + $txn->remark, + $txn->created_at?->toIso8601String(), + ]; + } + + return $rows; + } + + private function resolveDrawForReport(?array $filterJson): ?Draw + { + if (! is_array($filterJson)) { + return null; + } + + if (! empty($filterJson['draw_id'])) { + return Draw::query()->find((int) $filterJson['draw_id']); + } + + $drawNo = trim((string) ($filterJson['draw_no'] ?? '')); + if ($drawNo !== '') { + return Draw::query()->where('draw_no', $drawNo)->first(); + } + + return null; + } + + private function normalizedNumberFromFilters(?array $filterJson): ?string + { + if (! is_array($filterJson)) { + return null; + } + + $number = trim((string) ($filterJson['normalized_number'] ?? '')); + if (preg_match('/^[0-9]{4}$/', $number) === 1) { + return $number; + } + + return null; + } + + private function riskUsageRatioPercent(int $cap, int $locked): ?string + { + if ($cap <= 0) { + return null; + } + + return (string) round($locked / $cap * 100, 2); + } + private function formatBusinessDateValue(mixed $value): ?string { if ($value === null) { diff --git a/tests/Feature/AdminPhase15OperationsTest.php b/tests/Feature/AdminPhase15OperationsTest.php index f8e583b..5fe8f93 100644 --- a/tests/Feature/AdminPhase15OperationsTest.php +++ b/tests/Feature/AdminPhase15OperationsTest.php @@ -3,17 +3,25 @@ use App\Models\AuditLog; use App\Models\AdminRole; use App\Models\AdminUser; +use App\Models\Draw; use App\Models\ReportJob; +use App\Models\RiskPool; use App\Lottery\ErrorCode; use App\Models\ReconcileJob; use App\Services\AuditLogger; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use App\Support\AdminPermissionBridge; +use Database\Seeders\AdminRbacAndUserSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); +beforeEach(function (): void { + $this->seed(AdminRbacAndUserSeeder::class); + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + function phase15SuperToken(): string { $admin = AdminUser::query()->create([ @@ -124,6 +132,67 @@ test('report jobs support xlsx export filename convention', function (): void { ->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); }); +test('report jobs export draw profit and sold out reports by draw_no', function (): void { + $token = phase15SuperToken(); + + $draw = Draw::query()->create([ + 'draw_no' => 'RPT-DRAW-001', + 'business_date' => '2026-05-20', + 'sequence_no' => 1, + 'status' => 'settled', + 'start_time' => now()->subDay(), + 'close_time' => now()->subDay(), + 'draw_time' => now()->subDay(), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + RiskPool::query()->create([ + 'draw_id' => $draw->id, + 'normalized_number' => '1234', + 'total_cap_amount' => 10000, + 'locked_amount' => 10000, + 'remaining_amount' => 0, + 'sold_out_status' => 1, + 'version' => 1, + ]); + + $profit = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/report-jobs', [ + 'report_type' => 'draw_profit_summary', + 'export_format' => 'csv', + 'parameters' => ['draw_no' => 'RPT-DRAW-001'], + ]); + $profit->assertOk()->assertJsonPath('code', ErrorCode::Success->value); + $profitId = (int) $profit->json('data.id'); + expect(ReportJob::query()->find($profitId)?->output_path)->toContain('RPT-DRAW-001'); + + $profitCsv = $this->withHeader('Authorization', 'Bearer '.$token) + ->get('/api/v1/admin/report-jobs/'.$profitId.'/download') + ->assertOk() + ->streamedContent(); + expect($profitCsv)->toContain('summary') + ->and($profitCsv)->toContain('RPT-DRAW-001'); + + $soldOut = $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/report-jobs', [ + 'report_type' => 'sold_out_number_report', + 'export_format' => 'csv', + 'parameters' => ['draw_id' => $draw->id], + ]); + $soldOut->assertOk(); + $soldOutId = (int) $soldOut->json('data.id'); + $soldOutCsv = $this->withHeader('Authorization', 'Bearer '.$token) + ->get('/api/v1/admin/report-jobs/'.$soldOutId.'/download') + ->assertOk() + ->streamedContent(); + expect($soldOutCsv)->toContain('1234') + ->and($soldOutCsv)->toContain('是'); +}); + test('reconcile job create with items and nested items index', function (): void { $token = phase15SuperToken();