报名头像

master
lion 2 weeks ago
parent 21ffcc9403
commit d118c9230d

@ -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);
}
}

@ -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}%");

@ -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)

@ -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' => '删除成功']);
}

@ -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'));
}

@ -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',
];
}

@ -0,0 +1,423 @@
<?php
namespace App\Services;
use App\Models\Reservation;
use App\Models\TicketGrabEvent;
use App\Models\TicketGrabEventVenue;
use App\Models\TicketGrabVenueReleaseDay;
use App\Models\Venue;
use Carbon\Carbon;
use DateTimeImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class DashboardTicketGrabStatsService
{
/**
* @param \Illuminate\Support\Collection<int, int|string> $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<int, int|string> */
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();
}
}

@ -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);

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('study_tours', function (Blueprint $table) {
$table->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');
});
}
};

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('study_tours', 'is_active')) {
return;
}
DB::table('study_tours')->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']);
});
}
};

@ -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']);

Loading…
Cancel
Save