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.
307 lines
13 KiB
307 lines
13 KiB
<?php
|
|
|
|
namespace App\Http\Controllers\Api\Admin;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Application;
|
|
use App\Models\ApplicationFile;
|
|
use App\Models\ApplicationReviewScore;
|
|
use App\Models\Competition;
|
|
use App\Services\AdminApplicationExportService;
|
|
use App\Support\ProjectCode;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class ApplicationController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly AdminApplicationExportService $exportService,
|
|
) {}
|
|
|
|
public function index(Request $request, Competition $competition): JsonResponse
|
|
{
|
|
$data = $this->validatedListFilters($request);
|
|
|
|
$query = $this->exportService->buildFilteredQuery($competition, $data)
|
|
->with(['user.publicSourceChannel'])
|
|
->withCount(['files', 'reviewScores']);
|
|
|
|
$perPage = min((int) ($data['per_page'] ?? 15), 100);
|
|
$paginator = $query
|
|
->orderByDesc('submitted_at')
|
|
->orderByDesc('id')
|
|
->paginate($perPage);
|
|
|
|
$stats = $this->reviewStatsForApplicationIds(
|
|
$paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all()
|
|
);
|
|
|
|
$trackTitles = $competition->tracks()->pluck('title', 'track_code');
|
|
$paginator->getCollection()->transform(function (Application $app) use ($trackTitles, $stats) {
|
|
$app->loadMissing('signupChannel');
|
|
|
|
return $this->toListRow($app, $trackTitles, $stats[(int) $app->id] ?? null);
|
|
});
|
|
|
|
return response()->json($paginator);
|
|
}
|
|
|
|
public function exportMeta(Request $request, Competition $competition): JsonResponse
|
|
{
|
|
$filters = $this->validatedExportMetaFilters($request);
|
|
$mode = (string) $filters['mode'];
|
|
$this->exportService->assertExportable($competition, $filters, $mode);
|
|
|
|
$total = $this->exportService->countFiltered($competition, $filters);
|
|
$partSize = $this->exportService->partSize();
|
|
$partCount = $this->exportService->partCount($competition, $filters, $mode);
|
|
|
|
return response()->json([
|
|
'total' => $total,
|
|
'part_size' => $partSize,
|
|
'part_count' => $partCount,
|
|
'mode' => $mode,
|
|
'includes_attachments' => $this->exportService->modeIncludesAttachments($mode),
|
|
]);
|
|
}
|
|
|
|
public function export(Request $request, Competition $competition): BinaryFileResponse
|
|
{
|
|
$filters = $this->validatedExportFilters($request);
|
|
$mode = (string) $filters['mode'];
|
|
$this->exportService->assertExportable($competition, $filters, $mode);
|
|
|
|
if ($mode === AdminApplicationExportService::MODE_XLSX) {
|
|
$xlsxPath = $this->exportService->buildXlsxExportPath($competition, $filters);
|
|
|
|
return response()->download($xlsxPath, $this->exportService->buildXlsxFilename($competition), [
|
|
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'Cache-Control' => 'private, no-store',
|
|
])->deleteFileAfterSend(true);
|
|
}
|
|
|
|
$part = (int) ($filters['part'] ?? 1);
|
|
$partCount = $this->exportService->partCount($competition, $filters, $mode);
|
|
$zipPath = $this->exportService->buildPartZipPath($competition, $filters, $part, $mode);
|
|
$filename = $this->exportService->buildPartFilename($competition, $part, $partCount);
|
|
|
|
return response()->download($zipPath, $filename, [
|
|
'Content-Type' => 'application/zip',
|
|
'Cache-Control' => 'private, no-store',
|
|
])->deleteFileAfterSend(true);
|
|
}
|
|
|
|
public function show(Competition $competition, Application $application): JsonResponse
|
|
{
|
|
if ((int) $application->competition_id !== (int) $competition->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$application->loadMissing(['files', 'reviewScores.reviewer', 'user.publicSourceChannel', 'signupChannel']);
|
|
$trackTitles = $competition->tracks()->pluck('title', 'track_code');
|
|
$stats = $this->reviewStatsForApplicationIds([(int) $application->id]);
|
|
|
|
return response()->json(
|
|
$this->toDetail($application, $competition, $trackTitles, $stats[(int) $application->id] ?? null)
|
|
);
|
|
}
|
|
|
|
public function downloadFile(Competition $competition, Application $application, ApplicationFile $file): StreamedResponse
|
|
{
|
|
if ((int) $application->competition_id !== (int) $competition->id
|
|
|| (int) $file->application_id !== (int) $application->id) {
|
|
abort(404);
|
|
}
|
|
|
|
return Storage::disk($file->disk)->download(
|
|
$file->path,
|
|
$file->clientDownloadName(),
|
|
['Cache-Control' => 'private, no-store']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function validatedExportMetaFilters(Request $request): array
|
|
{
|
|
return $request->validate([
|
|
'mode' => ['required', 'string', Rule::in([
|
|
AdminApplicationExportService::MODE_XLSX,
|
|
AdminApplicationExportService::MODE_SELECTED,
|
|
AdminApplicationExportService::MODE_ALL,
|
|
])],
|
|
'status' => ['sometimes', 'nullable', 'string', 'max:32'],
|
|
'track' => ['sometimes', 'nullable', 'string', 'max:100'],
|
|
'keyword' => ['sometimes', 'nullable', 'string', 'max:100'],
|
|
'signup_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
|
'public_source_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
|
'application_ids' => ['sometimes', 'array'],
|
|
'application_ids.*' => ['integer', 'min:1'],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function validatedExportFilters(Request $request): array
|
|
{
|
|
$filters = $request->validate([
|
|
'mode' => ['required', 'string', Rule::in([
|
|
AdminApplicationExportService::MODE_XLSX,
|
|
AdminApplicationExportService::MODE_SELECTED,
|
|
AdminApplicationExportService::MODE_ALL,
|
|
])],
|
|
'status' => ['sometimes', 'nullable', 'string', 'max:32'],
|
|
'track' => ['sometimes', 'nullable', 'string', 'max:100'],
|
|
'keyword' => ['sometimes', 'nullable', 'string', 'max:100'],
|
|
'signup_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
|
'public_source_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
|
'application_ids' => ['sometimes', 'array'],
|
|
'application_ids.*' => ['integer', 'min:1'],
|
|
'part' => ['sometimes', 'integer', 'min:1'],
|
|
]);
|
|
|
|
if (($filters['mode'] ?? '') !== AdminApplicationExportService::MODE_XLSX && ! isset($filters['part'])) {
|
|
throw ValidationException::withMessages([
|
|
'part' => ['请指定导出分卷序号'],
|
|
]);
|
|
}
|
|
|
|
return $filters;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function validatedListFilters(Request $request): array
|
|
{
|
|
return $request->validate([
|
|
'page' => ['sometimes', 'integer', 'min:1'],
|
|
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
|
'status' => ['sometimes', 'nullable', 'string', 'max:32'],
|
|
'track' => ['sometimes', 'nullable', 'string', 'max:100'],
|
|
'keyword' => ['sometimes', 'nullable', 'string', 'max:100'],
|
|
'signup_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
|
'public_source_channel_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param list<int> $ids
|
|
* @return array<int, array{submitted_review_count: int, team_sum: float|null, 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('SUM(line_total) as team_sum'),
|
|
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_sum' => $row->team_sum !== null ? (float) $row->team_sum : null,
|
|
'team_avg' => $row->team_avg !== null ? (float) $row->team_avg : null,
|
|
],
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private function toListRow(Application $app, $trackTitles, ?array $stats): array
|
|
{
|
|
return [
|
|
'id' => $app->id,
|
|
'project_code' => ProjectCode::resolve($app),
|
|
'status' => $app->status,
|
|
'project_name' => $app->project_name ?? '',
|
|
'player_name' => $app->player_name ?? '',
|
|
'school' => $app->school ?? '',
|
|
'contact_mobile' => $app->contact_mobile ?? '',
|
|
'entry_group' => $app->entry_group ?? '',
|
|
'track_code' => $app->track ?? '',
|
|
'track_title' => $trackTitles->get($app->track ?? '') ?? ($app->track ?? ''),
|
|
'signup_channel' => $app->signupChannel ? [
|
|
'id' => $app->signupChannel->id,
|
|
'channel_code' => $app->signupChannel->channel_code,
|
|
'channel_name' => $app->signupChannel->channel_name,
|
|
] : null,
|
|
'signup_channel_code' => $app->signup_channel_code,
|
|
'recommend' => $app->recommend ?? '',
|
|
'submitted_at' => $app->submitted_at?->toIso8601String(),
|
|
'updated_at' => $app->updated_at?->toIso8601String(),
|
|
'files_count' => (int) ($app->files_count ?? 0),
|
|
'submitted_review_count' => (int) ($stats['submitted_review_count'] ?? 0),
|
|
'team_sum' => $stats['team_sum'] ?? null,
|
|
'team_avg' => $stats['team_avg'] ?? null,
|
|
'public_source_channel' => $app->user?->publicSourceChannel ? [
|
|
'id' => $app->user->publicSourceChannel->id,
|
|
'source_code' => $app->user->publicSourceChannel->source_code,
|
|
'source_name' => $app->user->publicSourceChannel->source_name,
|
|
] : null,
|
|
'public_source_code' => $app->user?->public_source_code,
|
|
'public_source_attributed_at' => $app->user?->public_source_attributed_at?->toIso8601String(),
|
|
];
|
|
}
|
|
|
|
private function toDetail(Application $app, Competition $competition, $trackTitles, ?array $stats): array
|
|
{
|
|
$base = $this->toListRow($app, $trackTitles, $stats);
|
|
|
|
return $base + [
|
|
'competition' => [
|
|
'id' => $competition->id,
|
|
'slug' => $competition->slug,
|
|
'name' => $competition->name,
|
|
],
|
|
'participant' => [
|
|
'id' => $app->user?->id,
|
|
'mobile' => $app->user?->mobile,
|
|
'name' => $app->user?->name,
|
|
'email' => $app->user?->email,
|
|
'company' => $app->user?->company,
|
|
],
|
|
'contact_email' => $app->contact_email ?? '',
|
|
'degree' => $app->degree ?? '',
|
|
'company_name' => $app->company_name ?? '',
|
|
'location_country' => $app->location_country ?? '',
|
|
'location_province' => $app->location_province ?? '',
|
|
'location_city' => $app->location_city ?? '',
|
|
'oversea_country' => $app->oversea_country ?? '',
|
|
'intro' => $app->intro ?? '',
|
|
'recommend' => $app->recommend ?? '',
|
|
'promise_signed_at' => $app->promise_signed_at?->toIso8601String(),
|
|
'signup_channel_state' => $app->signup_channel_state,
|
|
'files' => $app->files->map(fn (ApplicationFile $file) => [
|
|
'id' => $file->id,
|
|
'kind' => $file->kind,
|
|
'original_name' => $file->original_name,
|
|
'size' => $file->size,
|
|
'mime' => $file->mime,
|
|
])->values(),
|
|
'review_scores' => $app->reviewScores->map(fn (ApplicationReviewScore $score) => [
|
|
'id' => $score->id,
|
|
'reviewer_id' => $score->reviewer_id,
|
|
'reviewer_name' => $score->reviewer?->name,
|
|
'line_total' => $score->line_total !== null ? (string) $score->line_total : null,
|
|
'updated_at' => $score->updated_at?->toIso8601String(),
|
|
])->values(),
|
|
];
|
|
}
|
|
}
|