diff --git a/app/Services/AdminApplicationExportService.php b/app/Services/AdminApplicationExportService.php index b131a22..1238a25 100644 --- a/app/Services/AdminApplicationExportService.php +++ b/app/Services/AdminApplicationExportService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\Application; use App\Models\ApplicationFile; use App\Models\Competition; +use App\Models\ReviewerScope; use App\Support\ProjectCode; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; @@ -329,16 +330,22 @@ class AdminApplicationExportService '项目简介', '提交时间', '附件数量', + '进程', + '评分详情', ])); + $reviewersByTrack = $this->reviewersByTrackForCompetition($competition); + $chunkSize = max(20, (int) config('contest.export.chunk_size', 20)); $this->buildFilteredQuery($competition, $filters) - ->with(['signupChannel']) + ->with(['signupChannel', 'reviewScores.reviewer']) ->withCount('files') ->orderBy('id') - ->chunkById($chunkSize, function ($applications) use ($writer, $trackTitles): void { + ->chunkById($chunkSize, function ($applications) use ($writer, $trackTitles, $reviewersByTrack): void { foreach ($applications as $app) { - $writer->addRow(Row::fromValues($this->applicationExportRow($app, $trackTitles))); + $writer->addRow(Row::fromValues( + $this->applicationExportRow($app, $trackTitles, $reviewersByTrack) + )); } }, 'id'); @@ -372,16 +379,73 @@ class AdminApplicationExportService } } + /** + * 按赛道预加载评委名单(含姓名),用于导出进程与评分详情。 + * + * @return Collection> + */ + private function reviewersByTrackForCompetition(Competition $competition): Collection + { + $grouped = []; + ReviewerScope::query() + ->with('reviewer') + ->where('competition_id', $competition->id) + ->orderBy('id') + ->get() + ->each(function (ReviewerScope $scope) use (&$grouped): void { + $track = (string) ($scope->track_code ?? ''); + if ($track === '') { + return; + } + $reviewerId = (int) $scope->reviewer_id; + $name = trim((string) ($scope->reviewer?->name ?? '')); + if ($name === '') { + $name = '评委#'.$reviewerId; + } + $grouped[$track] ??= []; + $grouped[$track][$reviewerId] = [ + 'id' => $reviewerId, + 'name' => $name, + ]; + }); + + return collect($grouped)->map(fn (array $rows) => array_values($rows)); + } + /** * @param Collection $trackTitles + * @param Collection> $reviewersByTrack * @return list */ - private function applicationExportRow(Application $app, Collection $trackTitles): array + private function applicationExportRow(Application $app, Collection $trackTitles, Collection $reviewersByTrack): array { $channel = $app->signupChannel ? $app->signupChannel->channel_name.'('.$app->signupChannel->channel_code.')' : ($app->signup_channel_code ?: ''); + $trackReviewers = $reviewersByTrack->get((string) ($app->track ?? '')) ?? []; + $required = count($trackReviewers); + $scoresByReviewerId = []; + if ($app->relationLoaded('reviewScores')) { + foreach ($app->reviewScores as $score) { + $scoresByReviewerId[(int) $score->reviewer_id] = $score; + } + } + $completed = count($scoresByReviewerId); + $progress = $required > 0 ? ($completed.'/'.$required) : '-'; + + $scoreDetails = collect($trackReviewers) + ->map(function (array $reviewer) use ($scoresByReviewerId): string { + $name = $reviewer['name']; + $score = $scoresByReviewerId[$reviewer['id']] ?? null; + if ($score === null) { + return $name.' 未评审'; + } + + return $name.' '.$this->formatExportScore($score->line_total); + }) + ->implode(';'); + return [ ProjectCode::resolve($app), $app->status === 'submitted' ? '已提交' : '草稿', @@ -400,9 +464,23 @@ class AdminApplicationExportService $app->intro ?? '', $app->submitted_at?->format('Y-m-d H:i:s') ?? '', (int) ($app->files_count ?? $app->files->count()), + $progress, + $scoreDetails, ]; } + private function formatExportScore(mixed $value): string + { + if ($value === null || $value === '') { + return '—'; + } + if (! is_numeric($value)) { + return (string) $value; + } + + return rtrim(rtrim(number_format((float) $value, 2, '.', ''), '0'), '.') ?: '0'; + } + /** * @param array $filters */ diff --git a/tests/Feature/AdminApplicationExportTest.php b/tests/Feature/AdminApplicationExportTest.php index fa0929c..fd6bd03 100644 --- a/tests/Feature/AdminApplicationExportTest.php +++ b/tests/Feature/AdminApplicationExportTest.php @@ -4,11 +4,16 @@ namespace Tests\Feature; use App\Models\AdminUser; use App\Models\Application; +use App\Models\ApplicationReviewScore; use App\Models\Competition; +use App\Models\CompetitionTrack; +use App\Models\Reviewer; +use App\Models\ReviewerScope; use App\Models\User; use App\Services\AdminApplicationExportService; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Sanctum\Sanctum; +use OpenSpout\Reader\XLSX\Reader; use Tests\TestCase; class AdminApplicationExportTest extends TestCase @@ -79,4 +84,98 @@ class AdminApplicationExportTest extends TestCase ->assertStatus(422) ->assertJsonValidationErrors(['application_ids']); } + + public function test_xlsx_export_includes_progress_and_score_details(): void + { + $admin = AdminUser::query()->create([ + 'username' => 'admin_export3', + 'password_hash' => 'unused', + 'name' => '管理员3', + 'status' => 'active', + ]); + $competition = Competition::query()->create([ + 'slug' => 'xxf-export-score', + 'name' => '导出评分赛', + 'published' => true, + ]); + CompetitionTrack::query()->create([ + 'competition_id' => $competition->id, + 'track_code' => 'track_a', + 'title' => '赛道A', + 'sort' => 1, + 'is_enabled' => true, + ]); + + $reviewer1 = Reviewer::query()->create([ + 'mobile' => '13900139101', + 'username' => 'rev_export_1', + 'name' => '评委甲', + 'status' => 'active', + 'password_hash' => 'unused', + ]); + $reviewer2 = Reviewer::query()->create([ + 'mobile' => '13900139102', + 'username' => 'rev_export_2', + 'name' => '评委乙', + 'status' => 'active', + 'password_hash' => 'unused', + ]); + ReviewerScope::query()->create([ + 'competition_id' => $competition->id, + 'reviewer_id' => $reviewer1->id, + 'track_code' => 'track_a', + ]); + ReviewerScope::query()->create([ + 'competition_id' => $competition->id, + 'reviewer_id' => $reviewer2->id, + 'track_code' => 'track_a', + ]); + + $application = Application::query()->create([ + 'user_id' => User::query()->create(['mobile' => '13800138111'])->id, + 'competition_id' => $competition->id, + 'status' => 'submitted', + 'project_name' => '导出评分项目', + 'track' => 'track_a', + ]); + ApplicationReviewScore::query()->create([ + 'application_id' => $application->id, + 'reviewer_id' => $reviewer1->id, + 'payload_json' => ['line_total' => 88.5], + 'line_total' => 88.5, + ]); + + Sanctum::actingAs($admin); + + $service = app(AdminApplicationExportService::class); + $xlsxPath = $service->buildXlsxExportPath($competition, ['status' => 'submitted']); + $this->assertFileExists($xlsxPath); + + $reader = new Reader; + $reader->open($xlsxPath); + $rows = []; + foreach ($reader->getSheetIterator() as $sheet) { + foreach ($sheet->getRowIterator() as $row) { + $rows[] = $row->toArray(); + } + break; + } + $reader->close(); + @unlink($xlsxPath); + + $this->assertGreaterThanOrEqual(2, count($rows)); + $header = $rows[0]; + $this->assertContains('进程', $header); + $this->assertContains('评分详情', $header); + + $progressIdx = array_search('进程', $header, true); + $detailIdx = array_search('评分详情', $header, true); + $this->assertNotFalse($progressIdx); + $this->assertNotFalse($detailIdx); + + $data = $rows[1]; + $this->assertSame('1/2', (string) $data[$progressIdx]); + $this->assertStringContainsString('评委甲 88.5', (string) $data[$detailIdx]); + $this->assertStringContainsString('评委乙 未评审', (string) $data[$detailIdx]); + } }