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.
256 lines
10 KiB
256 lines
10 KiB
<?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\Models\ReviewerScope;
|
|
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();
|
|
|
|
$appIds = $apps->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
$reviewStats = $this->reviewStatsForApplicationIds($appIds);
|
|
$reviewerCountsByTrack = ReviewerScope::query()
|
|
->select('track_code', DB::raw('COUNT(*) as total'))
|
|
->where('competition_id', $competition->id)
|
|
->groupBy('track_code')
|
|
->pluck('total', 'track_code');
|
|
|
|
$trackTitles = $competition->tracks->pluck('title', 'track_code');
|
|
|
|
$total = $apps->count();
|
|
$passed = $apps->where('review_result', 'passed')->count();
|
|
$rejected = $apps->where('review_result', 'rejected')->count();
|
|
$pendingReview = 0;
|
|
$withCompany = 0;
|
|
|
|
foreach ($apps as $app) {
|
|
if (trim((string) ($app->company_name ?? '')) !== '') {
|
|
$withCompany += 1;
|
|
}
|
|
$trackCode = (string) ($app->track ?? '');
|
|
$required = (int) ($reviewerCountsByTrack[$trackCode] ?? 0);
|
|
$completed = (int) ($reviewStats[(int) $app->id]['submitted_review_count'] ?? 0);
|
|
if ($required > 0 && $completed < $required) {
|
|
$pendingReview += 1;
|
|
}
|
|
}
|
|
|
|
$passRate = $total > 0 ? round(($passed / $total) * 100, 1) : 0.0;
|
|
$companyRatio = $total > 0 ? round(($withCompany / $total) * 100, 1) : 0.0;
|
|
|
|
return response()->json([
|
|
'kpis' => [
|
|
['label' => '项目总数', 'value' => $total, 'sub' => '已提交的有效项目数'],
|
|
['label' => '待评审数量', 'value' => $pendingReview, 'sub' => '评审进程未全部完成的项目'],
|
|
['label' => '通过率', 'value' => $passRate.'%', 'sub' => '已通过 / 总项目'],
|
|
['label' => '企业化占比', 'value' => $companyRatio.'%', 'sub' => '填写企业名称的项目占比'],
|
|
],
|
|
'track_counts' => $this->countEntries($apps, fn (Application $a) => $trackTitles->get($a->track ?? '') ?? ($a->track ?? '未填写')),
|
|
'degree_counts' => $this->countEntries($apps, fn (Application $a) => trim((string) ($a->degree ?? '')) ?: '未填写'),
|
|
'country_counts' => $this->countEntries($apps, fn (Application $a) => trim((string) ($a->location_country ?? '')) ?: '未填写'),
|
|
'province_counts' => $this->countEntries(
|
|
$apps->filter(fn (Application $a) => trim((string) ($a->location_country ?? '')) === '中国'),
|
|
fn (Application $a) => trim((string) ($a->location_province ?? '')) ?: '未填写',
|
|
6,
|
|
),
|
|
'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, $reviewerCountsByTrack),
|
|
'track_top_projects' => $this->trackTopProjects($apps, $reviewStats, $trackTitles, $reviewerCountsByTrack),
|
|
'summary' => [
|
|
'total' => $total,
|
|
'passed' => $passed,
|
|
'rejected' => $rejected,
|
|
'pending_review' => $pendingReview,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param list<int> $ids
|
|
* @return array<int, array{submitted_review_count: int, team_avg: float|null}>
|
|
*/
|
|
private function reviewStatsForApplicationIds(array $ids): array
|
|
{
|
|
if (count($ids) === 0) {
|
|
return [];
|
|
}
|
|
|
|
return 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,
|
|
'team_avg' => $row->team_avg !== null ? (float) $row->team_avg : null,
|
|
],
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param \Illuminate\Support\Collection<int, Application> $apps
|
|
* @return list<array{label: string, count: int, percent: float}>
|
|
*/
|
|
private function countEntries($apps, callable $keyGetter, ?int $limit = null): array
|
|
{
|
|
$counts = [];
|
|
foreach ($apps as $app) {
|
|
$key = (string) $keyGetter($app);
|
|
$counts[$key] = ($counts[$key] ?? 0) + 1;
|
|
}
|
|
arsort($counts);
|
|
if ($limit !== null) {
|
|
$counts = array_slice($counts, 0, $limit, true);
|
|
}
|
|
$total = max(1, $apps->count());
|
|
|
|
return collect($counts)->map(fn ($count, $label) => [
|
|
'label' => (string) $label,
|
|
'count' => (int) $count,
|
|
'percent' => round(((int) $count / $total) * 100, 1),
|
|
])->values()->all();
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
{
|
|
if (trim((string) ($app->location_country ?? '')) === '海外') {
|
|
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, team_avg: float|null}> $reviewStats
|
|
* @param \Illuminate\Support\Collection<string, string> $trackTitles
|
|
* @param \Illuminate\Support\Collection<string, int> $reviewerCountsByTrack
|
|
* @return list<array{rank: int, track: string, avg: float, count: int}>
|
|
*/
|
|
private function trackScoreRank($apps, array $reviewStats, $trackTitles, $reviewerCountsByTrack): array
|
|
{
|
|
$groups = [];
|
|
foreach ($apps as $app) {
|
|
$avg = $this->completedAvg($app, $reviewStats, $reviewerCountsByTrack);
|
|
if ($avg === null) {
|
|
continue;
|
|
}
|
|
$track = $trackTitles->get($app->track ?? '') ?? ($app->track ?? '未填写');
|
|
$groups[$track][] = $avg;
|
|
}
|
|
$rows = [];
|
|
foreach ($groups as $track => $avgs) {
|
|
$rows[] = [
|
|
'track' => $track,
|
|
'avg' => 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, $reviewerCountsByTrack): array
|
|
{
|
|
$byTrack = [];
|
|
foreach ($apps as $app) {
|
|
$avg = $this->completedAvg($app, $reviewStats, $reviewerCountsByTrack);
|
|
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, team_avg: float|null}> $reviewStats
|
|
* @param \Illuminate\Support\Collection<string, int> $reviewerCountsByTrack
|
|
*/
|
|
private function completedAvg(Application $app, array $reviewStats, $reviewerCountsByTrack): ?float
|
|
{
|
|
$trackCode = (string) ($app->track ?? '');
|
|
$required = (int) ($reviewerCountsByTrack[$trackCode] ?? 0);
|
|
$stats = $reviewStats[(int) $app->id] ?? null;
|
|
$completed = (int) ($stats['submitted_review_count'] ?? 0);
|
|
if ($required <= 0 || $completed < $required) {
|
|
return null;
|
|
}
|
|
|
|
return $stats['team_avg'] ?? null;
|
|
}
|
|
}
|