You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

408 lines
15 KiB

3 months ago
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ApplicationReviewScore;
use App\Models\Competition;
4 weeks ago
use App\Support\ReviewAssignmentIndex;
4 weeks ago
use App\Support\SignupDisplay;
1 month ago
use App\Support\SignupOptionCatalog;
use App\Support\PortalLocale;
4 weeks ago
use App\Support\TrackScoringSheet;
3 months ago
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();
2 months ago
$eligibleApps = $apps->filter(fn (Application $a) => $a->isReviewEligible())->values();
3 months ago
$appIds = $apps->pluck('id')->map(fn ($id) => (int) $id)->all();
$reviewStats = $this->reviewStatsForApplicationIds($appIds);
4 weeks ago
$assignments = ReviewAssignmentIndex::forCompetition((int) $competition->id);
3 months ago
$trackTitles = $competition->tracks->pluck('title', 'track_code');
$total = $apps->count();
$passed = $apps->where('review_result', 'passed')->count();
$rejected = $apps->where('review_result', 'rejected')->count();
2 months ago
$reviewedCount = 0;
2 months ago
$eligibleReviewedCount = 0;
3 months ago
foreach ($apps as $app) {
4 weeks ago
$progress = $assignments->progress($app, $reviewStats[(int) $app->id]['reviewer_ids'] ?? []);
// 已评审:该项目所属赛区评委均已提交
if ($progress['fully_completed']) {
2 months ago
$reviewedCount += 1;
2 months ago
if ($app->isReviewEligible()) {
$eligibleReviewedCount += 1;
}
3 months ago
}
}
2 months ago
// 待评审 = 参与评审的项目数 − 其中已评审完成数(不含不参与评审)
$pendingReview = max(0, $eligibleApps->count() - $eligibleReviewedCount);
2 months ago
3 months ago
$passRate = $total > 0 ? round(($passed / $total) * 100, 1) : 0.0;
1 month ago
$locationCounts = $this->eventLocationCounts($apps);
4 weeks ago
$eventLocationBars = $this->eventLocationDistribution($apps);
3 months ago
return response()->json([
'kpis' => [
1 month ago
['label' => '项目总数', 'value' => $total, 'sub' => '已提交的有效项目数', 'type' => 'total'],
...$locationCounts,
2 months ago
['label' => '待评审数量', 'value' => $pendingReview, 'sub' => '参与评审项目中尚未评审完成的数量'],
3 months ago
['label' => '通过率', 'value' => $passRate.'%', 'sub' => '已通过 / 总项目'],
],
4 weeks ago
'track_counts' => $this->trackCounts($apps, $trackTitles),
'degree_counts' => $this->catalogCounts(
$apps,
SignupOptionCatalog::degrees(),
'degree',
fn (Application $a) => $a->degree ?? '',
3 months ago
),
4 weeks ago
'country_counts' => $this->catalogCounts(
$apps,
SignupOptionCatalog::locationCountries(),
'location_country',
fn (Application $a) => $a->location_country ?? '',
),
'event_location_counts' => $eventLocationBars,
'province_counts' => $eventLocationBars,
3 months ago
'school_top' => $this->topGroups($apps, fn (Application $a) => trim((string) ($a->school ?? '')) ?: '未填写'),
'city_top' => $this->topGroups($apps, fn (Application $a) => $this->cityLabel($a)),
4 weeks ago
'track_score_rank' => $this->trackScoreRank($apps, $reviewStats, $trackTitles, $assignments),
'track_top_projects' => $this->trackTopProjects($apps, $reviewStats, $trackTitles, $assignments),
3 months ago
'summary' => [
'total' => $total,
'passed' => $passed,
'rejected' => $rejected,
'pending_review' => $pendingReview,
2 months ago
'reviewed' => $eligibleReviewedCount,
3 months ago
],
]);
}
/**
* @param list<int> $ids
4 weeks ago
* @return array<int, array{submitted_review_count: int, reviewer_ids: list<int>, team_avg: float|null}>
3 months ago
*/
private function reviewStatsForApplicationIds(array $ids): array
{
if (count($ids) === 0) {
return [];
}
4 weeks ago
$stats = ApplicationReviewScore::query()
3 months ago
->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,
4 weeks ago
'reviewer_ids' => [],
4 weeks ago
'team_avg' => $row->team_avg !== null ? TrackScoringSheet::roundScore((float) $row->team_avg) : null,
3 months ago
],
])
->all();
4 weeks ago
$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;
3 months ago
}
1 month ago
/**
* @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;
}
3 months ago
/**
* @param \Illuminate\Support\Collection<int, Application> $apps
4 weeks ago
* @param \Illuminate\Support\Collection<string, string> $trackTitles
3 months ago
* @return list<array{label: string, count: int, percent: float}>
*/
4 weeks ago
private function trackCounts($apps, $trackTitles): array
3 months ago
{
4 weeks ago
$ordered = [];
foreach ($trackTitles as $code => $title) {
$code = trim((string) $code);
if ($code === '') {
continue;
}
$label = trim((string) $title);
$ordered[$code] = $label !== '' ? $label : $code;
}
3 months ago
foreach ($apps as $app) {
4 weeks ago
$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);
3 months ago
}
4 weeks ago
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]++;
3 months ago
}
$total = max(1, $apps->count());
4 weeks ago
$rows = [];
foreach ($ordered as $code => $label) {
$count = (int) ($counts[$code] ?? 0);
$rows[] = [
'label' => $label,
'count' => $count,
'percent' => round(($count / $total) * 100, 1),
];
}
3 months ago
4 weeks ago
return $rows;
3 months ago
}
/**
* @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
{
4 weeks ago
$country = SignupOptionCatalog::normalize('location_country', (string) ($app->location_country ?? ''));
if ($country === 'overseas') {
3 months ago
return trim((string) ($app->oversea_country ?? '')) ?: '海外';
}
return trim((string) ($app->location_city ?? '')) ?: '未填写';
}
/**
* @param \Illuminate\Support\Collection<int, Application> $apps
4 weeks ago
* @param array<int, array{submitted_review_count: int, reviewer_ids?: list<int>, team_avg: float|null}> $reviewStats
3 months ago
* @param \Illuminate\Support\Collection<string, string> $trackTitles
* @return list<array{rank: int, track: string, avg: float, count: int}>
*/
4 weeks ago
private function trackScoreRank($apps, array $reviewStats, $trackTitles, ReviewAssignmentIndex $assignments): array
3 months ago
{
$groups = [];
4 weeks ago
foreach ($trackTitles as $code => $title) {
$code = trim((string) $code);
if ($code === '') {
continue;
}
$label = trim((string) $title);
$groups[$code] = [
'track' => $label !== '' ? $label : $code,
'avgs' => [],
];
}
3 months ago
foreach ($apps as $app) {
4 weeks ago
$code = trim((string) ($app->track ?? ''));
if ($code === '') {
3 months ago
continue;
}
4 weeks ago
if (! isset($groups[$code])) {
$label = $trackTitles->get($code) ?? ($code === SignupDisplay::TRACK_OTHER_CODE ? '其他' : $code);
$groups[$code] = ['track' => $label, 'avgs' => []];
}
4 weeks ago
$avg = $this->completedAvg($app, $reviewStats, $assignments);
4 weeks ago
if ($avg !== null) {
$groups[$code]['avgs'][] = $avg;
}
3 months ago
}
$rows = [];
4 weeks ago
foreach ($groups as $group) {
$avgs = $group['avgs'];
3 months ago
$rows[] = [
4 weeks ago
'track' => $group['track'],
'avg' => $avgs === [] ? 0.0 : round(array_sum($avgs) / count($avgs), 2),
3 months ago
'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}>}>
*/
4 weeks ago
private function trackTopProjects($apps, array $reviewStats, $trackTitles, ReviewAssignmentIndex $assignments): array
3 months ago
{
$byTrack = [];
foreach ($apps as $app) {
4 weeks ago
$avg = $this->completedAvg($app, $reviewStats, $assignments);
3 months ago
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;
}
/**
4 weeks ago
* @param array<int, array{submitted_review_count: int, reviewer_ids?: list<int>, team_avg: float|null}> $reviewStats
3 months ago
*/
4 weeks ago
private function completedAvg(Application $app, array $reviewStats, ReviewAssignmentIndex $assignments): ?float
3 months ago
{
$stats = $reviewStats[(int) $app->id] ?? null;
4 weeks ago
$progress = $assignments->progress($app, $stats['reviewer_ids'] ?? []);
if (! $progress['fully_completed']) {
3 months ago
return null;
}
return $stats['team_avg'] ?? null;
}
}