*/ public function build(AdminUser $admin): array { $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, 'abnormal_transfer_total' => null, 'warnings' => [], 'capabilities' => [ 'draw_finance_risk' => $canDraw, 'wallet_transfer_view' => $canWallet, ], ]; 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['today_finance'] = $this->todayFinanceSummary(); $out['lifetime_finance'] = $this->reportQuery->platformLifetimeTotals(); $out['finance'] = $this->financeSummary($draw); $out['draw'] = $this->drawPanel($draw); $out['risk'] = $this->riskPanel($draw); } if ($canWallet) { $out['abnormal_transfer_total'] = $this->abnormalTransferTotal(); } return $out; } private function canDrawFinanceAndRisk(AdminUser $admin): bool { return $admin->hasAdminPermission('prd.dashboard.view') || $admin->hasAdminPermission('prd.draw_result.manage') || $admin->hasAdminPermission('prd.draw_result.view') || $admin->hasAdminPermission('prd.risk.view') || $admin->hasAdminPermission('prd.risk.manage'); } private function canWalletReconcile(AdminUser $admin): bool { return $admin->hasAdminPermission('prd.wallet_reconcile.manage') || $admin->hasAdminPermission('prd.wallet_reconcile.view') || $admin->hasAdminPermission('prd.wallet_reconcile.view_cs'); } private function abnormalTransferTotal(): int { return (int) TransferOrder::query() ->whereIn('status', ['processing', 'failed', 'pending_reconcile']) ->count(); } /** * 按业务日汇总「今日」投注/派彩/盈亏(与报表中心 daily-profit 口径一致)。 * * @return array */ private function todayFinanceSummary(): array { $today = now()->toDateString(); $rows = $this->reportQuery->dailyProfitRows($today, $today); $row = $rows[0] ?? [ 'business_date' => $today, 'total_bet_minor' => 0, 'total_payout_minor' => 0, 'approx_house_gross_minor' => 0, ]; $currencyCode = (string) (TicketOrder::query() ->join('draws', 'draws.id', '=', 'ticket_orders.draw_id') ->where('draws.business_date', $today) ->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): array { $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; $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 */ 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'; } }