loadMissing('tracks'); $apps = Application::query() ->where('competition_id', $competition->id) ->where('status', 'submitted') ->get(); $eligibleApps = $apps->filter(fn (Application $a) => $a->isReviewEligible())->values(); $appIds = $apps->pluck('id')->map(fn ($id) => (int) $id)->all(); $reviewStats = $this->reviewStatsForApplicationIds($appIds); $assignments = ReviewAssignmentIndex::forCompetition((int) $competition->id); $trackTitles = $competition->tracks->pluck('title', 'track_code'); $total = $apps->count(); $passed = $apps->where('review_result', 'passed')->count(); $rejected = $apps->where('review_result', 'rejected')->count(); $reviewedCount = 0; $eligibleReviewedCount = 0; foreach ($apps as $app) { $progress = $assignments->progress($app, $reviewStats[(int) $app->id]['reviewer_ids'] ?? []); // 已评审:该项目所属赛区评委均已提交 if ($progress['fully_completed']) { $reviewedCount += 1; if ($app->isReviewEligible()) { $eligibleReviewedCount += 1; } } } // 待评审 = 参与评审的项目数 − 其中已评审完成数(不含不参与评审) $pendingReview = max(0, $eligibleApps->count() - $eligibleReviewedCount); $passRate = $total > 0 ? round(($passed / $total) * 100, 1) : 0.0; $locationCounts = $this->eventLocationCounts($apps); $eventLocationBars = $this->eventLocationDistribution($apps); return response()->json([ 'kpis' => [ ['label' => '项目总数', 'value' => $total, 'sub' => '已提交的有效项目数', 'type' => 'total'], ...$locationCounts, ['label' => '待评审数量', 'value' => $pendingReview, 'sub' => '参与评审项目中尚未评审完成的数量'], ['label' => '通过率', 'value' => $passRate.'%', 'sub' => '已通过 / 总项目'], ], 'track_counts' => $this->trackCounts($apps, $trackTitles), 'degree_counts' => $this->catalogCounts( $apps, SignupOptionCatalog::degrees(), 'degree', fn (Application $a) => $a->degree ?? '', ), 'country_counts' => $this->catalogCounts( $apps, SignupOptionCatalog::locationCountries(), 'location_country', fn (Application $a) => $a->location_country ?? '', ), 'event_location_counts' => $eventLocationBars, 'province_counts' => $eventLocationBars, 'school_top' => $this->topGroups($apps, fn (Application $a) => trim((string) ($a->school ?? '')) ?: '未填写'), 'city_top' => $this->topGroups($apps, fn (Application $a) => $this->cityLabel($a)), 'track_score_rank' => $this->trackScoreRank($apps, $reviewStats, $trackTitles, $assignments), 'track_top_projects' => $this->trackTopProjects($apps, $reviewStats, $trackTitles, $assignments), 'summary' => [ 'total' => $total, 'passed' => $passed, 'rejected' => $rejected, 'pending_review' => $pendingReview, 'reviewed' => $eligibleReviewedCount, ], ]); } /** * @param list $ids * @return array, team_avg: float|null}> */ private function reviewStatsForApplicationIds(array $ids): array { if (count($ids) === 0) { return []; } $stats = ApplicationReviewScore::query() ->select([ 'application_id', DB::raw('COUNT(*) as submitted_review_count'), DB::raw('AVG(line_total) as team_avg'), ]) ->whereIn('application_id', $ids) ->groupBy('application_id') ->get() ->mapWithKeys(fn ($row) => [ (int) $row->application_id => [ 'submitted_review_count' => (int) $row->submitted_review_count, 'reviewer_ids' => [], 'team_avg' => $row->team_avg !== null ? TrackScoringSheet::roundScore((float) $row->team_avg) : null, ], ]) ->all(); $reviewerIdsByApp = ApplicationReviewScore::query() ->whereIn('application_id', $ids) ->get(['application_id', 'reviewer_id']) ->groupBy(fn ($row) => (int) $row->application_id) ->map(fn ($rows) => $rows->pluck('reviewer_id')->map(fn ($id) => (int) $id)->unique()->values()->all()) ->all(); foreach ($stats as $appId => $row) { $stats[$appId]['reviewer_ids'] = $reviewerIdsByApp[$appId] ?? []; } return $stats; } /** * @param \Illuminate\Support\Collection $apps * @return list */ private function eventLocationCounts($apps): array { $counts = []; foreach ($apps as $app) { $code = trim((string) ($app->event_location ?? '')); if ($code === '') { continue; } $counts[$code] = ($counts[$code] ?? 0) + 1; } $cards = []; foreach (SignupOptionCatalog::eventLocations() as $code => $labels) { $label = (string) ($labels[PortalLocale::ZH] ?? $code); $title = str_contains($label, '赛区') ? $label : $label.'赛区'; $cards[] = [ 'label' => $title.'报名', 'value' => (int) ($counts[$code] ?? 0), 'sub' => '按意向参赛地点统计', 'type' => 'session', ]; } return $cards; } /** * @param \Illuminate\Support\Collection $apps * @param \Illuminate\Support\Collection $trackTitles * @return list */ private function trackCounts($apps, $trackTitles): array { $ordered = []; foreach ($trackTitles as $code => $title) { $code = trim((string) $code); if ($code === '') { continue; } $label = trim((string) $title); $ordered[$code] = $label !== '' ? $label : $code; } foreach ($apps as $app) { $code = trim((string) ($app->track ?? '')); if ($code === '' || isset($ordered[$code])) { continue; } $ordered[$code] = $code === SignupDisplay::TRACK_OTHER_CODE ? '其他' : ($trackTitles->get($code) ?? $code); } return $this->fixedLabelCounts($apps, $ordered, fn (Application $a) => trim((string) ($a->track ?? ''))); } /** * @param \Illuminate\Support\Collection $apps * @return list */ private function eventLocationDistribution($apps): array { $catalog = SignupOptionCatalog::eventLocations(); $ordered = []; foreach ($catalog as $code => $labels) { $label = SignupOptionCatalog::label($catalog, $code, PortalLocale::ZH); $ordered[$code] = str_contains($label, '赛区') ? $label : $label.'赛区'; } return $this->fixedLabelCounts( $apps, $ordered, fn (Application $a) => SignupOptionCatalog::normalize('event_location', (string) ($a->event_location ?? '')) ?: trim((string) ($a->event_location ?? '')), ); } /** * @param \Illuminate\Support\Collection $apps * @param array $catalog * @return list */ private function catalogCounts($apps, array $catalog, string $kind, callable $rawGetter): array { $ordered = []; foreach ($catalog as $code => $_labels) { $ordered[$code] = SignupOptionCatalog::label($catalog, $code, PortalLocale::ZH); } return $this->fixedLabelCounts( $apps, $ordered, function (Application $app) use ($kind, $rawGetter): string { $raw = trim((string) $rawGetter($app)); if ($raw === '') { return ''; } return SignupOptionCatalog::normalize($kind, $raw) ?: $raw; }, ); } /** * @param \Illuminate\Support\Collection $apps * @param array $ordered code => 中文标签 * @return list */ private function fixedLabelCounts($apps, array $ordered, callable $codeGetter): array { $counts = array_fill_keys(array_keys($ordered), 0); foreach ($apps as $app) { $code = trim((string) $codeGetter($app)); if ($code === '' || ! array_key_exists($code, $counts)) { continue; } $counts[$code]++; } $total = max(1, $apps->count()); $rows = []; foreach ($ordered as $code => $label) { $count = (int) ($counts[$code] ?? 0); $rows[] = [ 'label' => $label, 'count' => $count, 'percent' => round(($count / $total) * 100, 1), ]; } return $rows; } /** * @param \Illuminate\Support\Collection $apps * @return list */ private function topGroups($apps, callable $keyGetter, int $limit = 5): array { $groups = []; foreach ($apps as $app) { $key = (string) $keyGetter($app); if (! isset($groups[$key])) { $groups[$key] = ['total' => 0, 'pass' => 0]; } $groups[$key]['total'] += 1; if ($app->review_result === 'passed') { $groups[$key]['pass'] += 1; } } uasort($groups, fn ($a, $b) => $b['total'] <=> $a['total']); $top = array_slice($groups, 0, $limit, true); $rank = 1; $rows = []; foreach ($top as $name => $v) { $rate = $v['total'] > 0 ? round(($v['pass'] / $v['total']) * 100, 1).'%' : '-'; $rows[] = ['rank' => $rank++, 'name' => $name, 'total' => $v['total'], 'pass_rate' => $rate]; } return $rows; } private function cityLabel(Application $app): string { $country = SignupOptionCatalog::normalize('location_country', (string) ($app->location_country ?? '')); if ($country === 'overseas') { return trim((string) ($app->oversea_country ?? '')) ?: '海外'; } return trim((string) ($app->location_city ?? '')) ?: '未填写'; } /** * @param \Illuminate\Support\Collection $apps * @param array, team_avg: float|null}> $reviewStats * @param \Illuminate\Support\Collection $trackTitles * @return list */ private function trackScoreRank($apps, array $reviewStats, $trackTitles, ReviewAssignmentIndex $assignments): array { $groups = []; foreach ($trackTitles as $code => $title) { $code = trim((string) $code); if ($code === '') { continue; } $label = trim((string) $title); $groups[$code] = [ 'track' => $label !== '' ? $label : $code, 'avgs' => [], ]; } foreach ($apps as $app) { $code = trim((string) ($app->track ?? '')); if ($code === '') { continue; } if (! isset($groups[$code])) { $label = $trackTitles->get($code) ?? ($code === SignupDisplay::TRACK_OTHER_CODE ? '其他' : $code); $groups[$code] = ['track' => $label, 'avgs' => []]; } $avg = $this->completedAvg($app, $reviewStats, $assignments); if ($avg !== null) { $groups[$code]['avgs'][] = $avg; } } $rows = []; foreach ($groups as $group) { $avgs = $group['avgs']; $rows[] = [ 'track' => $group['track'], 'avg' => $avgs === [] ? 0.0 : round(array_sum($avgs) / count($avgs), 2), 'count' => count($avgs), ]; } usort($rows, fn ($a, $b) => $b['avg'] <=> $a['avg']); return array_map(fn ($row, $idx) => $row + ['rank' => $idx + 1], $rows, array_keys($rows)); } /** * @param \Illuminate\Support\Collection $apps * @return list}> */ private function trackTopProjects($apps, array $reviewStats, $trackTitles, ReviewAssignmentIndex $assignments): array { $byTrack = []; foreach ($apps as $app) { $avg = $this->completedAvg($app, $reviewStats, $assignments); if ($avg === null) { continue; } $track = $trackTitles->get($app->track ?? '') ?? ($app->track ?? '未填写'); $byTrack[$track][] = [ 'name' => trim((string) ($app->project_name ?? '')) ?: ('项目 #'.$app->id), 'avg' => $avg, ]; } ksort($byTrack); $result = []; foreach ($byTrack as $track => $items) { usort($items, fn ($a, $b) => $b['avg'] <=> $a['avg']); $result[] = [ 'track' => $track, 'items' => array_map( fn ($item, $idx) => ['rank' => $idx + 1, 'name' => $item['name'], 'avg' => round($item['avg'], 2)], array_slice($items, 0, 3), array_keys(array_slice($items, 0, 3)), ), ]; } return $result; } /** * @param array, team_avg: float|null}> $reviewStats */ private function completedAvg(Application $app, array $reviewStats, ReviewAssignmentIndex $assignments): ?float { $stats = $reviewStats[(int) $app->id] ?? null; $progress = $assignments->progress($app, $stats['reviewer_ids'] ?? []); if (! $progress['fully_completed']) { return null; } return $stats['team_avg'] ?? null; } }