|
|
<?php
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
use App\Models\Application;
|
|
|
use App\Models\ApplicationFile;
|
|
|
use App\Models\Competition;
|
|
|
use App\Support\ProjectCode;
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
use Illuminate\Filesystem\FilesystemAdapter;
|
|
|
use Illuminate\Support\Collection;
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
|
|
use ZipArchive;
|
|
|
|
|
|
class AdminApplicationExportService
|
|
|
{
|
|
|
/**
|
|
|
* @param array<string, mixed> $filters
|
|
|
*/
|
|
|
public function buildZipPath(Competition $competition, array $filters): string
|
|
|
{
|
|
|
$applications = $this->filteredApplications($competition, $filters);
|
|
|
$trackTitles = $competition->tracks()->pluck('title', 'track_code');
|
|
|
|
|
|
$xlsxPath = $this->buildXlsxPath($applications, $trackTitles);
|
|
|
|
|
|
$tmp = tempnam(sys_get_temp_dir(), 'app_export_');
|
|
|
if ($tmp === false) {
|
|
|
@unlink($xlsxPath);
|
|
|
throw new \RuntimeException('无法创建临时文件');
|
|
|
}
|
|
|
|
|
|
$zipPath = $tmp.'.zip';
|
|
|
@unlink($tmp);
|
|
|
|
|
|
$zip = new ZipArchive;
|
|
|
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
|
|
@unlink($xlsxPath);
|
|
|
throw new \RuntimeException('无法创建 ZIP 文件');
|
|
|
}
|
|
|
|
|
|
$zip->addFile($xlsxPath, '报名信息.xlsx');
|
|
|
|
|
|
$usedFolderNames = [];
|
|
|
foreach ($applications as $application) {
|
|
|
$folder = $this->uniqueFolderName($application, $usedFolderNames);
|
|
|
$usedFolderNames[$folder] = true;
|
|
|
$zip->addEmptyDir($folder);
|
|
|
|
|
|
foreach ($application->files as $file) {
|
|
|
$absolutePath = $this->resolveFileAbsolutePath($file);
|
|
|
if ($absolutePath === null) {
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
$entryName = $this->uniqueFileNameInFolder($folder, $file, $zip);
|
|
|
$zip->addFile($absolutePath, $entryName);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
$zip->close();
|
|
|
@unlink($xlsxPath);
|
|
|
|
|
|
return $zipPath;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param Collection<int, Application> $applications
|
|
|
* @param Collection<string, string> $trackTitles
|
|
|
*/
|
|
|
private function buildXlsxPath(Collection $applications, Collection $trackTitles): string
|
|
|
{
|
|
|
$headers = [
|
|
|
'项目编号',
|
|
|
'状态',
|
|
|
'项目名称',
|
|
|
'负责人',
|
|
|
'学校',
|
|
|
'学历',
|
|
|
'手机号',
|
|
|
'邮箱',
|
|
|
'组别',
|
|
|
'企业名称',
|
|
|
'赛道',
|
|
|
'所在地',
|
|
|
'报名渠道',
|
|
|
'项目简介',
|
|
|
'提交时间',
|
|
|
'附件数量',
|
|
|
];
|
|
|
|
|
|
$rows = [$headers];
|
|
|
foreach ($applications as $app) {
|
|
|
$location = $this->formatLocation($app);
|
|
|
$channel = $app->signupChannel
|
|
|
? $app->signupChannel->channel_name.'('.$app->signupChannel->channel_code.')'
|
|
|
: ($app->signup_channel_code ?: '');
|
|
|
|
|
|
$rows[] = [
|
|
|
ProjectCode::resolve($app),
|
|
|
$app->status === 'submitted' ? '已提交' : '草稿',
|
|
|
$app->project_name ?? '',
|
|
|
$app->player_name ?? '',
|
|
|
$app->school ?? '',
|
|
|
$app->degree ?? '',
|
|
|
$app->contact_mobile ?? '',
|
|
|
$app->contact_email ?? '',
|
|
|
$app->entry_group ?? '',
|
|
|
$app->company_name ?? '',
|
|
|
$trackTitles->get($app->track ?? '') ?? ($app->track ?? ''),
|
|
|
$location,
|
|
|
$channel,
|
|
|
$app->intro ?? '',
|
|
|
$app->submitted_at?->format('Y-m-d H:i:s') ?? '',
|
|
|
$app->files->count(),
|
|
|
];
|
|
|
}
|
|
|
|
|
|
$spreadsheet = new Spreadsheet;
|
|
|
$sheet = $spreadsheet->getActiveSheet();
|
|
|
$sheet->fromArray($rows, null, 'A1');
|
|
|
|
|
|
$tmp = tempnam(sys_get_temp_dir(), 'app_export_xlsx_');
|
|
|
if ($tmp === false) {
|
|
|
throw new \RuntimeException('无法创建 Excel 临时文件');
|
|
|
}
|
|
|
|
|
|
$xlsxPath = $tmp.'.xlsx';
|
|
|
@unlink($tmp);
|
|
|
|
|
|
(new Xlsx($spreadsheet))->save($xlsxPath);
|
|
|
$spreadsheet->disconnectWorksheets();
|
|
|
|
|
|
return $xlsxPath;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, mixed> $filters
|
|
|
* @return Collection<int, Application>
|
|
|
*/
|
|
|
private function filteredApplications(Competition $competition, array $filters): Collection
|
|
|
{
|
|
|
return $this->buildFilteredQuery($competition, $filters)
|
|
|
->with(['files', 'signupChannel'])
|
|
|
->orderByDesc('submitted_at')
|
|
|
->orderByDesc('id')
|
|
|
->get();
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, mixed> $filters
|
|
|
*/
|
|
|
public function buildFilteredQuery(Competition $competition, array $filters): Builder
|
|
|
{
|
|
|
$query = Application::query()->where('competition_id', $competition->id);
|
|
|
|
|
|
$status = trim((string) ($filters['status'] ?? ''));
|
|
|
if ($status !== '') {
|
|
|
$query->where('status', $status);
|
|
|
}
|
|
|
|
|
|
$track = trim((string) ($filters['track'] ?? ''));
|
|
|
if ($track !== '') {
|
|
|
$query->where('track', $track);
|
|
|
}
|
|
|
|
|
|
$channelId = $filters['signup_channel_id'] ?? null;
|
|
|
if ($channelId !== null && $channelId !== '') {
|
|
|
$query->where('signup_channel_id', (int) $channelId);
|
|
|
}
|
|
|
|
|
|
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
|
|
if ($keyword !== '') {
|
|
|
$like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $keyword).'%';
|
|
|
$query->where(function ($q) use ($like): void {
|
|
|
$q->where('player_name', 'like', $like)
|
|
|
->orWhere('project_name', 'like', $like)
|
|
|
->orWhere('school', 'like', $like)
|
|
|
->orWhere('contact_mobile', 'like', $like)
|
|
|
->orWhere('company_name', 'like', $like)
|
|
|
->orWhere('project_code', 'like', $like);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
return $query;
|
|
|
}
|
|
|
|
|
|
private function resolveFileAbsolutePath(ApplicationFile $file): ?string
|
|
|
{
|
|
|
/** @var FilesystemAdapter $disk */
|
|
|
$disk = Storage::disk($file->disk);
|
|
|
if (! $disk->exists($file->path)) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
$absolutePath = $disk->path($file->path);
|
|
|
if (! is_file($absolutePath)) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
return $absolutePath;
|
|
|
}
|
|
|
|
|
|
private function formatLocation(Application $app): string
|
|
|
{
|
|
|
$country = trim((string) ($app->location_country ?? ''));
|
|
|
if ($country === '海外' && ($app->oversea_country ?? '') !== '') {
|
|
|
return '海外 / '.$app->oversea_country;
|
|
|
}
|
|
|
|
|
|
return implode(' / ', array_values(array_filter([
|
|
|
$app->location_country,
|
|
|
$app->location_province,
|
|
|
$app->location_city,
|
|
|
], fn ($v) => $v !== null && trim((string) $v) !== '')));
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @param array<string, true> $usedFolderNames
|
|
|
*/
|
|
|
private function uniqueFolderName(Application $application, array &$usedFolderNames): string
|
|
|
{
|
|
|
$base = $this->sanitizePathSegment(
|
|
|
ProjectCode::resolve($application).($application->player_name ?? '未知')
|
|
|
);
|
|
|
$folder = $base;
|
|
|
$suffix = 2;
|
|
|
while (isset($usedFolderNames[$folder])) {
|
|
|
$folder = $base.'_'.$suffix;
|
|
|
$suffix++;
|
|
|
}
|
|
|
|
|
|
return $folder;
|
|
|
}
|
|
|
|
|
|
private function uniqueFileNameInFolder(string $folder, ApplicationFile $file, ZipArchive $zip): string
|
|
|
{
|
|
|
$name = $file->clientDownloadName();
|
|
|
$candidate = $folder.'/'.$name;
|
|
|
if ($zip->locateName($candidate) === false) {
|
|
|
return $candidate;
|
|
|
}
|
|
|
|
|
|
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
|
|
$base = pathinfo($name, PATHINFO_FILENAME);
|
|
|
$suffix = 2;
|
|
|
while (true) {
|
|
|
$next = $folder.'/'.($ext !== '' ? $base.'_'.$suffix.'.'.$ext : $base.'_'.$suffix);
|
|
|
if ($zip->locateName($next) === false) {
|
|
|
return $next;
|
|
|
}
|
|
|
$suffix++;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private function sanitizePathSegment(string $value): string
|
|
|
{
|
|
|
$value = trim(str_replace(['\\', '/'], '_', $value));
|
|
|
$value = preg_replace('/[<>:"|?*]/u', '_', $value) ?? $value;
|
|
|
$value = preg_replace('/[\x00-\x1F\x7F]/u', '', $value) ?? $value;
|
|
|
|
|
|
return $value !== '' ? $value : '未命名';
|
|
|
}
|
|
|
}
|