You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

273 lines
10 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Activity;
use App\Models\Blacklist;
use App\Models\TicketGrabEvent;
use App\Models\TicketGrabEventVenue;
use App\Models\Venue;
use App\Services\DashboardTicketGrabStatsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DashboardController extends Controller
{
public function stats(Request $request): JsonResponse
{
$user = $request->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;
$userCount = (int) DB::table('reservations')
->whereIn('venue_id', $scopeVenueIds)
->whereNotNull('wechat_user_id')
->whereNull('deleted_at')
->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');
$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();
return response()->json([
'scope' => [
'role' => $user->role,
'venue_id' => $selectedVenueId,
'activity_id' => $activityId,
],
'summary' => [
'activity_sessions' => (int) $activitySessions,
'ticket_grab_sessions' => (int) $ticketGrabSessions,
'user_count' => (int) $userCount,
'blacklisted_unique' => (int) $blacklistedUnique,
],
'activity_stats' => [
'total_view_count' => $totalViews,
],
'activity_stats_activities' => [
'data' => $activityStatsActivities,
'total' => $totalActivities,
'page' => $page,
'page_size' => $pageSize,
],
]);
}
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',
]);
}
}