user(); $allowedVenueIds = $user->isSuperAdmin() ? Venue::query()->pluck('id') : $user->venues()->pluck('venues.id'); $selectedVenueId = $request->filled('venue_id') ? (int) $request->input('venue_id') : null; $venueIds = $allowedVenueIds; if ($selectedVenueId) { $venueIds = $venueIds->filter(fn ($id) => (int) $id === $selectedVenueId)->values(); } $activityId = $request->filled('activity_id') ? (int) $request->input('activity_id') : null; if ($activityId) { $allowed = Activity::query() ->whereKey($activityId) ->whereNull('deleted_at') ->whereIn('venue_id', $venueIds) ->exists(); if (! $allowed) { return response()->json(['message' => '活动不存在或无权查看'], 422); } } /** 顶部指标:账号可见场馆范围内的全量累计(不受「筛选场馆」影响) */ $scopeVenueIds = $allowedVenueIds; $userCountBase = DB::table('reservations') ->whereIn('venue_id', $scopeVenueIds) ->whereNotNull('wechat_user_id') ->whereNull('deleted_at'); /** 场馆管理员:仅统计预约了本场馆「活动」的用户(不含抢票等) */ if ($user->role === 'venue_admin') { $userCountBase->where(function ($q) { $q->whereNull('reservation_kind') ->orWhere('reservation_kind', Reservation::KIND_ACTIVITY); }); } $userCount = (int) $userCountBase->selectRaw('COUNT(DISTINCT wechat_user_id) as c')->value('c'); $activitySessions = Activity::query() ->whereIn('venue_id', $scopeVenueIds) ->whereNull('deleted_at') ->count(); $ticketGrabSessions = TicketGrabEvent::query() ->whereHas('venues', fn ($vq) => $vq->whereIn('venues.id', $scopeVenueIds)) ->count(); $blacklistedUniqueQuery = Blacklist::query() ->active() ->whereIn('venue_id', $scopeVenueIds) ->whereNotNull('visitor_phone') ->whereRaw("TRIM(visitor_phone) <> ''") ->whereExists(function ($q) use ($scopeVenueIds, $user) { $q->selectRaw('1') ->from('reservations') ->whereNull('reservations.deleted_at') ->whereColumn('reservations.visitor_phone', 'blacklists.visitor_phone') ->whereIn('reservations.venue_id', $scopeVenueIds); if ($user->isSuperAdmin() && Schema::hasTable('wechat_users')) { $q->whereNotNull('reservations.wechat_user_id') ->whereExists(function ($wq) { $wq->selectRaw('1') ->from('wechat_users') ->whereColumn('wechat_users.id', 'reservations.wechat_user_id'); }); } }); $blacklistedUnique = $blacklistedUniqueQuery ->distinct('visitor_phone') ->count('visitor_phone'); $venuesCount = $scopeVenueIds->isEmpty() ? 0 : (int) Venue::query()->whereIn('id', $scopeVenueIds)->count(); /** 与核心指标同范围:账号可见全部场馆(不受当前筛选场馆影响) */ $activityScheduleBase = Activity::query() ->whereIn('venue_id', $scopeVenueIds) ->whereNull('deleted_at'); $activityScheduleCounts = [ 'total' => (clone $activityScheduleBase)->count(), 'not_started' => (clone $activityScheduleBase)->whereComputedScheduleStatus('not_started')->count(), 'ongoing' => (clone $activityScheduleBase)->whereComputedScheduleStatus('ongoing')->count(), 'ended' => (clone $activityScheduleBase)->whereComputedScheduleStatus('ended')->count(), ]; $ticketGrabScheduleBase = TicketGrabEvent::query() ->whereHas('venues', fn ($vq) => $vq->whereIn('venues.id', $scopeVenueIds)); $ticketGrabEventIdsForScope = (clone $ticketGrabScheduleBase)->pluck('id'); $verifiedPeople = 0; $bookedPeople = 0; if ($ticketGrabEventIdsForScope->isNotEmpty()) { $tgReservations = Reservation::query() ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) ->whereIn('ticket_grab_event_id', $ticketGrabEventIdsForScope) ->where('status', '!=', 'cancelled') ->whereNull('deleted_at') ->get(['ticket_count', 'verified_at']); $bookedPeople = (int) $tgReservations->sum(fn (Reservation $r) => max(1, (int) ($r->ticket_count ?? 1))); $verifiedPeople = (int) $tgReservations ->filter(fn (Reservation $r) => $r->verified_at !== null) ->sum(fn (Reservation $r) => max(1, (int) ($r->ticket_count ?? 1))); } $verifyRatePct = $bookedPeople > 0 ? round(100 * $verifiedPeople / $bookedPeople, 1) : null; $ticketGrabScheduleCounts = [ 'total' => (clone $ticketGrabScheduleBase)->count(), 'not_started' => (clone $ticketGrabScheduleBase)->whereComputedScheduleStatus('not_started')->count(), 'ongoing' => (clone $ticketGrabScheduleBase)->whereComputedScheduleStatus('ongoing')->count(), 'ended' => (clone $ticketGrabScheduleBase)->whereComputedScheduleStatus('ended')->count(), 'verify_rate_pct' => $verifyRatePct, 'verified_people' => $verifiedPeople, 'booked_people' => $bookedPeople, ]; $baseActivityQuery = Activity::query() ->whereIn('venue_id', $venueIds) ->whereNull('deleted_at'); if ($activityId !== null) { $baseActivityQuery->whereKey($activityId); } $totalViews = (int) (clone $baseActivityQuery)->sum('view_count'); $page = max(1, (int) $request->input('activity_stats_page', 1)); $pageSize = max(1, min(2000, (int) $request->input('activity_stats_page_size', 500))); $totalActivities = (clone $baseActivityQuery)->count(); $activityRows = (clone $baseActivityQuery) ->orderByDesc('view_count') ->orderByDesc('id') ->forPage($page, $pageSize) ->get(['id', 'title', 'venue_id', 'start_at', 'end_at', 'view_count']); $venueIdsForNames = $activityRows->pluck('venue_id')->unique()->filter()->values()->all(); $venueNameMap = $venueIdsForNames === [] ? collect() : Venue::query()->whereIn('id', $venueIdsForNames)->pluck('name', 'id'); $activityStatsActivities = $activityRows->map(function ($a) use ($venueNameMap) { $vid = (int) $a->venue_id; return [ 'id' => (int) $a->id, 'title' => (string) $a->title, 'venue_id' => $vid, 'venue_name' => (string) ($venueNameMap[$vid] ?? ('#'.$vid)), 'view_count' => (int) ($a->view_count ?? 0), 'start_at' => $a->start_at, 'end_at' => $a->end_at, ]; })->values()->all(); $pendingAudits = null; if ($user->isSuperAdmin()) { $activityPendingCount = (int) Activity::query() ->whereNull('deleted_at') ->where('audit_status', Activity::AUDIT_PENDING) ->count(); $activityItems = Activity::query() ->whereNull('deleted_at') ->where('audit_status', Activity::AUDIT_PENDING) ->with(['venue:id,name']) ->orderByDesc('updated_at') ->limit(8) ->get(['id', 'title', 'venue_id', 'updated_at']) ->map(function (Activity $a) { return [ 'id' => $a->id, 'title' => $a->title, 'venue_name' => (string) ($a->venue?->name ?? ''), 'updated_at' => $a->updated_at, ]; }) ->values() ->all(); $pendingAudits = [ 'activities' => [ 'count' => $activityPendingCount, 'items' => $activityItems, ], ]; } elseif ($user->role === 'venue_admin') { /** 待办:本账号场馆下、审核已退回的活动(供前台修改后重新提交) */ if ($scopeVenueIds->isEmpty()) { $pendingAudits = [ 'activities' => [ 'count' => 0, 'items' => [], ], ]; } else { $rejectedCount = (int) Activity::query() ->whereIn('venue_id', $scopeVenueIds) ->whereNull('deleted_at') ->where('audit_status', Activity::AUDIT_REJECTED) ->count(); $rejectedItems = Activity::query() ->whereIn('venue_id', $scopeVenueIds) ->whereNull('deleted_at') ->where('audit_status', Activity::AUDIT_REJECTED) ->with(['venue:id,name']) ->orderByDesc('updated_at') ->limit(8) ->get(['id', 'title', 'venue_id', 'updated_at']) ->map(function (Activity $a) { return [ 'id' => $a->id, 'title' => $a->title, 'venue_name' => (string) ($a->venue?->name ?? ''), 'updated_at' => $a->updated_at, ]; }) ->values() ->all(); $pendingAudits = [ 'activities' => [ 'count' => $rejectedCount, 'items' => $rejectedItems, ], ]; } } /** 场馆管理员的排行榜口径与超级管理员一致(全场馆)。 */ $rankingScopeVenueIds = $user->role === 'venue_admin' ? Venue::query()->pluck('id') : $scopeVenueIds; $activityPublishRanking = $this->buildActivityPublishRanking($rankingScopeVenueIds); $livePeoplePayload = $this->fetchLivePeopleMapWithStatus(); $livePeopleRanking = $this->buildLivePeopleRanking($rankingScopeVenueIds, $livePeoplePayload['map']); return response()->json([ 'scope' => [ 'role' => $user->role, 'venue_id' => $selectedVenueId, 'activity_id' => $activityId, ], 'summary' => [ 'activity_sessions' => (int) $activitySessions, 'venues_count' => $venuesCount, 'ticket_grab_sessions' => (int) $ticketGrabSessions, 'user_count' => (int) $userCount, 'blacklisted_unique' => (int) $blacklistedUnique, ], 'pending_audits' => $pendingAudits, 'activity_schedule_counts' => $activityScheduleCounts, 'ticket_grab_schedule_counts' => $ticketGrabScheduleCounts, 'activity_stats' => [ 'total_view_count' => $totalViews, ], 'activity_stats_activities' => [ 'data' => $activityStatsActivities, 'total' => $totalActivities, 'page' => $page, 'page_size' => $pageSize, ], 'activity_publish_ranking' => $activityPublishRanking, 'live_people_ranking' => $livePeopleRanking, 'live_people_counting_ok' => $livePeoplePayload['ok'], 'live_people_counting_debug' => $livePeoplePayload['debug'], ]); } /** * @param Collection $scopeVenueIds * @return list */ private function buildActivityPublishRanking(Collection $scopeVenueIds): array { if ($scopeVenueIds->isEmpty()) { return []; } return Venue::query() ->whereIn('id', $scopeVenueIds) ->withCount(['activities as published_count']) ->having('published_count', '>', 0) ->orderByDesc('published_count') ->orderBy('id') ->limit(50) ->get(['id', 'name']) ->map(fn (Venue $v) => [ 'venue_id' => (int) $v->id, 'venue_name' => (string) $v->name, 'published_count' => (int) $v->published_count, ]) ->values() ->all(); } /** * @return array{ok: bool, map: array, debug: array|null} */ private function fetchLivePeopleMapWithStatus(): array { $url = config('services.people_counting.url'); if (! is_string($url) || trim($url) === '') { return [ 'ok' => false, 'map' => [], 'debug' => [ 'stage' => 'config', 'message' => '未配置 PEOPLE_COUNTING_URL(services.people_counting.url)', ], ]; } try { $res = Http::timeout(12)->acceptJson()->get($url); $status = $res->status(); $body = $res->body(); $json = $res->json(); $map = $this->parsePeopleCountingVenueLiveMap(is_array($json) ? $json : null); $ok = $res->successful() && is_array($json) && (int) ($json['code'] ?? 0) === 200; if ($ok) { return ['ok' => true, 'map' => $map, 'debug' => null]; } return [ 'ok' => false, 'map' => $map, 'debug' => [ 'stage' => 'remote_response', 'request_url' => $url, 'http_status' => $status, 'http_ok' => $res->successful(), 'parsed_code' => is_array($json) ? ($json['code'] ?? null) : null, 'parsed_message' => is_array($json) ? ($json['message'] ?? null) : null, 'response_json' => is_array($json) ? $json : null, 'response_body_preview' => mb_substr($body, 0, 6000), ], ]; } catch (\Throwable $e) { return [ 'ok' => false, 'map' => [], 'debug' => [ 'stage' => 'exception', 'request_url' => is_string($url) ? $url : null, 'exception_class' => $e::class, 'message' => $e->getMessage(), ], ]; } } /** * @param array|null $json * @return array venueId => max(0, enter - exit) */ private function parsePeopleCountingVenueLiveMap(?array $json): array { if (! is_array($json) || ! is_array($json['venues'] ?? null)) { return []; } $out = []; foreach ($json['venues'] as $row) { if (! is_array($row)) { continue; } $id = (int) trim((string) ($row['venueId'] ?? '')); if ($id <= 0) { continue; } $enter = (int) ($row['enter'] ?? 0); $exit = (int) ($row['exit'] ?? 0); $out[$id] = max(0, $enter - $exit); } return $out; } /** * 与 H5 /pages/stats/index 一致:仅「纳入人数统计」的场馆;仅返回在馆人数大于 0 的条目,按人数降序. * * @param Collection $scopeVenueIds * @param array $liveByVenueId * @return list */ private function buildLivePeopleRanking(Collection $scopeVenueIds, array $liveByVenueId): array { if ($scopeVenueIds->isEmpty()) { return []; } $venues = Venue::query() ->whereIn('id', $scopeVenueIds) ->where('is_included_in_stats', true) ->orderBy('sort') ->orderByDesc('id') ->get(['id', 'name']); $rows = []; foreach ($venues as $v) { $id = (int) $v->id; if (! array_key_exists($id, $liveByVenueId)) { continue; } $live = (int) $liveByVenueId[$id]; if ($live <= 0) { continue; } $rows[] = [ 'venue_id' => $id, 'venue_name' => (string) $v->name, 'live_count' => $live, ]; } usort($rows, function (array $a, array $b): int { if ($b['live_count'] !== $a['live_count']) { return $b['live_count'] <=> $a['live_count']; } return $a['venue_id'] <=> $b['venue_id']; }); return array_values($rows); } public function ticketGrabStats(Request $request, DashboardTicketGrabStatsService $ticketGrabStats): JsonResponse { $validated = $request->validate([ 'ticket_grab_event_id' => ['required', 'integer', 'exists:ticket_grab_events,id'], 'date' => ['nullable', 'date_format:Y-m-d'], ]); $user = $request->user(); $allowedVenueIds = $user->isSuperAdmin() ? Venue::query()->pluck('id') : $user->venues()->pluck('venues.id'); $eventId = (int) $validated['ticket_grab_event_id']; $date = isset($validated['date']) && $validated['date'] !== '' ? (string) $validated['date'] : now()->format('Y-m-d'); $pivotVenues = TicketGrabEventVenue::query() ->where('ticket_grab_event_id', $eventId) ->pluck('venue_id'); if ($pivotVenues->isNotEmpty() && ! $user->isSuperAdmin()) { $allow = $allowedVenueIds->map(fn ($id) => (int) $id); $ok = false; foreach ($pivotVenues as $vid) { if ($allow->contains((int) $vid)) { $ok = true; break; } } if (! $ok) { return response()->json(['message' => '无权查看该抢票活动统计'], 403); } } $scoped = DashboardTicketGrabStatsService::scopedVenueIdsForEvent($eventId, $allowedVenueIds); if ($scoped->isEmpty()) { return response()->json(['message' => '当前账号下无可统计的场馆或未配置参与场馆'], 403); } $payload = $ticketGrabStats->build($eventId, $date, $scoped); if (isset($payload['error'])) { return response()->json(['message' => $payload['error']], 404); } $payload['data_updated_at'] = $date; return response()->json($payload); } /** * 抢票每日核销统计 Excel(行=场馆,列=活动期内各日 + 合计占比) */ public function ticketGrabDailyVerifyExport(Request $request, DashboardTicketGrabStatsService $ticketGrabStats): StreamedResponse { $validated = $request->validate([ 'ticket_grab_event_id' => ['required', 'integer', 'exists:ticket_grab_events,id'], ]); $user = $request->user(); $allowedVenueIds = $user->isSuperAdmin() ? Venue::query()->pluck('id') : $user->venues()->pluck('venues.id'); $eventId = (int) $validated['ticket_grab_event_id']; $pivotVenues = TicketGrabEventVenue::query() ->where('ticket_grab_event_id', $eventId) ->pluck('venue_id'); if ($pivotVenues->isNotEmpty() && ! $user->isSuperAdmin()) { $allow = $allowedVenueIds->map(fn ($id) => (int) $id); $ok = false; foreach ($pivotVenues as $vid) { if ($allow->contains((int) $vid)) { $ok = true; break; } } if (! $ok) { abort(403, '无权查看该抢票活动统计'); } } $scoped = DashboardTicketGrabStatsService::scopedVenueIdsForEvent($eventId, $allowedVenueIds); if ($scoped->isEmpty()) { abort(403, '当前账号下无可统计的场馆或未配置参与场馆'); } $matrix = $ticketGrabStats->buildDailyVerifyMatrixPublic($eventId, $scoped); if (isset($matrix['error'])) { abort(404, $matrix['error']); } $labels = $matrix['date_labels'] ?? []; $rows = $matrix['rows'] ?? []; $table = [array_merge(['场馆'], $labels, ['总人数和核销比'])]; foreach ($rows as $r) { $line = [$r['venue_name'] ?? '']; foreach (($r['cells'] ?? []) as $c) { $line[] = $c['display'] ?? ''; } $line[] = ($r['total_cell'] ?? [])['display'] ?? ''; $table[] = $line; } $spreadsheet = new Spreadsheet; $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('每日核销统计'); $sheet->fromArray($table, null, 'A1'); $writer = new Xlsx($spreadsheet); $filename = 'ticket-grab-daily-verify-'.$eventId.'-'.now()->format('Ymd-His').'.xlsx'; return response()->streamDownload(function () use ($writer) { $writer->save('php://output'); }, $filename, [ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ]); } }