课程报名

master
lion 1 week ago
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)));
}
}
}];
}
}

@ -265,6 +265,7 @@ class CourseController extends BaseController
])->with(underlineToHump($all['show_relation'] ?? []))->find($all['id']);
if ($detail) {
$detail->setAppends(array_values(array_diff($detail->getAppends(), ['qrcode'])));
$detail->company_limit_duplicates = \App\Services\Course\CompanyRegistration::duplicates($detail->id);
}
return $this->success($detail);
}
@ -318,7 +319,7 @@ class CourseController extends BaseController
DB::beginTransaction();
try {
if (isset($all['id'])) {
$model = $this->model->find($all['id']);
$model = $this->model->whereKey($all['id'])->lockForUpdate()->first();
if (empty($model)) {
return $this->fail([ResponseCode::ERROR_BUSINESS, '数据不存在']);
}
@ -327,6 +328,8 @@ class CourseController extends BaseController
$all['admin_id'] = $this->getUserId();
$all['department_id'] = $this->getUser()->department_id;
}
if (array_key_exists('one_person_per_company', $all) && !in_array($all['one_person_per_company'], [0, 1, '0', '1', false, true], true)) throw new \InvalidArgumentException('每家公司限一人报名设置不正确');
unset($all['company_limit_duplicates']);
$model->fill($all);
$model->save();
DB::commit();
@ -352,6 +355,7 @@ class CourseController extends BaseController
$model = Course::find($model->id);
// 保存接口不强制生成小程序码,避免 Failed to cache access token 误报为保存失败
$model->setAppends(array_values(array_diff($model->getAppends(), ['qrcode'])));
$model->company_limit_duplicates = \App\Services\Course\CompanyRegistration::duplicates($model->id);
return $this->success($model);
}

@ -88,7 +88,32 @@ class CourseFormsController extends BaseController
*/
public function save()
{
return parent::save();
try {
$model = \Illuminate\Support\Facades\DB::transaction(function () {
$input = request()->all();
$model = !empty($input['id']) ? CourseForm::whereKey($input['id'])->lockForUpdate()->firstOrFail() : new CourseForm();
$courseId = $model->course_id ?? ($input['course_id'] ?? null);
if (!$courseId || !\App\Models\Course::whereKey($courseId)->exists()) throw new \InvalidArgumentException('课程不存在');
$data = array_intersect_key($input, array_flip(['name', 'field', 'edit_input', 'rule', 'sort', 'help', 'select_item', 'need_fill', 'belong_user', 'belong_user_table', 'config']));
$data = \App\Services\Course\CourseFormSchema::validateDefinition(array_replace($model->toArray(), $data, ['course_id' => $courseId]));
if ($model->exists && \App\Models\CourseSign::where('course_id', $courseId)->exists()) {
foreach (['field', 'edit_input', 'belong_user', 'belong_user_table'] as $key) if ((string) ($data[$key] ?? '') !== (string) ($model->$key ?? '')) throw new \InvalidArgumentException('已有报名,不能修改字段标识、类型或学员关联;请新建字段');
$oldKeys = array_map('strval', array_column($model->select_item ?: [], 'key'));
$newKeys = array_map('strval', array_column($data['select_item'], 'key'));
if (array_diff($oldKeys, $newKeys)) throw new \InvalidArgumentException('已有报名,不能删除或修改已有选项的键;可以新增选项或调整顺序');
}
$others = CourseForm::where('course_id', $courseId)->where('id', '!=', $model->id ?? 0)->get()->toArray();
if (in_array($data['field'], array_column($others, 'field'), true)) throw new \InvalidArgumentException('同一课程的字段标识不能重复');
$collection = array_merge($others, [$data + ['id' => $model->id ?? PHP_INT_MAX]]);
usort($collection, fn ($a, $b) => [($a['sort'] ?? 1), ($a['id'] ?? PHP_INT_MAX)] <=> [($b['sort'] ?? 1), ($b['id'] ?? PHP_INT_MAX)]);
\App\Services\Survey\SurveySchema::validateConditions(array_map([\App\Services\Course\CourseFormSchema::class, 'field'], $collection));
if (!$model->exists) { $data['admin_id'] = $this->getUserId(); $data['department_id'] = $this->getUser()->department_id; }
$model->fill(array_intersect_key($data, array_flip(['course_id', 'admin_id', 'department_id', 'name', 'field', 'edit_input', 'rule', 'sort', 'help', 'select_item', 'need_fill', 'belong_user', 'belong_user_table', 'config'])));
$model->save();
return $model;
});
return $this->success($model);
} catch (\InvalidArgumentException $error) { return $this->fail([ResponseCode::ERROR_PARAMETER, $error->getMessage()]); }
}
/**
@ -107,6 +132,11 @@ class CourseFormsController extends BaseController
*/
public function destroy()
{
$model = CourseForm::findOrFail(request('id'));
if (\App\Models\CourseSign::where('course_id', $model->course_id)->exists()) return $this->fail([ResponseCode::ERROR_BUSINESS, '已有报名,不能删除字段;请使用新课程或新表单']);
foreach (CourseForm::where('course_id', $model->course_id)->get() as $field) {
if (($field->config['visible_when']['field'] ?? null) === $model->field) return $this->fail([ResponseCode::ERROR_BUSINESS, '该字段被显示条件引用,请先调整条件']);
}
return parent::destroy();
}
@ -176,13 +206,17 @@ class CourseFormsController extends BaseController
if ($validator->fails()) {
return $this->fail([ResponseCode::ERROR_PARAMETER, implode(',', $validator->errors()->all())]);
}
CourseForm::where('course_id', $all['to_course_id'])->delete();
$fromForms = CourseForm::where('course_id', $all['from_course_id'])->get();
foreach ($fromForms as $fromForm) {
$array = $fromForm->toArray();
$array['course_id'] = $all['to_course_id'];
CourseForm::create($array);
}
if (!\App\Models\Course::whereKey($all['from_course_id'])->exists() || !\App\Models\Course::whereKey($all['to_course_id'])->exists() || !CourseForm::where('course_id', $all['from_course_id'])->exists()) return $this->fail([ResponseCode::ERROR_PARAMETER, '来源或目标课程不存在,或来源表单为空']);
if (\App\Models\CourseSign::where('course_id', $all['to_course_id'])->exists()) return $this->fail([ResponseCode::ERROR_BUSINESS, '当前课程已有人员报名,不可克隆覆盖']);
if ((string) $all['from_course_id'] === (string) $all['to_course_id']) return $this->fail([ResponseCode::ERROR_PARAMETER, '不能克隆到原课程']);
\Illuminate\Support\Facades\DB::transaction(function () use ($all) {
CourseForm::where('course_id', $all['to_course_id'])->delete();
foreach (CourseForm::where('course_id', $all['from_course_id'])->orderBy('sort')->orderBy('id')->get() as $fromForm) {
$copy = $fromForm->replicate();
$copy->course_id = $all['to_course_id'];
$copy->save();
}
});
return $this->success('克隆成功');
}

@ -209,8 +209,18 @@ class CourseSignController extends BaseController
})->orderBy($all['sort_name'] ?? 'id', $all['sort_type'] ?? 'desc')
->orderBy('created_at', 'desc');
if (isset($all['is_export']) && !empty($all['is_export'])) {
$list = $list->limit(5000)->get()->toArray();
return Excel::download(new CommonExport($list, $all['export_fields'] ?? ''), $all['file_name'] ?? '' . date('YmdHis') . '.xlsx');
$list = $list->get()->toArray();
$courseId = null;
foreach ($all['filter'] ?? [] as $filter) if (($filter['key'] ?? '') === 'course_id' && ($filter['op'] ?? '') === 'eq') $courseId = $filter['value'];
$forms = $courseId ? \App\Services\Course\CourseFormSchema::definitions($courseId) : [];
$export = new \App\Exports\CourseRegistrationExport($list, $all['export_fields'] ?? [], $forms);
$attachments = \App\Services\Course\CourseRegistrationArchive::attachments($list);
if ($attachments) {
$path = \App\Services\Course\CourseRegistrationArchive::build($export, $attachments);
$filename = pathinfo(basename($all['file_name'] ?? '报名信息'), PATHINFO_FILENAME).'.zip';
return response()->download($path, $filename)->deleteFileAfterSend(true);
}
return Excel::download($export, $all['file_name'] ?? date('YmdHis').'.xlsx');
} else {
// 输出
$list = $list->paginate($all['page_size'] ?? 20);
@ -301,15 +311,27 @@ class CourseSignController extends BaseController
return $this->fail([ResponseCode::ERROR_BUSINESS, '该手机号已存在']);
}
}
unset($all['form_snapshot']);
if (array_key_exists('data', $all)) {
$schema = \App\Services\Course\CourseFormSchema::class;
$fields = $schema::definitions($model->course_id ?? ($all['course_id'] ?? null), $model->exists ? $model : null);
$all['data'] = $schema::normalize($fields, $all['data'], $model->data ?: []);
$all['form_snapshot'] = $model->form_snapshot ?: ['version' => 2, 'fields' => $fields];
$all['change_data'] = $schema::changes($all['data'], User::find($model->user_id ?? ($all['user_id'] ?? null)));
$all['is_change'] = $all['change_data'] ? 1 : 0;
}
unset($all['company_key'], $all['company_name_snapshot'], $all['replaces_sign_id'], $all['replaced_by_sign_id'], $all['replaced_at']);
$original = $model->getOriginal();
$model->fill($all);
$model->fill(array_intersect_key($all, array_flip(\Illuminate\Support\Facades\Schema::getColumnListing($model->getTable()))));
$model->save();
// 是否强制更新用户信息
if ($update_user == 1 && $model->change_data) {
if ($update_user == 1 && (int) $model->status === 1 && $model->change_data) {
// 强制更新用户信息
$user = User::find($model->user_id);
foreach ($model->status == 1 && $model->change_data as $key => $value) {
foreach ($model->change_data as $change) foreach ((array) $change as $key => $value) {
if (!in_array($key, \App\Services\Course\CourseFormSchema::USER_FIELDS, true)) continue;
$user->$key = $value;
if ($key === 'username') $user->name = $value;
}
$user->save();
}
@ -556,7 +578,11 @@ class CourseSignController extends BaseController
$fee_status = $course->is_fee;
if ($item['status'] == 0)
$fee_status = 0;
$courseSign = CourseSign::updateOrCreate($signWhere, ['is_import' => 1, 'user_id' => $user->id, 'status' => $item['status'], 'fee_status' => $fee_status, 'course_id' => $course_id, 'data' => $signData]);
$attributes = ['is_import' => 1, 'user_id' => $user->id, 'status' => $item['status'], 'fee_status' => $fee_status, 'course_id' => $course_id, 'data' => $signData];
if ((int) $course->one_person_per_company) {
$courseSign = CourseSign::where($signWhere)->whereIn('status', [0, 1, 3, 6])->orderByDesc('id')->first() ?: new CourseSign();
$courseSign->fill($attributes)->save();
} else $courseSign = CourseSign::updateOrCreate($signWhere, $attributes);
// 加预约次数
if ($item['status'] == 1) {
CourseAppointmentTotal::add($courseSign->user_id, $courseSign->id);

@ -154,6 +154,7 @@ class CourseController extends CommonController
return response()->json(['message' => '课程不存在'], 404);
}
$this->attachPublicAssets($detail);
if ($detail && (int) $detail->one_person_per_company) $detail->my_user = CourseSign::where('course_id', $detail->id)->where('user_id', $this->getUserId())->whereIn('status', [0, 1, 3, 6])->count();
return $this->success((new PublicCourseResource($detail))->resolve());
}
@ -275,18 +276,17 @@ class CourseController extends CommonController
];
$validator = Validator::make($all, [
'course_id' => 'required',
'data' => 'required'
'data' => 'required',
'replacement_token' => 'nullable|string'
], $messages);
if ($validator->fails()) {
return $this->fail([ResponseCode::ERROR_PARAMETER, implode(',', $validator->errors()->all())]);
}
$sign = CourseSign::where('course_id', $all['course_id'])
->where('user_id', $this->getUserId())
->first();
if ($sign) {
return $this->fail([ResponseCode::ERROR_BUSINESS, '请勿重复报名']);
}
$course = Course::find($all['course_id']);
if (!$course) return $this->fail([ResponseCode::ERROR_BUSINESS, '课程不存在']);
$existing = CourseSign::where('course_id', $course->id)->where('user_id', $this->getUserId());
if ((int) $course->one_person_per_company) $existing->whereIn('status', [0, 1, 3, 6]);
if ($existing->exists()) return $this->fail([ResponseCode::ERROR_BUSINESS, '请勿重复报名']);
// 报名时间判断
if (!empty($course->sign_start_date)) {
if (time() < strtotime($course->sign_start_date)) {
@ -298,32 +298,13 @@ class CourseController extends CommonController
return $this->fail([ResponseCode::ERROR_BUSINESS, '报名已结束']);
}
}
// 检测必填字段
$requiredFields = CourseForm::where('course_id', $all['course_id'])
->where('rule', 'like', '%required%')
->get(['field', 'name']);
if ($requiredFields->count() > 0) {
// 将 data 数组转换为以 field 为 key 的关联数组
$dataArray = [];
if (isset($all['data']) && is_array($all['data'])) {
foreach ($all['data'] as $item) {
if (isset($item['field'])) {
$dataArray[$item['field']] = $item['value'] ?? null;
}
}
}
// 检查必填字段
$missingFields = [];
foreach ($requiredFields as $field) {
$fieldValue = $dataArray[$field->field] ?? null;
// 检查字段是否存在且值不为空(null、空字符串视为空,0是有效值)
if ($fieldValue === null || $fieldValue === '') {
$missingFields[] = $field->name;
}
}
if (!empty($missingFields)) {
return $this->fail([ResponseCode::ERROR_PARAMETER, '以下字段为必填项:' . implode('、', $missingFields)]);
}
try {
$fields = \App\Services\Course\CourseFormSchema::definitions($course->id);
$all['data'] = \App\Services\Course\CourseFormSchema::normalize($fields, $all['data']);
$all['change_data'] = \App\Services\Course\CourseFormSchema::changes($all['data'], $this->getUser());
$all['is_change'] = $all['change_data'] ? 1 : 0;
} catch (\InvalidArgumentException $error) {
return $this->fail([ResponseCode::ERROR_PARAMETER, $error->getMessage()]);
}
// 检测 company_name 字段是否包含特殊符号
if (isset($all['data']) && is_array($all['data'])) {
@ -336,27 +317,34 @@ class CourseController extends CommonController
}
}
}
$result = CourseSign::create([
'is_change' => $all['is_change'] ?? 0,
'course_id' => $all['course_id'],
'user_id' => $this->getUserId(),
'data' => $all['data'] ?? [],
'change_data' => $all['change_data'] ?? [],
'no' => getBatchNo(),
'money' => $course->price,
'title' => $course->name
]);
// 发短信
$smsSign = Config::getValueByKey('sms_sign');
if ($course->is_fee) {
$content = "{$smsSign}您已提交【{$course->name}】报名申请,请等待审核结果。";
} else {
$content = "{$smsSign}您已提交【{$course->name}】报名申请,请等待审核结果通知。";
try {
$submission = \App\Services\Course\CompanyRegistration::submit([
'is_change' => $all['is_change'] ?? 0,
'course_id' => $all['course_id'],
'user_id' => $this->getUserId(),
'data' => $all['data'] ?? [],
'form_snapshot' => ['version' => 2, 'fields' => $fields],
'change_data' => $all['change_data'] ?? [],
'no' => getBatchNo(),
'money' => $course->price,
'title' => $course->name
], $all['replacement_token'] ?? null);
if (!empty($submission['replacement_required'])) return $this->success($submission);
$result = $submission['sign'];
} catch (\InvalidArgumentException $error) {
return $this->fail([ResponseCode::ERROR_BUSINESS, $error->getMessage()]);
}
ymSms($this->getUser()->mobile, $content);
$this->notifySignSubmitted($course);
return $this->success($result);
}
protected function notifySignSubmitted(Course $course): void
{
$smsSign = Config::getValueByKey('sms_sign');
$content = "{$smsSign}您已提交【{$course->name}】报名申请,请等待审核结果通知。";
ymSms($this->getUser()->mobile, $content);
}
/**
* @OA\Get(
* path="/api/mobile/course/my-course",
@ -372,7 +360,7 @@ class CourseController extends CommonController
{
$list = Course::with('typeDetail', 'courseContentEvaluation')->with([
'courseSigns' => function ($query) {
$query->where('user_id', $this->getUserId());
$query->where('user_id', $this->getUserId())->orderByRaw('CASE WHEN status IN (0,1,3,6) THEN 0 ELSE 1 END')->orderByDesc('id');
}
])->whereHas('courseSigns', function ($query) {
$query->where('user_id', $this->getUserId());
@ -543,7 +531,8 @@ class CourseController extends CommonController
}
$detail = CourseSign::with('course.typeDetail', 'user')
->where('user_id', $this->getUserId())->where('course_id', $all['course_id'])
->first();
->orderByRaw('CASE WHEN status IN (0,1,3,6) THEN 0 ELSE 1 END')->orderByDesc('id')->first();
if ($detail) $detail->form_fields = \App\Services\Course\CourseFormSchema::definitions($detail->course_id, $detail);
return $this->success(compact('detail'));
}
@ -579,6 +568,8 @@ class CourseController extends CommonController
return $this->fail([ResponseCode::ERROR_BUSINESS, '报名记录不存在']);
}
if ($signModel->replaced_by_sign_id) return $this->fail([ResponseCode::ERROR_BUSINESS, '该报名已被更换,原报名信息只读,请重新报名']);
$isFeeUpload = isset($all['fee_file_ids']) && !empty($all['fee_file_ids']);
$isStatusUpdate = isset($all['status']) && in_array((int) $all['status'], [4, 5], true);
@ -600,6 +591,18 @@ class CourseController extends CommonController
}
}
if (array_key_exists('data', $all)) {
try {
$fields = \App\Services\Course\CourseFormSchema::definitions($signModel->course_id, $signModel);
$updates['data'] = \App\Services\Course\CourseFormSchema::normalize($fields, $all['data'], $signModel->data ?: []);
$updates['form_snapshot'] = $signModel->form_snapshot ?: ['version' => 2, 'fields' => $fields];
$updates['change_data'] = \App\Services\Course\CourseFormSchema::changes($updates['data'], $this->getUser());
$updates['is_change'] = $updates['change_data'] ? 1 : 0;
} catch (\InvalidArgumentException $error) {
return $this->fail([ResponseCode::ERROR_PARAMETER, $error->getMessage()]);
}
}
if ($isFeeUpload) {
$updates['fee_file_ids'] = $all['fee_file_ids'];
if (isset($all['fee_status']) && (int) $all['fee_status'] === 3) {
@ -620,7 +623,8 @@ class CourseController extends CommonController
$original = $signModel->getOriginal();
$signModel->fill($updates);
$signModel->save();
try { $signModel->save(); }
catch (\InvalidArgumentException $error) { return $this->fail([ResponseCode::ERROR_BUSINESS, $error->getMessage()]); }
if ($original['status'] == 1 && $signModel->status != 1) {
CourseAppointmentTotal::reduce($signModel->user_id, $signModel->id);
}

@ -27,6 +27,7 @@ class PublicCourseResource extends JsonResource
'content' => $this->content,
'publicize_content' => $this->publicize_content,
'type' => $this->type === null ? null : (int) $this->type,
'one_person_per_company' => (int) $this->one_person_per_company,
'is_fee' => $this->is_fee === null ? null : (int) $this->is_fee,
'total' => $this->total === null ? null : (int) $this->total,
'show_txl' => $this->show_txl === null ? null : (int) $this->show_txl,
@ -90,6 +91,8 @@ class PublicCourseResource extends JsonResource
'select_item' => $form->select_item,
'need_fill' => (bool) $form->need_fill,
'belong_user' => (bool) $form->belong_user,
'belong_user_table' => $form->belong_user_table,
'config' => $form->config ?: [],
];
})->values()->all();
}),

@ -40,9 +40,11 @@ class SendCourseCar implements ShouldQueue
*/
public function handle()
{
$carRepository = new CarRepository();
$course = Course::find($this->courseSignsModel->course_id);
$carRepository->courseAppointment($course, $this->courseSignsModel, $this->palte, $out);
\App\Services\Course\CompanyRegistration::grantAccess($this->courseSignsModel->id, function ($sign) {
$carRepository = new CarRepository();
$course = Course::find($sign->course_id);
$carRepository->courseAppointment($course, $sign, $this->palte, $out);
});
}
}

@ -38,11 +38,13 @@ class SendCourseDoor implements ShouldQueue
*/
public function handle()
{
$doors = Config::getValueByKey('course_sign_door');
$doors = json_decode($doors, true);
$doorRepository = new DoorRepository();
foreach ($doors as $door) {
$doorRepository->generateEmpAuthorSet1ByCourse($this->courseSignsModel, $door, $out);
}
\App\Services\Course\CompanyRegistration::grantAccess($this->courseSignsModel->id, function ($sign) {
$doors = Config::getValueByKey('course_sign_door');
$doors = json_decode($doors, true);
$doorRepository = new DoorRepository();
foreach ((array) $doors as $door) {
$doorRepository->generateEmpAuthorSet1ByCourse($sign, $door, $out);
}
});
}
}

@ -6,7 +6,7 @@ namespace App\Models;
class CourseForm extends SoftDeletesModel
{
protected $casts = ['select_item' => 'json'];
protected $casts = ['select_item' => 'json', 'config' => 'array'];
}

@ -12,9 +12,62 @@ class CourseSign extends SoftDeletesModel
// 统计接口缓存时间(秒)
const CACHE_TIME_STATISTICS = 600; // 10分钟
protected $casts = ['file_ids' => 'json', 'fee_file_ids' => 'json', 'data' => 'json', 'change_data' => 'json'];
protected $casts = ['file_ids' => 'json', 'fee_file_ids' => 'json', 'data' => 'json', 'change_data' => 'json', 'form_snapshot' => 'array'];
protected $appends = ['files', 'fee_files', 'status_text', 'fee_status_text'];
protected $appends = ['files', 'fee_files', 'status_text', 'fee_status_text', 'form_answers', 'replacement_text'];
/** Prefer the name submitted with this registration, including linked custom fields. */
public function participantName(): string
{
$fields = $this->form_snapshot['fields'] ?? [];
foreach ($this->data ?: [] as $answer) {
if (!is_array($answer) || !empty($answer['hidden'])) continue;
$definition = $answer;
foreach ($fields as $field) if (($field['field'] ?? null) === ($answer['field'] ?? null)) { $definition = $field; break; }
if (!in_array($definition['belong_user_table'] ?? ($answer['field'] ?? ''), ['name', 'username'], true)
&& !in_array($answer['field'] ?? '', ['name', 'username'], true)) continue;
if (is_scalar($answer['value'] ?? null) && trim((string) $answer['value']) !== '') return trim((string) $answer['value']);
}
$user = $this->relationLoaded('user') ? $this->getRelation('user') : User::withTrashed()->find($this->user_id);
return trim((string) ($user->name ?? '')) ?: (trim((string) ($user->username ?? '')) ?: '其他学员');
}
private function replacementParticipantName($signId): string
{
// Include deleted historical registrations without serializing recursive relationships.
$sign = static::withTrashed()->find($signId, ['id', 'user_id', 'data', 'form_snapshot']);
return $sign ? $sign->participantName() : '其他学员';
}
public function getGiveupReasonAttribute($value): string
{
if ($this->replaced_by_sign_id) return '因同公司更换参训人员,已由'.$this->replacementParticipantName($this->replaced_by_sign_id).'替换报名。';
return (string) $value;
}
public function getReplacementTextAttribute(): string
{
if ($this->replaced_by_sign_id) return '已由'.$this->replacementParticipantName($this->replaced_by_sign_id).'替换'.($this->replaced_at ? ';更换时间:'.$this->replaced_at : '');
if ($this->replaces_sign_id) return '替换'.$this->replacementParticipantName($this->replaces_sign_id).'的报名,需重新审核';
return '';
}
public function save(array $options = [])
{
// The shared model guard also covers admin saves, imports and batch audit.
if (!\Illuminate\Support\Facades\Schema::hasColumn('courses', 'one_person_per_company')) return parent::save($options);
return \Illuminate\Support\Facades\DB::transaction(function () use ($options) {
$course = Course::whereKey($this->course_id)->lockForUpdate()->first();
if ($this->exists && ($this->isDirty('course_id') || $this->isDirty('user_id'))) throw new \InvalidArgumentException('已有报名不可更换所属课程或报名人,请新增报名');
if ($course) \App\Services\Course\CompanyRegistration::guard($this, $course);
return parent::save($options);
});
}
public function getFormAnswersAttribute(): array
{
return \App\Services\Course\CourseFormSchema::display(is_array($this->data) ? $this->data : []);
}
public static $intToString = [
'status' => [

@ -0,0 +1,163 @@
<?php
namespace App\Services\Course;
use App\Models\Course;
use App\Models\CourseSign;
use App\Models\CourseAppointmentTotal;
use App\Models\ThirdAppointmentLog;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/** All enrollment writes take the course lock, including imports and reactivation. */
class CompanyRegistration
{
public const ACTIVE = [0, 1, 3];
public static function canonical($name): string
{
return mb_strtolower(preg_replace('/[\s\x{3000}]+/u', '', str_replace(['(', ')'], ['(', ')'], trim((string) $name))));
}
public static function companyName(array $data, array $fields = []): string
{
foreach ($data as $answer) {
if (!is_array($answer) || !empty($answer['hidden'])) continue;
$definition = $answer;
foreach ($fields as $field) if (($field['field'] ?? null) === ($answer['field'] ?? null)) { $definition = $field; break; }
if (($definition['belong_user_table'] ?? '') === 'company_name' || ($answer['field'] ?? '') === 'company_name') {
return is_scalar($answer['value'] ?? null) ? trim((string) $answer['value']) : '';
}
}
return '';
}
/** Submitted answers take priority; profile fallback is frozen once enrolled. */
public static function resolvedCompanyName(CourseSign $sign, array $fields = []): string
{
$name = self::companyName($sign->data ?: [], $fields);
if ($name !== '') return $name;
if ($sign->exists && trim((string) $sign->company_name_snapshot) !== '') return trim($sign->company_name_snapshot);
return trim((string) (User::find($sign->user_id)->company_name ?? ''));
}
public static function identity(CourseSign $sign): string
{
if ($sign->company_key) return $sign->company_key;
$name = self::resolvedCompanyName($sign, $sign->form_snapshot['fields'] ?? []);
return $name !== '' ? 'name:'.hash('sha256', self::canonical($name)) : '';
}
public static function occupants(CourseSign $sign)
{
$identity = self::identity($sign);
return CourseSign::where('course_id', $sign->course_id)->whereIn('status', self::ACTIVE)
->when($sign->exists, fn ($query) => $query->where('id', '!=', $sign->id))
->orderBy('id')->lockForUpdate()->get()->filter(fn ($other) => self::identity($other) === $identity)->values();
}
/** Called only under the course lock by CourseSign::save(). */
public static function guard(CourseSign $sign, Course $course): void
{
if (!(int) $course->one_person_per_company) return;
if ($sign->exists) {
$current = CourseSign::whereKey($sign->id)->lockForUpdate()->firstOrFail();
if ((int) $current->status !== (int) $sign->getRawOriginal('status')) throw new InvalidArgumentException('报名状态已变化,请刷新后重试');
if ($current->replaced_by_sign_id && ($sign->isDirty('data') || $sign->isDirty('fee_file_ids'))) throw new InvalidArgumentException('该报名已被更换,原报名信息只读,请重新报名');
if ($current->replaced_by_sign_id && in_array((int) $sign->status, self::ACTIVE, true)) throw new InvalidArgumentException('该报名已被更换,请重新提交报名,不可恢复原报名');
}
if (!in_array((int) ($sign->status ?? 0), self::ACTIVE, true)) return;
if (!$sign->exists || $sign->isDirty('data') || !$sign->company_key) {
$name = self::resolvedCompanyName($sign, $sign->form_snapshot['fields'] ?? CourseFormSchema::definitions($course->id));
if ($name === '') throw new InvalidArgumentException('请先完善个人信息中的公司名称,或在报名表中填写公司名称');
$sign->company_name_snapshot = $name;
$sign->company_key = 'name:'.hash('sha256', self::canonical($name));
}
if (CourseSign::where('course_id', $course->id)->where('user_id', $sign->user_id)->where('status', 6)->lockForUpdate()->first()) throw new InvalidArgumentException('您当前无法报名该课程,请联系管理员');
$self = CourseSign::where('course_id', $course->id)->where('user_id', $sign->user_id)->whereIn('status', self::ACTIVE)
->when($sign->exists, fn ($query) => $query->where('id', '!=', $sign->id))->lockForUpdate()->first();
if ($self) throw new InvalidArgumentException('请勿重复报名');
if (self::occupants($sign)->isNotEmpty()) throw new InvalidArgumentException('该公司已有有效报名,请先处理原报名后再操作');
}
private static function replacementError(CourseSign $old): void
{
if ((int) $old->fee_status === 1 || !empty($old->fee_file_ids)) throw new InvalidArgumentException('贵公司已有报名已缴费或提交缴费凭证,请联系管理员办理更换');
if (ThirdAppointmentLog::where('course_sign_id', $old->id)->where(function ($query) {
$query->where('plate_status', 1)->orWhere(function ($query) {
$query->where('finally', 1)->whereNotNull('door_name')->where(function ($query) { $query->whereNull('end_time')->orWhere('end_time', '>', now()); });
});
})->exists()) throw new InvalidArgumentException('贵公司已有报名已开通门禁或停车权限,请联系管理员办理更换');
}
private static function fingerprint(CourseSign $old, CourseSign $new): string
{
return hash('sha256', json_encode([$old->id, $old->getRawOriginal(), $new->course_id, $new->user_id, self::identity($new), $new->data], JSON_UNESCAPED_UNICODE));
}
/** Read-only on conflict until the exact current occupant has been confirmed. */
public static function submit(array $attributes, ?string $token = null): array
{
return DB::transaction(function () use ($attributes, $token) {
$course = Course::whereKey($attributes['course_id'])->lockForUpdate()->firstOrFail();
$new = new CourseSign($attributes);
$new->status = 0;
if (!(int) $course->one_person_per_company) {
if (CourseSign::where('course_id', $course->id)->where('user_id', $new->user_id)->exists()) throw new InvalidArgumentException('请勿重复报名');
$new->save(); return ['sign' => $new];
}
$fields = $attributes['form_snapshot']['fields'] ?? CourseFormSchema::definitions($course->id);
$name = self::resolvedCompanyName($new, $fields);
if ($name === '') throw new InvalidArgumentException('请先完善个人信息中的公司名称,或在报名表中填写公司名称');
$new->company_name_snapshot = $name;
$new->company_key = 'name:'.hash('sha256', self::canonical($name));
if (CourseSign::where('course_id', $course->id)->where('user_id', $new->user_id)->whereIn('status', [0, 1, 3, 6])->lockForUpdate()->first()) throw new InvalidArgumentException('您已报名该课程,请查看原报名记录');
$occupants = self::occupants($new);
if ($occupants->count() > 1) throw new InvalidArgumentException('贵公司存在多条历史有效报名,请联系管理员处理后再报名');
$old = $occupants->first();
if (!$old) { $new->save(); return ['sign' => $new]; }
self::replacementError($old);
$fingerprint = self::fingerprint($old, $new);
$confirmed = false;
if ($token) try {
$payload = json_decode(Crypt::decryptString($token), true);
$confirmed = ($payload['expires'] ?? 0) >= time() && hash_equals($fingerprint, (string) ($payload['fingerprint'] ?? ''));
} catch (\Throwable $e) { /* Return a fresh confirmation; never replace a changed occupant. */ }
$oldName = $old->participantName();
if (!$confirmed) return ['replacement_required' => true, 'replacement_name' => $oldName,
'replacement_token' => Crypt::encryptString(json_encode(['fingerprint' => $fingerprint, 'expires' => time() + 600])),
'message' => '贵公司'.$oldName.'已提交报名,请确认是否更换为您报名参训。确认后,'.$oldName.'的报名将调整为“主动放弃”,您的报名需重新审核。'];
$newName = $new->participantName();
$old->status = 5;
$old->giveup_reason = '因同公司更换参训人员,已由'.$newName.'替换报名。';
$old->replaced_at = now();
$old->save();
$new->replaces_sign_id = $old->id;
$new->save();
$old->replaced_by_sign_id = $new->id;
$old->save();
CourseAppointmentTotal::reduce($old->user_id, $old->id);
return ['sign' => $new];
});
}
/** Serialize queued grants with replacement so a delayed job cannot restore old access. */
public static function grantAccess(int $signId, callable $grant): void
{
DB::transaction(function () use ($signId, $grant) {
$sign = CourseSign::find($signId);
if (!$sign) return;
Course::whereKey($sign->course_id)->lockForUpdate()->first();
$sign = CourseSign::find($signId);
if ($sign && (int) $sign->status === 1) $grant($sign);
});
}
public static function duplicates(int $courseId): array
{
return CourseSign::where('course_id', $courseId)->whereIn('status', self::ACTIVE)->get()->groupBy(fn ($sign) => self::identity($sign))
->filter(fn ($signs, $key) => $key !== '' && $signs->count() > 1)->map(fn ($signs) => ['company' => self::resolvedCompanyName($signs->first(), $signs->first()->form_snapshot['fields'] ?? []), 'ids' => $signs->pluck('id')->all()])->values()->all();
}
}

@ -0,0 +1,197 @@
<?php
namespace App\Services\Course;
use App\Models\CourseForm;
use App\Models\CourseSign;
use App\Services\Survey\SurveySchema as Rules;
use InvalidArgumentException;
/** Adapts legacy label/comma answers to shared rules without rewriting stored data. */
class CourseFormSchema
{
public const USER_FIELDS = ['username', 'name', 'sex', 'birthday', 'mobile', 'idcard', 'from', 'talent_tags', 'education', 'introduce', 'company_name', 'company_introduce', 'company_date', 'company_position', 'company_has_share', 'type', 'company_type', 'company_need_fund', 'company_fund', 'company_area', 'company_industry', 'company_address', 'company_product', 'school', 'speciality', 'overseas_experience', 'sign_from', 'email', 'sales_volume', 'valuation', 'market_value', 'is_yuanhe'];
public static function definitions($courseId, ?CourseSign $sign = null): array
{
$snapshot = $sign ? $sign->form_snapshot : null;
if (is_array($snapshot) && array_key_exists('fields', $snapshot) && is_array($snapshot['fields'])) return $snapshot['fields'];
$current = CourseForm::where('course_id', $courseId)->orderBy('sort')->orderBy('id')->get()->toArray();
if (!$sign) return $current;
if (!$sign->data) return [];
// A legacy registration already stores its labels/options. Do not retroactively
// apply newly introduced conditions or limits when that registration is edited.
$legacy = [];
foreach ($sign->data as $old) {
if (!is_array($old) || !isset($old['field'])) continue;
$matched = null;
foreach ($current as $field) if ($field['field'] === $old['field']) { $matched = $field; break; }
$definition = $matched ?: ['belong_user' => false, 'belong_user_table' => null];
foreach (['id', 'name', 'field', 'edit_input', 'rule', 'sort', 'help', 'select_item'] as $key) if (array_key_exists($key, $old)) $definition[$key] = $old[$key];
$definition['config'] = [];
$definition['legacy'] = true;
if (!$matched) { $definition['readonly'] = true; $definition['rule'] = ''; }
$legacy[] = $definition;
}
return $legacy ?: $current;
}
public static function field(array $field): array
{
$field['config'] = Rules::arrayValue($field['config'] ?? []);
$field['select_item'] = Rules::arrayValue($field['select_item'] ?? []);
$field['need_fill'] = in_array('required', explode(',', $field['rule'] ?? ''), true);
return $field;
}
public static function validateDefinition(array $field): array
{
$originalNeedFill = $field['need_fill'] ?? 0;
$field = self::field($field);
if (!is_string($field['field'] ?? null) || trim($field['field']) === '') throw new InvalidArgumentException('请填写字段标识');
$linked = $field['belong_user_table'] ?? '';
if ($linked && !in_array($linked, self::USER_FIELDS, true)) throw new InvalidArgumentException('请选择有效的学员字段');
$candidate = $field;
$candidate['field'] = 'legacy_field'; // Old Chinese/custom identifiers remain valid and unchanged.
$candidate['edit_input'] = in_array($field['edit_input'], ['radio', 'checkbox', 'text', 'textarea', 'number', 'date', 'datetime'], true) ? $field['edit_input'] : 'text';
$candidate['config']['user_field'] = '';
$validated = Rules::question($candidate);
unset($validated['config']['user_field']);
$field['config'] = $validated['config'];
$field['select_item'] = $validated['select_item'];
$field['need_fill'] = $originalNeedFill;
return $field;
}
public static function selected(array $field, array $answer): array
{
if (isset($answer['selected_keys']) && (!is_array($answer['selected_keys']) || count(array_filter($answer['selected_keys'], fn ($key) => !is_scalar($key))))) throw new InvalidArgumentException('选项标识格式不正确');
if (isset($answer['selected_keys']) && is_array($answer['selected_keys'])) return array_values(array_unique(array_map('strval', $answer['selected_keys'])));
$value = $answer['value'] ?? '';
if (Rules::blank($value)) return [];
$values = is_array($value) ? $value : (($field['edit_input'] ?? '') === 'checkbox' ? explode(',', (string) $value) : [$value]);
$keys = [];
foreach ($values as $value) {
$found = false;
foreach ($field['select_item'] ?? [] as $option) {
if (is_scalar($value) && (string) $option['value'] === (string) $value) { $keys[] = (string) $option['key']; $found = true; break; }
}
if (!$found) throw new InvalidArgumentException(($field['name'] ?? '').':选项已变更,请重新加载报名表单');
}
return array_values(array_unique($keys));
}
public static function normalize(array $fields, $data, array $previous = []): array
{
if (!is_array($data)) throw new InvalidArgumentException('报名信息格式不正确');
$raw = [];
foreach ($data as $answer) {
if (!is_array($answer) || !isset($answer['field'])) throw new InvalidArgumentException('报名字段格式不正确');
if (isset($raw[$answer['field']])) throw new InvalidArgumentException('报名字段重复');
$raw[$answer['field']] = $answer;
}
$definitions = $input = $invalid = [];
$previousByField = [];
foreach ($previous as $answer) if (isset($answer['field'])) $previousByField[$answer['field']] = $answer;
foreach ($fields as $index => $field) {
$field = self::field($field);
$field['id'] = $field['id'] ?? ($index + 1);
$answer = $raw[$field['field']] ?? [];
foreach (['option_texts', 'other_text', 'fileList', 'fileListArr'] as $extra) if (!array_key_exists($extra, $answer) && isset($previousByField[$field['field']][$extra])) $answer[$extra] = $previousByField[$field['field']][$extra];
if (!empty($field['readonly'])) { foreach ($previous as $old) if (($old['field'] ?? null) === $field['field']) $answer = $old; $raw[$field['field']] = $answer; }
$adapter = $field;
if (!empty($field['readonly'])) { $adapter['edit_input'] = 'text'; $adapter['rule'] = ''; $adapter['need_fill'] = false; $adapter['config'] = []; }
if (!in_array($adapter['edit_input'], ['text', 'textarea', 'radio', 'checkbox', 'number', 'date', 'datetime'], true)) $adapter['edit_input'] = 'text';
$definitions[] = $adapter;
$value = $answer['value'] ?? null;
if (!empty($field['legacy']) && in_array($adapter['edit_input'], ['date', 'datetime'], true) && isset($previousByField[$field['field']]) && ($previousByField[$field['field']]['value'] ?? null) === $value) $adapter['edit_input'] = 'text';
$definitions[count($definitions) - 1] = $adapter;
if (!empty($field['readonly'])) $value = null;
elseif (in_array($field['edit_input'], ['radio', 'checkbox'], true)) {
try { $keys = self::selected($field, $answer); }
catch (InvalidArgumentException $error) { $invalid[$index] = $error; $keys = []; }
$value = $field['edit_input'] === 'checkbox' ? $keys : ($keys[0] ?? null);
}
$input[] = ['id' => $field['id'], 'value' => $value, 'option_texts' => $answer['option_texts'] ?? [], 'other_text' => $answer['other_text'] ?? ''];
}
// Hidden fields must not fail validation because of stale/unrecognised options.
$map = Rules::answerMap($input);
foreach ($definitions as $index => $field) {
if (!Rules::visible($field, $definitions, $map)) $input[$index]['value'] = null;
elseif (isset($invalid[$index])) throw $invalid[$index];
}
$answers = Rules::normalize($definitions, $input, true);
foreach ($answers as $index => &$answer) {
$field = self::field($fields[$index]);
if (!empty($field['readonly']) && isset($previousByField[$field['field']])) {
$answer = $previousByField[$field['field']];
$answer['readonly'] = true;
$answer['belong_user'] = false;
continue;
}
$answer['edit_input'] = $field['edit_input'];
$answer['answer_version'] = 2;
if (in_array($field['edit_input'], ['radio', 'checkbox'], true)) {
$answer['selected_keys'] = $answer['hidden'] ? [] : array_map('strval', (array) $answer['value']);
$labels = [];
foreach ($field['select_item'] as $option) if (in_array((string) $option['key'], $answer['selected_keys'], true)) $labels[] = $option['value'];
$answer['value'] = $answer['hidden'] ? null : implode(',', $labels); // Original consumers still receive readable scalar values.
}
if ($field['edit_input'] === 'files') {
$source = $raw[$field['field']] ?? [];
$answer['fileList'] = $answer['hidden'] ? [] : Rules::arrayValue($source['fileList'] ?? $source['fileListArr'] ?? []);
$answer['fileListArr'] = $answer['fileList'];
}
}
unset($answer);
// Retired legacy fields remain in the historical record, never silently discarded.
$known = array_column($fields, 'field');
foreach ($previous as $old) if (is_array($old) && isset($old['field']) && !in_array($old['field'], $known, true)) $answers[] = $old;
return $answers;
}
public static function changes(array $data, $user): array
{
$changes = [];
foreach ($data as $answer) {
if (!empty($answer['hidden']) || empty($answer['belong_user'])) continue;
$field = $answer['belong_user_table'] ?? $answer['field'];
if (!in_array($field, self::USER_FIELDS, true) || Rules::blank($answer['value'] ?? null)) continue;
$current = $user->$field ?? null;
if (is_array($current)) $current = implode(',', $current);
if ((string) $current !== (string) $answer['value']) $changes[] = [$field => $answer['value']];
}
return $changes;
}
public static function display(array $data): array
{
return array_map(function ($field) {
$field = self::field($field);
if (!empty($field['hidden'])) { $field['display_value'] = ''; return $field; }
$value = $field['value'] ?? '';
if (isset($field['selected_keys']) && in_array($field['edit_input'] ?? '', ['radio', 'checkbox'], true)) {
$value = Rules::display($field, ($field['edit_input'] === 'radio' ? ($field['selected_keys'][0] ?? null) : $field['selected_keys']), true);
if ($value === '未填写') $value = '';
} elseif (is_array($value)) $value = implode('、', array_filter($value, 'is_scalar'));
if (($field['edit_input'] ?? '') === 'files') {
$files = $field['fileList'] ?? [];
$value = $files ? implode('、', array_map(function ($file) {
$file = $file['response'] ?? $file;
return $file['original_name'] ?? $file['name'] ?? basename($file['url'] ?? '');
}, $files)) : implode('、', array_map('basename', array_filter(explode(',', (string) $value))));
}
$field['display_value'] = (string) $value;
$notes = [];
foreach ($field['select_item'] as $option) {
$key = (string) $option['key'];
if (isset($field['selected_keys']) && !in_array($key, array_map('strval', $field['selected_keys']), true)) continue;
$text = $field['option_texts'][$key] ?? '';
if (is_scalar($text) && trim((string) $text) !== '') $notes[] = $option['value'].':'.$text;
}
if (!empty($field['other_text']) && is_scalar($field['other_text'])) $notes[] = '补充:'.$field['other_text'];
$field['supplement_text'] = implode("\n", $notes);
return $field;
}, $data);
}
}

@ -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;
}
}
}

@ -136,9 +136,14 @@ class SurveySchema
if (!self::blank($config['other_key'] ?? null) && !in_array((string) $config['other_key'], $keys, true)) {
throw new InvalidArgumentException('其他选项必须属于本题选项');
}
if ($field['edit_input'] === 'checkbox' && isset($config['max_choices'])) {
$max = $config['max_choices'];
if ($field['edit_input'] === 'checkbox') {
$min = $config['min_choices'] ?? 1;
$max = $config['max_choices'] ?? 0;
if (filter_var($min, FILTER_VALIDATE_INT) === false || $min < 1 || $min > count($options)) throw new InvalidArgumentException('最少选择项数须为1至选项数量之间的整数');
if (filter_var($max, FILTER_VALIDATE_INT) === false || $max < 0 || $max > count($options)) throw new InvalidArgumentException('最多选择项数须为0至选项数量之间的整数');
if ($max > 0 && $max < $min) throw new InvalidArgumentException('最多选择项数不能小于最少选择项数');
$field['config']['min_choices'] = (int) $min;
$field['config']['max_choices'] = (int) $max;
}
$notes = $config['option_notes'] ?? [];
if (!is_array($notes)) throw new InvalidArgumentException('选项附加输入框配置不正确');
@ -338,6 +343,7 @@ class SurveySchema
$keys[] = $match;
}
$keys = array_values(array_unique($keys));
if ($type === 'checkbox' && count($keys) < (int) ($config['min_choices'] ?? 1)) throw new InvalidArgumentException($label.':至少选择'.($config['min_choices'] ?? 1).'项');
if ($type === 'checkbox' && !empty($config['max_choices']) && count($keys) > (int) $config['max_choices']) throw new InvalidArgumentException($label.':最多选择'.$config['max_choices'].'项');
return $type === 'checkbox' ? $keys : $keys[0];
}

@ -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

@ -0,0 +1,306 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\Mobile\CourseController;
use App\Models\CourseSign;
use App\Models\User;
use App\Models\Admin;
use App\Services\Course\CompanyRegistration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class CompanyRegistrationTest 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();
Schema::create('courses', function (Blueprint $t) {
$t->id(); $t->string('name')->default('课程'); $t->integer('status')->default(1); $t->integer('is_fee')->default(0); $t->decimal('price')->default(0);
$t->date('sign_start_date')->nullable(); $t->date('sign_end_date')->nullable(); $t->softDeletes(); $t->timestamps();
});
Schema::create('users', function (Blueprint $t) { $t->id(); $t->string('name'); $t->string('company_name'); $t->string('mobile'); $t->softDeletes(); $t->timestamps(); });
Schema::create('course_signs', function (Blueprint $t) {
$t->id(); $t->integer('course_id'); $t->integer('user_id'); $t->integer('status')->default(0); $t->integer('fee_status')->default(0);
$t->json('data')->nullable(); $t->json('change_data')->nullable(); $t->integer('is_change')->default(0); $t->json('fee_file_ids')->nullable();
$t->string('no')->nullable(); $t->decimal('money')->default(0); $t->string('title')->nullable(); $t->text('giveup_reason')->nullable();
$t->softDeletes(); $t->timestamps();
});
(require database_path('migrations/2024_07_29_100821_create_course_forms_table.php'))->up();
(require database_path('migrations/2026_10_30_000001_extend_course_forms_and_sign_snapshots.php'))->up();
(require database_path('migrations/2026_10_30_000002_add_course_company_registration_limit.php'))->up();
Schema::create('third_appointment_logs', function (Blueprint $t) { $t->id(); $t->integer('course_sign_id'); $t->integer('plate_status')->default(0); $t->integer('finally')->default(0); $t->string('door_name')->nullable(); $t->timestamp('end_time')->nullable(); $t->softDeletes(); });
Schema::create('course_appointment_totals', function (Blueprint $t) { $t->id(); $t->integer('user_id'); $t->integer('course_sign_id'); $t->softDeletes(); $t->timestamps(); });
DB::table('courses')->insert([['id' => 10, 'one_person_per_company' => 1], ['id' => 11, 'one_person_per_company' => 0]]);
foreach ([1 => '张三', 2 => '李四', 3 => '王五'] as $id => $name) DB::table('users')->insert(['id' => $id, 'name' => $name, 'company_name' => 'A公司', 'mobile' => '1380000000'.$id]);
foreach ([10,11] as $id) DB::table('course_forms')->insert($this->field() + ['course_id' => $id]);
Auth::guard('mobile')->setUser(User::find(2));
$admin = new Admin(); $admin->forceFill(['id' => 1, 'department_id' => 1]); Auth::guard('admin')->setUser($admin);
// No real SMS during integration tests; count only committed submissions.
$controller = \Mockery::mock(CourseController::class)->makePartial()->shouldAllowMockingProtectedMethods();
$controller->shouldReceive('notifySignSubmitted')->zeroOrMoreTimes()->andReturnNull();
$this->app->instance(CourseController::class, $controller);
}
protected function tearDown(): void
{
Auth::guard('mobile')->forgetUser(); Auth::guard('admin')->forgetUser(); DB::disconnect('sqlite'); parent::tearDown();
}
private function field(): array { return ['field' => 'company_name', 'name' => '公司', 'edit_input' => 'text', 'rule' => 'required', 'belong_user' => 1, 'belong_user_table' => 'company_name']; }
private function attributes(int $user = 2, int $course = 10, string $company = 'A公司'): array
{
return ['course_id' => $course, 'user_id' => $user, 'status' => 0, 'data' => [$this->field() + ['value' => $company]], 'form_snapshot' => ['version' => 2, 'fields' => [$this->field()]]];
}
private function old(int $status = 0): CourseSign { $sign = new CourseSign($this->attributes(1)); $sign->status = $status; $sign->save(); return $sign; }
public function testFullHttpConfirmationCancelThenReplaceAndNoDuplicateSubmission(): void
{
$old = $this->old(1);
DB::table('course_appointment_totals')->insert(['user_id' => 1, 'course_sign_id' => $old->id]);
DB::table('course_appointment_totals')->insert(['user_id' => 1, 'course_sign_id' => 999]);
$data = ['course_id' => 10, 'data' => [['field' => 'company_name', 'value' => 'A公司']]];
$url = '/api/mobile/course/sign';
$prompt = $this->postJson($url, $data)->assertOk()->assertJsonPath('replacement_required', true)->assertJsonPath('replacement_name', '张三')->json();
// Cancelling means no confirmation request. The first request is entirely read-only.
$this->assertSame(1, CourseSign::count()); $this->assertSame(1, (int) $old->fresh()->status);
$new = $this->postJson($url, $data + ['replacement_token' => $prompt['replacement_token']])->assertOk()->assertJsonPath('status', 0)->json();
$this->assertSame($old->id, $new['replaces_sign_id']);
$this->assertSame(5, (int) $old->fresh()->status);
$this->assertSame($new['id'], (int) $old->fresh()->replaced_by_sign_id);
$this->assertStringContainsString('李四', $old->fresh()->giveup_reason);
$this->assertSame($this->attributes(1)['data'], $old->fresh()->data);
$this->assertNotNull(DB::table('course_appointment_totals')->where('course_sign_id', $old->id)->value('deleted_at'));
$this->assertNull(DB::table('course_appointment_totals')->where('course_sign_id', 999)->value('deleted_at'));
$this->postJson($url, $data + ['replacement_token' => $prompt['replacement_token']])->assertJsonPath('errmsg', '请勿重复报名');
$this->assertSame(2, CourseSign::count());
Auth::guard('mobile')->setUser(User::find(1));
$this->postJson('/api/mobile/course/update-sign', ['id' => $old->id, 'data' => [['field' => 'company_name', 'value' => 'B公司']]])
->assertJsonPath('errmsg', '该报名已被更换,原报名信息只读,请重新报名');
}
public function testChangedOccupantRequiresFreshConfirmationAndTokenCannotTransferUsers(): void
{
$old = $this->old(); $prompt = CompanyRegistration::submit($this->attributes());
$old->status = 3; $old->save();
$newPrompt = CompanyRegistration::submit($this->attributes(), $prompt['replacement_token']);
$this->assertTrue($newPrompt['replacement_required']);
$this->assertSame(3, (int) $old->fresh()->status);
$otherUser = CompanyRegistration::submit($this->attributes(3), $newPrompt['replacement_token']);
$this->assertTrue($otherUser['replacement_required']);
$otherData = CompanyRegistration::submit($this->attributes() + ['extra' => 'unused'], 'tampered-token');
$this->assertTrue($otherData['replacement_required']);
$this->assertSame(1, CourseSign::count());
}
public function testPaidProofAndExternalAccessBlockReplacement(): void
{
$old = $this->old();
foreach ([['fee_status' => 1, 'fee_file_ids' => null], ['fee_status' => 0, 'fee_file_ids' => [9]]] as $update) {
$old->fill($update)->save();
try { CompanyRegistration::submit($this->attributes()); $this->fail('paid replacement accepted'); }
catch (\InvalidArgumentException $e) { $this->assertStringContainsString('缴费', $e->getMessage()); }
}
$old->fee_file_ids = null; $old->save();
DB::table('third_appointment_logs')->insert(['course_sign_id' => $old->id, 'finally' => 1, 'door_name' => '门1', 'end_time' => now()->addDay()]);
try { CompanyRegistration::submit($this->attributes()); $this->fail('permission replacement accepted'); }
catch (\InvalidArgumentException $e) { $this->assertStringContainsString('门禁或停车', $e->getMessage()); }
$this->assertSame(0, (int) $old->fresh()->status);
}
public function testReplacedUserCanReapplyAsNewRecordAndDelayedGrantsAreSkipped(): void
{
$old = $this->old(1); $prompt = CompanyRegistration::submit($this->attributes());
$new = CompanyRegistration::submit($this->attributes(), $prompt['replacement_token'])['sign'];
$called = false; CompanyRegistration::grantAccess($old->id, function () use (&$called) { $called = true; });
$this->assertFalse($called);
$returnPrompt = CompanyRegistration::submit($this->attributes(1));
$reapply = CompanyRegistration::submit($this->attributes(1), $returnPrompt['replacement_token'])['sign'];
$this->assertNotSame($old->id, $reapply->id);
$this->assertSame(5, (int) $new->fresh()->status);
$this->assertSame(3, CourseSign::count());
$old->refresh(); $old->status = 1;
$this->expectException(\InvalidArgumentException::class); $old->save();
}
public function testSharedGuardRejectsAdminCreationReactivationAndCompanyChange(): void
{
$this->old();
$this->postJson('/api/admin/course-signs/batch-update-status', ['course_id' => 10, 'ids' => '', 'status' => 1])->assertJsonStructure(['errcode']);
foreach (['create', 'reactivate', 'change'] as $kind) {
$attributes = $this->attributes(2, 10, $kind === 'change' ? 'B公司' : 'A公司');
$candidate = new CourseSign($attributes);
if ($kind === 'reactivate') $candidate->status = 5;
if ($kind !== 'create') $candidate->save();
if ($kind === 'reactivate') $candidate->status = 1;
if ($kind === 'change') $candidate->data = $this->attributes()['data'];
try { $candidate->save(); $this->fail('company conflict accepted'); }
catch (\InvalidArgumentException $e) { $this->assertStringContainsString('有效报名', $e->getMessage()); }
}
}
public function testUnrestrictedOtherCompanyReleasedStatusesAndCompanySnapshotCompatibility(): void
{
$old = $this->old();
$unrestricted = CompanyRegistration::submit($this->attributes(1, 11))['sign'];
$this->assertSame(11, (int) $unrestricted->course_id);
$this->assertArrayHasKey('sign', CompanyRegistration::submit($this->attributes(2, 11)));
$this->assertArrayHasKey('sign', CompanyRegistration::submit($this->attributes(3, 10, 'B公司')));
DB::table('users')->where('id', 1)->update(['company_name' => 'C公司']);
$this->assertTrue(CompanyRegistration::submit($this->attributes())['replacement_required']);
$old->status = 2; $old->save();
$this->assertArrayHasKey('sign', CompanyRegistration::submit($this->attributes()));
}
public function testFailedNewRegistrationRollsBackWithdrawalAndExpiredTokenNeedsConfirmation(): void
{
$old = $this->old(); $prompt = CompanyRegistration::submit($this->attributes());
$payload = json_decode(\Illuminate\Support\Facades\Crypt::decryptString($prompt['replacement_token']), true);
$payload['expires'] = time() - 1;
$expired = \Illuminate\Support\Facades\Crypt::encryptString(json_encode($payload));
$this->assertTrue(CompanyRegistration::submit($this->attributes(), $expired)['replacement_required']);
$failInsert = true;
CourseSign::saving(function ($sign) use (&$failInsert) { if ($failInsert && !$sign->exists && $sign->replaces_sign_id) throw new \RuntimeException('test insert failure'); });
try { CompanyRegistration::submit($this->attributes(), $prompt['replacement_token']); $this->fail('insert failure was ignored'); }
catch (\RuntimeException $e) { $this->assertSame('test insert failure', $e->getMessage()); }
finally { $failInsert = false; }
$this->assertSame(0, (int) $old->fresh()->status);
$this->assertNull($old->fresh()->replaced_at);
$this->assertSame(1, CourseSign::count());
}
public function testAdminApiCannotBypassLimitAndCompanyFieldCanBeOptional(): void
{
$this->old();
$response = $this->postJson('/api/admin/course-signs/save', $this->attributes());
$this->assertStringContainsString('有效报名', $response->json('errmsg'));
$this->assertSame(1, CourseSign::count());
$inactive = new CourseSign($this->attributes()); $inactive->status = 5; $inactive->save();
$response = $this->postJson('/api/admin/course-signs/batch-update-status', ['course_id' => 10, 'ids' => (string) $inactive->id, 'status' => 1]);
$this->assertStringContainsString('有效报名', $response->json('errmsg'));
$this->assertSame(5, (int) $inactive->fresh()->status);
$fieldId = DB::table('course_forms')->where('course_id', 10)->value('id');
$this->postJson('/api/admin/course-forms/save', ['id' => $fieldId, 'rule' => ''])->assertOk()->assertJsonPath('rule', '');
}
public function testLegacyCompanyAndBlacklistedUserAreChecked(): void
{
// Historical rows have no company snapshot; their saved answers still occupy the company slot.
DB::table('course_signs')->insert(['course_id' => 10, 'user_id' => 1, 'status' => 0, 'data' => json_encode([['field' => 'company_name', 'value' => ' A 公司 ']])]);
$this->assertTrue(CompanyRegistration::submit($this->attributes())['replacement_required']);
DB::table('course_signs')->insert(['course_id' => 10, 'user_id' => 2, 'status' => 6]);
$this->expectException(\InvalidArgumentException::class);
CompanyRegistration::submit($this->attributes());
}
private function useNameOnlyForm(): array
{
DB::table('course_forms')->where('course_id', 10)->delete();
$field = ['field' => 'name', 'name' => '姓名', 'edit_input' => 'text', 'rule' => 'required'];
DB::table('course_forms')->insert($field + ['course_id' => 10]);
return $field;
}
public function testNoCompanyFieldUsesProfileForConfirmationReplacementAndFrozenSnapshot(): void
{
$field = $this->useNameOnlyForm();
$old = CourseSign::create(['course_id' => 10, 'user_id' => 1, 'status' => 0, 'data' => [$field + ['value' => '张三']], 'form_snapshot' => ['fields' => [$field]]]);
$this->assertSame('A公司', $old->company_name_snapshot);
$payload = ['course_id' => 10, 'data' => [['field' => 'name', 'value' => '李四']]];
$url = '/api/mobile/course/sign';
$prompt = $this->postJson($url, $payload)->assertOk()->assertJsonPath('replacement_required', true)->json();
$new = $this->postJson($url, $payload + ['replacement_token' => $prompt['replacement_token']])->assertOk()->assertJsonPath('company_name_snapshot', 'A公司')->assertJsonPath('status', 0)->json();
$this->assertSame(5, (int) $old->fresh()->status);
DB::table('users')->where('id', 2)->update(['company_name' => 'B公司']);
$sign = CourseSign::findOrFail($new['id']); $originalKey = $sign->company_key;
$answers = $sign->data; $answers[0]['value'] = '李四更新姓名'; $sign->data = $answers; $sign->save();
$this->assertSame('A公司', $sign->fresh()->company_name_snapshot);
$this->assertSame($originalKey, $sign->fresh()->company_key);
$response = $this->postJson('/api/admin/course-signs/save', ['course_id' => 10, 'user_id' => 3, 'status' => 0, 'data' => [['field' => 'name', 'value' => '王五']]]);
$this->assertStringContainsString('有效报名', $response->json('errmsg'));
}
public function testSubmittedLinkedCompanyOverridesDifferentProfileCompany(): void
{
$this->old();
DB::table('course_forms')->where('course_id', 10)->update(['field' => 'employer']);
$payload = ['course_id' => 10, 'data' => [['field' => 'employer', 'value' => 'B公司']]];
$this->postJson('/api/mobile/course/sign', $payload)->assertOk()->assertJsonPath('company_name_snapshot', 'B公司')->assertJsonPath('status', 0);
// User 3 belongs to A in their profile, but their submitted B company occupies B's slot.
Auth::guard('mobile')->setUser(User::find(3));
$this->postJson('/api/mobile/course/sign', $payload)->assertOk()->assertJsonPath('replacement_required', true)->assertJsonPath('replacement_name', '李四');
$this->assertSame(2, CourseSign::count());
}
public function testOptionalEmptyCompanyFallsBackToProfileAndMissingBothIsRejected(): void
{
$field = $this->field(); $field['rule'] = '';
$attributes = $this->attributes(1); $attributes['data'] = [$field + ['value' => '']]; $attributes['form_snapshot'] = ['fields' => [$field]];
$sign = CompanyRegistration::submit($attributes)['sign'];
$this->assertSame('A公司', $sign->company_name_snapshot);
$this->useNameOnlyForm();
DB::table('users')->where('id', 2)->update(['company_name' => ' ']);
$this->postJson('/api/mobile/course/sign', ['course_id' => 10, 'data' => [['field' => 'name', 'value' => '李四']]])
->assertJsonPath('errmsg', '请先完善个人信息中的公司名称,或在报名表中填写公司名称');
$this->assertSame(1, CourseSign::count());
}
public function testCompanyFieldCanBeRemovedFromPublishedCourseWithoutRegistrations(): void
{
// The audit helper uses MySQL column comments; omit that unrelated side effect in SQLite.
$this->app->instance(\App\Http\Controllers\Admin\CourseFormsController::class,
new class extends \App\Http\Controllers\Admin\CourseFormsController {
protected function saveLogs($original, $model) {}
});
$fieldId = DB::table('course_forms')->where('course_id', 10)->value('id');
$this->getJson('/api/admin/course-forms/destroy?id='.$fieldId)->assertOk();
$this->assertNull(DB::table('course_forms')->where('id', $fieldId)->whereNull('deleted_at')->first());
$attributes = $this->attributes(); $attributes['data'] = []; $attributes['form_snapshot'] = ['fields' => []];
$this->assertSame('A公司', CompanyRegistration::submit($attributes)['sign']->company_name_snapshot);
}
public function testReplacementNamesUseSubmittedNamesInListDetailsReasonsAndExport(): void
{
$nameField = ['field' => 'participant', 'name' => '姓名', 'edit_input' => 'text', 'belong_user_table' => 'username'];
$oldAttributes = $this->attributes(1);
$oldAttributes['data'][] = $nameField + ['value' => '原报名填写姓名'];
$oldAttributes['form_snapshot']['fields'][] = $nameField;
$old = CourseSign::create($oldAttributes);
$newAttributes = $this->attributes(2);
$newAttributes['data'][] = $nameField + ['value' => '新报名填写姓名'];
$newAttributes['form_snapshot']['fields'][] = $nameField;
$prompt = CompanyRegistration::submit($newAttributes);
$this->assertSame('原报名填写姓名', $prompt['replacement_name']);
$new = CompanyRegistration::submit($newAttributes, $prompt['replacement_token'])['sign'];
// Existing stored descriptions can contain IDs; display resolves the linked person's name.
DB::table('course_signs')->where('id', $old->id)->update(['giveup_reason' => '旧版描述:报名ID '.$new->id]);
$old->refresh(); $new->refresh();
$this->assertSame('因同公司更换参训人员,已由新报名填写姓名替换报名。', $old->giveup_reason);
$this->assertStringContainsString('已由新报名填写姓名替换;更换时间:', $old->replacement_text);
$this->assertSame('替换原报名填写姓名的报名,需重新审核', $new->replacement_text);
$this->assertStringNotContainsString('报名ID', $old->toArray()['replacement_text']);
$export = new \App\Exports\CourseRegistrationExport([$old->toArray(), $new->toArray()], []);
$sheet = $export->array(); $column = array_search('报名更换记录', $sheet[0], true);
$this->assertSame($old->replacement_text, $sheet[1][$column]);
$this->assertSame($new->replacement_text, $sheet[2][$column]);
$new->delete();
DB::table('users')->where('id', 2)->update(['deleted_at' => now()]);
$this->assertStringContainsString('新报名填写姓名', $old->fresh()->giveup_reason);
}
public function testHistoricalDuplicatesAreListedButNeverAutomaticallyChanged(): void
{
DB::table('courses')->where('id', 10)->update(['one_person_per_company' => 0]);
$one = $this->old(); $two = CourseSign::create($this->attributes(2));
DB::table('courses')->where('id', 10)->update(['one_person_per_company' => 1]);
$this->assertCount(1, CompanyRegistration::duplicates(10));
try { CompanyRegistration::submit($this->attributes(3)); $this->fail('historical duplicates replaced'); }
catch (\InvalidArgumentException $e) { $this->assertStringContainsString('多条历史', $e->getMessage()); }
$this->assertSame(0, (int) $one->fresh()->status); $this->assertSame(0, (int) $two->fresh()->status);
}
}

@ -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); }
}
}

@ -14,9 +14,15 @@ use Tests\TestCase;
class SurveyWorkflowTest extends TestCase
{
private $previousValueBinder;
protected function setUp(): void
{
parent::setUp();
// Other export tests use Maatwebsite's global string binder. Isolate the
// native Spreadsheet exporter so merged empty cells keep their null type.
$this->previousValueBinder = \PhpOffice\PhpSpreadsheet\Cell\Cell::getValueBinder();
\PhpOffice\PhpSpreadsheet\Cell\Cell::setValueBinder(new \PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder());
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:', 'audit.enabled' => false]);
DB::purge('sqlite');
$this->withoutMiddleware();
@ -47,6 +53,7 @@ class SurveyWorkflowTest extends TestCase
protected function tearDown(): void
{
\PhpOffice\PhpSpreadsheet\Cell\Cell::setValueBinder($this->previousValueBinder);
Auth::guard('mobile')->forgetUser();
Auth::guard('admin')->forgetUser();
DB::disconnect('sqlite');

@ -9,6 +9,20 @@ use PHPUnit\Framework\TestCase;
class SurveySchemaTest extends TestCase
{
public function testMinimumChoicesAreCheckedOnSubmissionAndOptionalBlanksStayValid(): void
{
$q = ['id' => 1, 'field' => 'venues', 'name' => '会场', 'edit_input' => 'checkbox', 'select_item' => [['key' => 'a', 'value' => '甲'], ['key' => 'b', 'value' => '乙'], ['key' => 'c', 'value' => '丙']], 'config' => ['min_choices' => 2, 'max_choices' => 2]];
$this->assertSame([], SurveySchema::normalize([$q], [], true)[0]['value']);
$this->assertSame(['a', 'b'], SurveySchema::normalize([$q], [['id' => 1, 'value' => ['a', 'b']]], true)[0]['value']);
foreach ([['a'], ['a', 'a'], ['a', 'b', 'c']] as $value) {
try { SurveySchema::normalize([$q], [['id' => 1, 'value' => $value]], true); $this->fail('invalid choice count accepted'); }
catch (InvalidArgumentException $error) { $this->assertStringContainsString('选择', $error->getMessage()); }
}
$q['rule'] = 'required';
$this->expectException(InvalidArgumentException::class);
SurveySchema::normalize([$q], [], true);
}
public function testConditionReferencesAndHiddenChains(): void
{
$parent = ['id' => 1, 'field' => 'first', 'name' => '前置', 'edit_input' => 'radio', 'select_item' => [['key' => 'yes', 'value' => '是']]];

Loading…
Cancel
Save