问卷导出

master
lion 2 days ago
parent f0a571208d
commit fb1d23ccd2

@ -56,6 +56,11 @@ class CourseContentEvaluationFormController extends BaseController
public function index()
{
$all = request()->all();
if (request()->boolean('export_bundle')) {
$survey = CourseContentEvaluation::findOrFail(request('course_content_evaluation_id'));
$path = \App\Services\Survey\SurveyExport::build($survey);
return response()->download($path, '问卷答卷_'.$survey->id.'_'.date('Ymd_His').'.zip')->deleteFileAfterSend(true);
}
$list = $this->model->with('user')->where(function ($query) use ($all) {
if (isset($all['filter']) && !empty($all['filter'])) {
foreach ($all['filter'] as $condition) {

@ -0,0 +1,100 @@
<?php
namespace App\Services\Survey;
use App\Models\CourseContentEvaluation;
use App\Models\Upload;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use ZipArchive;
class SurveyExport
{
private const TYPES = ['radio' => '单选/评分', 'checkbox' => '多选', 'text' => '短文本', 'textarea' => '长文本', 'number' => '数字', 'date' => '日期', 'datetime' => '日期时间', 'file' => '文件上传'];
private static function safeName($name): string
{
return mb_substr(trim(preg_replace('/[\\\\\\/<>:"|?*\x00-\x1f]/u', '_', (string) $name), '. '), 0, 80) ?: '未命名';
}
public static function build(CourseContentEvaluation $survey): string
{
$dir = storage_path('app/survey-exports');
if (!is_dir($dir)) mkdir($dir, 0700, true);
$base = $dir.'/'.bin2hex(random_bytes(16));
$book = new Spreadsheet();
$zip = new ZipArchive();
$opened = false;
try {
if ($zip->open($base.'.zip', ZipArchive::CREATE | ZipArchive::EXCL) !== true) throw new \RuntimeException('无法创建导出文件');
$opened = true;
$summary = $book->getActiveSheet()->setTitle('答卷列表');
$detail = $book->createSheet()->setTitle('问题详细表');
$attachments = $book->createSheet()->setTitle('附件清单');
self::row($summary, 1, ['答卷ID', '问卷名称', '答题人姓名', '学号', '公司', '职务', '首次提交时间', '最近提交时间']);
self::row($detail, 1, ['答卷ID', '答题人姓名', '章节', '题号', '题目', '子题/维度', '题型', '题目说明', '所有选项', '答案', '补充说明', '状态']);
self::row($attachments, 1, ['答卷ID', '答题人姓名', '题目', '原文件名', '压缩包内路径', '状态']);
$sr = $dr = $ar = 2;
$current = $survey->courseContentEvaluationAsks()->orderBy('sort')->orderBy('id')->get()->toArray();
foreach ($survey->courseContentEvaluationForms()->with('user')->lazyById(100) as $reply) {
$name = $reply->user->name ?? '未填写姓名';
self::row($summary, $sr++, [$reply->id, $reply->snapshot['title'] ?? $survey->title, $name, $reply->user->no ?? '', $reply->user->company_name ?? '', $reply->user->company_position ?? '', $reply->created_at, $reply->updated_at]);
$section = '';
$number = 0;
foreach (SurveySchema::replyQuestions($current, $reply->data, $reply->snapshot) as $q) {
if ($q['edit_input'] === 'section') { $section = $q['name']; continue; }
$hidden = !empty($q['hidden']);
$position = $hidden ? '' : ++$number;
$extra = $hidden ? '' : ($q['other_text'] ?? '');
foreach ($q['select_item'] ?? [] as $opt) {
$note = $q['option_texts'][$opt['key']] ?? '';
if (!$hidden && $note !== '') $extra .= ($extra ? "\n" : '').$opt['value'].':'.$note;
}
$fields = $q['edit_input'] === 'multi_dimension' ? ($q['dimensions'] ?? []) : [$q];
foreach ($fields as $field) {
$options = array_map(fn ($o) => $o['value'].(isset($o['score']) ? '('.$o['score'].'分)' : ''), $field['select_item'] ?? []);
self::row($detail, $dr++, [$reply->id, $name, $section, $position, $q['name'], $q['edit_input'] === 'multi_dimension' ? $field['name'] : '', self::TYPES[$field['edit_input']] ?? $field['edit_input'], $field['help'] ?? $q['help'] ?? '', implode("\n", $options), $hidden ? '' : ($field['display_value'] ?? ''), $extra, $hidden ? '条件未满足,未显示' : '已显示']);
}
if ($hidden || $q['edit_input'] !== 'file') continue;
foreach ($q['value'] ?? [] as $item) {
$file = Upload::find($item['id'] ?? null);
$original = $file->original_name ?? $item['original_name'] ?? '附件';
$entry = '附件/'.$reply->id.'_'.self::safeName($name).'/'.$q['id'].'_'.self::safeName($q['name']).'/'.($item['id'] ?? '0').'_'.self::safeName($original);
$valid = $file && $file->folder === 'storage/files' && basename($file->name) === $file->name && Storage::disk('public')->exists('files/'.$file->name);
if ($valid && !$zip->addFile(Storage::disk('public')->path('files/'.$file->name), $entry)) throw new \RuntimeException('附件打包失败');
self::row($attachments, $ar++, [$reply->id, $name, $q['name'], $original, $valid ? $entry : '', $valid ? '已打包' : '文件缺失,未打包']);
}
}
}
foreach ($book->getAllSheets() as $sheet) {
$sheet->freezePane('C2');
$sheet->setAutoFilter($sheet->calculateWorksheetDimension());
$sheet->getStyle('1:1')->getFont()->setBold(true);
$sheet->getStyle($sheet->calculateWorksheetDimension())->getAlignment()->setWrapText(true)->setVertical('top');
foreach (range(1, Coordinate::columnIndexFromString($sheet->getHighestColumn())) as $col) $sheet->getColumnDimensionByColumn($col)->setWidth($col < 3 ? 18 : 32);
}
(new Xlsx($book))->save($base.'.xlsx');
$zip->addFile($base.'.xlsx', '问卷答卷明细.xlsx');
if (!$zip->close()) throw new \RuntimeException('导出压缩包生成失败');
$opened = false;
return $base.'.zip';
} catch (\Throwable $e) {
if ($opened) $zip->close();
@unlink($base.'.zip');
throw $e;
} finally {
@unlink($base.'.xlsx');
$book->disconnectWorksheets();
}
}
private static function row($sheet, int $row, array $values): void
{
foreach ($values as $index => $value) {
// User input is always text, never an Excel formula.
$sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($index + 1).$row, (string) $value, DataType::TYPE_STRING);
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -95,6 +95,25 @@ class SurveyWorkflowTest extends TestCase
\Illuminate\Support\Facades\Storage::disk('public')->put('files/81.pdf', 'example attachment');
$this->get($download.'81')->assertOk()->assertStreamedContent('example attachment');
$this->get($download.'82')->assertStatus(403);
Schema::table('users', function (Blueprint $table) { $table->string('name')->nullable(); });
DB::table('users')->insert(['id' => 200, 'name' => '=SUM(1,2)']);
$export = $this->get('/api/admin/course-content-evaluation-form/index?export_bundle=1&course_content_evaluation_id=10')->assertOk();
$path = $export->baseResponse->getFile()->getPathname();
$zip = new \ZipArchive();
$this->assertTrue($zip->open($path));
$xlsxPath = $path.'.xlsx';
file_put_contents($xlsxPath, $zip->getFromName('问卷答卷明细.xlsx'));
$book = \PhpOffice\PhpSpreadsheet\IOFactory::load($xlsxPath);
$this->assertSame('=SUM(1,2)', $book->getSheetByName('答卷列表')->getCell('C2')->getValue());
$this->assertSame('s', $book->getSheetByName('答卷列表')->getCell('C2')->getDataType());
$this->assertSame('调研附件.pdf', $book->getSheetByName('问题详细表')->getCell('J2')->getValue());
$entry = $book->getSheetByName('附件清单')->getCell('E2')->getValue();
$this->assertSame('example attachment', $zip->getFromName($entry));
$book->disconnectWorksheets();
$zip->close();
unlink($xlsxPath);
unlink($path);
$this->getJson('/api/admin/course-content-evaluation-form/text-answers?course_content_evaluation_id=10&question_id='.$id)->assertJsonPath('data.0.files.0.original_name', '调研附件.pdf');
$this->getJson('/api/mobile/course/evaluation-detail?course_content_evaluation_id=10')->assertJsonPath('my_response.questions.0.value.0.id', 81);
@ -184,6 +203,21 @@ class SurveyWorkflowTest extends TestCase
// Even an out-of-band definition change cannot rewrite an existing reply.
DB::table('course_content_evaluation_asks')->where('id', $id)->update(['name' => '数据库修改']);
$this->getJson('/api/admin/course-content-evaluation-form/show?id=' . $replyId)->assertJsonPath('questions.0.name', '融资对接');
$path = \App\Services\Survey\SurveyExport::build(\App\Models\CourseContentEvaluation::find(10));
$zip = new \ZipArchive();
$zip->open($path);
file_put_contents($path.'.xlsx', $zip->getFromName('问卷答卷明细.xlsx'));
$book = \PhpOffice\PhpSpreadsheet\IOFactory::load($path.'.xlsx');
$sheet = $book->getSheetByName('问题详细表');
$this->assertSame('融资对接', $sheet->getCell('E2')->getValue());
$this->assertSame('意向数量', $sheet->getCell('F2')->getValue());
$this->assertSame('0', $sheet->getCell('J2')->getValue());
$this->assertSame('否', $sheet->getCell('J3')->getValue());
$book->disconnectWorksheets();
$zip->close();
unlink($path.'.xlsx');
unlink($path);
}
public function testMobileReentryReturnsOnlyOwnSnapshot(): void

Loading…
Cancel
Save