From d118c9230dcdcc63d678578385445917e4cae0a8 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Tue, 28 Apr 2026 00:38:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=A5=E5=90=8D=E5=A4=B4=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Api/DashboardController.php | 373 +++++++++------ .../Controllers/Api/H5ContentController.php | 6 +- app/Http/Controllers/Api/H5HomeController.php | 2 +- .../Controllers/Api/StudyTourController.php | 38 +- .../Api/TicketGrabEventController.php | 8 + app/Models/StudyTour.php | 4 +- .../DashboardTicketGrabStatsService.php | 423 ++++++++++++++++++ app/Services/TicketGrabReleaseDayService.php | 33 +- ...0_add_is_on_shelf_to_study_tours_table.php | 23 + ..._drop_is_active_from_study_tours_table.php | 31 ++ routes/api.php | 1 + 11 files changed, 796 insertions(+), 146 deletions(-) create mode 100644 app/Services/DashboardTicketGrabStatsService.php create mode 100644 database/migrations/2026_04_27_100000_add_is_on_shelf_to_study_tours_table.php create mode 100644 database/migrations/2026_04_27_120000_drop_is_active_from_study_tours_table.php diff --git a/app/Http/Controllers/Api/DashboardController.php b/app/Http/Controllers/Api/DashboardController.php index 0c266ef..6e51040 100644 --- a/app/Http/Controllers/Api/DashboardController.php +++ b/app/Http/Controllers/Api/DashboardController.php @@ -3,27 +3,26 @@ 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\RealtimeCrowdService; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Schema; +use App\Services\DashboardTicketGrabStatsService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; class DashboardController extends Controller { - public function __construct(private readonly RealtimeCrowdService $realtimeCrowdService) - { - } - public function stats(Request $request): JsonResponse { $user = $request->user(); $allowedVenueIds = $user->isSuperAdmin() ? Venue::query()->pluck('id') : $user->venues()->pluck('venues.id'); - $ownVenueIds = $user->venues()->pluck('venues.id')->map(fn ($id) => (int) $id)->values(); $selectedVenueId = $request->filled('venue_id') ? (int) $request->input('venue_id') : null; $venueIds = $allowedVenueIds; @@ -31,42 +30,57 @@ class DashboardController extends Controller $venueIds = $venueIds->filter(fn ($id) => (int) $id === $selectedVenueId)->values(); } - $startDate = $request->input('start_date') ?: now()->subDays(29)->toDateString(); - $endDate = $request->input('end_date') ?: now()->toDateString(); - - $base = DB::table('reservations') - ->leftJoin('activity_days', 'activity_days.id', '=', 'reservations.activity_day_id') - ->whereIn('reservations.venue_id', $venueIds) - ->whereRaw("COALESCE(activity_days.activity_date, DATE(reservations.created_at)) >= ?", [$startDate]) - ->whereRaw("COALESCE(activity_days.activity_date, DATE(reservations.created_at)) <= ?", [$endDate]); - - $summary = (clone $base) - ->selectRaw('COUNT(*) as total_count') - ->selectRaw("SUM(CASE WHEN reservations.status = 'verified' THEN 1 ELSE 0 END) as verified_count") - ->selectRaw("SUM(CASE WHEN reservations.status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_count") - ->selectRaw("SUM(CASE WHEN reservations.status = 'pending' THEN 1 ELSE 0 END) as pending_count") - ->selectRaw("SUM(CASE WHEN reservations.status = 'expired' THEN 1 ELSE 0 END) as expired_count") - ->first(); - - $totalCount = (int) ($summary->total_count ?? 0); - $verifiedCount = (int) ($summary->verified_count ?? 0); - $cancelledCount = (int) ($summary->cancelled_count ?? 0); - $pendingCount = (int) ($summary->pending_count ?? 0); - $expiredCount = (int) ($summary->expired_count ?? 0); - $effectiveCount = max(0, $totalCount - $cancelledCount); - $verifyRate = $effectiveCount > 0 ? round($verifiedCount / $effectiveCount, 4) : 0; + $datesBlank = ! $request->filled('start_date') || ! $request->filled('end_date'); + $startDate = $datesBlank ? null : trim((string) $request->input('start_date')); + $endDate = $datesBlank ? null : trim((string) $request->input('end_date')); + + $activityId = null; + if (! $datesBlank && $request->filled('activity_id')) { + $activityId = (int) $request->input('activity_id'); + $allowed = Activity::query() + ->whereKey($activityId) + ->whereNull('deleted_at') + ->whereIn('venue_id', $venueIds) + ->exists(); + if (! $allowed) { + return response()->json(['message' => '活动不存在或无权查看'], 422); + } + } + + $dateOnReservation = 'COALESCE(activity_days.activity_date, DATE(reservations.created_at))'; + + /** 顶部五项:账号可见场馆范围内的全量累计,不受日期与「筛选场馆」影响 */ + $scopeVenueIds = $allowedVenueIds; + $activeVenueCount = (int) DB::table('reservations') + ->whereIn('venue_id', $scopeVenueIds) + ->distinct('venue_id') + ->count('venue_id'); + + $userCount = (int) DB::table('reservations') + ->whereIn('venue_id', $scopeVenueIds) + ->whereNotNull('wechat_user_id') + ->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() - ->whereIn('venue_id', $venueIds) + ->whereIn('venue_id', $scopeVenueIds) ->whereNotNull('visitor_phone') ->whereRaw("TRIM(visitor_phone) <> ''") - ->whereExists(function ($q) use ($venueIds, $user) { + ->whereExists(function ($q) use ($scopeVenueIds, $user) { $q->selectRaw('1') ->from('reservations') ->whereColumn('reservations.visitor_phone', 'blacklists.visitor_phone') - ->whereIn('reservations.venue_id', $venueIds); + ->whereIn('reservations.venue_id', $scopeVenueIds); - // 与用户管理(super_admin)口径对齐:仅统计可映射到 wechat_users 的用户手机号 if ($user->isSuperAdmin() && Schema::hasTable('wechat_users')) { $q->whereNotNull('reservations.wechat_user_id') ->whereExists(function ($wq) { @@ -80,10 +94,18 @@ class DashboardController extends Controller ->distinct('visitor_phone') ->count('visitor_phone'); - $activitySessions = DB::table('activities') - ->whereIn('venue_id', $venueIds) - ->whereNull('deleted_at') - ->where(function ($q) use ($startDate, $endDate) { + $publishedCount = 0; + $activitySums = null; + $arTotal = 0; + $arVerified = 0; + $arCancelled = 0; + $arPending = 0; + $arExpired = 0; + $arVerifyRate = 0.0; + $activityStatsByVenue = []; + + if (! $datesBlank) { + $activityOverlap = function ($q) use ($startDate, $endDate) { $q->where(function ($w) use ($startDate, $endDate) { $w->whereNotNull('start_at') ->whereNotNull('end_at') @@ -95,86 +117,124 @@ class DashboardController extends Controller ->whereDate('created_at', '>=', $startDate) ->whereDate('created_at', '<=', $endDate); }); - }) - ->count(); + }; + + $statsActivityIdsQuery = Activity::query() + ->whereIn('venue_id', $venueIds) + ->whereNull('deleted_at'); + if ($activityId !== null) { + $statsActivityIdsQuery->whereKey($activityId); + } else { + $statsActivityIdsQuery->where($activityOverlap); + } + $statsActivityIds = $statsActivityIdsQuery->pluck('id'); + + $publishedCount = $statsActivityIds->isEmpty() + ? 0 + : (int) Activity::query() + ->whereIn('id', $statsActivityIds) + ->whereDate('created_at', '>=', $startDate) + ->whereDate('created_at', '<=', $endDate) + ->count(); + + $activitySums = $statsActivityIds->isEmpty() + ? null + : Activity::query() + ->whereIn('id', $statsActivityIds) + ->selectRaw('COALESCE(SUM(view_count),0) as v') + ->selectRaw('COALESCE(SUM(external_link_click_count),0) as l') + ->first(); + + if ($statsActivityIds->isNotEmpty()) { + $ar = DB::table('reservations') + ->leftJoin('activity_days', 'activity_days.id', '=', 'reservations.activity_day_id') + ->whereIn('reservations.venue_id', $venueIds) + ->where('reservations.reservation_kind', Reservation::KIND_ACTIVITY) + ->whereIn('reservations.activity_id', $statsActivityIds) + ->whereRaw("{$dateOnReservation} >= ?", [$startDate]) + ->whereRaw("{$dateOnReservation} <= ?", [$endDate]) + ->selectRaw('COUNT(*) as total_count') + ->selectRaw("SUM(CASE WHEN reservations.status = 'verified' THEN 1 ELSE 0 END) as verified_count") + ->selectRaw("SUM(CASE WHEN reservations.status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_count") + ->selectRaw("SUM(CASE WHEN reservations.status = 'pending' THEN 1 ELSE 0 END) as pending_count") + ->selectRaw("SUM(CASE WHEN reservations.status = 'expired' THEN 1 ELSE 0 END) as expired_count") + ->first(); + + $arTotal = (int) ($ar->total_count ?? 0); + $arVerified = (int) ($ar->verified_count ?? 0); + $arCancelled = (int) ($ar->cancelled_count ?? 0); + $arPending = (int) ($ar->pending_count ?? 0); + $arExpired = (int) ($ar->expired_count ?? 0); + $effective = max(0, $arTotal - $arCancelled); + $arVerifyRate = $effective > 0 ? round($arVerified / $effective, 4) : 0; + } + + if ($statsActivityIds->isNotEmpty()) { + $actByVenueRows = Activity::query() + ->whereIn('id', $statsActivityIds) + ->selectRaw('venue_id') + ->selectRaw('COALESCE(SUM(view_count), 0) as total_view_count') + ->selectRaw('COALESCE(SUM(external_link_click_count), 0) as total_external_link_click_count') + ->selectRaw('SUM(CASE WHEN DATE(created_at) >= ? AND DATE(created_at) <= ? THEN 1 ELSE 0 END) as published_count', [$startDate, $endDate]) + ->groupBy('venue_id') + ->get(); + $actByVenue = []; + foreach ($actByVenueRows as $row) { + $actByVenue[(int) $row->venue_id] = $row; + } + + $resByVenueRows = DB::table('reservations') + ->leftJoin('activity_days', 'activity_days.id', '=', 'reservations.activity_day_id') + ->whereIn('reservations.venue_id', $venueIds) + ->where('reservations.reservation_kind', Reservation::KIND_ACTIVITY) + ->whereIn('reservations.activity_id', $statsActivityIds) + ->whereRaw("{$dateOnReservation} >= ?", [$startDate]) + ->whereRaw("{$dateOnReservation} <= ?", [$endDate]) + ->selectRaw('reservations.venue_id as venue_id') + ->selectRaw('COUNT(*) as total_count') + ->selectRaw("SUM(CASE WHEN reservations.status = 'verified' THEN 1 ELSE 0 END) as verified_count") + ->selectRaw("SUM(CASE WHEN reservations.status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_count") + ->selectRaw("SUM(CASE WHEN reservations.status = 'pending' THEN 1 ELSE 0 END) as pending_count") + ->selectRaw("SUM(CASE WHEN reservations.status = 'expired' THEN 1 ELSE 0 END) as expired_count") + ->groupBy('reservations.venue_id') + ->get(); + $resByVenue = []; + foreach ($resByVenueRows as $row) { + $resByVenue[(int) $row->venue_id] = $row; + } + + $allVenueIds = collect(array_keys($actByVenue))->merge(array_keys($resByVenue))->unique()->sort()->values(); + $names = Venue::query()->whereIn('id', $allVenueIds)->pluck('name', 'id'); + + foreach ($allVenueIds as $vid) { + $vid = (int) $vid; + $a = $actByVenue[$vid] ?? null; + $r = $resByVenue[$vid] ?? null; + $total = (int) ($r->total_count ?? 0); + $verified = (int) ($r->verified_count ?? 0); + $cancelled = (int) ($r->cancelled_count ?? 0); + $pending = (int) ($r->pending_count ?? 0); + $expired = (int) ($r->expired_count ?? 0); + $effective = max(0, $total - $cancelled); + $rate = $effective > 0 ? round($verified / $effective, 4) : 0.0; + $activityStatsByVenue[] = [ + 'venue_id' => (int) $vid, + 'venue_name' => (string) ($names[$vid] ?? ('#'.$vid)), + 'published_count' => (int) ($a->published_count ?? 0), + 'total_view_count' => (int) ($a->total_view_count ?? 0), + 'total_external_link_click_count' => (int) ($a->total_external_link_click_count ?? 0), + 'total_count' => $total, + 'verified_count' => $verified, + 'cancelled_count' => $cancelled, + 'pending_count' => $pending, + 'expired_count' => $expired, + 'verify_rate' => $rate, + ]; + } - $activeVenueCount = (clone $base) - ->distinct('reservations.venue_id') - ->count('reservations.venue_id'); - - $trends = (clone $base) - ->selectRaw("COALESCE(activity_days.activity_date, DATE(reservations.created_at)) as date") - ->selectRaw("COUNT(*) as total_count") - ->selectRaw("SUM(CASE WHEN reservations.status = 'verified' THEN 1 ELSE 0 END) as verified_count") - ->selectRaw("SUM(CASE WHEN reservations.status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_count") - ->selectRaw("SUM(CASE WHEN reservations.status = 'expired' THEN 1 ELSE 0 END) as expired_count") - ->groupBy('date') - ->orderBy('date') - ->get() - ->map(function ($row) { - $total = (int) ($row->total_count ?? 0); - $verified = (int) ($row->verified_count ?? 0); - $cancelled = (int) ($row->cancelled_count ?? 0); - $expired = (int) ($row->expired_count ?? 0); - $effective = max(0, $total - $cancelled); - return [ - 'date' => $row->date, - 'total_count' => $total, - 'verified_count' => $verified, - 'cancelled_count' => $cancelled, - 'expired_count' => $expired, - 'verify_rate' => $effective > 0 ? round($verified / $effective, 4) : 0, - ]; - }) - ->values(); - - $compareVenues = DB::table('reservations') - ->leftJoin('activity_days', 'activity_days.id', '=', 'reservations.activity_day_id') - ->join('venues', 'venues.id', '=', 'reservations.venue_id') - ->whereIn('reservations.venue_id', $venueIds) - ->whereRaw("COALESCE(activity_days.activity_date, DATE(reservations.created_at)) >= ?", [$startDate]) - ->whereRaw("COALESCE(activity_days.activity_date, DATE(reservations.created_at)) <= ?", [$endDate]) - ->selectRaw('reservations.venue_id, venues.name as venue_name') - ->selectRaw('COUNT(*) as total_count') - ->selectRaw("SUM(CASE WHEN reservations.status = 'verified' THEN 1 ELSE 0 END) as verified_count") - ->selectRaw("SUM(CASE WHEN reservations.status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_count") - ->groupBy('reservations.venue_id', 'venues.name') - ->orderByDesc('total_count') - ->get() - ->map(function ($row) { - $total = (int) ($row->total_count ?? 0); - $verified = (int) ($row->verified_count ?? 0); - $cancelled = (int) ($row->cancelled_count ?? 0); - $effective = max(0, $total - $cancelled); - $verifyRate = $effective > 0 ? round($verified / $effective, 4) : 0; - $cancelRate = $total > 0 ? round($cancelled / $total, 4) : 0; - $sampleEnough = $total >= 10; - return [ - 'venue_id' => (int) $row->venue_id, - 'venue_name' => $row->venue_name, - 'total_count' => $total, - 'verified_count' => $verified, - 'cancelled_count' => $cancelled, - 'verify_rate' => $verifyRate, - 'cancel_rate' => $cancelRate, - 'warning_low_verify' => $sampleEnough && $verifyRate < 0.4, - 'warning_high_cancel' => $sampleEnough && $cancelRate > 0.3, - ]; - }) - ->values(); - - $realtimeAll = collect($this->realtimeCrowdService->venueCurrentMap($venueIds)); - if (!$user->isSuperAdmin() && $ownVenueIds->isNotEmpty()) { - $mine = $realtimeAll->filter(fn ($v) => $ownVenueIds->contains((int) $v['venue_id']))->sortByDesc('current_count')->values(); - $others = $realtimeAll->filter(fn ($v) => !$ownVenueIds->contains((int) $v['venue_id']))->sortByDesc('current_count')->values(); - $realtimeAll = $mine->concat($others)->values(); - } else { - $realtimeAll = $realtimeAll->sortByDesc('current_count')->values(); + usort($activityStatsByVenue, fn (array $x, array $y) => strcmp($x['venue_name'], $y['venue_name'])); + } } - $realtime = [ - 'city_total' => (int) $realtimeAll->sum('current_count'), - 'venues' => $realtimeAll->all(), - ]; return response()->json([ 'scope' => [ @@ -182,22 +242,75 @@ class DashboardController extends Controller 'venue_id' => $selectedVenueId, 'start_date' => $startDate, 'end_date' => $endDate, + 'activity_id' => $activityId, + 'dates_applied' => ! $datesBlank, ], 'summary' => [ - 'total_count' => $totalCount, - 'verified_count' => $verifiedCount, - 'cancelled_count' => $cancelledCount, - 'pending_count' => $pendingCount, - 'expired_count' => $expiredCount, - 'effective_count' => $effectiveCount, - 'verify_rate' => $verifyRate, - 'blacklisted_unique' => (int) $blacklistedUnique, - 'activity_sessions' => (int) $activitySessions, 'active_venue_count' => (int) $activeVenueCount, + 'activity_sessions' => (int) $activitySessions, + 'ticket_grab_sessions' => (int) $ticketGrabSessions, + 'user_count' => (int) $userCount, + 'blacklisted_unique' => (int) $blacklistedUnique, + ], + 'activity_stats' => [ + 'published_count' => $publishedCount, + 'total_view_count' => (int) ($activitySums->v ?? 0), + 'total_external_link_click_count' => (int) ($activitySums->l ?? 0), + 'total_count' => $arTotal, + 'verified_count' => $arVerified, + 'cancelled_count' => $arCancelled, + 'pending_count' => $arPending, + 'expired_count' => $arExpired, + 'verify_rate' => $arVerifyRate, ], - 'trends' => $trends, - 'compare_venues' => $compareVenues, - 'realtime' => $realtime, + 'activity_stats_venues' => $activityStatsByVenue, ]); } + + public function ticketGrabStats(Request $request, DashboardTicketGrabStatsService $ticketGrabStats): JsonResponse + { + $validated = $request->validate([ + 'ticket_grab_event_id' => ['required', 'integer', 'exists:ticket_grab_events,id'], + 'date' => ['required', '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 = $validated['date']; + + $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); + } } diff --git a/app/Http/Controllers/Api/H5ContentController.php b/app/Http/Controllers/Api/H5ContentController.php index 260ff8d..83ccfb2 100644 --- a/app/Http/Controllers/Api/H5ContentController.php +++ b/app/Http/Controllers/Api/H5ContentController.php @@ -518,7 +518,9 @@ class H5ContentController extends Controller public function studyTourDetail(int $id): JsonResponse { - $row = StudyTour::query()->where('is_active', true)->findOrFail($id); + $row = StudyTour::query() + ->where('is_on_shelf', true) + ->findOrFail($id); $venueIds = collect($row->venue_ids ?? [])->filter()->values(); $venueMap = Venue::query() ->whereIn('id', $venueIds->all()) @@ -556,7 +558,7 @@ class H5ContentController extends Controller public function studyTours(Request $request): JsonResponse { $rows = StudyTour::query() - ->where('is_active', true) + ->where('is_on_shelf', true) ->when($request->filled('keyword'), function ($q) use ($request) { $keyword = trim((string) $request->input('keyword')); $q->where('name', 'like', "%{$keyword}%"); diff --git a/app/Http/Controllers/Api/H5HomeController.php b/app/Http/Controllers/Api/H5HomeController.php index ed4e3a2..5269dee 100644 --- a/app/Http/Controllers/Api/H5HomeController.php +++ b/app/Http/Controllers/Api/H5HomeController.php @@ -186,7 +186,7 @@ class H5HomeController extends Controller $rankings = $hotActivities->take(2)->values(); $activeStudyTours = StudyTour::query() - ->where('is_active', true) + ->where('is_on_shelf', true) ->orderBy('sort') ->orderByDesc('id') ->limit(3) diff --git a/app/Http/Controllers/Api/StudyTourController.php b/app/Http/Controllers/Api/StudyTourController.php index 9082fa5..103d429 100644 --- a/app/Http/Controllers/Api/StudyTourController.php +++ b/app/Http/Controllers/Api/StudyTourController.php @@ -9,10 +9,34 @@ use Illuminate\Http\Request; class StudyTourController extends Controller { - public function index(): JsonResponse + public function index(Request $request): JsonResponse { - $rows = StudyTour::query()->orderBy('sort')->orderByDesc('id')->get(); - return response()->json($rows); + $q = StudyTour::query()->orderBy('sort')->orderByDesc('id'); + + if ($request->filled('keyword')) { + $kw = trim((string) $request->input('keyword')); + if ($kw !== '') { + $q->where('name', 'like', '%'.$kw.'%'); + } + } + + if ($request->filled('venue_id')) { + $vid = (int) $request->input('venue_id'); + if ($vid > 0) { + $q->whereJsonContains('venue_ids', $vid); + } + } + + if ($request->has('is_on_shelf') && $request->input('is_on_shelf') !== '' && $request->input('is_on_shelf') !== null) { + $raw = $request->input('is_on_shelf'); + $on = in_array($raw, [1, '1', true, 'true', 'on', 'yes'], true); + $off = in_array($raw, [0, '0', false, 'false', 'off', 'no'], true); + if ($on || $off) { + $q->where('is_on_shelf', $on); + } + } + + return response()->json($q->get()); } public function store(Request $request): JsonResponse @@ -30,7 +54,7 @@ class StudyTourController extends Controller 'gallery_media.*.url' => ['required_with:gallery_media', 'string', 'max:255'], 'intro_html' => ['nullable', 'string'], 'sort' => ['nullable', 'integer', 'min:0'], - 'is_active' => ['boolean'], + 'is_on_shelf' => ['sometimes', 'boolean'], ]); $row = StudyTour::create($data + [ @@ -38,7 +62,7 @@ class StudyTourController extends Controller 'venue_ids' => array_values($data['venue_ids'] ?? []), 'gallery_media' => array_values($data['gallery_media'] ?? []), 'sort' => $data['sort'] ?? 0, - 'is_active' => $data['is_active'] ?? true, + 'is_on_shelf' => $data['is_on_shelf'] ?? true, ]); return response()->json($row, 201); @@ -59,7 +83,7 @@ class StudyTourController extends Controller 'gallery_media.*.url' => ['required_with:gallery_media', 'string', 'max:255'], 'intro_html' => ['sometimes', 'nullable', 'string'], 'sort' => ['nullable', 'integer', 'min:0'], - 'is_active' => ['boolean'], + 'is_on_shelf' => ['sometimes', 'boolean'], ]); if (array_key_exists('tags', $data)) { @@ -73,6 +97,7 @@ class StudyTourController extends Controller } $studyTour->fill($data)->save(); + return response()->json($studyTour); } @@ -80,6 +105,7 @@ class StudyTourController extends Controller { $this->ensureSuperAdmin($request); $studyTour->delete(); + return response()->json(['message' => '删除成功']); } diff --git a/app/Http/Controllers/Api/TicketGrabEventController.php b/app/Http/Controllers/Api/TicketGrabEventController.php index 89f94ba..36bf724 100644 --- a/app/Http/Controllers/Api/TicketGrabEventController.php +++ b/app/Http/Controllers/Api/TicketGrabEventController.php @@ -30,6 +30,14 @@ class TicketGrabEventController extends Controller $keyword = trim((string) $request->input('keyword')); $query->where('title', 'like', "%{$keyword}%"); } + if ($request->filled('venue_id')) { + $vid = (int) $request->input('venue_id'); + if ($vid > 0) { + $query->whereHas('venues', function ($q) use ($vid) { + $q->where('venues.id', $vid); + }); + } + } if ($request->filled('is_active')) { $query->where('is_active', (bool) $request->boolean('is_active')); } diff --git a/app/Models/StudyTour.php b/app/Models/StudyTour.php index da6ab3d..feab12d 100644 --- a/app/Models/StudyTour.php +++ b/app/Models/StudyTour.php @@ -17,14 +17,14 @@ class StudyTour extends Model 'gallery_media', 'intro_html', 'sort', - 'is_active', + 'is_on_shelf', ]; protected $casts = [ 'tags' => 'array', 'venue_ids' => 'array', 'gallery_media' => 'array', - 'is_active' => 'boolean', + 'is_on_shelf' => 'boolean', 'sort' => 'integer', ]; } diff --git a/app/Services/DashboardTicketGrabStatsService.php b/app/Services/DashboardTicketGrabStatsService.php new file mode 100644 index 0000000..7f32ee9 --- /dev/null +++ b/app/Services/DashboardTicketGrabStatsService.php @@ -0,0 +1,423 @@ + $scopedVenueIds 已按权限收窄的场馆 id + */ + public function build(int $eventId, string $dateYmd, Collection $scopedVenueIds): array + { + $event = TicketGrabEvent::query()->find($eventId); + if (! $event) { + return ['error' => '抢票活动不存在']; + } + + $releaseRows = TicketGrabVenueReleaseDay::query() + ->where('ticket_grab_event_id', $eventId) + ->whereDate('release_date', $dateYmd) + ->whereIn('venue_id', $scopedVenueIds) + ->with('venue:id,name') + ->get(); + + if ($releaseRows->isEmpty()) { + return [ + 'event' => ['id' => $event->id, 'title' => $event->title, 'booking_audience' => $event->booking_audience], + 'date' => $dateYmd, + 'overview' => [ + 'total_released' => 0, + 'total_grabbed' => 0, + 'total_remaining' => 0, + 'sellout_duration_label' => '-', + 'remaining_badge' => '尚余0张', + ], + 'highlights' => [ + 'fastest_sellout' => null, + 'max_release_venue' => null, + 'summary' => '当日暂无放票计划或无权限范围内的场馆数据。', + ], + 'ticket_types' => [], + 'age_groups' => [], + 'venues' => [], + 'hourly_matrix' => ['hours' => [], 'rows' => []], + ]; + } + + $releaseDayIdList = $releaseRows->pluck('id')->all(); + + $totalReleased = 0; + $totalGrabbed = 0; + $totalRemaining = 0; + $venueStats = []; + + foreach ($releaseRows as $row) { + $pool = (int) $row->carry_in + (int) $row->day_quota; + $booked = (int) $row->booked_count; + $remaining = max(0, $pool - $booked); + $totalReleased += $pool; + $totalGrabbed += $booked; + $totalRemaining += $remaining; + + $venueName = $row->venue?->name ?? (Venue::query()->whereKey($row->venue_id)->value('name') ?? '场馆 #'.$row->venue_id); + + $firstAt = Reservation::query() + ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) + ->where('ticket_grab_event_id', $eventId) + ->where('ticket_grab_venue_release_day_id', $row->id) + ->where('status', '!=', 'cancelled') + ->min('created_at'); + $lastAt = Reservation::query() + ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) + ->where('ticket_grab_event_id', $eventId) + ->where('ticket_grab_venue_release_day_id', $row->id) + ->where('status', '!=', 'cancelled') + ->max('created_at'); + $soldOut = $pool > 0 && $remaining === 0; + $durationLabel = '未抢完'; + $seconds = null; + if ($soldOut && $firstAt && $lastAt) { + $a = Carbon::parse($firstAt); + $b = Carbon::parse($lastAt); + $seconds = max(0, $b->diffInSeconds($a)); + $durationLabel = $this->formatDurationCn($seconds); + } elseif (! $soldOut) { + $durationLabel = '未抢完'; + } + + $venueStats[] = [ + 'venue_id' => (int) $row->venue_id, + 'venue_name' => $venueName, + 'released' => $pool, + 'grabbed' => $booked, + 'remaining' => $remaining, + 'duration_label' => $durationLabel, + 'duration_seconds' => $seconds, + 'status' => $soldOut ? '抢完' : '未抢完', + 'release_day_id' => (int) $row->id, + ]; + } + + $overallSelloutLabel = $totalRemaining > 0 ? '未抢完' : $this->overallSelloutDurationLabel($eventId, $releaseDayIdList); + + $fastest = collect($venueStats) + ->filter(fn ($v) => ($v['duration_seconds'] ?? null) !== null) + ->sortBy('duration_seconds') + ->first(); + + $maxVenue = collect($venueStats)->sortByDesc('released')->first(); + + $highlights = [ + 'fastest_sellout' => $fastest + ? ['venue_name' => $fastest['venue_name'], 'duration_label' => $fastest['duration_label']] + : null, + 'max_release_venue' => $maxVenue && $maxVenue['released'] > 0 + ? ['venue_name' => $maxVenue['venue_name'], 'released' => $maxVenue['released']] + : null, + 'summary' => $this->buildSummaryText($totalRemaining, $totalReleased, $totalGrabbed), + ]; + + $ticketTypes = $this->ticketTypeBreakdown($event, $releaseDayIdList); + $ageGroups = $event->booking_audience === TicketGrabEvent::AUDIENCE_SCHOOL_AGE + ? $this->ageGroupBreakdown($releaseDayIdList, $dateYmd) + : []; + + $hourlyMatrix = $this->hourlyMatrix($eventId, $releaseRows, $scopedVenueIds, $dateYmd); + + return [ + 'event' => [ + 'id' => $event->id, + 'title' => $event->title, + 'booking_audience' => $event->booking_audience, + ], + 'date' => $dateYmd, + 'overview' => [ + 'total_released' => $totalReleased, + 'total_grabbed' => $totalGrabbed, + 'total_remaining' => $totalRemaining, + 'sellout_duration_label' => $overallSelloutLabel, + 'remaining_badge' => '尚余'.$totalRemaining.'张', + ], + 'highlights' => $highlights, + 'ticket_types' => $ticketTypes, + 'age_groups' => $ageGroups, + 'venues' => array_map(function ($v) { + return [ + 'venue_id' => $v['venue_id'], + 'venue_name' => $v['venue_name'], + 'released' => $v['released'], + 'grabbed' => $v['grabbed'], + 'remaining' => $v['remaining'], + 'duration_label' => $v['duration_label'], + 'status' => $v['status'], + ]; + }, $venueStats), + 'hourly_matrix' => $hourlyMatrix, + ]; + } + + private function overallSelloutDurationLabel(int $eventId, array $releaseDayIds): string + { + if ($releaseDayIds === []) { + return '-'; + } + $first = Reservation::query() + ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) + ->where('ticket_grab_event_id', $eventId) + ->whereIn('ticket_grab_venue_release_day_id', $releaseDayIds) + ->where('status', '!=', 'cancelled') + ->min('created_at'); + $last = Reservation::query() + ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) + ->where('ticket_grab_event_id', $eventId) + ->whereIn('ticket_grab_venue_release_day_id', $releaseDayIds) + ->where('status', '!=', 'cancelled') + ->max('created_at'); + if (! $first || ! $last) { + return '-'; + } + $a = Carbon::parse($first); + $b = Carbon::parse($last); + + return $this->formatDurationCn(max(0, $b->diffInSeconds($a))); + } + + private function formatDurationCn(int $seconds): string + { + if ($seconds <= 0) { + return '不足1分钟'; + } + $h = intdiv($seconds, 3600); + $m = intdiv($seconds % 3600, 60); + if ($h > 0) { + return $h.'小时'.($m > 0 ? $m.'分钟' : ''); + } + if ($m > 0) { + return $m.'分钟'; + } + + return '不足1分钟'; + } + + private function buildSummaryText(int $remaining, int $released, int $grabbed): string + { + if ($released <= 0) { + return '当日无放票。'; + } + if ($remaining > 0) { + return '整体结果:尚余 '.$remaining.' 张,未全部抢完(已抢 '.$grabbed.' / '.$released.')。'; + } + + return '整体结果:当日票已全部抢完('.$grabbed.' / '.$released.')。'; + } + + private function ticketTypeBreakdown(TicketGrabEvent $event, array $releaseDayIds): array + { + if ($releaseDayIds === []) { + return []; + } + $rows = Reservation::query() + ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) + ->where('ticket_grab_event_id', $event->id) + ->whereIn('ticket_grab_venue_release_day_id', $releaseDayIds) + ->where('status', '!=', 'cancelled') + ->selectRaw('ticket_mode, SUM(ticket_count) as people') + ->groupBy('ticket_mode') + ->get(); + + $out = []; + foreach ($rows as $r) { + $mode = $r->ticket_mode; + $people = (int) $r->people; + if ($people <= 0) { + continue; + } + if ($mode === 'pair') { + $label = '1大1小'; + } elseif ($mode === 'single') { + $label = $event->booking_audience === TicketGrabEvent::AUDIENCE_SCHOOL_AGE ? '1张学生票' : '单人预约'; + } else { + $label = '其他'; + } + $out[] = ['label' => $label, 'people_count' => $people]; + } + + return $out; + } + + private function ageGroupBreakdown(array $releaseDayIds, string $refDateYmd): array + { + if ($releaseDayIds === []) { + return []; + } + $reservations = Reservation::query() + ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) + ->whereIn('ticket_grab_venue_release_day_id', $releaseDayIds) + ->where('status', '!=', 'cancelled') + ->whereNotNull('id_card') + ->get(['id_card', 'ticket_count']); + + $buckets = [ + '1-3年级' => 0, + '4-6年级' => 0, + '7-9年级' => 0, + '其他年龄段' => 0, + ]; + foreach ($reservations as $res) { + $raw = (string) $res->id_card; + $n = max(1, (int) $res->ticket_count); + $age = $this->ageAtDateFromIdCard($raw, $refDateYmd); + if ($age === null) { + $buckets['其他年龄段'] += $n; + + continue; + } + if ($age >= 6 && $age <= 8) { + $buckets['1-3年级'] += $n; + } elseif ($age >= 9 && $age <= 11) { + $buckets['4-6年级'] += $n; + } elseif ($age >= 12 && $age <= 14) { + $buckets['7-9年级'] += $n; + } else { + $buckets['其他年龄段'] += $n; + } + } + + return collect($buckets) + ->map(fn ($n, $label) => ['label' => $label, 'people_count' => (int) $n]) + ->values() + ->filter(fn ($x) => $x['people_count'] > 0) + ->values() + ->all(); + } + + private function ageAtDateFromIdCard(string $id, string $dateYmd): ?int + { + $id = strtoupper(trim($id)); + if (strlen($id) !== 18 || ! preg_match('/^\d{17}[\dX]$/', $id)) { + return null; + } + $y = (int) substr($id, 6, 4); + $m = (int) substr($id, 10, 2); + $d = (int) substr($id, 12, 2); + if ($y < 1900 || $m < 1 || $m > 12 || $d < 1 || $d > 31) { + return null; + } + try { + $birth = new DateTimeImmutable(sprintf('%04d-%02d-%02d', $y, $m, $d)); + $ref = new DateTimeImmutable($dateYmd); + } catch (\Throwable) { + return null; + } + + return $birth->diff($ref)->y; + } + + /** + * 行:场馆;列:有抢票的小时;单元格:该小时抢票张数 / 该馆当日已抢(占比) + */ + private function hourlyMatrix(int $eventId, Collection $releaseRows, Collection $scopedVenueIds, string $dateYmd): array + { + $dayIds = $releaseRows->pluck('id')->all(); + if ($dayIds === []) { + return ['hours' => [], 'rows' => []]; + } + + $hourExpr = DB::connection()->getDriverName() === 'sqlite' + ? "CAST(strftime('%H', created_at) AS INTEGER)" + : 'HOUR(created_at)'; + + $raw = Reservation::query() + ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) + ->where('ticket_grab_event_id', $eventId) + ->whereIn('ticket_grab_venue_release_day_id', $dayIds) + ->where('status', '!=', 'cancelled') + ->whereDate('created_at', $dateYmd) + ->selectRaw('venue_id, '.$hourExpr.' as h, SUM(ticket_count) as c') + ->groupBy('venue_id', 'h') + ->get(); + + $hours = $raw->pluck('h')->unique()->sort()->values()->map(fn ($h) => (int) $h)->all(); + if ($hours === []) { + return ['hours' => [], 'rows' => []]; + } + + $byVenueHour = []; + foreach ($raw as $r) { + $vid = (int) $r->venue_id; + $h = (int) $r->h; + $byVenueHour[$vid][$h] = (int) $r->c; + } + + $venueDayTotals = Reservation::query() + ->where('reservation_kind', Reservation::KIND_TICKET_GRAB) + ->where('ticket_grab_event_id', $eventId) + ->whereIn('ticket_grab_venue_release_day_id', $dayIds) + ->where('status', '!=', 'cancelled') + ->whereDate('created_at', $dateYmd) + ->selectRaw('venue_id, SUM(ticket_count) as c') + ->groupBy('venue_id') + ->pluck('c', 'venue_id'); + + $eventDayTotal = (int) $venueDayTotals->sum(); + + $rows = []; + foreach ($releaseRows->sortBy('venue_id') as $rr) { + $vid = (int) $rr->venue_id; + $name = $rr->venue?->name ?? (Venue::query()->whereKey($vid)->value('name') ?? '场馆 #'.$vid); + $dayTot = (int) ($venueDayTotals[$vid] ?? 0); + $cells = []; + foreach ($hours as $h) { + $g = (int) ($byVenueHour[$vid][$h] ?? 0); + $pct = $dayTot > 0 ? round(100 * $g / $dayTot, 2) : 0.0; + $cells[] = [ + 'hour' => $h, + 'grabbed' => $g, + 'display' => $dayTot > 0 ? $g.'/'.$dayTot.' ('.$pct.'%)' : (string) $g, + ]; + } + $rowPct = $eventDayTotal > 0 ? round(100 * $dayTot / $eventDayTotal, 2) : 0.0; + $rows[] = [ + 'venue_id' => $vid, + 'venue_name' => $name, + 'cells' => $cells, + 'day_total' => $dayTot, + 'day_share_display' => $eventDayTotal > 0 ? $dayTot.'/'.$eventDayTotal.' ('.$rowPct.'%)' : (string) $dayTot, + ]; + } + + return [ + 'hours' => $hours, + 'rows' => $rows, + ]; + } + + /** @return \Illuminate\Support\Collection */ + public static function scopedVenueIdsForEvent(int $eventId, Collection $allowedVenueIds): Collection + { + $pivot = TicketGrabEventVenue::query() + ->where('ticket_grab_event_id', $eventId) + ->pluck('venue_id') + ->map(fn ($id) => (int) $id) + ->unique() + ->values(); + + if ($pivot->isEmpty()) { + return collect(); + } + + $allow = $allowedVenueIds->map(fn ($id) => (int) $id); + + return $pivot->filter(fn ($id) => $allow->contains((int) $id))->values(); + } +} diff --git a/app/Services/TicketGrabReleaseDayService.php b/app/Services/TicketGrabReleaseDayService.php index c9d1f00..ed5ac17 100644 --- a/app/Services/TicketGrabReleaseDayService.php +++ b/app/Services/TicketGrabReleaseDayService.php @@ -95,7 +95,7 @@ class TicketGrabReleaseDayService return; } - DB::transaction(function () use ($event, $eventId, $dates, $n, $preserveBooked) { + DB::transaction(function () use ($eventId, $dates, $n, $preserveBooked) { if (! $preserveBooked) { TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $eventId) @@ -135,18 +135,41 @@ class TicketGrabReleaseDayService ->where('venue_id', $venueId) ->firstOrFail(); - $sum = 0; + $total = (int) $pivot->venue_total_quota; + + if ($total === 0) { + foreach ($dateToQuota as $q) { + if ((int) $q !== 0) { + throw ValidationException::withMessages([ + 'day_quota' => ['该馆放票总数为 0 时,各日放票数须均为 0。'], + ]); + } + } + TicketGrabVenueReleaseDay::query() + ->where('ticket_grab_event_id', $eventId) + ->where('venue_id', $venueId) + ->update(['day_quota' => 0]); + self::syncCarryInChain($eventId, $venueId); + + return; + } + foreach ($dateToQuota as $date => $q) { - $sum += (int) $q; TicketGrabVenueReleaseDay::query() ->where('ticket_grab_event_id', $eventId) ->where('venue_id', $venueId) ->whereDate('release_date', $date) ->update(['day_quota' => (int) $q]); } - if ($sum !== (int) $pivot->venue_total_quota) { + + $dbSum = (int) TicketGrabVenueReleaseDay::query() + ->where('ticket_grab_event_id', $eventId) + ->where('venue_id', $venueId) + ->sum('day_quota'); + + if ($dbSum !== $total) { throw ValidationException::withMessages([ - 'day_quota' => ["各日放票数之和必须等于该馆放票总数({$pivot->venue_total_quota})。"], + 'day_quota' => ["各日放票数之和必须等于该馆放票总数({$total})。当前合计为 {$dbSum}。"], ]); } self::syncCarryInChain($eventId, $venueId); diff --git a/database/migrations/2026_04_27_100000_add_is_on_shelf_to_study_tours_table.php b/database/migrations/2026_04_27_100000_add_is_on_shelf_to_study_tours_table.php new file mode 100644 index 0000000..195c438 --- /dev/null +++ b/database/migrations/2026_04_27_100000_add_is_on_shelf_to_study_tours_table.php @@ -0,0 +1,23 @@ +boolean('is_on_shelf')->default(true)->after('is_active'); + $table->index(['is_on_shelf']); + }); + } + + public function down(): void + { + Schema::table('study_tours', function (Blueprint $table) { + $table->dropColumn('is_on_shelf'); + }); + } +}; diff --git a/database/migrations/2026_04_27_120000_drop_is_active_from_study_tours_table.php b/database/migrations/2026_04_27_120000_drop_is_active_from_study_tours_table.php new file mode 100644 index 0000000..69be524 --- /dev/null +++ b/database/migrations/2026_04_27_120000_drop_is_active_from_study_tours_table.php @@ -0,0 +1,31 @@ +where('is_active', false)->update(['is_on_shelf' => false]); + + Schema::table('study_tours', function (Blueprint $table) { + $table->dropIndex(['is_active']); + $table->dropColumn('is_active'); + }); + } + + public function down(): void + { + Schema::table('study_tours', function (Blueprint $table) { + $table->boolean('is_active')->default(true)->after('sort'); + $table->index(['is_active']); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index e0df53f..e0c6666 100644 --- a/routes/api.php +++ b/routes/api.php @@ -153,6 +153,7 @@ Route::middleware(['auth:sanctum', 'audit.log'])->group(function () { Route::delete('/blacklists/{blacklist}', [BlacklistController::class, 'destroy']); Route::get('/dashboard/stats', [DashboardController::class, 'stats']); + Route::get('/dashboard/ticket-grab-stats', [DashboardController::class, 'ticketGrabStats']); Route::get('/audit-logs', [AuditLogController::class, 'index']); Route::get('/reservations/today-summary', [ReservationVerifyController::class, 'todaySummary']);