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.

524 lines
20 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\Reservation;
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\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
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');
$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,
],
];
}
$activityPublishRanking = $this->buildActivityPublishRanking($scopeVenueIds);
$livePeoplePayload = $this->fetchLivePeopleMapWithStatus();
$livePeopleRanking = $this->buildLivePeopleRanking($scopeVenueIds, $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<int, mixed> $scopeVenueIds
* @return list<array{venue_id: int, venue_name: string, published_count: int}>
*/
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<int, int>, debug: array<string, mixed>|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_URLservices.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<string, mixed>|null $json
* @return array<int, int> 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<int, mixed> $scopeVenueIds
* @param array<int, int> $liveByVenueId
* @return list<array{venue_id: int, venue_name: string, live_count: int}>
*/
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',
]);
}
}