*/ public function build(AdminScopeContext $scope): array { $admin = $scope->admin; $hall = $this->hallSnapshot->build(); $canDraw = $this->canDrawFinanceAndRisk($admin); $canWallet = $this->canWalletReconcile($admin); $out = [ 'hall' => $hall, 'resolved_draw' => null, 'today_finance' => null, 'lifetime_finance' => null, 'finance' => null, 'draw' => null, 'risk' => null, 'platform_risk' => null, 'result_batch_queue' => null, 'abnormal_transfer_total' => null, 'warnings' => [], 'capabilities' => [ 'draw_finance_risk' => $canDraw, 'wallet_transfer_view' => $canWallet, ], 'agent_overview' => null, ]; if ($admin->primaryAgentNode() !== null) { $out['agent_overview'] = $this->agentOverview->build($admin); } if ($canDraw) { $this->fillPlatformOverview($out, $scope); } if ($canWallet) { $out['abnormal_transfer_total'] = $this->abnormalTransferTotal($scope); } if ($hall === null) { return $out; } $drawNo = (string) ($hall['draw_no'] ?? ''); $draw = Draw::query()->where('draw_no', $drawNo)->first(); if ($draw === null) { $out['warnings'][] = [ 'code' => 'draw_row_missing', 'message' => '大厅期号在 draws 表中未找到对应行。', ]; return $out; } $out['resolved_draw'] = [ 'id' => (int) $draw->id, 'draw_no' => $draw->draw_no, ]; if ($canDraw) { $out['finance'] = $this->financeSummary($draw, $scope); $out['draw'] = $this->drawPanel($draw); $out['risk'] = $this->riskPanel($draw); } return $out; } /** @param array $out */ private function fillPlatformOverview(array &$out, AdminScopeContext $scope): void { $admin = $scope->admin; $out['today_finance'] = $this->todayFinanceSummary($scope); $out['lifetime_finance'] = $this->reportQuery->platformLifetimeTotals( $scope, ); if ($admin->isSuperAdmin() && $scope->effectiveRequestedSiteCode() === null && $scope->effectiveRequestedAgentNodeId() === null ) { $out['platform_risk'] = $this->platformRiskSummary(); $out['result_batch_queue'] = $this->resultBatchQueue(); } } private function canDrawFinanceAndRisk(AdminUser $admin): bool { return $admin->hasPermissionCode('dashboard.view') || $admin->hasPermissionCode('draw.results.view') || $admin->hasPermissionCode('draw.review.review') || $admin->hasPermissionCode('draw.review.publish') || $admin->hasPermissionCode('risk.monitor.view') || $admin->hasPermissionCode('risk.monitor.manage'); } private function canWalletReconcile(AdminUser $admin): bool { return $admin->hasPermissionCode('service.reconcile.manage') || $admin->hasPermissionCode('service.reconcile.view') || $admin->hasPermissionCode('service.wallet.view') || $admin->hasPermissionCode('service.wallet.manage'); } private function abnormalTransferTotal(AdminScopeContext $scope): int { $admin = $scope->admin; $query = TransferOrder::query() ->whereIn('status', ['processing', 'failed', 'pending_reconcile']); AdminDataScope::applyEloquentViaPlayer($query, $admin); $this->applyRequestedScopeViaPlayer($query, $scope); return (int) $query->count(); } /** * 按业务日汇总「今日」投注/派彩/盈亏(与报表中心 daily-profit 口径一致)。 * * @return array */ private function todayFinanceSummary(AdminScopeContext $scope): array { $admin = $scope->admin; $today = now()->toDateString(); $rows = $this->reportQuery->dailyProfitRows( $today, $today, $scope, ); $row = $rows[0] ?? [ 'business_date' => $today, 'total_bet_minor' => 0, 'total_payout_minor' => 0, 'approx_house_gross_minor' => 0, ]; $currencyQuery = TicketOrder::query() ->join('draws', 'draws.id', '=', 'ticket_orders.draw_id') ->where('draws.business_date', $today); AdminDataScope::applyEloquentViaPlayer($currencyQuery, $admin); $this->applyRequestedScopeViaPlayer($currencyQuery, $scope); $currencyCode = (string) ($currencyQuery->value('ticket_orders.currency_code') ?? ''); return [ 'business_date' => (string) $row['business_date'], 'currency_code' => $currencyCode !== '' ? $currencyCode : null, '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'], ]; } /** @return array */ private function financeSummary(Draw $draw, AdminScopeContext $scope): array { $admin = $scope->admin; $drawId = (int) $draw->id; $orderQuery = TicketOrder::query()->where('draw_id', $drawId); $itemQuery = TicketItem::query()->where('draw_id', $drawId); AdminDataScope::applyEloquentViaPlayer($orderQuery, $admin); AdminDataScope::applyEloquentViaPlayer($itemQuery, $admin); $this->applyRequestedScopeViaPlayer($orderQuery, $scope); $this->applyRequestedScopeViaPlayer($itemQuery, $scope); $totalBetMinor = (int) $orderQuery->sum('total_actual_deduct'); $orderCount = (int) $orderQuery->count(); $itemCount = (int) $itemQuery->count(); $currencyCode = (string) ((clone $orderQuery)->value('currency_code') ?? ''); $totalWinMinor = (int) $itemQuery->sum('win_amount'); $totalJackpotWinMinor = (int) (clone $itemQuery)->sum('jackpot_win_amount'); $totalPayoutMinor = $totalWinMinor + $totalJackpotWinMinor; $approxHouseGrossMinor = $totalBetMinor - $totalPayoutMinor; $batches = SettlementBatch::query() ->where('draw_id', $drawId) ->orderByDesc('id') ->limit(30) ->get(['id', 'status', 'total_ticket_count', 'total_win_count', 'total_payout_amount', 'total_jackpot_payout_amount', 'finished_at']); $batchRows = $batches->map(static function (SettlementBatch $b): array { return [ 'id' => (int) $b->id, 'status' => $b->status, 'total_ticket_count' => (int) $b->total_ticket_count, 'total_win_count' => (int) $b->total_win_count, 'total_payout_amount' => (int) $b->total_payout_amount, 'total_jackpot_payout_amount' => (int) $b->total_jackpot_payout_amount, 'finished_at' => $b->finished_at?->toIso8601String(), ]; })->values()->all(); return [ 'draw_id' => $drawId, 'draw_no' => $draw->draw_no, 'draw_status' => $draw->status, 'currency_code' => $currencyCode !== '' ? $currencyCode : null, 'order_count' => $orderCount, 'ticket_item_count' => $itemCount, 'total_bet_minor' => $totalBetMinor, 'total_win_payout_minor' => $totalWinMinor, 'total_jackpot_win_minor' => $totalJackpotWinMinor, 'total_payout_minor' => $totalPayoutMinor, 'approx_house_gross_minor' => $approxHouseGrossMinor, 'settlement_batches' => $batchRows, ]; } /** * 全站待审核开奖批次(首页「待审核开奖」卡片用,不限于大厅当前期)。 * * @return array{ * pending_review_total: int, * pending_draw_count: int, * published_total: int, * batch_total: int, * first_pending_draw_id: int|null, * first_pending_batch_id: int|null * } */ private function resultBatchQueue(): array { $pendingQuery = DrawResultBatch::query() ->where('status', DrawResultBatchStatus::PendingReview->value); $pendingTotal = (int) (clone $pendingQuery)->count(); $pendingDrawCount = $pendingTotal > 0 ? (int) (clone $pendingQuery)->distinct('draw_id')->count('draw_id') : 0; $firstPending = $pendingTotal > 0 ? (clone $pendingQuery)->orderBy('id')->first(['id', 'draw_id']) : null; return [ 'pending_review_total' => $pendingTotal, 'pending_draw_count' => $pendingDrawCount, 'published_total' => (int) DrawResultBatch::query() ->where('status', DrawResultBatchStatus::Published->value) ->count(), 'batch_total' => (int) DrawResultBatch::query()->count(), 'first_pending_draw_id' => $firstPending !== null ? (int) $firstPending->draw_id : null, 'first_pending_batch_id' => $firstPending !== null ? (int) $firstPending->id : null, ]; } /** @return array{locked_amount: int, cap_amount: int, usage_percent: float} */ private function platformRiskSummary(): array { $sums = RiskPool::query() ->selectRaw('COALESCE(SUM(locked_amount), 0) as locked, COALESCE(SUM(total_cap_amount), 0) as cap') ->first(); $locked = (int) (($sums?->locked) ?? 0); $cap = (int) (($sums?->cap) ?? 0); return [ 'locked_amount' => $locked, 'cap_amount' => $cap, 'usage_percent' => $cap > 0 ? round(($locked / $cap) * 100, 4) : 0.0, ]; } /** @return array */ private function drawPanel(Draw $draw): array { $nowUtc = now()->utc(); $batchCounts = [ 'total' => $draw->resultBatches()->count(), 'pending_review' => $draw->resultBatches() ->where('status', DrawResultBatchStatus::PendingReview->value) ->count(), 'published' => $draw->resultBatches() ->where('status', DrawResultBatchStatus::Published->value) ->count(), ]; return [ 'id' => (int) $draw->id, 'draw_no' => $draw->draw_no, 'business_date' => $draw->business_date instanceof Carbon ? $draw->business_date->format('Y-m-d') : (string) $draw->business_date, 'sequence_no' => (int) $draw->sequence_no, 'status' => $draw->status, 'hall_preview_status' => $this->hallSnapshot->effectiveHallDisplayStatus($draw, $nowUtc), 'result_batch_counts' => $batchCounts, ]; } /** @return array */ private function riskPanel(Draw $draw): array { $drawId = (int) $draw->id; $sums = RiskPool::query() ->where('draw_id', $drawId) ->selectRaw('COALESCE(SUM(locked_amount), 0) as locked, COALESCE(SUM(total_cap_amount), 0) as cap') ->first(); $locked = (int) (($sums?->locked) ?? 0); $cap = (int) (($sums?->cap) ?? 0); $usagePercent = $cap > 0 ? round(($locked / $cap) * 100, 4) : 0.0; $hotPools = RiskPool::query() ->where('draw_id', $drawId) ->orderByRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) DESC') ->orderByDesc('locked_amount') ->orderBy('normalized_number') ->limit(100) ->get(); $hotRows = $hotPools->map(fn (RiskPool $pool) => $this->riskPoolRow($pool))->values()->all(); $buckets = ['d4' => 0, 'd3' => 0, 'd2' => 0, 'special' => 0, 'other' => 0]; RiskPool::query() ->where('draw_id', $drawId) ->where('sold_out_status', 1) ->select(['id', 'normalized_number']) ->chunkById(500, function ($rows) use (&$buckets): void { foreach ($rows as $row) { $k = $this->soldOutBucketKey((string) $row->normalized_number); $buckets[$k]++; } }); return [ 'draw_id' => $drawId, 'draw_no' => $draw->draw_no, 'locked_amount' => $locked, 'cap_amount' => $cap, 'usage_percent' => $usagePercent, 'hot_pool_rows' => $hotRows, 'sold_out_buckets' => $buckets, ]; } /** @return array */ private function riskPoolRow(RiskPool $pool): array { $cap = (int) $pool->total_cap_amount; $locked = (int) $pool->locked_amount; return [ 'normalized_number' => $pool->normalized_number, 'total_cap_amount' => $cap, 'locked_amount' => $locked, 'remaining_amount' => (int) $pool->remaining_amount, 'sold_out_status' => (int) $pool->sold_out_status, 'is_sold_out' => (int) $pool->sold_out_status === 1, 'usage_ratio' => $cap > 0 ? round($locked / $cap, 6) : null, 'version' => (int) $pool->version, ]; } /** * 与仪表盘前端维度一致:先按「提取数字位数」分 4D/3D/2D;含字母且不足 3 位有效数字的视为特别号。 * * @return 'd4'|'d3'|'d2'|'special'|'other' */ private function soldOutBucketKey(string $normalizedNumber): string { $raw = trim($normalizedNumber); $digits = preg_replace('/\D/', '', $raw) ?? ''; $digitLen = strlen($digits); $hasLetter = preg_match('/[A-Za-z]/', $raw) === 1; if ($hasLetter && $digitLen < 3) { return 'special'; } if ($digitLen >= 4) { return 'd4'; } if ($digitLen === 3) { return 'd3'; } if ($digitLen === 2) { return 'd2'; } if ($hasLetter) { return 'special'; } return 'other'; } /** * 叠加全局 scope 参数(site_code / agent_node_id)到 player 关联模型查询。 * * @param EloquentBuilder $query */ private function applyRequestedScopeViaPlayer(EloquentBuilder $query, AdminScopeContext $scope): void { $siteCode = $scope->effectiveRequestedSiteCode(); $agentNodeId = $scope->effectiveRequestedAgentNodeId(); if ($siteCode === null && $agentNodeId === null) { return; } $admin = $scope->admin; $query->whereHas('player', static function (EloquentBuilder $playerQuery) use ($admin, $siteCode, $agentNodeId): void { if ($siteCode !== null) { $playerQuery->where('site_code', $siteCode); } if ($agentNodeId !== null) { AdminAgentScope::applyRequestedAgentNodeFilter($playerQuery, $admin, $agentNodeId); } }); } }