|
|
<?php
|
|
|
|
|
|
namespace App\Http\Controllers\Api\Admin;
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
|
|
use App\Models\Application;
|
|
|
use App\Models\ApplicationReviewScore;
|
|
|
use App\Models\Competition;
|
|
|
use App\Support\ReviewAssignmentIndex;
|
|
|
use App\Support\SignupDisplay;
|
|
|
use App\Support\SignupOptionCatalog;
|
|
|
use App\Support\PortalLocale;
|
|
|
use App\Support\TrackScoringSheet;
|
|
|
use Illuminate\Http\JsonResponse;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
|
class ManageReportController extends Controller
|
|
|
{
|
|
|
public function show(Competition $competition): JsonResponse
|
|
|
{
|
|
|
$competition->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<int> $ids
|
|
|
* @return array<int, array{submitted_review_count: int, reviewer_ids: list<int>, 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<int, Application> $apps
|
|
|
* @return list<array{label: string, value: int, sub: string, type: string}>
|
|
|
*/
|
|
|
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<int, Application> $apps
|
|
|
* @param \Illuminate\Support\Collection<string, string> $trackTitles
|
|
|
* @return list<array{label: string, count: int, percent: float}>
|
|
|
*/
|
|
|
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<int, Application> $apps
|
|
|
* @return list<array{label: string, count: int, percent: float}>
|
|
|
*/
|
|
|
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<int, Application> $apps
|
|
|
* @param array<string, array{zh-CN: string, en: string}> $catalog
|
|
|
* @return list<array{label: string, count: int, percent: float}>
|
|
|
*/
|
|
|
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<int, Application> $apps
|
|
|
* @param array<string, string> $ordered code => 中文标签
|
|
|
* @return list<array{label: string, count: int, percent: float}>
|
|
|
*/
|
|
|
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<int, Application> $apps
|
|
|
* @return list<array{rank: int, name: string, total: int, pass_rate: string}>
|
|
|
*/
|
|
|
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<int, Application> $apps
|
|
|
* @param array<int, array{submitted_review_count: int, reviewer_ids?: list<int>, team_avg: float|null}> $reviewStats
|
|
|
* @param \Illuminate\Support\Collection<string, string> $trackTitles
|
|
|
* @return list<array{rank: int, track: string, avg: float, count: int}>
|
|
|
*/
|
|
|
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<int, Application> $apps
|
|
|
* @return list<array{track: string, items: list<array{rank: int, name: string, avg: float}>}>
|
|
|
*/
|
|
|
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<int, array{submitted_review_count: int, reviewer_ids?: list<int>, 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;
|
|
|
}
|
|
|
}
|