parent
8c10f642c2
commit
d6f87e2d96
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Services\Course\CourseFormSchema;
|
||||
use Maatwebsite\Excel\Concerns\FromArray;
|
||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||
use Maatwebsite\Excel\Events\AfterSheet;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
|
||||
/** Registration-only export: union schemas instead of deriving headers from the first answer. */
|
||||
class CourseRegistrationExport implements FromArray, WithEvents
|
||||
{
|
||||
private array $rows;
|
||||
private array $columns;
|
||||
private array $sheet;
|
||||
|
||||
public function __construct(array $rows, $baseFields, array $current = [])
|
||||
{
|
||||
$this->rows = $rows;
|
||||
$this->columns = [];
|
||||
$baseFields = is_array($baseFields) ? $baseFields : [];
|
||||
foreach ($rows as $row) if (!empty($row['replaces_sign_id']) || !empty($row['replaced_by_sign_id'])) {
|
||||
$baseFields += ['company_name_snapshot' => '报名公司', 'replacement_text' => '报名更换记录', 'giveup_reason' => '放弃原因', 'replaced_at' => '更换时间'];
|
||||
break;
|
||||
}
|
||||
foreach (['id' => '报名ID'] + $baseFields as $key => $name) $this->columns[] = ['base' => $key, 'name' => $name];
|
||||
$fields = [];
|
||||
foreach (array_merge($current, ...array_map(fn ($row) => is_array($row['data'] ?? null) ? $row['data'] : [], $rows)) as $field) {
|
||||
if (!is_array($field) || !isset($field['field'])) continue;
|
||||
$field = CourseFormSchema::field($field);
|
||||
// A renamed historical field keeps its own title/column and can never shift another answer.
|
||||
$key = $field['field']."\0".($field['name'] ?? $field['field']);
|
||||
if (!isset($fields[$key])) $fields[$key] = $field;
|
||||
elseif (!empty($field['option_texts']) || !empty($field['other_text'])) $fields[$key]['has_notes'] = true;
|
||||
}
|
||||
foreach ($fields as $field) {
|
||||
$this->columns[] = ['field' => $field['field'], 'title' => $field['name'] ?? $field['field'], 'name' => $field['name'] ?? $field['field']];
|
||||
if (!empty($field['config']['option_notes']) || !empty($field['config']['other_key']) || !empty($field['option_texts']) || !empty($field['other_text']) || !empty($field['has_notes'])) {
|
||||
$this->columns[] = ['field' => $field['field'], 'title' => $field['name'] ?? $field['field'], 'notes' => true, 'name' => ($field['name'] ?? $field['field']).'-补充说明'];
|
||||
}
|
||||
}
|
||||
$this->sheet = [array_column($this->columns, 'name')];
|
||||
foreach ($rows as $row) {
|
||||
$answers = CourseFormSchema::display(is_array($row['data'] ?? null) ? $row['data'] : []);
|
||||
$cells = [];
|
||||
foreach ($this->columns as $column) {
|
||||
if (isset($column['base'])) { $value = data_get($row, $column['base']); $cells[] = is_scalar($value) ? (string) $value : ''; continue; }
|
||||
$matches = array_values(array_filter($answers, fn ($answer) => ($answer['field'] ?? null) === $column['field'] && ($answer['name'] ?? $answer['field']) === $column['title']));
|
||||
$answer = $matches[0] ?? [];
|
||||
$value = !empty($answer['hidden']) ? '' : ($answer[!empty($column['notes']) ? 'supplement_text' : 'display_value'] ?? '');
|
||||
if (empty($column['notes']) && empty($answer['hidden']) && ($answer['edit_input'] ?? '') === 'checkbox' && isset($answer['selected_keys'])) {
|
||||
$selected = array_map('strval', $answer['selected_keys']);
|
||||
$value = implode("\n", array_column(array_filter($answer['select_item'], fn ($option) => in_array((string) $option['key'], $selected, true)), 'value'));
|
||||
}
|
||||
$cells[] = $value;
|
||||
}
|
||||
$this->sheet[] = $cells;
|
||||
}
|
||||
}
|
||||
|
||||
public function array(): array { return $this->sheet; }
|
||||
|
||||
public function registerEvents(): array
|
||||
{
|
||||
return [AfterSheet::class => function (AfterSheet $event) {
|
||||
$sheet = $event->sheet->getDelegate();
|
||||
$last = Coordinate::stringFromColumnIndex(max(1, count($this->columns)));
|
||||
$range = 'A1:'.$last.count($this->sheet);
|
||||
$sheet->getStyle($range)->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
|
||||
$sheet->getStyle('A1:'.$last.'1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER)->setVertical(Alignment::VERTICAL_CENTER);
|
||||
$sheet->getStyle('A1:'.$last.'1')->getFont()->setBold(true);
|
||||
$sheet->getRowDimension(1)->setRowHeight(42);
|
||||
$sheet->freezePane('A2');
|
||||
$sheet->setAutoFilter('A1:'.$last.count($this->sheet));
|
||||
foreach ($this->columns as $index => $column) $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth(isset($column['base']) ? 20 : (!empty($column['notes']) ? 42 : 30));
|
||||
foreach ($this->sheet as $index => $row) {
|
||||
foreach ($row as $col => $value) $sheet->setCellValueExplicit(Coordinate::stringFromColumnIndex($col + 1).($index + 1), (string) $value, DataType::TYPE_STRING);
|
||||
if ($index > 0) {
|
||||
$lines = 1;
|
||||
foreach ($row as $value) $lines = max($lines, count(explode("\n", (string) $value)), (int) ceil(mb_strwidth((string) $value) / 28));
|
||||
$sheet->getRowDimension($index + 1)->setRowHeight(min(300, max(32, 18 * $lines + 10)));
|
||||
}
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Course;
|
||||
|
||||
use App\Exports\CourseRegistrationExport;
|
||||
use App\Models\Upload;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Maatwebsite\Excel\Excel as Format;
|
||||
use ZipArchive;
|
||||
|
||||
class CourseRegistrationArchive
|
||||
{
|
||||
public static function attachments(array $rows): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($rows as $row) foreach ($row['data'] ?? [] as $field) {
|
||||
if (!is_array($field) || !empty($field['hidden']) || ($field['edit_input'] ?? '') !== 'files') continue;
|
||||
$files = $field['fileList'] ?? [];
|
||||
if (!$files) $files = $field['fileListArr'] ?? [];
|
||||
if (!$files) $files = array_filter(explode(',', (string) ($field['value'] ?? '')));
|
||||
foreach ($files as $file) $items[] = ['row' => $row, 'field' => $field, 'file' => is_array($file) ? ($file['response'] ?? $file) : ['url' => $file]];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function safeName($name): string
|
||||
{
|
||||
return mb_substr(trim(preg_replace('/[\\\\\\/<>:"|?*\x00-\x1f]/u', '_', (string) $name), '. '), 0, 80) ?: '未命名';
|
||||
}
|
||||
|
||||
public static function build(CourseRegistrationExport $export, array $items): string
|
||||
{
|
||||
$dir = storage_path('app/course-registration-exports');
|
||||
if (!is_dir($dir)) mkdir($dir, 0700, true);
|
||||
$path = $dir.'/'.bin2hex(random_bytes(16)).'.zip';
|
||||
$zip = new ZipArchive();
|
||||
$opened = false;
|
||||
try {
|
||||
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::EXCL) !== true) throw new \RuntimeException('无法创建报名导出文件');
|
||||
$opened = true;
|
||||
if (!$zip->addFromString('报名信息.xlsx', Excel::raw($export, Format::XLSX))) throw new \RuntimeException('报名表打包失败');
|
||||
$manifest = [["报名ID", "报名人", "字段", "原文件名", "压缩包内路径", "状态"]];
|
||||
foreach ($items as $index => $item) {
|
||||
$row = $item['row']; $field = $item['field']; $file = $item['file'];
|
||||
$upload = !empty($file['id']) ? Upload::find($file['id']) : null;
|
||||
// Historical answers may contain only URLs. Resolve through upload records;
|
||||
// never download a submitted URL or use its path to read arbitrary files.
|
||||
if (!$upload && empty($file['id'])) {
|
||||
$urlPath = parse_url((string) ($file['url'] ?? ''), PHP_URL_PATH);
|
||||
if (is_string($urlPath) && preg_match('#(?:^|/)storage/files/([^/]+)$#', $urlPath, $match)) {
|
||||
$upload = Upload::where('folder', 'storage/files')->where('name', $match[1])->first();
|
||||
}
|
||||
}
|
||||
$name = data_get($row, 'user.name') ?: '未填写姓名';
|
||||
foreach ($row['data'] ?? [] as $answer) if (($answer['field'] ?? '') === 'name' && !empty($answer['value']) && is_scalar($answer['value'])) { $name = $answer['value']; break; }
|
||||
$original = $upload->original_name ?? $file['original_name'] ?? $file['name'] ?? basename((string) ($file['url'] ?? '附件'));
|
||||
$entry = '附件/'.$row['id'].'_'.self::safeName($name).'/'.self::safeName($field['field']).'_'.self::safeName($field['name'] ?? '').'/'.($index + 1).'_'.self::safeName($original);
|
||||
$valid = $upload && $upload->folder === 'storage/files' && basename($upload->name) === $upload->name;
|
||||
$source = $valid ? realpath(Storage::disk('public')->path('files/'.$upload->name)) : false;
|
||||
$root = realpath(Storage::disk('public')->path('files'));
|
||||
$valid = $source && $root && strpos($source, $root.DIRECTORY_SEPARATOR) === 0 && is_file($source);
|
||||
if ($valid && !$zip->addFile($source, $entry)) throw new \RuntimeException('报名附件打包失败');
|
||||
$manifest[] = [$row['id'], $name, $field['name'] ?? $field['field'], $original, $valid ? $entry : '', $valid ? '已打包' : '文件缺失,未打包'];
|
||||
}
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
fwrite($stream, "\xEF\xBB\xBF");
|
||||
foreach ($manifest as $line) fputcsv($stream, array_map(function ($value) {
|
||||
$text = (string) $value;
|
||||
return preg_match('/^[\s]*[=+@-]/u', $text) ? "'".$text : $text;
|
||||
}, $line));
|
||||
rewind($stream); $csv = stream_get_contents($stream); fclose($stream);
|
||||
if (!$zip->addFromString('附件清单.csv', $csv) || !$zip->close()) throw new \RuntimeException('报名导出文件生成失败');
|
||||
$opened = false;
|
||||
return $path;
|
||||
} catch (\Throwable $e) {
|
||||
if ($opened) $zip->close();
|
||||
@unlink($path);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up()
|
||||
{
|
||||
if (!Schema::hasColumn('course_forms', 'config')) Schema::table('course_forms', function (Blueprint $table) { $table->json('config')->nullable(); });
|
||||
if (!Schema::hasColumn('course_forms', 'belong_user_table')) Schema::table('course_forms', function (Blueprint $table) { $table->string('belong_user_table')->nullable(); });
|
||||
Schema::table('course_signs', function (Blueprint $table) { $table->json('form_snapshot')->nullable(); });
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('course_signs', function (Blueprint $table) { $table->dropColumn('form_snapshot'); });
|
||||
// Keep possibly pre-existing field mappings and configuration on rollback.
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('courses', function (Blueprint $table) {
|
||||
$table->boolean('one_person_per_company')->default(false)->comment('每家公司限一人报名');
|
||||
});
|
||||
Schema::table('course_signs', function (Blueprint $table) {
|
||||
$table->string('company_key', 80)->nullable()->index();
|
||||
$table->string('company_name_snapshot')->nullable();
|
||||
$table->unsignedBigInteger('replaces_sign_id')->nullable()->index();
|
||||
$table->unsignedBigInteger('replaced_by_sign_id')->nullable()->index();
|
||||
$table->timestamp('replaced_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('course_signs', function (Blueprint $table) {
|
||||
$table->dropIndex(['company_key']); $table->dropIndex(['replaces_sign_id']); $table->dropIndex(['replaced_by_sign_id']);
|
||||
$table->dropColumn(['company_key', 'company_name_snapshot', 'replaces_sign_id', 'replaced_by_sign_id', 'replaced_at']);
|
||||
});
|
||||
Schema::table('courses', fn (Blueprint $table) => $table->dropColumn('one_person_per_company'));
|
||||
}
|
||||
};
|
||||
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
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
@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Exports\CourseRegistrationExport;
|
||||
use App\Models\Admin;
|
||||
use App\Models\CourseSign;
|
||||
use App\Models\User;
|
||||
use App\Services\Course\CourseFormSchema;
|
||||
use App\Services\Survey\SurveySchema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CourseFormWorkflowTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:', 'audit.enabled' => false]);
|
||||
DB::purge('sqlite');
|
||||
$this->withoutMiddleware();
|
||||
(require database_path('migrations/2024_07_29_100821_create_course_forms_table.php'))->up();
|
||||
Schema::create('course_signs', function (Blueprint $table) {
|
||||
$table->id(); $table->integer('course_id'); $table->integer('user_id'); $table->integer('status')->default(0); $table->integer('fee_status')->default(0);
|
||||
$table->json('data')->nullable(); $table->json('change_data')->nullable(); $table->integer('is_change')->default(0); $table->json('fee_file_ids')->nullable();
|
||||
$table->softDeletes(); $table->timestamps();
|
||||
});
|
||||
(require database_path('migrations/2026_10_30_000001_extend_course_forms_and_sign_snapshots.php'))->up();
|
||||
Schema::create('courses', function (Blueprint $table) { $table->id(); $table->softDeletes(); });
|
||||
DB::table('courses')->insert([['id' => 10], ['id' => 11]]);
|
||||
$admin = new Admin(); $admin->forceFill(['id' => 1, 'department_id' => 1]); Auth::guard('admin')->setUser($admin);
|
||||
$user = new User(); $user->forceFill(['id' => 200, 'name' => '原姓名', 'mobile' => '13800000000']); Auth::guard('mobile')->setUser($user);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Auth::guard('admin')->forgetUser(); Auth::guard('mobile')->forgetUser(); DB::disconnect('sqlite'); parent::tearDown();
|
||||
}
|
||||
|
||||
public function testRegistrationArchiveIncludesCurrentAndLegacyFilesAndListsMissingFiles(): void
|
||||
{
|
||||
\Illuminate\Support\Facades\Storage::fake('public');
|
||||
Schema::create('uploads', function (Blueprint $table) {
|
||||
$table->id(); $table->string('folder'); $table->string('name'); $table->string('original_name');
|
||||
});
|
||||
DB::table('uploads')->insert(['id' => 8, 'folder' => 'storage/files', 'name' => 'example.pdf', 'original_name' => '材料.pdf']);
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->put('files/example.pdf', 'test-pdf');
|
||||
$rows = [['id' => 1, 'user' => ['name' => '张三'], 'data' => [
|
||||
['field' => 'docs', 'name' => '材料', 'edit_input' => 'files', 'fileList' => [['response' => ['id' => 8]]]],
|
||||
['field' => 'legacy', 'name' => '历史材料', 'edit_input' => 'files', 'value' => 'https://example.com/storage/files/example.pdf'],
|
||||
['field' => 'missing', 'name' => '缺失材料', 'edit_input' => 'files', 'fileList' => [['id' => 99, 'original_name' => '失效.pdf']]],
|
||||
['field' => 'hidden', 'name' => '隐藏', 'edit_input' => 'files', 'hidden' => true, 'fileList' => [['id' => 8]]],
|
||||
]]];
|
||||
$items = \App\Services\Course\CourseRegistrationArchive::attachments($rows);
|
||||
$this->assertCount(3, $items);
|
||||
$path = \App\Services\Course\CourseRegistrationArchive::build(new CourseRegistrationExport($rows, []), $items);
|
||||
try {
|
||||
$zip = new \ZipArchive(); $this->assertTrue($zip->open($path));
|
||||
$this->assertNotFalse($zip->locateName('报名信息.xlsx'));
|
||||
$this->assertSame('test-pdf', $zip->getFromName('附件/1_张三/docs_材料/1_材料.pdf'));
|
||||
$this->assertSame('test-pdf', $zip->getFromName('附件/1_张三/legacy_历史材料/2_材料.pdf'));
|
||||
$this->assertStringContainsString('文件缺失,未打包', $zip->getFromName('附件清单.csv'));
|
||||
$this->assertSame(4, $zip->numFiles);
|
||||
$zip->close();
|
||||
} finally { @unlink($path); }
|
||||
$this->assertSame([], \App\Services\Course\CourseRegistrationArchive::attachments([['data' => [['field' => 'x', 'edit_input' => 'files', 'value' => '']]]]));
|
||||
}
|
||||
|
||||
private function fields(): array
|
||||
{
|
||||
return [
|
||||
['id' => 1, 'field' => 'finance', 'name' => '融资', 'edit_input' => 'radio', 'rule' => 'required', 'sort' => 1, 'select_item' => [['key' => 'later', 'value' => '事后对接'], ['key' => 'road', 'value' => '参加路演'], ['key' => 'no', 'value' => '不需要']]],
|
||||
['id' => 2, 'field' => 'venues', 'name' => '会场', 'edit_input' => 'checkbox', 'sort' => 2, 'select_item' => [['key' => 'tax', 'value' => '财税'], ['key' => 'legal', 'value' => '法律'], ['key' => 'ip', 'value' => '知识产权']], 'config' => ['min_choices' => 2, 'max_choices' => 2, 'option_notes' => ['tax' => ['required' => true, 'placeholder' => '咨询问题']], 'visible_when' => ['field' => 'finance', 'values' => ['road', 'later'], 'required_values' => ['road']]]],
|
||||
];
|
||||
}
|
||||
|
||||
public function testLegacyLabelsAndNewKeysRoundTripWithConditionalNotesAndZero(): void
|
||||
{
|
||||
$fields = $this->fields();
|
||||
$fields[] = ['id' => 3, 'field' => 'count', 'name' => '数量', 'edit_input' => 'number'];
|
||||
$result = CourseFormSchema::normalize($fields, [
|
||||
['field' => 'finance', 'value' => '参加路演', 'name' => '伪造字段名'],
|
||||
['field' => 'venues', 'selected_keys' => ['tax', 'legal'], 'value' => '客户端值', 'option_texts' => ['tax' => '税务筹划', 'ip' => '未选中应删除']],
|
||||
['field' => 'count', 'value' => 0],
|
||||
]);
|
||||
$this->assertSame('融资', $result[0]['name']);
|
||||
$this->assertSame('参加路演', $result[0]['value']);
|
||||
$this->assertSame('财税,法律', $result[1]['value']);
|
||||
$this->assertSame(['tax', 'legal'], $result[1]['selected_keys']);
|
||||
$this->assertSame(['tax' => '税务筹划'], $result[1]['option_texts']);
|
||||
$this->assertSame(0.0, $result[2]['value']);
|
||||
$this->assertSame('财税:税务筹划', CourseFormSchema::display($result)[1]['supplement_text']);
|
||||
$this->assertSame($result, CourseFormSchema::normalize($fields, $result));
|
||||
}
|
||||
|
||||
public function testHiddenFieldsDoNotRequireValuesOrUpdateProfiles(): void
|
||||
{
|
||||
$fields = $this->fields();
|
||||
$fields[1]['belong_user'] = 1; $fields[1]['belong_user_table'] = 'company_type';
|
||||
$result = CourseFormSchema::normalize($fields, [['field' => 'finance', 'value' => '不需要'], ['field' => 'venues', 'value' => '已删除的旧选项', 'option_texts' => ['tax' => '旧说明']]]);
|
||||
$this->assertTrue($result[1]['hidden']); $this->assertNull($result[1]['value']); $this->assertSame([], $result[1]['option_texts']);
|
||||
$this->assertSame([], CourseFormSchema::changes($result, (object) []));
|
||||
$optional = CourseFormSchema::normalize($fields, [['field' => 'finance', 'value' => '事后对接']]);
|
||||
$this->assertFalse($optional[1]['need_fill']); $this->assertSame('', $optional[1]['value']);
|
||||
}
|
||||
|
||||
public function testVisibleMinimumMaximumRequiredNotesAndOptionalPartialAnswersAreEnforced(): void
|
||||
{
|
||||
foreach ([
|
||||
[['field' => 'finance', 'value' => '参加路演']],
|
||||
[['field' => 'finance', 'value' => '事后对接'], ['field' => 'venues', 'value' => '法律']],
|
||||
[['field' => 'finance', 'value' => '参加路演'], ['field' => 'venues', 'value' => '财税,法律']],
|
||||
[['field' => 'finance', 'value' => '参加路演'], ['field' => 'venues', 'value' => '财税,法律,知识产权']],
|
||||
] as $invalid) {
|
||||
try { CourseFormSchema::normalize($this->fields(), $invalid); $this->fail('invalid answer accepted'); }
|
||||
catch (\InvalidArgumentException $error) { $this->assertNotSame('', $error->getMessage()); }
|
||||
}
|
||||
}
|
||||
|
||||
public function testQuestionnaireAndRegistrationChoiceSettingsShareDefaultsAndBounds(): void
|
||||
{
|
||||
$field = $this->fields()[1]; unset($field['config']);
|
||||
$this->assertSame(1, CourseFormSchema::validateDefinition($field)['config']['min_choices']);
|
||||
$this->assertSame(1, SurveySchema::question($field)['config']['min_choices']);
|
||||
foreach ([['min_choices' => 0], ['min_choices' => 4], ['min_choices' => 2, 'max_choices' => 1], ['min_choices' => 1.5]] as $config) {
|
||||
foreach ([CourseFormSchema::class, SurveySchema::class] as $schema) {
|
||||
try { $schema === CourseFormSchema::class ? $schema::validateDefinition($field + ['config' => $config]) : $schema::question($field + ['config' => $config]); $this->fail('invalid limits accepted'); }
|
||||
catch (\InvalidArgumentException $error) { $this->assertNotSame('', $error->getMessage()); }
|
||||
}
|
||||
}
|
||||
$field['config'] = ['min_choices' => 2, 'max_choices' => 0];
|
||||
$this->assertSame(0, CourseFormSchema::validateDefinition($field)['config']['max_choices']);
|
||||
}
|
||||
|
||||
public function testFieldSettingsCloneAndHistoricalStructureProtection(): void
|
||||
{
|
||||
$url = '/api/admin/course-forms/save';
|
||||
$field = $this->fields()[0]; unset($field['id']); $field['course_id'] = 10;
|
||||
$id = $this->postJson($url, $field)->assertOk()->json('id'); $this->assertNotNull($id);
|
||||
$child = $this->fields()[1]; unset($child['id']); $child['course_id'] = 10;
|
||||
$childId = $this->postJson($url, $child)->assertJsonPath('config.min_choices', 2)->json('id');
|
||||
$this->getJson('/api/admin/course-forms/clone?from_course_id=10&to_course_id=11')->assertOk();
|
||||
$this->assertSame(2, DB::table('course_forms')->where('course_id', 11)->count());
|
||||
$copy = \App\Models\CourseForm::where('course_id', 11)->where('field', 'venues')->first();
|
||||
$this->assertSame(['road', 'later'], $copy->config['visible_when']['values']);
|
||||
$this->assertNotSame($childId, $copy->id);
|
||||
DB::table('course_signs')->insert(['course_id' => 10, 'user_id' => 200]);
|
||||
$this->postJson($url, ['id' => $id, 'field' => 'changed'])->assertJsonPath('errcode', 10001);
|
||||
$this->getJson('/api/admin/course-forms/destroy?id='.$id)->assertJsonPath('errmsg', '已有报名,不能删除字段;请使用新课程或新表单');
|
||||
$this->getJson('/api/admin/course-forms/clone?from_course_id=11&to_course_id=10')->assertJsonPath('errmsg', '当前课程已有人员报名,不可克隆覆盖');
|
||||
$this->assertSame(2, DB::table('course_forms')->where('course_id', 10)->count());
|
||||
}
|
||||
|
||||
public function testExistingRegistrationsAllowAddingAndReorderingOptionsWithoutChangingHistory(): void
|
||||
{
|
||||
$url = '/api/admin/course-forms/save';
|
||||
$field = $this->fields()[0]; unset($field['id']); $field['course_id'] = 10;
|
||||
$id = $this->postJson($url, $field)->assertOk()->json('id');
|
||||
$child = $this->fields()[1]; unset($child['id']); $child['course_id'] = 10;
|
||||
$this->postJson($url, $child)->assertOk();
|
||||
$definitions = CourseFormSchema::definitions(10);
|
||||
$answers = CourseFormSchema::normalize($definitions, [['field' => 'finance', 'value' => '参加路演'], ['field' => 'venues', 'value' => '财税,法律', 'option_texts' => ['tax' => '原咨询']]]);
|
||||
$replyId = DB::table('course_signs')->insertGetId(['course_id' => 10, 'user_id' => 200, 'data' => json_encode($answers), 'form_snapshot' => json_encode(['version' => 2, 'fields' => $definitions])]);
|
||||
$before = DB::table('course_signs')->where('id', $replyId)->first();
|
||||
$newOptions = array_merge([['key' => 'other', 'value' => '其他']], $field['select_item']);
|
||||
$this->postJson($url, ['id' => $id, 'select_item' => $newOptions])->assertJsonPath('select_item.0.key', 'other');
|
||||
$reordered = array_reverse($newOptions);
|
||||
$this->postJson($url, ['id' => $id, 'select_item' => $reordered])->assertJsonPath('select_item.3.key', 'other');
|
||||
$after = DB::table('course_signs')->where('id', $replyId)->first();
|
||||
$this->assertSame($before->data, $after->data);
|
||||
$this->assertSame($before->form_snapshot, $after->form_snapshot);
|
||||
$this->assertSame('later', CourseFormSchema::definitions(10, CourseSign::find($replyId))[0]['select_item'][0]['key']);
|
||||
$newAnswer = CourseFormSchema::normalize(CourseFormSchema::definitions(10), [['field' => 'finance', 'selected_keys' => ['other']]]);
|
||||
$this->assertSame('其他', $newAnswer[0]['value']);
|
||||
$this->assertTrue($newAnswer[1]['hidden']);
|
||||
$deleted = array_values(array_filter($reordered, fn ($option) => $option['key'] !== 'road'));
|
||||
$this->postJson($url, ['id' => $id, 'select_item' => $deleted])->assertJsonPath('errmsg', '已有报名,不能删除或修改已有选项的键;可以新增选项或调整顺序');
|
||||
$renamed = $reordered; $renamed[0]['key'] = 'renamed';
|
||||
$this->postJson($url, ['id' => $id, 'select_item' => $renamed])->assertJsonPath('errcode', 10001);
|
||||
$this->assertSame('no', \App\Models\CourseForm::find($id)->select_item[0]['key']);
|
||||
}
|
||||
|
||||
public function testNewSubmissionCannotBypassMinAndExistingSnapshotEditsKeepTheirRules(): void
|
||||
{
|
||||
$fields = $this->fields();
|
||||
foreach ($fields as $field) {
|
||||
$field['course_id'] = 10;
|
||||
foreach (['config', 'select_item'] as $key) $field[$key] = json_encode($field[$key] ?? []);
|
||||
DB::table('course_forms')->insert($field);
|
||||
}
|
||||
$invalid = [['field' => 'finance', 'value' => '参加路演'], ['field' => 'venues', 'value' => '法律']];
|
||||
$this->postJson('/api/mobile/course/sign', ['course_id' => 10, 'data' => $invalid])->assertJsonPath('errcode', 10001);
|
||||
$this->assertSame(0, DB::table('course_signs')->count());
|
||||
$valid = [['field' => 'finance', 'value' => '参加路演'], ['field' => 'venues', 'value' => '财税,法律', 'option_texts' => ['tax' => '原咨询']]];
|
||||
$answers = CourseFormSchema::normalize($fields, $valid);
|
||||
$id = DB::table('course_signs')->insertGetId(['course_id' => 10, 'user_id' => 200, 'data' => json_encode($answers), 'form_snapshot' => json_encode(['version' => 2, 'fields' => $fields])]);
|
||||
$changed = $fields[1]['config']; $changed['min_choices'] = 3;
|
||||
DB::table('course_forms')->where('id', 2)->update(['config' => json_encode($changed)]);
|
||||
$valid[1]['option_texts']['tax'] = '修改后的咨询';
|
||||
$this->postJson('/api/mobile/course/update-sign', ['id' => $id, 'data' => $valid])->assertJsonPath('signModel.data.1.option_texts.tax', '修改后的咨询');
|
||||
$this->assertSame(2, CourseSign::find($id)->form_snapshot['fields'][1]['config']['min_choices']);
|
||||
$this->postJson('/api/mobile/course/update-sign', ['id' => $id, 'data' => $invalid])->assertJsonPath('errcode', 10001);
|
||||
$this->assertSame('财税,法律', CourseSign::find($id)->data[1]['value']);
|
||||
DB::table('course_signs')->where('id', $id)->update(['fee_status' => 1]);
|
||||
$this->postJson('/api/mobile/course/update-sign', ['id' => $id, 'data' => $valid])->assertJsonPath('errmsg', '已缴费或已上传凭证,不允许修改');
|
||||
}
|
||||
|
||||
public function testHistoricalDefinitionsAndExportsKeepMissingFieldsNotesAndIdentifiers(): void
|
||||
{
|
||||
$old = ['id' => 1, 'field' => 'legacy', 'name' => '历史问题', 'edit_input' => 'text', 'value' => '=1+1'];
|
||||
$sign = new CourseSign(); $sign->forceFill(['course_id' => 10, 'data' => [$old]]);
|
||||
$definitions = CourseFormSchema::definitions(10, $sign);
|
||||
$this->assertSame('历史问题', $definitions[0]['name']);
|
||||
$this->assertTrue($definitions[0]['readonly']);
|
||||
$restored = CourseFormSchema::normalize($definitions, [], [$old]);
|
||||
$this->assertSame('=1+1', $restored[0]['value']);
|
||||
$empty = new CourseSign(); $empty->forceFill(['course_id' => 10, 'data' => [], 'form_snapshot' => ['version' => 2, 'fields' => []]]);
|
||||
$this->assertSame([], CourseFormSchema::definitions(10, $empty));
|
||||
$legacyChoice = $old; $legacyChoice['edit_input'] = 'checkbox'; $legacyChoice['value'] = '旧选项甲,旧选项乙'; $legacyChoice['select_item'] = [];
|
||||
$sign->data = [$legacyChoice];
|
||||
$this->assertSame('旧选项甲,旧选项乙', CourseFormSchema::normalize(CourseFormSchema::definitions(10, $sign), [], [$legacyChoice])[0]['value']);
|
||||
$result = CourseFormSchema::normalize($this->fields(), [['field' => 'finance', 'value' => '参加路演'], ['field' => 'venues', 'value' => '财税,法律', 'option_texts' => ['tax' => '问题']]]);
|
||||
$export = new CourseRegistrationExport([['id' => 1, 'data' => [$old]], ['id' => 2, 'data' => $result]], [], $this->fields());
|
||||
$sheet = $export->array();
|
||||
$this->assertContains('历史问题', $sheet[0]); $this->assertContains('会场-补充说明', $sheet[0]);
|
||||
$this->assertSame('=1+1', $sheet[1][array_search('历史问题', $sheet[0])]);
|
||||
$this->assertSame('财税:问题', $sheet[2][array_search('会场-补充说明', $sheet[0])]);
|
||||
$this->assertSame("财税\n法律", $sheet[2][array_search('会场', $sheet[0])]);
|
||||
$this->assertSame('', $sheet[1][array_search('会场', $sheet[0])]);
|
||||
$binder = \PhpOffice\PhpSpreadsheet\Cell\Cell::getValueBinder();
|
||||
$path = tempnam(sys_get_temp_dir(), 'course-export-');
|
||||
try {
|
||||
\Maatwebsite\Excel\Facades\Excel::store($export, basename($path).'.xlsx', 'local');
|
||||
$file = storage_path('app/'.basename($path).'.xlsx');
|
||||
$book = \PhpOffice\PhpSpreadsheet\IOFactory::load($file);
|
||||
$cell = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(array_search('历史问题', $sheet[0]) + 1).'2';
|
||||
$this->assertSame('s', $book->getActiveSheet()->getCell($cell)->getDataType());
|
||||
$this->assertTrue($book->getActiveSheet()->getStyle('A2')->getAlignment()->getWrapText());
|
||||
$book->disconnectWorksheets();
|
||||
} finally { \PhpOffice\PhpSpreadsheet\Cell\Cell::setValueBinder($binder); if (isset($file) && is_file($file)) unlink($file); unlink($path); }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue